feat: 完整游戏运维功能

This commit is contained in:
npc0-hue
2026-07-18 09:04:01 +08:00
parent f3b14b7945
commit 48b8ad8d6c
187 changed files with 16607 additions and 1140 deletions
+3 -2
View File
@@ -9,7 +9,7 @@ const provider: AiProviderResponse = {
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai",
apiKeyConfigured: true,
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
@@ -43,7 +43,8 @@ describe("AiProvidersPage", () => {
expect(html).toContain("OpenAI Relay");
expect(html).toContain("本地开发");
expect(html).toContain("secret://providers/openai");
expect(html).toContain("已配置");
expect(html).not.toContain("secret://providers/openai");
expect(html).toContain("测试");
expect(html).toContain("模型");
expect(html).toContain("编辑");
+6 -4
View File
@@ -118,6 +118,8 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const key = event.target.name as keyof AiProviderFormState;
if (key === "kind") {
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
} else if (key === "apiKeyRef") {
setForm((current) => ({ ...current, apiKeyRef: event.target.value, apiKeyConfigured: current.apiKeyConfigured || Boolean(event.target.value.trim()) }));
} else {
updateForm(key, event.target.value);
}
@@ -166,7 +168,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
if (!completed.baseUrl.trim()) {
missing.push("Base URL");
}
if (completed.relayMode !== "local" && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
if (completed.relayMode !== "local" && !completed.apiKeyConfigured && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
missing.push("secret://providers/... 密钥引用");
}
if (models.length === 0) {
@@ -365,7 +367,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
@@ -385,7 +387,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
<td>{provider.relayMode}</td>
<td>{provider.models.length}</td>
<td>
<code className="secret-ref">{provider.apiKeyRef}</code>
<span className={cx("status-pill", provider.apiKeyConfigured ? "status-active" : "status-disabled")}>{provider.apiKeyConfigured ? "已配置" : "未配置"}</span>
</td>
<td className="provider-actions-cell">
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
@@ -459,7 +461,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</label>
<label>
<span>{secretRequired ? "平台密钥引用" : "密钥引用(本地模式可留空)"}</span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={secretRequired ? formDefaults.apiKeyRef : "本地服务通常不需要"} />
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={form.apiKeyConfigured ? "已配置;留空保持不变" : secretRequired ? "secret://providers/..." : "本地服务通常不需要"} />
<small className="field-help">
{secretRequired ? "填写 secret://providers/...;真实密钥进入平台 secret store,不粘贴到页面。" : "Ollama 本地模式默认只需要 Base URL 和模型名。"}
</small>
+10
View File
@@ -80,6 +80,16 @@ describe("first-party console pages", () => {
expect(html).not.toContain("/Users/");
});
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
expect(serversPageSource).toContain('name="profileKey"');
expect(serversPageSource).toContain("runtimeBindingFields");
expect(serversPageSource).toContain("updateBinding(field.key");
expect(serversPageSource).toContain('type={field.sensitive ? "password" : "text"}');
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
expect(serversPageSource).not.toContain(forbidden);
}
});
it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => {
expect(serversPageSource).toContain('aria-haspopup="menu"');
expect(serversPageSource).toContain("createPortal");
+5
View File
@@ -234,6 +234,9 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
<span> {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
<span> {endpoint ? endpoint.displayName : job.runEndpointId}</span>
<span>{progressMessage(job)}</span>
<span>
{job.attempt}/{job.retryPolicy.maxAttempts}
</span>
<span>{formatTimestamp(job.updatedAt)}</span>
</div>
<div className="maintenance-actions">
@@ -360,6 +363,8 @@ function jobStateLabel(state: JobResponse["state"]): string {
return "已接收";
case "running":
return "运行中";
case "retrying":
return "等待重试";
case "succeeded":
return "成功";
case "cancelled":
+15 -3
View File
@@ -90,12 +90,24 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("sqlite://");
});
it("reviews and updates only redacted runtime binding metadata", () => {
const runtimeBindingSectionSource = serverDetailPageSource.split("function RuntimeBindingSection")[1]?.split("function RuntimeDistributionSection")[0] ?? "";
expect(serverDetailPageSource).toContain("getServerRuntimeBinding");
expect(serverDetailPageSource).toContain("updateServerRuntimeBinding");
expect(runtimeBindingSectionSource).toContain("missingKeys");
expect(runtimeBindingSectionSource).toContain('type={field.sensitive ? "password" : "text"}');
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://", "mysql://", "sqlite://"]) {
expect(runtimeBindingSectionSource).not.toContain(forbidden);
}
});
it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => {
expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"');
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"');
expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop"');
expect(serverDetailPageSource).toContain('action === "install" || action === "restart"');
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop" && action !== "status"');
expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status"');
expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(instance.id");
expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(instance.id");
expect(serverDetailPageSource).toContain("platformApiClient.queryServerProcessStatus(instance.id");
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"start\")");
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"stop\")");
expect(serverDetailPageSource).not.toContain('capability: "process.start"');
+315 -26
View File
@@ -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>
);
}
+61 -10
View File
@@ -22,6 +22,7 @@ import {
endpointLabel,
pendingJobsForServer,
pluginLabel,
runtimeBindingFields,
type ServerCreateFormState
} from "../contracts/serverManagement";
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
@@ -75,13 +76,21 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
setEndpoints(endpointResponse.items);
setInstances(instanceResponse.items);
setJobs(jobResponse.items);
setForm((current) => ({
...current,
pluginId: pluginResponse.items.some((plugin) => plugin.id === current.pluginId) ? current.pluginId : pluginResponse.items[0]?.id || "",
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
? current.runEndpointId
: endpointResponse.items[0]?.id || ""
}));
setForm((current) => {
const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0];
const profileKey = plugin?.runtimeProfiles?.lifecycleProfiles?.some((profile) => profile.key === current.profileKey)
? current.profileKey
: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "";
return {
...current,
pluginId: plugin?.id ?? "",
profileKey,
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
? current.runEndpointId
: endpointResponse.items[0]?.id || ""
};
});
setListState("ready");
setListError("");
} catch (error) {
@@ -115,10 +124,25 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
const createPending = operations.isPending("platform", "创建服务器");
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
setForm((current) => {
if (name === "pluginId") {
const plugin = plugins.find((item) => item.id === value);
return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {} };
}
if (name === "profileKey") {
return { ...current, profileKey: value, bindings: {} };
}
return { ...current, [name]: value };
});
}
function updateBinding(key: string, value: string) {
setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } }));
}
async function handleCreate(event: FormEvent<HTMLFormElement>) {
@@ -203,7 +227,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
return `依赖检查任务已排队,job ${job.id}`;
}
if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
const catalog = await platformApiClient.getDependencyCatalog(instance.id);
const plan = catalog.plans.find((candidate) => candidate.key === defaults.installPlanKey);
const probe = catalog.probes.find((candidate) => candidate.key === defaults.probeKey);
if (!plan || probe?.installPlanKey !== plan.key) throw new Error("Platform 未返回与当前 probe 匹配的审核安装计划");
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
return `依赖安装任务已排队,job ${job.id}`;
}
if (action === "live-logs") {
@@ -384,8 +412,31 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
))}
</select>
</label>
<label>
<select name="profileKey" value={form.profileKey} onChange={updateForm} required>
{(selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? []).map((profile) => (
<option key={profile.key} value={profile.key}>
{profile.key} · {profile.mode}
</option>
))}
</select>
</label>
{createBindingFields.map((field) => (
<label key={field.key}>
{field.key}{field.required ? "(必填)" : ""}
<input
type={field.sensitive ? "password" : "text"}
autoComplete="off"
value={form.bindings[field.key] ?? ""}
onChange={(event) => updateBinding(field.key, event.target.value)}
placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"}
required={field.required}
/>
</label>
))}
</div>
<button type="submit" className="primary-command" disabled={createPending} title="创建服务器">
<button type="submit" className="primary-command" disabled={createPending || !form.profileKey} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span>
</button>