Files
UHD_Media_Tool/apps/web/app/routes/console/home.tsx
T
rootandClaude Opus 5.5 e681fcedf6 feat(schedules): per-check cost and model summary on GET /schedules (INT-3)
agent_check items carry agentRuns (last 100 sessions: last/avg cost, 7-day count and cost,
models, recent 3); plan page and home plan band use it instead of fetching agent runs.
Migration 0052 indexes agent_run(created_by, created_at).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 17:55:47 +08:00

712 lines
26 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import type { Dashboard } from "@umt/contracts";
import {
Activity,
AlertTriangle,
ArrowRight,
Bot,
CalendarClock,
CalendarCog,
CircleCheck,
Gauge,
HardDrive,
Library as LibraryIcon,
ListTodo,
type LucideIcon,
Pause,
Play,
Radio,
ShieldAlert,
} from "lucide-react";
import type { ReactNode } from "react";
import { useRef } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
import { useDecisions } from "~/api/decisions.ts";
import { useEngineRunsPage, useEngineStats } from "~/api/engine.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, settingsPath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
import { formatUsd, planPath } from "~/components/schedules/labels.ts";
import { planStats, useLiveSchedules } from "~/components/schedules/schedule-board.tsx";
import { engineRunStatus, 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 { 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",
"download.status_changed",
"download.threshold",
"organization.changed",
"agent.run_status",
"feed.fetched",
"feed.health_changed",
"decision.created",
"decision.updated",
"settings.changed",
];
function useLiveDashboard() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(DASHBOARD_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.dashboard });
}, 1500);
});
return useDashboard();
}
function Section({
title,
icon: Icon,
to,
children,
className,
extra,
}: {
title: string;
icon: LucideIcon;
to?: string;
children: ReactNode;
className?: string;
/** 标题右侧的摘要(计数、状态) */
extra?: ReactNode;
}) {
return (
<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">
<Icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
{extra}
{to ? (
<Link
to={to}
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
查看
<ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}
</div>
{children}
</section>
);
}
function Row({ children }: { children: ReactNode }) {
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
}
// ─── 自动化总开关:显眼状态 + 切换 ───
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
const update = useUpdateSettings();
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(ok);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
};
const paused = !a.enabled;
const readOnly = a.readOnlyReconcile;
return (
<div
className={cn(
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
(paused || readOnly) && "border border-warning/40",
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<span
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
)}
>
{readOnly ? (
<ShieldAlert className="h-5 w-5" />
) : paused ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-lead font-semibold text-foreground">
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
</p>
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
</div>
<p className="mt-0.5 text-caption text-muted-foreground">
{paused
? "自动收录、下载、整理已停止,手动操作仍可用。"
: readOnly
? "只核对,不写入。确认无误后关闭。"
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<div className="flex items-center gap-2 text-caption text-foreground">
<label htmlFor="home-readonly">只读对账</label>
<Switch
id="home-readonly"
checked={readOnly}
disabled={update.isPending}
onCheckedChange={(v) =>
setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
}
aria-label="只读对账模式"
/>
</div>
{paused ? (
<Button
size="sm"
disabled={update.isPending}
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
>
<Play />
恢复自动化
</Button>
) : (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={update.isPending}>
<Pause />
暂停自动化
</Button>
}
title="暂停全部自动化?"
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
confirmLabel="暂停自动化"
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
/>
)}
</div>
</div>
);
}
// ─── 正在进行:任务流 top N ───
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
const stats = useEngineStats();
const rows = running.data?.items ?? [];
return (
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
{running.isPending ? (
<LoadingState rows={3} />
) : running.isError ? (
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
) : rows.length === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{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>
</Section>
);
}
// ─── 需要我处理:计数 + 前 3 条 ───
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
const list = useDecisions({});
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
return (
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
) : (
<>
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
{n.total}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">
项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")}
</span>
</p>
<ul className="divide-y divide-border/60">
{top.map((d) => (
<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>
</Row>
))}
</ul>
</>
)}
</Section>
);
}
// ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ───
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
const c = a.slotCounts;
const total = Object.values(c).reduce((x, y) => x + y, 0);
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
const lib = a.library;
return (
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
<div className="grid grid-cols-3 gap-2">
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
</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>
);
}
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
function PlanCount({
to,
label,
icon: Icon,
children,
}: {
to: string;
label: string;
icon: LucideIcon;
children: ReactNode;
}) {
return (
<Link
to={to}
className="flex min-w-0 flex-col gap-1 rounded-xl border border-border/70 px-3 py-2.5 transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
</span>
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-caption text-muted-foreground">
{children}
</span>
</Link>
);
}
function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) {
return (
<span className={cn("text-lead font-semibold tabular-nums", tone ? "text-destructive" : "text-foreground")}>
{children}
</span>
);
}
function PlanStrip() {
const board = useLiveSchedules();
const s = planStats(board.data?.items ?? []);
return (
<Section
title="计划"
icon={CalendarCog}
to={planPath()}
extra={
board.data && !board.data.schedulerOnline ? <span className="text-meta text-warning">调度器未在线</span> : null
}
>
{board.isPending ? (
<LoadingState rows={2} />
) : board.isError ? (
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
) : (
<div className="grid grid-cols-2 gap-2">
<PlanCount to={planPath({ section: "auto" })} label="自动任务" icon={CalendarClock}>
<span>
启用 <Num>{s.auto.enabled}</Num> / {s.auto.total}
</span>
<span>
失败 <Num tone={s.auto.failed ? "destructive" : undefined}>{s.auto.failed}</Num>
</span>
</PlanCount>
<PlanCount to={planPath({ section: "checks" })} label="Agent 巡检" icon={Bot}>
<span>
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
</span>
<span title="近 7 天巡检会话费用合计">
近 7 天 <Num>{formatUsd(s.checks.weekCost)}</Num>
</span>
</PlanCount>
</div>
)}
</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 = engineRunStatus(r);
return (
<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)} · {formatRelative(r.finishedAt)}
</span>
</Link>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</Row>
);
})}
</ul>
)}
</Section>
);
}
// ─── 来源健康:站点(含账号状态)+ 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 (
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
{rows.length === 0 ? (
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
) : (
<>
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
<ul className="divide-y divide-border/60">
{rows.slice(0, 7).map((r) => {
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
return (
<Row key={`${r.kind}-${r.id}`}>
<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>
</>
)}
</Section>
);
}
// ─── 运行状态摘要(/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"] }) {
const volumeLabel = useVolumeLabel();
return (
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
{c.volumes.length === 0 ? (
<EmptyState
plain
icon={HardDrive}
title="还没有下载器"
description="添加下载器后显示缓存水位。"
className="py-6"
/>
) : (
<ul className="space-y-4">
{c.volumes.map((v) => {
const pct = v.usedPercent;
const over = pct !== null && pct >= v.hardThresholdPercent;
const near = pct !== null && pct >= v.resumePercent;
return (
<li key={v.volumeKey} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{volumeLabel(v)}</span>
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
</StatusBadge>
</div>
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${pct ?? 0}%` }}
/>
<span
aria-hidden
className="absolute inset-y-0 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
/>
</div>
<p className="mt-1.5 text-meta text-muted-foreground">
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
</p>
{v.notes.map((n) => (
<p key={n} className="text-meta text-muted-foreground">
{n}
</p>
))}
</li>
);
})}
</ul>
)}
<Link
to={settingsPath("automation", "retention")}
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
调整阈值与保留天数
</Link>
</Section>
);
}
function LiveIndicator() {
const s = useSseStatus();
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
return <StatusBadge tone={tone}>{label}</StatusBadge>;
}
export default function Home() {
const q = useLiveDashboard();
useLiveTasks();
return (
<>
<PageHeader
title="首页"
description="正在进行的任务、需要处理的事项、计划和最近结果。"
actions={
<div className="flex items-center gap-2">
<LiveIndicator />
{q.data ? (
<span className="hidden text-meta text-muted-foreground sm:inline">
更新于 {formatRelative(q.data.generatedAt)}
</span>
) : null}
</div>
}
/>
{q.isPending ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["a", "b", "c", "d", "e", "f"].map((k) => (
<div key={k} className="pg-slab rounded-xl p-5">
<LoadingState rows={3} />
</div>
))}
</div>
) : q.isError ? (
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
) : (
<>
<AutomationBanner a={q.data.automation} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<InProgress w={q.data.currentWork} />
<NeedsMe n={q.data.needsAttention} />
<MediaOverview a={q.data.automation} />
<PlanStrip />
<RecentRuns />
<CacheSection c={q.data.cache} />
<SourcesSection s={q.data.sources} />
<StatusSummary />
</div>
</>
)}
</>
);
}