- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { AcquisitionPlan, DownloadStatus, DownloadTask, PlanStatus } from "@umt/contracts";
|
|
import { EventType } from "@umt/contracts";
|
|
import { Download, ListChecks, RefreshCw } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { Link, useNavigate } from "react-router";
|
|
import {
|
|
DOWNLOAD_STATUS_LABEL,
|
|
PLAN_STATUS_LABEL,
|
|
useDownloaderNames,
|
|
useDownloadTasks,
|
|
usePlans,
|
|
} from "~/api/downloads.ts";
|
|
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
|
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.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 } from "~/components/console/status-badge.tsx";
|
|
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
|
import {
|
|
DownloadDetail,
|
|
DownloadStatusBadges,
|
|
PauseResume,
|
|
ProgressLine,
|
|
} from "~/components/downloads/download-detail.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
|
import { useSse } from "~/lib/sse.ts";
|
|
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
|
|
|
const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const;
|
|
|
|
export default function TasksPage() {
|
|
const [status, setStatus] = useState("");
|
|
const [managed, setManaged] = useState("");
|
|
const [planStatus, setPlanStatus] = useState("");
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
const [openPlan, setOpenPlan] = useState<AcquisitionPlan | null>(null);
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const downloads = useDownloadTasks({
|
|
status: (status || undefined) as DownloadStatus | undefined,
|
|
managed: (managed || undefined) as "true" | "false" | undefined,
|
|
});
|
|
const plans = usePlans((planStatus || undefined) as PlanStatus | undefined);
|
|
const names = useDownloaderNames();
|
|
|
|
// 下载进度/状态、阈值事件到达时刷新列表(SSE 为主,轮询兜底)
|
|
useSse(
|
|
[
|
|
EventType.downloadProgress,
|
|
EventType.downloadStatusChanged,
|
|
EventType.downloadSubmitted,
|
|
EventType.downloadThreshold,
|
|
],
|
|
() => void qc.invalidateQueries({ queryKey: ["downloads"] }),
|
|
);
|
|
|
|
const rows = downloads.data?.pages.flatMap((p) => p.items);
|
|
const planRows = plans.data?.pages.flatMap((p) => p.items);
|
|
const open = rows?.find((t) => t.id === openId) ?? null;
|
|
const linkedDownload = openPlan ? rows?.find((t) => t.acquisitionPlanId === openPlan.id) : undefined;
|
|
|
|
const columns: DataColumn<DownloadTask>[] = [
|
|
{
|
|
key: "name",
|
|
header: "任务",
|
|
cell: (t) => (
|
|
<div className="min-w-0 max-w-sm">
|
|
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
|
|
<p className="mt-0.5 truncate text-meta text-muted-foreground">
|
|
{names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{ key: "status", header: "状态", cell: (t) => <DownloadStatusBadges t={t} /> },
|
|
{ key: "progress", header: "进度", hideBelow: "sm", cell: (t) => <ProgressLine t={t} /> },
|
|
{
|
|
key: "size",
|
|
header: "大小",
|
|
hideBelow: "lg",
|
|
align: "right",
|
|
cell: (t) => (
|
|
<span className="whitespace-nowrap tabular-nums">
|
|
{t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "at",
|
|
header: "更新",
|
|
hideBelow: "md",
|
|
cell: (t) => (
|
|
<span className="whitespace-nowrap text-muted-foreground">{relTime(t.lastSyncedAt ?? t.updatedAt)}</span>
|
|
),
|
|
},
|
|
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
|
|
];
|
|
|
|
const planColumns: DataColumn<AcquisitionPlan>[] = [
|
|
{
|
|
key: "purpose",
|
|
header: "计划",
|
|
cell: (p) => (
|
|
<div className="min-w-0">
|
|
<p className="text-foreground">
|
|
{PURPOSE[p.purpose]} · 优先级 {p.priority}
|
|
</p>
|
|
<p className="truncate font-mono text-meta text-muted-foreground">{p.id.slice(0, 8)}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "状态",
|
|
cell: (p) => (
|
|
<StatusBadge
|
|
tone={
|
|
p.status === "failed"
|
|
? "destructive"
|
|
: p.status === "completed"
|
|
? "success"
|
|
: p.status === "expired"
|
|
? "warning"
|
|
: "neutral"
|
|
}
|
|
>
|
|
{PLAN_STATUS_LABEL[p.status]}
|
|
</StatusBadge>
|
|
),
|
|
},
|
|
{
|
|
key: "reserved",
|
|
header: "预留空间",
|
|
hideBelow: "md",
|
|
align: "right",
|
|
cell: (p) => <span className="tabular-nums">{formatBytes(p.reservedBytes, 1)}</span>,
|
|
},
|
|
{
|
|
key: "reason",
|
|
header: "说明",
|
|
hideBelow: "lg",
|
|
cell: (p) => <span className="line-clamp-1 text-muted-foreground">{p.statusReason ?? "—"}</span>,
|
|
},
|
|
{
|
|
key: "at",
|
|
header: "创建",
|
|
hideBelow: "sm",
|
|
cell: (p) => <span className="whitespace-nowrap text-muted-foreground">{relTime(p.createdAt)}</span>,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="下载与任务"
|
|
description="下载、整理、核验串成一条业务时间线;阈值暂停不能手动绕过,无进展只记录不自动换源。"
|
|
actions={
|
|
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
|
|
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
|
|
刷新
|
|
</Button>
|
|
}
|
|
/>
|
|
<CacheVolumes />
|
|
<Tabs defaultValue="downloads">
|
|
<TabsList>
|
|
<TabsTrigger value="downloads">下载任务</TabsTrigger>
|
|
<TabsTrigger value="plans">下载计划</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="downloads">
|
|
<FilterBar
|
|
onReset={
|
|
status || managed
|
|
? () => {
|
|
setStatus("");
|
|
setManaged("");
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<FilterSelect
|
|
label="状态"
|
|
value={status}
|
|
onChange={setStatus}
|
|
options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
|
/>
|
|
<FilterSelect
|
|
label="纳管"
|
|
value={managed}
|
|
onChange={setManaged}
|
|
options={[
|
|
{ value: "true", label: "纳管任务" },
|
|
{ value: "false", label: "未纳管" },
|
|
]}
|
|
/>
|
|
</FilterBar>
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(t) => t.id}
|
|
loading={downloads.isPending}
|
|
error={downloads.error}
|
|
onRetry={() => void downloads.refetch()}
|
|
onRowClick={(t) => setOpenId(t.id)}
|
|
selectedKey={openId}
|
|
hasMore={downloads.hasNextPage}
|
|
onLoadMore={() => void downloads.fetchNextPage()}
|
|
loadingMore={downloads.isFetchingNextPage}
|
|
empty={{
|
|
icon: Download,
|
|
title: status || managed ? "没有符合筛选的下载" : "还没有下载任务",
|
|
description: "自动收录或在站点工作台手动下载后,任务会出现在这里。",
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="plans">
|
|
<FilterBar onReset={planStatus ? () => setPlanStatus("") : undefined}>
|
|
<FilterSelect
|
|
label="状态"
|
|
value={planStatus}
|
|
onChange={setPlanStatus}
|
|
options={Object.entries(PLAN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
|
/>
|
|
</FilterBar>
|
|
<DataTable
|
|
columns={planColumns}
|
|
rows={planRows}
|
|
rowKey={(p) => p.id}
|
|
loading={plans.isPending}
|
|
error={plans.error}
|
|
onRetry={() => void plans.refetch()}
|
|
onRowClick={setOpenPlan}
|
|
selectedKey={openPlan?.id}
|
|
hasMore={plans.hasNextPage}
|
|
onLoadMore={() => void plans.fetchNextPage()}
|
|
loadingMore={plans.isFetchingNextPage}
|
|
empty={{
|
|
icon: ListChecks,
|
|
title: "还没有下载计划",
|
|
description: "规则评估选中候选后生成计划并预留 Slot 与预算。",
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<DetailSheet
|
|
open={!!open}
|
|
onOpenChange={(o) => !o && setOpenId(null)}
|
|
width="lg"
|
|
title={open?.name ?? ""}
|
|
footer={
|
|
open ? (
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link to={`/console/tasks/${open.id}`}>在独立页面打开</Link>
|
|
</Button>
|
|
) : null
|
|
}
|
|
>
|
|
{open ? <DownloadDetail task={open} /> : null}
|
|
</DetailSheet>
|
|
|
|
<DetailSheet
|
|
open={!!openPlan}
|
|
onOpenChange={(o) => !o && setOpenPlan(null)}
|
|
title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""}
|
|
description={openPlan?.statusReason ?? undefined}
|
|
footer={
|
|
linkedDownload ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setOpenPlan(null);
|
|
navigate(`/console/tasks/${linkedDownload.id}`);
|
|
}}
|
|
>
|
|
查看下载任务
|
|
</Button>
|
|
) : null
|
|
}
|
|
>
|
|
{openPlan ? (
|
|
<>
|
|
<KvList
|
|
items={[
|
|
{ label: "状态", value: PLAN_STATUS_LABEL[openPlan.status] },
|
|
{ label: "优先级", value: openPlan.priority },
|
|
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
|
|
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
|
|
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
|
|
{ label: "Slot", value: openPlan.slotId, mono: true },
|
|
{ label: "资源", value: openPlan.sourceReleaseId, mono: true, wide: true },
|
|
]}
|
|
/>
|
|
<DetailSection title="评估依据">
|
|
<JsonView value={openPlan.evaluation} />
|
|
</DetailSection>
|
|
<JsonView label="预算预留" value={openPlan.budgetReservation} />
|
|
</>
|
|
) : null}
|
|
</DetailSheet>
|
|
</>
|
|
);
|
|
}
|