feat(web): 日志页——任务 / 定时与自动 / 登录 / 审计合一,实时追加与暂停,勾选后问智能体;计划并入「定时任务」页签,侧栏系统组只留日志、运行状态、设置
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
2ce1841628
commit
805ba33ddf
21 files changed
+1693
-846
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user