Complete platform management workflows
This commit is contained in:
@@ -1,26 +1,29 @@
|
||||
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, 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,
|
||||
ClientManagerDistributionResponse,
|
||||
GamePluginResponse,
|
||||
JobResponse,
|
||||
LogEntryBody,
|
||||
LogStreamResponse,
|
||||
RunDistributionResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
ServerConfigResponse,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberResponse,
|
||||
ServerMetricsResponse
|
||||
ServerMetricsResponse,
|
||||
ServerRuntimeActionsResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import { canStartServer, canStopServer, pluginLabel } from "../contracts/serverManagement";
|
||||
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
@@ -30,7 +33,16 @@ import {
|
||||
type PluginControlGroupView,
|
||||
type ServerDetailSection
|
||||
} from "../contracts/workspace";
|
||||
import { serverLifecycleCommandRequest } from "../schemas/serverManagement";
|
||||
import {
|
||||
clientManagerBuildRequest,
|
||||
dependencyJobRequest,
|
||||
logBackfillRequest,
|
||||
runDistributionGenerateRequest,
|
||||
runUpdateRequest,
|
||||
serverArchiveConfirmation,
|
||||
serverLifecycleCommandRequest,
|
||||
serverMetadataUpdateRequestFromForm
|
||||
} from "../schemas/serverManagement";
|
||||
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
|
||||
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
|
||||
import { cx } from "../utils/classes";
|
||||
@@ -38,7 +50,6 @@ import { stateLabel, statusClass } from "./ServersPage";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
|
||||
const fallbackConfig = "# server.properties\nmax-players=20\nmotd=Welcome to the server\npvp=true\nview-distance=8\n";
|
||||
const defaultConfigKey = "server.properties";
|
||||
|
||||
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
|
||||
@@ -49,6 +60,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
|
||||
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
|
||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
@@ -59,14 +71,19 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
}
|
||||
setInstance({ status: "loading" });
|
||||
try {
|
||||
const [detail, pluginResponse, jobResponse] = await Promise.all([
|
||||
const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([
|
||||
platformApiClient.getServerInstance(serverId),
|
||||
platformApiClient.listGamePlugins(),
|
||||
platformApiClient.listJobs(serverId)
|
||||
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 : "运行分发状态加载失败" }))
|
||||
]);
|
||||
setInstance({ status: "ready", data: detail });
|
||||
setPlugins(pluginResponse.items);
|
||||
setJobs(jobResponse.items);
|
||||
setRuntimeActions(runtimeResponse);
|
||||
const artifactLists = await Promise.all(
|
||||
jobResponse.items.slice(0, 20).map((job) =>
|
||||
platformApiClient
|
||||
@@ -79,6 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
} catch (error) {
|
||||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||
setArtifacts([]);
|
||||
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
|
||||
}
|
||||
try {
|
||||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||||
@@ -224,6 +242,25 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
</nav>
|
||||
|
||||
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
|
||||
{section === "overview" && (
|
||||
<RuntimeDistributionSection
|
||||
instance={instance.data}
|
||||
runtimeActions={runtimeActions}
|
||||
session={session}
|
||||
operations={operations}
|
||||
onOpenLogs={() => setSection("logs")}
|
||||
onChanged={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && (
|
||||
<ServerMetadataSection
|
||||
instance={instance.data}
|
||||
session={session}
|
||||
operations={operations}
|
||||
onChanged={(next) => setInstance({ status: "ready", data: next })}
|
||||
onArchived={() => onNavigate("servers")}
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
||||
{section === "logs" && <LogsSection serverId={serverId} />}
|
||||
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
@@ -264,6 +301,99 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
|
||||
return [...byID.values()];
|
||||
}
|
||||
|
||||
interface ServerMetadataSectionProps {
|
||||
instance: ServerInstanceResponse;
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
onChanged: (instance: ServerInstanceResponse) => void;
|
||||
onArchived: () => void;
|
||||
}
|
||||
|
||||
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
|
||||
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||||
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
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 : "服务器信息更新失败" });
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveServer() {
|
||||
setConfirmBusy(true);
|
||||
const operationId = operations.begin({ intent: "归档服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
try {
|
||||
await platformApiClient.archiveServerInstance(instance.id);
|
||||
operations.succeed(operationId, `服务器已归档:${instance.id}`);
|
||||
setResult({ status: "succeeded", label: `${instance.name} 已归档` });
|
||||
setConfirmArchive(null);
|
||||
onArchived();
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "服务器归档失败");
|
||||
setResult({ status: "failed", label: error instanceof Error ? error.message : "归档失败,平台拒绝当前状态" });
|
||||
} finally {
|
||||
setConfirmBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<button type="button" className="icon-command danger-command" disabled={!canArchiveServer(instance.state)} onClick={() => setConfirmArchive(serverArchiveConfirmation(instance))}>
|
||||
<Archive size={14} />
|
||||
<span>归档</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{!canArchiveServer(instance.state) && (
|
||||
<p className="provider-id" style={{ marginTop: 10 }}>
|
||||
运行中、安装中或已归档的服务器不能直接归档;请先停止或等待状态稳定。
|
||||
</p>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmArchive !== null}
|
||||
title="归档服务器"
|
||||
description={`确认归档 ${confirmArchive?.name ?? ""}(${confirmArchive?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
|
||||
confirmLabel="确认归档"
|
||||
danger
|
||||
busy={confirmBusy}
|
||||
onCancel={() => setConfirmArchive(null)}
|
||||
onConfirm={() => void archiveServer()}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerAdministratorsSectionProps {
|
||||
instance: ServerInstanceResponse;
|
||||
session: PageComponentProps["session"];
|
||||
@@ -451,6 +581,362 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio
|
||||
);
|
||||
}
|
||||
|
||||
interface RuntimeDistributionSectionProps {
|
||||
instance: ServerInstanceResponse;
|
||||
runtimeActions: LoadState<ServerRuntimeActionsResponse>;
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
onOpenLogs: () => void;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
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 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) {
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
|
||||
setResult({ status: "pending", label: `${intent} 执行中` });
|
||||
try {
|
||||
const value = await execute();
|
||||
const label = summarize(value);
|
||||
operations.succeed(operationId, label);
|
||||
setResult({ status: "succeeded", label });
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : `${intent} 失败`;
|
||||
operations.fail(operationId, reason, operationId);
|
||||
setResult({ status: "failed", label: 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;
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="run distribution controls">
|
||||
<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
|
||||
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
安装 plan
|
||||
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
|
||||
</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}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
<RuntimeActionRow
|
||||
title="run 下载与更新"
|
||||
description={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);
|
||||
return reference;
|
||||
},
|
||||
(reference) => `下载引用已创建,artifact ${reference.artifactId},有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}`
|
||||
)
|
||||
}
|
||||
secondaryLabel="推送更新"
|
||||
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
|
||||
secondaryReason={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}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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},secret ref ${safeRuntimeRef(distribution.secretRef)}`
|
||||
)
|
||||
}
|
||||
secondaryLabel="下载客户端"
|
||||
secondaryDisabled={!canUse("download-client-manager")}
|
||||
secondaryReason={reasonFor("download-client-manager")}
|
||||
onSecondary={() =>
|
||||
void runOperation(
|
||||
"下载客户端管理器",
|
||||
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }),
|
||||
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
|
||||
disabled={!canUse("dependencies-check")}
|
||||
reason={reasonFor("dependencies-check")}
|
||||
actionLabel="依赖检查"
|
||||
onAction={() =>
|
||||
void runOperation(
|
||||
"依赖检查",
|
||||
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
|
||||
(job) => `依赖检查任务已排队,job ${job.id}`
|
||||
)
|
||||
}
|
||||
secondaryLabel="依赖安装"
|
||||
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
|
||||
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
|
||||
onSecondary={() =>
|
||||
void runOperation(
|
||||
"依赖安装",
|
||||
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
|
||||
(job) => `依赖安装任务已排队,job ${job.id}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
<RuntimeActionRow
|
||||
title="日志"
|
||||
description="实时日志来自平台日志 API,历史日志通过 backfill job 返回 cursor/ref。"
|
||||
disabled={!canUse("live-logs")}
|
||||
reason={reasonFor("live-logs")}
|
||||
actionLabel="实时日志"
|
||||
onAction={onOpenLogs}
|
||||
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}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<label>
|
||||
checkpoint ref
|
||||
<input value={checkpointRef} placeholder="可选 artifact://logs/checkpoint/..." onChange={(event) => setCheckpointRef(event.target.value)} />
|
||||
</label>
|
||||
</RuntimeActionRow>
|
||||
</div>
|
||||
</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 ? "server-log" : "latest-log"
|
||||
};
|
||||
}
|
||||
|
||||
function safeRuntimeRef(ref: string): string {
|
||||
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
|
||||
return ref;
|
||||
}
|
||||
return "[redacted-ref]";
|
||||
}
|
||||
|
||||
interface LogsSectionProps {
|
||||
serverId: string;
|
||||
}
|
||||
@@ -471,8 +957,8 @@ function LogsSection({ serverId }: LogsSectionProps) {
|
||||
const refresh = useCallback(async () => {
|
||||
setStreams({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listLogStreams();
|
||||
const serverStreams = response.items.filter((stream) => stream.serverInstanceId === serverId);
|
||||
const response = await platformApiClient.listServerLiveLogs(serverId);
|
||||
const serverStreams = response.items;
|
||||
setStreams({ status: "ready", data: serverStreams });
|
||||
const collected: Array<LogEntryBody & { source: string }> = [];
|
||||
for (const stream of serverStreams) {
|
||||
@@ -659,9 +1145,9 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
|
||||
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
|
||||
setDraft(response.content);
|
||||
} catch {
|
||||
setConfig({ status: "ready", data: { content: fallbackConfig, source: "local" } });
|
||||
setDraft(fallbackConfig);
|
||||
} catch (error) {
|
||||
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
|
||||
setDraft("");
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
@@ -717,9 +1203,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
<article className="console-panel" aria-label="server configuration">
|
||||
<div className="panel-header">
|
||||
<h2>配置</h2>
|
||||
{config.status === "ready" && (
|
||||
<span className="page-status">{config.data.source === "api" ? `配置版本 v${instance.configVersion}` : "本地示例配置(配置读取接口未提供)"}</span>
|
||||
)}
|
||||
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}</span>}
|
||||
</div>
|
||||
{writeOperation && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
@@ -736,6 +1220,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
</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)}>
|
||||
@@ -1125,7 +1610,7 @@ interface LlmSectionProps {
|
||||
|
||||
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [currentConfig, setCurrentConfig] = useState<string>(fallbackConfig);
|
||||
const [currentConfig, setCurrentConfig] = useState<string>("");
|
||||
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -1140,7 +1625,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// keep the local fallback config
|
||||
setCurrentConfig("");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
Reference in New Issue
Block a user