fix: 调试发布run

This commit is contained in:
npc0-hue
2026-07-22 11:44:48 +08:00
parent b06623d0ce
commit 6c3eb6e45f
41 changed files with 1289 additions and 206 deletions
+35
View File
@@ -162,12 +162,16 @@ describe("first-party console pages", () => {
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
expect(serversPageSource).toContain("<ManagementDialog");
expect(serversPageSource).toContain('className="provider-form dialog-form"');
expect(serversPageSource).toContain('name="name"');
expect(serversPageSource).toContain('name="profileKey"');
expect(serversPageSource).toContain("runtimeBindingFields");
expect(serversPageSource).toContain("createProfileUnavailable");
expect(serversPageSource).toContain("当前插件没有声明运行配置");
expect(serversPageSource).toContain("updateBinding(field.key");
expect(serversPageSource).toContain('type={field.sensitive ? "password" : "text"}');
expect(serversPageSource).toContain("serverCreateRequestFromForm(form)");
expect(serversPageSource).not.toContain('name="id"');
expect(serversPageSource).not.toContain("实例 ID");
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
expect(serversPageSource).not.toContain(forbidden);
}
@@ -181,10 +185,41 @@ describe("first-party console pages", () => {
expect(serversPageSource).not.toContain("<details");
});
it("places server deletion in the list runtime actions with password confirmation", () => {
const deleteHandlerSource = serversPageSource.split("async function handleDeleteServer")[1]?.split("function openRunTargetSelection")[0] ?? "";
expect(serversPageSource).toContain("deleteServerInstance(deleteConfirmation.serverInstanceId, { password: deletePassword })");
expect(serversPageSource).toContain("serverDeleteConfirmation(card.instance)");
expect(serversPageSource).toContain("serverDeleteDisabledReason(session, card.instance)");
expect(serversPageSource).toContain("危险操作");
expect(serversPageSource).toContain("删除服务器");
expect(serversPageSource).toContain("请输入当前登录密码");
expect(serversPageSource).toContain("canOpenActions");
expect(deleteHandlerSource).toContain("await refresh();");
expect(deleteHandlerSource).not.toContain("onNavigate");
expect(serverDetailPageSource).not.toContain("deleteServerInstance");
expect(serverDetailPageSource).not.toContain("serverDeleteConfirmation");
});
it("surfaces runtime actions through progress dialogs with build stages", () => {
const generateRunSource = serversPageSource.split("async function generateRunForTarget")[1]?.split("async function handleQuickRuntimeAction")[0] ?? "";
expect(serversPageSource).toContain("RuntimeTaskProgressDialog");
expect(serversPageSource).toContain("选择生成平台");
expect(serversPageSource).toContain("runTargetSelection");
expect(serversPageSource).toContain("runtimeRunBuildStages");
expect(serversPageSource).toContain("requireQuickRuntimeActionAvailable(instance.id, action)");
expect(serversPageSource).toContain("该操作不可用");
expect(generateRunSource.indexOf("runtimeTask.runTrackedTask")).toBeLessThan(generateRunSource.indexOf('await requireQuickRuntimeActionAvailable(instance.id, "generate-run")'));
expect(serverDetailPageSource).toContain("RuntimeTaskProgressDialog");
expect(serverDetailPageSource).toContain("runtimeRunBuildStages");
expect(runtimeTaskProgressSource).toContain("runtimeBuildStages");
expect(runtimeTaskProgressSource).toContain("runtimeRunBuildStages");
expect(runtimeTaskProgressSource).toContain("进度展示");
expect(runtimeTaskProgressSource).toContain("activeStepLabel");
expect(runtimeTaskProgressSource).toContain("平台正在执行当前阶段");
expect(runtimeTaskProgressSource).toContain("拉取 run 更新");
expect(runtimeTaskProgressSource).toContain("检测构建环境");
expect(runtimeTaskProgressSource).toContain("构建中");
expect(runtimeTaskProgressSource).toContain("构建完成");
expect(runtimeTaskProgressSource).toContain("拉取代码");
expect(runtimeTaskProgressSource).toContain("安装环境");
expect(runtimeTaskProgressSource).toContain("编译构建");
+5 -4
View File
@@ -47,12 +47,13 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
});
it("uses typed server metadata and archive APIs without local fallback mutation", () => {
it("uses typed server metadata APIs and keeps destructive deletion out of detail metadata", () => {
expect(serverDetailPageSource).toContain("updateServerInstance(instance.id");
expect(serverDetailPageSource).toContain("archiveServerInstance(instance.id)");
expect(serverDetailPageSource).toContain("serverMetadataUpdateRequestFromForm");
expect(serverDetailPageSource).toContain("canArchiveServer(instance.state)");
expect(serverDetailPageSource).toContain("配置读取不可用");
expect(serverDetailPageSource).not.toContain("deleteServerInstance");
expect(serverDetailPageSource).not.toContain("serverDeleteConfirmation");
expect(serverDetailPageSource).not.toContain("canDeleteServer(instance.state)");
expect(serverDetailPageSource).not.toContain("请输入当前登录密码");
expect(serverDetailPageSource).not.toContain("fallbackConfig");
});
+12 -52
View File
@@ -1,4 +1,4 @@
import { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
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";
@@ -35,6 +35,7 @@ import {
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeRunBuildStages,
runtimeUpdateStages,
type RuntimeTaskDialogAction,
type RuntimeTaskStage,
@@ -43,7 +44,7 @@ import {
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
@@ -60,7 +61,6 @@ import {
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverArchiveConfirmation,
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
@@ -312,7 +312,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
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 })} />}
@@ -360,14 +359,11 @@ interface ServerMetadataSectionProps {
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: (instance: ServerInstanceResponse) => void;
onArchived: () => void;
}
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
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);
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
useEffect(() => {
setDraft(serverMetadataFormFromInstance(instance));
@@ -388,23 +384,6 @@ function ServerMetadataSection({ instance, session, operations, onChanged, onArc
}
}
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">
@@ -423,27 +402,8 @@ function ServerMetadataSection({ instance, session, operations, onChanged, onArc
<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>
);
}
@@ -930,7 +890,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
async function pushRunArtifact(artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
await runOperation(
"推送 run 更新",
"更新 run",
() => platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)),
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
@@ -1050,14 +1010,14 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
},
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建,artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`,
{
description: `${instance.name} 构建 ${targetOs}/${targetArch} run 包,包含拉取代码、安装环境、编译和打包进度。`,
stages: runtimeBuildStages,
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: "推送更新", onClick: () => void pushRunArtifact(artifact) }
{ label: "更新 run", disabled: !serverIsOnline(instance.state), title: serverIsOnline(instance.state) ? "更新 run" : "run 未运行,无法在线更新", onClick: () => void pushRunArtifact(artifact) }
]);
}
}
@@ -1087,12 +1047,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
}
)
}
secondaryLabel="推送更新"
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
secondaryReason={latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
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 更新",
"更新 run",
async () => {
const artifact = latestRunArtifact();
if (!artifact) {
+265 -44
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
@@ -11,13 +11,15 @@ import {
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeRunBuildStages,
runtimeUpdateStages,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { ManagementDialog, UsageMeter } from "../components/OperationControls";
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
canDeleteServer,
defaultServerCreateForm,
endpointLabel,
pluginLabel,
@@ -32,7 +34,8 @@ import {
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverCreateRequestFromForm
serverCreateRequestFromForm,
serverDeleteConfirmation
} from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace";
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
@@ -40,6 +43,12 @@ import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
interface RunTargetSelectionState {
instance: ServerInstanceResponse;
targetOs: string;
targetArch: string;
}
const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
{ id: "all", label: "全部" },
{ id: "online", label: "在线" },
@@ -63,6 +72,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [showCreate, setShowCreate] = useState(false);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
const [deletePassword, setDeletePassword] = useState("");
const [deleteBusy, setDeleteBusy] = useState(false);
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
const refreshList = useCallback(async () => {
setListState("loading");
@@ -175,30 +188,98 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
}
}
async function handleDeleteServer() {
if (!deleteConfirmation) {
return;
}
setDeleteBusy(true);
const operationId = operations.begin({ intent: "删除服务器", targetKind: "server", targetId: deleteConfirmation.serverInstanceId, requester: session.displayName });
try {
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, { password: deletePassword });
operations.succeed(operationId, `服务器已删除:${deleteConfirmation.serverInstanceId}`);
setDeleteConfirmation(null);
setDeletePassword("");
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器删除失败", operationId);
} finally {
setDeleteBusy(false);
setDeletePassword("");
}
}
function openRunTargetSelection(instance: ServerInstanceResponse) {
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
setRuntimeTaskActions([]);
setRunTargetSelection({ instance, targetOs: defaults.runOs, targetArch: "amd64" });
}
async function handleRunTargetSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!runTargetSelection) {
return;
}
const { instance, targetOs, targetArch } = runTargetSelection;
setRunTargetSelection(null);
await generateRunForTarget(instance, targetOs, targetArch);
}
async function generateRunForTarget(instance: ServerInstanceResponse, targetOs: string, targetArch: string) {
const intent = quickRuntimeActionLabel("generate-run");
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:generate-run`, requester: session.displayName });
setRuntimeTaskActions([]);
try {
const distribution = await runtimeTask.runTrackedTask({
title: intent,
description: `${instance.name}${instance.id})选择生成平台 ${targetOs}/${targetArch},正在拉取 run 更新并构建可下载产物。`,
stages: runtimeRunBuildStages,
start: async () => {
await requireQuickRuntimeActionAvailable(instance.id, "generate-run");
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
return { value: distribution, jobId: distribution.buildJobId };
},
poll: (jobId) => platformApiClient.getJob(jobId)
});
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
const message = `run ${distribution.targetOs}/${distribution.targetArch} 已构建完成,artifact ${distribution.artifactId}`;
operations.succeed(operationId, message);
runtimeTask.succeedTask(message);
setRuntimeTaskActions([
{
label: "下载 run",
kind: "primary",
onClick: () => void downloadGeneratedRun(instance, artifact)
},
{
label: "更新 run",
disabled: !serverIsOnline(instance.state),
title: serverIsOnline(instance.state) ? "更新 run" : "run 未运行,无法在线更新",
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
}
]);
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "运行操作失败";
operations.fail(operationId, message, operationId);
runtimeTask.failTask(message);
}
}
async function handleQuickRuntimeAction(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction) {
if (action === "generate-run") {
openRunTargetSelection(instance);
return;
}
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
const intent = quickRuntimeActionLabel(action);
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
setRuntimeTaskActions([]);
let generatedRunArtifact: { artifactId: string; checksum?: string } | null = null;
let generatedClientProfile: string | null = null;
try {
await requireQuickRuntimeActionAvailable(instance.id, action);
let message: string;
if (action === "generate-run") {
const distribution = await runtimeTask.runTrackedTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: runtimeBuildStages,
start: async () => {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
return { value: distribution, jobId: distribution.buildJobId };
},
poll: (jobId) => platformApiClient.getJob(jobId)
});
generatedRunArtifact = { artifactId: distribution.artifactId };
message = `run 二进制已构建并上传,artifact ${distribution.artifactId}`;
} else if (action === "generate-client-manager") {
if (action === "generate-client-manager") {
const distribution = await runtimeTask.runTrackedTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
@@ -261,20 +342,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
}
operations.succeed(operationId, message);
runtimeTask.succeedTask(message);
if (generatedRunArtifact) {
const artifact = generatedRunArtifact;
setRuntimeTaskActions([
{
label: "下载 run",
kind: "primary",
onClick: () => void downloadGeneratedRun(instance, artifact)
},
{
label: "推送更新",
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
}
]);
} else if (generatedClientProfile) {
if (generatedClientProfile) {
const clientProfile = generatedClientProfile;
setRuntimeTaskActions([
{
@@ -314,10 +382,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
async function pushGeneratedRunUpdate(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
const operationId = operations.begin({ intent: "推送 run 更新", targetKind: "server", targetId: `${instance.id}:push-run-update`, requester: session.displayName });
const operationId = operations.begin({ intent: "更新 run", targetKind: "server", targetId: `${instance.id}:push-run-update`, requester: session.displayName });
try {
const message = await runtimeTask.runTask({
title: "推送 run 更新",
title: "更新 run",
description: `${instance.name} 将使用刚生成的 artifact ${artifact.artifactId} 派发 run 自更新任务。`,
stages: runtimeUpdateStages,
executeStageIndex: 2,
@@ -357,6 +425,9 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
}
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
const runTargetPlugin = runTargetSelection ? plugins.find((plugin) => plugin.id === runTargetSelection.instance.pluginId) : undefined;
const runTargetOsOptions = runPlatformOptions(runTargetPlugin, runTargetSelection?.targetOs ?? "linux");
const runTargetArchOptions = ["amd64", "arm64"];
return (
<section className="servers-page" aria-labelledby="server-page-title">
@@ -413,10 +484,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
>
<form className="provider-form dialog-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
<div className="form-grid">
<label>
ID
<input name="id" value={form.id} onChange={updateForm} placeholder="server-example-3" required />
</label>
<label>
<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required />
@@ -481,6 +548,53 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</form>
</ManagementDialog>
<ManagementDialog
open={runTargetSelection !== null}
title="选择生成平台"
description="选择 run 目标平台和架构后,平台会展示拉取 run 更新、检测构建环境、构建中、构建完成的进度。"
onClose={() => setRunTargetSelection(null)}
>
<form className="provider-form dialog-form" onSubmit={(event) => void handleRunTargetSubmit(event)} aria-label="选择生成平台">
<div className="form-grid">
<label>
<select
value={runTargetSelection?.targetOs ?? ""}
onChange={(event) => setRunTargetSelection((current) => (current ? { ...current, targetOs: event.target.value } : current))}
required
>
{runTargetOsOptions.map((option) => (
<option key={option} value={option}>
{runPlatformLabel(option)}
</option>
))}
</select>
</label>
<label>
<select
value={runTargetSelection?.targetArch ?? "amd64"}
onChange={(event) => setRunTargetSelection((current) => (current ? { ...current, targetArch: event.target.value } : current))}
required
>
{runTargetArchOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<div className="confirm-actions">
<button type="button" onClick={() => setRunTargetSelection(null)}></button>
<button type="submit" className="confirm-primary">
<Sparkles size={16} />
<span></span>
</button>
</div>
</form>
</ManagementDialog>
<div className="server-toolbar" role="search">
<Search size={16} aria-hidden="true" />
<input
@@ -533,12 +647,41 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
metricsPending={metricsPending}
metricsUnavailable={Boolean(metricsError)}
canManage={canManageServers}
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
onDelete={() => {
setDeletePassword("");
setDeleteConfirmation(serverDeleteConfirmation(card.instance));
}}
/>
))}
</div>
)}
<ConfirmDialog
open={deleteConfirmation !== null}
title="删除服务器"
description={`确认删除 ${deleteConfirmation?.name ?? ""}${deleteConfirmation?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
confirmLabel="确认删除"
danger
busy={deleteBusy}
confirmDisabled={deletePassword.trim() === ""}
onCancel={() => {
setDeleteConfirmation(null);
setDeletePassword("");
}}
onConfirm={() => void handleDeleteServer()}
>
<label>
<input
type="password"
autoComplete="current-password"
value={deletePassword}
onChange={(event) => setDeletePassword(event.target.value)}
/>
</label>
</ConfirmDialog>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</section>
);
@@ -554,18 +697,43 @@ type ServerQuickRuntimeAction =
| "live-logs"
| "historical-logs";
async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> {
const runtimeActions = await platformApiClient.getServerRuntimeActions(serverInstanceId);
const runtimeAction = runtimeActions.actions.find((candidate) => candidate.key === action);
if (!runtimeAction) {
throw new Error("平台未返回该运行操作");
}
if (!runtimeAction.available) {
throw new Error(`该操作不可用:${runtimeAction.reason || "平台暂未开放该操作"}`);
}
}
function serverDeleteDisabledReason(session: PageComponentProps["session"], instance: ServerInstanceResponse): string {
if (!isPlatformAdmin(session) && instance.ownerUserId !== session.id) {
return "仅创建人或平台管理员可删除";
}
if (!canDeleteServer(instance.state)) {
return "运行中、安装中或已删除的服务器不能直接删除";
}
return "";
}
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;
metricsUnavailable: boolean;
canManage: boolean;
deleteDisabledReason: string;
onOpen: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void;
onDelete: () => void;
}
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpen, onQuickAction }: ServerCardProps) {
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onQuickAction, onDelete }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const canDelete = deleteDisabledReason === "";
const canOpenActions = canManage || canDelete;
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -584,7 +752,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = Math.min(320, Math.max(220, viewportWidth - 24));
const estimatedMenuHeight = 232;
const estimatedMenuHeight = 288;
const left = Math.min(Math.max(12, rect.right - menuWidth), Math.max(12, viewportWidth - menuWidth - 12));
const belowTop = rect.bottom + 8;
const top = belowTop + estimatedMenuHeight <= viewportHeight - 12 ? belowTop : Math.max(12, rect.top - estimatedMenuHeight - 8);
@@ -636,6 +804,9 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
}, [closeMenu, menuOpen]);
const chooseQuickAction = (action: ServerQuickRuntimeAction) => {
if (!canManage) {
return;
}
closeMenu();
onQuickAction(action);
};
@@ -679,7 +850,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
<Sparkles size={14} />
<span></span>
</button>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canManage} title={canManage ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span>
</button>
</div>
@@ -692,13 +863,32 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
<span className="runtime-action-group-label">{group.label}</span>
<div className="runtime-action-grid">
{group.actions.map((action) => (
<button key={action} type="button" className="runtime-action-item" role="menuitem" onClick={() => chooseQuickAction(action)}>
<button key={action} type="button" className="runtime-action-item" role="menuitem" disabled={!canManage} title={canManage ? quickRuntimeActionLabel(action) : "当前账号没有运行操作权限"} onClick={() => chooseQuickAction(action)}>
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</section>
))}
<section className="runtime-action-group" aria-label="危险操作">
<span className="runtime-action-group-label"></span>
<div className="runtime-action-grid">
<button
type="button"
className="runtime-action-item danger-command"
role="menuitem"
disabled={deleteDisabledReason !== ""}
title={deleteDisabledReason || "删除服务器"}
onClick={() => {
closeMenu();
onDelete();
}}
>
<Trash2 size={13} />
<span></span>
</button>
</div>
</section>
</div>,
document.body
)}
@@ -724,7 +914,7 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
case "download-run":
return "下载 run";
case "push-run-update":
return "推送更新";
return "更新 run";
case "generate-client-manager":
return "生成客户端";
case "dependencies-check":
@@ -739,7 +929,10 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
}
function quickRuntimeStages(action: ServerQuickRuntimeAction) {
if (action === "generate-run" || action === "generate-client-manager") {
if (action === "generate-run") {
return runtimeRunBuildStages;
}
if (action === "generate-client-manager") {
return runtimeBuildStages;
}
if (action === "download-run") {
@@ -782,6 +975,34 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) {
};
}
function runPlatformOptions(plugin: GamePluginResponse | undefined, fallback: string): string[] {
const options = new Set<string>();
const add = (value: string | undefined) => {
const normalized = value?.trim().toLowerCase();
if (normalized) {
options.add(normalized);
}
};
add(fallback);
plugin?.supportedOs?.forEach(add);
plugin?.runtimeProfiles?.lifecycleProfiles?.forEach((profile) => profile.platforms?.forEach(add));
["linux", "windows", "darwin"].forEach(add);
return [...options];
}
function runPlatformLabel(platform: string): string {
switch (platform) {
case "linux":
return "Linux";
case "windows":
return "Windows";
case "darwin":
return "macOS";
default:
return platform;
}
}
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
if (typeof value === "number" && Number.isFinite(value)) {
return format(value);