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:
rootandClaude Opus 5.5 committed 2026-09-28 16:10:42 +08:00
1 parent d8b9bd9662
commit 4bb2583f6c
5 files changed
+1012 -182

No files matched your search

@@ -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>
);
}