Files
gitlab-instance-0a899031_sh/d2ypp2/web-console/components/live-product/LiveProcessLiveLogCard.tsx
root 07aa02bca6 s
2026-05-17 04:31:59 +00:00

330 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ExternalLink, Pause, Play, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
extractDouyinCurrentRoomFromLog,
extractDouyinLiveUrls,
extractDouyinRoomHints,
} from "@/lib/youtubeConfigUtils";
type TFn = (zh: string, en: string) => string;
type StatusPayload = {
process_status?: string;
recent_log?: string;
recent_error?: string;
business_status?: string;
business_note?: string;
ffmpeg_pids?: number[];
log_age_seconds?: number | null;
is_pushing?: boolean;
};
function fmtDuration(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60);
const mm = m % 60;
return `${h}h ${mm}m`;
}
function statusLine(t: TFn, raw: string | undefined): string {
const s = (raw || "unknown").toLowerCase();
if (s === "online" || s === "running") return t("运行中PM2/进程在线)", "Running (process online)");
if (s === "stopped" || s === "stopping") return t("已停止", "Stopped");
if (s === "not_found" || s === "errored") return t("未运行或未找到", "Not running / not found");
if (s === "invalid") return t("无效进程名", "Invalid process");
return raw || "—";
}
type Props = {
t: TFn;
currentProc: string;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
/** 用于源站房间线索(与 Electron 一致) */
urlListText: string;
/** YouTube 密钥尾码;非 YouTube 进程可传空并关 showKeyRow */
keySuffixUi: string;
showKeyRow?: boolean;
pollMs?: number;
};
function businessLine(t: TFn, raw: string | undefined, note: string | undefined): string {
const s = (raw || "").toLowerCase();
if (s === "streaming") return t("Pushing to YouTube", "Pushing to YouTube");
if (s === "waiting_source") return t("Waiting for the upstream live source", "Waiting for the upstream live source");
if (s === "source_live")
return t("Source live detected, but no active FFmpeg relay worker", "Source live detected, but no active FFmpeg relay worker");
if (s === "monitoring") return t("Process online and monitoring the source", "Process online and monitoring the source");
if (s === "misconfigured") return t("YouTube key missing or invalid", "YouTube key missing or invalid");
if (s === "stale") return t("Process online but the log heartbeat is stale", "Process online but the log heartbeat is stale");
if (s === "error") return t("Recent process error detected", "Recent process error detected");
if (note?.trim()) return note.trim();
return t("No business-state hint yet", "No business-state hint yet");
}
export function LiveProcessLiveLogCard({
t,
currentProc,
fetchJson,
urlListText,
keySuffixUi,
showKeyRow = true,
pollMs = 1000,
}: Props) {
const [processStatus, setProcessStatus] = useState<string>("");
const [recentLog, setRecentLog] = useState("");
const [recentError, setRecentError] = useState("");
const [businessStatus, setBusinessStatus] = useState("");
const [businessNote, setBusinessNote] = useState("");
const [ffmpegPids, setFfmpegPids] = useState<number[]>([]);
const [logAgeSeconds, setLogAgeSeconds] = useState<number | null>(null);
const [paused, setPaused] = useState(false);
const [liveSince, setLiveSince] = useState<number | null>(null);
const [nowTick, setNowTick] = useState(0);
const outRef = useRef<HTMLPreElement>(null);
const errRef = useRef<HTMLPreElement>(null);
const prevOnlineRef = useRef(false);
const pullInFlightRef = useRef(false);
const pullQueuedRef = useRef(false);
const douyinHints = extractDouyinRoomHints(urlListText);
const onlineNow = /^(online|running)$/i.test(processStatus);
const roomFromLog = extractDouyinCurrentRoomFromLog(recentLog);
const fallbackUrl = extractDouyinLiveUrls(urlListText)[0] ?? "";
const primaryRoomId = roomFromLog || (fallbackUrl.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)?.[1] ?? null);
const primaryUrl = primaryRoomId ? `https://live.douyin.com/${primaryRoomId}` : fallbackUrl || null;
const pull = useCallback(async () => {
if (!currentProc) return;
if (pullInFlightRef.current) {
pullQueuedRef.current = true;
return;
}
pullInFlightRef.current = true;
try {
const data = await fetchJson<StatusPayload>(
`/status?process=${encodeURIComponent(currentProc)}`,
);
const ps = data.process_status || "unknown";
setProcessStatus(ps);
setRecentLog(data.recent_log || "");
setRecentError(data.recent_error || "");
setBusinessStatus(data.business_status || "");
setBusinessNote(data.business_note || "");
setFfmpegPids(Array.isArray(data.ffmpeg_pids) ? data.ffmpeg_pids : []);
setLogAgeSeconds(typeof data.log_age_seconds === "number" ? data.log_age_seconds : null);
const online = /^(online|running)$/i.test(ps);
if (online && !prevOnlineRef.current) {
setLiveSince(Date.now());
}
if (!online) {
setLiveSince(null);
}
prevOnlineRef.current = online;
} catch {
setProcessStatus("error");
setBusinessStatus("error");
setBusinessNote("");
setFfmpegPids([]);
setLogAgeSeconds(null);
} finally {
pullInFlightRef.current = false;
if (pullQueuedRef.current) {
pullQueuedRef.current = false;
window.setTimeout(() => void pull(), 0);
}
}
}, [currentProc, fetchJson]);
useEffect(() => {
void pull();
}, [pull]);
useEffect(() => {
if (paused || !currentProc) return;
const id = window.setInterval(() => void pull(), pollMs);
return () => clearInterval(id);
}, [paused, currentProc, pull, pollMs]);
useEffect(() => {
const onVis = () => {
if (document.visibilityState === "visible") void pull();
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [pull]);
useEffect(() => {
if (!liveSince) return;
const id = window.setInterval(() => setNowTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, [liveSince]);
useEffect(() => {
if (paused) return;
const el = outRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [recentLog, paused]);
useEffect(() => {
if (paused) return;
const el = errRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [recentError, paused]);
const durationLabel =
liveSince == null
? t("—(未检测到在线)", "— (not online)")
: fmtDuration(Math.max(0, Math.floor((Date.now() - liveSince) / 1000)));
return (
<section className="rounded-2xl border border-emerald-500/20 bg-gradient-to-b from-emerald-500/[0.07] to-zinc-950/80 p-6 ring-1 ring-white/[0.04]">
<span className="sr-only" aria-hidden>
{nowTick}
</span>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">{t("当前直播日志", "Live process log")}</h3>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-200/90 ring-1 ring-emerald-500/25">
{paused ? t("已暂停刷新", "Paused") : t("每秒刷新", "~1s refresh")}
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void pull()}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300 hover:bg-white/[0.08]"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("立即拉取", "Refresh")}
</button>
<button
type="button"
onClick={() => setPaused((p) => !p)}
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs ring-1 ${
paused
? "bg-amber-500/20 text-amber-100 ring-amber-500/35"
: "border border-white/10 bg-black/30 text-zinc-300"
}`}
>
{paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
{paused ? t("继续自动刷新", "Resume") : t("暂停自动刷新", "Pause")}
</button>
</div>
</div>
<div className="mt-4 space-y-3 rounded-xl border border-white/[0.06] bg-black/25 p-4">
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div className="flex flex-wrap gap-2">
<span className="text-zinc-500">{t("进程状态", "Status")}</span>
<span className="font-medium text-zinc-200">{statusLine(t, processStatus)}</span>
</div>
<div className="flex flex-wrap gap-2">
<span className="text-zinc-500">{t("Business state", "Business state")}</span>
<span className="font-medium text-cyan-200/90">{businessLine(t, businessStatus, businessNote)}</span>
</div>
<div className="flex flex-wrap gap-2">
<span className="text-zinc-500">{t("本次在线时长(估算)", "Online duration (est.)")}</span>
<span className="font-mono text-cyan-200/90">{durationLabel}</span>
</div>
<div className="flex flex-wrap gap-2">
<span className="text-zinc-500">{t("FFmpeg workers", "FFmpeg workers")}</span>
<span className="font-mono text-zinc-300">
{ffmpegPids.length ? ffmpegPids.join(", ") : "none"}
{logAgeSeconds != null ? ` · log ${fmtDuration(Math.max(0, Math.floor(logAgeSeconds)))}` : ""}
</span>
</div>
</div>
{showKeyRow ? (
<div className="flex flex-wrap gap-2 text-xs">
<span className="text-zinc-500">{t("YouTube key 尾码(参考)", "YT key suffix")}</span>
<span className="font-mono text-zinc-300">{keySuffixUi || "—"}</span>
</div>
) : null}
{primaryUrl ? (
<div className="rounded-lg border border-violet-500/20 bg-violet-500/[0.06] px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[11px] font-medium text-zinc-400">
{roomFromLog
? t("当前直播源站(日志)", "Current source (from log)")
: onlineNow
? t("配置中的源站地址", "Configured source URL")
: t("源站快捷入口", "Source quick link")}
</span>
<a
href={primaryUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs font-medium text-violet-100 ring-1 ring-violet-500/35 transition hover:bg-violet-500/30"
>
<ExternalLink className="h-3.5 w-3.5 shrink-0 opacity-90" />
{primaryRoomId || t("打开抖音直播页", "Open Douyin live")}
</a>
</div>
{douyinHints.length > 1 && roomFromLog ? (
<p className="mt-2 text-[10px] text-zinc-600">
{t("配置中还有", "Also in config:")}{" "}
{douyinHints
.filter((id) => id !== primaryRoomId)
.slice(0, 3)
.map((id) => (
<a
key={id}
href={`https://live.douyin.com/${id}`}
target="_blank"
rel="noreferrer"
className="ml-1 text-violet-400/90 underline-offset-2 hover:underline"
>
{id}
</a>
))}
{douyinHints.length > 4 ? "…" : null}
</p>
) : null}
</div>
) : (
<p className="text-[11px] text-zinc-600">
{t("(日志与地址列表中暂无抖音房间)", "(No Douyin room in log or URL list)")}
</p>
)}
</div>
{processStatus !== "not_found" && processStatus !== "invalid" ? (
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400/90">
{t("进程输出stdout / 合并日志)", "Process output")}
</div>
<pre
ref={outRef}
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-emerald-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-emerald-100/95"
>
{recentLog.trim() || t("(暂无)", "(empty)")}
</pre>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-rose-400/90">
{t("异常信息stderr", "Stderr")}
</div>
<pre
ref={errRef}
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-rose-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-rose-100/95"
>
{recentError.trim() || t("(暂无)", "(empty)")}
</pre>
</div>
</div>
) : (
<p className="mt-4 text-xs text-zinc-500">
{t("进程未在 PM2 / 本地注册表中运行,日志区域已隐藏。", "Process not running — log panes hidden.")}
</p>
)}
</section>
);
}