Files
browser/platform_web/components/ServerLiveOperations.tsx
T

247 lines
13 KiB
TypeScript

import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { LogEntryBody, LogStreamResponse } from "../api/types";
import { sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
import { cx } from "../utils/classes";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "error"; text: string; at: string };
const liveLogPollMs = 2000;
const maxLogEntries = 500;
const terminalQuickCommands = [
{ label: "查询玩家", command: "ListPlayers" },
{ label: "服务器状态", command: "ServerInfo" },
{ label: "设为中午", command: "SetTime 12" },
{ label: "保存世界", command: "SaveWorld" }
];
interface LiveOperationDrawerProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
backdropClassName?: string;
panelClassName?: string;
bodyClassName?: string;
}
function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName }: LiveOperationDrawerProps) {
useEffect(() => {
if (!open) return undefined;
const previous = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.body.style.overflow = "hidden";
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previous;
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, open]);
if (!open) return null;
return (
<div className={cx("drawer-backdrop", backdropClassName)} role="presentation" onClick={onClose}>
<aside className={cx("drawer-panel live-operation-drawer", panelClassName)} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
<div className="panel-header">
<div>
<h2>{title}</h2>
{description && <p className="page-status">{description}</p>}
</div>
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span>关闭</span></button>
</div>
<div className={cx("live-operation-content", bodyClassName)}>{children}</div>
</aside>
</div>
);
}
interface ServerLiveLogDrawerProps {
open: boolean;
serverId: string;
serverName: string;
onClose: () => void;
}
export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: ServerLiveLogDrawerProps) {
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [selectedStreamId, setSelectedStreamId] = useState("");
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
const [paused, setPaused] = useState(false);
const [keyword, setKeyword] = useState("");
const [lastRefreshAt, setLastRefreshAt] = useState("");
const loadStreams = useCallback(async () => {
if (!open) return;
setStreams({ status: "loading" });
try {
const response = await platformApiClient.listServerLiveLogs(serverId);
setStreams({ status: "ready", data: response.items });
setSelectedStreamId((current) => response.items.some((stream) => stream.id === current) ? current : response.items[0]?.id ?? "");
} catch (error) {
setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" });
}
}, [open, serverId]);
useEffect(() => {
if (!open) return;
setEntries([]);
setCursorByStream({});
setPaused(false);
void loadStreams();
}, [loadStreams, open]);
const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined;
const tailSelectedStream = useCallback(async () => {
if (!open || !selectedStream) return;
const afterSeq = cursorByStream[selectedStream.id] ?? 0;
const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 });
setCursorByStream((current) => ({ ...current, [selectedStream.id]: Math.max(current[selectedStream.id] ?? 0, cursor.nextSeq, cursor.latestSeq) }));
setLastRefreshAt(new Date().toLocaleTimeString());
if (cursor.entries.length === 0) return;
setEntries((current) => [
...current,
...cursor.entries.map((entry) => ({ ...entry, source: selectedStream.source || selectedStream.streamKey, streamId: selectedStream.id, streamKey: selectedStream.streamKey }))
].slice(-maxLogEntries));
}, [cursorByStream, open, selectedStream]);
useEffect(() => {
if (!open || paused || !selectedStream) return undefined;
void tailSelectedStream().catch(() => undefined);
const timer = window.setInterval(() => void tailSelectedStream().catch(() => undefined), liveLogPollMs);
return () => window.clearInterval(timer);
}, [open, paused, selectedStream, tailSelectedStream]);
const visibleEntries = useMemo(() => {
const query = keyword.trim().toLowerCase();
return entries.filter((entry) => entry.streamId === selectedStreamId && (!query || entry.line.toLowerCase().includes(query) || (entry.level ?? "info").toLowerCase().includes(query)));
}, [entries, keyword, selectedStreamId]);
function clearVisibleBuffer() {
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq }));
}
return (
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · 每 ${liveLogPollMs / 1000} 秒刷新一次平台日志游标`} onClose={onClose}>
<div className="log-filter-bar live-operation-toolbar">
<select value={selectedStreamId} aria-label="选择日志源" onChange={(event) => { setSelectedStreamId(event.target.value); setEntries([]); setCursorByStream((current) => ({ ...current, [event.target.value]: 0 })); }}>
{streams.status === "ready" && streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey || stream.source} · seq {stream.latestSeq}</option>)}
</select>
<input type="search" value={keyword} placeholder="过滤可见日志" aria-label="过滤可见日志" onChange={(event) => setKeyword(event.target.value)} />
<button type="button" className="icon-command" onClick={() => setPaused((current) => !current)}>{paused ? <Play size={14} /> : <Pause size={14} />}<span>{paused ? "继续" : "暂停"}</span></button>
<button type="button" className="icon-command" onClick={() => void tailSelectedStream().catch(() => undefined)}><RotateCw size={14} /><span>刷新</span></button>
<button type="button" className="icon-command" onClick={clearVisibleBuffer}><Trash2 size={14} /><span>清屏</span></button>
</div>
<span className="page-status">状态:{paused ? "已暂停" : "自动刷新"} · 最新刷新 {lastRefreshAt || "等待"} · 游标 {selectedStream ? cursorByStream[selectedStream.id] ?? 0 : "--"}</span>
{streams.status === "loading" && <LoadingState label="正在加载日志源…" compact />}
{streams.status === "error" && <ErrorState title="实时日志不可用" reason={streams.reason} diagnosticId={`live-logs:${serverId}`} onRetry={() => void loadStreams()} compact />}
{streams.status === "ready" && streams.data.length === 0 && <EmptyState title="暂无日志源" description="运行端还没有向平台登记该服务器的日志流。" />}
{streams.status === "ready" && streams.data.length > 0 && visibleEntries.length === 0 && <EmptyState title="等待日志" description="没有新的匹配日志;保持窗口打开会继续按游标刷新。" />}
{visibleEntries.length > 0 && (
<div className="log-list live-log-list" role="log" aria-live={paused ? "off" : "polite"}>
{visibleEntries.map((entry) => (
<div key={`${entry.streamId}-${entry.seq}`} className="log-line live-log-line">
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
<span>{entry.line}</span>
</div>
))}
</div>
)}
</LiveOperationDrawer>
);
}
interface ServerManagementTerminalDrawerProps {
open: boolean;
serverId: string;
serverName: string;
pluginId: string;
canManage: boolean;
onClose: () => void;
}
export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) {
const [command, setCommand] = useState("");
const [pending, setPending] = useState(false);
const [lines, setLines] = useState<TerminalLine[]>([]);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const supportsCommands = pluginId === "game.scum";
useEffect(() => {
if (!open) return;
setCommand("");
setPending(false);
setResult(null);
setLines([{ id: `open-${Date.now()}`, tone: "info", text: supportsCommands ? "SCUM 管理终端已连接到平台 Source RCON 调度通道。" : "该插件暂未声明可用的管理终端命令通道。", at: new Date().toLocaleTimeString() }]);
}, [open, supportsCommands]);
async function submitCommand(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!supportsCommands || !canManage || pending || !command.trim()) return;
const submitted = command.trim();
setCommand("");
setPending(true);
setResult({ status: "pending", label: "正在提交命令" });
setLines((current) => [...current, { id: `input-${Date.now()}`, tone: "input", text: `> ${submitted}`, at: new Date().toLocaleTimeString() }]);
try {
const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted));
const label = `已排队 · 任务 ${response.jobId}`;
setResult({ status: "succeeded", label });
setLines((current) => [...current, { id: `ok-${response.jobId}`, tone: "success", text: `${label} · ${response.message || response.status}`, at: new Date().toLocaleTimeString() }]);
} catch (error) {
const label = error instanceof Error ? error.message : "命令提交失败";
setResult({ status: "failed", label });
setLines((current) => [...current, { id: `err-${Date.now()}`, tone: "error", text: label, at: new Date().toLocaleTimeString() }]);
} finally {
setPending(false);
}
}
return (
<LiveOperationDrawer open={open} title="管理终端" description={`${serverName} · 平台授权的一次性命令调度`} onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body">
<section className="terminal-output-panel" aria-label="terminal output">
<div className="terminal-output" role="log" aria-live="polite">
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span>{line.text}</span></div>)}
</div>
</section>
<section className="terminal-command-dock" aria-label="terminal command controls">
<div className="terminal-command-dock-header">
<span><ListChecks size={14} />快捷指令</span>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<div className="terminal-quick-command-list">
{terminalQuickCommands.map((item) => <button key={item.command} type="button" className="terminal-quick-command" disabled={!supportsCommands || !canManage || pending} onClick={() => setCommand(item.command)}><strong>{item.label}</strong><span>{item.command}</span></button>)}
</div>
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
{supportsCommands && (
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
<label>
SCUM 管理命令
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令,回车提交" : "当前账号没有运行操作权限"} onChange={(event) => setCommand(event.target.value)} />
</label>
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
</form>
)}
</section>
</LiveOperationDrawer>
);
}
function levelClass(level?: string): string {
const normalized = (level ?? "info").toLowerCase();
if (normalized === "error" || normalized === "fatal") return "log-level-error";
if (normalized === "warn" || normalized === "warning") return "log-level-warn";
return "log-level-info";
}