merge: ui2/v-logs-status

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:23:24 +08:00
commit ee3ebcb25c
53 files changed
+4100 -934

No files matched your search

+5 -263
View File
@@ -1,266 +1,8 @@
import { AuditAction, type AuditEvent, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useAuditEvents } from "~/api/audit.ts";
import { 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 { Button } from "~/components/ui/button.tsx";
import { ACTOR_TYPES, actorTypeLabel, formatRelative } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { legacyRedirectLoader } from "./redirects.ts";
export const meta = () => [{ title: "审计 · UHD 媒体自动化控制台" }];
// 界面第二轮:审计页并入日志页(登录、API Key 在「登录」类,其余在「审计」类);?targetType=&targetId= 换成对象筛选。
export const clientLoader = legacyRedirectLoader;
const actorLabel = actorTypeLabel;
const ACTION_LABEL: Record<string, string> = {
"admin.setup": "初始化管理员",
"admin.login_success": "登录",
"admin.logout": "退出登录",
"admin.password_change": "修改密码",
"admin.totp_enable": "启用二次验证",
"admin.totp_disable": "停用二次验证",
"api_key.create": "创建 API Key",
"api_key.update": "修改 API Key",
"api_key.revoke": "撤销 API Key",
"settings.update": "修改设置",
"secret.create": "新建凭证",
"secret.rotate": "轮换凭证",
"secret.delete": "删除凭证",
"site_account.credentials_set": "录入站点凭证",
"engine.run_cancel": "取消任务",
"pi_upstream.oauth_start": "开始订阅登录",
"pi_upstream.oauth_login": "完成订阅登录",
"pi_upstream.oauth_logout": "退出订阅登录",
"schedule.run_now": "立即执行定时任务",
"schedule.skip": "跳过定时任务",
"acquisition_plan.manual_submit": "手动提交下载",
"acquisition_plan.cancel": "取消下载",
"engine.run_retry": "重跑任务",
"capability.invoke": "智能体操作",
"capability.denied": "智能体操作被拒绝",
"site.created": "登记站点",
"site.updated": "修改站点",
"feed_source.created": "新建 RSS",
"identity.release_assigned": "指定资源身份",
"schedule.update": "修改定时任务",
"files.mkdir": "新建目录",
"files.rename": "重命名",
"files.move": "移动文件",
"files.copy": "复制文件",
"files.organize": "整理文件",
"files.delete": "删除文件",
"files.trash_restore": "从回收站还原",
};
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
const NOUN: Record<string, string> = {
library_root: "媒体库目录",
downloader_instance: "下载器",
media_server_instance: "媒体服务器",
path_mapping: "路径映射",
pi_upstream: "模型服务",
schedule: "定时任务",
automation_grant: "持续授权",
};
const TARGET_LABEL: Record<string, string> = {
...NOUN,
admin_user: "管理员",
api_key: "API Key",
settings: "设置",
secret: "凭证",
site: "站点",
site_account: "站点账号",
feed_source: "RSS",
engine_run: "任务",
acquisition_plan: "下载",
download_task: "下载任务",
media_entity: "作品",
source_release: "资源",
organization_record: "整理记录",
file: "文件",
capability: "能力",
};
/** 对象列:能力显示中文名,其余显示对象类型;ID 只在详情里 */
function targetText(type: string, id: string | null): string {
if (type === "capability" && id) return CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
return TARGET_LABEL[type] ?? type;
}
function actionLabel(a: string): string {
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
const [noun, verb] = a.split(".");
if (noun && verb && NOUN[noun] && VERB[verb]) return `${VERB[verb]}${NOUN[noun]}`;
return a;
}
// 前缀组 + 具体动作
const ACTION_OPTIONS = [
...[
"admin",
"api_key",
"settings",
"secret",
"library_root",
"downloader_instance",
"media_server_instance",
"path_mapping",
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
...AuditAction.options.map((a) => ({ value: a, label: `${actionLabel(a)} · ${a}` })),
];
export default function AuditPage() {
const [params, setParams] = useSearchParams();
const actorType = params.get("actorType") ?? "";
const action = params.get("action") ?? "";
const targetId = params.get("targetId") ?? "";
const [target, setTarget] = useState(params.get("targetType") ?? "");
const [targetType, setTargetType] = useState(target);
const [selected, setSelected] = useState<AuditEvent | null>(null);
useEffect(() => {
const t = setTimeout(() => {
const v = target.trim();
setTargetType(v);
setParams(
(p) => {
if (v) p.set("targetType", v);
else p.delete("targetType");
return p;
},
{ replace: true },
);
}, 350);
return () => clearTimeout(t);
}, [target, setParams]);
const setParam = (k: string, v: string) =>
setParams(
(p) => {
if (v) p.set(k, v);
else p.delete(k);
return p;
},
{ replace: true },
);
const q = useAuditEvents({
actorType: actorType || undefined,
action: action || undefined,
targetType: targetType || undefined,
targetId: targetId || undefined,
});
const rows = q.data?.pages.flatMap((p) => p.items);
const filtered = !!(actorType || action || targetType || targetId);
return (
<>
<PageHeader title="审计" />
<FilterBar
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
onReset={
filtered
? () => {
setTarget("");
setParams({}, { replace: true });
}
: undefined
}
>
<FilterSelect
label="操作者"
value={actorType}
onChange={(v) => setParam("actorType", v)}
options={ACTOR_TYPES}
/>
<FilterSelect label="动作" value={action} onChange={(v) => setParam("action", v)} options={ACTION_OPTIONS} />
{targetId ? (
<Button variant="outline" size="sm" onClick={() => setParam("targetId", "")} title={targetId}>
对象 <span className="max-w-40 truncate font-mono">{targetId}</span>
<X />
</Button>
) : null}
</FilterBar>
<DataTable<AuditEvent>
paginationKey={JSON.stringify({ actorType, action, targetType, targetId })}
rows={rows}
rowKey={(r) => r.id}
loading={q.isPending}
error={q.error}
onRetry={() => q.refetch()}
empty={{
title: filtered ? "没有符合条件的审计事件" : "还没有审计事件",
}}
onRowClick={setSelected}
selectedKey={selected?.id ?? null}
hasMore={!!q.hasNextPage}
onLoadMore={() => q.fetchNextPage()}
loadingMore={q.isFetchingNextPage}
columns={[
{
key: "at",
header: "时间",
cell: (r) => (
<span className="whitespace-nowrap tabular-nums" title={formatDateTime(r.at)}>
{formatRelative(r.at)}
</span>
),
},
{
key: "action",
header: "动作",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{actionLabel(r.action)}</p>
</div>
),
},
{ key: "actor", header: "操作者", hideBelow: "sm", cell: (r) => actorLabel(r.actorType) },
{
key: "target",
header: "对象",
hideBelow: "md",
cell: (r) =>
r.targetType ? (
<p className="min-w-0 max-w-[18rem] truncate" title={`${r.targetType} · ${r.targetId ?? ""}`}>
{targetText(r.targetType, r.targetId)}
</p>
) : (
"—"
),
},
]}
/>
<DetailSheet
open={!!selected}
onOpenChange={(o) => !o && setSelected(null)}
title={selected ? actionLabel(selected.action) : ""}
description={selected ? formatDateTime(selected.at) : undefined}
>
{selected ? (
<>
<DetailSection title="事件">
<KvList
items={[
{ label: "动作", value: selected.action, mono: true },
{
label: "操作者",
value: `${actorLabel(selected.actorType)}${selected.actorId ? ` · ${selected.actorId}` : ""}`,
wide: true,
},
{ label: "对象类型", value: selected.targetType },
{ label: "对象 ID", value: selected.targetId, mono: true },
{ label: "Trace", value: selected.traceId, mono: true },
{ label: "事件 ID", value: selected.id, mono: true },
]}
/>
</DetailSection>
<JsonView label="负载" value={selected.payload} />
</>
) : null}
</DetailSheet>
</>
);
export default function LegacyRedirect() {
return null;
}
+3 -3
View File
@@ -28,7 +28,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
import { useLiveTasks } from "~/components/tasks/live.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
@@ -217,7 +217,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
]);
const empty = !downloads.length && !agents.length && !runs.length;
return (
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
<Section title="在忙什么" icon={Activity} to="/console/logs?type=task&level=running">
{running.isPending && !downloads.length ? (
<LoadingState rows={3} />
) : empty ? (
@@ -246,7 +246,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
))}
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
<Row key={r.id}>
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
<Link to={`/console/logs?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{runTitle(r)}</span>
</Link>
</Row>
+460
View File
@@ -0,0 +1,460 @@
import { useQueryClient } from "@tanstack/react-query";
import type { LogEntry } from "@umt/contracts";
import { Pause, Play, RotateCw, ScrollText, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { agentKeys } from "~/api/agent.ts";
import { type LogParams, type LogStreamStatus, upsertEntries, useLogStream, useLogs } from "~/api/logs.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import {
LOG_FILTER_KEYS,
LOG_LEVEL,
LOG_LEVELS,
LOG_SINCE,
LOG_TYPE_LABEL,
LOG_TYPES,
type LogFilterKey,
type LogFilters,
logBadge,
logSubtitle,
logTitle,
objectText,
sinceToFrom,
TARGET_LABEL,
} from "~/components/logs/labels.ts";
import { AskAgentButton, LogDetailSheet } from "~/components/logs/log-detail.tsx";
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
import { useLiveTasks } from "~/components/tasks/live.ts";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
export const meta = () => [{ title: "日志 · UHD 媒体自动化控制台" }];
// 日志(界面第二轮 V 线):任务、定时与自动任务、登录、审计合成一份列表(GET /logs),实时追加(GET /logs/stream);
// 「定时任务」页签是原计划页(定时任务的开关、间隔、立即执行;运行记录回到日志按定时任务筛选)。
// URL:?type= &level= &since= &q= &objectType= &objectId= &system=1 筛选;?log=<id> / ?run=<引擎运行 id> 打开详情;
// ?tab=schedules[&job=] 定时任务页签。
type Tab = "logs" | "schedules";
/** 列表里的时间:今天只写时分秒,其余带月日 */
function shortTime(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
const today = new Date();
return d.toDateString() === today.toDateString() ? hms : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hms}`;
}
const STREAM_LABEL: Record<LogStreamStatus, string> = {
off: "已暂停",
connecting: "连接中…",
open: "实时",
reconnecting: "重连中…",
};
function StreamState({ status }: { status: LogStreamStatus }) {
return (
<span className="inline-flex items-center gap-1.5 text-meta text-muted-foreground" role="status">
<span
aria-hidden
className={cn(
"inline-block size-2 rounded-full",
status === "open" ? "animate-pulse bg-success" : status === "off" ? "bg-foreground/30" : "bg-warning",
)}
/>
{STREAM_LABEL[status]}
</span>
);
}
function LogsView({
params,
setParams,
}: {
params: URLSearchParams;
setParams: ReturnType<typeof useSearchParams>[1];
}) {
const qc = useQueryClient();
useLiveTasks();
const filters = Object.fromEntries(LOG_FILTER_KEYS.map((k) => [k, params.get(k) ?? ""])) as LogFilters;
const [keyword, setKeyword] = useState(filters.q);
const [live, setLive] = useState(true);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const anchor = useRef<string | null>(null);
const update = (fn: (p: URLSearchParams) => void) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
fn(next);
return next;
},
{ replace: true },
);
const setFilter = (k: LogFilterKey, v: string) =>
update((p) => {
if (v) p.set(k, v);
else p.delete(k);
});
// 关键词输入防抖写进 URL
// biome-ignore lint/correctness/useExhaustiveDependencies: 只跟输入走
useEffect(() => {
const t = setTimeout(() => {
if (keyword.trim() !== filters.q) setFilter("q", keyword.trim());
}, 350);
return () => clearTimeout(t);
}, [keyword]);
const from = useMemo(() => sinceToFrom(filters.since), [filters.since]);
const query: LogParams = {
type: filters.type || undefined,
level: filters.level || undefined,
from,
q: filters.q || undefined,
objectType: filters.objectType || undefined,
objectId: filters.objectId || undefined,
system: filters.system || undefined,
};
const list = useLogs(query);
const listRows = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
// 流:从列表里最新一条之后接着推;时间范围只决定下界,新日志总在范围内
const stream = useLogStream(query, { enabled: live && !list.isPending, since: listRows[0]?.at ?? null });
const rows = useMemo(
() => (stream.entries.length ? upsertEntries(listRows, stream.entries) : listRows),
[listRows, stream.entries],
);
const fresh = useMemo(() => new Set(stream.entries.map((e) => e.id)), [stream.entries]);
// 筛选变了:清掉选择
const filterKey = JSON.stringify(query);
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在筛选变化时清空
useEffect(() => {
setSelected(new Set());
anchor.current = null;
}, [filterKey]);
const openId = params.get("log") ?? (params.get("run") ? `engine_run:${params.get("run")}` : null);
const openEntry = openId ? (rows.find((e) => e.id === openId) ?? null) : null;
const selectedRows = rows.filter((e) => selected.has(e.id));
/** 勾选:按住 Shift 选中与上一次勾选之间的一段 */
const toggle = (e: LogEntry, shift: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
const on = !prev.has(e.id);
if (shift && anchor.current) {
const a = rows.findIndex((r) => r.id === anchor.current);
const b = rows.findIndex((r) => r.id === e.id);
if (a >= 0 && b >= 0) {
for (const r of rows.slice(Math.min(a, b), Math.max(a, b) + 1)) {
if (on) next.add(r.id);
else next.delete(r.id);
}
return next;
}
}
if (on) next.add(e.id);
else next.delete(e.id);
return next;
});
anchor.current = e.id;
};
const filtered = LOG_FILTER_KEYS.some((k) => !!filters[k]);
const objectChip = filters.objectType || filters.objectId;
return (
<>
<div className="mb-3 flex flex-wrap items-center gap-2">
<StreamState status={live ? stream.status : "off"} />
<Button
variant="outline"
size="sm"
onClick={() => setLive((v) => !v)}
aria-pressed={live}
aria-label={live ? "暂停实时" : "继续实时"}
>
{live ? <Pause /> : <Play />}
{live ? "暂停" : "继续"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
stream.clear();
void qc.invalidateQueries({ queryKey: qk.all("logs") });
}}
>
<RotateCw />
刷新
</Button>
{selected.size ? (
<span className="ml-auto flex flex-wrap items-center gap-2" data-floating-bar>
<span className="text-caption text-muted-foreground">已选 {selected.size} 条</span>
<AskAgentButton entries={selectedRows} />
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
<X />
清除选择
</Button>
</span>
) : (
<span className="ml-auto text-meta text-muted-foreground">勾选日志(按住 Shift 选一段)后可以问智能体</span>
)}
</div>
<FilterBar
search={{ value: keyword, onChange: setKeyword, placeholder: "搜索标题、错误、对象 ID" }}
onReset={
filtered
? () => {
setKeyword("");
update((p) => {
for (const k of LOG_FILTER_KEYS) p.delete(k);
});
}
: undefined
}
>
<FilterSelect
label="类型"
value={filters.type}
onChange={(v) => setFilter("type", v)}
options={LOG_TYPES.map((value) => ({ value, label: LOG_TYPE_LABEL[value] }))}
/>
<FilterSelect
label="结果"
value={filters.level}
onChange={(v) => setFilter("level", v)}
options={LOG_LEVELS.map((value) => ({ value, label: LOG_LEVEL[value].label }))}
/>
<FilterSelect
label="时间"
value={filters.since}
onChange={(v) => setFilter("since", v)}
options={LOG_SINCE}
allLabel="不限"
/>
<Button
variant={filters.system === "1" ? "secondary" : "ghost"}
size="sm"
aria-pressed={filters.system === "1"}
title="没有业务对象的任务、例行定时任务的成功运行"
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
>
{filters.system === "1" ? "隐藏例行记录" : "显示例行记录"}
</Button>
{objectChip ? (
<Button
variant="outline"
size="sm"
title={`${filters.objectType} ${filters.objectId}`}
onClick={() =>
update((p) => {
p.delete("objectType");
p.delete("objectId");
})
}
>
{filters.objectType ? (TARGET_LABEL[filters.objectType] ?? filters.objectType) : "对象"}
{filters.objectId ? <span className="max-w-40 truncate font-mono">{filters.objectId}</span> : null}
<X />
</Button>
) : null}
</FilterBar>
<DataTable<LogEntry>
paginationKey={filterKey}
rows={list.isPending ? undefined : rows}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => void list.refetch()}
onRowClick={(r) =>
update((p) => {
p.delete("run");
p.set("log", r.id);
})
}
selectedKey={openId}
hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
empty={{
icon: ScrollText,
title: filtered ? "没有符合条件的日志" : "还没有日志",
description: filtered ? "换个筛选或放宽时间范围。" : "任务运行、定时任务执行、登录和设置修改都会记在这里。",
}}
columns={[
{
key: "pick",
header: <span className="sr-only">选择</span>,
className: "w-8",
cell: (r) => (
<input
type="checkbox"
aria-label={`选择 ${logTitle(r)}`}
checked={selected.has(r.id)}
onClick={(ev) => {
ev.stopPropagation();
toggle(r, ev.shiftKey);
}}
onChange={() => {}}
className="h-4 w-4 cursor-pointer accent-foreground"
/>
),
},
{
key: "at",
header: "时间",
cell: (r) => (
<span
className={cn(
"whitespace-nowrap font-mono text-caption tabular-nums",
fresh.has(r.id) ? "text-foreground" : "text-muted-foreground",
)}
title={`${formatDateTime(r.at)}(${formatRelative(r.at)})`}
>
{shortTime(r.at)}
</span>
),
},
{
key: "type",
header: "类型",
hideBelow: "sm",
cell: (r) => <span className="whitespace-nowrap text-caption">{LOG_TYPE_LABEL[r.type]}</span>,
},
{
key: "level",
header: "结果",
cell: (r) => {
const b = logBadge(r);
return <StatusBadge tone={b.tone}>{b.label}</StatusBadge>;
},
},
{
key: "title",
header: "摘要",
cell: (r) => {
const sub = logSubtitle(r);
return (
<div className="min-w-0 max-w-[16rem] sm:max-w-md lg:max-w-xl">
<p className="truncate font-medium text-foreground" title={logTitle(r)}>
{logTitle(r)}
</p>
{sub ? (
<p
className={cn(
"truncate text-meta",
r.level === "error" ? "text-destructive" : "text-muted-foreground",
)}
title={sub}
>
{sub}
</p>
) : null}
</div>
);
},
},
{
key: "object",
header: "对象",
hideBelow: "lg",
cell: (r) => {
const t = objectText(r.object);
return t ? (
<span className="block max-w-[14rem] truncate text-caption text-muted-foreground" title={t}>
{t}
</span>
) : (
<span className="text-muted-foreground">—</span>
);
},
},
]}
/>
<LogDetailSheet
logId={openId}
entry={openEntry}
onOpenLog={(id) =>
update((p) => {
p.delete("run");
p.set("log", id);
})
}
onClose={() =>
update((p) => {
p.delete("log");
p.delete("run");
})
}
/>
</>
);
}
export default function LogsPage() {
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const tab: Tab = params.get("tab") === "schedules" ? "schedules" : "logs";
return (
<>
<PageHeader
title="日志"
actions={
tab === "schedules" ? (
<Button
variant="outline"
size="sm"
onClick={() => {
qc.invalidateQueries({ queryKey: qk.all("schedules") });
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
}}
>
<RotateCw />
刷新
</Button>
) : null
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (v === "schedules") next.set("tab", "schedules");
else {
next.delete("tab");
next.delete("job");
}
return next;
},
{ replace: true },
)
}
>
<TabsList>
<TabsTrigger value="logs">日志</TabsTrigger>
<TabsTrigger value="schedules">定时任务</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-4">
{tab === "schedules" ? (
<PlanBoard highlightJob={params.get("job") ?? undefined} />
) : (
<LogsView params={params} setParams={setParams} />
)}
</div>
</>
);
}
+91 -16
View File
@@ -1,8 +1,10 @@
import { redirect } from "react-router";
import { BUSINESS_OBJECT_TYPE, RUN_STATUS_LEVEL } from "~/components/logs/labels.ts";
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。
// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。
// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。
// 界面第二轮(V 线):任务、审计、引擎面板、计划合进日志页 /console/logs;任务详情 /console/tasks/:id 保留为独立页。
const IDENTITY_TAB: Record<string, string> = {
pool: "filter=unidentified",
@@ -19,10 +21,14 @@ export function legacyRedirect(pathname: string, search: string): string | null
const rest = q.toString();
const withRest = (to: string) => (rest ? `${to}${to.includes("?") ? "&" : "?"}${rest}` : to);
// IA-3:引擎面板并入任务页;运行详情用任务页抽屉(?run=)
if (path === "/console/engine") return withRest(tab ? `/console/tasks?tab=${tab}` : "/console/tasks");
// 引擎面板 → 日志页「任务」;运行详情用日志页抽屉(?run=)
if (path === "/console/engine") return tasksToLogs(new URLSearchParams(search));
const engineRun = /^\/console\/engine\/([^/]+)$/.exec(path);
if (engineRun) return withRest(`/console/tasks?run=${engineRun[1]}`);
if (engineRun)
return logsUrl([
["type", "task"],
["run", decodeURIComponent(engineRun[1]!)],
]);
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
// DL-3:下载计划页签取消,计划并入下载面板;带计划 id 的深链(?plan= 或 /plans/:id)打开对应行
@@ -42,20 +48,70 @@ export function legacyRedirect(pathname: string, search: string): string | null
// 2026-10-06:待处理中心取消,问题回到发生处;旧链接按问题类型落到对应页面
if (path === "/console/inbox") return inboxRedirect(q.get("kind"));
if (!tab) return null;
if (path === "/console/tasks") {
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
if (tab === "plan") return withRest("/console/schedules");
// FE-6 / DL-3 的旧页签
if (tab === "plan") return withRest("/console/logs?tab=schedules");
if (tab === "downloads") return withRest("/console/downloads");
if (tab === "plans") return withRest("/console/downloads");
// 旧"业务任务"页签:统一任务流
if (tab === "tasks" || tab === "business") return withRest("/console/tasks");
return null;
return tasksToLogs(new URLSearchParams(search));
}
// 审计 → 日志页(审计里的登录、API Key 事件在「登录」类)
if (path === "/console/audit") return auditToLogs(new URLSearchParams(search));
// 计划 → 日志页「定时任务」页签(?job= 透传)
if (path === "/console/schedules") return withRest("/console/logs?tab=schedules");
if (!tab) return null;
if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules");
return null;
}
const LOGS_PATH = "/console/logs";
function logsUrl(pairs: [string, string | null | undefined][]): string {
const q = new URLSearchParams();
for (const [k, v] of pairs) if (v) q.set(k, v);
const s = q.toString();
return s ? `${LOGS_PATH}?${s}` : LOGS_PATH;
}
/** 旧任务页 / 引擎面板的筛选 → 日志页:页签与状态换成级别,业务对象换成对象筛选(按对象时不限类型) */
function tasksToLogs(q: URLSearchParams): string {
const tab = q.get("tab");
const levels = new Set<string>();
if (tab === "active") levels.add("running");
else if (tab === "failed") levels.add("error");
else
for (const st of (q.get("status") ?? "").split(",")) {
const l = RUN_STATUS_LEVEL[st.trim()];
if (l) levels.add(l);
}
const kind = q.get("kind");
const ref = q.get("ref");
return logsUrl([
["type", ref ? null : "task"],
["level", [...levels].join(",")],
["since", q.get("since")],
["q", q.get("workflow") ?? q.get("workflowName")],
["objectType", ref && kind ? (BUSINESS_OBJECT_TYPE[kind] ?? null) : null],
["objectId", ref],
["system", q.get("system")],
["run", q.get("run")],
]);
}
/** 旧审计页:对象筛选照搬;动作当关键词(去掉 .* 前缀组的通配) */
function auditToLogs(q: URLSearchParams): string {
const targetType = q.get("targetType");
const targetId = q.get("targetId");
const action = q.get("action")?.replace(/\.\*$/, "") ?? null;
return logsUrl([
["type", targetType || targetId || action ? null : "audit"],
["q", action],
["objectType", targetType],
["objectId", targetId],
]);
}
/** 整理侧的问题(下载完识别不到、路径冲突、文件被排除、整理卡住 / 失败)→ 整理记录「需要处理」 */
export const ORGANIZE_ATTENTION_PATH = "/console/organize?tab=records&attention=1";
@@ -88,8 +144,27 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
{ from: "/console/downloads/plans", to: "/console/downloads" },
{ from: "/console/downloads/plans?plan=p-1", to: "/console/downloads?plan=p-1" },
{ from: "/console/downloads/plans/p-1", to: "/console/downloads?plan=p-1" },
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
{ from: "/console/tasks?tab=tasks", to: "/console/logs?type=task" },
{ from: "/console/tasks?tab=business", to: "/console/logs?type=task" },
{ from: "/console/tasks", to: "/console/logs?type=task" },
{ from: "/console/tasks?tab=active", to: "/console/logs?type=task&level=running" },
{ from: "/console/tasks?tab=failed&since=24h", to: "/console/logs?type=task&level=error&since=24h" },
{ from: "/console/tasks?tab=all&status=failed", to: "/console/logs?type=task&level=error" },
{ from: "/console/tasks?run=run-1", to: "/console/logs?type=task&run=run-1" },
{
from: "/console/tasks?tab=all&kind=schedule&ref=trash.purge",
to: "/console/logs?objectType=scheduled_job&objectId=trash.purge",
},
{ from: "/console/tasks?kind=download&ref=d-1", to: "/console/logs?objectType=download_task&objectId=d-1" },
{ from: "/console/audit", to: "/console/logs?type=audit" },
{
from: "/console/audit?targetType=engine_run&targetId=r1",
to: "/console/logs?objectType=engine_run&objectId=r1",
},
{ from: "/console/audit?action=settings.update", to: "/console/logs?q=settings.update" },
{ from: "/console/audit?action=api_key.*", to: "/console/logs?q=api_key" },
{ from: "/console/schedules", to: "/console/logs?tab=schedules" },
{ from: "/console/schedules?job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
{ from: "/console/sources?tab=snapshots", to: "/console/sites" },
{ from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" },
@@ -98,11 +173,11 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
{ from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" },
{ from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" },
{ from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" },
{ from: "/console/engine", to: "/console/tasks" },
{ from: "/console/engine?status=failed", to: "/console/tasks?status=failed" },
{ from: "/console/engine/run-1", to: "/console/tasks?run=run-1" },
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
{ from: "/console/engine", to: "/console/logs?type=task" },
{ from: "/console/engine?status=failed", to: "/console/logs?type=task&level=error" },
{ from: "/console/engine/run-1", to: "/console/logs?type=task&run=run-1" },
{ from: "/console/tasks?tab=plan", to: "/console/logs?tab=schedules" },
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
{ from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" },
+5 -35
View File
@@ -1,38 +1,8 @@
import { useQueryClient } from "@tanstack/react-query";
import { RotateCw } from "lucide-react";
import { useSearchParams } from "react-router";
import { agentKeys } from "~/api/agent.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
import { Button } from "~/components/ui/button.tsx";
import { qk } from "~/lib/query-keys.ts";
import { legacyRedirectLoader } from "./redirects.ts";
// FE-6:计划页(定时任务配置)与任务页(运行记录)分开。?job= 高亮并滚到该定时任务;#auto / #checks 定位到节。
// 界面第二轮:计划页并入日志页的「定时任务」页签(/console/logs?tab=schedules,?job= 透传)。
export const clientLoader = legacyRedirectLoader;
export const meta = () => [{ title: "计划 · UHD 媒体自动化控制台" }];
export default function SchedulesPage() {
const qc = useQueryClient();
const [params] = useSearchParams();
return (
<>
<PageHeader
title="计划"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
qc.invalidateQueries({ queryKey: qk.all("schedules") });
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
}}
>
<RotateCw />
刷新
</Button>
}
/>
<PlanBoard highlightJob={params.get("job") ?? undefined} />
</>
);
export default function LegacyRedirect() {
return null;
}
+38 -15
View File
@@ -24,6 +24,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { TechInfo } from "~/components/console/tech-info.tsx";
import { AgentSpendCard, AttentionCard } from "~/components/status/attention-cards.tsx";
import { HistoryChart } from "~/components/status/history-chart.tsx";
import { ResourceSection } from "~/components/status/resource-section.tsx";
import { StorageSection } from "~/components/status/storage-section.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
@@ -35,6 +36,7 @@ export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制
// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。
// 2026-10-06:顶部加「需要关注」汇总(系统健康逐条列出,铃铛点进来落到 #attention)与智能体用量。
// 界面第二轮:加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势,参考 MP 仪表盘);进程表直接展示并带 CPU 列。
const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" },
@@ -250,10 +252,24 @@ function Processes({ d }: { d: SystemHealth }) {
align: "right",
cell: (p) => <span className="tabular-nums">{p.sseConnections ?? "—"}</span>,
},
{
key: "cpu",
header: "CPU",
hideBelow: "md",
align: "right",
cell: (p) => {
const cpu = p.extra.cpuPercent;
return (
<span className="tabular-nums" title="两次心跳之间本进程占一个核的百分比">
{typeof cpu === "number" ? `${cpu.toFixed(1)}%` : "—"}
</span>
);
},
},
{
key: "mem",
header: "内存",
hideBelow: "lg",
hideBelow: "md",
align: "right",
cell: (p) => {
const rss = p.extra.rssBytes;
@@ -517,6 +533,7 @@ export default function StatusPage() {
<AttentionCard />
<AgentSpendCard />
</div>
<ResourceSection />
<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}
@@ -542,7 +559,10 @@ export default function StatusPage() {
icon={ListTodo}
level={d.engine.level}
action={
<Link to="/console/tasks?tab=active" className="text-meta text-muted-foreground hover:text-foreground">
<Link
to="/console/logs?type=task&level=running"
className="text-meta text-muted-foreground hover:text-foreground"
>
查看任务
</Link>
}
@@ -567,8 +587,11 @@ export default function StatusPage() {
icon={Timer}
level={d.schedules.level}
action={
<Link to="/console/schedules" className="text-meta text-muted-foreground hover:text-foreground">
查看计划
<Link
to="/console/logs?tab=schedules"
className="text-meta text-muted-foreground hover:text-foreground"
>
查看定时任务
</Link>
}
>
@@ -584,7 +607,7 @@ export default function StatusPage() {
{d.schedules.failing.map((f) => (
<li key={f.id} className="min-w-0 text-caption">
<Link
to={`/console/schedules?job=${encodeURIComponent(f.id)}`}
to={`/console/logs?objectType=scheduled_job&objectId=${encodeURIComponent(f.id)}`}
className="font-medium text-foreground hover:underline"
>
{f.name}
@@ -600,16 +623,16 @@ export default function StatusPage() {
<Mounts d={d} />
</div>
<StorageSection health={d} />
<TechInfo title="进程与趋势" columns={1}>
<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} />
<RetiredProcesses rows={d.retiredProcesses} />
</section>
<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} />
<RetiredProcesses rows={d.retiredProcesses} />
</section>
<TechInfo title="事件循环与 SSE 趋势" columns={1}>
<History />
</TechInfo>
</div>
+2 -2
View File
@@ -124,9 +124,9 @@ function Operations({ taskId }: { taskId: string }) {
const back = (
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("tasks")}>
<Link to={`${consolePath("logs")}?type=task`}>
<ArrowLeft />
任务
日志
</Link>
</Button>
);
+3 -146
View File
@@ -1,151 +1,8 @@
import { useQueryClient } from "@tanstack/react-query";
import { ExternalLink, RotateCw } from "lucide-react";
import { Link, useSearchParams } from "react-router";
import { useEngineRun, useEngineStats } from "~/api/engine.ts";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { runTitle } from "~/components/tasks/labels.ts";
import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx";
import { type StreamFilters, type StreamTab, TaskStream } from "~/components/tasks/task-stream.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { qk } from "~/lib/query-keys.ts";
import { legacyRedirectLoader } from "./redirects.ts";
export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }];
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载面板 / 任务流 / 计划页
// 界面第二轮:任务页并入日志页(/console/logs?type=task);旧链接与筛选在 redirects.ts 里换算。任务详情 /console/tasks/:id 保留。
export const clientLoader = legacyRedirectLoader;
type Tab = StreamTab;
const TABS: Tab[] = ["all", "active", "failed"];
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
function Count({ n }: { n: number | undefined }) {
if (!n) return null;
return <span className="text-meta text-muted-foreground tabular-nums">{n > 999 ? "999+" : n}</span>;
}
function RunSheet({
runId,
onOpen,
onClose,
}: {
runId: string | null;
onOpen: (id: string) => void;
onClose: () => void;
}) {
const q = useEngineRun(runId ?? undefined);
const r = q.data;
return (
<DetailSheet
open={!!runId}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={r ? runTitle(r) : "任务"}
description={r ? <TaskStatusLine r={r} /> : undefined}
footer={
r ? (
<>
<Button asChild variant="ghost" size="sm">
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
<ExternalLink />
独立页面
</Link>
</Button>
<RunActions r={r} onOpenRun={onOpen} />
</>
) : null
}
>
{q.isPending ? (
<LoadingState rows={6} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : r ? (
<TaskDetailBody r={r} onOpenRun={onOpen} />
) : null}
</DetailSheet>
);
}
export default function TasksPage() {
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const raw = params.get("tab") as Tab | null;
const tab: Tab = raw && TABS.includes(raw) ? raw : "all";
const runId = params.get("run");
const filters = Object.fromEntries(FILTER_KEYS.map((k) => [k, params.get(k) ?? ""])) as StreamFilters;
const stats = useEngineStats();
const by = stats.data?.byStatus;
const update = (fn: (p: URLSearchParams) => void) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
fn(next);
return next;
},
{ replace: true },
);
const setFilter = (k: keyof StreamFilters, v: string) =>
update((p) => {
if (v) p.set(k, v);
else p.delete(k);
});
return (
<>
<PageHeader
title="任务"
actions={
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
<RotateCw />
刷新
</Button>
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
update((p) => {
p.set("tab", v);
if (v !== "all") p.delete("status");
})
}
>
<TabsList>
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="active">
进行中
<Count n={by ? (by.running ?? 0) + (by.pending ?? 0) : undefined} />
</TabsTrigger>
<TabsTrigger value="failed">
失败
<Count n={by?.failed} />
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-4">
<TaskStream
tab={tab}
filters={filters}
setFilter={setFilter}
resetFilters={() =>
update((p) => {
for (const k of FILTER_KEYS) p.delete(k);
})
}
onOpen={(id) => update((p) => p.set("run", id))}
selectedId={runId}
/>
</div>
<RunSheet
runId={runId}
onOpen={(id) => update((p) => p.set("run", id))}
onClose={() => update((p) => p.delete("run"))}
/>
</>
);
export default function LegacyRedirect() {
return null;
}