From fb5adf06d6953bef20a5bdb3c430337c42ffc361 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Fri, 21 Aug 2026 12:03:06 +0800 Subject: [PATCH] Remove run node UI and expire registrations --- platform/repo/resources.go | 1 + platform/service/auth_sessions_test.go | 2 +- platform/service/control.go | 43 ++++ platform/service/control_test.go | 34 +++ platform/service/resources.go | 17 +- .../acceptance/browser-acceptance.mjs | 6 +- .../components/RuntimeTaskProgress.tsx | 4 +- .../components/ServerDeploymentWorkflow.tsx | 4 +- platform_web/contracts/pages.md | 4 +- platform_web/contracts/serverManagement.ts | 2 +- platform_web/contracts/shell.ts | 2 +- platform_web/pages/ConsolePages.test.tsx | 5 +- platform_web/pages/HomePage.tsx | 58 +----- platform_web/pages/MaintenancePage.tsx | 193 ++++++------------ platform_web/pages/ServersPage.tsx | 2 +- platform_web/routes/routes.md | 2 +- platform_web/routes/routes.ts | 2 +- 17 files changed, 175 insertions(+), 206 deletions(-) diff --git a/platform/repo/resources.go b/platform/repo/resources.go index e140ce3..eae19fb 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -60,6 +60,7 @@ type RunEndpointRepository interface { Get(id string) (domain.RunEndpoint, error) List(domain.RunEndpointFilter) ([]domain.RunEndpoint, error) Update(domain.RunEndpoint) error + Delete(id string) error } type JobRepository interface { diff --git a/platform/service/auth_sessions_test.go b/platform/service/auth_sessions_test.go index 607cece..14de4d8 100644 --- a/platform/service/auth_sessions_test.go +++ b/platform/service/auth_sessions_test.go @@ -168,7 +168,7 @@ func TestRunSessionPersistsAndSignedEnvelopeRejectsReplay(t *testing.T) { if err != nil { t.Fatalf("reload store: %v", err) } - reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(time.Minute) }) + reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(runHeartbeatStaleAfter / 2) }) result, err := reloaded.AcceptRunHeartbeat(domain.RunControlHeartbeat{ RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1", Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-jobs", diff --git a/platform/service/control.go b/platform/service/control.go index e8f8ade..e69c077 100644 --- a/platform/service/control.go +++ b/platform/service/control.go @@ -73,6 +73,9 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R svc.controlMu.Lock() defer svc.controlMu.Unlock() + if err := svc.sweepExpiredRunRegistrationsLocked(stamp); err != nil { + return domain.RunControlHelloResult{}, err + } if err := svc.upsertRunEndpoint(endpoint); err != nil { return domain.RunControlHelloResult{}, err } @@ -183,6 +186,9 @@ func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) svc.controlMu.Lock() defer svc.controlMu.Unlock() + if err := svc.sweepExpiredRunRegistrationsLocked(stamp); err != nil { + return domain.RunControlHeartbeatResult{}, err + } session, err := svc.currentRunSession(heartbeat.RunEndpointID, heartbeat.SessionToken) if err != nil { return domain.RunControlHeartbeatResult{}, err @@ -230,6 +236,43 @@ func (svc *CoreService) upsertRunEndpoint(endpoint domain.RunEndpoint) error { return svc.store.RunEndpoints().Update(endpoint) } +func (svc *CoreService) sweepExpiredRunRegistrationsLocked(stamp time.Time) error { + endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{}) + if err != nil { + return err + } + for _, endpoint := range endpoints { + if runEndpointRegistrationCurrentAt(endpoint, stamp) { + continue + } + if err := svc.store.RunEndpoints().Delete(endpoint.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { + return err + } + delete(svc.runSessions, endpoint.ID) + session, err := svc.store.RunControlSessions().Get(endpoint.ID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + continue + } + return err + } + if session.Status != domain.AuthSessionStatusActive { + continue + } + session.Status = domain.AuthSessionStatusRevoked + session.RevokedAt = stamp + session.UpdatedAt = stamp + if err := svc.store.RunControlSessions().Update(session); err != nil { + return err + } + } + return nil +} + +func runEndpointRegistrationCurrentAt(endpoint domain.RunEndpoint, stamp time.Time) bool { + return !endpoint.LastHeartbeatAt.IsZero() && !stamp.After(endpoint.LastHeartbeatAt.Add(runHeartbeatStaleAfter)) +} + func (svc *CoreService) nextSessionToken() (string, error) { return randomToken() } diff --git a/platform/service/control_test.go b/platform/service/control_test.go index 9739763..b147289 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -118,6 +118,40 @@ func TestCoreServiceAcceptsRunHeartbeat(t *testing.T) { } } +func TestCoreServiceClearsRunRegistrationAfterHeartbeatTTL(t *testing.T) { + svc := newTestCoreService() + hello, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register hello: %v", err) + } + + svc.now = func() time.Time { return fixedTime.Add(runHeartbeatStaleAfter - time.Nanosecond) } + active, err := svc.ListRunEndpoints(domain.RunEndpointFilter{Status: domain.RunEndpointStatusOnline}) + if err != nil || len(active) != 1 || active[0].ID != "run-local" { + t.Fatalf("expected registration inside TTL, endpoints=%+v err=%v", active, err) + } + + svc.now = func() time.Time { return fixedTime.Add(runHeartbeatStaleAfter + time.Second) } + if _, err := svc.GetRunEndpoint("run-local"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected expired registration to be cleared, got %v", err) + } + cleared, err := svc.ListRunEndpoints(domain.RunEndpointFilter{}) + if err != nil || len(cleared) != 0 { + t.Fatalf("expected no stale registrations in list, endpoints=%+v err=%v", cleared, err) + } + _, err = svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.1", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-v1", + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err == nil || !strings.Contains(err.Error(), "sessionToken is invalid") { + t.Fatalf("expected stale session to be rejected after registration cleanup, got %v", err) + } +} + func TestCoreServiceRejectsInvalidRunHeartbeatToken(t *testing.T) { svc := newTestCoreService() if _, err := svc.RegisterRunHello(validRunControlHello()); err != nil { diff --git a/platform/service/resources.go b/platform/service/resources.go index 6053660..e56fefc 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -25,7 +25,7 @@ var ( const ( ServerDeletionForceConfirmation = "FORCE DELETE" - runHeartbeatStaleAfter = 2 * time.Minute + runHeartbeatStaleAfter = 30 * time.Second ) type ForbiddenError struct { @@ -1609,10 +1609,20 @@ func (svc *CoreService) CreateRunEndpoint(endpoint domain.RunEndpoint) (domain.R } func (svc *CoreService) GetRunEndpoint(id string) (domain.RunEndpoint, error) { + svc.controlMu.Lock() + defer svc.controlMu.Unlock() + if err := svc.sweepExpiredRunRegistrationsLocked(svc.now()); err != nil { + return domain.RunEndpoint{}, err + } return svc.store.RunEndpoints().Get(id) } func (svc *CoreService) ListRunEndpoints(filter domain.RunEndpointFilter) ([]domain.RunEndpoint, error) { + svc.controlMu.Lock() + defer svc.controlMu.Unlock() + if err := svc.sweepExpiredRunRegistrationsLocked(svc.now()); err != nil { + return nil, err + } return svc.store.RunEndpoints().List(filter) } @@ -2645,10 +2655,7 @@ func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, ca } func (svc *CoreService) runEndpointHeartbeatCurrent(endpoint domain.RunEndpoint) bool { - if endpoint.LastHeartbeatAt.IsZero() { - return false - } - return !svc.now().After(endpoint.LastHeartbeatAt.Add(runHeartbeatStaleAfter)) + return runEndpointRegistrationCurrentAt(endpoint, svc.now()) } func maxInt(a, b int) int { diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs index ade483c..d477551 100644 --- a/platform_web/acceptance/browser-acceptance.mjs +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -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 心跳`, "启动", "停止", "管理", diff --git a/platform_web/components/RuntimeTaskProgress.tsx b/platform_web/components/RuntimeTaskProgress.tsx index 810f744..3be1cad 100644 --- a/platform_web/components/RuntimeTaskProgress.tsx +++ b/platform_web/components/RuntimeTaskProgress.tsx @@ -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 并刷新后台任务状态。" } ]; diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index df40151..21ddbfc 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -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 + return
void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
{step === pluginStep &&
@@ -154,7 +154,7 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: { icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" } ]; - return
确认后,{pluginName} 会这样安装“安装目录”就是游戏服务端、数据和配置将落地的位置;它不是命令执行目录,也不会在日志中回显。
{isScum ? "全部 4 步通过才算安装成功" : "节点按插件契约执行"}
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}

不会做:{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}

; + return
确认后,{pluginName} 会这样安装“安装目录”就是游戏服务端、数据和配置将落地的位置;它不是命令执行目录,也不会在日志中回显。
{isScum ? "全部 4 步通过才算安装成功" : "Run 按插件契约执行"}
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}

不会做:{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}

; } function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { diff --git a/platform_web/contracts/pages.md b/platform_web/contracts/pages.md index 7547861..b4ab01f 100644 --- a/platform_web/contracts/pages.md +++ b/platform_web/contracts/pages.md @@ -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. diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index 72083e9..e70c38d 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -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 = { diff --git a/platform_web/contracts/shell.ts b/platform_web/contracts/shell.ts index 16c1957..a0e44b1 100644 --- a/platform_web/contracts/shell.ts +++ b/platform_web/contracts/shell.ts @@ -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" } ]; diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index af943d2..a4914c2 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -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("最近失败任务"); }); diff --git a/platform_web/pages/HomePage.tsx b/platform_web/pages/HomePage.tsx index d83c6e8..251238a 100644 --- a/platform_web/pages/HomePage.tsx +++ b/platform_web/pages/HomePage.tsx @@ -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(() => 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) {
- {core.status === "loading" && } + {core.status === "loading" && } {core.status === "error" && void refreshCore()} />} {core.status === "ready" && core.data.instances.length === 0 && ( } title="还没有服务器实例" - description={canManageServers ? "平台尚未创建服务器。先确认运行节点在线,再创建第一个实例。" : "当前账号可查看概览,但没有创建服务器的权限。"} + description={canManageServers ? "平台尚未创建服务器。先创建第一个实例,再生成并启动 Run。" : "当前账号可查看概览,但没有创建服务器的权限。"} actionLabel="前往服务器管理" onAction={() => onNavigate("servers")} /> @@ -196,11 +190,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { {offlineCount}

{core.data.instances.filter((item) => item.state === "failed").length} 个异常

-
0 ? "metric-tone-warning" : "metric-tone-neutral")}> - 运行节点 - {endpointSummary?.online ?? 0} 在线 -

{endpointSummary ? `${endpointSummary.degraded} 降级,${endpointSummary.offline} 离线` : "节点状态不可用"}

-
0 ? "metric-tone-warning" : "metric-tone-success")}> AI 提供商 {providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "加载中" : "不可用"} @@ -246,36 +235,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { )}
-
-
-

运行节点

- -
- {core.status === "loading" && } - {core.status === "error" && void refreshCore()} compact />} - {core.status === "ready" && endpointSummary && ( - <> -
-
在线
{endpointSummary.online}
-
活跃任务
{endpointSummary.activeJobs}
-
排队
{endpointSummary.queuedJobs}
-
- {core.data.endpoints.length === 0 ? ( -

Platform 未返回运行节点。

- ) : ( -
- {core.data.endpoints.slice(0, 4).map((endpoint) => ( -
- {endpoint.displayName}{endpoint.version} - {endpointStatusLabel(endpoint.status)} - {endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} 运行 -
- ))} -
- )} - - )} -
@@ -386,8 +345,3 @@ function jobStateLabel(state: JobResponse["state"]): string { const labels: Record = { queued: "排队", accepted: "已领取", running: "执行中", retrying: "等待重试", succeeded: "完成", failed: "失败", cancelled: "已取消" }; return labels[state]; } - -function endpointStatusLabel(status: RunEndpointResponse["status"]): string { - const labels: Record = { online: "在线", offline: "离线", degraded: "降级", disabled: "停用" }; - return labels[status]; -} diff --git a/platform_web/pages/MaintenancePage.tsx b/platform_web/pages/MaintenancePage.tsx index 967e47e..e81119e 100644 --- a/platform_web/pages/MaintenancePage.tsx +++ b/platform_web/pages/MaintenancePage.tsx @@ -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 = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) { - const [endpoints, setEndpoints] = useState>({ status: "loading" }); const [jobs, setJobs] = useState>({ status: "loading" }); const [servers, setServers] = useState>({ 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
维护排障入口 - 从节点详情和最近失败任务进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。 + 从异常服务器和最近失败任务进入重试、查看相关服务器、查看日志链路。
-
{triageResult && } -
+ +
-

运行节点

+

异常服务器

- {endpoints.status === "loading" && } - {endpoints.status === "error" && ( - void refreshEndpoints()} compact /> + {servers.status === "loading" && } + {servers.status === "error" && void refreshServers()} compact />} + {servers.status === "ready" && failedServers.length === 0 && ( + void refreshServers()} /> )} - {servers.status === "error" && void refreshServers()} compact />} - {endpoints.status === "ready" && endpoints.data.length === 0 && ( - void refreshEndpoints()} /> - )} - {endpoints.status === "ready" && endpoints.data.length > 0 && ( -
- {endpoints.data.map((endpoint) => { - const relatedServers = serverItems.filter((server) => server.runEndpointId === endpoint.id); - const firstRelatedServer = relatedServers[0]; - return ( -
-
- {endpoint.displayName} - {endpoint.id} -
- {endpointStatusLabel(endpoint)} - - 任务 {endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs}(排队 {endpoint.capacity.queuedJobs}) - - {heartbeatReason(endpoint)} -
- 节点详情 - 版本 {endpoint.version} - 能力 {endpoint.capabilities.slice(0, 4).join(" / ") || "未上报"} - 相关服务器 {relatedServers.length} -
-
- - - -
-
- ); - })} + {servers.status === "ready" && failedServers.length > 0 && ( +
+ {failedServers.map((server) => ( +
+
+ {server.name} + {serverStateLabel(server.state)} +
+
+ 服务器 {server.id} + 插件 {server.pluginId}@{server.pluginVersion} + {formatTimestamp(server.updatedAt)} +
+
+ + +
+
+ ))}
)}
@@ -194,13 +155,12 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone {jobs.status === "loading" && } {jobs.status === "error" && void refreshJobs()} compact />} {jobs.status === "ready" && failedJobs.length === 0 && ( - void refreshJobs()} /> + void refreshJobs()} /> )} {jobs.status === "ready" && failedJobs.length > 0 && (
{failedJobs.map((job) => { const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined; - const endpoint = endpointById.get(job.runEndpointId); return (
@@ -208,15 +168,10 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone {jobStateLabel(job.state)}
- - 任务 {job.id} - + 任务 {job.id} 服务器 {server ? server.name : job.serverInstanceId ?? "平台任务"} - 节点 {endpoint ? endpoint.displayName : job.runEndpointId} {progressMessage(job)} - - 尝试 {job.attempt}/{job.retryPolicy.maxAttempts} - + 尝试 {job.attempt}/{job.retryPolicy.maxAttempts} {formatTimestamp(job.updatedAt)}
@@ -237,53 +192,10 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
)}
- ); } -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}%`; } diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 017f137..ecf0873 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -580,7 +580,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr title="暂无可管理的服务器" description={ isPlatformAdmin(session) - ? "平台还没有服务器实例。点击上方“创建服务器”开始,或检查运行节点状态。" + ? "平台还没有服务器实例。点击上方“创建服务器”开始,再生成并启动 Run。" : "当前账号名下没有可管理的服务器。如果这不符合预期,请联系平台管理员为你分配服务器,或点击刷新重试。" } actionLabel="刷新" diff --git a/platform_web/routes/routes.md b/platform_web/routes/routes.md index 792da84..98981c3 100644 --- a/platform_web/routes/routes.md +++ b/platform_web/routes/routes.md @@ -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 diff --git a/platform_web/routes/routes.ts b/platform_web/routes/routes.ts index a863251..ca931b6 100644 --- a/platform_web/routes/routes.ts +++ b/platform_web/routes/routes.ts @@ -80,7 +80,7 @@ export const firstPartyRoutes: PageRoute[] = [ label: "系统维护", path: "/maintenance", hash: "#/maintenance", - description: "运行节点健康与失败任务", + description: "服务器异常与失败任务", requiredCapability: "system.maintenance", showInNav: true }