Files
kickkingsadmin/src/components/matchmaker-log-terminal.tsx
T
2026-05-05 20:02:40 +00:00

279 lines
8.5 KiB
TypeScript

"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
type Props = {
layout: "embedded" | "fullscreen";
source: MatchmakerLogSource;
processedHref: string;
rawHref: string;
content: string;
errorMessage: string | null;
};
/** If the user is within this many px of the bottom, treat as "following" the tail. */
const TAIL_FOLLOW_PX = 80;
const POLL_INTERVAL_MS = 5000;
const REFRESH_BTN =
"shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50";
function segClass(active: boolean) {
return [
"rounded border px-2.5 py-1 font-mono text-xs transition",
active
? "border-emerald-500/60 bg-emerald-950/40 text-emerald-200"
: "border-zinc-600 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-white",
].join(" ");
}
async function fetchLogApi(source: MatchmakerLogSource): Promise<
| { ok: true; content: string }
| { ok: false; message: string }
> {
try {
const params = new URLSearchParams();
if (source === "raw") params.set("mklog", "raw");
params.set("_t", String(Date.now()));
const res = await fetch(`/api/matchmaker-logs?${params.toString()}`, {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
});
const rawText = await res.text();
let data: { content?: string; error?: string };
try {
data = rawText ? (JSON.parse(rawText) as typeof data) : {};
} catch {
return {
ok: false,
message:
"Server returned non-JSON (lost session?). Refresh and sign in again.",
};
}
if (!res.ok) {
return {
ok: false,
message: data.error ?? `Request failed (${res.status})`,
};
}
return { ok: true, content: data.content ?? "" };
} catch (e) {
return {
ok: false,
message: e instanceof Error ? e.message : "Network error",
};
}
}
export function MatchmakerLogTerminal({
layout,
source,
processedHref,
rawHref,
content,
errorMessage,
}: Props) {
const filename =
source === "processed" ? "history.log" : "matchmaker.log";
const scrollRef = useRef<HTMLDivElement>(null);
const tailFollowRef = useRef(true);
/** Avoid overlapping auto + manual fetches (helps dev Strict Mode / double clicks). */
const fetchLockRef = useRef(false);
const [displayContent, setDisplayContent] = useState(content);
const [displayError, setDisplayError] = useState(errorMessage);
const [refreshing, setRefreshing] = useState(false);
/** Bumped on every successful reload so identical file text still updates the UI (React skips re-render when setState equals previous string). */
const [reloadKey, setReloadKey] = useState(0);
const [lastFetchedAtMs, setLastFetchedAtMs] = useState<number | null>(
null,
);
const runLoad = useCallback(async (manual: boolean) => {
if (
typeof document !== "undefined" &&
document.hidden &&
!manual
) {
return;
}
if (fetchLockRef.current) return;
fetchLockRef.current = true;
if (manual) {
tailFollowRef.current = true;
setRefreshing(true);
}
try {
const result = await fetchLogApi(source);
if (!result.ok) {
setDisplayError(result.message);
return;
}
setDisplayError(null);
setDisplayContent(result.content);
if (manual) {
setReloadKey((k) => k + 1);
setLastFetchedAtMs(Date.now());
}
} finally {
fetchLockRef.current = false;
if (manual) setRefreshing(false);
}
}, [source]);
const refresh = useCallback(async () => {
await runLoad(true);
}, [runLoad]);
const updateTailFollowFromScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
tailFollowRef.current = gap <= TAIL_FOLLOW_PX;
}, []);
useEffect(() => {
setDisplayContent(content);
setDisplayError(errorMessage);
setReloadKey((k) => k + 1);
}, [content, errorMessage]);
useEffect(() => {
tailFollowRef.current = true;
}, [source]);
useEffect(() => {
let cancelled = false;
async function tick() {
if (cancelled) return;
await runLoad(false);
}
queueMicrotask(tick);
const intervalId = window.setInterval(tick, POLL_INTERVAL_MS);
function onVisibilityChange() {
if (!document.hidden && !cancelled) void tick();
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [source, runLoad]);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !tailFollowRef.current) return;
el.scrollTop = el.scrollHeight;
}, [displayContent, displayError, reloadKey]);
const frameClass =
layout === "fullscreen"
? "flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg"
: "flex h-[calc(100dvh-12rem)] max-h-[calc(100dvh-12rem)] min-h-[200px] shrink-0 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg";
return (
<div className={frameClass}>
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
<div className="flex gap-1.5 pr-2" aria-hidden="true">
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
</div>
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
<span className="text-emerald-400/90">matchmaker</span>
<span className="text-zinc-500"> </span>
<span className="text-zinc-100">{filename}</span>
</p>
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-wide text-zinc-500">
Log
</span>
<Link
href={processedHref}
className={segClass(source === "processed")}
scroll={false}
>
Processed
</Link>
<Link
href={rawHref}
className={segClass(source === "raw")}
scroll={false}
>
Raw
</Link>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className={REFRESH_BTN}
disabled={refreshing}
aria-busy={refreshing}
aria-label="Refresh log from disk"
onClick={() => void refresh()}
>
{refreshing ? "…" : "Refresh"}
</button>
{lastFetchedAtMs != null ? (
<span className="font-mono text-[10px] text-zinc-500">
· manual{" "}
{new Date(lastFetchedAtMs).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
) : null}
<span className="font-mono text-[10px] text-zinc-500">
· auto {POLL_INTERVAL_MS / 1000}s
</span>
</div>
</header>
<div
ref={scrollRef}
onScroll={updateTailFollowFromScroll}
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-4"
>
{displayError ? (
<p className="font-mono text-sm text-red-400">
<span className="text-red-500/80">error:</span> {displayError}
</p>
) : displayContent === "" ? (
<p className="font-mono text-sm text-zinc-600">(empty file)</p>
) : (
<MatchLogColoredBody
key={reloadKey}
content={displayContent}
/>
)}
</div>
<footer className="shrink-0 border-t border-zinc-800 bg-[#161b22] px-3 py-1.5">
<p className="font-mono text-[11px] text-zinc-500">
<span className="text-emerald-600/80"></span> UTF-8
</p>
</footer>
</div>
);
}