Remove run node UI and expire registrations

This commit is contained in:
npc0-hue
2026-08-21 12:03:06 +08:00
parent b5a366e30d
commit fb5adf06d6
17 changed files with 175 additions and 206 deletions
@@ -132,7 +132,7 @@ async function main() {
{
name: "首页",
hash: "#/home",
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU"]
markers: ["平台概览", "运营数据已同步", "game.example", "CPU"]
},
{
name: "服务器管理",
@@ -170,14 +170,14 @@ async function main() {
{
name: "系统维护",
hash: "#/maintenance",
markers: ["系统维护", "运行节点", "最近失败任务"]
markers: ["系统维护", "异常服务器", "最近失败任务"]
},
{
name: "服务器详情",
hash: `#/servers/${encodeURIComponent(server.id)}`,
markers: [
server.name,
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · Run 心跳`,
"启动",
"停止",
"管理",
@@ -73,7 +73,7 @@ export const runtimeDownloadStages: RuntimeTaskStage[] = [
export const runtimeUpdateStages: RuntimeTaskStage[] = [
{ key: "artifact_lookup", label: "定位产物", description: "读取最近生成或下载的 run artifact。" },
{ key: "checksum_verify", label: "校验签名", description: "确认 checksum 可用于 run 自更新。" },
{ key: "dispatch_job", label: "推送更新", description: "向在线 run 节点派发自更新任务。" },
{ key: "dispatch_job", label: "推送更新", description: "向在线 Run 派发自更新任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
@@ -86,7 +86,7 @@ export const runtimeKeyResetStages: RuntimeTaskStage[] = [
export const runtimeDependencyStages: RuntimeTaskStage[] = [
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
{ key: "env_probe", label: "环境检查", description: "让 Run 评估当前运行环境。" },
{ key: "install_prepare", label: "安装环境", description: "准备依赖安装任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
@@ -104,7 +104,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置";
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "先选择插件类型和服务器名称,再按部署方式填写启动项;Run 由心跳自动识别,不提供节点或部署目标选择。" : "任何运行状态都可以修改部署设置;这里只保存定义,不会直接重启进程。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "先选择插件类型和服务器名称,再按部署方式填写启动项;Run 由心跳自动识别,不提供人工部署目标选择。" : "任何运行状态都可以修改部署设置;这里只保存定义,不会直接重启进程。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
{step === pluginStep && <div className="deployment-workflow-body">
@@ -154,7 +154,7 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum:
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
];
return <section className="guided-install-plan" aria-label="新建并安装执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span></span></div><small>{isScum ? "全部 4 步通过才算安装成功" : "节点按插件契约执行"}</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong>{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}</p></section>;
return <section className="guided-install-plan" aria-label="新建并安装执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span></span></div><small>{isScum ? "全部 4 步通过才算安装成功" : "Run 按插件契约执行"}</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong>{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}</p></section>;
}
function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) {
+2 -2
View File
@@ -12,7 +12,7 @@ All first-party pages inherit the platform_web game-operations style with black-
## 平台概览(原首页)
Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states.
Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states.
## 服务器管理
@@ -39,4 +39,4 @@ Shows configured model providers, base URL, model list, relay mode, status, and
## 系统维护
Shows run endpoint health/capacity and operational events. Platform administrators only.
Shows failed servers, failed tasks, retry actions, and links into the relevant server/detail log context. Platform administrators only.
+1 -1
View File
@@ -56,7 +56,7 @@ export type RuntimeObservationFreshness = "fresh" | "unverified";
export function runtimeObservationFreshness(instance: ServerInstanceResponse, endpoint: RunEndpointResponse | undefined, now = Date.now()): RuntimeObservationFreshness {
if (!endpoint || endpoint.status !== "online") return "unverified";
const heartbeat = Date.parse(endpoint.lastHeartbeatAt);
return Number.isFinite(heartbeat) && now-heartbeat <= 45_000 ? "fresh" : "unverified";
return Number.isFinite(heartbeat) && now-heartbeat <= 30_000 ? "fresh" : "unverified";
}
export const emptyServerCreateForm: ServerCreateFormState = {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginCatalogItem, ServerInstanceView, ShellSummaryItem, UserAcces
export const shellSummary: ShellSummaryItem[] = [
{ label: "服务器实例", value: "2", detail: "1 个运行中,1 个待配置", tone: "success" },
{ label: "运行节点", value: "1", detail: "本地执行器在线", tone: "success" },
{ label: "运行通道", value: "1", detail: "Run 心跳已接入", tone: "success" },
{ label: "插件", value: "2", detail: "桥接能力已就绪", tone: "success" },
{ label: "访问审核", value: "1", detail: "待确认角色变更", tone: "warning" }
];
+2 -3
View File
@@ -82,7 +82,6 @@ describe("first-party console pages", () => {
refreshedAt: "2026-07-18T10:00:00Z",
data: {
instances: [],
endpoints: [],
jobs: [
{
id: "job-failed-1",
@@ -126,7 +125,7 @@ describe("first-party console pages", () => {
{...pageProps()}
session={readOnlySession}
initialState={{
core: { status: "ready", data: { instances: [], endpoints: [], jobs: [] }, refreshedAt: "2026-07-18T10:00:00Z" },
core: { status: "ready", data: { instances: [], jobs: [] }, refreshedAt: "2026-07-18T10:00:00Z" },
metrics: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
usage: { status: "error", reason: "unavailable", diagnosticId: "usage" },
providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }
@@ -374,7 +373,7 @@ describe("first-party console pages", () => {
expect(html).toContain("系统维护");
expect(html).toContain("维护排障入口");
expect(html).toContain("节点详情");
expect(html).toContain("异常服务器");
expect(html).toContain("最近失败任务");
expect(html).toContain("最近失败任务");
});
+6 -52
View File
@@ -4,11 +4,9 @@ import {
Bot,
CakeSlice,
Candy,
CircleGauge,
Info,
MoonStar,
RotateCw,
ServerCog,
Sparkles,
Workflow
} from "lucide-react";
@@ -19,13 +17,12 @@ import type {
AiProviderResponse,
JobResponse,
PlatformResourceUsageResponse,
RunEndpointResponse,
ServerInstanceResponse,
ServerMetricsResponse
} from "../api/types";
import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
import { jobBuckets, moduleFreshnessLabel, type OperationsModuleState } from "../contracts/operationsConsole";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PageComponentProps } from "../contracts/page";
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
@@ -34,7 +31,6 @@ import { cx } from "../utils/classes";
interface OverviewData {
instances: ServerInstanceResponse[];
endpoints: RunEndpointResponse[];
jobs: JobResponse[];
}
@@ -58,14 +54,13 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
const refreshCore = useCallback(async () => {
setCore({ status: "loading" });
try {
const [instances, endpoints, jobs] = await Promise.all([
const [instances, jobs] = await Promise.all([
platformApiClient.listServerInstances(),
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs()
]);
setCore({ status: "ready", data: { instances: instances.items, endpoints: endpoints.items, jobs: jobs.items }, refreshedAt: refreshedNow() });
setCore({ status: "ready", data: { instances: instances.items, jobs: jobs.items }, refreshedAt: refreshedNow() });
} catch (error) {
setCore({ status: "error", reason: errorMessage(error, "服务器、节点或任务加载失败"), diagnosticId: "overview-core" });
setCore({ status: "error", reason: errorMessage(error, "服务器或任务加载失败"), diagnosticId: "overview-core" });
}
}, []);
@@ -126,7 +121,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers), [core, providers]);
const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null;
const endpointSummary = core.status === "ready" ? summarizeEndpointOperations(core.data.endpoints) : null;
const onlineCount = core.status === "ready" ? core.data.instances.filter((item) => serverIsOnline(item.state)).length : 0;
const offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0;
const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 0;
@@ -172,13 +166,13 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
</div>
</header>
{core.status === "loading" && <LoadingState label="正在加载服务器、节点与任务概况…" />}
{core.status === "loading" && <LoadingState label="正在加载服务器与任务概况…" />}
{core.status === "error" && <ErrorState title="平台核心概况不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} />}
{core.status === "ready" && core.data.instances.length === 0 && (
<EmptyState
icon={<CakeSlice size={26} />}
title="还没有服务器实例"
description={canManageServers ? "平台尚未创建服务器。先确认运行节点在线,再创建第一个实例。" : "当前账号可查看概览,但没有创建服务器的权限。"}
description={canManageServers ? "平台尚未创建服务器。先创建第一个实例,再生成并启动 Run。" : "当前账号可查看概览,但没有创建服务器的权限。"}
actionLabel="前往服务器管理"
onAction={() => onNavigate("servers")}
/>
@@ -196,11 +190,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
<strong className="metric-value">{offlineCount}</strong>
<p>{core.data.instances.filter((item) => item.state === "failed").length} </p>
</article>
<article className={cx("overview-card", endpointSummary && endpointSummary.degraded + endpointSummary.offline > 0 ? "metric-tone-warning" : "metric-tone-neutral")}>
<span className="metric-label"></span>
<strong className="metric-value">{endpointSummary?.online ?? 0} 线</strong>
<p>{endpointSummary ? `${endpointSummary.degraded} 降级,${endpointSummary.offline} 离线` : "节点状态不可用"}</p>
</article>
<article className={cx("overview-card", providers.status === "error" || errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
<span className="metric-label">AI </span>
<strong className="metric-value">{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "加载中" : "不可用"}</strong>
@@ -246,36 +235,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
)}
</article>
<article className="console-panel console-module" aria-label="运行节点状态">
<div className="panel-header">
<h2><ServerCog size={16} /> </h2>
<button type="button" className="icon-command" onClick={() => onNavigate("maintenance")}><CircleGauge size={14} /> </button>
</div>
{core.status === "loading" && <LoadingState label="正在汇总节点…" compact />}
{core.status === "error" && <ErrorState title="节点状态不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />}
{core.status === "ready" && endpointSummary && (
<>
<dl className="console-stat-strip">
<div><dt>线</dt><dd>{endpointSummary.online}</dd></div>
<div><dt></dt><dd>{endpointSummary.activeJobs}</dd></div>
<div><dt></dt><dd>{endpointSummary.queuedJobs}</dd></div>
</dl>
{core.data.endpoints.length === 0 ? (
<p className="console-empty-note">Platform </p>
) : (
<div className="console-row-list">
{core.data.endpoints.slice(0, 4).map((endpoint) => (
<div key={endpoint.id} className="console-row">
<span><strong>{endpoint.displayName}</strong><small>{endpoint.version}</small></span>
<span className={cx("status-pill", `status-${endpoint.status}`)}>{endpointStatusLabel(endpoint.status)}</span>
<span>{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} </span>
</div>
))}
</div>
)}
</>
)}
</article>
</section>
<section className="overview-two-col">
<article className="console-panel" aria-label="resource usage">
@@ -386,8 +345,3 @@ function jobStateLabel(state: JobResponse["state"]): string {
const labels: Record<JobResponse["state"], string> = { queued: "排队", accepted: "已领取", running: "执行中", retrying: "等待重试", succeeded: "完成", failed: "失败", cancelled: "已取消" };
return labels[state];
}
function endpointStatusLabel(status: RunEndpointResponse["status"]): string {
const labels: Record<RunEndpointResponse["status"], string> = { online: "在线", offline: "离线", degraded: "降级", disabled: "停用" };
return labels[status];
}
+62 -131
View File
@@ -1,8 +1,8 @@
import { ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
import { AlertTriangle, ListChecks, RotateCcw, Sparkles, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import type { JobResponse, ServerInstanceResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PageComponentProps } from "../contracts/page";
@@ -11,21 +11,10 @@ import { cx } from "../utils/classes";
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const refreshEndpoints = useCallback(async () => {
setEndpoints({ status: "loading" });
try {
const response = await platformApiClient.listRunEndpoints();
setEndpoints({ status: "ready", data: response.items });
} catch (error) {
setEndpoints({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshJobs = useCallback(async () => {
setJobs({ status: "loading" });
try {
@@ -47,22 +36,19 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
}, []);
const refreshAll = useCallback(() => {
void refreshEndpoints();
void refreshJobs();
void refreshServers();
}, [refreshEndpoints, refreshJobs, refreshServers]);
}, [refreshJobs, refreshServers]);
useEffect(() => {
refreshAll();
}, [refreshAll]);
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
const jobItems = jobs.status === "ready" ? jobs.data : [];
const serverItems = servers.status === "ready" ? servers.data : [];
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
const failedServers = useMemo(() => serverItems.filter((server) => server.state === "failed").slice(0, 8), [serverItems]);
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
async function retryJob(job: JobResponse) {
const retryStamp = Date.now();
@@ -106,15 +92,15 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
<div className="form-guidance maintenance-triage-intro">
<strong></strong>
<span> run </span>
<span></span>
</div>
<section className="maintenance-triage-grid" aria-label="维护排障入口">
<button type="button" className="triage-card" onClick={() => void refreshEndpoints()}>
<ServerCog size={18} />
<span></span>
<strong>{endpoints.status === "ready" ? `${unhealthyEndpointCount} 个需关注` : "加载中"}</strong>
<small></small>
<button type="button" className="triage-card" onClick={() => void refreshServers()}>
<AlertTriangle size={18} />
<span></span>
<strong>{servers.status === "ready" ? `${failedServers.length} 个需关注` : "加载中"}</strong>
<small></small>
</button>
<button type="button" className="triage-card" onClick={() => void refreshJobs()}>
<ListChecks size={18} />
@@ -125,64 +111,39 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
</section>
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
<section className="console-panel" aria-label="run endpoints">
<section className="console-panel" aria-label="failed servers">
<div className="panel-header">
<h2></h2>
<h2></h2>
</div>
{endpoints.status === "loading" && <LoadingState label="正在加载运行节点…" compact />}
{endpoints.status === "error" && (
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} compact />
{servers.status === "loading" && <LoadingState label="正在加载服务器…" compact />}
{servers.status === "error" && <ErrorState title="服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
{servers.status === "ready" && failedServers.length === 0 && (
<EmptyState title="暂无异常服务器" description="当前没有处于失败状态的服务器实例。" actionLabel="刷新服务器" onAction={() => void refreshServers()} />
)}
{servers.status === "error" && <ErrorState title="相关服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
{endpoints.status === "ready" && endpoints.data.length === 0 && (
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
)}
{endpoints.status === "ready" && endpoints.data.length > 0 && (
<div className="resource-list maintenance-node-list">
{endpoints.data.map((endpoint) => {
const relatedServers = serverItems.filter((server) => server.runEndpointId === endpoint.id);
const firstRelatedServer = relatedServers[0];
return (
<article key={endpoint.id} className="resource-list-item maintenance-node-item">
<div>
<strong>{endpoint.displayName}</strong>
<span className="provider-id">{endpoint.id}</span>
</div>
<span className={cx("status-pill", endpointStatusClass(endpoint))}>{endpointStatusLabel(endpoint)}</span>
<span>
{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} {endpoint.capacity.queuedJobs}
</span>
<span>{heartbeatReason(endpoint)}</span>
<details className="node-detail">
<summary></summary>
<span> {endpoint.version}</span>
<span> {endpoint.capabilities.slice(0, 4).join(" / ") || "未上报"}</span>
<span> {relatedServers.length}</span>
</details>
<div className="console-row-actions">
<button type="button" className="theme-upload" onClick={() => void refreshEndpoints()}>
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
</button>
</div>
</article>
);
})}
{servers.status === "ready" && failedServers.length > 0 && (
<div className="console-record-list">
{failedServers.map((server) => (
<div key={server.id} className="console-record">
<div className="console-record-head">
<strong>{server.name}</strong>
<span className={cx("status-pill", "status-error")}>{serverStateLabel(server.state)}</span>
</div>
<div className="console-record-meta">
<span> <code>{server.id}</code></span>
<span> {server.pluginId}@{server.pluginVersion}</span>
<span>{formatTimestamp(server.updatedAt)}</span>
</div>
<div className="console-row-actions">
<button type="button" className="theme-upload" onClick={() => onNavigate("serverDetail", { serverId: server.id })}>
</button>
<button type="button" className="theme-upload" onClick={() => onNavigate("serverDetail", { serverId: server.id })}>
</button>
</div>
</div>
))}
</div>
)}
</section>
@@ -194,13 +155,12 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
{jobs.status === "loading" && <LoadingState label="正在加载任务…" compact />}
{jobs.status === "error" && <ErrorState title="任务加载失败" reason={jobs.reason} diagnosticId="maintenance-jobs" onRetry={() => void refreshJobs()} compact />}
{jobs.status === "ready" && failedJobs.length === 0 && (
<EmptyState title="暂无失败任务" description="最近任务没有失败记录;如果节点异常,请先查看运行节点心跳和容量。" actionLabel="刷新任务" onAction={() => void refreshJobs()} />
<EmptyState title="暂无失败任务" description="最近任务没有失败记录。" actionLabel="刷新任务" onAction={() => void refreshJobs()} />
)}
{jobs.status === "ready" && failedJobs.length > 0 && (
<div className="console-record-list">
{failedJobs.map((job) => {
const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined;
const endpoint = endpointById.get(job.runEndpointId);
return (
<div key={job.id} className="console-record">
<div className="console-record-head">
@@ -208,15 +168,10 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
<span className="status-pill status-error">{jobStateLabel(job.state)}</span>
</div>
<div className="console-record-meta">
<span>
<code>{job.id}</code>
</span>
<span> <code>{job.id}</code></span>
<span> {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
<span> {endpoint ? endpoint.displayName : job.runEndpointId}</span>
<span>{progressMessage(job)}</span>
<span>
{job.attempt}/{job.retryPolicy.maxAttempts}
</span>
<span> {job.attempt}/{job.retryPolicy.maxAttempts}</span>
<span>{formatTimestamp(job.updatedAt)}</span>
</div>
<div className="console-row-actions">
@@ -237,53 +192,10 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
</div>
)}
</section>
</div>
);
}
function endpointStatusClass(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) <= 5) {
return "status-active";
}
if (endpoint.status === "offline") {
return "status-error";
}
return "status-disabled";
}
function endpointStatusLabel(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5) {
return "心跳延迟";
}
return endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status;
}
function heartbeatReason(endpoint: RunEndpointResponse): string {
const age = heartbeatAgeMinutes(endpoint.lastHeartbeatAt);
if (!Number.isFinite(age)) {
return `心跳时间异常:${endpoint.lastHeartbeatAt}`;
}
if (endpoint.status === "offline") {
return `心跳异常:节点离线,最后 ${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
if (age > 5) {
return `心跳异常:${Math.round(age)} 分钟未更新`;
}
if (endpoint.capacity.runningJobs >= endpoint.capacity.maxJobs) {
return "容量已满:等待任务会继续排队";
}
return `心跳正常:${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
function heartbeatAgeMinutes(value: string): number {
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) {
return Number.POSITIVE_INFINITY;
}
return (Date.now() - timestamp) / 60000;
}
function jobStateLabel(state: JobResponse["state"]): string {
switch (state) {
case "queued":
@@ -303,6 +215,25 @@ function jobStateLabel(state: JobResponse["state"]): string {
}
}
function serverStateLabel(state: ServerInstanceResponse["state"]): string {
switch (state) {
case "running":
return "运行中";
case "stopped":
return "已停止";
case "installing":
return "安装中";
case "ready":
return "就绪";
case "deleted":
return "已删除";
case "draft":
return "草稿";
default:
return "失败";
}
}
function progressMessage(job: JobResponse): string {
return job.progress.message ? `原因 ${job.progress.message}` : `进度 ${job.progress.percent}%`;
}
+1 -1
View File
@@ -580,7 +580,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
title="暂无可管理的服务器"
description={
isPlatformAdmin(session)
? "平台还没有服务器实例。点击上方“创建服务器”开始,或检查运行节点状态。"
? "平台还没有服务器实例。点击上方“创建服务器”开始,再生成并启动 Run。"
: "当前账号名下没有可管理的服务器。如果这不符合预期,请联系平台管理员为你分配服务器,或点击刷新重试。"
}
actionLabel="刷新"
+1 -1
View File
@@ -10,7 +10,7 @@ First-party routes must be declared here before page implementation.
- `/plugins`: 插件市场.
- `/users`: 用户管理.
- `/ai-providers`: AI 提供商管理.
- `/maintenance`: 系统维护(运行节点与失败任务).
- `/maintenance`: 系统维护(服务器异常与失败任务).
- `/plugin-pages/:pluginId/:routeKey?serverInstanceId=:serverId`: platform-hosted plugin page route with optional server context; IDs are URL encoded and the route is not shown in primary navigation.
## Role Scoping
+1 -1
View File
@@ -80,7 +80,7 @@ export const firstPartyRoutes: PageRoute[] = [
label: "系统维护",
path: "/maintenance",
hash: "#/maintenance",
description: "运行节点健康与失败任务",
description: "服务器异常与失败任务",
requiredCapability: "system.maintenance",
showInNav: true
}