Files
browser/platform_web/components/ScumOperationsPanel.tsx
T
2026-07-20 16:42:33 +08:00

437 lines
23 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.
import { Activity, BellRing, Database, RefreshCw, ShieldCheck, UsersRound, Wrench } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
BackupResponse,
GameClientBridgeCommandResponse,
GameClientBridgeJsonObject,
GameClientBridgeSnapshotResponse,
GameClientBridgeStatusResponse,
LogEntryBody,
LogStreamResponse
} from "../api/types";
import type { ScumOperationsPageContract, ScumOperationsSnapshotView } from "../contracts/scumOperations";
import { projectScumOperationsSnapshots } from "../schemas/scumOperations";
import { cx } from "../utils/classes";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
type SnapshotSection = "players" | "sessions" | "squads" | "vehicles" | "flags";
export interface ScumSemanticLogView {
streamKey: string;
eventType: string;
entry: LogEntryBody;
}
export interface ScumOperationsPanelData {
status?: GameClientBridgeStatusResponse;
commands: GameClientBridgeCommandResponse[];
snapshots: GameClientBridgeSnapshotResponse[];
logs: ScumSemanticLogView[];
backups: BackupResponse[];
errors: string[];
}
type ScumOperationsPanelState =
| { status: "loading" }
| { status: "error"; reason: string }
| { status: "ready"; data: ScumOperationsPanelData };
interface ScumOperationsPanelProps {
contract: ScumOperationsPageContract;
initialData?: ScumOperationsPanelData;
}
export function ScumOperationsPanel({ contract, initialData }: ScumOperationsPanelProps) {
const [state, setState] = useState<ScumOperationsPanelState>(() => initialData ? { status: "ready", data: initialData } : { status: "loading" });
const [snapshotSection, setSnapshotSection] = useState<SnapshotSection>("players");
const [announcement, setAnnouncement] = useState("");
const [pendingCommand, setPendingCommand] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const refresh = useCallback(async () => {
setState({ status: "loading" });
const results = await Promise.allSettled([
platformApiClient.getGameClientBridgeStatus(contract.serverInstanceId),
platformApiClient.listGameClientBridgeCommands(contract.serverInstanceId),
platformApiClient.listGameClientBridgeSnapshots(contract.serverInstanceId, { limit: 200 }),
loadSemanticLogs(contract),
platformApiClient.listBackups(contract.serverInstanceId)
]);
const errors = results.flatMap((result, index) => result.status === "rejected" ? [loadErrorLabel(index, result.reason)] : []);
const status = settledValue(results[0]);
const commands = settledValue(results[1]);
const snapshots = settledValue(results[2]);
const logs = settledValue(results[3]);
const backups = settledValue(results[4]);
if (!status && !commands && !snapshots) {
setState({ status: "error", reason: errors.join("") || "Game Client Bridge 核心投影不可用。" });
return;
}
setState({
status: "ready",
data: {
status,
commands: commands?.items ?? [],
snapshots: snapshots?.items ?? [],
logs: logs ?? [],
backups: backups?.items ?? [],
errors
}
});
}, [contract]);
useEffect(() => {
if (!initialData) {
void refresh();
}
}, [initialData, refresh]);
const snapshots = useMemo(() => state.status === "ready" ? projectScumOperationsSnapshots(state.data.snapshots) : emptySnapshotView(), [state]);
if (state.status === "loading") {
return <LoadingState label="正在加载 SCUM Bridge 运维投影…" />;
}
if (state.status === "error") {
return <ErrorState title="SCUM 运维投影不可用" reason={state.reason} onRetry={() => void refresh()} />;
}
const data = state.data;
const bridgeAvailable = data.status?.available === true;
const bridgeReason = data.status?.reason || data.status?.profiles.find((profile) => !profile.available)?.reason;
const profile = data.status?.profiles.find((candidate) => candidate.available) ?? data.status?.profiles[0];
const canQueue = bridgeAvailable && profile?.available === true;
const diagnostics = contract.commands.find((command) => command.type === "companion.diagnostics");
const announcementDeclaration = contract.commands.find((command) => command.type === "announcement.send");
const announcementAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("announcement.send")) === true;
const diagnosticsAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("companion.diagnostics")) === true;
const latestSnapshotAt = data.snapshots.reduce((latest, snapshot) => snapshot.observedAt > latest ? snapshot.observedAt : latest, "");
async function queueCommand(commandType: string, payload: GameClientBridgeJsonObject) {
const declaration = contract.commands.find((command) => command.type === commandType);
const commandProfile = data.status?.profiles.find((candidate) => candidate.available && candidate.commandTypes.includes(commandType));
if (!declaration || !commandProfile) {
setActionResult({ status: "failed", label: "命令未在当前可用 Bridge profile 中声明。" });
return;
}
setPendingCommand(commandType);
setActionResult({ status: "pending", label: `正在提交 ${declaration.title}` });
try {
const expirySeconds = Math.max(300, Math.min(3600, declaration.timeoutSeconds * 2));
const queued = await platformApiClient.queueGameClientBridgeCommand(contract.serverInstanceId, {
profileKey: commandProfile.profileKey,
commandType,
payload,
idempotencyKey: `web-scum-${commandType.replaceAll(".", "-")}-${Date.now()}`,
priority: 50,
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString()
});
setState((current) => current.status === "ready" ? {
status: "ready",
data: { ...current.data, commands: [queued, ...current.data.commands.filter((command) => command.id !== queued.id)] }
} : current);
setAnnouncement("");
setActionResult({ status: "succeeded", label: `命令已进入队列,审批状态:${approvalLabel(queued.approvalState)}` });
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" });
} finally {
setPendingCommand(null);
}
}
function submitAnnouncement(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const message = announcement.trim();
if (!message || message.length > 500) {
setActionResult({ status: "failed", label: "公告内容必须为 1500 个字符。" });
return;
}
void queueCommand("announcement.send", { message });
}
return (
<div className="console-page" aria-label="SCUM operations">
<div className="panel-header">
<div>
<strong>Game Client Bridge 运维投影</strong>
<p className="provider-id">只显示 Platform 安全 DTO game.scum 声明,不接触组件会话、主机路径或 Run socket</p>
</div>
<button type="button" className="icon-command" onClick={() => void refresh()}>
<RefreshCw size={14} aria-hidden="true" />
<span>刷新</span>
</button>
</div>
{data.errors.length > 0 && <ErrorState title="部分运维投影不可用" reason={data.errors.join("")} compact />}
<div className="console-grid" aria-label="SCUM operations summary">
<Metric label="Bridge" value={bridgeAvailable ? "可用" : "不可用"} detail={bridgeReason || profile?.profileKey || "未声明 profile"} tone={bridgeAvailable ? "success" : "warning"} />
<Metric label="Companion" value={healthLabel(snapshots.health?.status)} detail={snapshots.health?.observedAt ? `观测于 ${formatTime(snapshots.health.observedAt)}` : "暂无健康快照"} tone={snapshots.health?.status === "online" ? "success" : "warning"} />
<Metric label="在线会话" value={String(snapshots.sessions.total)} detail={snapshots.sessions.observedAt ? `快照 ${formatTime(snapshots.sessions.observedAt)}` : "暂无会话快照"} tone="neutral" />
<Metric label="最近快照" value={latestSnapshotAt ? formatTime(latestSnapshotAt) : "--"} detail={`${data.snapshots.length} 条安全投影`} tone="neutral" />
</div>
<section className="console-panel" aria-label="SCUM command queue">
<div className="panel-header">
<h2><BellRing size={16} aria-hidden="true" /> 命令队列与结果</h2>
<span className="page-status">{canQueue ? "Bridge 可提交" : bridgeReason || "Bridge 不可提交"}</span>
</div>
<div className="action-list">
{announcementDeclaration && (
<form className="provider-form" onSubmit={submitAnnouncement} aria-label="发送 SCUM 公告">
<div className="form-grid">
<label>
公告内容
<input value={announcement} maxLength={500} onChange={(event) => setAnnouncement(event.target.value)} placeholder="输入 1500 字公告" disabled={!announcementAvailable || pendingCommand !== null} />
</label>
</div>
<button type="submit" className="icon-command" disabled={!announcementAvailable || pendingCommand !== null || !announcement.trim()}>
<BellRing size={14} aria-hidden="true" />
<span>{pendingCommand === "announcement.send" ? "提交中" : `提交公告 · ${approvalLevelLabel(announcementDeclaration.approvalLevel)}`}</span>
</button>
</form>
)}
{diagnostics && (
<div className="plugin-control-row">
<span><strong>{diagnostics.title}</strong><p>仅请求声明的窗口状态摘要与最多 10 条诊断,不传递组件会话或路径。</p></span>
<button type="button" className="icon-command" disabled={!diagnosticsAvailable || pendingCommand !== null} onClick={() => void queueCommand("companion.diagnostics", { includeWindowState: true, maxEntries: 10 })}>
<Activity size={14} aria-hidden="true" />
<span>{pendingCommand === "companion.diagnostics" ? "提交中" : "采集诊断"}</span>
</button>
</div>
)}
{actionResult && <ResultBadge status={actionResult.status} label={safeDiagnosticText(actionResult.label) ?? actionResult.label} />}
</div>
<CommandTable commands={data.commands} />
</section>
<section className="console-panel" aria-label="SCUM snapshot projections">
<div className="panel-header">
<h2><UsersRound size={16} aria-hidden="true" /> 玩家与世界状态快照</h2>
<div className="action-strip" role="group" aria-label="SCUM snapshot sections">
{snapshotTabs(snapshots).map((tab) => (
<button key={tab.key} type="button" className={cx("icon-command")} aria-pressed={snapshotSection === tab.key} onClick={() => setSnapshotSection(tab.key)}>
<span>{tab.label} {tab.count}</span>
</button>
))}
</div>
</div>
<SnapshotTable section={snapshotSection} snapshots={snapshots} />
</section>
<section className="console-panel" aria-label="SCUM semantic logs">
<div className="panel-header">
<h2><Database size={16} aria-hidden="true" /> 语义日志</h2>
<span className="page-status">{contract.logEvents.length} 类已声明事件</span>
</div>
<div className="action-list">
<span>{contract.logEvents.map((event) => event.eventType).join(" / ") || "插件未声明语义日志事件"}</span>
<span>可疑行为仅作为证据与复核线索展示,不触发自动封禁或惩罚。</span>
</div>
<SemanticLogTable logs={data.logs} />
</section>
<section className="console-panel" aria-label="SCUM maintenance policy">
<div className="panel-header">
<h2><Wrench size={16} aria-hidden="true" /> 维护与备份策略</h2>
<span className="page-status">审批优先</span>
</div>
<div className="action-list">
<span><strong>依赖策略</strong> {contract.productionLifecycle.dependencyPolicy === "required" ? "必须满足依赖后执行" : "依赖为可选项"}</span>
<span><strong>生命周期审批</strong> {contract.productionLifecycle.approvalRequired.join(" / ") || "无额外声明"}</span>
{contract.commands.filter((command) => command.permission === "server.game-client.maintenance").map((command) => (
<span key={command.type}><ShieldCheck size={14} aria-hidden="true" /> {command.title} · {approvalLevelLabel(command.approvalLevel)}</span>
))}
<span>本页不自动执行重启、奖励、惩罚或备份;操作仍需插件声明、Platform 权限与审批。</span>
</div>
<BackupTable backups={data.backups} />
</section>
</div>
);
}
async function loadSemanticLogs(contract: ScumOperationsPageContract): Promise<ScumSemanticLogView[]> {
const streamsResponse = await platformApiClient.listLogStreams();
const sourceByKey = new Map(contract.logSources.map((source) => [source.key, source]));
const eventTypesByStream = new Map<string, string[]>();
for (const event of contract.logEvents) {
const streamKey = sourceByKey.get(event.sourceKey)?.streamKey;
if (!streamKey) continue;
eventTypesByStream.set(streamKey, [...(eventTypesByStream.get(streamKey) ?? []), event.eventType]);
}
const streams = streamsResponse.items.filter((stream) => stream.serverInstanceId === contract.serverInstanceId && eventTypesByStream.has(stream.streamKey)).slice(0, 12);
const results = await Promise.allSettled(streams.map(async (stream) => ({
stream,
response: await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 50), limit: 50 })
})));
return results.flatMap((result) => {
if (result.status === "rejected") return [];
return result.value.response.entries.map((entry) => ({
streamKey: result.value.stream.streamKey,
eventType: declaredEventType(entry, result.value.stream, eventTypesByStream),
entry
}));
}).sort((left, right) => right.entry.timestamp.localeCompare(left.entry.timestamp)).slice(0, 100);
}
function declaredEventType(entry: LogEntryBody, stream: LogStreamResponse, eventTypesByStream: Map<string, string[]>): string {
const declared = eventTypesByStream.get(stream.streamKey) ?? [];
const projected = entry.fields?.eventType;
return projected && declared.includes(projected) ? projected : declared.join(" / ") || stream.streamKey;
}
function settledValue<T>(result: PromiseSettledResult<T>): T | undefined {
return result.status === "fulfilled" ? result.value : undefined;
}
function loadErrorLabel(index: number, reason: unknown): string {
const labels = ["Bridge 状态", "命令队列", "快照", "语义日志", "备份记录"];
const detail = safeDiagnosticText(reason instanceof Error ? reason.message : String(reason), "加载失败");
return `${labels[index] ?? "运维数据"}${detail}`;
}
function Metric({ label, value, detail, tone }: { label: string; value: string; detail: string; tone: "neutral" | "success" | "warning" }) {
return (
<article className={cx("metric-card", `metric-tone-${tone}`)}>
<span className="metric-label">{label}</span>
<strong className="metric-value">{value}</strong>
<p className="metric-detail">{detail}</p>
</article>
);
}
function CommandTable({ commands }: { commands: GameClientBridgeCommandResponse[] }) {
if (commands.length === 0) return <p className="provider-id">暂无命令记录。</p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th>命令</th><th>状态</th><th>审批</th><th>结果</th><th>更新时间</th></tr></thead>
<tbody>
{commands.slice(0, 50).map((command) => (
<tr key={command.id}>
<td><strong>{command.commandType}</strong><span className="provider-id">{command.id}</span></td>
<td>{commandStateLabel(command.state)}</td>
<td>{approvalLabel(command.approvalState)}</td>
<td>{safeDiagnosticText(command.result?.summary || command.resultSummary, "--") || "--"}</td>
<td>{formatTime(command.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function snapshotTabs(snapshots: ScumOperationsSnapshotView): Array<{ key: SnapshotSection; label: string; count: number }> {
return [
{ key: "players", label: "玩家", count: snapshots.players.total },
{ key: "sessions", label: "会话", count: snapshots.sessions.total },
{ key: "squads", label: "小队", count: snapshots.squads.total },
{ key: "vehicles", label: "载具", count: snapshots.vehicles.total },
{ key: "flags", label: "旗帜", count: snapshots.flags.total }
];
}
function SnapshotTable({ section, snapshots }: { section: SnapshotSection; snapshots: ScumOperationsSnapshotView }) {
const configuration: Record<SnapshotSection, { headers: string[]; rows: ReactNode[][]; total: number }> = {
players: { headers: ["玩家", "状态", "小队", "延迟", "最后出现"], total: snapshots.players.total, rows: snapshots.players.items.map((item) => [`${item.playerName} · ${item.playerId}`, item.status, item.squadId ?? "--", item.pingMs === undefined ? "--" : `${item.pingMs} ms`, formatTime(item.lastSeenAt)]) },
sessions: { headers: ["会话", "玩家", "开始时间"], total: snapshots.sessions.total, rows: snapshots.sessions.items.map((item) => [item.sessionId, item.playerName, formatTime(item.startedAt)]) },
squads: { headers: ["小队", "成员", "队长", "最后活跃"], total: snapshots.squads.total, rows: snapshots.squads.items.map((item) => [`${item.name} · ${item.squadId}`, item.memberCount, item.leaderPlayerId ?? "--", formatTime(item.lastActiveAt)]) },
vehicles: { headers: ["载具", "状态", "所有者 / 小队", "燃油 / 耐久", "最后出现"], total: snapshots.vehicles.total, rows: snapshots.vehicles.items.map((item) => [`${item.vehicleType} · ${item.vehicleId}`, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, `${percent(item.fuelPercent)} / ${percent(item.healthPercent)}`, formatTime(item.lastSeenAt)]) },
flags: { headers: ["旗帜", "状态", "所有者 / 小队", "半径", "更新时间"], total: snapshots.flags.total, rows: snapshots.flags.items.map((item) => [item.flagId, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, item.radiusMeters === undefined ? "--" : `${item.radiusMeters} m`, formatTime(item.lastUpdatedAt)]) }
};
const table = configuration[section];
if (table.total === 0) return <p className="provider-id">该类型暂无安全快照。</p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr>{table.headers.map((header) => <th key={header}>{header}</th>)}</tr></thead>
<tbody>{table.rows.map((row, rowIndex) => <tr key={`${section}-${rowIndex}`}>{row.map((cell, index) => <td key={`${index}-${String(cell)}`}>{cell}</td>)}</tr>)}</tbody>
</table>
{table.total > table.rows.length && <p className="provider-id">仅展示前 {table.rows.length} / {table.total} 项。</p>}
</div>
);
}
function SemanticLogTable({ logs }: { logs: ScumSemanticLogView[] }) {
if (logs.length === 0) return <p className="provider-id">暂无可用语义日志投影。</p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th>事件类型</th><th>时间</th><th>级别</th><th>安全摘要</th></tr></thead>
<tbody>{logs.slice(0, 100).map((log) => (
<tr key={`${log.streamKey}-${log.entry.seq}`}>
<td>{log.eventType}</td>
<td>{formatTime(log.entry.timestamp)}</td>
<td>{log.entry.level || "info"}{log.entry.redacted ? " · 已脱敏" : ""}</td>
<td>{safeDiagnosticText(log.entry.line, "日志内容已隐藏")}</td>
</tr>
))}</tbody>
</table>
</div>
);
}
function BackupTable({ backups }: { backups: BackupResponse[] }) {
if (backups.length === 0) return <p className="provider-id">暂无 Platform 备份记录。</p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th>备份</th><th>状态</th><th>大小</th><th>恢复状态</th><th>保留至</th></tr></thead>
<tbody>{backups.slice(0, 20).map((backup) => (
<tr key={backup.id}><td>{backup.id}</td><td>{backup.state}</td><td>{formatBytes(backup.sizeBytes)}</td><td>{backup.recoveryStatus || "--"}</td><td>{formatTime(backup.retentionUntil)}</td></tr>
))}</tbody>
</table>
</div>
);
}
function emptySnapshotView(): ScumOperationsSnapshotView {
const empty = { total: 0, items: [] };
return { sessions: { ...empty }, players: { ...empty }, squads: { ...empty }, vehicles: { ...empty }, flags: { ...empty } };
}
function approvalLevelLabel(level: string): string {
if (level === "platform-admin") return "需平台管理员审批";
if (level === "operator") return "需操作员审批";
return "无需额外审批";
}
function approvalLabel(state: string): string {
if (state === "not_required") return "无需审批";
if (state === "approved") return "已批准";
if (state === "rejected") return "已拒绝";
return "待审批";
}
function commandStateLabel(state: string): string {
const labels: Record<string, string> = { pending: "等待领取", claimed: "执行中", succeeded: "成功", failed: "失败", cancelled: "已取消", expired: "已过期" };
return labels[state] ?? state;
}
function healthLabel(status: string | undefined): string {
if (status === "online") return "在线";
if (status === "degraded") return "降级";
if (status === "offline") return "离线";
return "未知";
}
function formatTime(value: string | undefined): string {
if (!value) return "--";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "--" : parsed.toLocaleString("zh-CN", { hour12: false });
}
function percent(value: number | undefined): string {
return value === undefined ? "--" : `${Math.round(value)}%`;
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}