2465 lines
114 KiB
TypeScript
2465 lines
114 KiB
TypeScript
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";
|
||
import type {
|
||
ConfigDiffLineResponse,
|
||
ArtifactDownloadReferenceResponse,
|
||
ArtifactResponse,
|
||
BackupResponse,
|
||
ClientManagerDistributionResponse,
|
||
DependencyCatalogResponse,
|
||
GamePluginResponse,
|
||
JobResponse,
|
||
LogStreamResponse,
|
||
RunDistributionResponse,
|
||
RunUpdateJobResponse,
|
||
ServerConfigDiffPreviewResponse,
|
||
ServerConfigResponse,
|
||
ServerInstanceResponse,
|
||
ServerMemberResponse,
|
||
ServerMetricsResponse,
|
||
RuntimeBindingResponse,
|
||
ServerDeploymentResponse,
|
||
ServerRuntimeActionsResponse,
|
||
MetricSampleResponse,
|
||
RemoteAdapterDeclarationResponse
|
||
} from "../api/types";
|
||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
|
||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
|
||
import { RuntimeDLLExtensionsPanel } from "../components/RuntimeDLLExtensionsPanel";
|
||
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
|
||
import {
|
||
RuntimeTaskProgressDialog,
|
||
runtimeBuildStages,
|
||
runtimeDependencyStages,
|
||
runtimeDownloadStages,
|
||
runtimeLogStages,
|
||
runtimeRunBuildStages,
|
||
runtimeUpdateStages,
|
||
type RuntimeTaskDialogAction,
|
||
type RuntimeTaskStage,
|
||
useRuntimeTaskController
|
||
} from "../components/RuntimeTaskProgress";
|
||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||
import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||
import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
|
||
import {
|
||
serverDetailSections,
|
||
serverIsOnline,
|
||
isPlatformAdmin,
|
||
type ConfigDiffView,
|
||
type LlmSuggestionView,
|
||
type PluginControlDescriptor,
|
||
type PluginControlGroupView,
|
||
type ServerDetailSection
|
||
} from "../contracts/workspace";
|
||
import {
|
||
clientManagerBuildRequest,
|
||
dependencyJobRequest,
|
||
logBackfillRequest,
|
||
runDistributionGenerateRequest,
|
||
runUpdateRequest,
|
||
serverLifecycleCommandRequest,
|
||
serverMetadataUpdateRequestFromForm
|
||
} from "../schemas/serverManagement";
|
||
import { diffHasChanges } from "../utils/diff";
|
||
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
|
||
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
|
||
import { cx } from "../utils/classes";
|
||
import { stateLabel, statusClass } from "./ServersPage";
|
||
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
|
||
|
||
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>("logs");
|
||
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
|
||
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
|
||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
|
||
const [metricHistory, setMetricHistory] = useState<MetricSampleResponse[]>([]);
|
||
const [backups, setBackups] = useState<BackupResponse[]>([]);
|
||
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
|
||
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 [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);
|
||
|
||
const refresh = useCallback(async () => {
|
||
if (!serverId) {
|
||
setInstance({ status: "error", reason: "缺少服务器 ID" });
|
||
return;
|
||
}
|
||
setInstance({ status: "loading" });
|
||
try {
|
||
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
|
||
platformApiClient.getServerInstance(serverId),
|
||
platformApiClient.listGamePlugins(),
|
||
platformApiClient.listJobs(serverId),
|
||
platformApiClient
|
||
.getServerRuntimeActions(serverId)
|
||
.then((data): LoadState<ServerRuntimeActionsResponse> => ({ status: "ready", data }))
|
||
.catch((error): LoadState<ServerRuntimeActionsResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" })),
|
||
platformApiClient
|
||
.getServerRuntimeBinding(serverId)
|
||
.then((data): LoadState<RuntimeBindingResponse> => ({ status: "ready", data }))
|
||
.catch((error): LoadState<RuntimeBindingResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })),
|
||
platformApiClient
|
||
.getServerDeployment(serverId)
|
||
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
|
||
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
|
||
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
|
||
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
|
||
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 }))
|
||
]);
|
||
setInstance({ status: "ready", data: detail });
|
||
setPlugins(pluginResponse.items);
|
||
setJobs(jobResponse.items);
|
||
setRuntimeActions(runtimeResponse);
|
||
setRuntimeBinding(bindingResponse);
|
||
setDeployment(deploymentResponse);
|
||
setMetricHistory(metricHistoryResponse.items);
|
||
setBackups(backupResponse.items);
|
||
setRemoteAdapters(adapterResponse.items);
|
||
const artifactLists = await Promise.all(
|
||
jobResponse.items.slice(0, 20).map((job) =>
|
||
platformApiClient
|
||
.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })
|
||
.then((response) => response.items)
|
||
.catch(() => [] as ArtifactResponse[])
|
||
)
|
||
);
|
||
setArtifacts(uniqueArtifacts(artifactLists.flat()));
|
||
} catch (error) {
|
||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||
setArtifacts([]);
|
||
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
|
||
setRuntimeBinding({ status: "error", reason: "运行配置加载失败" });
|
||
setDeployment({ status: "error", reason: "部署定义加载失败" });
|
||
setMetricHistory([]);
|
||
setBackups([]);
|
||
setRemoteAdapters([]);
|
||
}
|
||
try {
|
||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
|
||
} catch {
|
||
setMetrics(null);
|
||
}
|
||
}, [serverId]);
|
||
|
||
useEffect(() => {
|
||
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(() => {
|
||
const target = document.getElementById("run-builder");
|
||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
target?.focus({ preventScroll: true });
|
||
});
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [instance.status, params.routeKey]);
|
||
|
||
const serverOperations = useMemo(
|
||
() => 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({
|
||
title: action === "start" ? "启动服务器" : "停止服务器",
|
||
description:
|
||
action === "start"
|
||
? `确认启动服务器 ${current.name}(${current.id})?`
|
||
: `停止服务器 ${current.name}(${current.id})会断开所有在线玩家,确认继续?`,
|
||
danger: action === "stop",
|
||
run: async () => {
|
||
const operationId = operations.begin({
|
||
intent: action === "start" ? "启动服务器" : "停止服务器",
|
||
targetKind: "server",
|
||
targetId: current.id,
|
||
requester: session.displayName
|
||
});
|
||
try {
|
||
const result =
|
||
action === "start"
|
||
? await platformApiClient.startServerInstance(current.id, serverLifecycleCommandRequest(current, "start"))
|
||
: await platformApiClient.stopServerInstance(current.id, serverLifecycleCommandRequest(current, "stop"));
|
||
operations.succeed(operationId, `任务 ${result.job.id}(${result.job.capability})已派发`, result.job);
|
||
await refresh();
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "操作失败", operationId);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
if (!serverId) {
|
||
return (
|
||
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section className="server-detail-page" aria-labelledby="server-detail-title">
|
||
<div className="action-strip">
|
||
<button type="button" className="icon-command" onClick={() => onNavigate("servers")}>
|
||
<MoonStar size={16} />
|
||
<span>返回列表</span>
|
||
</button>
|
||
<button type="button" className="icon-command" onClick={() => void refresh()}>
|
||
<Sparkles size={16} />
|
||
<span>刷新</span>
|
||
</button>
|
||
</div>
|
||
|
||
{instance.status === "loading" && <LoadingState label="正在加载服务器详情…" />}
|
||
{instance.status === "error" && (
|
||
<ErrorState title="服务器详情加载失败" reason={instance.reason} diagnosticId={`server-detail:${serverId}`} onRetry={() => void refresh()} />
|
||
)}
|
||
|
||
{instance.status !== "ready" && (
|
||
<nav className="section-tabs" aria-label="server sections">
|
||
{serverDetailSections.map((entry) => (
|
||
<button
|
||
key={entry.id}
|
||
type="button"
|
||
className={cx("section-tab", section === entry.id && "section-tab-active")}
|
||
aria-current={section === entry.id ? "page" : undefined}
|
||
onClick={() => setSection(entry.id)}
|
||
>
|
||
{entry.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
)}
|
||
|
||
{instance.status === "ready" && (
|
||
<>
|
||
<header className="server-detail-header">
|
||
<div className="server-detail-title-row">
|
||
<div>
|
||
<h1 id="server-detail-title">{instance.data.name}</h1>
|
||
<span className="provider-id">
|
||
{instance.data.id} · 插件 {instance.data.pluginId}@{instance.data.pluginVersion} · 节点 {instance.data.runEndpointId}
|
||
</span>
|
||
</div>
|
||
<div className="action-strip">
|
||
<span className={cx("status-pill", statusClass(instance.data.state))}>{stateLabel(instance.data.state)}</span>
|
||
<button
|
||
type="button"
|
||
className="icon-command"
|
||
disabled={!canStartServer(instance.data.state) || runtimeBinding.status !== "ready" || runtimeBinding.data.status !== "complete" || operations.isPending(instance.data.id, "启动服务器")}
|
||
onClick={() => requestLifecycle(instance.data, "start")}
|
||
>
|
||
<WandSparkles size={15} />
|
||
<span>启动</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="icon-command danger-command"
|
||
disabled={!canStopServer(instance.data.state) || runtimeBinding.status !== "ready" || runtimeBinding.data.status !== "complete" || operations.isPending(instance.data.id, "停止服务器")}
|
||
onClick={() => requestLifecycle(instance.data, "stop")}
|
||
>
|
||
<Square 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">
|
||
<HeaderStat label="状态" value={serverIsOnline(instance.data.state) ? "在线" : "离线"} />
|
||
<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="指标" 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>
|
||
|
||
<nav className="section-tabs" aria-label="server sections">
|
||
{serverDetailSections.map((entry) => (
|
||
<button
|
||
key={entry.id}
|
||
type="button"
|
||
className={cx("section-tab", section === entry.id && "section-tab-active")}
|
||
aria-current={section === entry.id ? "page" : undefined}
|
||
onClick={() => setSection(entry.id)}
|
||
>
|
||
{entry.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
{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)}
|
||
binding={runtimeBinding}
|
||
session={session}
|
||
operations={operations}
|
||
onChanged={() => void refresh()}
|
||
/>
|
||
)}
|
||
{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}
|
||
session={session}
|
||
operations={operations}
|
||
onOpenLogs={() => setSection("logs")}
|
||
onChanged={() => void refresh()}
|
||
/>
|
||
)}
|
||
{section === "runtime" && <ClientManagerLifecyclePanel serverId={instance.data.id} serverName={instance.data.name} session={session} operations={operations} />}
|
||
{section === "runtime" && (
|
||
<ServerMetadataSection
|
||
instance={instance.data}
|
||
session={session}
|
||
operations={operations}
|
||
onChanged={(next) => setInstance({ status: "ready", data: next })}
|
||
/>
|
||
)}
|
||
{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} />}
|
||
<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)} />
|
||
</>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={confirm !== null}
|
||
title={confirm?.title ?? ""}
|
||
description={confirm?.description ?? ""}
|
||
confirmLabel="确认执行"
|
||
danger={confirm?.danger}
|
||
busy={confirmBusy}
|
||
onCancel={() => setConfirm(null)}
|
||
onConfirm={() => {
|
||
if (!confirm) {
|
||
return;
|
||
}
|
||
setConfirmBusy(true);
|
||
void confirm.run().finally(() => {
|
||
setConfirmBusy(false);
|
||
setConfirm(null);
|
||
});
|
||
}}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
|
||
const byID = new Map<string, ArtifactResponse>();
|
||
for (const artifact of artifacts) {
|
||
byID.set(artifact.id, artifact);
|
||
}
|
||
return [...byID.values()];
|
||
}
|
||
|
||
interface ServerMetadataSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
onChanged: (instance: ServerInstanceResponse) => void;
|
||
}
|
||
|
||
function ServerMetadataSection({ instance, session, operations, onChanged }: ServerMetadataSectionProps) {
|
||
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
|
||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||
|
||
useEffect(() => {
|
||
setDraft(serverMetadataFormFromInstance(instance));
|
||
}, [instance.id, instance.name]);
|
||
|
||
async function saveMetadata(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const operationId = operations.begin({ intent: "更新服务器信息", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
setResult({ status: "pending", label: "正在保存服务器信息" });
|
||
try {
|
||
const updated = await platformApiClient.updateServerInstance(instance.id, serverMetadataUpdateRequestFromForm(draft));
|
||
onChanged(updated);
|
||
operations.succeed(operationId, `服务器信息已更新:${updated.id}`);
|
||
setResult({ status: "succeeded", label: `已更新 ${updated.name}` });
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "服务器信息更新失败");
|
||
setResult({ status: "failed", label: error instanceof Error ? error.message : "服务器信息更新失败" });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="server metadata">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<Pencil size={16} style={{ verticalAlign: "-2px" }} /> 基本信息
|
||
</h2>
|
||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||
</div>
|
||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveMetadata(event)}>
|
||
<label>
|
||
显示名称
|
||
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
|
||
</label>
|
||
<div className="action-strip">
|
||
<button type="submit" className="primary-command" disabled={draft.name.trim() === instance.name}>
|
||
<Pencil size={14} />
|
||
<span>保存名称</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface ServerDeploymentSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
deployment: LoadState<ServerDeploymentResponse>;
|
||
}
|
||
|
||
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;
|
||
const projection = view.projection;
|
||
const isScumTemplate = (instance.pluginId === "game.scum" && (view.mode === "guided-install" || view.mode === "existing-server")) || projection?.templateKey?.startsWith("scum-");
|
||
return <article className="console-panel" aria-label="server deployment">
|
||
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 部署定义</h2><span className="page-status">{view.mode || "未配置"} · 修订 {view.revision}</span></div>
|
||
<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>}
|
||
</article>;
|
||
}
|
||
|
||
function deploymentProjectionLabel(value?: string): string {
|
||
switch (value) {
|
||
case "queued": return "排队中";
|
||
case "running": return "执行中";
|
||
case "passed": return "已通过";
|
||
case "applied": return "已写入";
|
||
case "unchanged": return "未变化";
|
||
case "failed": return "失败";
|
||
case "skipped": return "已跳过";
|
||
default: return "待返回";
|
||
}
|
||
}
|
||
|
||
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
|
||
switch (progress.phase) {
|
||
case "queued": return "任务已排队,等待 Run 领取";
|
||
case "claimed": return "Run 已领取任务";
|
||
case "preflight": return "正在执行本机预检";
|
||
case "install": return "正在安装服务器";
|
||
case "configure": return "正在写入游戏配置";
|
||
case "start": return "正在启动服务器";
|
||
case "health": return "正在进行健康检查";
|
||
default: return "部署任务已提交";
|
||
}
|
||
}
|
||
|
||
interface ServerAdministratorsSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
onChanged: (instance: ServerInstanceResponse) => void;
|
||
}
|
||
|
||
function ServerAdministratorsSection({ instance, session, onChanged }: ServerAdministratorsSectionProps) {
|
||
const [candidates, setCandidates] = useState<LoadState<ServerMemberResponse[]>>({ status: "loading" });
|
||
const [selectedUserId, setSelectedUserId] = useState("");
|
||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
|
||
const isOwner = instance.ownerUserId === session.id;
|
||
|
||
const refreshCandidates = useCallback(async () => {
|
||
if (!isOwner) {
|
||
setCandidates({ status: "ready", data: [] });
|
||
return;
|
||
}
|
||
setCandidates({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listServerAdministratorCandidates(instance.id);
|
||
setCandidates({ status: "ready", data: response.items });
|
||
setSelectedUserId((current) => (response.items.some((user) => user.id === current) ? current : response.items[0]?.id ?? ""));
|
||
} catch (error) {
|
||
setCandidates({ status: "error", reason: error instanceof Error ? error.message : "加载候选管理员失败" });
|
||
}
|
||
}, [instance.id, isOwner]);
|
||
|
||
useEffect(() => {
|
||
void refreshCandidates();
|
||
}, [refreshCandidates]);
|
||
|
||
async function addAdministrator(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!selectedUserId) {
|
||
return;
|
||
}
|
||
setBusyUserId(selectedUserId);
|
||
setResult(null);
|
||
try {
|
||
const next = await platformApiClient.addServerAdministrator(instance.id, { userId: selectedUserId });
|
||
onChanged(next);
|
||
setResult({ status: "succeeded", label: "已添加服务器管理员" });
|
||
await refreshCandidates();
|
||
} catch (error) {
|
||
setResult({ status: "failed", label: error instanceof Error ? error.message : "添加管理员失败" });
|
||
} finally {
|
||
setBusyUserId(null);
|
||
}
|
||
}
|
||
|
||
async function removeAdministrator(userId: string) {
|
||
setBusyUserId(userId);
|
||
setResult(null);
|
||
try {
|
||
const next = await platformApiClient.removeServerAdministrator(instance.id, userId);
|
||
onChanged(next);
|
||
setResult({ status: "succeeded", label: "已移除服务器管理员" });
|
||
await refreshCandidates();
|
||
} catch (error) {
|
||
setResult({ status: "failed", label: error instanceof Error ? error.message : "移除管理员失败" });
|
||
} finally {
|
||
setBusyUserId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="server administrators">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<ShieldCheck size={16} style={{ verticalAlign: "-2px" }} /> 管理成员
|
||
</h2>
|
||
<span className="page-status">{isOwner ? "服主可邀请/移除管理员" : "仅服主可调整成员"}</span>
|
||
</div>
|
||
{result && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<ResultBadge status={result.status} label={result.label} />
|
||
</div>
|
||
)}
|
||
<div className="action-list">
|
||
<span>服主:{instance.ownerUserId || "未绑定"}</span>
|
||
<span>服务器管理员:{instance.adminUserIds.length > 0 ? instance.adminUserIds.join(" / ") : "暂无"}</span>
|
||
</div>
|
||
{isOwner && (
|
||
<>
|
||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void addAdministrator(event)} aria-label="邀请服务器管理员">
|
||
<div className="form-grid">
|
||
<label>
|
||
邀请管理员
|
||
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} disabled={candidates.status !== "ready" || candidates.data.length === 0}>
|
||
{candidates.status === "ready" && candidates.data.length === 0 && <option value="">暂无可邀请用户</option>}
|
||
{candidates.status === "ready" &&
|
||
candidates.data.map((user) => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.displayName}({user.email ?? user.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{candidates.status === "loading" && <LoadingState label="正在加载可邀请用户…" compact />}
|
||
{candidates.status === "error" && (
|
||
<ErrorState title="候选管理员加载失败" reason={candidates.reason} diagnosticId={`server-admin-candidates:${instance.id}`} onRetry={() => void refreshCandidates()} compact />
|
||
)}
|
||
<button type="submit" className="primary-command" disabled={!selectedUserId || busyUserId !== null}>
|
||
<UserRoundPlus size={14} />
|
||
<span>{busyUserId === selectedUserId ? "邀请中…" : "邀请为管理员"}</span>
|
||
</button>
|
||
</form>
|
||
{instance.adminUserIds.length > 0 && (
|
||
<div className="resource-list" style={{ marginTop: 12 }}>
|
||
{instance.adminUserIds.map((userId) => (
|
||
<article key={userId} className="resource-list-item">
|
||
<span>
|
||
<strong>{userId}</strong>
|
||
<span className="provider-id">服务器管理员</span>
|
||
</span>
|
||
<button type="button" className="theme-upload" disabled={busyUserId !== null} onClick={() => void removeAdministrator(userId)}>
|
||
<UserRoundMinus size={13} />
|
||
<span>{busyUserId === userId ? "移除中…" : "移除"}</span>
|
||
</button>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function HeaderStat({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<span className="server-card-stat">
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 {
|
||
instance: ServerInstanceResponse;
|
||
runtimeActions: LoadState<ServerRuntimeActionsResponse>;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
onOpenLogs: () => void;
|
||
onChanged: () => void;
|
||
}
|
||
|
||
interface RuntimeBindingSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
plugin?: GamePluginResponse;
|
||
binding: LoadState<RuntimeBindingResponse>;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
onChanged: () => void;
|
||
}
|
||
|
||
function RuntimeBindingSection({ instance, plugin, binding, session, operations, onChanged }: RuntimeBindingSectionProps) {
|
||
const bindingData = binding.status === "ready" ? binding.data : null;
|
||
const [profileKey, setProfileKey] = useState(bindingData?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "");
|
||
const [values, setValues] = useState<Record<string, string>>({});
|
||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||
const canManage = isPlatformAdmin(session) || instance.ownerUserId === session.id;
|
||
const activeExistingBinding = bindingData?.configured === true && (instance.state === "installing" || instance.state === "running");
|
||
const fields = runtimeBindingFields(plugin, profileKey);
|
||
|
||
useEffect(() => {
|
||
setProfileKey(bindingData?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "");
|
||
setValues({});
|
||
}, [bindingData?.profileKey, bindingData?.updatedAt, plugin?.id]);
|
||
|
||
async function saveBinding(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const operationId = operations.begin({ intent: "更新运行配置", targetKind: "server", targetId: `${instance.id}:runtime-binding`, requester: session.displayName });
|
||
setResult({ status: "pending", label: "正在保存运行配置" });
|
||
try {
|
||
const updated = await platformApiClient.updateServerRuntimeBinding(instance.id, {
|
||
profileKey,
|
||
bindings: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== ""))
|
||
});
|
||
operations.succeed(operationId, updated.status === "complete" ? "运行配置已就绪" : "运行配置已保存,仍有缺失项");
|
||
setResult({ status: "succeeded", label: updated.status === "complete" ? "运行配置已就绪" : `仍缺少:${updated.missingKeys.join("、")}` });
|
||
setValues({});
|
||
onChanged();
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : "运行配置保存失败";
|
||
operations.fail(operationId, reason, operationId);
|
||
setResult({ status: "failed", label: reason });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="runtime binding">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<ShieldCheck size={16} style={{ verticalAlign: "-2px" }} /> 运行配置绑定
|
||
</h2>
|
||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||
</div>
|
||
{binding.status === "loading" && <LoadingState label="正在加载运行配置…" />}
|
||
{binding.status === "error" && <ErrorState title="运行配置加载失败" reason={binding.reason} diagnosticId={`runtime-binding:${instance.id}`} onRetry={onChanged} />}
|
||
{bindingData && (
|
||
<>
|
||
<div className="server-detail-stat-strip" style={{ marginTop: 12 }}>
|
||
<HeaderStat label="绑定状态" value={bindingData.status === "complete" ? "完整" : "待补齐"} />
|
||
<HeaderStat label="运行模式" value={bindingData.mode || "未选择"} />
|
||
<HeaderStat label="配置项" value={`${bindingData.keys.filter((key) => key.configured).length}/${bindingData.keys.length}`} />
|
||
</div>
|
||
{bindingData.reason && <p className="page-status">{bindingData.reason}</p>}
|
||
{bindingData.missingKeys.length > 0 && <p className="page-status">缺少逻辑绑定:{bindingData.missingKeys.join("、")}</p>}
|
||
{bindingData.keys.length > 0 && (
|
||
<div className="tag-list" aria-label="runtime binding status">
|
||
{bindingData.keys.map((key) => (
|
||
<span key={key.key} className={cx("status-pill", key.configured ? "status-active" : "status-disabled")}>
|
||
{key.key} · {key.configured ? (key.secret ? "受保护" : "已配置") : "缺失"}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveBinding(event)}>
|
||
<label>
|
||
运行配置
|
||
<select
|
||
value={profileKey}
|
||
onChange={(event) => {
|
||
setProfileKey(event.target.value);
|
||
setValues({});
|
||
}}
|
||
disabled={!canManage || activeExistingBinding}
|
||
required
|
||
>
|
||
{(plugin?.runtimeProfiles?.lifecycleProfiles ?? []).map((profile) => (
|
||
<option key={profile.key} value={profile.key}>
|
||
{profile.key} · {profile.mode}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="form-grid">
|
||
{fields.map((field) => {
|
||
const existing = bindingData.keys.find((key) => key.key === field.key);
|
||
return (
|
||
<label key={field.key}>
|
||
{field.key}{field.required ? "(必填)" : ""}
|
||
<input
|
||
type={field.sensitive ? "password" : "text"}
|
||
autoComplete="off"
|
||
value={values[field.key] ?? ""}
|
||
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
||
placeholder={existing?.configured ? "已配置" : "待配置"}
|
||
disabled={!canManage || activeExistingBinding}
|
||
/>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
<button type="submit" className="primary-command" disabled={!canManage || activeExistingBinding || !profileKey}>
|
||
<ShieldCheck size={16} />
|
||
<span>保存运行配置</span>
|
||
</button>
|
||
</form>
|
||
</>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) {
|
||
const defaults = runtimeDefaultsForPlugin(instance.pluginId);
|
||
const [targetOs, setTargetOs] = useState(defaults.runOs);
|
||
const [targetArch, setTargetArch] = useState("amd64");
|
||
const [profileKey, setProfileKey] = useState(defaults.clientProfileKey);
|
||
const [repositoryUrl, setRepositoryUrl] = useState(defaults.repositoryUrl);
|
||
const [sourceRevision, setSourceRevision] = useState(defaults.sourceRevision);
|
||
const [probeKey, setProbeKey] = useState(defaults.probeKey);
|
||
const [installPlanKey, setInstallPlanKey] = useState(defaults.installPlanKey);
|
||
const [logSourceKey, setLogSourceKey] = useState(defaults.logSourceKey);
|
||
const [checkpointRef, setCheckpointRef] = useState("");
|
||
const [lastRun, setLastRun] = useState<RunDistributionResponse | null>(null);
|
||
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
|
||
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
|
||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||
const [dependencyCatalog, setDependencyCatalog] = useState<LoadState<DependencyCatalogResponse>>({ status: "loading" });
|
||
const [runUpdates, setRunUpdates] = useState<LoadState<RunUpdateJobResponse[]>>({ status: "loading" });
|
||
const runtimeTask = useRuntimeTaskController();
|
||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||
|
||
const refreshRuntimeProjections = useCallback(async () => {
|
||
const dependencyActions = runtimeActions.status === "ready"
|
||
? runtimeActions.data.actions.filter((action) => action.key === "dependencies-check" || action.key === "dependencies-install")
|
||
: [];
|
||
const dependencyActionReason = dependencyActions.find((action) => action.reason)?.reason ?? "依赖操作未开放";
|
||
const catalogRequest: Promise<LoadState<DependencyCatalogResponse>> =
|
||
runtimeActions.status === "ready" && dependencyActions.some((action) => action.available)
|
||
? platformApiClient
|
||
.getDependencyCatalog(instance.id)
|
||
.then((data): LoadState<DependencyCatalogResponse> => ({ status: "ready", data }))
|
||
.catch((error): LoadState<DependencyCatalogResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" }))
|
||
: Promise.resolve(
|
||
runtimeActions.status === "error"
|
||
? { status: "error", reason: runtimeActions.reason }
|
||
: runtimeActions.status === "ready"
|
||
? { status: "error", reason: dependencyActionReason }
|
||
: { status: "loading" }
|
||
);
|
||
const [catalog, updates] = await Promise.all([
|
||
catalogRequest,
|
||
platformApiClient
|
||
.listRunUpdates(instance.id)
|
||
.then((data): LoadState<RunUpdateJobResponse[]> => ({ status: "ready", data: data.items }))
|
||
.catch((error): LoadState<RunUpdateJobResponse[]> => ({ status: "error", reason: error instanceof Error ? error.message : "Run 更新状态加载失败" }))
|
||
]);
|
||
setDependencyCatalog(catalog);
|
||
setRunUpdates(updates);
|
||
}, [instance.id, runtimeActions]);
|
||
|
||
useEffect(() => {
|
||
void refreshRuntimeProjections();
|
||
}, [refreshRuntimeProjections]);
|
||
|
||
useEffect(() => {
|
||
if (dependencyCatalog.status !== "ready") return;
|
||
const selectedProbe = dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) ?? dependencyCatalog.data.probes[0];
|
||
if (selectedProbe && selectedProbe.key !== probeKey) setProbeKey(selectedProbe.key);
|
||
const matchingPlan = dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey)
|
||
?? dependencyCatalog.data.plans.find((plan) => plan.key === selectedProbe?.installPlanKey)
|
||
?? dependencyCatalog.data.plans[0];
|
||
if (matchingPlan && matchingPlan.key !== installPlanKey) setInstallPlanKey(matchingPlan.key);
|
||
}, [dependencyCatalog, installPlanKey, probeKey]);
|
||
|
||
const selectedDependencyProbe = dependencyCatalog.status === "ready" ? dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) : undefined;
|
||
const selectedDependencyPlan = dependencyCatalog.status === "ready" ? dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey) : undefined;
|
||
const latestRunUpdate = runUpdates.status === "ready" ? runUpdates.data[0] : undefined;
|
||
|
||
const actionByKey = useMemo(() => {
|
||
if (runtimeActions.status !== "ready") {
|
||
return new Map<string, { available: boolean; reason?: string }>();
|
||
}
|
||
return new Map(runtimeActions.data.actions.map((action) => [action.key, { available: action.available, reason: action.reason }]));
|
||
}, [runtimeActions]);
|
||
|
||
function canUse(key: string): boolean {
|
||
return actionByKey.get(key)?.available ?? false;
|
||
}
|
||
|
||
function reasonFor(key: string): string {
|
||
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
|
||
}
|
||
|
||
async function runOperation<T>(
|
||
intent: string,
|
||
execute: () => Promise<T>,
|
||
summarize: (value: T) => string,
|
||
taskOptions?: {
|
||
description: string;
|
||
stages: RuntimeTaskStage[];
|
||
executeStageIndex?: number;
|
||
trackedJobId?: (value: T) => string;
|
||
afterSuccess?: (value: T) => void;
|
||
}
|
||
) {
|
||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
|
||
setRuntimeTaskActions([]);
|
||
setResult({ status: "pending", label: `${intent} 执行中` });
|
||
try {
|
||
const value = taskOptions?.trackedJobId
|
||
? await runtimeTask.runTrackedTask({
|
||
title: intent,
|
||
description: taskOptions.description,
|
||
stages: taskOptions.stages,
|
||
start: async () => {
|
||
const value = await execute();
|
||
return { value, jobId: taskOptions.trackedJobId?.(value) ?? "" };
|
||
},
|
||
poll: (jobId) => platformApiClient.getJob(jobId)
|
||
})
|
||
: taskOptions
|
||
? await runtimeTask.runTask({
|
||
title: intent,
|
||
description: taskOptions.description,
|
||
stages: taskOptions.stages,
|
||
executeStageIndex: taskOptions.executeStageIndex,
|
||
execute
|
||
})
|
||
: await execute();
|
||
const label = summarize(value);
|
||
operations.succeed(operationId, label);
|
||
setResult({ status: "succeeded", label });
|
||
runtimeTask.succeedTask(label);
|
||
taskOptions?.afterSuccess?.(value);
|
||
void refreshRuntimeProjections();
|
||
onChanged();
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : `${intent} 失败`;
|
||
operations.fail(operationId, reason, operationId);
|
||
setResult({ status: "failed", label: reason });
|
||
runtimeTask.failTask(reason);
|
||
}
|
||
}
|
||
|
||
function latestRunArtifact(): { artifactId: string; checksum?: string } | null {
|
||
if (lastRun) {
|
||
return { artifactId: lastRun.artifactId, checksum: lastRun.checksum };
|
||
}
|
||
if (lastDownload) {
|
||
return { artifactId: lastDownload.artifactId, checksum: lastDownload.checksum };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function downloadRunArtifact(artifact: { artifactId: string; checksum?: string }) {
|
||
setRuntimeTaskActions([]);
|
||
try {
|
||
const label = await runtimeTask.runTask({
|
||
title: "下载 run",
|
||
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}。`,
|
||
stages: runtimeDownloadStages,
|
||
executeStageIndex: 1,
|
||
execute: async () => {
|
||
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
|
||
setLastDownload(reference);
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
|
||
}
|
||
});
|
||
runtimeTask.succeedTask(label);
|
||
} catch (error) {
|
||
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
|
||
}
|
||
}
|
||
|
||
async function pushRunArtifact(artifact: { artifactId: string; checksum?: string }) {
|
||
setRuntimeTaskActions([]);
|
||
await runOperation(
|
||
"更新 run",
|
||
() => platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)),
|
||
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
|
||
{
|
||
description: `将 artifact ${artifact.artifactId} 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
|
||
stages: runtimeUpdateStages,
|
||
executeStageIndex: 2
|
||
}
|
||
);
|
||
}
|
||
|
||
async function downloadClientArtifact(profileKeyForDownload: string) {
|
||
setRuntimeTaskActions([]);
|
||
try {
|
||
const label = await runtimeTask.runTask({
|
||
title: "下载客户端",
|
||
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
|
||
stages: runtimeDownloadStages,
|
||
executeStageIndex: 1,
|
||
execute: async () => {
|
||
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey: profileKeyForDownload });
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
|
||
}
|
||
});
|
||
runtimeTask.succeedTask(label);
|
||
} catch (error) {
|
||
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article id="run-builder" className="console-panel" aria-label="run distribution controls" tabIndex={-1}>
|
||
<div className="panel-header">
|
||
<h2>
|
||
<PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 运行分发
|
||
</h2>
|
||
{runtimeActions.status === "ready" ? (
|
||
<span className={cx("status-pill", runtimeActions.data.runStatus === "online" ? "status-active" : "status-disabled")}>
|
||
run {runtimeActions.data.runStatus}
|
||
</span>
|
||
) : runtimeActions.status === "error" ? (
|
||
<ResultBadge status="failed" label={runtimeActions.reason} />
|
||
) : (
|
||
<span className="page-status">读取中</span>
|
||
)}
|
||
</div>
|
||
{result && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<ResultBadge status={result.status} label={result.label} />
|
||
</div>
|
||
)}
|
||
<div className="provider-form" style={{ marginBottom: 12 }}>
|
||
<div className="form-grid">
|
||
<label>
|
||
run 平台
|
||
<select value={targetOs} onChange={(event) => setTargetOs(event.target.value)}>
|
||
<option value="linux">linux</option>
|
||
<option value="windows">windows</option>
|
||
<option value="darwin">darwin</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
架构
|
||
<select value={targetArch} onChange={(event) => setTargetArch(event.target.value)}>
|
||
<option value="amd64">amd64</option>
|
||
<option value="arm64">arm64</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
客户端 profile
|
||
<input value={profileKey} onChange={(event) => setProfileKey(event.target.value)} />
|
||
</label>
|
||
<label>
|
||
源仓库
|
||
<input value={repositoryUrl} onChange={(event) => setRepositoryUrl(event.target.value)} />
|
||
</label>
|
||
<label>
|
||
revision
|
||
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} />
|
||
</label>
|
||
<label>
|
||
依赖 probe
|
||
<select value={probeKey} disabled={dependencyCatalog.status !== "ready" || dependencyCatalog.data.probes.length === 0} onChange={(event) => setProbeKey(event.target.value)}>
|
||
{dependencyCatalog.status === "ready" && dependencyCatalog.data.probes.map((probe) => (
|
||
<option key={probe.key} value={probe.key}>{probe.key} · {probe.state}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
安装 plan
|
||
<select value={installPlanKey} disabled={dependencyCatalog.status !== "ready" || dependencyCatalog.data.plans.length === 0} onChange={(event) => setInstallPlanKey(event.target.value)}>
|
||
{dependencyCatalog.status === "ready" && dependencyCatalog.data.plans.map((plan) => (
|
||
<option key={plan.key} value={plan.key}>{plan.title} · {plan.targetOs}/{plan.targetArch}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
日志源
|
||
<input value={logSourceKey} onChange={(event) => setLogSourceKey(event.target.value)} />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="plugin-group-body">
|
||
<RuntimeActionRow
|
||
title="run 包"
|
||
description={`生成 ${targetOs}/${targetArch} run。包内含当前密钥,界面只显示 generation、artifact 和 secret ref。`}
|
||
disabled={!canUse("generate-run")}
|
||
reason={reasonFor("generate-run")}
|
||
actionLabel="生成 run"
|
||
onAction={() =>
|
||
void runOperation(
|
||
"生成 run",
|
||
async () => {
|
||
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
|
||
setLastRun(distribution);
|
||
return distribution;
|
||
},
|
||
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建,artifact ${distribution.artifactId},generation ${distribution.keyGeneration}`,
|
||
{
|
||
description: `为 ${instance.name} 构建 ${targetOs}/${targetArch} run 包,展示拉取 run 更新、检测构建环境、构建中和构建完成进度。`,
|
||
stages: runtimeRunBuildStages,
|
||
trackedJobId: (distribution) => distribution.buildJobId,
|
||
afterSuccess: (distribution) => {
|
||
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
|
||
setRuntimeTaskActions([
|
||
{ label: "下载 run", kind: "primary", onClick: () => void downloadRunArtifact(artifact) },
|
||
{ label: "更新 run", disabled: !serverIsOnline(instance.state), title: serverIsOnline(instance.state) ? "更新 run" : "run 未运行,无法在线更新", onClick: () => void pushRunArtifact(artifact) }
|
||
]);
|
||
}
|
||
}
|
||
)
|
||
}
|
||
/>
|
||
<RuntimeActionRow
|
||
title="run 下载与更新"
|
||
description={latestRunUpdate ? `最近更新 ${latestRunUpdate.targetOs}/${latestRunUpdate.targetArch} · ${runUpdatePhaseLabel(latestRunUpdate.phase)}` : lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
|
||
disabled={!canUse("download-run")}
|
||
reason={reasonFor("download-run")}
|
||
actionLabel="下载 run"
|
||
onAction={() =>
|
||
void runOperation(
|
||
"下载 run",
|
||
async () => {
|
||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||
setLastDownload(reference);
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||
return reference;
|
||
},
|
||
(reference) => `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`,
|
||
{
|
||
description: `为 ${instance.name} 创建最新 run 包下载引用,并展示 artifact 定位进度。`,
|
||
stages: runtimeDownloadStages,
|
||
executeStageIndex: 1
|
||
}
|
||
)
|
||
}
|
||
secondaryLabel="更新 run"
|
||
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null || !serverIsOnline(instance.state)}
|
||
secondaryReason={!serverIsOnline(instance.state) ? "run 未运行,无法在线更新" : latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
|
||
onSecondary={() =>
|
||
void runOperation(
|
||
"更新 run",
|
||
async () => {
|
||
const artifact = latestRunArtifact();
|
||
if (!artifact) {
|
||
throw new Error("请先生成或下载 run 包");
|
||
}
|
||
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
|
||
},
|
||
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
|
||
{
|
||
description: `将最近 run artifact 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
|
||
stages: runtimeUpdateStages,
|
||
executeStageIndex: 2
|
||
}
|
||
)
|
||
}
|
||
>
|
||
{latestRunUpdate && (
|
||
<div className="tag-list" aria-label="latest Run update status">
|
||
<span className={cx("status-pill", latestRunUpdate.phase === "succeeded" ? "status-active" : latestRunUpdate.phase === "failed" || latestRunUpdate.phase === "rolled-back" ? "status-disabled" : "status-pending")}>
|
||
phase {latestRunUpdate.phase}
|
||
</span>
|
||
<span className="provider-id" title={latestRunUpdate.checksum}>checksum {shortChecksum(latestRunUpdate.checksum)}</span>
|
||
<span className="provider-id">release {latestRunUpdate.targetRelease ?? "pending"}</span>
|
||
<span className="provider-id">rollback {latestRunUpdate.rollback ? "yes" : "no"}</span>
|
||
{latestRunUpdate.message && <span className="provider-id">audit {latestRunUpdate.message}</span>}
|
||
</div>
|
||
)}
|
||
{runUpdates.status === "error" && <ResultBadge status="failed" label={runUpdates.reason} />}
|
||
</RuntimeActionRow>
|
||
<RuntimeActionRow
|
||
title="run 密钥"
|
||
description="重置后旧 run 包会失效,必须重新生成并重新部署。"
|
||
disabled={!canUse("reset-run-key")}
|
||
reason={reasonFor("reset-run-key")}
|
||
actionLabel="重置 run 密钥"
|
||
danger
|
||
onAction={() =>
|
||
void runOperation(
|
||
"重置 run 密钥",
|
||
() => platformApiClient.resetRunKey(instance.id),
|
||
(key) => `run 密钥已重置,generation ${key.generation},fingerprint ${key.fingerprint}`
|
||
)
|
||
}
|
||
/>
|
||
<RuntimeActionRow
|
||
title="客户端管理器"
|
||
description={lastClient ? `最近构建 ${lastClient.artifactId},generation ${lastClient.keyGeneration}` : "按插件声明的 profile 构建客户端管理器,使用独立密钥。"}
|
||
disabled={!canUse("generate-client-manager")}
|
||
reason={reasonFor("generate-client-manager")}
|
||
actionLabel="生成客户端"
|
||
onAction={() =>
|
||
void runOperation(
|
||
"生成客户端管理器",
|
||
async () => {
|
||
const distribution = await platformApiClient.generateClientManager(
|
||
instance.id,
|
||
clientManagerBuildRequest({ serverInstanceId: instance.id, profileKey, targetOs, targetArch, repositoryUrl, sourceRevision })
|
||
);
|
||
setLastClient(distribution);
|
||
return distribution;
|
||
},
|
||
(distribution) => `客户端管理器二进制已构建,artifact ${distribution.artifactId},组件密钥仅由 Platform/Run 受控使用`,
|
||
{
|
||
description: `按 ${profileKey} profile 拉取客户端代码、安装环境、编译并生成可下载 artifact。`,
|
||
stages: runtimeBuildStages,
|
||
trackedJobId: (distribution) => distribution.buildJobId,
|
||
afterSuccess: () => {
|
||
setRuntimeTaskActions([{ label: "下载客户端", kind: "primary", onClick: () => void downloadClientArtifact(profileKey) }]);
|
||
}
|
||
}
|
||
)
|
||
}
|
||
secondaryLabel="下载客户端"
|
||
secondaryDisabled={!canUse("download-client-manager")}
|
||
secondaryReason={reasonFor("download-client-manager")}
|
||
onSecondary={() =>
|
||
void runOperation(
|
||
"下载客户端管理器",
|
||
async () => {
|
||
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||
return reference;
|
||
},
|
||
(reference) => `客户端下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`
|
||
)
|
||
}
|
||
/>
|
||
<RuntimeActionRow
|
||
title="客户端密钥"
|
||
description="客户端管理器和 run 使用不同密钥。重置后旧客户端必须重新生成。"
|
||
disabled={!canUse("reset-client-manager-key")}
|
||
reason={reasonFor("reset-client-manager-key")}
|
||
actionLabel="重置客户端密钥"
|
||
danger
|
||
onAction={() =>
|
||
void runOperation(
|
||
"重置客户端密钥",
|
||
() => platformApiClient.resetClientManagerKey(instance.id, { componentKind: "client-manager", componentKey: profileKey }),
|
||
(key) => `客户端密钥已重置,generation ${key.generation},fingerprint ${key.fingerprint}`
|
||
)
|
||
}
|
||
/>
|
||
<RuntimeActionRow
|
||
title="依赖"
|
||
description={dependencyCatalog.status === "ready" ? `${dependencyCatalog.data.pluginId}@${dependencyCatalog.data.pluginVersion} · ${dependencyCatalog.data.profileKey} · ${dependencyCatalog.data.targetOs}/${dependencyCatalog.data.targetArch}` : "正在读取 Platform 审核后的依赖目录"}
|
||
disabled={!canUse("dependencies-check") || dependencyCatalog.status !== "ready" || !selectedDependencyProbe}
|
||
reason={dependencyCatalog.status === "error" ? dependencyCatalog.reason : dependencyCatalog.status !== "ready" || !selectedDependencyProbe ? "依赖目录尚未就绪" : reasonFor("dependencies-check")}
|
||
actionLabel="依赖检查"
|
||
onAction={() =>
|
||
void runOperation(
|
||
"依赖检查",
|
||
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
|
||
(job) => `依赖检查任务已排队,job ${job.id}`,
|
||
{
|
||
description: `使用 ${probeKey} probe 检查 ${instance.name} 的运行依赖。`,
|
||
stages: runtimeDependencyStages,
|
||
executeStageIndex: 1
|
||
}
|
||
)
|
||
}
|
||
secondaryLabel="依赖安装"
|
||
secondaryDisabled={!canUse("dependencies-install") || !selectedDependencyPlan || selectedDependencyProbe?.installPlanKey !== selectedDependencyPlan.key}
|
||
secondaryReason={!selectedDependencyPlan ? "请选择 Platform 返回的审核计划" : selectedDependencyProbe?.installPlanKey !== selectedDependencyPlan.key ? "所选计划不属于当前 probe" : reasonFor("dependencies-install")}
|
||
onSecondary={() =>
|
||
void runOperation(
|
||
"依赖安装",
|
||
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey, selectedDependencyPlan?.digest ?? "")),
|
||
(job) => `依赖安装任务已排队,job ${job.id}`,
|
||
{
|
||
description: `审批 ${installPlanKey} 的 immutable digest ${shortChecksum(selectedDependencyPlan?.digest ?? "")} 后派发依赖安装任务。`,
|
||
stages: runtimeDependencyStages,
|
||
executeStageIndex: 2
|
||
}
|
||
)
|
||
}
|
||
>
|
||
{selectedDependencyProbe && (
|
||
<div className="tag-list" aria-label="dependency status and approved plan">
|
||
<span className={cx("status-pill", selectedDependencyProbe.state === "present" ? "status-active" : selectedDependencyProbe.state === "failed" ? "status-disabled" : "status-pending")}>
|
||
{selectedDependencyProbe.key} · {selectedDependencyProbe.state}
|
||
</span>
|
||
<span className="provider-id">required {selectedDependencyProbe.required ? "yes" : "no"}</span>
|
||
{selectedDependencyProbe.evidence && <span className="provider-id">evidence {selectedDependencyProbe.evidence}</span>}
|
||
{selectedDependencyPlan && <span className="provider-id" title={selectedDependencyPlan.digest}>digest {shortChecksum(selectedDependencyPlan.digest)}</span>}
|
||
{selectedDependencyPlan && <span className="provider-id">steps {selectedDependencyPlan.steps.map((step) => `${step.type}:${step.packageManager ?? step.downloadHost ?? step.targetKey}`).join(" → ")}</span>}
|
||
</div>
|
||
)}
|
||
{dependencyCatalog.status === "error" && <ResultBadge status="failed" label={dependencyCatalog.reason} />}
|
||
</RuntimeActionRow>
|
||
<RuntimeActionRow
|
||
title="日志"
|
||
description="实时日志来自平台日志 API,历史日志通过 backfill job 返回 cursor/ref。"
|
||
disabled={!canUse("live-logs")}
|
||
reason={reasonFor("live-logs")}
|
||
actionLabel="实时日志"
|
||
onAction={() =>
|
||
void runOperation(
|
||
"实时日志",
|
||
async () => {
|
||
onOpenLogs();
|
||
return true;
|
||
},
|
||
() => "已打开实时日志视图",
|
||
{
|
||
description: `读取 ${instance.name} 的平台日志源并打开实时日志视图。`,
|
||
stages: runtimeLogStages,
|
||
executeStageIndex: 1
|
||
}
|
||
)
|
||
}
|
||
secondaryLabel="历史回填"
|
||
secondaryDisabled={!canUse("historical-logs")}
|
||
secondaryReason={reasonFor("historical-logs")}
|
||
onSecondary={() =>
|
||
void runOperation(
|
||
"历史日志回填",
|
||
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
|
||
(job) => `历史日志回填任务已排队,job ${job.id}`,
|
||
{
|
||
description: `从 ${logSourceKey} 日志源准备历史回填游标并派发后台 job。`,
|
||
stages: runtimeLogStages,
|
||
executeStageIndex: 1
|
||
}
|
||
)
|
||
}
|
||
>
|
||
<label>
|
||
checkpoint ref
|
||
<input value={checkpointRef} placeholder="可选 artifact://logs/checkpoint/..." onChange={(event) => setCheckpointRef(event.target.value)} />
|
||
</label>
|
||
</RuntimeActionRow>
|
||
</div>
|
||
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface RuntimeActionRowProps {
|
||
title: string;
|
||
description: string;
|
||
disabled: boolean;
|
||
reason: string;
|
||
actionLabel: string;
|
||
danger?: boolean;
|
||
onAction: () => void;
|
||
secondaryLabel?: string;
|
||
secondaryDisabled?: boolean;
|
||
secondaryReason?: string;
|
||
onSecondary?: () => void;
|
||
children?: ReactNode;
|
||
}
|
||
|
||
function RuntimeActionRow({ title, description, disabled, reason, actionLabel, danger, onAction, secondaryLabel, secondaryDisabled, secondaryReason, onSecondary, children }: RuntimeActionRowProps) {
|
||
return (
|
||
<div className="plugin-control-row">
|
||
<span>
|
||
<strong>{title}</strong>
|
||
<p>{description}</p>
|
||
{disabled && <span className="provider-id">不可用:{reason}</span>}
|
||
{children}
|
||
</span>
|
||
<div className="action-strip">
|
||
<button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : actionLabel} onClick={onAction}>
|
||
<Sparkles size={14} />
|
||
<span>{actionLabel}</span>
|
||
</button>
|
||
{secondaryLabel && onSecondary && (
|
||
<button type="button" className="icon-command" disabled={secondaryDisabled} title={secondaryDisabled ? secondaryReason : secondaryLabel} onClick={onSecondary}>
|
||
<Download size={14} />
|
||
<span>{secondaryLabel}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function runtimeDefaultsForPlugin(pluginId: string) {
|
||
const isScum = pluginId.toLowerCase().includes("scum");
|
||
return {
|
||
runOs: isScum ? "windows" : "linux",
|
||
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
|
||
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
||
sourceRevision: "main",
|
||
probeKey: isScum ? "steamcmd" : "java-21",
|
||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||
logSourceKey: isScum ? "scum-server-events" : "latest-log"
|
||
};
|
||
}
|
||
|
||
function shortChecksum(value: string): string {
|
||
if (!value) return "unavailable";
|
||
return value.length > 22 ? `${value.slice(0, 22)}…` : value;
|
||
}
|
||
|
||
function runUpdatePhaseLabel(phase: RunUpdateJobResponse["phase"]): string {
|
||
switch (phase) {
|
||
case "queued": return "等待下载";
|
||
case "downloading": return "分块下载与校验";
|
||
case "staged": return "已安全暂存";
|
||
case "restart-requested": return "等待重启激活";
|
||
case "activating": return "激活与健康确认";
|
||
case "succeeded": return "更新成功";
|
||
case "rolled-back": return "已回滚";
|
||
case "failed": return "更新失败";
|
||
}
|
||
}
|
||
|
||
interface LogsSectionProps {
|
||
serverId: string;
|
||
}
|
||
|
||
interface LogFilterState {
|
||
level: string;
|
||
keyword: string;
|
||
source: string;
|
||
sinceMinutes: string;
|
||
}
|
||
|
||
function LogsSection({ serverId }: LogsSectionProps) {
|
||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
||
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
|
||
const [selected, setSelected] = useState<LiveLogEntry | null>(null);
|
||
const [eventSourceKey, setEventSourceKey] = useState(0);
|
||
|
||
const refresh = useCallback(() => setEventSourceKey((current) => current + 1), []);
|
||
|
||
useEffect(() => {
|
||
setStreams({ status: "loading" });
|
||
setEntries([]);
|
||
setSelected(null);
|
||
let ready = false;
|
||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: 200 });
|
||
events.addEventListener("stream", (event) => {
|
||
const stream = parseLogStreamEvent(event);
|
||
if (!stream) return;
|
||
ready = true;
|
||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||
});
|
||
events.addEventListener("ready", () => {
|
||
ready = true;
|
||
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||
});
|
||
events.addEventListener("log", (event) => {
|
||
const payload = parseServerLogEvent(event);
|
||
if (!payload) return;
|
||
ready = true;
|
||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
|
||
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], 1000));
|
||
});
|
||
events.onerror = () => {
|
||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||
};
|
||
return () => events.close();
|
||
}, [eventSourceKey, serverId]);
|
||
|
||
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
|
||
|
||
const visible = useMemo(() => {
|
||
const keyword = filter.keyword.trim().toLowerCase();
|
||
const sinceMs = filter.sinceMinutes === "all" ? null : Date.now() - Number(filter.sinceMinutes) * 60_000;
|
||
return entries.filter((entry) => {
|
||
if (filter.level !== "all" && (entry.level ?? "info").toLowerCase() !== filter.level) {
|
||
return false;
|
||
}
|
||
if (filter.source !== "all" && entry.source !== filter.source) {
|
||
return false;
|
||
}
|
||
if (keyword && !entry.line.toLowerCase().includes(keyword)) {
|
||
return false;
|
||
}
|
||
if (sinceMs !== null && new Date(entry.timestamp).getTime() < sinceMs) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}).sort(compareLogEntriesDesc);
|
||
}, [entries, filter]);
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="server logs">
|
||
<div className="panel-header">
|
||
<h2>日志</h2>
|
||
<button type="button" className="icon-command" onClick={refresh}>
|
||
<Sparkles size={14} />
|
||
<span>重连</span>
|
||
</button>
|
||
</div>
|
||
<div className="log-filter-bar">
|
||
<select value={filter.level} aria-label="按级别过滤" onChange={(event) => setFilter((current) => ({ ...current, level: event.target.value }))}>
|
||
<option value="all">全部级别</option>
|
||
<option value="error">error</option>
|
||
<option value="warn">warn</option>
|
||
<option value="info">info</option>
|
||
<option value="debug">debug</option>
|
||
</select>
|
||
<select value={filter.source} aria-label="按来源过滤" onChange={(event) => setFilter((current) => ({ ...current, source: event.target.value }))}>
|
||
<option value="all">全部来源</option>
|
||
{sources.map((source) => (
|
||
<option key={source} value={source}>
|
||
{source}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={filter.sinceMinutes}
|
||
aria-label="按时间过滤"
|
||
onChange={(event) => setFilter((current) => ({ ...current, sinceMinutes: event.target.value }))}
|
||
>
|
||
<option value="all">全部时间</option>
|
||
<option value="15">最近 15 分钟</option>
|
||
<option value="60">最近 1 小时</option>
|
||
<option value="1440">最近 24 小时</option>
|
||
</select>
|
||
<input
|
||
type="search"
|
||
value={filter.keyword}
|
||
placeholder="关键字"
|
||
aria-label="按关键字过滤"
|
||
onChange={(event) => setFilter((current) => ({ ...current, keyword: event.target.value }))}
|
||
/>
|
||
</div>
|
||
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
|
||
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={refresh} compact />}
|
||
{streams.status === "ready" && entries.length === 0 && (
|
||
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="重连" onAction={refresh} />
|
||
)}
|
||
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
|
||
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
|
||
)}
|
||
{visible.length > 0 && (
|
||
<div className="log-list" role="list">
|
||
{visible.map((entry) => (
|
||
<button key={`${entry.streamId}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
|
||
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
|
||
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
|
||
<span>{entry.line}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{selected && (
|
||
<div className="drawer-backdrop" role="presentation" onClick={() => setSelected(null)}>
|
||
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="日志详情" onClick={(event) => event.stopPropagation()}>
|
||
<div className="panel-header">
|
||
<h2>日志详情</h2>
|
||
<button type="button" className="drawer-close" onClick={() => setSelected(null)}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
<dl className="detail-list">
|
||
<div>
|
||
<dt>时间</dt>
|
||
<dd>{new Date(selected.timestamp).toLocaleString()}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>级别</dt>
|
||
<dd>{(selected.level ?? "info").toUpperCase()}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>来源</dt>
|
||
<dd>{selected.source}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>序号</dt>
|
||
<dd>{selected.seq}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>内容</dt>
|
||
<dd>{selected.line}</dd>
|
||
</div>
|
||
{selected.fields && Object.keys(selected.fields).length > 0 && (
|
||
<div>
|
||
<dt>字段</dt>
|
||
<dd>
|
||
{Object.entries(selected.fields)
|
||
.map(([key, value]) => `${key}=${value}`)
|
||
.join(" ")}
|
||
</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
<DiagnosticSummary diagnosticId={`log:${serverId}:${selected.source}:${selected.seq}`} detail={selected.line} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
function compareLogEntriesDesc(a: LiveLogEntry, b: LiveLogEntry): number {
|
||
const time = (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0);
|
||
if (time !== 0) return time;
|
||
return b.seq - a.seq;
|
||
}
|
||
|
||
interface ConfigSectionProps {
|
||
serverId: string;
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
}
|
||
|
||
function ConfigSection({ serverId, instance, session, operations }: ConfigSectionProps) {
|
||
const [config, setConfig] = useState<LoadState<{ content: string; source: "api" | "local" }>>({ status: "loading" });
|
||
const [draft, setDraft] = useState("");
|
||
const [diff, setDiff] = useState<ConfigDiffView | null>(null);
|
||
const [previewBusy, setPreviewBusy] = useState(false);
|
||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||
|
||
const refresh = useCallback(async () => {
|
||
setConfig({ status: "loading" });
|
||
try {
|
||
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
|
||
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
|
||
setDraft(response.content);
|
||
} catch (error) {
|
||
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
|
||
setDraft("");
|
||
}
|
||
}, [serverId]);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
async function prepareDiff(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (config.status !== "ready") {
|
||
return;
|
||
}
|
||
setPreviewBusy(true);
|
||
setPreviewError(null);
|
||
try {
|
||
const preview = await platformApiClient.previewServerConfigDiff(serverId, {
|
||
expectedConfigVersion: instance.configVersion,
|
||
expectedChecksum: instance.configChecksum,
|
||
key: defaultConfigKey,
|
||
proposedContent: draft
|
||
});
|
||
setDiff(configDiffViewFromPreview(preview));
|
||
} catch (error) {
|
||
setPreviewError(error instanceof Error ? error.message : "配置差异预览失败");
|
||
} finally {
|
||
setPreviewBusy(false);
|
||
}
|
||
}
|
||
|
||
async function submitDiff() {
|
||
if (!diff || config.status !== "ready") {
|
||
return;
|
||
}
|
||
const operationId = operations.begin({ intent: "写入配置", targetKind: "config", targetId: serverId, requester: session.displayName });
|
||
try {
|
||
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
|
||
expectedConfigVersion: diff.configVersion ?? instance.configVersion,
|
||
expectedChecksum: diff.checksum ?? instance.configChecksum,
|
||
key: diff.key ?? defaultConfigKey,
|
||
proposedContent: diff.nextContent,
|
||
proposedContentInputRef: diff.proposedContentInputRef,
|
||
idempotencyKey: `web:config.write:${serverId}:${Date.now()}`
|
||
});
|
||
const job = dispatch.job;
|
||
operations.succeed(operationId, `配置写入任务 ${job.id} 已派发`, job);
|
||
setDiff(null);
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "配置写入任务派发失败", operationId);
|
||
}
|
||
}
|
||
|
||
const writeOperation = operations.operations.find((operation) => operation.intent === "写入配置" && operation.targetId === serverId);
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="server configuration">
|
||
<div className="panel-header">
|
||
<h2>配置</h2>
|
||
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}{instance.configChecksum ? ` · ${instance.configChecksum.slice(0, 18)}` : ""}</span>}
|
||
</div>
|
||
{writeOperation && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<ResultBadge
|
||
status={writeOperation.status}
|
||
label={
|
||
writeOperation.status === "pending"
|
||
? "配置写入中…"
|
||
: writeOperation.status === "succeeded"
|
||
? (writeOperation.message ?? "写入任务已派发")
|
||
: `写入失败:${writeOperation.errorReason}(诊断 ${writeOperation.diagnosticId})`
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
|
||
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
|
||
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
|
||
{config.status === "ready" && (
|
||
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
|
||
<label>
|
||
配置内容
|
||
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} rows={10} aria-label="配置编辑器" />
|
||
</label>
|
||
<button type="submit" className="primary-command" disabled={previewBusy || draft === config.data.content}>
|
||
{previewBusy ? "预览中…" : "预览变更"}
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{diff && (
|
||
<div className="drawer-backdrop" role="presentation" onClick={() => setDiff(null)}>
|
||
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="配置变更确认" onClick={(event) => event.stopPropagation()}>
|
||
<div className="panel-header">
|
||
<h2>确认配置变更</h2>
|
||
<span className="page-status">{diff.summary}</span>
|
||
</div>
|
||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>
|
||
目标服务器:<strong>{instance.name}</strong>({serverId})。请检查平台返回的差异,确认后才会派发写入任务。
|
||
</p>
|
||
<DiffView lines={diff.lines} />
|
||
<div className="confirm-actions">
|
||
<button type="button" onClick={() => setDiff(null)}>
|
||
取消
|
||
</button>
|
||
<button type="button" className="confirm-primary" disabled={!diffHasChanges(diff)} onClick={() => void submitDiff()}>
|
||
确认并派发写入任务
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface PluginControlsSectionProps {
|
||
serverId: string;
|
||
instance: ServerInstanceResponse;
|
||
plugins: GamePluginResponse[];
|
||
artifacts: ArtifactResponse[];
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
onNavigate: PageComponentProps["onNavigate"];
|
||
}
|
||
|
||
function controlsForPlugin(plugin: GamePluginResponse): PluginControlDescriptor[] {
|
||
const controls: PluginControlDescriptor[] = [];
|
||
for (const [action] of Object.entries(plugin.lifecycleActions)) {
|
||
if (action === "install" || action === "restart") {
|
||
continue;
|
||
}
|
||
if (action !== "start" && action !== "stop" && action !== "status") {
|
||
continue;
|
||
}
|
||
controls.push({
|
||
key: `lifecycle:${action}`,
|
||
label: lifecycleControlLabel(action),
|
||
description: `通过平台生命周期 API 执行插件声明的 ${action} 动作`,
|
||
capability: `process.${action}`,
|
||
lifecycleAction: action,
|
||
dangerous: action === "stop"
|
||
});
|
||
}
|
||
for (const bridgeAction of plugin.bridgeActions) {
|
||
if (bridgeAction === "jobs.dispatch") {
|
||
controls.push({
|
||
key: "bridge:gift",
|
||
label: "发送礼物",
|
||
description: "通过插件任务向在线玩家发放礼物",
|
||
capability: "plugin.gift.send",
|
||
dangerous: false
|
||
});
|
||
controls.push({
|
||
key: "bridge:activity",
|
||
label: "调整活动",
|
||
description: "修改插件当前的活动配置",
|
||
capability: "plugin.activity.update",
|
||
dangerous: false
|
||
});
|
||
}
|
||
if (bridgeAction === "logs.query") {
|
||
controls.push({
|
||
key: "bridge:module-restart",
|
||
label: "重启插件模块",
|
||
description: "重启该插件在此服务器上的运行模块",
|
||
capability: "plugin.module.restart",
|
||
dangerous: true
|
||
});
|
||
}
|
||
}
|
||
const unique = new Map(controls.map((control) => [control.key, control]));
|
||
return [...unique.values()];
|
||
}
|
||
|
||
function lifecycleControlLabel(action: string): string {
|
||
switch (action) {
|
||
case "start":
|
||
return "启动进程";
|
||
case "stop":
|
||
return "停止进程";
|
||
case "status":
|
||
return "查询进程";
|
||
case "restart":
|
||
return "重启进程";
|
||
default:
|
||
return action;
|
||
}
|
||
}
|
||
|
||
function PluginControlsSection({ serverId, instance, plugins, artifacts, session, operations, onNavigate }: PluginControlsSectionProps) {
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||
const [confirmControl, setConfirmControl] = useState<null | { plugin: PluginControlGroupView; control: PluginControlDescriptor }>(null);
|
||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||
|
||
const groups = useMemo<PluginControlGroupView[]>(() => {
|
||
const installed = plugins.filter((plugin) => plugin.id === instance.pluginId || plugin.status === "installed");
|
||
const relevant = installed.some((plugin) => plugin.id === instance.pluginId)
|
||
? installed.filter((plugin) => plugin.id === instance.pluginId)
|
||
: installed;
|
||
return relevant.map((plugin) => ({
|
||
pluginId: plugin.id,
|
||
pluginName: pluginLabel(plugin, plugin.id),
|
||
version: plugin.version,
|
||
status: plugin.status,
|
||
controls: controlsForPlugin(plugin)
|
||
}));
|
||
}, [plugins, instance.pluginId]);
|
||
|
||
function toggleGroup(pluginId: string) {
|
||
setCollapsed((current) => {
|
||
const next = new Set(current);
|
||
if (next.has(pluginId)) {
|
||
next.delete(pluginId);
|
||
} else {
|
||
next.add(pluginId);
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function dispatchControl(group: PluginControlGroupView, control: PluginControlDescriptor) {
|
||
const operationId = operations.begin({
|
||
intent: control.label,
|
||
targetKind: "plugin",
|
||
targetId: `${serverId}:${group.pluginId}`,
|
||
requester: session.displayName
|
||
});
|
||
try {
|
||
if (control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status") {
|
||
const result =
|
||
control.lifecycleAction === "start"
|
||
? await platformApiClient.startServerInstance(instance.id, serverLifecycleCommandRequest(instance, "start"))
|
||
: control.lifecycleAction === "stop"
|
||
? await platformApiClient.stopServerInstance(instance.id, serverLifecycleCommandRequest(instance, "stop"))
|
||
: await platformApiClient.queryServerProcessStatus(instance.id, serverLifecycleCommandRequest(instance, "status"));
|
||
operations.succeed(operationId, `平台生命周期任务 ${result.job.id} 已派发(${result.job.capability})`, result.job);
|
||
return;
|
||
}
|
||
const job = await platformApiClient.createJob({
|
||
id: `job-${control.capability.replaceAll(".", "-")}-${serverId}-${Date.now()}`,
|
||
serverInstanceId: serverId,
|
||
runEndpointId: instance.runEndpointId,
|
||
capability: control.capability,
|
||
idempotencyKey: `web:${control.capability}:${serverId}:${group.pluginId}:${Date.now()}`
|
||
});
|
||
operations.succeed(operationId, `任务 ${job.id} 已派发(${control.capability})`, job);
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "插件操作派发失败", operationId);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="console-record-list" aria-label="plugin controls">
|
||
{groups.length === 0 && (
|
||
<EmptyState title="该服务器没有可控制的插件" description="安装插件后,这里会按插件分组显示可用的日常操作。" />
|
||
)}
|
||
{groups.map((group) => {
|
||
const isCollapsed = collapsed.has(group.pluginId);
|
||
return (
|
||
<article key={group.pluginId} className="plugin-group">
|
||
<button type="button" className="plugin-group-header" aria-expanded={!isCollapsed} onClick={() => toggleGroup(group.pluginId)}>
|
||
<span>
|
||
<strong>{group.pluginName}</strong>
|
||
<span className="provider-id">
|
||
{group.pluginId}@{group.version} · 作用于 {serverId}
|
||
</span>
|
||
</span>
|
||
{isCollapsed ? <ChevronRight size={18} /> : <ChevronDown size={18} />}
|
||
</button>
|
||
{!isCollapsed && (
|
||
<div className="plugin-group-body">
|
||
{plugins.find((plugin) => plugin.id === group.pluginId) && (
|
||
<>
|
||
<PluginLifecycleWorkbench pluginId={group.pluginId} pluginName={group.pluginName} operations={plugins.find((plugin) => plugin.id === group.pluginId)?.productionLifecycle?.operations} serverId={serverId} />
|
||
<PluginBridgeExecutionPanel
|
||
plugin={plugins.find((plugin) => plugin.id === group.pluginId)!}
|
||
serverId={serverId}
|
||
serverInstance={instance}
|
||
artifacts={artifacts}
|
||
onNavigate={onNavigate}
|
||
/>
|
||
</>
|
||
)}
|
||
{group.controls.length === 0 && <span className="provider-id">该插件未声明可用控制项。</span>}
|
||
{group.controls.map((control) => {
|
||
const targetId = `${serverId}:${group.pluginId}`;
|
||
const latest = operations.operations.find((operation) => operation.targetId === targetId && operation.intent === control.label);
|
||
const pending = latest?.status === "pending";
|
||
return (
|
||
<div key={control.key} className="plugin-control-row">
|
||
<span>
|
||
<strong>{control.label}</strong>
|
||
<p>{control.description}</p>
|
||
{latest && (
|
||
<ResultBadge
|
||
status={latest.status}
|
||
label={
|
||
latest.status === "pending"
|
||
? "执行中…"
|
||
: latest.status === "succeeded"
|
||
? (latest.message ?? "已完成")
|
||
: `失败:${latest.errorReason}(诊断 ${latest.diagnosticId})`
|
||
}
|
||
/>
|
||
)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className={cx("icon-command", control.dangerous && "danger-command")}
|
||
disabled={pending}
|
||
onClick={() => setConfirmControl({ plugin: group, control })}
|
||
>
|
||
{pending ? "执行中…" : "执行"}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
})}
|
||
|
||
<ConfirmDialog
|
||
open={confirmControl !== null}
|
||
title={`执行「${confirmControl?.control.label ?? ""}」`}
|
||
description={`目标:服务器 ${serverId} 的插件 ${confirmControl?.plugin.pluginName ?? ""}。该操作只影响当前服务器实例。`}
|
||
confirmLabel="确认执行"
|
||
danger={confirmControl?.control.dangerous}
|
||
busy={confirmBusy}
|
||
onCancel={() => setConfirmControl(null)}
|
||
onConfirm={() => {
|
||
if (!confirmControl) {
|
||
return;
|
||
}
|
||
setConfirmBusy(true);
|
||
void dispatchControl(confirmControl.plugin, confirmControl.control).finally(() => {
|
||
setConfirmBusy(false);
|
||
setConfirmControl(null);
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface PluginBridgeExecutionPanelProps {
|
||
plugin: GamePluginResponse;
|
||
serverId: string;
|
||
serverInstance: ServerInstanceResponse;
|
||
artifacts: ArtifactResponse[];
|
||
onNavigate: PageComponentProps["onNavigate"];
|
||
}
|
||
|
||
function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifacts, onNavigate }: PluginBridgeExecutionPanelProps) {
|
||
const [pendingAction, setPendingAction] = useState<PluginBridgeAction | null>(null);
|
||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||
const declaredPageKey = plugin.gameClientBridge?.pages?.[0]?.pageKey;
|
||
const page = plugin.pages.find((candidate) => candidate.key === declaredPageKey) ?? plugin.pages[0];
|
||
if (!page || plugin.bridgeActions.length === 0) {
|
||
return null;
|
||
}
|
||
const contract: PluginBridgeManifestContract = {
|
||
id: plugin.id,
|
||
declaredPermissions: plugin.declaredPermissions as PluginBridgeManifestContract["declaredPermissions"],
|
||
bridgeActions: plugin.bridgeActions as PluginBridgeAction[],
|
||
pages: plugin.pages.map((item) => ({
|
||
key: item.key,
|
||
title: item.title,
|
||
path: item.path,
|
||
bundleKey: item.bundleKey,
|
||
bundleVersion: item.bundleVersion,
|
||
bundleIntegritySha256: item.bundleIntegritySha256,
|
||
permissions: item.permissions as PluginBridgeManifestContract["declaredPermissions"],
|
||
bridgeActions: item.bridgeActions as PluginBridgeAction[] | undefined
|
||
})),
|
||
aiPurposes: plugin.aiPurposes
|
||
};
|
||
const context = createPluginBridgeHostContext({
|
||
plugin: contract,
|
||
routeKey: page.key,
|
||
serverInstanceId: serverId,
|
||
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
|
||
});
|
||
const executableActions = context.bridgeActions.filter(
|
||
(action) => action === "server.instances.read" || action === "files.request" || action === "artifacts.open" || action === "ai.invoke"
|
||
);
|
||
|
||
async function execute(action: PluginBridgeAction) {
|
||
setPendingAction(action);
|
||
setResult({ status: "pending", label: "桥接请求执行中" });
|
||
const dispatch = createPluginBridgeDispatcher(context, platformApiClient);
|
||
const response = await dispatch({
|
||
requestId: `web:bridge:${serverId}:${plugin.id}:${action}:${Date.now()}`,
|
||
action,
|
||
aiPurpose: action === "ai.invoke" ? plugin.aiPurposes[0] : undefined,
|
||
payload: bridgePayloadForAction(action, serverInstance, artifacts[0])
|
||
});
|
||
setPendingAction(null);
|
||
if (response.status === "ok" || response.status === "queued") {
|
||
setResult({ status: "succeeded", label: bridgeResultLabel(response.status, response.result) });
|
||
return;
|
||
}
|
||
setResult({ status: "failed", label: response.error?.message ?? "桥接执行被拒绝" });
|
||
}
|
||
|
||
return (
|
||
<div className="plugin-control-row" aria-label="plugin bridge execution">
|
||
<span>
|
||
<strong>{page.title} 桥接执行</strong>
|
||
<p>{context.permissions.join(" / ") || "无可用权限"}</p>
|
||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||
</span>
|
||
<div className="action-strip">
|
||
<button
|
||
type="button"
|
||
className="icon-command"
|
||
onClick={() => onNavigate("pluginPage", { pluginId: plugin.id, routeKey: page.key, serverId })}
|
||
title={`打开 ${page.title}`}
|
||
>
|
||
<PackageOpen size={14} />
|
||
<span>打开页面</span>
|
||
</button>
|
||
{executableActions.slice(0, 3).map((action) => (
|
||
<button
|
||
key={action}
|
||
type="button"
|
||
className="icon-command"
|
||
disabled={pendingAction !== null || (action === "artifacts.open" && artifacts.length === 0)}
|
||
onClick={() => void execute(action)}
|
||
title={`执行 ${action}`}
|
||
>
|
||
<Sparkles size={14} />
|
||
<span>{pendingAction === action ? "执行中" : bridgeActionLabel(action)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function bridgePayloadForAction(action: PluginBridgeAction, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record<string, string> | undefined {
|
||
if (action === "files.request") {
|
||
return { operation: "read", key: "logs/latest.log", expectedConfigVersion: String(serverInstance.configVersion) };
|
||
}
|
||
if (action === "artifacts.open" && artifact) {
|
||
return { artifactId: artifact.id };
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function bridgeActionLabel(action: PluginBridgeAction): string {
|
||
switch (action) {
|
||
case "server.instances.read":
|
||
return "读取上下文";
|
||
case "files.request":
|
||
return "请求文件";
|
||
case "ai.invoke":
|
||
return "AI 调用";
|
||
case "artifacts.open":
|
||
return "打开制品";
|
||
default:
|
||
return action;
|
||
}
|
||
}
|
||
|
||
function bridgeResultLabel(status: string, result?: Record<string, string>): string {
|
||
if (status === "queued") {
|
||
return `已派发任务 ${result?.jobId ?? ""}`.trim();
|
||
}
|
||
if (result?.recommendation) {
|
||
return "AI 建议已返回";
|
||
}
|
||
if (parsePluginArtifactReference(result)) {
|
||
return "制品引用已返回";
|
||
}
|
||
return result?.serverInstanceId ? `服务器上下文 ${result.serverInstanceId} 已返回` : "桥接请求已完成";
|
||
}
|
||
|
||
interface LlmSectionProps {
|
||
serverId: string;
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
}
|
||
|
||
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
|
||
const [prompt, setPrompt] = useState("");
|
||
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [approvalBusy, setApprovalBusy] = useState(false);
|
||
const [suggestionError, setSuggestionError] = useState("");
|
||
|
||
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!prompt.trim()) {
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setSuggestion(null);
|
||
setSuggestionError("");
|
||
try {
|
||
const response = await platformApiClient.invokeAI({ requestId: `web:ai.config:${serverId}:${Date.now()}`, serverInstanceId: serverId, purpose: "config.suggest", prompt: prompt.trim() });
|
||
if (response.status !== "ok") {
|
||
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
|
||
}
|
||
const recommendation = response.configRecommendation;
|
||
const preview = recommendation?.suggestedConfig
|
||
? await platformApiClient.previewServerConfigDiff(serverId, {
|
||
expectedConfigVersion: instance.configVersion,
|
||
expectedChecksum: instance.configChecksum,
|
||
key: recommendation.key,
|
||
proposedContent: recommendation.suggestedConfig
|
||
})
|
||
: undefined;
|
||
setSuggestion({
|
||
serverInstanceId: serverId,
|
||
source: "api",
|
||
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
||
diffId: recommendation?.diffId,
|
||
expiresAt: recommendation?.expiresAt,
|
||
diff: preview ? configDiffViewFromPreview(preview) : undefined
|
||
});
|
||
} catch (caught) {
|
||
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function applySuggestion() {
|
||
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) {
|
||
return;
|
||
}
|
||
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
||
setApprovalBusy(true);
|
||
try {
|
||
const approved = await platformApiClient.approveAIConfigDiff(suggestion.diffId, `web:ai.config.approve:${suggestion.diffId}`);
|
||
const job = approved.dispatch.job;
|
||
operations.succeed(operationId, `AI 建议已确认,写入任务 ${job.id} 已派发`, job);
|
||
setSuggestion(null);
|
||
setConfirming(false);
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "写入任务派发失败", operationId);
|
||
setConfirming(false);
|
||
} finally {
|
||
setApprovalBusy(false);
|
||
}
|
||
}
|
||
|
||
const llmOperation = operations.operations.find((operation) => operation.intent === "应用 AI 配置建议" && operation.targetId === serverId);
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="llm configuration assistance">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<Sparkles size={16} style={{ verticalAlign: "-2px" }} /> AI 配置助手
|
||
</h2>
|
||
<span className="page-status">建议仅作用于 {serverId}</span>
|
||
</div>
|
||
<p style={{ margin: "0 0 12px", color: "var(--ink-soft)", fontSize: 13.5 }}>
|
||
AI 建议会先生成推荐说明和配置差异,<strong>不会自动写入</strong>。只有你确认差异后,平台才会派发写入任务。前端不会接触任何 AI 提供商密钥。
|
||
</p>
|
||
{llmOperation && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<ResultBadge
|
||
status={llmOperation.status}
|
||
label={
|
||
llmOperation.status === "pending"
|
||
? "写入中…"
|
||
: llmOperation.status === "succeeded"
|
||
? (llmOperation.message ?? "已派发")
|
||
: `失败:${llmOperation.errorReason}(诊断 ${llmOperation.diagnosticId})`
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{suggestionError && <ErrorState title="AI 建议不可用" reason={suggestionError} diagnosticId={`ai-config:${serverId}`} onRetry={() => setSuggestionError("")} compact />}
|
||
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void requestSuggestion(event)}>
|
||
<label>
|
||
想让 AI 帮你调整什么?
|
||
<textarea
|
||
value={prompt}
|
||
rows={3}
|
||
placeholder="例如:把最大玩家数提高到 40,并关闭 PVP"
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
/>
|
||
</label>
|
||
<button type="submit" className="primary-command" disabled={busy || !prompt.trim()}>
|
||
{busy ? "生成建议中…" : "生成建议"}
|
||
</button>
|
||
</form>
|
||
|
||
{suggestion && (
|
||
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
||
<div className="panel-header" style={{ marginBottom: 0 }}>
|
||
<h3>AI 建议</h3>
|
||
<span className="page-status">平台 AI Provider</span>
|
||
</div>
|
||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
||
{suggestion.diff ? (
|
||
<>
|
||
<DiffView lines={suggestion.diff.lines} />
|
||
<div className="confirm-actions">
|
||
<button type="button" onClick={() => setSuggestion(null)}>
|
||
放弃建议
|
||
</button>
|
||
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
|
||
确认差异并写入
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<span className="provider-id">该建议没有生成可应用的配置差异,仅供参考。</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={confirming}
|
||
title="确认应用 AI 配置建议"
|
||
description={`即将向服务器 ${instance.name}(${serverId})派发配置写入任务。写入内容以上方差异为准。`}
|
||
confirmLabel="确认写入"
|
||
busy={approvalBusy || llmOperation?.status === "pending"}
|
||
onCancel={() => setConfirming(false)}
|
||
onConfirm={() => void applySuggestion()}
|
||
/>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
export function configDiffViewFromPreview(preview: ServerConfigDiffPreviewResponse): ConfigDiffView {
|
||
const lines = preview.diff.map(configDiffLineFromPreviewLine);
|
||
const added = lines.filter((line) => line.kind === "added").length;
|
||
const removed = lines.filter((line) => line.kind === "removed").length;
|
||
return {
|
||
serverInstanceId: preview.serverInstanceId,
|
||
configVersion: preview.configVersion,
|
||
checksum: preview.checksum,
|
||
key: preview.key,
|
||
source: preview.source,
|
||
summary: `+${added} / -${removed} 行变更`,
|
||
lines,
|
||
nextContent: preview.proposedContent ?? "",
|
||
proposedContentInputRef: preview.proposedContentInputRef
|
||
};
|
||
}
|
||
|
||
function configDiffLineFromPreviewLine(line: ConfigDiffLineResponse): ConfigDiffView["lines"][number] {
|
||
return {
|
||
kind: line.kind === "context" ? "same" : line.kind,
|
||
text: line.content
|
||
};
|
||
}
|
||
|
||
interface HistorySectionProps {
|
||
serverId: string;
|
||
serverOperations: PageComponentProps["operations"]["operations"];
|
||
jobs: JobResponse[];
|
||
artifacts: ArtifactResponse[];
|
||
metricHistory: MetricSampleResponse[];
|
||
backups: BackupResponse[];
|
||
remoteAdapters: RemoteAdapterDeclarationResponse[];
|
||
}
|
||
|
||
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
|
||
return (
|
||
<>
|
||
<ProductionGovernancePanel compact title={`服务器 ${serverId} 的容量与告警`} />
|
||
<div className="overview-two-col" aria-label="operation history">
|
||
<article className="console-panel">
|
||
<div className="panel-header">
|
||
<h2>本次会话操作</h2>
|
||
</div>
|
||
{serverOperations.length === 0 ? (
|
||
<EmptyState title="暂无操作记录" description="在本页发起启动、停止、插件控制或配置写入后,这里会显示完整的操作生命周期。" />
|
||
) : (
|
||
<div className="console-record-list">
|
||
{serverOperations.map((operation) => (
|
||
<div key={operation.id} className="console-record">
|
||
<div className="console-record-head">
|
||
<strong>{operation.intent}</strong>
|
||
<ResultBadge
|
||
status={operation.status}
|
||
label={operation.status === "pending" ? "进行中" : operation.status === "succeeded" ? "成功" : "失败"}
|
||
/>
|
||
</div>
|
||
<div className="console-record-meta">
|
||
<span>
|
||
操作 <code>{operation.id}</code>
|
||
</span>
|
||
{operation.jobId && (
|
||
<span>
|
||
任务 <code>{operation.jobId}</code>
|
||
{operation.jobState ? `(${operation.jobState})` : ""}
|
||
</span>
|
||
)}
|
||
<span>目标 {operation.targetId}</span>
|
||
<span>发起人 {operation.requester}</span>
|
||
<span>{new Date(operation.updatedAt).toLocaleString()}</span>
|
||
</div>
|
||
{operation.message && <span className="provider-id">{operation.message}</span>}
|
||
{operation.status === "failed" && operation.errorReason && (
|
||
<span className="provider-id">
|
||
失败原因:{operation.errorReason}
|
||
{operation.diagnosticId ? `(诊断 ${operation.diagnosticId})` : ""}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</article>
|
||
<article className="console-panel">
|
||
<div className="panel-header">
|
||
<h2>平台任务记录</h2>
|
||
</div>
|
||
{jobs.length === 0 ? (
|
||
<EmptyState title="暂无任务" description="该服务器还没有平台侧任务记录。" />
|
||
) : (
|
||
<div className="console-record-list">
|
||
{jobs.slice(0, 20).map((job) => (
|
||
<div key={job.id} className="console-record">
|
||
<div className="console-record-head">
|
||
<strong>{jobCapabilityLabel(job.capability)}</strong>
|
||
<span className={cx("status-pill", job.state === "succeeded" ? "status-active" : job.state === "failed" ? "status-error" : "status-disabled")}>
|
||
{job.state}
|
||
</span>
|
||
</div>
|
||
<div className="console-record-meta">
|
||
<span>
|
||
任务 <code>{job.id}</code>
|
||
</span>
|
||
<span>进度 {job.progress.percent}%</span>
|
||
{job.progress.phase && <span>阶段:{deploymentProgressLabel(job.progress)}</span>}
|
||
<span>
|
||
尝试 {job.attempt}/{job.retryPolicy.maxAttempts}
|
||
</span>
|
||
{job.nextAttemptAt && <span>下次尝试 {new Date(job.nextAttemptAt).toLocaleString()}</span>}
|
||
{job.lastReconciledAt && <span>最近协调 {new Date(job.lastReconciledAt).toLocaleString()}</span>}
|
||
<span>{new Date(job.updatedAt).toLocaleString()}</span>
|
||
</div>
|
||
{job.progress.message && <span className="provider-id">{job.progress.message}</span>}
|
||
{job.cancelReason && <span className="provider-id">取消原因:{job.cancelReason}</span>}
|
||
{job.reconcileOutcome && <span className="provider-id">协调结果:{job.reconcileOutcome}</span>}
|
||
{job.executionResult && (job.executionResult.processState || job.executionResult.checksum || job.executionResult.version !== undefined) && (
|
||
<span className="provider-id">
|
||
执行结果:{job.executionResult.processState ?? job.executionResult.kind ?? "已记录"}
|
||
{job.executionResult.version !== undefined ? ` · v${job.executionResult.version}` : ""}
|
||
{job.executionResult.checksum ? ` · ${job.executionResult.checksum.slice(0, 18)}` : ""}
|
||
{job.executionResult.sizeBytes !== undefined ? ` · ${job.executionResult.sizeBytes} B` : ""}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</article>
|
||
<ArtifactDownloadPanel serverId={serverId} artifacts={artifacts} />
|
||
<article className="console-panel" aria-label="durable observability">
|
||
<div className="panel-header">
|
||
<h2>持久化观测</h2>
|
||
</div>
|
||
<div className="console-record-list">
|
||
<div className="console-record">
|
||
<div className="console-record-head"><strong>指标样本</strong><span className="status-pill status-active">{metricHistory.length} 条</span></div>
|
||
<div className="console-record-meta"><span>最新采集 {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}</span></div>
|
||
</div>
|
||
<div className="console-record">
|
||
<div className="console-record-head"><strong>备份记录</strong><span className="status-pill status-active">{backups.length} 条</span></div>
|
||
<div className="console-record-meta">{backups.slice(0, 4).map((backup) => <span key={backup.id}>{backup.id} · {backup.state} · {backup.checksum.slice(0, 18)}</span>)}</div>
|
||
</div>
|
||
<div className="console-record">
|
||
<div className="console-record-head"><strong>远端适配器声明</strong><span className="status-pill status-active">{remoteAdapters.length} 个</span></div>
|
||
<div className="console-record-meta">{remoteAdapters.slice(0, 4).map((adapter) => <span key={adapter.key}>{adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")}</span>)}</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
interface ArtifactDownloadPanelProps {
|
||
serverId: string;
|
||
artifacts: ArtifactResponse[];
|
||
}
|
||
|
||
function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelProps) {
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
const [result, setResult] = useState<Record<string, { status: "pending" | "succeeded" | "failed"; label: string; progress?: number }>>({});
|
||
|
||
async function downloadArtifact(artifact: ArtifactResponse) {
|
||
setActiveId(artifact.id);
|
||
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
|
||
try {
|
||
const reference = await platformApiClient.openArtifactDownload(artifact.id);
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
|
||
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
|
||
});
|
||
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
|
||
} catch (error) {
|
||
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
|
||
} finally {
|
||
setActiveId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className="console-panel">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<PackageOpen size={16} />
|
||
浏览器制品传输
|
||
</h2>
|
||
</div>
|
||
{artifacts.length === 0 ? (
|
||
<EmptyState title="暂无可下载制品" description="该服务器当前没有已完成的可用制品。" />
|
||
) : (
|
||
<div className="console-record-list">
|
||
{artifacts.slice(0, 12).map((artifact) => {
|
||
const itemResult = result[artifact.id];
|
||
return (
|
||
<div key={artifact.id} className="console-record">
|
||
<div className="console-record-head">
|
||
<strong>{artifact.id}</strong>
|
||
<ResultBadge status={itemResult?.status ?? "pending"} label={itemResult?.label ?? artifact.state} />
|
||
</div>
|
||
<div className="console-record-meta">
|
||
<span>服务器 {serverId}</span>
|
||
<span>{formatBytes(artifact.sizeBytes)}</span>
|
||
<span>{artifact.checksum}</span>
|
||
</div>
|
||
{itemResult?.progress !== undefined && <UsageMeter label="传输" percent={itemResult.progress} />}
|
||
<div className="action-strip">
|
||
<button type="button" className="icon-command" disabled={activeId !== null} onClick={() => void downloadArtifact(artifact)}>
|
||
<Download size={14} />
|
||
<span>{activeId === artifact.id ? "传输中" : "打开"}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function formatBytes(value: number): string {
|
||
if (value < 1024) {
|
||
return `${value} B`;
|
||
}
|
||
if (value < 1024 * 1024) {
|
||
return `${(value / 1024).toFixed(1)} KiB`;
|
||
}
|
||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||
}
|