init
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user