Files
browser/platform_web/pages/ServerDetailPage.tsx
T
2026-07-22 11:44:48 +08:00

2389 lines
107 KiB
TypeScript
Raw Blame History

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