Repair live server operations console
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
## 插件市场
|
||||
|
||||
|
||||
@@ -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 助手" },
|
||||
|
||||
@@ -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 助手");
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 提供商管理.
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user