feat: 完整游戏运维功能
This commit is contained in:
@@ -6,18 +6,24 @@ import type {
|
||||
ConfigDiffLineResponse,
|
||||
ArtifactDownloadReferenceResponse,
|
||||
ArtifactResponse,
|
||||
BackupResponse,
|
||||
ClientManagerDistributionResponse,
|
||||
DependencyCatalogResponse,
|
||||
GamePluginResponse,
|
||||
JobResponse,
|
||||
LogEntryBody,
|
||||
LogStreamResponse,
|
||||
RunDistributionResponse,
|
||||
RunUpdateJobResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
ServerConfigResponse,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberResponse,
|
||||
ServerMetricsResponse,
|
||||
ServerRuntimeActionsResponse
|
||||
RuntimeBindingResponse,
|
||||
ServerRuntimeActionsResponse,
|
||||
MetricSampleResponse,
|
||||
RemoteAdapterDeclarationResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||
import {
|
||||
@@ -34,10 +40,11 @@ import {
|
||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
isPlatformAdmin,
|
||||
type ConfigDiffView,
|
||||
type LlmSuggestionView,
|
||||
type PluginControlDescriptor,
|
||||
@@ -72,7 +79,11 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
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 [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
@@ -83,19 +94,30 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
}
|
||||
setInstance({ status: "loading" });
|
||||
try {
|
||||
const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([
|
||||
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, 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 : "运行分发状态加载失败" }))
|
||||
.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.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);
|
||||
setMetricHistory(metricHistoryResponse.items);
|
||||
setBackups(backupResponse.items);
|
||||
setRemoteAdapters(adapterResponse.items);
|
||||
const artifactLists = await Promise.all(
|
||||
jobResponse.items.slice(0, 20).map((job) =>
|
||||
platformApiClient
|
||||
@@ -109,6 +131,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||
setArtifacts([]);
|
||||
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
|
||||
setRuntimeBinding({ status: "error", reason: "运行配置加载失败" });
|
||||
setMetricHistory([]);
|
||||
setBackups([]);
|
||||
setRemoteAdapters([]);
|
||||
}
|
||||
try {
|
||||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||||
@@ -211,7 +237,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
<button
|
||||
type="button"
|
||||
className="icon-command"
|
||||
disabled={!canStartServer(instance.data.state) || operations.isPending(instance.data.id, "启动服务器")}
|
||||
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} />
|
||||
@@ -220,7 +246,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
<button
|
||||
type="button"
|
||||
className="icon-command danger-command"
|
||||
disabled={!canStopServer(instance.data.state) || operations.isPending(instance.data.id, "停止服务器")}
|
||||
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} />
|
||||
@@ -254,6 +280,16 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
</nav>
|
||||
|
||||
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
|
||||
{section === "overview" && (
|
||||
<RuntimeBindingSection
|
||||
instance={instance.data}
|
||||
plugin={plugins.find((plugin) => plugin.id === instance.data.pluginId)}
|
||||
binding={runtimeBinding}
|
||||
session={session}
|
||||
operations={operations}
|
||||
onChanged={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && (
|
||||
<RuntimeDistributionSection
|
||||
instance={instance.data}
|
||||
@@ -278,7 +314,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
{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} />}
|
||||
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} />}
|
||||
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -554,7 +590,7 @@ interface OverviewSectionProps {
|
||||
}
|
||||
|
||||
function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectionProps) {
|
||||
const pending = jobs.filter((job) => job.state === "queued" || job.state === "accepted" || job.state === "running");
|
||||
const pending = jobs.filter((job) => job.state === "queued" || job.state === "accepted" || job.state === "running" || job.state === "retrying");
|
||||
const failed = jobs.filter((job) => job.state === "failed");
|
||||
return (
|
||||
<div className="overview-two-col">
|
||||
@@ -602,6 +638,125 @@ interface RuntimeDistributionSectionProps {
|
||||
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);
|
||||
@@ -617,9 +772,44 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
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 [catalog, updates] = await Promise.all([
|
||||
platformApiClient
|
||||
.getDependencyCatalog(instance.id)
|
||||
.then((data): LoadState<DependencyCatalogResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<DependencyCatalogResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" })),
|
||||
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]);
|
||||
|
||||
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 }>();
|
||||
@@ -676,6 +866,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
setResult({ status: "succeeded", label });
|
||||
runtimeTask.succeedTask(label);
|
||||
taskOptions?.afterSuccess?.(value);
|
||||
void refreshRuntimeProjections();
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : `${intent} 失败`;
|
||||
@@ -802,11 +993,19 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
</label>
|
||||
<label>
|
||||
依赖 probe
|
||||
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
|
||||
<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
|
||||
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
|
||||
<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>
|
||||
日志源
|
||||
@@ -847,7 +1046,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
/>
|
||||
<RuntimeActionRow
|
||||
title="run 下载与更新"
|
||||
description={lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
|
||||
description={latestRunUpdate ? `最近更新 ${latestRunUpdate.targetOs}/${latestRunUpdate.targetArch} · ${runUpdatePhaseLabel(latestRunUpdate.phase)}` : lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
|
||||
disabled={!canUse("download-run")}
|
||||
reason={reasonFor("download-run")}
|
||||
actionLabel="下载 run"
|
||||
@@ -889,7 +1088,20 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
>
|
||||
{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 包会失效,必须重新生成并重新部署。"
|
||||
@@ -965,9 +1177,9 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
/>
|
||||
<RuntimeActionRow
|
||||
title="依赖"
|
||||
description={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
|
||||
disabled={!canUse("dependencies-check")}
|
||||
reason={reasonFor("dependencies-check")}
|
||||
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(
|
||||
@@ -982,21 +1194,34 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
)
|
||||
}
|
||||
secondaryLabel="依赖安装"
|
||||
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
|
||||
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
|
||||
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)),
|
||||
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey, selectedDependencyPlan?.digest ?? "")),
|
||||
(job) => `依赖安装任务已排队,job ${job.id}`,
|
||||
{
|
||||
description: `使用 ${installPlanKey} 安装计划派发依赖安装任务,并保留 job 追踪。`,
|
||||
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。"
|
||||
@@ -1098,6 +1323,24 @@ function runtimeDefaultsForPlugin(pluginId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
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 "更新失败";
|
||||
}
|
||||
}
|
||||
|
||||
function safeRuntimeRef(ref: string): string {
|
||||
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
|
||||
return ref;
|
||||
@@ -1333,6 +1576,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
try {
|
||||
const preview = await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: defaultConfigKey,
|
||||
proposedContent: draft
|
||||
});
|
||||
@@ -1352,6 +1596,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
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,
|
||||
@@ -1371,7 +1616,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">配置版本 v{instance.configVersion}</span>}
|
||||
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}{instance.configChecksum ? ` · ${instance.configChecksum.slice(0, 18)}` : ""}</span>}
|
||||
</div>
|
||||
{writeOperation && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
@@ -1440,10 +1685,10 @@ interface PluginControlsSectionProps {
|
||||
function controlsForPlugin(plugin: GamePluginResponse): PluginControlDescriptor[] {
|
||||
const controls: PluginControlDescriptor[] = [];
|
||||
for (const [action] of Object.entries(plugin.lifecycleActions)) {
|
||||
if (action === "install" || action === "restart" || action === "status") {
|
||||
if (action === "install" || action === "restart") {
|
||||
continue;
|
||||
}
|
||||
if (action !== "start" && action !== "stop") {
|
||||
if (action !== "start" && action !== "stop" && action !== "status") {
|
||||
continue;
|
||||
}
|
||||
controls.push({
|
||||
@@ -1492,6 +1737,8 @@ function lifecycleControlLabel(action: string): string {
|
||||
return "启动进程";
|
||||
case "stop":
|
||||
return "停止进程";
|
||||
case "status":
|
||||
return "查询进程";
|
||||
case "restart":
|
||||
return "重启进程";
|
||||
default:
|
||||
@@ -1538,11 +1785,13 @@ function PluginControlsSection({ serverId, instance, plugins, artifacts, session
|
||||
requester: session.displayName
|
||||
});
|
||||
try {
|
||||
if (control.lifecycleAction === "start" || control.lifecycleAction === "stop") {
|
||||
if (control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status") {
|
||||
const result =
|
||||
control.lifecycleAction === "start"
|
||||
? await platformApiClient.startServerInstance(instance.id, serverLifecycleCommandRequest(instance, "start"))
|
||||
: await platformApiClient.stopServerInstance(instance.id, serverLifecycleCommandRequest(instance, "stop"));
|
||||
: 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;
|
||||
}
|
||||
@@ -1812,6 +2061,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
const preview = response.suggestedConfig
|
||||
? await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: defaultConfigKey,
|
||||
proposedContent: response.suggestedConfig
|
||||
})
|
||||
@@ -1837,6 +2087,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
try {
|
||||
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
|
||||
expectedConfigVersion: suggestion.diff.configVersion ?? instance.configVersion,
|
||||
expectedChecksum: suggestion.diff.checksum ?? instance.configChecksum,
|
||||
key: suggestion.diff.key ?? defaultConfigKey,
|
||||
proposedContent: suggestion.diff.nextContent,
|
||||
proposedContentInputRef: suggestion.diff.proposedContentInputRef,
|
||||
@@ -1981,6 +2232,7 @@ export function configDiffViewFromPreview(preview: ServerConfigDiffPreviewRespon
|
||||
return {
|
||||
serverInstanceId: preview.serverInstanceId,
|
||||
configVersion: preview.configVersion,
|
||||
checksum: preview.checksum,
|
||||
key: preview.key,
|
||||
source: preview.source,
|
||||
summary: `+${added} / -${removed} 行变更`,
|
||||
@@ -2002,9 +2254,12 @@ interface HistorySectionProps {
|
||||
serverOperations: PageComponentProps["operations"]["operations"];
|
||||
jobs: JobResponse[];
|
||||
artifacts: ArtifactResponse[];
|
||||
metricHistory: MetricSampleResponse[];
|
||||
backups: BackupResponse[];
|
||||
remoteAdapters: RemoteAdapterDeclarationResponse[];
|
||||
}
|
||||
|
||||
function HistorySection({ serverId, serverOperations, jobs, artifacts }: HistorySectionProps) {
|
||||
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
|
||||
return (
|
||||
<div className="overview-two-col" aria-label="operation history">
|
||||
<article className="console-panel">
|
||||
@@ -2071,15 +2326,49 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts }: History
|
||||
任务 <code>{job.id}</code>
|
||||
</span>
|
||||
<span>进度 {job.progress.percent}%</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="operation-list">
|
||||
<div className="operation-item">
|
||||
<div className="operation-item-head"><strong>指标样本</strong><span className="status-pill status-active">{metricHistory.length} 条</span></div>
|
||||
<div className="operation-meta"><span>最新采集 {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}</span></div>
|
||||
</div>
|
||||
<div className="operation-item">
|
||||
<div className="operation-item-head"><strong>备份记录</strong><span className="status-pill status-active">{backups.length} 条</span></div>
|
||||
<div className="operation-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="operation-item">
|
||||
<div className="operation-item-head"><strong>远端适配器声明</strong><span className="status-pill status-active">{remoteAdapters.length} 个</span></div>
|
||||
<div className="operation-meta">{remoteAdapters.slice(0, 4).map((adapter) => <span key={adapter.key}>{adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")}</span>)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user