fix: 调试发布run
This commit is contained in:
@@ -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("编译构建");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user