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:
commit
ee3ebcb25c
53 files changed
+4100
-934
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user