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(() => initialData ? { status: "ready", data: initialData } : { status: "loading" }); const [snapshotSection, setSnapshotSection] = useState("players"); const [announcement, setAnnouncement] = useState(""); const [pendingCommand, setPendingCommand] = useState(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 ; } if (state.status === "error") { return 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) { event.preventDefault(); const message = announcement.trim(); if (!message || message.length > 500) { setActionResult({ status: "failed", label: "公告内容必须为 1–500 个字符。" }); return; } void queueCommand("announcement.send", { message }); } return (
Game Client Bridge 运维投影

只显示 Platform 安全 DTO 与 game.scum 声明,不接触组件会话、主机路径或 Run socket。

{data.errors.length > 0 && }

{canQueue ? "Bridge 可提交" : bridgeReason || "Bridge 不可提交"}
{announcementDeclaration && (
)} {diagnostics && (
{diagnostics.title}

仅请求声明的窗口状态摘要与最多 10 条诊断,不传递组件会话或路径。

)} {actionResult && }

{snapshotTabs(snapshots).map((tab) => ( ))}

{contract.logEvents.length} 类已声明事件
{contract.logEvents.map((event) => event.eventType).join(" / ") || "插件未声明语义日志事件"} 可疑行为仅作为证据与复核线索展示,不触发自动封禁或惩罚。

审批优先
依赖策略 {contract.productionLifecycle.dependencyPolicy === "required" ? "必须满足依赖后执行" : "依赖为可选项"} 生命周期审批 {contract.productionLifecycle.approvalRequired.join(" / ") || "无额外声明"} {contract.commands.filter((command) => command.permission === "server.game-client.maintenance").map((command) => ( ))} 本页不自动执行重启、奖励、惩罚或备份;操作仍需插件声明、Platform 权限与审批。
); } async function loadSemanticLogs(contract: ScumOperationsPageContract): Promise { const streamsResponse = await platformApiClient.listLogStreams(); const sourceByKey = new Map(contract.logSources.map((source) => [source.key, source])); const eventTypesByStream = new Map(); 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 { const declared = eventTypesByStream.get(stream.streamKey) ?? []; const projected = entry.fields?.eventType; return projected && declared.includes(projected) ? projected : declared.join(" / ") || stream.streamKey; } function settledValue(result: PromiseSettledResult): 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 (
{label} {value}

{detail}

); } function CommandTable({ commands }: { commands: GameClientBridgeCommandResponse[] }) { if (commands.length === 0) return

暂无命令记录。

; return (
{commands.slice(0, 50).map((command) => ( ))}
命令状态审批结果更新时间
{command.commandType}{command.id} {commandStateLabel(command.state)} {approvalLabel(command.approvalState)} {safeDiagnosticText(command.result?.summary || command.resultSummary, "--") || "--"} {formatTime(command.updatedAt)}
); } 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 = { 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

该类型暂无安全快照。

; return (
{table.headers.map((header) => )}{table.rows.map((row, rowIndex) => {row.map((cell, index) => )})}
{header}
{cell}
{table.total > table.rows.length &&

仅展示前 {table.rows.length} / {table.total} 项。

}
); } function SemanticLogTable({ logs }: { logs: ScumSemanticLogView[] }) { if (logs.length === 0) return

暂无可用语义日志投影。

; return (
{logs.slice(0, 100).map((log) => ( ))}
事件类型时间级别安全摘要
{log.eventType} {formatTime(log.entry.timestamp)} {log.entry.level || "info"}{log.entry.redacted ? " · 已脱敏" : ""} {safeDiagnosticText(log.entry.line, "日志内容已隐藏")}
); } function BackupTable({ backups }: { backups: BackupResponse[] }) { if (backups.length === 0) return

暂无 Platform 备份记录。

; return (
{backups.slice(0, 20).map((backup) => ( ))}
备份状态大小恢复状态保留至
{backup.id}{backup.state}{formatBytes(backup.sizeBytes)}{backup.recoveryStatus || "--"}{formatTime(backup.retentionUntil)}
); } 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 = { 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`; }