279 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|