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 { useRecordSheet } from "~/components/console/record-sheet.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= / ?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 = { off: "已暂停", connecting: "连接中…", open: "实时", reconnecting: "重连中…", }; function StreamState({ status }: { status: LogStreamStatus }) { return ( {STREAM_LABEL[status]} ); } function LogsView({ params, setParams, }: { params: URLSearchParams; setParams: ReturnType[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>(() => new Set()); const anchor = useRef(null); // 记录抽屉(console-ia.md §9):日志页的记录参数是 ?log=<日志 id>,任务也可以只给 ?run=<引擎运行 id> const sheet = useRecordSheet({ key: "log", scoped: ["run"] }); 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 ( <>
{selected.size ? ( 已选 {selected.size} 条 ) : ( 勾选日志(按住 Shift 选一段)后可以问智能体 )}
{ setKeyword(""); update((p) => { for (const k of LOG_FILTER_KEYS) p.delete(k); }); } : undefined } > setFilter("type", v)} options={LOG_TYPES.map((value) => ({ value, label: LOG_TYPE_LABEL[value] }))} /> setFilter("level", v)} options={LOG_LEVELS.map((value) => ({ value, label: LOG_LEVEL[value].label }))} /> setFilter("since", v)} options={LOG_SINCE} allLabel="不限" /> {objectChip ? ( ) : null} paginationKey={filterKey} rows={list.isPending ? undefined : rows} rowKey={(r) => r.id} loading={list.isPending} error={list.error} onRetry={() => void list.refetch()} onRowClick={(r) => sheet.open(r.id)} selectedKey={openId} hasMore={!!list.hasNextPage} onLoadMore={() => list.fetchNextPage()} loadingMore={list.isFetchingNextPage} empty={{ icon: ScrollText, title: filtered ? "没有符合条件的日志" : "还没有日志", description: filtered ? "换个筛选或放宽时间范围。" : "任务运行、定时任务执行、登录和设置修改都会记在这里。", }} columns={[ { key: "pick", header: 选择, className: "w-8", cell: (r) => ( { ev.stopPropagation(); toggle(r, ev.shiftKey); }} onChange={() => {}} className="h-4 w-4 cursor-pointer accent-foreground" /> ), }, { key: "at", header: "时间", cell: (r) => ( {shortTime(r.at)} ), }, { key: "type", header: "类型", hideBelow: "sm", cell: (r) => {LOG_TYPE_LABEL[r.type]}, }, { key: "level", header: "结果", cell: (r) => { const b = logBadge(r); return {b.label}; }, }, { key: "title", header: "摘要", cell: (r) => { const sub = logSubtitle(r); return (

{logTitle(r)}

{sub ? (

{sub}

) : null}
); }, }, { key: "object", header: "对象", hideBelow: "lg", cell: (r) => { const t = objectText(r.object); return t ? ( {t} ) : ( — ); }, }, ]} /> sheet.open(id)} onClose={sheet.close} /> ); } export default function LogsPage() { const qc = useQueryClient(); const [params, setParams] = useSearchParams(); const tab: Tab = params.get("tab") === "schedules" ? "schedules" : "logs"; return ( <> { qc.invalidateQueries({ queryKey: qk.all("schedules") }); qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] }); }} > 刷新 ) : null } /> 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 }, ) } > 日志 定时任务
{tab === "schedules" ? ( ) : ( )}
); }