437 lines
23 KiB
TypeScript
437 lines
23 KiB
TypeScript
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: "公告内容必须为 1–500 个字符。" });
|
||
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="输入 1–500 字公告" 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`;
|
||
}
|