Repair live server operations console
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user