feat(web): status page and home regrouped around tasks, inbox and plan (FE-1)
Status page reads /system/health and history: level banner, process table, event-loop lag and SSE charts (monochrome recharts), database, engine, schedules, disks with thresholds and mount roots. Home now shows in-progress tasks, items needing attention, media overview, a plan strip with quick toggles, recent finished/failed tasks, cache and retention, source health with site account status, and a run-status summary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d8b9bd9662
commit
4bb2583f6c
5 files changed
+1012
-182
No files matched your search
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 { apiData } from "~/lib/api.ts";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
|
import { apiParsed } from "./parse.ts";
|
||||||
|
|
||||||
/** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */
|
/** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */
|
||||||
export function useDashboard() {
|
export function useDashboard() {
|
||||||
@@ -11,3 +12,15 @@ export function useDashboard() {
|
|||||||
refetchInterval: 60_000,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<SystemHealth>("/system/health", { signal }),
|
||||||
|
refetchInterval: refetchMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSystemHealthHistory(hours: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.sub("system", "history", hours),
|
||||||
|
queryFn: ({ signal }) => apiData<SystemHealthHistory>("/system/health/history", { params: { hours }, signal }),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, Record<string, number | string | null>>();
|
||||||
|
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 <EmptyState plain title="这段时间没有采样" className="py-8" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground" style={{ height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke="var(--color-border)" strokeDasharray="2 4" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="at"
|
||||||
|
tickFormatter={(v: string) => clock(v, hours)}
|
||||||
|
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
minTickGap={40}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
width={44}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(v) => 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 ? <Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} /> : null}
|
||||||
|
{series.map((s, i) => (
|
||||||
|
<Line
|
||||||
|
key={s.instanceId}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={s.instanceId}
|
||||||
|
name={seriesLabel(s)}
|
||||||
|
stroke="var(--color-foreground)"
|
||||||
|
strokeOpacity={OPACITY[i % OPACITY.length]}
|
||||||
|
strokeDasharray={DASH[i % DASH.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--color-background)" }}
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,47 +4,50 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
CalendarClock,
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
|
Gauge,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Library as LibraryIcon,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Radio,
|
Radio,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { toast } from "sonner";
|
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 { useUpdateSettings } from "~/api/settings.ts";
|
||||||
|
import { useSystemHealth } from "~/api/system.ts";
|
||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatTile } from "~/components/console/stat-tile.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 { 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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import {
|
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts";
|
||||||
agentRunLabel,
|
|
||||||
decisionKindLabel,
|
|
||||||
downloadLabel,
|
|
||||||
formatRelative,
|
|
||||||
healthLabel,
|
|
||||||
organizationActionLabel,
|
|
||||||
organizationLabel,
|
|
||||||
} from "~/lib/labels.ts";
|
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||||
|
|
||||||
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
||||||
const DASHBOARD_EVENTS = [
|
const DASHBOARD_EVENTS = [
|
||||||
|
"schedule.changed",
|
||||||
"task.created",
|
"task.created",
|
||||||
"task.status_changed",
|
"task.status_changed",
|
||||||
"download.submitted",
|
"download.submitted",
|
||||||
@@ -78,18 +81,22 @@ function Section({
|
|||||||
to,
|
to,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
extra,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
to?: string;
|
to?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 标题右侧的摘要(计数、状态) */
|
||||||
|
extra?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
||||||
|
{extra}
|
||||||
{to ? (
|
{to ? (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
@@ -204,68 +211,226 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Overview({ a }: { a: Dashboard["automation"] }) {
|
// ─── 正在进行:任务流 top N ───
|
||||||
const t = a.today;
|
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
|
||||||
|
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
|
||||||
|
const stats = useEngineStats();
|
||||||
|
const rows = running.data?.items ?? [];
|
||||||
return (
|
return (
|
||||||
<Section title="自动化概览" icon={Sparkles} to={consolePath("subscriptions")}>
|
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{running.isPending ? (
|
||||||
<StatTile plain label="今日新计划" value={t.plansCreated} />
|
<LoadingState rows={3} />
|
||||||
<StatTile plain label="今日提交下载" value={t.downloadsSubmitted} />
|
) : running.isError ? (
|
||||||
<StatTile plain label="今日入库文件" value={t.filesImported} />
|
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
|
||||||
<StatTile plain label="今日升级" value={t.upgrades} />
|
) : rows.length === 0 ? (
|
||||||
</div>
|
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
|
||||||
<p className="mt-3 text-meta text-muted-foreground">
|
) : (
|
||||||
整理成功 {t.recordsSucceeded} · 失败 {t.recordsFailed} · 满足的版本目标 {a.slotCounts.satisfied} · 需关注{" "}
|
<ul className="divide-y divide-border/60">
|
||||||
{a.slotCounts.attention}
|
{rows.slice(0, 5).map((r) => (
|
||||||
|
<Row key={r.id}>
|
||||||
|
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||||
|
<span className="block truncate">{runTitle(r)}</span>
|
||||||
|
<span className="block truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</span>
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||||
|
{formatDuration(spanSec(r))}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
||||||
|
排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
|
// ─── 需要我处理:计数 + 前 3 条 ───
|
||||||
const counts = [
|
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||||
{ label: "下载中", v: w.downloading },
|
const list = useDecisions({});
|
||||||
{ label: "已暂停", v: w.paused },
|
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
|
||||||
{ label: "无进展", v: w.stalled },
|
|
||||||
{ label: "入库中", v: w.importing },
|
|
||||||
];
|
|
||||||
const idle = counts.every((c) => c.v === 0) && w.items.length === 0 && w.agentRuns.length === 0;
|
|
||||||
return (
|
return (
|
||||||
<Section title="当前工作" icon={Activity} to={consolePath("downloads")}>
|
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||||
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
|
{n.total === 0 ? (
|
||||||
{counts.map((c) => (
|
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
|
||||||
<span key={c.label}>
|
|
||||||
{c.label} <span className="font-medium text-foreground tabular-nums">{c.v}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{idle ? (
|
|
||||||
<EmptyState plain icon={CircleCheck} title="没有进行中的工作" className="py-6" />
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/60">
|
<>
|
||||||
{w.items.slice(0, 5).map((it) => {
|
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
||||||
const s = downloadLabel(it.status);
|
{n.total}
|
||||||
return (
|
<span className="ml-1.5 text-caption font-normal text-muted-foreground">
|
||||||
<Row key={it.downloadTaskId}>
|
项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")}
|
||||||
<Link
|
</span>
|
||||||
to={consolePath("downloads", it.downloadTaskId)}
|
</p>
|
||||||
className="min-w-0 flex-1 truncate hover:underline"
|
<ul className="divide-y divide-border/60">
|
||||||
>
|
{top.map((d) => (
|
||||||
{it.name}
|
<Row key={d.id}>
|
||||||
|
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
|
||||||
|
<span className="block truncate">{d.title}</span>
|
||||||
|
<span className="block truncate text-meta text-muted-foreground">
|
||||||
|
{decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
|
||||||
{Math.round(it.progress * 100)}%
|
|
||||||
</span>
|
|
||||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 媒体概览:版本目标满足率 + 今日产出(作品/季/资产计数待 /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 (
|
||||||
|
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<StatTile plain label="作品" value={num(lib?.entities)} />
|
||||||
|
<StatTile plain label="季" value={num(lib?.seasons)} />
|
||||||
|
<StatTile plain label="资产" value={num(lib?.assets)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-baseline justify-between text-sm">
|
||||||
|
<span>版本目标满足率</span>
|
||||||
|
<span className="font-semibold tabular-nums">{rate === null ? "—" : `${rate}%`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||||
|
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${rate ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-meta text-muted-foreground">
|
||||||
|
满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
||||||
|
今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} ·
|
||||||
|
升级 {a.today.upgrades}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 计划带:下一次运行的 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 (
|
||||||
|
<Section
|
||||||
|
title="计划"
|
||||||
|
icon={CalendarClock}
|
||||||
|
to="/console/tasks?tab=plan"
|
||||||
|
className="md:col-span-2 xl:col-span-3"
|
||||||
|
extra={
|
||||||
|
s ? (
|
||||||
|
<span className="text-meta text-muted-foreground">
|
||||||
|
启用 <span className="font-medium text-foreground tabular-nums">{s.enabled}</span> / {s.total}
|
||||||
|
{s.failed ? (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<span className="font-medium text-destructive tabular-nums">上次失败 {s.failed}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{board.data && !board.data.schedulerOnline ? <span className="text-warning"> · 调度器未在线</span> : null}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{board.isPending ? (
|
||||||
|
<LoadingState rows={2} />
|
||||||
|
) : board.isError ? (
|
||||||
|
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
|
||||||
|
) : next.length === 0 ? (
|
||||||
|
<EmptyState plain icon={CalendarClock} title="没有待运行的定时任务" className="py-4" />
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{next.map((j) => {
|
||||||
|
const last = j.lastStatus ? RUN_STATUS[j.lastStatus] : null;
|
||||||
|
return (
|
||||||
|
<li key={j.id} className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-border/70 px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/console/tasks?tab=plan&job=${encodeURIComponent(j.id)}`}
|
||||||
|
className="min-w-0 flex-1 truncate text-sm font-medium hover:underline"
|
||||||
|
title={j.name}
|
||||||
|
>
|
||||||
|
{j.name}
|
||||||
|
</Link>
|
||||||
|
<Switch
|
||||||
|
checked={j.enabled}
|
||||||
|
disabled={update.isPending}
|
||||||
|
aria-label={`${j.enabled ? "停用" : "启用"}${j.name}`}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
update.mutate(
|
||||||
|
{ id: j.id, body: { enabled: v } },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success(v ? `已启用${j.name}` : `已停用${j.name}`),
|
||||||
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="flex items-center justify-between gap-2 text-meta text-muted-foreground">
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{j.runningSince ? "运行中" : j.nextRunAt ? countdown(j.nextRunAt, now) : "—"}
|
||||||
|
</span>
|
||||||
|
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : null}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{w.agentRuns.slice(0, 3).map((r) => {
|
</ul>
|
||||||
const s = agentRunLabel(r.status);
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 最近完成 / 失败 ───
|
||||||
|
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 (
|
||||||
|
<Section title="最近完成 / 失败" icon={ListTodo} to="/console/tasks">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingState rows={3} />
|
||||||
|
) : err ? (
|
||||||
|
<ErrorState
|
||||||
|
plain
|
||||||
|
error={err}
|
||||||
|
onRetry={() => {
|
||||||
|
void done.refetch();
|
||||||
|
void failed.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<EmptyState plain icon={ListTodo} title="还没有结束的任务" className="py-6" />
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border/60">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const s = runStatus(r.status);
|
||||||
return (
|
return (
|
||||||
<Row key={r.id}>
|
<Row key={r.id}>
|
||||||
<Link to={consolePath("agent", r.id)} className="min-w-0 flex-1 truncate hover:underline">
|
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||||
Agent:{r.goal}
|
<span className="block truncate">{runTitle(r)}</span>
|
||||||
|
<span className="block truncate text-meta text-muted-foreground">
|
||||||
|
{workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -277,24 +442,94 @@ function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
|
// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ───
|
||||||
|
const ACCOUNT: Record<string, { label: string; tone: StatusTone }> = {
|
||||||
|
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 (
|
return (
|
||||||
<Section title="待处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
|
||||||
{n.total === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
|
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
|
||||||
{n.total}
|
|
||||||
<span className="ml-1.5 text-caption font-normal text-muted-foreground">项待处理</span>
|
|
||||||
</p>
|
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
{n.byKind.map((k) => (
|
{rows.slice(0, 7).map((r) => {
|
||||||
<Row key={k.kind}>
|
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
||||||
<span className="truncate">{decisionKindLabel(k.kind)}</span>
|
return (
|
||||||
<span className="text-meta text-muted-foreground tabular-nums">{k.count}</span>
|
<Row key={`${r.kind}-${r.id}`}>
|
||||||
</Row>
|
<Link to={r.to} className="min-w-0 flex-1 hover:underline">
|
||||||
))}
|
<span className="block truncate">
|
||||||
|
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
{r.account ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block truncate text-meta",
|
||||||
|
r.account.tone === "destructive"
|
||||||
|
? "text-destructive"
|
||||||
|
: r.account.tone === "warning"
|
||||||
|
? "text-warning"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.account.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -302,10 +537,69 @@ function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 运行状态摘要(/system/health) ───
|
||||||
|
const LEVEL: Record<string, { label: string; tone: StatusTone }> = {
|
||||||
|
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 (
|
||||||
|
<Section
|
||||||
|
title="运行状态"
|
||||||
|
icon={Gauge}
|
||||||
|
to={consolePath("status")}
|
||||||
|
className="md:col-span-2 xl:col-span-3"
|
||||||
|
extra={d ? <StatusBadge tone={lv.tone}>{lv.label}</StatusBadge> : null}
|
||||||
|
>
|
||||||
|
{h.isPending ? (
|
||||||
|
<LoadingState rows={1} />
|
||||||
|
) : h.isError ? (
|
||||||
|
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
|
||||||
|
) : d ? (
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
|
||||||
|
<span>
|
||||||
|
进程 <span className="font-medium tabular-nums">{alive}</span> 在线
|
||||||
|
{stale.length ? (
|
||||||
|
<span className="text-warning">
|
||||||
|
{" "}
|
||||||
|
· {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
待执行 <span className="font-medium tabular-nums">{d.engine.pending}</span>
|
||||||
|
{d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
24 小时失败率{" "}
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
数据库{" "}
|
||||||
|
<span className="font-medium tabular-nums">{d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`}</span>
|
||||||
|
</span>
|
||||||
|
{mountsBad ? <span className="text-destructive">{mountsBad} 个挂载根不可用</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
||||||
const volumeLabel = useVolumeLabel();
|
const volumeLabel = useVolumeLabel();
|
||||||
return (
|
return (
|
||||||
<Section title="缓存水位" icon={HardDrive} to={consolePath("downloads")}>
|
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
|
||||||
{c.volumes.length === 0 ? (
|
{c.volumes.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
plain
|
plain
|
||||||
@@ -357,106 +651,12 @@ function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Section>
|
<Link
|
||||||
);
|
to={settingsPath("automation", "retention")}
|
||||||
}
|
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
|
调整阈值与保留天数
|
||||||
const rows = [
|
</Link>
|
||||||
...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 (
|
|
||||||
<Section title="来源健康" icon={Radio} to={consolePath("sources")}>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-border/60">
|
|
||||||
{rows.slice(0, 8).map((r) => {
|
|
||||||
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
|
||||||
return (
|
|
||||||
<Row key={`${r.kind}-${r.id}`}>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate">
|
|
||||||
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
|
||||||
{r.name}
|
|
||||||
</p>
|
|
||||||
{r.err ? <p className="truncate text-meta text-destructive">{r.err}</p> : null}
|
|
||||||
</div>
|
|
||||||
<span className="hidden shrink-0 text-meta text-muted-foreground sm:inline">
|
|
||||||
{formatRelative(r.at)}
|
|
||||||
</span>
|
|
||||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) {
|
|
||||||
return (
|
|
||||||
<Section title="最近产出" icon={ListTodo} to={consolePath("organize")}>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<EmptyState plain icon={ListTodo} title="还没有整理记录" className="py-6" />
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-border/60">
|
|
||||||
{items.slice(0, 6).map((r) => {
|
|
||||||
const s = organizationLabel(r.status);
|
|
||||||
return (
|
|
||||||
<li key={r.recordId} className="min-w-0 py-2">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<Link to={consolePath("organize", r.recordId)} className="min-w-0 truncate hover:underline">
|
|
||||||
{r.title ?? "未关联作品"}
|
|
||||||
</Link>
|
|
||||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
|
||||||
</div>
|
|
||||||
<p className="truncate text-meta text-muted-foreground">
|
|
||||||
{organizationActionLabel(r.action)} · {r.counts.succeeded}/{r.counts.total} 个文件 ·{" "}
|
|
||||||
{formatRelative(r.finishedAt)}
|
|
||||||
{r.targetPath ? ` · ${r.targetPath}` : ""}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -470,11 +670,12 @@ function LiveIndicator() {
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const q = useLiveDashboard();
|
const q = useLiveDashboard();
|
||||||
|
useLiveTasks();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="首页"
|
title="首页"
|
||||||
description="目标进度、今日产出、缺口和待处理事项。"
|
description="正在进行的任务、需要处理的事项、计划和最近结果。"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LiveIndicator />
|
<LiveIndicator />
|
||||||
@@ -500,12 +701,14 @@ export default function Home() {
|
|||||||
<>
|
<>
|
||||||
<AutomationBanner a={q.data.automation} />
|
<AutomationBanner a={q.data.automation} />
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<Overview a={q.data.automation} />
|
<InProgress w={q.data.currentWork} />
|
||||||
<CurrentWork w={q.data.currentWork} />
|
<NeedsMe n={q.data.needsAttention} />
|
||||||
<NeedsAttention n={q.data.needsAttention} />
|
<MediaOverview a={q.data.automation} />
|
||||||
|
<PlanStrip />
|
||||||
|
<RecentRuns />
|
||||||
<CacheSection c={q.data.cache} />
|
<CacheSection c={q.data.cache} />
|
||||||
<SourcesSection s={q.data.sources} />
|
<SourcesSection s={q.data.sources} />
|
||||||
<RecentOutput items={q.data.recentOutput} />
|
<StatusSummary />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<HealthLevel, { label: string; tone: StatusTone }> = {
|
||||||
|
ok: { label: "正常", tone: "success" },
|
||||||
|
warn: { label: "需要关注", tone: "warning" },
|
||||||
|
critical: { label: "需要处理", tone: "destructive" },
|
||||||
|
unknown: { label: "取不到数据", tone: "neutral" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
api: "API",
|
||||||
|
worker: "Worker(io)",
|
||||||
|
"worker:agent": "Worker(Agent)",
|
||||||
|
all: "单进程(all)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOUNT_REASON: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<section className="pg-slab min-w-0 rounded-xl p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
||||||
|
{level ? <StatusBadge tone={LEVEL[level].tone}>{LEVEL[level].label}</StatusBadge> : null}
|
||||||
|
{action ? <span className="ml-auto">{action}</span> : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部横幅:整体档位 + 需要看的几条 */
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
"mb-4 flex items-start gap-3 rounded-xl border px-4 py-3",
|
||||||
|
ok
|
||||||
|
? "border-success/30 bg-success/10"
|
||||||
|
: d.level === "critical"
|
||||||
|
? "border-destructive/30 bg-destructive/10"
|
||||||
|
: "border-warning/30 bg-warning/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ok ? (
|
||||||
|
<CircleCheck className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||||
|
) : d.level === "critical" ? (
|
||||||
|
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 text-caption">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{ok ? "全部正常" : lv.label}
|
||||||
|
<span className="ml-2 font-normal text-muted-foreground">
|
||||||
|
检查于 {formatRelative(d.checkedAt)} · API {d.api.version},已运行 {formatDuration(d.api.uptimeSec)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{list.length ? (
|
||||||
|
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-muted-foreground">
|
||||||
|
{list.map((x) => (
|
||||||
|
<li key={x}>{x}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Processes({ d }: { d: SystemHealth }) {
|
||||||
|
return (
|
||||||
|
<DataTable<SystemProcess>
|
||||||
|
rows={d.processes}
|
||||||
|
rowKey={(p) => p.instanceId}
|
||||||
|
empty={{ title: "24 小时内没有进程心跳", description: "进程启动后每 15 秒写一次心跳。" }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "kind",
|
||||||
|
header: "进程",
|
||||||
|
cell: (p) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="flex items-center gap-2 font-medium">
|
||||||
|
{KIND_LABEL[p.processKind] ?? p.processKind}
|
||||||
|
{p.stale ? <StatusBadge tone="warning">无心跳</StatusBadge> : null}
|
||||||
|
</p>
|
||||||
|
<p className="truncate font-mono text-meta text-muted-foreground" title={p.instanceId}>
|
||||||
|
{p.host} · pid {p.pid}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ver",
|
||||||
|
header: "版本",
|
||||||
|
hideBelow: "lg",
|
||||||
|
cell: (p) => <span className="font-mono text-meta">{p.version}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "start",
|
||||||
|
header: "启动",
|
||||||
|
hideBelow: "md",
|
||||||
|
cell: (p) => (
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(p.startedAt)}>
|
||||||
|
{formatRelative(p.startedAt)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "beat",
|
||||||
|
header: "最后心跳",
|
||||||
|
cell: (p) => (
|
||||||
|
<span className={cn("whitespace-nowrap tabular-nums", p.stale ? "text-warning" : "text-muted-foreground")}>
|
||||||
|
{formatDuration(p.beatAgeSec)}前
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lag",
|
||||||
|
header: "延迟 / 峰值",
|
||||||
|
align: "right",
|
||||||
|
cell: (p) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap tabular-nums",
|
||||||
|
p.eventLoopLagMaxMs >= 5000 ? "text-destructive" : p.eventLoopLagMaxMs >= 1000 ? "text-warning" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.eventLoopLagMs} / {p.eventLoopLagMaxMs} 毫秒
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "conc",
|
||||||
|
header: "并发",
|
||||||
|
hideBelow: "md",
|
||||||
|
align: "right",
|
||||||
|
cell: (p) =>
|
||||||
|
p.concurrency.io === undefined && p.concurrency.agent === undefined ? (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-nowrap tabular-nums">
|
||||||
|
io {p.concurrency.io ?? 0} · Agent {p.concurrency.agent ?? 0}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "running",
|
||||||
|
header: "运行中",
|
||||||
|
hideBelow: "sm",
|
||||||
|
align: "right",
|
||||||
|
cell: (p) => <span className="tabular-nums">{p.runningRuns ?? "—"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sse",
|
||||||
|
header: "SSE",
|
||||||
|
hideBelow: "lg",
|
||||||
|
align: "right",
|
||||||
|
cell: (p) => <span className="tabular-nums">{p.sseConnections ?? "—"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mem",
|
||||||
|
header: "内存",
|
||||||
|
hideBelow: "lg",
|
||||||
|
align: "right",
|
||||||
|
cell: (p) => {
|
||||||
|
const rss = p.extra.rssBytes;
|
||||||
|
return <span className="tabular-nums">{typeof rss === "number" ? formatBytes(rss, 0) : "—"}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function History() {
|
||||||
|
const [hours, setHours] = useState(6);
|
||||||
|
const h = useSystemHealthHistory(hours);
|
||||||
|
return (
|
||||||
|
<section className="pg-slab min-w-0 rounded-xl p-5">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-body font-semibold text-foreground">趋势</h2>
|
||||||
|
{h.data ? (
|
||||||
|
<span className="text-meta text-muted-foreground">每点 {formatDuration(h.data.bucketSec)}</span>
|
||||||
|
) : null}
|
||||||
|
<Tabs value={String(hours)} onValueChange={(v) => setHours(Number(v))} className="ml-auto">
|
||||||
|
<TabsList className="pb-0">
|
||||||
|
{HOURS.map((n) => (
|
||||||
|
<TabsTrigger key={n} value={String(n)} className="h-7 px-2.5 text-caption">
|
||||||
|
{n === 168 ? "7 天" : `${n} 小时`}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
{h.isPending ? (
|
||||||
|
<LoadingState rows={4} />
|
||||||
|
) : h.isError ? (
|
||||||
|
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<DetailSection title="事件循环延迟峰值(毫秒)">
|
||||||
|
<HistoryChart data={h.data} metric="lagMaxMs" unit="毫秒" hours={hours} />
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="SSE 连接数">
|
||||||
|
<HistoryChart data={h.data} metric="sseConnections" unit="个" hours={hours} />
|
||||||
|
</DetailSection>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Disks({ d }: { d: SystemHealth }) {
|
||||||
|
const t = d.diskThresholds;
|
||||||
|
const scope = { data_dir: "数据目录", library_root: "库根", download_dir: "下载目录" } as const;
|
||||||
|
return (
|
||||||
|
<Card title="磁盘" icon={HardDrive} level={worst(d.disks.map((x) => x.level))}>
|
||||||
|
<p className="mb-3 text-meta text-muted-foreground">
|
||||||
|
阈值:{t.warnPercent}% 需要关注,{t.criticalPercent}% 需要处理(取自缓存设置)
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{d.disks.map((x) => {
|
||||||
|
const pct = x.usedRatio === null ? null : x.usedRatio * 100;
|
||||||
|
return (
|
||||||
|
<li key={`${x.scope}-${x.id ?? x.path}`} className="min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="mr-1.5 text-meta text-muted-foreground">{scope[x.scope]}</span>
|
||||||
|
{x.name}
|
||||||
|
</span>
|
||||||
|
<StatusBadge tone={LEVEL[x.level].tone}>
|
||||||
|
{pct === null ? (x.error ? `不可读 ${x.error}` : "用量未知") : `${pct.toFixed(0)}%`}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full",
|
||||||
|
x.level === "critical" ? "bg-destructive" : x.level === "warn" ? "bg-warning" : "bg-foreground/60",
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-y-0 w-px bg-warning" style={{ left: `${t.warnPercent}%` }} />
|
||||||
|
<span className="absolute inset-y-0 w-px bg-destructive" style={{ left: `${t.criticalPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate font-mono text-meta text-muted-foreground" title={x.path}>
|
||||||
|
{x.path}
|
||||||
|
{x.totalBytes !== null && x.availableBytes !== null
|
||||||
|
? ` · 剩余 ${formatBytes(x.availableBytes, 1)} / ${formatBytes(x.totalBytes, 1)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card title="挂载根" icon={HardDrive} level={d.mounts.length ? (bad ? "critical" : "ok") : undefined}>
|
||||||
|
{d.mounts.length === 0 ? (
|
||||||
|
<p className="text-caption text-muted-foreground">没有启用的库根或下载目录映射。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border/60">
|
||||||
|
{d.mounts.map((m) => (
|
||||||
|
<li key={`${m.kind}-${m.id}`} className="flex min-w-0 items-center gap-3 py-2 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate">
|
||||||
|
<span className="mr-1.5 text-meta text-muted-foreground">
|
||||||
|
{m.kind === "library_root" ? "库根" : "下载目录"}
|
||||||
|
</span>
|
||||||
|
{m.name}
|
||||||
|
</p>
|
||||||
|
<p className="truncate font-mono text-meta text-muted-foreground" title={m.path}>
|
||||||
|
{m.path}
|
||||||
|
{m.sentinelChecked ? "" : " · 只查目录可读"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={m.status === "online" ? "success" : "destructive"}>
|
||||||
|
{m.status === "online" ? "在线" : m.status === "timeout" ? "超时" : "离线"}
|
||||||
|
{m.reason && m.status !== "timeout" ? ` · ${MOUNT_REASON[m.reason] ?? m.reason}` : ""}
|
||||||
|
</StatusBadge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const h = useSystemHealth();
|
||||||
|
const d = h.data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="运行状态"
|
||||||
|
description="进程心跳、数据库、磁盘与挂载根。15 秒刷新一次。"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => qc.invalidateQueries({ queryKey: qk.all("system") })}
|
||||||
|
disabled={h.isFetching}
|
||||||
|
>
|
||||||
|
<RotateCw className={h.isFetching ? "animate-spin" : undefined} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{h.isPending ? (
|
||||||
|
<LoadingState rows={8} />
|
||||||
|
) : h.isError ? (
|
||||||
|
<ErrorState title="运行状态加载失败" error={h.error} onRetry={() => h.refetch()} />
|
||||||
|
) : d ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Banner d={d} />
|
||||||
|
<section className="min-w-0">
|
||||||
|
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
|
||||||
|
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||||
|
进程
|
||||||
|
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
|
||||||
|
</h2>
|
||||||
|
<Processes d={d} />
|
||||||
|
</section>
|
||||||
|
<History />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card title="数据库" icon={Database} level={d.db.level}>
|
||||||
|
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "延迟", value: d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒` },
|
||||||
|
{ label: "库大小", value: d.db.sizeBytes === null ? "—" : formatBytes(d.db.sizeBytes, 1) },
|
||||||
|
{
|
||||||
|
label: "连接",
|
||||||
|
value: d.db.connections ? `${d.db.connections.total} / ${d.db.connections.max}` : "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "活跃 / 空闲 / 事务中空闲",
|
||||||
|
value: d.db.connections
|
||||||
|
? `${d.db.connections.active} / ${d.db.connections.idle} / ${d.db.connections.idleInTransaction}`
|
||||||
|
: "—",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
title="任务引擎"
|
||||||
|
icon={ListTodo}
|
||||||
|
level={d.engine.level}
|
||||||
|
action={
|
||||||
|
<Link to="/console/tasks?tab=active" className="text-meta text-muted-foreground hover:text-foreground">
|
||||||
|
查看任务
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "待执行", value: d.engine.pending },
|
||||||
|
{ label: "执行中", value: d.engine.running },
|
||||||
|
{ label: "最久待执行", value: formatDuration(d.engine.oldestPendingAgeSec) },
|
||||||
|
{
|
||||||
|
label: "24 小时失败率",
|
||||||
|
value:
|
||||||
|
d.engine.failureRate24h === null
|
||||||
|
? "—"
|
||||||
|
: `${(d.engine.failureRate24h * 100).toFixed(1)}%(${d.engine.failed24h} / ${d.engine.finished24h})`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
title="定时任务"
|
||||||
|
icon={Timer}
|
||||||
|
level={d.schedules.level}
|
||||||
|
action={
|
||||||
|
<Link to="/console/tasks?tab=plan" className="text-meta text-muted-foreground hover:text-foreground">
|
||||||
|
查看计划
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "启用 / 全部", value: `${d.schedules.enabled} / ${d.schedules.total}` },
|
||||||
|
{ label: "运行中", value: d.schedules.running },
|
||||||
|
{ label: "24 小时失败运行", value: d.schedules.failedRuns24h },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{d.schedules.failing.length ? (
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{d.schedules.failing.map((f) => (
|
||||||
|
<li key={f.id} className="min-w-0 text-caption">
|
||||||
|
<Link
|
||||||
|
to={`/console/tasks?tab=plan&job=${encodeURIComponent(f.id)}`}
|
||||||
|
className="font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Link>
|
||||||
|
<span className="ml-1.5 text-meta text-muted-foreground">{formatRelative(f.lastRunAt)}</span>
|
||||||
|
{f.lastError ? <p className="truncate text-meta text-destructive">{f.lastError}</p> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
<Disks d={d} />
|
||||||
|
<Mounts d={d} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user