feat: 自动更新
This commit is contained in:
@@ -1,8 +1,19 @@
|
||||
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
|
||||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
|
||||
import {
|
||||
RuntimeTaskProgressDialog,
|
||||
type RuntimeTaskDialogAction,
|
||||
runtimeBuildStages,
|
||||
runtimeDependencyStages,
|
||||
runtimeDownloadStages,
|
||||
runtimeLogStages,
|
||||
runtimeUpdateStages,
|
||||
useRuntimeTaskController
|
||||
} from "../components/RuntimeTaskProgress";
|
||||
import { UsageMeter } from "../components/OperationControls";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
@@ -23,6 +34,7 @@ import {
|
||||
serverCreateRequestFromForm
|
||||
} from "../schemas/serverManagement";
|
||||
import { isPlatformAdmin } from "../contracts/workspace";
|
||||
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
type ListState = "loading" | "ready" | "error";
|
||||
@@ -47,6 +59,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
|
||||
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const runtimeTask = useRuntimeTaskController();
|
||||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setListState("loading");
|
||||
@@ -126,48 +140,175 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
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 {
|
||||
let message = "运行操作已提交";
|
||||
let message: string;
|
||||
if (action === "generate-run") {
|
||||
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
|
||||
message = `run 包已生成,artifact ${distribution.artifactId}`;
|
||||
} else if (action === "download-run") {
|
||||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||
message = `run 下载引用已创建,artifact ${reference.artifactId}`;
|
||||
} else if (action === "push-run-update") {
|
||||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
|
||||
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`;
|
||||
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") {
|
||||
const distribution = await platformApiClient.generateClientManager(
|
||||
instance.id,
|
||||
clientManagerBuildRequest({
|
||||
serverInstanceId: instance.id,
|
||||
profileKey: defaults.clientProfileKey,
|
||||
targetOs: defaults.clientOs,
|
||||
targetArch: "amd64",
|
||||
repositoryUrl: defaults.repositoryUrl,
|
||||
sourceRevision: "main"
|
||||
})
|
||||
);
|
||||
message = `客户端管理器已生成,artifact ${distribution.artifactId}`;
|
||||
} else if (action === "dependencies-check") {
|
||||
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
|
||||
message = `依赖检查任务已排队,job ${job.id}`;
|
||||
} else if (action === "dependencies-install") {
|
||||
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
|
||||
message = `依赖安装任务已排队,job ${job.id}`;
|
||||
} else if (action === "live-logs") {
|
||||
onNavigate("serverDetail", { serverId: instance.id });
|
||||
message = "已打开服务器详情,可切换到日志页查看实时日志";
|
||||
} else if (action === "historical-logs") {
|
||||
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
|
||||
message = `历史日志回填任务已排队,job ${job.id}`;
|
||||
const distribution = await runtimeTask.runTrackedTask({
|
||||
title: intent,
|
||||
description: quickRuntimeTaskDescription(instance, action),
|
||||
stages: runtimeBuildStages,
|
||||
start: async () => {
|
||||
const distribution = await platformApiClient.generateClientManager(
|
||||
instance.id,
|
||||
clientManagerBuildRequest({
|
||||
serverInstanceId: instance.id,
|
||||
profileKey: defaults.clientProfileKey,
|
||||
targetOs: defaults.clientOs,
|
||||
targetArch: "amd64",
|
||||
repositoryUrl: defaults.repositoryUrl,
|
||||
sourceRevision: "main"
|
||||
})
|
||||
);
|
||||
return { value: distribution, jobId: distribution.buildJobId };
|
||||
},
|
||||
poll: (jobId) => platformApiClient.getJob(jobId)
|
||||
});
|
||||
generatedClientProfile = defaults.clientProfileKey;
|
||||
message = `客户端二进制已构建并上传,artifact ${distribution.artifactId}`;
|
||||
} else {
|
||||
message = await runtimeTask.runTask({
|
||||
title: intent,
|
||||
description: quickRuntimeTaskDescription(instance, action),
|
||||
stages: quickRuntimeStages(action),
|
||||
executeStageIndex: quickRuntimeExecuteStageIndex(action),
|
||||
execute: async () => {
|
||||
if (action === "download-run") {
|
||||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||||
return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`;
|
||||
}
|
||||
if (action === "push-run-update") {
|
||||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
|
||||
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
|
||||
}
|
||||
if (action === "dependencies-check") {
|
||||
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
|
||||
return `依赖检查任务已排队,job ${job.id}`;
|
||||
}
|
||||
if (action === "dependencies-install") {
|
||||
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
|
||||
return `依赖安装任务已排队,job ${job.id}`;
|
||||
}
|
||||
if (action === "live-logs") {
|
||||
onNavigate("serverDetail", { serverId: instance.id });
|
||||
return "已打开服务器详情,可切换到日志页查看实时日志";
|
||||
}
|
||||
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
|
||||
return `历史日志回填任务已排队,job ${job.id}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
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) {
|
||||
const clientProfile = generatedClientProfile;
|
||||
setRuntimeTaskActions([
|
||||
{
|
||||
label: "下载客户端",
|
||||
kind: "primary",
|
||||
onClick: () => void downloadGeneratedClient(instance, clientProfile)
|
||||
}
|
||||
]);
|
||||
}
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId);
|
||||
const message = error instanceof Error ? error.message : "运行操作失败";
|
||||
operations.fail(operationId, message, operationId);
|
||||
runtimeTask.failTask(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadGeneratedRun(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
|
||||
setRuntimeTaskActions([]);
|
||||
try {
|
||||
const message = 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);
|
||||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||||
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
|
||||
}
|
||||
});
|
||||
runtimeTask.succeedTask(message);
|
||||
} catch (error) {
|
||||
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
try {
|
||||
const message = await runtimeTask.runTask({
|
||||
title: "推送 run 更新",
|
||||
description: `${instance.name} 将使用刚生成的 artifact ${artifact.artifactId} 派发 run 自更新任务。`,
|
||||
stages: runtimeUpdateStages,
|
||||
executeStageIndex: 2,
|
||||
execute: async () => {
|
||||
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
|
||||
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
|
||||
}
|
||||
});
|
||||
operations.succeed(operationId, message);
|
||||
runtimeTask.succeedTask(message);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "推送 run 更新失败";
|
||||
operations.fail(operationId, message, operationId);
|
||||
runtimeTask.failTask(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadGeneratedClient(instance: ServerInstanceResponse, profileKey: string) {
|
||||
setRuntimeTaskActions([]);
|
||||
try {
|
||||
const message = await runtimeTask.runTask({
|
||||
title: "下载客户端",
|
||||
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
|
||||
stages: runtimeDownloadStages,
|
||||
executeStageIndex: 1,
|
||||
execute: async () => {
|
||||
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
|
||||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||||
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
|
||||
}
|
||||
});
|
||||
runtimeTask.succeedTask(message);
|
||||
} catch (error) {
|
||||
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +448,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -331,6 +473,80 @@ interface ServerCardProps {
|
||||
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
|
||||
const { instance, metrics, pendingJobs } = card;
|
||||
const online = serverIsOnline(instance.state);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||||
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
const trigger = menuButtonRef.current;
|
||||
if (!trigger) {
|
||||
setMenuOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const menuWidth = Math.min(320, Math.max(220, viewportWidth - 24));
|
||||
const estimatedMenuHeight = 232;
|
||||
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);
|
||||
|
||||
setMenuStyle({ left, top, width: menuWidth });
|
||||
setMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
if (menuOpen) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
openMenu();
|
||||
}, [closeMenu, menuOpen, openMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (menuButtonRef.current?.contains(target) || menuPanelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
menuButtonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", closeMenu);
|
||||
window.addEventListener("scroll", closeMenu, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", closeMenu);
|
||||
window.removeEventListener("scroll", closeMenu, true);
|
||||
};
|
||||
}, [closeMenu, menuOpen]);
|
||||
|
||||
const chooseQuickAction = (action: ServerQuickRuntimeAction) => {
|
||||
closeMenu();
|
||||
onQuickAction(action);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
|
||||
<div className="server-card-head">
|
||||
@@ -368,31 +584,42 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
|
||||
<Sparkles size={14} />
|
||||
<span>详情</span>
|
||||
</button>
|
||||
<details className="runtime-action-menu">
|
||||
<summary className="icon-command">运行操作</summary>
|
||||
<div className="action-list">
|
||||
{serverQuickActions.map((action) => (
|
||||
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
|
||||
<Candy size={13} />
|
||||
<span>{quickRuntimeActionLabel(action)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<button ref={menuButtonRef} type="button" className="icon-command" aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
|
||||
<span>运行操作</span>
|
||||
</button>
|
||||
</div>
|
||||
{menuOpen &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div ref={menuPanelRef} className="runtime-action-popover" style={menuStyle} role="menu" aria-label={`${instance.name} 运行操作`}>
|
||||
{serverQuickActionGroups.map((group) => (
|
||||
<section key={group.label} className="runtime-action-group" aria-label={group.label}>
|
||||
<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)}>
|
||||
<span>{quickRuntimeActionLabel(action)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const serverQuickActions: ServerQuickRuntimeAction[] = [
|
||||
"generate-run",
|
||||
"download-run",
|
||||
"push-run-update",
|
||||
"generate-client-manager",
|
||||
"dependencies-check",
|
||||
"dependencies-install",
|
||||
"live-logs",
|
||||
"historical-logs"
|
||||
const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntimeAction[] }> = [
|
||||
{
|
||||
label: "运行分发",
|
||||
actions: ["generate-run", "download-run", "push-run-update", "generate-client-manager"]
|
||||
},
|
||||
{
|
||||
label: "诊断维护",
|
||||
actions: ["dependencies-check", "dependencies-install", "live-logs", "historical-logs"]
|
||||
}
|
||||
];
|
||||
|
||||
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||
@@ -416,6 +643,37 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||
}
|
||||
}
|
||||
|
||||
function quickRuntimeStages(action: ServerQuickRuntimeAction) {
|
||||
if (action === "generate-run" || action === "generate-client-manager") {
|
||||
return runtimeBuildStages;
|
||||
}
|
||||
if (action === "download-run") {
|
||||
return runtimeDownloadStages;
|
||||
}
|
||||
if (action === "push-run-update") {
|
||||
return runtimeUpdateStages;
|
||||
}
|
||||
if (action === "dependencies-check" || action === "dependencies-install") {
|
||||
return runtimeDependencyStages;
|
||||
}
|
||||
return runtimeLogStages;
|
||||
}
|
||||
|
||||
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
|
||||
if (action === "generate-run" || action === "generate-client-manager") {
|
||||
return 3;
|
||||
}
|
||||
if (action === "push-run-update") {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function quickRuntimeTaskDescription(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction): string {
|
||||
const label = quickRuntimeActionLabel(action);
|
||||
return `${instance.name}(${instance.id})${label},通过平台 API 派发并保留可追踪进度。`;
|
||||
}
|
||||
|
||||
function quickRuntimeDefaultsForPlugin(pluginId: string) {
|
||||
const isScum = pluginId.toLowerCase().includes("scum");
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user