Files
UHD_Media_Tool/apps/web/app/routes/console/logs.tsx
T

461 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}