Remove run node UI and expire registrations
This commit is contained in:
@@ -60,6 +60,7 @@ type RunEndpointRepository interface {
|
|||||||
Get(id string) (domain.RunEndpoint, error)
|
Get(id string) (domain.RunEndpoint, error)
|
||||||
List(domain.RunEndpointFilter) ([]domain.RunEndpoint, error)
|
List(domain.RunEndpointFilter) ([]domain.RunEndpoint, error)
|
||||||
Update(domain.RunEndpoint) error
|
Update(domain.RunEndpoint) error
|
||||||
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobRepository interface {
|
type JobRepository interface {
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ func TestRunSessionPersistsAndSignedEnvelopeRejectsReplay(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reload store: %v", err)
|
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{
|
result, err := reloaded.AcceptRunHeartbeat(domain.RunControlHeartbeat{
|
||||||
RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1",
|
RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1",
|
||||||
Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-jobs",
|
Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-jobs",
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
|
|||||||
svc.controlMu.Lock()
|
svc.controlMu.Lock()
|
||||||
defer svc.controlMu.Unlock()
|
defer svc.controlMu.Unlock()
|
||||||
|
|
||||||
|
if err := svc.sweepExpiredRunRegistrationsLocked(stamp); err != nil {
|
||||||
|
return domain.RunControlHelloResult{}, err
|
||||||
|
}
|
||||||
if err := svc.upsertRunEndpoint(endpoint); err != nil {
|
if err := svc.upsertRunEndpoint(endpoint); err != nil {
|
||||||
return domain.RunControlHelloResult{}, err
|
return domain.RunControlHelloResult{}, err
|
||||||
}
|
}
|
||||||
@@ -183,6 +186,9 @@ func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat)
|
|||||||
svc.controlMu.Lock()
|
svc.controlMu.Lock()
|
||||||
defer svc.controlMu.Unlock()
|
defer svc.controlMu.Unlock()
|
||||||
|
|
||||||
|
if err := svc.sweepExpiredRunRegistrationsLocked(stamp); err != nil {
|
||||||
|
return domain.RunControlHeartbeatResult{}, err
|
||||||
|
}
|
||||||
session, err := svc.currentRunSession(heartbeat.RunEndpointID, heartbeat.SessionToken)
|
session, err := svc.currentRunSession(heartbeat.RunEndpointID, heartbeat.SessionToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RunControlHeartbeatResult{}, err
|
return domain.RunControlHeartbeatResult{}, err
|
||||||
@@ -230,6 +236,43 @@ func (svc *CoreService) upsertRunEndpoint(endpoint domain.RunEndpoint) error {
|
|||||||
return svc.store.RunEndpoints().Update(endpoint)
|
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) {
|
func (svc *CoreService) nextSessionToken() (string, error) {
|
||||||
return randomToken()
|
return randomToken()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestCoreServiceRejectsInvalidRunHeartbeatToken(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
if _, err := svc.RegisterRunHello(validRunControlHello()); err != nil {
|
if _, err := svc.RegisterRunHello(validRunControlHello()); err != nil {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ var (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ServerDeletionForceConfirmation = "FORCE DELETE"
|
ServerDeletionForceConfirmation = "FORCE DELETE"
|
||||||
runHeartbeatStaleAfter = 2 * time.Minute
|
runHeartbeatStaleAfter = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type ForbiddenError struct {
|
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) {
|
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)
|
return svc.store.RunEndpoints().Get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) ListRunEndpoints(filter domain.RunEndpointFilter) ([]domain.RunEndpoint, error) {
|
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)
|
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 {
|
func (svc *CoreService) runEndpointHeartbeatCurrent(endpoint domain.RunEndpoint) bool {
|
||||||
if endpoint.LastHeartbeatAt.IsZero() {
|
return runEndpointRegistrationCurrentAt(endpoint, svc.now())
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !svc.now().After(endpoint.LastHeartbeatAt.Add(runHeartbeatStaleAfter))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func maxInt(a, b int) int {
|
func maxInt(a, b int) int {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "首页",
|
name: "首页",
|
||||||
hash: "#/home",
|
hash: "#/home",
|
||||||
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU"]
|
markers: ["平台概览", "运营数据已同步", "game.example", "CPU"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "服务器管理",
|
name: "服务器管理",
|
||||||
@@ -170,14 +170,14 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "系统维护",
|
name: "系统维护",
|
||||||
hash: "#/maintenance",
|
hash: "#/maintenance",
|
||||||
markers: ["系统维护", "运行节点", "最近失败任务"]
|
markers: ["系统维护", "异常服务器", "最近失败任务"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "服务器详情",
|
name: "服务器详情",
|
||||||
hash: `#/servers/${encodeURIComponent(server.id)}`,
|
hash: `#/servers/${encodeURIComponent(server.id)}`,
|
||||||
markers: [
|
markers: [
|
||||||
server.name,
|
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[] = [
|
export const runtimeUpdateStages: RuntimeTaskStage[] = [
|
||||||
{ key: "artifact_lookup", label: "定位产物", description: "读取最近生成或下载的 run artifact。" },
|
{ key: "artifact_lookup", label: "定位产物", description: "读取最近生成或下载的 run artifact。" },
|
||||||
{ key: "checksum_verify", label: "校验签名", description: "确认 checksum 可用于 run 自更新。" },
|
{ 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 并刷新后台任务状态。" }
|
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export const runtimeKeyResetStages: RuntimeTaskStage[] = [
|
|||||||
|
|
||||||
export const runtimeDependencyStages: RuntimeTaskStage[] = [
|
export const runtimeDependencyStages: RuntimeTaskStage[] = [
|
||||||
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
|
{ 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: "install_prepare", label: "安装环境", description: "准备依赖安装任务。" },
|
||||||
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
|
{ 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 protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
|
||||||
const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置";
|
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" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
|
<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>
|
<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">
|
{step === pluginStep && <div className="deployment-workflow-body">
|
||||||
@@ -154,7 +154,7 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum:
|
|||||||
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
|
{ 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 }) {
|
function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export type RuntimeObservationFreshness = "fresh" | "unverified";
|
|||||||
export function runtimeObservationFreshness(instance: ServerInstanceResponse, endpoint: RunEndpointResponse | undefined, now = Date.now()): RuntimeObservationFreshness {
|
export function runtimeObservationFreshness(instance: ServerInstanceResponse, endpoint: RunEndpointResponse | undefined, now = Date.now()): RuntimeObservationFreshness {
|
||||||
if (!endpoint || endpoint.status !== "online") return "unverified";
|
if (!endpoint || endpoint.status !== "online") return "unverified";
|
||||||
const heartbeat = Date.parse(endpoint.lastHeartbeatAt);
|
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 = {
|
export const emptyServerCreateForm: ServerCreateFormState = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { PluginCatalogItem, ServerInstanceView, ShellSummaryItem, UserAcces
|
|||||||
|
|
||||||
export const shellSummary: ShellSummaryItem[] = [
|
export const shellSummary: ShellSummaryItem[] = [
|
||||||
{ label: "服务器实例", value: "2", detail: "1 个运行中,1 个待配置", tone: "success" },
|
{ 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: "2", detail: "桥接能力已就绪", tone: "success" },
|
||||||
{ label: "访问审核", value: "1", detail: "待确认角色变更", tone: "warning" }
|
{ label: "访问审核", value: "1", detail: "待确认角色变更", tone: "warning" }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ describe("first-party console pages", () => {
|
|||||||
refreshedAt: "2026-07-18T10:00:00Z",
|
refreshedAt: "2026-07-18T10:00:00Z",
|
||||||
data: {
|
data: {
|
||||||
instances: [],
|
instances: [],
|
||||||
endpoints: [],
|
|
||||||
jobs: [
|
jobs: [
|
||||||
{
|
{
|
||||||
id: "job-failed-1",
|
id: "job-failed-1",
|
||||||
@@ -126,7 +125,7 @@ describe("first-party console pages", () => {
|
|||||||
{...pageProps()}
|
{...pageProps()}
|
||||||
session={readOnlySession}
|
session={readOnlySession}
|
||||||
initialState={{
|
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" },
|
metrics: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
|
||||||
usage: { status: "error", reason: "unavailable", diagnosticId: "usage" },
|
usage: { status: "error", reason: "unavailable", diagnosticId: "usage" },
|
||||||
providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }
|
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("异常服务器");
|
||||||
expect(html).toContain("最近失败任务");
|
expect(html).toContain("最近失败任务");
|
||||||
expect(html).toContain("最近失败任务");
|
expect(html).toContain("最近失败任务");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
CakeSlice,
|
CakeSlice,
|
||||||
Candy,
|
Candy,
|
||||||
CircleGauge,
|
|
||||||
Info,
|
Info,
|
||||||
MoonStar,
|
MoonStar,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
ServerCog,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Workflow
|
Workflow
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -19,13 +17,12 @@ import type {
|
|||||||
AiProviderResponse,
|
AiProviderResponse,
|
||||||
JobResponse,
|
JobResponse,
|
||||||
PlatformResourceUsageResponse,
|
PlatformResourceUsageResponse,
|
||||||
RunEndpointResponse,
|
|
||||||
ServerInstanceResponse,
|
ServerInstanceResponse,
|
||||||
ServerMetricsResponse
|
ServerMetricsResponse
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
import { UsageMeter } from "../components/OperationControls";
|
import { UsageMeter } from "../components/OperationControls";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
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 { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
|
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
|
||||||
@@ -34,7 +31,6 @@ import { cx } from "../utils/classes";
|
|||||||
|
|
||||||
interface OverviewData {
|
interface OverviewData {
|
||||||
instances: ServerInstanceResponse[];
|
instances: ServerInstanceResponse[];
|
||||||
endpoints: RunEndpointResponse[];
|
|
||||||
jobs: JobResponse[];
|
jobs: JobResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +54,13 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
const refreshCore = useCallback(async () => {
|
const refreshCore = useCallback(async () => {
|
||||||
setCore({ status: "loading" });
|
setCore({ status: "loading" });
|
||||||
try {
|
try {
|
||||||
const [instances, endpoints, jobs] = await Promise.all([
|
const [instances, jobs] = await Promise.all([
|
||||||
platformApiClient.listServerInstances(),
|
platformApiClient.listServerInstances(),
|
||||||
platformApiClient.listRunEndpoints(),
|
|
||||||
platformApiClient.listJobs()
|
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) {
|
} 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 overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers), [core, providers]);
|
||||||
const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null;
|
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 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 offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0;
|
||||||
const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 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>
|
</div>
|
||||||
</header>
|
</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 === "error" && <ErrorState title="平台核心概况不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} />}
|
||||||
{core.status === "ready" && core.data.instances.length === 0 && (
|
{core.status === "ready" && core.data.instances.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<CakeSlice size={26} />}
|
icon={<CakeSlice size={26} />}
|
||||||
title="还没有服务器实例"
|
title="还没有服务器实例"
|
||||||
description={canManageServers ? "平台尚未创建服务器。先确认运行节点在线,再创建第一个实例。" : "当前账号可查看概览,但没有创建服务器的权限。"}
|
description={canManageServers ? "平台尚未创建服务器。先创建第一个实例,再生成并启动 Run。" : "当前账号可查看概览,但没有创建服务器的权限。"}
|
||||||
actionLabel="前往服务器管理"
|
actionLabel="前往服务器管理"
|
||||||
onAction={() => onNavigate("servers")}
|
onAction={() => onNavigate("servers")}
|
||||||
/>
|
/>
|
||||||
@@ -196,11 +190,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
<strong className="metric-value">{offlineCount}</strong>
|
<strong className="metric-value">{offlineCount}</strong>
|
||||||
<p>{core.data.instances.filter((item) => item.state === "failed").length} 个异常</p>
|
<p>{core.data.instances.filter((item) => item.state === "failed").length} 个异常</p>
|
||||||
</article>
|
</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")}>
|
<article className={cx("overview-card", providers.status === "error" || errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
|
||||||
<span className="metric-label">AI 提供商</span>
|
<span className="metric-label">AI 提供商</span>
|
||||||
<strong className="metric-value">{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "加载中" : "不可用"}</strong>
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
<section className="overview-two-col">
|
<section className="overview-two-col">
|
||||||
<article className="console-panel" aria-label="resource usage">
|
<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: "已取消" };
|
const labels: Record<JobResponse["state"], string> = { queued: "排队", accepted: "已领取", running: "执行中", retrying: "等待重试", succeeded: "完成", failed: "失败", cancelled: "已取消" };
|
||||||
return labels[state];
|
return labels[state];
|
||||||
}
|
}
|
||||||
|
|
||||||
function endpointStatusLabel(status: RunEndpointResponse["status"]): string {
|
|
||||||
const labels: Record<RunEndpointResponse["status"], string> = { online: "在线", offline: "离线", degraded: "降级", disabled: "停用" };
|
|
||||||
return labels[status];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
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 { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
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 };
|
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||||
|
|
||||||
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
|
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
|
||||||
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
|
|
||||||
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
|
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
|
||||||
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
|
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
|
||||||
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
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 () => {
|
const refreshJobs = useCallback(async () => {
|
||||||
setJobs({ status: "loading" });
|
setJobs({ status: "loading" });
|
||||||
try {
|
try {
|
||||||
@@ -47,22 +36,19 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
void refreshEndpoints();
|
|
||||||
void refreshJobs();
|
void refreshJobs();
|
||||||
void refreshServers();
|
void refreshServers();
|
||||||
}, [refreshEndpoints, refreshJobs, refreshServers]);
|
}, [refreshJobs, refreshServers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}, [refreshAll]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
|
|
||||||
const jobItems = jobs.status === "ready" ? jobs.data : [];
|
const jobItems = jobs.status === "ready" ? jobs.data : [];
|
||||||
const serverItems = servers.status === "ready" ? servers.data : [];
|
const serverItems = servers.status === "ready" ? servers.data : [];
|
||||||
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
|
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 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) {
|
async function retryJob(job: JobResponse) {
|
||||||
const retryStamp = Date.now();
|
const retryStamp = Date.now();
|
||||||
@@ -106,15 +92,15 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
|
|
||||||
<div className="form-guidance maintenance-triage-intro">
|
<div className="form-guidance maintenance-triage-intro">
|
||||||
<strong>维护排障入口</strong>
|
<strong>维护排障入口</strong>
|
||||||
<span>从节点详情和最近失败任务进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。</span>
|
<span>从异常服务器和最近失败任务进入重试、查看相关服务器、查看日志链路。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="maintenance-triage-grid" aria-label="维护排障入口">
|
<section className="maintenance-triage-grid" aria-label="维护排障入口">
|
||||||
<button type="button" className="triage-card" onClick={() => void refreshEndpoints()}>
|
<button type="button" className="triage-card" onClick={() => void refreshServers()}>
|
||||||
<ServerCog size={18} />
|
<AlertTriangle size={18} />
|
||||||
<span>节点详情</span>
|
<span>异常服务器</span>
|
||||||
<strong>{endpoints.status === "ready" ? `${unhealthyEndpointCount} 个需关注` : "加载中"}</strong>
|
<strong>{servers.status === "ready" ? `${failedServers.length} 个需关注` : "加载中"}</strong>
|
||||||
<small>查看心跳、容量、能力标签、相关服务器和日志链路。</small>
|
<small>查看失败实例、更新时间和关联日志链路。</small>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="triage-card" onClick={() => void refreshJobs()}>
|
<button type="button" className="triage-card" onClick={() => void refreshJobs()}>
|
||||||
<ListChecks size={18} />
|
<ListChecks size={18} />
|
||||||
@@ -125,64 +111,39 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
{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">
|
<div className="panel-header">
|
||||||
<h2>运行节点</h2>
|
<h2>异常服务器</h2>
|
||||||
</div>
|
</div>
|
||||||
{endpoints.status === "loading" && <LoadingState label="正在加载运行节点…" compact />}
|
{servers.status === "loading" && <LoadingState label="正在加载服务器…" compact />}
|
||||||
{endpoints.status === "error" && (
|
{servers.status === "error" && <ErrorState title="服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
|
||||||
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} 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 />}
|
{servers.status === "ready" && failedServers.length > 0 && (
|
||||||
{endpoints.status === "ready" && endpoints.data.length === 0 && (
|
<div className="console-record-list">
|
||||||
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
|
{failedServers.map((server) => (
|
||||||
)}
|
<div key={server.id} className="console-record">
|
||||||
{endpoints.status === "ready" && endpoints.data.length > 0 && (
|
<div className="console-record-head">
|
||||||
<div className="resource-list maintenance-node-list">
|
<strong>{server.name}</strong>
|
||||||
{endpoints.data.map((endpoint) => {
|
<span className={cx("status-pill", "status-error")}>{serverStateLabel(server.state)}</span>
|
||||||
const relatedServers = serverItems.filter((server) => server.runEndpointId === endpoint.id);
|
</div>
|
||||||
const firstRelatedServer = relatedServers[0];
|
<div className="console-record-meta">
|
||||||
return (
|
<span>服务器 <code>{server.id}</code></span>
|
||||||
<article key={endpoint.id} className="resource-list-item maintenance-node-item">
|
<span>插件 {server.pluginId}@{server.pluginVersion}</span>
|
||||||
<div>
|
<span>{formatTimestamp(server.updatedAt)}</span>
|
||||||
<strong>{endpoint.displayName}</strong>
|
</div>
|
||||||
<span className="provider-id">{endpoint.id}</span>
|
<div className="console-row-actions">
|
||||||
</div>
|
<button type="button" className="theme-upload" onClick={() => onNavigate("serverDetail", { serverId: server.id })}>
|
||||||
<span className={cx("status-pill", endpointStatusClass(endpoint))}>{endpointStatusLabel(endpoint)}</span>
|
查看相关服务器
|
||||||
<span>
|
</button>
|
||||||
任务 {endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs}(排队 {endpoint.capacity.queuedJobs})
|
<button type="button" className="theme-upload" onClick={() => onNavigate("serverDetail", { serverId: server.id })}>
|
||||||
</span>
|
查看日志链路
|
||||||
<span>{heartbeatReason(endpoint)}</span>
|
</button>
|
||||||
<details className="node-detail">
|
</div>
|
||||||
<summary>节点详情</summary>
|
</div>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -194,13 +155,12 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
{jobs.status === "loading" && <LoadingState label="正在加载任务…" compact />}
|
{jobs.status === "loading" && <LoadingState label="正在加载任务…" compact />}
|
||||||
{jobs.status === "error" && <ErrorState title="任务加载失败" reason={jobs.reason} diagnosticId="maintenance-jobs" onRetry={() => void refreshJobs()} compact />}
|
{jobs.status === "error" && <ErrorState title="任务加载失败" reason={jobs.reason} diagnosticId="maintenance-jobs" onRetry={() => void refreshJobs()} compact />}
|
||||||
{jobs.status === "ready" && failedJobs.length === 0 && (
|
{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 && (
|
{jobs.status === "ready" && failedJobs.length > 0 && (
|
||||||
<div className="console-record-list">
|
<div className="console-record-list">
|
||||||
{failedJobs.map((job) => {
|
{failedJobs.map((job) => {
|
||||||
const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined;
|
const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined;
|
||||||
const endpoint = endpointById.get(job.runEndpointId);
|
|
||||||
return (
|
return (
|
||||||
<div key={job.id} className="console-record">
|
<div key={job.id} className="console-record">
|
||||||
<div className="console-record-head">
|
<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>
|
<span className="status-pill status-error">{jobStateLabel(job.state)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="console-record-meta">
|
<div className="console-record-meta">
|
||||||
<span>
|
<span>任务 <code>{job.id}</code></span>
|
||||||
任务 <code>{job.id}</code>
|
|
||||||
</span>
|
|
||||||
<span>服务器 {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
|
<span>服务器 {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
|
||||||
<span>节点 {endpoint ? endpoint.displayName : job.runEndpointId}</span>
|
|
||||||
<span>{progressMessage(job)}</span>
|
<span>{progressMessage(job)}</span>
|
||||||
<span>
|
<span>尝试 {job.attempt}/{job.retryPolicy.maxAttempts}</span>
|
||||||
尝试 {job.attempt}/{job.retryPolicy.maxAttempts}
|
|
||||||
</span>
|
|
||||||
<span>{formatTimestamp(job.updatedAt)}</span>
|
<span>{formatTimestamp(job.updatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="console-row-actions">
|
<div className="console-row-actions">
|
||||||
@@ -237,53 +192,10 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</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 {
|
function jobStateLabel(state: JobResponse["state"]): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "queued":
|
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 {
|
function progressMessage(job: JobResponse): string {
|
||||||
return job.progress.message ? `原因 ${job.progress.message}` : `进度 ${job.progress.percent}%`;
|
return job.progress.message ? `原因 ${job.progress.message}` : `进度 ${job.progress.percent}%`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
title="暂无可管理的服务器"
|
title="暂无可管理的服务器"
|
||||||
description={
|
description={
|
||||||
isPlatformAdmin(session)
|
isPlatformAdmin(session)
|
||||||
? "平台还没有服务器实例。点击上方“创建服务器”开始,或检查运行节点状态。"
|
? "平台还没有服务器实例。点击上方“创建服务器”开始,再生成并启动 Run。"
|
||||||
: "当前账号名下没有可管理的服务器。如果这不符合预期,请联系平台管理员为你分配服务器,或点击刷新重试。"
|
: "当前账号名下没有可管理的服务器。如果这不符合预期,请联系平台管理员为你分配服务器,或点击刷新重试。"
|
||||||
}
|
}
|
||||||
actionLabel="刷新"
|
actionLabel="刷新"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ First-party routes must be declared here before page implementation.
|
|||||||
- `/plugins`: 插件市场.
|
- `/plugins`: 插件市场.
|
||||||
- `/users`: 用户管理.
|
- `/users`: 用户管理.
|
||||||
- `/ai-providers`: AI 提供商管理.
|
- `/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.
|
- `/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
|
## Role Scoping
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const firstPartyRoutes: PageRoute[] = [
|
|||||||
label: "系统维护",
|
label: "系统维护",
|
||||||
path: "/maintenance",
|
path: "/maintenance",
|
||||||
hash: "#/maintenance",
|
hash: "#/maintenance",
|
||||||
description: "运行节点健康与失败任务",
|
description: "服务器异常与失败任务",
|
||||||
requiredCapability: "system.maintenance",
|
requiredCapability: "system.maintenance",
|
||||||
showInNav: true
|
showInNav: true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user