diff --git a/apps/web/app/api/dashboard.ts b/apps/web/app/api/dashboard.ts index 260544e0..f044f62f 100644 --- a/apps/web/app/api/dashboard.ts +++ b/apps/web/app/api/dashboard.ts @@ -1,7 +1,8 @@ import { useQuery } from "@tanstack/react-query"; -import type { Dashboard } from "@umt/contracts"; +import { cursorPage, type Dashboard, SiteListItem } from "@umt/contracts"; import { apiData } from "~/lib/api.ts"; import { qk } from "~/lib/query-keys.ts"; +import { apiParsed } from "./parse.ts"; /** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */ export function useDashboard() { @@ -11,3 +12,15 @@ export function useDashboard() { refetchInterval: 60_000, }); } + +const SitePage = cursorPage(SiteListItem); + +/** 首页"来源健康":站点 + 整体账号状态(GET /sites?include=accounts) */ +export function useSitesWithAccounts() { + return useQuery({ + queryKey: [...qk.dashboard, "sites-accounts"], + queryFn: ({ signal }) => + apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }), + refetchInterval: 60_000, + }); +} diff --git a/apps/web/app/api/system.ts b/apps/web/app/api/system.ts new file mode 100644 index 00000000..d444a40f --- /dev/null +++ b/apps/web/app/api/system.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import type { SystemHealth, SystemHealthHistory } from "@umt/contracts"; +import { apiData } from "~/lib/api.ts"; +import { qk } from "~/lib/query-keys.ts"; + +// 进程与健康(W9-I):GET /system/health、/system/health/history,仅 admin。 + +export function useSystemHealth(refetchMs = 15_000) { + return useQuery({ + queryKey: qk.sub("system", "health"), + queryFn: ({ signal }) => apiData("/system/health", { signal }), + refetchInterval: refetchMs, + }); +} + +export function useSystemHealthHistory(hours: number) { + return useQuery({ + queryKey: qk.sub("system", "history", hours), + queryFn: ({ signal }) => apiData("/system/health/history", { params: { hours }, signal }), + refetchInterval: 60_000, + }); +} diff --git a/apps/web/app/components/status/history-chart.tsx b/apps/web/app/components/status/history-chart.tsx new file mode 100644 index 00000000..d07a78fe --- /dev/null +++ b/apps/web/app/components/status/history-chart.tsx @@ -0,0 +1,104 @@ +import type { SystemHealthHistory } from "@umt/contracts"; +import { useMemo } from "react"; +import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { EmptyState } from "~/components/console/empty-state.tsx"; + +// 心跳采样折线:无彩(前景色),多进程靠线型 + 图例区分,不靠颜色。 + +type Metric = "lagMaxMs" | "lagMs" | "sseConnections" | "runningRuns"; + +/** 同一色,不同线型;第 5 个起循环线型但图例仍带进程名 */ +const DASH = ["", "6 3", "2 3", "10 3 2 3"]; +const OPACITY = [0.9, 0.7, 0.55, 0.45]; + +function clock(iso: string, spanHours: number) { + const d = new Date(iso); + return spanHours > 24 + ? d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", hour12: false }) + : d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); +} + +export function seriesLabel(s: { processKind: string; instanceId: string }) { + const pid = s.instanceId.split(":").at(-1); + return `${s.processKind} · ${pid}`; +} + +export function HistoryChart({ + data, + metric, + unit, + hours, + height = 200, +}: { + data: SystemHealthHistory; + metric: Metric; + unit: string; + hours: number; + height?: number; +}) { + const series = data.series.filter((s) => s.points.some((p) => p[metric] !== null)); + const rows = useMemo(() => { + const m = new Map>(); + for (const s of series) + for (const p of s.points) { + const row = m.get(p.at) ?? { at: p.at }; + row[s.instanceId] = p[metric]; + m.set(p.at, row); + } + return [...m.values()].sort((a, b) => String(a.at).localeCompare(String(b.at))); + }, [series, metric]); + + if (rows.length === 0) return ; + + return ( +
+ + + + clock(v, hours)} + tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} + stroke="var(--color-border)" + minTickGap={40} + /> + + new Date(String(v)).toLocaleString("zh-CN", { hour12: false })} + formatter={(v) => [`${Math.round(Number(v) * 10) / 10} ${unit}`]} + contentStyle={{ + background: "var(--color-popover)", + border: "1px solid var(--color-border)", + borderRadius: 12, + fontSize: 12, + color: "var(--color-foreground)", + }} + cursor={{ stroke: "var(--color-muted-foreground)", strokeDasharray: "3 3" }} + /> + {series.length > 1 ? : null} + {series.map((s, i) => ( + + ))} + + +
+ ); +} diff --git a/apps/web/app/routes/console/home.tsx b/apps/web/app/routes/console/home.tsx index 733cf2a7..f14e6813 100644 --- a/apps/web/app/routes/console/home.tsx +++ b/apps/web/app/routes/console/home.tsx @@ -4,47 +4,50 @@ import { Activity, AlertTriangle, ArrowRight, + CalendarClock, CircleCheck, + Gauge, HardDrive, + Library as LibraryIcon, ListTodo, type LucideIcon, Pause, Play, Radio, ShieldAlert, - Sparkles, } from "lucide-react"; import type { ReactNode } from "react"; import { useRef } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; -import { useDashboard } from "~/api/dashboard.ts"; +import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts"; +import { useDecisions } from "~/api/decisions.ts"; +import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts"; +import { useUpdateSchedule } from "~/api/schedules.ts"; import { useUpdateSettings } from "~/api/settings.ts"; +import { useSystemHealth } from "~/api/system.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; -import { consolePath } from "~/components/console/nav.ts"; +import { consolePath, settingsPath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatTile } from "~/components/console/stat-tile.tsx"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx"; +import { countdown, RUN_STATUS } from "~/components/schedules/labels.ts"; +import { useLiveSchedules, useNow } from "~/components/schedules/schedule-board.tsx"; +import { runStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts"; +import { useLiveTasks } from "~/components/tasks/task-stream.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; -import { - agentRunLabel, - decisionKindLabel, - downloadLabel, - formatRelative, - healthLabel, - organizationActionLabel, - organizationLabel, -} from "~/lib/labels.ts"; +import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts"; import { qk } from "~/lib/query-keys.ts"; import { useSse, useSseStatus } from "~/lib/sse.ts"; import { cn, formatBytes } from "~/lib/utils.ts"; /** 首页关心的事件:任意一个到达就(节流)刷新聚合 */ const DASHBOARD_EVENTS = [ + "schedule.changed", "task.created", "task.status_changed", "download.submitted", @@ -78,18 +81,22 @@ function Section({ to, children, className, + extra, }: { title: string; icon: LucideIcon; to?: string; children: ReactNode; className?: string; + /** 标题右侧的摘要(计数、状态) */ + extra?: ReactNode; }) { return (

{title}

+ {extra} {to ? ( -
- - - - -
-

- 整理成功 {t.recordsSucceeded} · 失败 {t.recordsFailed} · 满足的版本目标 {a.slotCounts.satisfied} · 需关注{" "} - {a.slotCounts.attention} +

+ {running.isPending ? ( + + ) : running.isError ? ( + running.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( +
    + {rows.slice(0, 5).map((r) => ( + + + {runTitle(r)} + {workflowLabel(r.workflowName)} + + + {formatDuration(spanSec(r))} + + + ))} +
+ )} +

+ 排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}

); } -function CurrentWork({ w }: { w: Dashboard["currentWork"] }) { - const counts = [ - { label: "下载中", v: w.downloading }, - { label: "已暂停", v: w.paused }, - { label: "无进展", v: w.stalled }, - { label: "入库中", v: w.importing }, - ]; - const idle = counts.every((c) => c.v === 0) && w.items.length === 0 && w.agentRuns.length === 0; +// ─── 需要我处理:计数 + 前 3 条 ─── +function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) { + const list = useDecisions({}); + const top = (list.data?.pages[0]?.items ?? []).slice(0, 3); return ( -
-
- {counts.map((c) => ( - - {c.label} {c.v} - - ))} -
- {idle ? ( - +
+ {n.total === 0 ? ( + ) : ( -
    - {w.items.slice(0, 5).map((it) => { - const s = downloadLabel(it.status); - return ( - - - {it.name} + <> +

    + {n.total} + + 项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")} + +

    +
      + {top.map((d) => ( + + + {d.title} + + {decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)} + - - {Math.round(it.progress * 100)}% - - {s.label} + ))} +
    + + )} +
+ ); +} + +// ─── 媒体概览:版本目标满足率 + 今日产出(作品/季/资产计数待 /dashboard 提供) ─── +function MediaOverview({ a }: { a: Dashboard["automation"] }) { + const c = a.slotCounts; + const total = Object.values(c).reduce((x, y) => x + y, 0); + const rate = total ? Math.round((c.satisfied / total) * 100) : null; + const lib = (a as { library?: { entities?: number; seasons?: number; assets?: number } }).library; + const num = (v: number | undefined) => (v === undefined ? "—" : v); + return ( +
+
+ + + +
+
+
+ 版本目标满足率 + {rate === null ? "—" : `${rate}%`} +
+
+
+
+

+ 满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading} +

+
+

+ 今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} · + 升级 {a.today.upgrades} +

+
+ ); +} + +// ─── 计划带:下一次运行的 5 项 + 计数 + 快速开关 ─── +function PlanStrip() { + const board = useLiveSchedules(); + const now = useNow(1000); + const update = useUpdateSchedule(); + const items = board.data?.items ?? []; + const next = items + .filter((j) => j.available && j.enabled && j.nextRunAt) + .sort((x, y) => (x.nextRunAt ?? "").localeCompare(y.nextRunAt ?? "")) + .slice(0, 5); + const s = board.data?.stats; + return ( +
+ 启用 {s.enabled} / {s.total} + {s.failed ? ( + <> + {" · "} + 上次失败 {s.failed} + + ) : null} + {board.data && !board.data.schedulerOnline ? · 调度器未在线 : null} + + ) : null + } + > + {board.isPending ? ( + + ) : board.isError ? ( + board.refetch()} /> + ) : next.length === 0 ? ( + + ) : ( +
    + {next.map((j) => { + const last = j.lastStatus ? RUN_STATUS[j.lastStatus] : null; + return ( +
  • +
    + + {j.name} + + + update.mutate( + { id: j.id, body: { enabled: v } }, + { + onSuccess: () => toast.success(v ? `已启用${j.name}` : `已停用${j.name}`), + onError: (e) => toast.error(errorMessage(e)), + }, + ) + } + /> +
    +

    + + {j.runningSince ? "运行中" : j.nextRunAt ? countdown(j.nextRunAt, now) : "—"} + + {last ? {last.label} : null} +

    +
  • ); })} - {w.agentRuns.slice(0, 3).map((r) => { - const s = agentRunLabel(r.status); +
+ )} +
+ ); +} + +// ─── 最近完成 / 失败 ─── +function RecentRuns() { + const done = useEngineRunsPage({ status: "completed" }, 6); + const failed = useEngineRunsPage({ status: "failed" }, 6); + const rows = [...(done.data?.items ?? []), ...(failed.data?.items ?? [])] + .sort((x, y) => (y.finishedAt ?? y.createdAt).localeCompare(x.finishedAt ?? x.createdAt)) + .slice(0, 6); + const loading = done.isPending || failed.isPending; + const err = done.error ?? failed.error; + return ( +
+ {loading ? ( + + ) : err ? ( + { + void done.refetch(); + void failed.refetch(); + }} + /> + ) : rows.length === 0 ? ( + + ) : ( +
    + {rows.map((r) => { + const s = runStatus(r.status); return ( - - Agent:{r.goal} + + {runTitle(r)} + + {workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)} + {s.label} @@ -277,24 +442,94 @@ function CurrentWork({ w }: { w: Dashboard["currentWork"] }) { ); } -function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) { +// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ─── +const ACCOUNT: Record = { + unconfigured: { label: "未配置账号", tone: "neutral" }, + unknown: { label: "账号未验证", tone: "neutral" }, + active: { label: "账号正常", tone: "success" }, + invalid: { label: "账号失效", tone: "destructive" }, + expired: { label: "登录过期", tone: "warning" }, + challenge: { label: "需人工验证", tone: "warning" }, +}; + +function SourcesSection({ s }: { s: Dashboard["sources"] }) { + const sites = useSitesWithAccounts(); + const siteRows = (sites.data?.items ?? s.sites).map((x) => { + const acc = "accountStatus" in x && x.accountStatus ? ACCOUNT[x.accountStatus] : undefined; + return { + id: x.id, + kind: "站点", + name: x.name, + enabled: x.enabled, + health: "healthStatus" in x ? x.healthStatus : x.health, + account: acc, + to: `/console/sites/${x.id}`, + }; + }); + const rows = [ + ...siteRows, + ...s.feeds.map((x) => ({ + id: x.id, + kind: "Feed", + name: x.name, + enabled: x.enabled, + health: x.health, + account: undefined, + to: consolePath("sources"), + })), + ...s.downloaders.map((x) => ({ + id: x.id, + kind: "下载器", + name: x.name, + enabled: x.enabled, + health: x.health, + account: undefined, + to: settingsPath("downloaders"), + })), + ]; + const bad = (r: (typeof rows)[number]) => + (r.enabled && healthLabel(r.health).tone !== "success" ? 2 : 0) + + (r.account && r.account.tone !== "success" ? 1 : 0); + rows.sort((x, y) => bad(y) - bad(x)); + const accountIssues = siteRows.filter( + (r) => r.account && (r.account.tone === "destructive" || r.account.tone === "warning"), + ).length; return ( -
    - {n.total === 0 ? ( - +
    + {rows.length === 0 ? ( + ) : ( <> -

    - {n.total} - 项待处理 -

    + {accountIssues ?

    {accountIssues} 个站点的账号需要处理

    : null}
      - {n.byKind.map((k) => ( - - {decisionKindLabel(k.kind)} - {k.count} - - ))} + {rows.slice(0, 7).map((r) => { + const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled"); + return ( + + + + {r.kind} + {r.name} + + {r.account ? ( + + {r.account.label} + + ) : null} + + {h.label} + + ); + })}
    )} @@ -302,10 +537,69 @@ function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) { ); } +// ─── 运行状态摘要(/system/health) ─── +const LEVEL: Record = { + ok: { label: "正常", tone: "success" }, + warn: { label: "需要关注", tone: "warning" }, + critical: { label: "需要处理", tone: "destructive" }, + unknown: { label: "未知", tone: "neutral" }, +}; + +function StatusSummary() { + const h = useSystemHealth(30_000); + const d = h.data; + const stale = d?.processes.filter((p) => p.stale) ?? []; + const alive = (d?.processes.length ?? 0) - stale.length; + const mountsBad = d?.mounts.filter((m) => m.status !== "online").length ?? 0; + const lv = LEVEL[d?.level ?? "unknown"] ?? LEVEL.unknown; + return ( +
    {lv.label} : null} + > + {h.isPending ? ( + + ) : h.isError ? ( + h.refetch()} /> + ) : d ? ( +
    + + 进程 {alive} 在线 + {stale.length ? ( + + {" "} + · {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")}) + + ) : null} + + + 待执行 {d.engine.pending} + {d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""} + + + 24 小时失败率{" "} + + {d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`} + + + + 数据库{" "} + {d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`} + + {mountsBad ? {mountsBad} 个挂载根不可用 : null} +
    + ) : null} +
    + ); +} + function CacheSection({ c }: { c: Dashboard["cache"] }) { const volumeLabel = useVolumeLabel(); return ( -
    +
    {c.volumes.length === 0 ? ( )} -
    - ); -} - -function SourcesSection({ s }: { s: Dashboard["sources"] }) { - const rows = [ - ...s.sites.map((x) => ({ - id: x.id, - kind: "站点", - name: x.name, - enabled: x.enabled, - health: x.health, - at: x.lastSnapshotAt, - err: null as string | null, - })), - ...s.feeds.map((x) => ({ - id: x.id, - kind: "Feed", - name: x.name, - enabled: x.enabled, - health: x.health, - at: x.lastSuccessAt, - err: x.lastError, - })), - ...s.downloaders.map((x) => ({ - id: x.id, - kind: "下载器", - name: x.name, - enabled: x.enabled, - health: x.health, - at: x.lastSeenAt, - err: null, - })), - ...s.mediaServers.map((x) => ({ - id: x.id, - kind: "媒体服务器", - name: x.name, - enabled: x.enabled, - health: x.health, - at: x.lastSeenAt, - err: null, - })), - ]; - return ( -
    - {rows.length === 0 ? ( - - ) : ( -
      - {rows.slice(0, 8).map((r) => { - const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled"); - return ( - -
      -

      - {r.kind} - {r.name} -

      - {r.err ?

      {r.err}

      : null} -
      - - {formatRelative(r.at)} - - {h.label} -
      - ); - })} -
    - )} -
    - ); -} - -function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) { - return ( -
    - {items.length === 0 ? ( - - ) : ( -
      - {items.slice(0, 6).map((r) => { - const s = organizationLabel(r.status); - return ( -
    • -
      - - {r.title ?? "未关联作品"} - - {s.label} -
      -

      - {organizationActionLabel(r.action)} · {r.counts.succeeded}/{r.counts.total} 个文件 ·{" "} - {formatRelative(r.finishedAt)} - {r.targetPath ? ` · ${r.targetPath}` : ""} -

      -
    • - ); - })} -
    - )} + + 调整阈值与保留天数 +
    ); } @@ -470,11 +670,12 @@ function LiveIndicator() { export default function Home() { const q = useLiveDashboard(); + useLiveTasks(); return ( <> @@ -500,12 +701,14 @@ export default function Home() { <>
    - - - + + + + + - +
    )} diff --git a/apps/web/app/routes/console/status.tsx b/apps/web/app/routes/console/status.tsx new file mode 100644 index 00000000..b5b72e30 --- /dev/null +++ b/apps/web/app/routes/console/status.tsx @@ -0,0 +1,488 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { HealthLevel, SystemHealth, SystemProcess } from "@umt/contracts"; +import { AlertTriangle, CircleCheck, Cpu, Database, HardDrive, ListTodo, RotateCw, Timer, XCircle } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { Link } from "react-router"; +import { useSystemHealth, useSystemHealthHistory } from "~/api/system.ts"; +import { DataTable } from "~/components/console/data-table.tsx"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { KvList } from "~/components/console/kv-list.tsx"; +import { PageHeader } from "~/components/console/page-header.tsx"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { HistoryChart } from "~/components/status/history-chart.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; +import { formatDuration, formatRelative } from "~/lib/labels.ts"; +import { qk } from "~/lib/query-keys.ts"; +import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; + +export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制台" }]; + +// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务。阈值见 docs/runbook/ops.md。 + +const LEVEL: Record = { + ok: { label: "正常", tone: "success" }, + warn: { label: "需要关注", tone: "warning" }, + critical: { label: "需要处理", tone: "destructive" }, + unknown: { label: "取不到数据", tone: "neutral" }, +}; + +const KIND_LABEL: Record = { + api: "API", + worker: "Worker(io)", + "worker:agent": "Worker(Agent)", + all: "单进程(all)", +}; + +const MOUNT_REASON: Record = { + root_missing: "目录不存在", + sentinel_missing: "哨兵文件缺失", + permission_denied: "没有权限", + error: "检查出错", + timeout: "检查超时", +}; + +const HOURS = [1, 6, 24, 168]; + +function Card({ + title, + icon: Icon, + level, + children, + action, +}: { + title: string; + icon: typeof Cpu; + level?: HealthLevel; + children: ReactNode; + action?: ReactNode; +}) { + return ( +
    +
    + +

    {title}

    + {level ? {LEVEL[level].label} : null} + {action ? {action} : null} +
    + {children} +
    + ); +} + +/** 顶部横幅:整体档位 + 需要看的几条 */ +function problems(d: SystemHealth): string[] { + const out: string[] = []; + const stale = d.processes.filter((p) => p.stale); + if (stale.length) out.push(`${stale.length} 个进程超过 45 秒没有心跳`); + if (!d.processes.some((p) => !p.stale && p.processKind !== "api")) out.push("没有在线的 worker,任务不会被执行"); + const lag = d.processes.filter((p) => !p.stale && p.eventLoopLagMaxMs >= 1000); + if (lag.length) out.push(`${lag.length} 个进程事件循环延迟超过 1 秒`); + if (d.db.level === "warn" || d.db.level === "critical") out.push("数据库延迟或连接数偏高"); + const disks = d.disks.filter((x) => x.level === "warn" || x.level === "critical"); + if (disks.length) out.push(`${disks.length} 个磁盘用量超过阈值`); + const mounts = d.mounts.filter((m) => m.status !== "online"); + if (mounts.length) out.push(`${mounts.length} 个挂载根不可用`); + if (d.engine.level === "warn" || d.engine.level === "critical") out.push("任务积压或失败率偏高"); + if (d.schedules.failing.length) out.push(`${d.schedules.failing.length} 个定时任务上次运行失败`); + return out; +} + +function Banner({ d }: { d: SystemHealth }) { + const lv = LEVEL[d.level]; + const list = problems(d); + const ok = d.level === "ok"; + return ( +
    + {ok ? ( + + ) : d.level === "critical" ? ( + + ) : ( + + )} +
    +

    + {ok ? "全部正常" : lv.label} + + 检查于 {formatRelative(d.checkedAt)} · API {d.api.version},已运行 {formatDuration(d.api.uptimeSec)} + +

    + {list.length ? ( +
      + {list.map((x) => ( +
    • {x}
    • + ))} +
    + ) : null} +
    +
    + ); +} + +function Processes({ d }: { d: SystemHealth }) { + return ( + + rows={d.processes} + rowKey={(p) => p.instanceId} + empty={{ title: "24 小时内没有进程心跳", description: "进程启动后每 15 秒写一次心跳。" }} + columns={[ + { + key: "kind", + header: "进程", + cell: (p) => ( +
    +

    + {KIND_LABEL[p.processKind] ?? p.processKind} + {p.stale ? 无心跳 : null} +

    +

    + {p.host} · pid {p.pid} +

    +
    + ), + }, + { + key: "ver", + header: "版本", + hideBelow: "lg", + cell: (p) => {p.version}, + }, + { + key: "start", + header: "启动", + hideBelow: "md", + cell: (p) => ( + + {formatRelative(p.startedAt)} + + ), + }, + { + key: "beat", + header: "最后心跳", + cell: (p) => ( + + {formatDuration(p.beatAgeSec)}前 + + ), + }, + { + key: "lag", + header: "延迟 / 峰值", + align: "right", + cell: (p) => ( + = 5000 ? "text-destructive" : p.eventLoopLagMaxMs >= 1000 ? "text-warning" : "", + )} + > + {p.eventLoopLagMs} / {p.eventLoopLagMaxMs} 毫秒 + + ), + }, + { + key: "conc", + header: "并发", + hideBelow: "md", + align: "right", + cell: (p) => + p.concurrency.io === undefined && p.concurrency.agent === undefined ? ( + — + ) : ( + + io {p.concurrency.io ?? 0} · Agent {p.concurrency.agent ?? 0} + + ), + }, + { + key: "running", + header: "运行中", + hideBelow: "sm", + align: "right", + cell: (p) => {p.runningRuns ?? "—"}, + }, + { + key: "sse", + header: "SSE", + hideBelow: "lg", + align: "right", + cell: (p) => {p.sseConnections ?? "—"}, + }, + { + key: "mem", + header: "内存", + hideBelow: "lg", + align: "right", + cell: (p) => { + const rss = p.extra.rssBytes; + return {typeof rss === "number" ? formatBytes(rss, 0) : "—"}; + }, + }, + ]} + /> + ); +} + +function History() { + const [hours, setHours] = useState(6); + const h = useSystemHealthHistory(hours); + return ( +
    +
    +

    趋势

    + {h.data ? ( + 每点 {formatDuration(h.data.bucketSec)} + ) : null} + setHours(Number(v))} className="ml-auto"> + + {HOURS.map((n) => ( + + {n === 168 ? "7 天" : `${n} 小时`} + + ))} + + +
    + {h.isPending ? ( + + ) : h.isError ? ( + h.refetch()} /> + ) : ( +
    + + + + + + +
    + )} +
    + ); +} + +function Disks({ d }: { d: SystemHealth }) { + const t = d.diskThresholds; + const scope = { data_dir: "数据目录", library_root: "库根", download_dir: "下载目录" } as const; + return ( + x.level))}> +

    + 阈值:{t.warnPercent}% 需要关注,{t.criticalPercent}% 需要处理(取自缓存设置) +

    +
      + {d.disks.map((x) => { + const pct = x.usedRatio === null ? null : x.usedRatio * 100; + return ( +
    • +
      + + {scope[x.scope]} + {x.name} + + + {pct === null ? (x.error ? `不可读 ${x.error}` : "用量未知") : `${pct.toFixed(0)}%`} + +
      +
      +
      + + +
      +

      + {x.path} + {x.totalBytes !== null && x.availableBytes !== null + ? ` · 剩余 ${formatBytes(x.availableBytes, 1)} / ${formatBytes(x.totalBytes, 1)}` + : ""} +

      +
    • + ); + })} +
    +
    + ); +} + +function worst(levels: HealthLevel[]): HealthLevel { + if (levels.includes("critical")) return "critical"; + if (levels.includes("warn")) return "warn"; + if (levels.includes("ok")) return "ok"; + return "unknown"; +} + +function Mounts({ d }: { d: SystemHealth }) { + const bad = d.mounts.filter((m) => m.status !== "online").length; + return ( + + {d.mounts.length === 0 ? ( +

    没有启用的库根或下载目录映射。

    + ) : ( +
      + {d.mounts.map((m) => ( +
    • +
      +

      + + {m.kind === "library_root" ? "库根" : "下载目录"} + + {m.name} +

      +

      + {m.path} + {m.sentinelChecked ? "" : " · 只查目录可读"} +

      +
      + + {m.status === "online" ? "在线" : m.status === "timeout" ? "超时" : "离线"} + {m.reason && m.status !== "timeout" ? ` · ${MOUNT_REASON[m.reason] ?? m.reason}` : ""} + +
    • + ))} +
    + )} +
    + ); +} + +export default function StatusPage() { + const qc = useQueryClient(); + const h = useSystemHealth(); + const d = h.data; + return ( + <> + qc.invalidateQueries({ queryKey: qk.all("system") })} + disabled={h.isFetching} + > + + 刷新 + + } + /> + {h.isPending ? ( + + ) : h.isError ? ( + h.refetch()} /> + ) : d ? ( +
    + +
    +

    + + 进程 + SSE 连接合计 {d.sse.connections} +

    + +
    + +
    + + {d.db.error ?

    {d.db.error}

    : null} + +
    + + 查看任务 + + } + > + + + + 查看计划 + + } + > + + {d.schedules.failing.length ? ( +
      + {d.schedules.failing.map((f) => ( +
    • + + {f.name} + + {formatRelative(f.lastRunAt)} + {f.lastError ?

      {f.lastError}

      : null} +
    • + ))} +
    + ) : null} +
    + + +
    +
    + ) : null} + + ); +}