Files
browser/platform_web/pages/ServerDetailPage.tsx
T
2026-07-15 19:43:06 +08:00

2162 lines
90 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 { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
ConfigDiffLineResponse,
ArtifactDownloadReferenceResponse,
ArtifactResponse,
ClientManagerDistributionResponse,
GamePluginResponse,
JobResponse,
LogEntryBody,
LogStreamResponse,
RunDistributionResponse,
ServerConfigDiffPreviewResponse,
ServerConfigResponse,
ServerInstanceResponse,
ServerMemberResponse,
ServerMetricsResponse,
ServerRuntimeActionsResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import {
RuntimeTaskProgressDialog,
runtimeBuildStages,
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
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 { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
type ConfigDiffView,
type LlmSuggestionView,
type PluginControlDescriptor,
type PluginControlGroupView,
type ServerDetailSection
} from "../contracts/workspace";
import {
clientManagerBuildRequest,
dependencyJobRequest,
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverArchiveConfirmation,
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { 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 [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const refresh = useCallback(async () => {
if (!serverId) {
setInstance({ status: "error", reason: "缺少服务器 ID" });
return;
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse, runtimeResponse] = 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 : "运行分发状态加载失败" }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse);
const artifactLists = await Promise.all(
jobResponse.items.slice(0, 20).map((job) =>
platformApiClient
.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: "运行分发状态加载失败" });
}
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) || 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) || 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" && (
<RuntimeDistributionSection
instance={instance.data}
runtimeActions={runtimeActions}
session={session}
operations={operations}
onOpenLogs={() => setSection("logs")}
onChanged={() => void refresh()}
/>
)}
{section === "overview" && (
<ServerMetadataSection
instance={instance.data}
session={session}
operations={operations}
onChanged={(next) => setInstance({ status: "ready", data: next })}
onArchived={() => onNavigate("servers")}
/>
)}
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{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} />}
</>
)}
<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;
onArchived: () => void;
}
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
useEffect(() => {
setDraft(serverMetadataFormFromInstance(instance));
}, [instance.id, instance.name]);
async function saveMetadata(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const operationId = operations.begin({ intent: "更新服务器信息", targetKind: "server", targetId: instance.id, requester: session.displayName });
setResult({ status: "pending", label: "正在保存服务器信息" });
try {
const updated = await platformApiClient.updateServerInstance(instance.id, serverMetadataUpdateRequestFromForm(draft));
onChanged(updated);
operations.succeed(operationId, `服务器信息已更新:${updated.id}`);
setResult({ status: "succeeded", label: `已更新 ${updated.name}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器信息更新失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "服务器信息更新失败" });
}
}
async function archiveServer() {
setConfirmBusy(true);
const operationId = operations.begin({ intent: "归档服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.archiveServerInstance(instance.id);
operations.succeed(operationId, `服务器已归档:${instance.id}`);
setResult({ status: "succeeded", label: `${instance.name} 已归档` });
setConfirmArchive(null);
onArchived();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器归档失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "归档失败,平台拒绝当前状态" });
} finally {
setConfirmBusy(false);
}
}
return (
<article className="console-panel" aria-label="server metadata">
<div className="panel-header">
<h2>
<Pencil size={16} style={{ verticalAlign: "-2px" }} /> 基本信息
</h2>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveMetadata(event)}>
<label>
显示名称
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<div className="action-strip">
<button type="submit" className="primary-command" disabled={draft.name.trim() === instance.name}>
<Pencil size={14} />
<span>保存名称</span>
</button>
<button type="button" className="icon-command danger-command" disabled={!canArchiveServer(instance.state)} onClick={() => setConfirmArchive(serverArchiveConfirmation(instance))}>
<Archive size={14} />
<span>归档</span>
</button>
</div>
</form>
{!canArchiveServer(instance.state) && (
<p className="provider-id" style={{ marginTop: 10 }}>
运行中、安装中或已归档的服务器不能直接归档;请先停止或等待状态稳定。
</p>
)}
<ConfirmDialog
open={confirmArchive !== null}
title="归档服务器"
description={`确认归档 ${confirmArchive?.name ?? ""}${confirmArchive?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
confirmLabel="确认归档"
danger
busy={confirmBusy}
onCancel={() => setConfirmArchive(null)}
onConfirm={() => void archiveServer()}
/>
</article>
);
}
interface ServerAdministratorsSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
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");
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;
}
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 runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
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);
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
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
</label>
<label>
安装 plan
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
</label>
<label>
日志源
<input value={logSourceKey} onChange={(event) => setLogSourceKey(event.target.value)} />
</label>
</div>
</div>
<div className="plugin-group-body">
<RuntimeActionRow
title="run 包"
description={`生成 ${targetOs}/${targetArch} run。包内含当前密钥,界面只显示 generation、artifact 和 secret ref。`}
disabled={!canUse("generate-run")}
reason={reasonFor("generate-run")}
actionLabel="生成 run"
onAction={() =>
void runOperation(
"生成 run",
async () => {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
setLastRun(distribution);
return distribution;
},
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建,artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`,
{
description: `为 ${instance.name} 构建 ${targetOs}/${targetArch} run 包,包含拉取代码、安装环境、编译和打包进度。`,
stages: runtimeBuildStages,
trackedJobId: (distribution) => distribution.buildJobId,
afterSuccess: (distribution) => {
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
setRuntimeTaskActions([
{ label: "下载 run", kind: "primary", onClick: () => void downloadRunArtifact(artifact) },
{ label: "推送更新", onClick: () => void pushRunArtifact(artifact) }
]);
}
}
)
}
/>
<RuntimeActionRow
title="run 下载与更新"
description={lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
disabled={!canUse("download-run")}
reason={reasonFor("download-run")}
actionLabel="下载 run"
onAction={() =>
void runOperation(
"下载 run",
async () => {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
setLastDownload(reference);
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="推送更新"
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
secondaryReason={latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
onSecondary={() =>
void runOperation(
"推送 run 更新",
async () => {
const artifact = latestRunArtifact();
if (!artifact) {
throw new Error("请先生成或下载 run 包");
}
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
},
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
description: `将最近 run artifact 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
stages: runtimeUpdateStages,
executeStageIndex: 2
}
)
}
/>
<RuntimeActionRow
title="run 密钥"
description="重置后旧 run 包会失效,必须重新生成并重新部署。"
disabled={!canUse("reset-run-key")}
reason={reasonFor("reset-run-key")}
actionLabel="重置 run 密钥"
danger
onAction={() =>
void runOperation(
"重置 run 密钥",
() => platformApiClient.resetRunKey(instance.id),
(key) => `run 密钥已重置,generation ${key.generation}fingerprint ${key.fingerprint}`
)
}
/>
<RuntimeActionRow
title="客户端管理器"
description={lastClient ? `最近构建 ${lastClient.artifactId}generation ${lastClient.keyGeneration}` : "按插件声明的 profile 构建客户端管理器,使用独立密钥。"}
disabled={!canUse("generate-client-manager")}
reason={reasonFor("generate-client-manager")}
actionLabel="生成客户端"
onAction={() =>
void runOperation(
"生成客户端管理器",
async () => {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({ serverInstanceId: instance.id, profileKey, targetOs, targetArch, repositoryUrl, sourceRevision })
);
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器二进制已构建,artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`,
{
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={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
disabled={!canUse("dependencies-check")}
reason={reasonFor("dependencies-check")}
actionLabel="依赖检查"
onAction={() =>
void runOperation(
"依赖检查",
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
(job) => `依赖检查任务已排队,job ${job.id}`,
{
description: `使用 ${probeKey} probe 检查 ${instance.name} 的运行依赖。`,
stages: runtimeDependencyStages,
executeStageIndex: 1
}
)
}
secondaryLabel="依赖安装"
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
onSecondary={() =>
void runOperation(
"依赖安装",
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
(job) => `依赖安装任务已排队,job ${job.id}`,
{
description: `使用 ${installPlanKey} 安装计划派发依赖安装任务,并保留 job 追踪。`,
stages: runtimeDependencyStages,
executeStageIndex: 2
}
)
}
/>
<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 safeRuntimeRef(ref: string): string {
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
return ref;
}
return "[redacted-ref]";
}
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,
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,
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}</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"];
}
function controlsForPlugin(plugin: GamePluginResponse): PluginControlDescriptor[] {
const controls: PluginControlDescriptor[] = [];
for (const [action] of Object.entries(plugin.lifecycleActions)) {
if (action === "install" || action === "restart" || action === "status") {
continue;
}
if (action !== "start" && action !== "stop") {
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 "restart":
return "重启进程";
default:
return action;
}
}
function PluginControlsSection({ serverId, instance, plugins, artifacts, session, operations }: 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") {
const result =
control.lifecycleAction === "start"
? await platformApiClient.startServerInstance(instance.id, serverLifecycleCommandRequest(instance, "start"))
: await platformApiClient.stopServerInstance(instance.id, serverLifecycleCommandRequest(instance, "stop"));
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) && (
<PluginBridgeExecutionPanel
plugin={plugins.find((plugin) => plugin.id === group.pluginId)!}
serverId={serverId}
serverInstance={instance}
artifacts={artifacts}
/>
)}
{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[];
}
function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifacts }: PluginBridgeExecutionPanelProps) {
const [pendingAction, setPendingAction] = useState<PluginBridgeAction | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const page = 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">
{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 [currentConfig, setCurrentConfig] = useState<string>("");
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
void platformApiClient
.getServerConfig(serverId)
.then((response) => {
if (!cancelled) {
setCurrentConfig(response.content);
}
})
.catch(() => {
setCurrentConfig("");
});
return () => {
cancelled = true;
};
}, [serverId]);
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!prompt.trim()) {
return;
}
setBusy(true);
setSuggestion(null);
try {
const response = await platformApiClient.suggestServerConfig({ serverInstanceId: serverId, prompt: prompt.trim(), currentConfig });
const preview = response.suggestedConfig
? await platformApiClient.previewServerConfigDiff(serverId, {
expectedConfigVersion: instance.configVersion,
key: defaultConfigKey,
proposedContent: response.suggestedConfig
})
: undefined;
setSuggestion({
serverInstanceId: serverId,
source: "api",
recommendation: response.recommendation,
diff: preview ? configDiffViewFromPreview(preview) : undefined
});
} catch {
setSuggestion(buildLocalSuggestion(serverId, prompt.trim(), currentConfig));
} finally {
setBusy(false);
}
}
async function applySuggestion() {
if (!suggestion?.diff) {
return;
}
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
try {
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
expectedConfigVersion: suggestion.diff.configVersion ?? instance.configVersion,
key: suggestion.diff.key ?? defaultConfigKey,
proposedContent: suggestion.diff.nextContent,
proposedContentInputRef: suggestion.diff.proposedContentInputRef,
idempotencyKey: `web:config.write.llm:${serverId}:${Date.now()}`
});
const job = 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);
}
}
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>
)}
<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">{suggestion.source === "api" ? "平台 LLM" : "本地建议(LLM 接口未提供)"}</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={llmOperation?.status === "pending"}
onCancel={() => setConfirming(false)}
onConfirm={() => void applySuggestion()}
/>
</article>
);
}
function buildLocalSuggestion(serverId: string, prompt: string, currentConfig: string): LlmSuggestionView {
const lines = currentConfig.split("\n");
const next = [...lines];
const changed: string[] = [];
const playerMatch = prompt.match(/(\d+)\s*(?:人|名玩家|players?)/i) ?? prompt.match(/玩家[^\d]*(\d+)/);
if (playerMatch) {
const index = next.findIndex((line) => line.startsWith("max-players="));
if (index >= 0) {
next[index] = `max-players=${playerMatch[1]}`;
changed.push(`max-players 调整为 ${playerMatch[1]}`);
}
}
if (/关闭\s*pvp|禁用\s*pvp|pvp.*(off|false|关)/i.test(prompt)) {
const index = next.findIndex((line) => line.startsWith("pvp="));
if (index >= 0) {
next[index] = "pvp=false";
changed.push("pvp 关闭");
}
}
if (/开启\s*pvp|pvp.*(on|true|开)/i.test(prompt)) {
const index = next.findIndex((line) => line.startsWith("pvp="));
if (index >= 0) {
next[index] = "pvp=true";
changed.push("pvp 开启");
}
}
const nextContent = next.join("\n");
if (changed.length === 0) {
return {
serverInstanceId: serverId,
source: "local",
recommendation: `暂时无法为“${prompt}”生成配置差异。平台 LLM 建议接口尚未提供;本地建议引擎只支持常见字段(如 max-players、pvp)。`
};
}
return {
serverInstanceId: serverId,
source: "local",
recommendation: `根据请求“${prompt}”,建议:${changed.join("")}。请确认差异后再写入。`,
diff: buildConfigDiff(serverId, currentConfig, nextContent)
};
}
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,
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[];
}
function HistorySection({ serverId, serverOperations, jobs, artifacts }: HistorySectionProps) {
return (
<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>{new Date(job.updatedAt).toLocaleString()}</span>
</div>
{job.progress.message && <span className="provider-id">{job.progress.message}</span>}
</div>
))}
</div>
)}
</article>
<ArtifactDownloadPanel serverId={serverId} artifacts={artifacts} />
</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`;
}