Repair live server operations console

This commit is contained in:
npc0-hue
2026-08-03 11:21:07 +08:00
parent 9f82e310b2
commit 1d5fb586d0
16 changed files with 594 additions and 134 deletions
@@ -0,0 +1,227 @@
import { 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;
interface LiveOperationDrawerProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
}
function LiveOperationDrawer({ open, title, description, onClose, children }: 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="drawer-backdrop" role="presentation" onClick={onClose}>
<aside className="drawer-panel live-operation-drawer" 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>
{children}
</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}>
<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>
{result && <ResultBadge status={result.status} label={result.label} />}
{!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>
)}
</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";
}
+1 -1
View File
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
## 服务器详情
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk, plus confirmed start/stop lifecycle actions. Sections: 概览 (live status cards, warnings), 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 配置 (edit with reviewable diff before any write job), 插件控制 (controls grouped by plugin, scoped to this server instance, confirmation + lifecycle feedback per action), AI 助手 (LLM suggestions produce recommendation/diff; write jobs require explicit diff confirmation; no raw AI keys reach the frontend), 操作历史 (operation/job IDs, status, timestamps, target, requester, error reasons).
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, confirmed start/stop lifecycle actions, and direct live-log/management-terminal entry points. Sections: 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 管理终端 (platform-mediated plugin command surface), 运行操作 (run binding, read-only deployment status, distribution, lifecycle, members), 配置 (edit with reviewable diff before any write job), 插件控制 (controls grouped by plugin, scoped to this server instance, confirmation + lifecycle feedback per action), AI 助手 (LLM suggestions produce recommendation/diff; write jobs require explicit diff confirmation; no raw AI keys reach the frontend), 操作历史 (operation/job IDs, status, timestamps, target, requester, error reasons).
## 插件市场
+3 -2
View File
@@ -143,11 +143,12 @@ export interface PlatformOverviewSignal {
at: string;
}
export type ServerDetailSection = "overview" | "logs" | "config" | "plugins" | "llm" | "history";
export type ServerDetailSection = "logs" | "terminal" | "runtime" | "config" | "plugins" | "llm" | "history";
export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [
{ id: "overview", label: "概览" },
{ id: "logs", label: "日志" },
{ id: "terminal", label: "管理终端" },
{ id: "runtime", label: "运行操作" },
{ id: "config", label: "配置" },
{ id: "plugins", label: "插件控制" },
{ id: "llm", label: "AI 助手" },
+5 -2
View File
@@ -172,7 +172,8 @@ describe("first-party console pages", () => {
expect(serversPageSource).toContain("<ServerDeploymentWorkflow");
expect(serversPageSource).toContain("openEditDeployment");
expect(serversPageSource).toContain("编辑部署");
expect(serverDetailPageSource).toContain("<ServerDeploymentWorkflow");
expect(serverDetailPageSource).not.toContain("<ServerDeploymentWorkflow");
expect(serverDetailPageSource).toContain("ServerDeploymentSection");
expect(serverDeploymentWorkflowSource).toContain("基本信息");
expect(serverDeploymentWorkflowSource).toContain("部署方式");
expect(serverDeploymentWorkflowSource).toContain("相关配置");
@@ -278,8 +279,10 @@ describe("first-party console pages", () => {
const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />);
expect(html).toContain("返回列表");
expect(html).toContain("概览");
expect(html).not.toContain("概览");
expect(html).toContain("日志");
expect(html).toContain("管理终端");
expect(html).toContain("运行操作");
expect(html).toContain("配置");
expect(html).toContain("插件控制");
expect(html).toContain("AI 助手");
+63 -94
View File
@@ -1,4 +1,4 @@
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ScrollText, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
@@ -21,14 +21,12 @@ import type {
ServerMemberResponse,
ServerMetricsResponse,
RuntimeBindingResponse,
RunEndpointResponse,
ServerDeploymentResponse,
ServerRuntimeActionsResponse,
MetricSampleResponse,
RemoteAdapterDeclarationResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
@@ -50,7 +48,8 @@ import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge }
import type { PageComponentProps } from "../contracts/page";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import {
serverDetailSections,
serverIsOnline,
@@ -79,10 +78,12 @@ import { stateLabel, statusClass } from "./ServersPage";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
const defaultConfigKey = "server.properties";
const serverDetailRefreshMs = 5000;
const serverMetricFreshMs = 30000;
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
const serverId = params.serverId ?? "";
const [section, setSection] = useState<ServerDetailSection>("overview");
const [section, setSection] = useState<ServerDetailSection>("logs");
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
@@ -94,8 +95,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
const [showDeploymentEditor, setShowDeploymentEditor] = useState(false);
const [liveLogOpen, setLiveLogOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -106,10 +107,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, endpointResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs(serverId),
platformApiClient
.getServerRuntimeActions(serverId)
@@ -129,7 +129,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setEndpoints(endpointResponse.items);
setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse);
setRuntimeBinding(bindingResponse);
@@ -168,6 +167,27 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
void refresh();
}, [refresh]);
const refreshOperationalState = useCallback(async () => {
if (!serverId) return;
try {
const [detail, jobResponse, metricsResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listJobs(serverId),
platformApiClient.listServerMetrics()
]);
setInstance({ status: "ready", data: detail });
setJobs(jobResponse.items);
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
} catch {
setMetrics(null);
}
}, [serverId]);
useEffect(() => {
const timer = window.setInterval(() => void refreshOperationalState(), serverDetailRefreshMs);
return () => window.clearInterval(timer);
}, [refreshOperationalState]);
useEffect(() => {
if (params.routeKey !== "run-builder" || instance.status !== "ready" || typeof document === "undefined") return;
const frame = window.requestAnimationFrame(() => {
@@ -182,6 +202,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
() => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)),
[operations.operations, serverId]
);
const canManageServers = session.capabilities.includes("servers.manage");
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
setConfirm({
@@ -212,18 +233,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
});
}
async function saveDeploymentWorkflow(form: ServerCreateFormState) {
if (instance.status !== "ready") return;
const current = instance.data;
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: current.id, requester: session.displayName });
try {
await platformApiClient.updateServerDeployment(current.id, { runEndpointId: form.runEndpointId || undefined, mode: form.deploymentMode, profileKey: form.profileKey || undefined, createInputs: form.createInputs, serverRoot: form.serverRoot.trim() || undefined, workingDirectory: form.workingDirectory.trim() || undefined, installCommand: form.installCommand.trim() || undefined, startCommand: form.startCommand.trim() || undefined, stopCommand: form.stopCommand.trim() || undefined, statusCommand: form.statusCommand.trim() || undefined, shell: form.shell || undefined });
operations.succeed(operationId, "部署设置已保存;路径和命令保持受保护状态。");
setShowDeploymentEditor(false);
await refresh();
} catch (error) { operations.fail(operationId, error instanceof Error ? error.message : "部署设置保存失败"); }
}
if (!serverId) {
return (
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
@@ -294,7 +303,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
<Square size={15} />
<span></span>
</button>
<button type="button" className="icon-command" disabled={instance.data.state === "running" || instance.data.state === "installing"} title={instance.data.state === "running" || instance.data.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={() => setShowDeploymentEditor(true)}><Pencil size={15} /><span></span></button>
<button type="button" className="icon-command" onClick={() => setLiveLogOpen(true)}><ScrollText size={15} /><span></span></button>
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有运行操作权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span></span></button>
</div>
</div>
<div className="server-detail-stat-strip">
@@ -302,9 +312,12 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
<HeaderStat label="玩家" value={metrics?.playerCount !== undefined ? `${metrics.playerCount}${metrics.maxPlayers ? `/${metrics.maxPlayers}` : ""}` : "--"} />
<HeaderStat label="TPS" value={metrics?.tps !== undefined ? metrics.tps.toFixed(1) : "--"} />
<HeaderStat label="延迟" value={metrics?.latencyMs !== undefined ? `${Math.round(metrics.latencyMs)}ms` : "--"} />
<HeaderStat label="CPU" value={metrics?.cpuPercent !== undefined ? `${Math.round(metrics.cpuPercent)}%` : "--"} />
<HeaderStat label="内存" value={metrics?.memoryPercent !== undefined ? `${Math.round(metrics.memoryPercent)}%` : "--"} />
<HeaderStat label="磁盘" value={metrics?.diskPercent !== undefined ? `${Math.round(metrics.diskPercent)}%` : "--"} />
<HeaderStat label="指标" value={metricFreshnessLabel(metrics)} />
</div>
<div className="server-detail-meter-strip">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
</header>
@@ -322,8 +335,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
))}
</nav>
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
{section === "overview" && (
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "runtime" && (
<RuntimeBindingSection
instance={instance.data}
plugin={plugins.find((plugin) => plugin.id === instance.data.pluginId)}
@@ -333,10 +347,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()}
/>
)}
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} onEdit={() => setShowDeploymentEditor(true)} />}
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "overview" && (
{section === "runtime" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
{section === "runtime" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
{section === "runtime" && (
<RuntimeDistributionSection
instance={instance.data}
runtimeActions={runtimeActions}
@@ -346,10 +359,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()}
/>
)}
{section === "overview" && (
<ClientManagerLifecyclePanel serverId={instance.data.id} serverName={instance.data.name} session={session} operations={operations} />
)}
{section === "overview" && (
{section === "runtime" && <ClientManagerLifecyclePanel serverId={instance.data.id} serverName={instance.data.name} session={session} operations={operations} />}
{section === "runtime" && (
<ServerMetadataSection
instance={instance.data}
session={session}
@@ -357,13 +368,13 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={(next) => setInstance({ status: "ready", data: next })}
/>
)}
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "runtime" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
<ServerDeploymentWorkflow open={showDeploymentEditor && deployment.status === "ready"} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={deploymentWorkflowForm(instance.data, deployment.status === "ready" ? deployment.data : undefined, plugins, endpoints)} deployment={deployment.status === "ready" ? deployment.data : undefined} busy={operations.isPending(instance.data.id, "更新部署定义")} onReveal={() => platformApiClient.revealServerDeployment(instance.data.id)} onClose={() => setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} />
<ServerLiveLogDrawer open={liveLogOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setLiveLogOpen(false)} />
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
</>
)}
@@ -455,10 +466,9 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser
interface ServerDeploymentSectionProps {
instance: ServerInstanceResponse;
deployment: LoadState<ServerDeploymentResponse>;
onEdit: () => void;
}
function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploymentSectionProps) {
function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSectionProps) {
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data;
@@ -469,7 +479,6 @@ function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploym
<p className="section-copy"></p>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span> Run </span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run </span><strong> r{view.latestDispatch.deploymentRevision} </strong></div>}</div>
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM </span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>}
<div className="action-strip" style={{ marginTop: 12 }}><button type="button" className="primary-command" disabled={instance.state === "running" || instance.state === "installing"} onClick={onEdit}><Pencil size={14} /><span></span></button>{(instance.state === "draft" || instance.state === "failed") && <span className="field-help"></span>}</div>
</article>;
}
@@ -486,11 +495,6 @@ function deploymentProjectionLabel(value?: string): string {
}
}
function deploymentWorkflowForm(instance: ServerInstanceResponse, deployment: ServerDeploymentResponse | undefined, plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState {
const plugin = plugins.find((item) => item.id === instance.pluginId);
return { ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment?.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment?.mode ?? "guided-install", shell: deployment?.shell ?? "" };
}
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
switch (progress.phase) {
case "queued": return "任务已排队,等待 Run 领取";
@@ -644,51 +648,11 @@ function HeaderStat({ label, value }: { label: string; value: string }) {
);
}
interface OverviewSectionProps {
instance: ServerInstanceResponse;
metrics: ServerMetricsResponse | null;
jobs: JobResponse[];
onOpenLogs: () => void;
}
function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectionProps) {
const pending = jobs.filter((job) => job.state === "queued" || job.state === "accepted" || job.state === "running" || job.state === "retrying");
const failed = jobs.filter((job) => job.state === "failed");
return (
<div className="overview-two-col">
<article className="console-panel">
<div className="panel-header">
<h2>使</h2>
<span className="page-status">{metrics ? new Date(metrics.collectedAt).toLocaleTimeString() : "暂无指标"}</span>
</div>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
</article>
<article className="console-panel">
<div className="panel-header">
<h2></h2>
<button type="button" className="icon-command" onClick={onOpenLogs}>
</button>
</div>
<div className="action-list">
{instance.state === "failed" && <span> </span>}
{failed.length > 0 && <span> {failed.length} </span>}
{pending.length > 0 ? (
<span>
{pending[0].capability}{deploymentProgressLabel(pending[0].progress)}{pending[0].progress.percent}%
</span>
) : (
<span></span>
)}
<span>v{instance.configVersion} {new Date(instance.updatedAt).toLocaleString()}</span>
</div>
</article>
</div>
);
function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
if (!metrics || metrics.source === "run-metrics-pending") return "等待上报";
const collectedAt = new Date(metrics.collectedAt).getTime();
if (Number.isFinite(collectedAt) && Date.now() - collectedAt > serverMetricFreshMs) return "指标过期";
return new Date(metrics.collectedAt).toLocaleTimeString();
}
interface RuntimeDistributionSectionProps {
@@ -1434,8 +1398,8 @@ function LogsSection({ serverId }: LogsSectionProps) {
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
const [selected, setSelected] = useState<(LogEntryBody & { source: string }) | null>(null);
const refresh = useCallback(async () => {
setStreams({ status: "loading" });
const refresh = useCallback(async (showLoading = true) => {
if (showLoading) setStreams({ status: "loading" });
try {
const response = await platformApiClient.listServerLiveLogs(serverId);
const serverStreams = response.items;
@@ -1460,6 +1424,11 @@ function LogsSection({ serverId }: LogsSectionProps) {
void refresh();
}, [refresh]);
useEffect(() => {
const timer = window.setInterval(() => void refresh(false), 2000);
return () => window.clearInterval(timer);
}, [refresh]);
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
const visible = useMemo(() => {
+42 -8
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
import { AlertTriangle, CakeSlice, Candy, ScrollText, Search, Sparkles, Terminal, Trash2 } from "lucide-react";
import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
@@ -16,6 +16,7 @@ import {
runtimeUpdateStages,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
@@ -57,6 +58,9 @@ const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
{ id: "attention", label: "需关注" }
];
const serverListRefreshMs = 5000;
const serverMetricFreshMs = 30000;
export function ServersPage({ session, operations, onNavigate }: PageComponentProps) {
const [listState, setListState] = useState<ListState>("loading");
const [listError, setListError] = useState<string>("");
@@ -78,9 +82,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [deletePassword, setDeletePassword] = useState("");
const [deleteBusy, setDeleteBusy] = useState(false);
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
const [liveLogTarget, setLiveLogTarget] = useState<ServerInstanceResponse | null>(null);
const [terminalTarget, setTerminalTarget] = useState<ServerInstanceResponse | null>(null);
const refreshList = useCallback(async () => {
setListState("loading");
const refreshList = useCallback(async (showLoading = true) => {
if (showLoading) setListState("loading");
try {
const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([
platformApiClient.listGamePlugins(),
@@ -92,7 +98,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
setEndpoints(endpointResponse.items);
setInstances(instanceResponse.items);
setJobs(jobResponse.items);
setForm((current) => {
if (showLoading) setForm((current) => {
const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0];
const profileKey = plugin?.runtimeProfiles?.lifecycleProfiles?.some((profile) => profile.key === current.profileKey)
? current.profileKey
@@ -107,10 +113,12 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
: endpointResponse.items[0]?.id || ""
};
});
setListState("ready");
setListError("");
if (showLoading) {
setListState("ready");
setListError("");
}
} catch (error) {
setListState("error");
if (showLoading) setListState("error");
setListError(error instanceof Error ? error.message : "加载失败");
}
}, []);
@@ -137,6 +145,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
void refresh();
}, [refresh]);
useEffect(() => {
const timer = window.setInterval(() => {
void refreshList(false);
void refreshMetrics();
}, serverListRefreshMs);
return () => window.clearInterval(timer);
}, [refreshList, refreshMetrics]);
const cards = useMemo<ServerCardView[]>(
() =>
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
@@ -596,6 +612,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onEdit={() => void openEditDeployment(card.instance)}
onOpenLogs={() => setLiveLogTarget(card.instance)}
onOpenTerminal={() => setTerminalTarget(card.instance)}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
onDelete={() => {
setDeletePassword("");
@@ -630,6 +648,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</label>
</ConfirmDialog>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
<ServerLiveLogDrawer open={liveLogTarget !== null} serverId={liveLogTarget?.id ?? ""} serverName={liveLogTarget?.name ?? ""} onClose={() => setLiveLogTarget(null)} />
<ServerManagementTerminalDrawer open={terminalTarget !== null} serverId={terminalTarget?.id ?? ""} serverName={terminalTarget?.name ?? ""} pluginId={terminalTarget?.pluginId ?? ""} canManage={canManageServers} onClose={() => setTerminalTarget(null)} />
</section>
);
}
@@ -674,15 +694,19 @@ interface ServerCardProps {
deleteDisabledReason: string;
onOpen: () => void;
onEdit: () => void;
onOpenLogs: () => void;
onOpenTerminal: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void;
onDelete: () => void;
}
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onQuickAction, onDelete }: ServerCardProps) {
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onOpenLogs, onOpenTerminal, onQuickAction, onDelete }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const canDelete = deleteDisabledReason === "";
const canOpenActions = canManage || canDelete;
const metricsWaiting = metrics?.source === "run-metrics-pending";
const metricsStale = isMetricsStale(metrics);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -793,6 +817,8 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
{metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{!metricsUnavailable && metricsWaiting && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{!metricsUnavailable && metricsStale && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{failedJobs > 0 && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
<div className="action-strip" style={{ justifyContent: "space-between" }}>
<button type="button" className="icon-command" onClick={onOpen}>
@@ -800,6 +826,8 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
<span></span>
</button>
<button type="button" className="icon-command" disabled={!canManage || instance.state === "running" || instance.state === "installing"} title={instance.state === "running" || instance.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={onEdit}><span></span></button>
<button type="button" className="icon-command" onClick={onOpenLogs}><ScrollText size={14} /><span></span></button>
<button type="button" className="icon-command" disabled={!canManage} title={canManage ? "管理终端" : "当前账号没有运行操作权限"} onClick={onOpenTerminal}><Terminal size={14} /><span></span></button>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span>
</button>
@@ -979,6 +1007,12 @@ function formatStat(value: number | undefined, pending: boolean, format: (value:
return pending ? "…" : "--";
}
function isMetricsStale(metrics?: ServerMetricsResponse): boolean {
if (!metrics || metrics.source === "run-metrics-pending") return false;
const collectedAt = new Date(metrics.collectedAt).getTime();
return Number.isFinite(collectedAt) && Date.now() - collectedAt > serverMetricFreshMs;
}
export function stateLabel(state: ServerInstanceResponse["state"]): string {
switch (state) {
case "installing":
+1 -1
View File
@@ -6,7 +6,7 @@ First-party routes must be declared here before page implementation.
- `/`: 平台概览(平台管理员默认落地页).
- `/servers`: 服务器管理(服主/服务器管理员默认落地页).
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:概览、日志、配置、插件控制、AI 助手、操作历史).
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:日志、管理终端、运行操作、配置、插件控制、AI 助手、操作历史).
- `/plugins`: 插件市场.
- `/users`: 用户管理.
- `/ai-providers`: AI 提供商管理.
+16
View File
@@ -465,6 +465,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-detail-title-row>div:first-child{min-width:0}
.server-detail-title-row h1{margin:0;font-size:24px;color:var(--ink);overflow-wrap:anywhere}
.server-detail-stat-strip{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:8px}
.server-detail-meter-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.section-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 72%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap}
.section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0}
@@ -484,6 +485,20 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.log-line span:last-child{overflow-wrap:anywhere}
.drawer-backdrop{position:fixed;inset:0;z-index:40;background:rgba(61,36,65,.34);backdrop-filter:blur(6px);display:flex;justify-content:flex-end;overflow-y:auto;overscroll-behavior:contain}
.drawer-panel{width:min(440px,100%);height:100%;max-height:100dvh;display:grid;align-content:start;gap:14px;padding:20px;background:var(--glass-wash),var(--glass-tint),var(--surface-raised);backdrop-filter:blur(14px) saturate(0.28);border-left:1px solid var(--line);box-shadow:inset 1px 0 0 var(--crystal-rim),-18px 0 42px var(--glass-shadow);position:relative;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain}
.live-operation-drawer{width:min(720px,100%)}
.live-operation-toolbar .icon-command{min-height:36px}
.live-log-list{max-height:min(58dvh,560px)}
.live-log-line{cursor:default}
.terminal-output{display:grid;gap:6px;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:12px;border-radius:8px;background:radial-gradient(circle at 92% 0,rgba(255,255,255,.1),transparent 36%),var(--code-surface);font-family:var(--font-mono);font-size:12.5px;color:var(--code-ink);box-shadow:inset 0 1px 0 rgba(255,255,255,.16),0 14px 32px rgba(255,255,255,.12)}
.terminal-line{display:grid;grid-template-columns:72px minmax(0,1fr);gap:10px;align-items:start}
.terminal-line time{color:var(--code-muted)}
.terminal-line span{overflow-wrap:anywhere}
.terminal-line-input span{color:var(--accent)}
.terminal-line-success span{color:var(--teal)}
.terminal-line-error span{color:var(--danger)}
.terminal-command-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:end}
.terminal-command-form label{display:grid;gap:6px;color:var(--ink-soft);font-size:12px}
.terminal-command-form input{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}
.plugin-detail-panel{width:100%;height:auto;overflow:visible;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--panel-shadow)}
.management-dialog-panel{width:min(720px,calc(100vw - 32px));height:auto;max-height:min(760px,calc(100dvh - 32px));overflow-x:hidden;overflow-y:auto;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 48px var(--glass-shadow),0 0 34px var(--moonbeam)}
.management-dialog-wide{width:min(920px,calc(100vw - 32px))}
@@ -668,6 +683,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.catalog-grid,.console-grid,.metric-grid,.overview-two-col,.resource-list-item,.server-card-grid{grid-template-columns:1fr}
.form-grid,.server-metrics,.server-workspace{grid-template-columns:1fr}
.server-detail-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}
.server-detail-meter-strip,.terminal-command-form{grid-template-columns:1fr}
.client-manager-command-grid,.client-manager-version-grid{grid-template-columns:1fr}
.server-card-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}