- POST /acquisition-plans: manual submit via download.plan -> download.submit
(same authorization/audit/idempotency path as the agent), slotId or entityId
- read-only /media-assets, /asset-locations, /asset-unit-coverage, /path-bindings,
/reconcile-runs, /server-mappings, /artifacts, GET /title-aliases/{id};
unimplemented write endpoints removed from api.ts; registry test now checks both directions
- candidate evaluation upsert per (slot, source_release) + migration 0022 dedupe/unique index
- organization records expose downloadTaskId (+ list filter); inventory adds
subscribed/candidateReasons; feed sources expose derived possibleGap
- GET /events?after=&limit= JSON replay page for API key callers (same sse_event table)
- preference tag revision history from audit_event; subscriptions/identities/aliases
lists carry mediaEntityTitle; subscription q/mediaKind search; slot subscriptionId
filter + currentAsset summary; policy preview samples carry title/reasons
- DecisionSummary, IdentityRevisionList, ReleaseIdentityResult, PolicyPreviewSummary exported from contracts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
import type { FeedHealth, FeedSource } from "@umt/contracts";
|
|
import { Plus, RefreshCw, RotateCcw, Rss, Zap } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { feedGapHint, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
|
import { useSiteAccounts, useSites } from "~/api/sites.ts";
|
|
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
|
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
|
import { JsonView } from "~/components/console/json-view.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 { Button } from "~/components/ui/button.tsx";
|
|
import { Switch } from "~/components/ui/switch.tsx";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
|
import { FeedFormDialog, PURPOSE_LABEL } from "~/components/workbench/feed-form.tsx";
|
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
|
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { formatDateTime } from "~/lib/utils.ts";
|
|
|
|
const HEALTH: Record<FeedHealth, { label: string; tone: StatusTone }> = {
|
|
unknown: { label: "未抓取", tone: "neutral" },
|
|
healthy: { label: "健康", tone: "success" },
|
|
degraded: { label: "降级", tone: "warning" },
|
|
failing: { label: "失败", tone: "destructive" },
|
|
circuit_open: { label: "熔断", tone: "destructive" },
|
|
disabled: { label: "已停用", tone: "neutral" },
|
|
};
|
|
|
|
const lastErrorText = (f: FeedSource) =>
|
|
f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null;
|
|
|
|
/** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */
|
|
function cursorSummary(f: FeedSource): string {
|
|
const w = f.cursor.window as { lastFetchedAt?: string | null; lastGuidSet?: unknown[] } | undefined;
|
|
if (!w) return "尚无游标";
|
|
const n = Array.isArray(w.lastGuidSet) ? w.lastGuidSet.length : null;
|
|
return `${n !== null ? `窗口 ${n} 条` : "窗口"}${w.lastFetchedAt ? ` · ${relTime(w.lastFetchedAt)}` : ""}`;
|
|
}
|
|
|
|
function FeedActions({ f }: { f: FeedSource }) {
|
|
const fetchNow = useFetchFeedNow();
|
|
const action = useFeedAction();
|
|
return (
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Switch
|
|
checked={f.enabled}
|
|
disabled={action.isPending}
|
|
aria-label={f.enabled ? "停用" : "启用"}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
onCheckedChange={(v) =>
|
|
action.mutate(
|
|
{ id: f.id, action: v ? "enable" : "disable" },
|
|
{ onSuccess: () => toast.success(v ? "已启用" : "已停用"), onError: (e) => toast.error(errorMessage(e)) },
|
|
)
|
|
}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
title="立即抓取(仍受站点限流)"
|
|
aria-label="立即抓取"
|
|
disabled={!f.enabled || fetchNow.isPending}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
fetchNow.mutate(f.id, {
|
|
onSuccess: () => toast.success("已排队抓取"),
|
|
onError: (err) => toast.error(errorMessage(err)),
|
|
});
|
|
}}
|
|
>
|
|
<Zap />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SourcesPage() {
|
|
const [health, setHealth] = useState("");
|
|
const [siteFilter, setSiteFilter] = useState("");
|
|
const feeds = useFeeds({ health: (health || undefined) as FeedHealth | undefined, siteId: siteFilter || undefined });
|
|
const sites = useSites();
|
|
const accounts = useSiteAccounts();
|
|
const [editing, setEditing] = useState<FeedSource | null | "new">(null);
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
const action = useFeedAction();
|
|
const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name]));
|
|
const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label]));
|
|
const open = feeds.data?.items.find((f) => f.id === openId) ?? null;
|
|
|
|
const columns: DataColumn<FeedSource>[] = [
|
|
{
|
|
key: "name",
|
|
header: "名称",
|
|
cell: (f) => (
|
|
<div className="min-w-0 max-w-xs">
|
|
<p className="truncate font-medium text-foreground">{f.name}</p>
|
|
<p className="truncate font-mono text-meta text-muted-foreground" title={f.urlDisplay}>
|
|
{f.urlDisplay}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "health",
|
|
header: "健康",
|
|
cell: (f) => {
|
|
const gap = feedGapHint(f);
|
|
return (
|
|
<div className="flex flex-col items-start gap-1">
|
|
<StatusBadge tone={HEALTH[f.healthStatus].tone}>{HEALTH[f.healthStatus].label}</StatusBadge>
|
|
{gap ? (
|
|
<span title={gap}>
|
|
<StatusBadge tone="warning">{f.possibleGap ? "疑似缺口" : "可能漏项"}</StatusBadge>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{ key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] },
|
|
{
|
|
key: "site",
|
|
header: "站点 / 账号",
|
|
hideBelow: "lg",
|
|
cell: (f) => (
|
|
<span className="text-muted-foreground">
|
|
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
|
|
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "poll",
|
|
header: "轮询",
|
|
hideBelow: "lg",
|
|
cell: (f) => <span className="whitespace-nowrap">{Math.round(f.pollIntervalSec / 60)} 分钟</span>,
|
|
},
|
|
{
|
|
key: "success",
|
|
header: "最后成功",
|
|
hideBelow: "sm",
|
|
cell: (f) => (
|
|
<div className="whitespace-nowrap">
|
|
<p className="text-foreground">{relTime(f.lastSuccessAt)}</p>
|
|
{f.consecutiveFailures ? (
|
|
<p className="text-meta text-destructive">连续失败 {f.consecutiveFailures}</p>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{ key: "ops", header: "", align: "right", cell: (f) => <FeedActions f={f} /> },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="来源 / Feed / 快照"
|
|
description="RSS 与站点快照的抓取状态、新鲜度与健康;站点不可访问时不会把零条目当作“没有更新”。"
|
|
actions={
|
|
<Button size="sm" onClick={() => setEditing("new")}>
|
|
<Plus />
|
|
新建 Feed
|
|
</Button>
|
|
}
|
|
/>
|
|
<Tabs defaultValue="feeds">
|
|
<TabsList>
|
|
<TabsTrigger value="feeds">Feed</TabsTrigger>
|
|
<TabsTrigger value="snapshots">站点快照</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="feeds">
|
|
<FilterBar
|
|
onReset={
|
|
health || siteFilter
|
|
? () => {
|
|
setHealth("");
|
|
setSiteFilter("");
|
|
}
|
|
: undefined
|
|
}
|
|
actions={
|
|
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
|
|
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
|
|
刷新
|
|
</Button>
|
|
}
|
|
>
|
|
<FilterSelect
|
|
label="健康"
|
|
value={health}
|
|
onChange={setHealth}
|
|
options={Object.entries(HEALTH).map(([value, h]) => ({ value, label: h.label }))}
|
|
/>
|
|
<FilterSelect
|
|
label="站点"
|
|
value={siteFilter}
|
|
onChange={setSiteFilter}
|
|
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
|
/>
|
|
</FilterBar>
|
|
<DataTable
|
|
columns={columns}
|
|
rows={feeds.data?.items}
|
|
rowKey={(f) => f.id}
|
|
loading={feeds.isPending}
|
|
error={feeds.error}
|
|
onRetry={() => void feeds.refetch()}
|
|
onRowClick={(f) => setOpenId(f.id)}
|
|
selectedKey={openId}
|
|
empty={{
|
|
icon: Rss,
|
|
title: health || siteFilter ? "没有符合筛选的 Feed" : "还没有 Feed",
|
|
description: "新建 RSS Feed 后按轮询策略抓取,新条目进入识别与收录评估。",
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="snapshots">
|
|
{sites.isPending ? (
|
|
<LoadingState rows={3} />
|
|
) : sites.error ? (
|
|
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
|
|
) : !sites.data.items.length ? (
|
|
<EmptyState title="还没有登记站点" description="登记站点后可以在这里看快照覆盖范围与新鲜度。" />
|
|
) : (
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
{sites.data.items.map((s) => (
|
|
<SnapshotPanel key={s.id} site={s} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<DetailSheet
|
|
open={!!open}
|
|
onOpenChange={(o) => !o && setOpenId(null)}
|
|
title={open?.name ?? ""}
|
|
description={open ? <span className="font-mono break-all">{open.urlDisplay}</span> : undefined}
|
|
footer={
|
|
open ? (
|
|
<>
|
|
{open.healthStatus === "circuit_open" || open.healthStatus === "failing" || open.backoffUntil ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={action.isPending}
|
|
onClick={() =>
|
|
action.mutate(
|
|
{ id: open.id, action: "reset" },
|
|
{
|
|
onSuccess: () => toast.success("已关闭熔断并清空退避"),
|
|
onError: (e) => toast.error(errorMessage(e)),
|
|
},
|
|
)
|
|
}
|
|
>
|
|
<RotateCcw />
|
|
复位熔断
|
|
</Button>
|
|
) : null}
|
|
<Button size="sm" onClick={() => setEditing(open)}>
|
|
编辑
|
|
</Button>
|
|
</>
|
|
) : null
|
|
}
|
|
>
|
|
{open ? (
|
|
<>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<StatusBadge tone={HEALTH[open.healthStatus].tone}>{HEALTH[open.healthStatus].label}</StatusBadge>
|
|
<StatusBadge>{open.enabled ? "已启用" : "已停用"}</StatusBadge>
|
|
<StatusBadge>{PURPOSE_LABEL[open.purpose]}</StatusBadge>
|
|
</div>
|
|
{feedGapHint(open) ? (
|
|
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
|
{feedGapHint(open)}
|
|
</p>
|
|
) : null}
|
|
{lastErrorText(open) ? (
|
|
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption break-words text-destructive">
|
|
{lastErrorText(open)}
|
|
</p>
|
|
) : null}
|
|
<DetailSection title="抓取状态">
|
|
<KvList
|
|
items={[
|
|
{ label: "最后成功", value: formatDateTime(open.lastSuccessAt) },
|
|
{ label: "最后抓取", value: formatDateTime(open.lastFetchAt) },
|
|
{ label: "下次抓取", value: formatDateTime(open.nextFetchAt) },
|
|
{ label: "退避至", value: formatDateTime(open.backoffUntil) },
|
|
{ label: "连续失败", value: open.consecutiveFailures },
|
|
{ label: "轮询间隔", value: `${Math.round(open.pollIntervalSec / 60)} 分钟` },
|
|
{ label: "游标", value: cursorSummary(open) },
|
|
{ label: "解析器", value: open.parserVersion, mono: true },
|
|
{ label: "ETag", value: open.etag, mono: true },
|
|
{ label: "Last-Modified", value: open.lastModified, mono: true },
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
<JsonView label="过滤条件" value={open.filter} />
|
|
<JsonView label="游标原文" value={open.cursor} />
|
|
</>
|
|
) : null}
|
|
</DetailSheet>
|
|
|
|
{editing ? (
|
|
<FeedFormDialog
|
|
open
|
|
onOpenChange={(o) => !o && setEditing(null)}
|
|
feed={editing === "new" ? null : editing}
|
|
sites={sites.data?.items ?? []}
|
|
accounts={accounts.data?.items ?? []}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|