feat(server,contracts,web): dashboard library counts and engine runs triggeredBy/multi-status filters

- GET /dashboard: automation.library {entities, movies, series, seasons,
  episodes, assets} and automation.slotSatisfiedRate; home media overview
  shows the counts instead of placeholders.
- GET /engine/runs: `status` accepts comma-separated values; new
  `triggeredBy` filter (comma-separated) evaluated server-side by scanning
  batches and paging with the last inspected row's cursor (scan cap 2000).
- Task stream: source filter and the "active" tab (running,pending) are now
  one paginated server query.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:49:11 +08:00
1 parent 615917bb45
commit 363f85b1a1
13 files changed
+165 -250

No files matched your search

@@ -445,6 +445,12 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临
expect(dash.body.data.recentOutput.length).toBeGreaterThan(0); expect(dash.body.data.recentOutput.length).toBeGreaterThan(0);
expect(dash.body.data.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 }); expect(dash.body.data.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 });
expect(dash.body.data.needsAttention.total).toBeGreaterThan(0); expect(dash.body.data.needsAttention.total).toBeGreaterThan(0);
const auto = dash.body.data.automation;
expect(auto.library.entities).toBeGreaterThanOrEqual(1);
expect(auto.library.movies).toBeGreaterThanOrEqual(1);
expect(auto.library.assets).toBeGreaterThanOrEqual(1);
const slotTotal = Object.values(auto.slotCounts as Record<string, number>).reduce((a, b) => a + b, 0);
expect(auto.slotSatisfiedRate).toBe(slotTotal ? auto.slotCounts.satisfied / slotTotal : null);
const libList = await h.api("GET", "/library?q=沙丘"); const libList = await h.api("GET", "/library?q=沙丘");
expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 }); expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 });
const det = await h.api("GET", `/library/${movieId}`); const det = await h.api("GET", `/library/${movieId}`);
@@ -63,6 +63,26 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
attention: 0, attention: 0,
}; };
for (const r of slotRows) slotCounts[r.status] = r.n; for (const r of slotRows) slotCounts[r.status] = r.n;
const slotTotal = Object.values(slotCounts).reduce((a, b) => a + b, 0);
const library = (
await sql<{
entities: number;
movies: number;
series: number;
seasons: number;
episodes: number;
assets: number;
}>`
select
(select count(*) from media_entity where status <> 'merged')::int as entities,
(select count(*) from media_entity where status <> 'merged' and kind = 'movie')::int as movies,
(select count(*) from media_entity where status <> 'merged' and kind in ('series','variety'))::int as series,
(select count(*) from content_unit where kind = 'season' and status <> 'retired')::int as seasons,
(select count(*) from content_unit where kind = 'episode' and status <> 'retired')::int as episodes,
(select count(*) from media_asset where status = 'active' and kind in ('video','bluray_dir'))::int as assets`.execute(
db,
)
).rows[0]!;
const dlRows = await db const dlRows = await db
.selectFrom("download_task") .selectFrom("download_task")
@@ -203,6 +223,8 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
}, },
activeSubscriptions: today.subs, activeSubscriptions: today.subs,
slotCounts, slotCounts,
slotSatisfiedRate: slotTotal ? slotCounts.satisfied / slotTotal : null,
library,
}, },
currentWork: { currentWork: {
downloading: count("downloading"), downloading: count("downloading"),
@@ -114,7 +114,7 @@ const RUN_COLS = sql.raw(`id, workflow_name, version, status, attempts, worker_i
export interface EngineQueries { export interface EngineQueries {
listRuns( listRuns(
q: z.infer<typeof EngineRunQuery>, q: z.infer<typeof EngineRunQuery>,
): Promise<{ items: ReturnType<typeof runView>[]; nextCursor: string | null }>; ): Promise<{ items: ReturnType<typeof runView>[]; nextCursor: string | null; cursors: string[] }>;
getRun(id: string): Promise<z.input<typeof EngineRunDetail> | null>; getRun(id: string): Promise<z.input<typeof EngineRunDetail> | null>;
stats(): Promise<z.input<typeof EngineStats>>; stats(): Promise<z.input<typeof EngineStats>>;
} }
@@ -127,7 +127,7 @@ export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQu
async listRuns(q) { async listRuns(q) {
const cur: Cursor | null = decodeCursor(q.cursor); const cur: Cursor | null = decodeCursor(q.cursor);
const conds = [sql`namespace_id = ${NAMESPACE}`]; const conds = [sql`namespace_id = ${NAMESPACE}`];
if (q.status) conds.push(sql`status = ${q.status}`); if (q.status?.length) conds.push(sql`status = any(${q.status}::text[])`);
if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`); if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`);
if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`); if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`);
if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`); if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`);
@@ -144,6 +144,8 @@ export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQu
items: items.map(runView), items: items.map(runView),
nextCursor: nextCursor:
rows.rows.length > q.limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null, rows.rows.length > q.limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null,
// 每行的游标(triggeredBy 逐页筛选时在中途截断用)
cursors: items.map((r) => encodeCursor({ us: Number(r.cursor_us), id: r.id })),
}; };
}, },
@@ -242,6 +244,10 @@ export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQu
}; };
} }
/** triggeredBy 筛选:每批扫描行数与单次请求扫描上限 */
const TRIGGER_SCAN_BATCH = 200;
const TRIGGER_SCAN_CAP = 2000;
export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: { schema?: string } = {}) { export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: { schema?: string } = {}) {
let q: EngineQueries | null = null; let q: EngineQueries | null = null;
const queries = () => { const queries = () => {
@@ -251,8 +257,32 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: {
// W9-I:列表与详情补 business 字段(engine-business.ts;查询失败降级为 null) // W9-I:列表与详情补 business 字段(engine-business.ts;查询失败降级为 null)
const schema = opts.schema ?? "openworkflow"; const schema = opts.schema ?? "openworkflow";
endpoint(app, D.runs, async ({ query }) => { endpoint(app, D.runs, async ({ query }) => {
const page = await queries().listRuns(query); const triggered = query.triggeredBy?.length ? new Set(query.triggeredBy) : null;
return { ...page, items: await attachBusiness(ctx.db, schema, page.items) }; if (!triggered) {
const page = await queries().listRuns(query);
return { items: await attachBusiness(ctx.db, schema, page.items), nextCursor: page.nextCursor };
}
// 触发来源是逐行推断的(engine-business.ts),不能下推成 SQL:按批扫描、补字段后筛选,
// 凑够 limit 或扫满 TRIGGER_SCAN_CAP 行就返回;游标停在最后检查的那一行,可能少于 limit 但仍有下一页
const out: Awaited<ReturnType<typeof attachBusiness<ReturnType<typeof runView>>>> = [];
let cursor = query.cursor;
let scanned = 0;
for (;;) {
const page = await queries().listRuns({ ...query, cursor, limit: TRIGGER_SCAN_BATCH });
const items = await attachBusiness(ctx.db, schema, page.items);
for (let i = 0; i < items.length; i++) {
scanned += 1;
const r = items[i]!;
if (r.business?.triggeredBy && triggered.has(r.business.triggeredBy)) out.push(r);
if (out.length === query.limit) {
const more = i < items.length - 1 || page.nextCursor !== null;
return { items: out, nextCursor: more ? page.cursors[i]! : null };
}
}
if (!page.nextCursor) return { items: out, nextCursor: null };
cursor = page.nextCursor;
if (scanned >= TRIGGER_SCAN_CAP) return { items: out, nextCursor: cursor };
}
}); });
endpoint(app, D.run, async ({ params }) => { endpoint(app, D.run, async ({ params }) => {
const r = await queries().getRun(params.id); const r = await queries().getRun(params.id);
@@ -298,6 +298,9 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => {
expect(p2.data.items.map((r: { id: string }) => r.id)).toEqual(["r1"]); expect(p2.data.items.map((r: { id: string }) => r.id)).toEqual(["r1"]);
expect(p2.data.nextCursor).toBeNull(); expect(p2.data.nextCursor).toBeNull();
expect((await admin("GET", "/engine/runs?status=bogus")).status).toBe(422); expect((await admin("GET", "/engine/runs?status=bogus")).status).toBe(422);
const multi = await json(await admin("GET", "/engine/runs?status=running,pending"));
expect(multi.data.items.map((r: { id: string }) => r.id)).toEqual(["r4", "r3"]);
expect((await admin("GET", "/engine/runs?status=running,bogus")).status).toBe(422);
}); });
it("run 详情:step attempts + signals;敏感字段脱敏,不返回 config/context", async () => { it("run 详情:step attempts + signals;敏感字段脱敏,不返回 config/context", async () => {
+22
View File
@@ -353,6 +353,28 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => {
expect(s.data.items.map((r: { id: string }) => r.id)).toEqual(["b4"]); expect(s.data.items.map((r: { id: string }) => r.id)).toEqual(["b4"]);
expect((await admin("GET", "/engine/runs?businessKind=bogus")).status).toBe(422); expect((await admin("GET", "/engine/runs?businessKind=bogus")).status).toBe(422);
// triggeredBy:服务端逐页推断后筛选;多值逗号分隔;limit 截断时游标接着扫
const range = "from=2026-09-10T00:00:00Z&to=2026-09-11T00:00:00Z";
const ids = (r: { data: { items: { id: string }[] } }) => r.data.items.map((x) => x.id);
expect(ids(await json(await admin("GET", `/engine/runs?${range}&triggeredBy=schedule`)))).toEqual(["b4", "b2"]);
expect(ids(await json(await admin("GET", `/engine/runs?${range}&triggeredBy=event,api_key`)))).toEqual([
"b3",
"b1",
]);
const t1 = await json(await admin("GET", `/engine/runs?${range}&triggeredBy=schedule&limit=1`));
expect(ids(t1)).toEqual(["b4"]);
expect(t1.data.nextCursor).toBeTruthy();
const t2 = await json(
await admin("GET", `/engine/runs?${range}&triggeredBy=schedule&limit=1&cursor=${t1.data.nextCursor}`),
);
expect(ids(t2)).toEqual(["b2"]);
const t3 = await json(
await admin("GET", `/engine/runs?${range}&triggeredBy=schedule&limit=1&cursor=${t2.data.nextCursor}`),
);
expect(ids(t3)).toEqual([]);
expect(t3.data.nextCursor).toBeNull();
expect((await admin("GET", "/engine/runs?triggeredBy=bogus")).status).toBe(422);
const detail = (await json(await admin("GET", "/engine/runs/b2"))).data; const detail = (await json(await admin("GET", "/engine/runs/b2"))).data;
expect(detail.business).toMatchObject({ kind: "agent", refId: agentId }); expect(detail.business).toMatchObject({ kind: "agent", refId: agentId });
expect(detail.stepAttempts).toEqual([]); expect(detail.stepAttempts).toEqual([]);
+5 -1
View File
@@ -5,7 +5,10 @@ import { qk } from "~/lib/query-keys.ts";
import { errorCode } from "./parse.ts"; import { errorCode } from "./parse.ts";
export type EngineRunFilters = { export type EngineRunFilters = {
/** 单值或逗号分隔多值(running,pending) */
status?: string; status?: string;
/** 触发来源,单值或逗号分隔多值(user,api_key) */
triggeredBy?: string;
workflowName?: string; workflowName?: string;
businessKind?: EngineBusinessKind; businessKind?: EngineBusinessKind;
businessRefId?: string; businessRefId?: string;
@@ -16,9 +19,10 @@ export type EngineRunFilters = {
/** 终态:不能再 cancel */ /** 终态:不能再 cancel */
export const ENGINE_TERMINAL = new Set(["completed", "succeeded", "failed", "canceled"]); export const ENGINE_TERMINAL = new Set(["completed", "succeeded", "failed", "canceled"]);
export function useEngineRuns(filters: EngineRunFilters, enabled = true) { export function useEngineRuns(filters: EngineRunFilters, enabled = true, refetchMs: number | false = false) {
return useInfiniteQuery({ return useInfiniteQuery({
enabled, enabled,
refetchInterval: refetchMs,
queryKey: qk.list("engine", filters), queryKey: qk.list("engine", filters),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) => queryFn: ({ pageParam, signal }) =>
+7
View File
@@ -25,6 +25,13 @@ export const SOURCE_LABEL: Record<TaskSource, string> = {
manual: "手动", manual: "手动",
agent: "Agent", agent: "Agent",
}; };
/** 来源 → 服务端 triggeredBy 筛选值(逗号分隔多值) */
export const SOURCE_TRIGGERED_BY: Record<TaskSource, string> = {
schedule: "schedule",
event: "event",
manual: "user,api_key",
agent: "agent",
};
export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null { export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null {
if (!t) return null; if (!t) return null;
if (t === "user" || t === "api_key") return "manual"; if (t === "user" || t === "api_key") return "manual";
+14 -24
View File
@@ -3,7 +3,7 @@ import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts
import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react"; import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { useEngineRuns, useEngineRunsPage } from "~/api/engine.ts"; import { useEngineRuns } from "~/api/engine.ts";
import { useScheduleBoard } from "~/api/schedules.ts"; import { useScheduleBoard } from "~/api/schedules.ts";
import { DataTable } from "~/components/console/data-table.tsx"; import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
@@ -22,6 +22,7 @@ import {
runStatus, runStatus,
runTitle, runTitle,
SOURCE_LABEL, SOURCE_LABEL,
SOURCE_TRIGGERED_BY,
sourceOf, sourceOf,
type TaskSource, type TaskSource,
workflowLabel, workflowLabel,
@@ -132,26 +133,20 @@ export function TaskStream({
businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined, businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined,
businessRefId: filters.ref || undefined, businessRefId: filters.ref || undefined,
workflowName: filters.workflow || undefined, workflowName: filters.workflow || undefined,
triggeredBy: SOURCE_TRIGGERED_BY[filters.source as TaskSource] ?? undefined,
from, from,
}; };
const active = tab === "active";
// 进行中 = 进行中 + 排队(status 多值,服务端分页);来源筛选同样在服务端做
const list = useEngineRuns( const list = useEngineRuns(
{ {
...base, ...base,
status: tab === "failed" ? "failed" : filters.status || undefined, status: active ? "running,pending" : tab === "failed" ? "failed" : filters.status || undefined,
}, },
tab !== "active", true,
active ? 10_000 : false,
); );
// 进行中 = 进行中 + 排队(两次取第一页合并) const rows = list.data?.pages.flatMap((p) => p.items);
const running = useEngineRunsPage({ ...base, status: "running" }, 100, 10_000, tab === "active");
const pending = useEngineRunsPage({ ...base, status: "pending" }, 100, 10_000, tab === "active");
const active = tab === "active";
const loaded = active
? running.data && pending.data
? [...running.data.items, ...pending.data.items]
: undefined
: list.data?.pages.flatMap((p) => p.items);
const rows = filters.source ? loaded?.filter((r) => sourceOf(r.business?.triggeredBy) === filters.source) : loaded;
const filtered = Object.values(filters).some(Boolean); const filtered = Object.values(filters).some(Boolean);
return ( return (
@@ -212,17 +207,12 @@ export function TaskStream({
<DataTable<EngineRun> <DataTable<EngineRun>
rows={rows} rows={rows}
rowKey={(r) => r.id} rowKey={(r) => r.id}
loading={active ? running.isPending || pending.isPending : list.isPending} loading={list.isPending}
error={active ? (running.error ?? pending.error) : list.error} error={list.error}
onRetry={() => { onRetry={() => void list.refetch()}
if (active) {
void running.refetch();
void pending.refetch();
} else void list.refetch();
}}
onRowClick={(r) => onOpen(r.id)} onRowClick={(r) => onOpen(r.id)}
selectedKey={selectedId} selectedKey={selectedId}
hasMore={!active && !!list.hasNextPage} hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()} onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage} loadingMore={list.isFetchingNextPage}
empty={{ empty={{
@@ -234,7 +224,7 @@ export function TaskStream({
: tab === "failed" : tab === "failed"
? "没有失败的任务" ? "没有失败的任务"
: "还没有任务", : "还没有任务",
description: filters.source && loaded?.length ? "来源筛选只作用于已加载的记录,可加载更多再看。" : undefined, description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可加载更多继续查找。" : undefined,
}} }}
columns={[ columns={[
{ {
+6 -7
View File
@@ -280,19 +280,18 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
); );
} }
// ─── 媒体概览:版本目标满足率 + 今日产出(作品/季/资产计数待 /dashboard 提供) ─── // ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ───
function MediaOverview({ a }: { a: Dashboard["automation"] }) { function MediaOverview({ a }: { a: Dashboard["automation"] }) {
const c = a.slotCounts; const c = a.slotCounts;
const total = Object.values(c).reduce((x, y) => x + y, 0); const total = Object.values(c).reduce((x, y) => x + y, 0);
const rate = total ? Math.round((c.satisfied / total) * 100) : null; const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
const lib = (a as { library?: { entities?: number; seasons?: number; assets?: number } }).library; const lib = a.library;
const num = (v: number | undefined) => (v === undefined ? "—" : v);
return ( return (
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}> <Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<StatTile plain label="作品" value={num(lib?.entities)} /> <StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
<StatTile plain label="季" value={num(lib?.seasons)} /> <StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
<StatTile plain label="资产" value={num(lib?.assets)} /> <StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
</div> </div>
<div className="mt-3"> <div className="mt-3">
<div className="flex items-baseline justify-between text-sm"> <div className="flex items-baseline justify-between text-sm">
Generated
-207
View File
@@ -45,7 +45,6 @@
"npm:react-dom@^19.2.4": "[email protected]", "npm:react-dom@^19.2.4": "[email protected]",
"npm:[email protected]": "[email protected][email protected]", "npm:[email protected]": "[email protected][email protected]",
"npm:react@^19.2.4": "19.3.0", "npm:react@^19.2.4": "19.3.0",
"npm:recharts@^3.10.1": "[email protected][email protected][email protected]_@[email protected][email protected]",
"npm:shadcn@^3.8.5": "3.8.5_@[email protected][email protected]", "npm:shadcn@^3.8.5": "3.8.5_@[email protected][email protected]",
"npm:sonner@^2.0.7": "2.0.8_@[email protected][email protected][email protected]", "npm:sonner@^2.0.7": "2.0.8_@[email protected][email protected][email protected]",
"npm:tailwind-merge@^3.5.0": "3.7.0", "npm:tailwind-merge@^3.5.0": "3.7.0",
@@ -2111,23 +2110,6 @@
"typescript" "typescript"
] ]
}, },
"@reduxjs/[email protected][email protected][email protected]_@[email protected]": {
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"dependencies": [
"@standard-schema/spec",
"@standard-schema/utils",
"immer",
"react",
"react-redux",
"redux",
"redux-thunk",
"reselect"
],
"optionalPeers": [
"react",
"react-redux"
]
},
"@remix-run/[email protected]": { "@remix-run/[email protected]": {
"integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==" "integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA=="
}, },
@@ -2310,12 +2292,6 @@
"@stablelib/[email protected]": { "@stablelib/[email protected]": {
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="
}, },
"@standard-schema/[email protected]": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"@standard-schema/[email protected]": {
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"@tailwindcss/[email protected]": { "@tailwindcss/[email protected]": {
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"dependencies": [ "dependencies": [
@@ -2438,42 +2414,6 @@
"assertion-error" "assertion-error"
] ]
}, },
"@types/[email protected]": {
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"@types/[email protected]": {
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"@types/[email protected]": {
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"@types/[email protected]": {
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": [
"@types/d3-color"
]
},
"@types/[email protected]": {
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"@types/[email protected]": {
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": [
"@types/d3-time"
]
},
"@types/[email protected]": {
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"dependencies": [
"@types/d3-path"
]
},
"@types/[email protected]": {
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"@types/[email protected]": {
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"@types/[email protected]": { "@types/[email protected]": {
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="
}, },
@@ -2521,9 +2461,6 @@
"@types/[email protected]": { "@types/[email protected]": {
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==" "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="
}, },
"@types/[email protected]": {
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"@types/[email protected]": { "@types/[email protected]": {
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==" "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="
}, },
@@ -3019,61 +2956,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
}, },
"[email protected]": {
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": [
"internmap"
]
},
"[email protected]": {
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
},
"[email protected]": {
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
},
"[email protected]": {
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="
},
"[email protected]": {
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": [
"d3-color"
]
},
"[email protected]": {
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
},
"[email protected]": {
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": [
"d3-array",
"d3-format",
"d3-interpolate",
"d3-time",
"d3-time-format"
]
},
"[email protected]": {
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": [
"d3-path"
]
},
"[email protected]": {
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": [
"d3-time"
]
},
"[email protected]": {
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": [
"d3-array"
]
},
"[email protected]": {
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
}, },
@@ -3095,9 +2977,6 @@
"ms" "ms"
] ]
}, },
"[email protected]": {
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==" "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="
}, },
@@ -3258,9 +3137,6 @@
"es-errors" "es-errors"
] ]
}, },
"[email protected]": {
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"optionalDependencies": [ "optionalDependencies": [
@@ -3313,9 +3189,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
}, },
"[email protected]": {
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==" "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="
}, },
@@ -3739,9 +3612,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==" "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="
}, },
"[email protected]": {
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dependencies": [ "dependencies": [
@@ -3752,9 +3622,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"[email protected]": {
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==" "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w=="
}, },
@@ -4729,23 +4596,6 @@
"scheduler" "scheduler"
] ]
}, },
"[email protected]": {
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA=="
},
"[email protected]_@[email protected][email protected][email protected]": {
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"dependencies": [
"@types/react",
"@types/use-sync-external-store",
"react",
"redux",
"use-sync-external-store"
],
"optionalPeers": [
"@types/react",
"redux"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="
}, },
@@ -4822,43 +4672,12 @@
"tslib" "tslib"
] ]
}, },
"[email protected][email protected][email protected][email protected]_@[email protected][email protected]": {
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"dependencies": [
"@reduxjs/toolkit",
"clsx",
"decimal.js-light",
"es-toolkit",
"eventemitter3",
"immer",
"react",
"react-dom",
"react-is",
"react-redux",
"reselect",
"tiny-invariant",
"use-sync-external-store",
"victory-vendor"
]
},
"[email protected][email protected]": {
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"dependencies": [
"redux"
]
},
"[email protected]": {
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
}, },
"[email protected]": { "[email protected]": {
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
}, },
"[email protected]": {
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
}, },
@@ -5376,12 +5195,6 @@
"@types/react" "@types/react"
] ]
}, },
"[email protected][email protected]": {
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"dependencies": [
"react"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
}, },
@@ -5400,25 +5213,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
}, },
"[email protected]": {
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": [
"@types/d3-array",
"@types/d3-ease",
"@types/d3-interpolate",
"@types/d3-scale",
"@types/d3-shape",
"@types/d3-time",
"@types/d3-timer",
"d3-array",
"d3-ease",
"d3-interpolate",
"d3-scale",
"d3-shape",
"d3-time",
"d3-timer"
]
},
"[email protected]_@[email protected][email protected]": { "[email protected]_@[email protected][email protected]": {
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dependencies": [ "dependencies": [
@@ -5652,7 +5446,6 @@
"npm:react-dom@^19.2.4", "npm:react-dom@^19.2.4",
"npm:[email protected]", "npm:[email protected]",
"npm:react@^19.2.4", "npm:react@^19.2.4",
"npm:recharts@^3.10.1",
"npm:shadcn@^3.8.5", "npm:shadcn@^3.8.5",
"npm:sonner@^2.0.7", "npm:sonner@^2.0.7",
"npm:tailwind-merge@^3.5.0", "npm:tailwind-merge@^3.5.0",
+9 -6
View File
@@ -322,13 +322,13 @@
- 页签:全部 / 进行中(进行中 + 排队)/ 失败 / 计划;页签带计数(`/engine/stats`)。 - 页签:全部 / 进行中(进行中 + 排队)/ 失败 / 计划;页签带计数(`/engine/stats`)。
- 任务流 = `GET /engine/runs` + `business`:行显示业务标题(`title`,否则类型 + 短 id,否则工作流做什么)、来源(定时 / 事件 / 手动 / Agent;API Key 算手动)、状态、开始与耗时、关联链接。 - 任务流 = `GET /engine/runs` + `business`:行显示业务标题(`title`,否则类型 + 短 id,否则工作流做什么)、来源(定时 / 事件 / 手动 / Agent;API Key 算手动)、状态、开始与耗时、关联链接。
- 筛选写进 URL:`source`、`kind`(businessKind)、`ref`(businessRefId)、`status`、`since`(1h/24h/7d/30d → `from`)、`workflow`(旧引擎链接)。来源筛选在前端对已加载记录做(后端无 triggeredBy 参数)。 - 筛选写进 URL:`source`、`kind`(businessKind)、`ref`(businessRefId)、`status`、`since`(1h/24h/7d/30d → `from`)、`workflow`(旧引擎链接)。来源筛选走服务端 `triggeredBy`(手动 = `user,api_key`)。
- 详情抽屉 `?run=`:业务摘要 → 时间线(创建、开始、等待、失败步骤、结束)→ 相关审计(`/audit?targetType=engine_run&targetId=`,定时任务另链 `scheduled_job`)→ 折叠的引擎步骤(原引擎面板详情:步骤尝试、信号、输入输出)。操作:未结束可取消,已结束可重跑(重跑后抽屉切到新任务)。`/console/tasks/:id` 是同内容的独立页。 - 详情抽屉 `?run=`:业务摘要 → 时间线(创建、开始、等待、失败步骤、结束)→ 相关审计(`/audit?targetType=engine_run&targetId=`,定时任务另链 `scheduled_job`)→ 折叠的引擎步骤(原引擎面板详情:步骤尝试、信号、输入输出)。操作:未结束可取消,已结束可重跑(重跑后抽屉切到新任务)。`/console/tasks/:id` 是同内容的独立页。
- 计划页签 = 原定时任务看板(`components/schedules/schedule-board.tsx`)。卡片"运行记录"跳任务流 `kind=schedule&ref=<key>`;该筛选下页头给出定时任务自己的运行记录(含跳过)与回到卡片的链接;`?job=` 高亮并滚到卡片。 - 计划页签 = 原定时任务看板(`components/schedules/schedule-board.tsx`)。卡片"运行记录"跳任务流 `kind=schedule&ref=<key>`;该筛选下页头给出定时任务自己的运行记录(含跳过)与回到卡片的链接;`?job=` 高亮并滚到卡片。
### 7.4 首页 ### 7.4 首页
正在进行(任务流进行中前 5 + 排队/下载计数)、需要我处理(计数 + 前 3 条)、媒体概览(版本目标满足率、今日产出;作品/季/资产数占位)、计划带(下一次运行的 5 项、启用/失败计数、快速开关)、最近完成/失败、缓存与保留、来源健康(站点 + 账号状态,来自 `/sites?include=accounts`,有问题的排前)、运行状态摘要(`/system/health`)。 正在进行(任务流进行中前 5 + 排队/下载计数)、需要我处理(计数 + 前 3 条)、媒体概览(作品/季/资产计数、版本目标满足率、今日产出)、计划带(下一次运行的 5 项、启用/失败计数、快速开关)、最近完成/失败、缓存与保留、来源健康(站点 + 账号状态,来自 `/sites?include=accounts`,有问题的排前)、运行状态摘要(`/system/health`)。
### 7.5 运行状态页(`/console/status`) ### 7.5 运行状态页(`/console/status`)
@@ -336,7 +336,10 @@
### 7.6 后端缺口 ### 7.6 后端缺口
- `/dashboard` 没有 `library{entities, seasons, assets}`,首页媒体概览三个计数显示"—"(site-center.md §10 已列)。 W10 已补(2026-09-28):
- `/engine/runs` 没有 `triggeredBy` 筛选参数,来源筛选只作用于已加载的记录。
- `/engine/runs` 的 `status` 只收单值,"进行中"页签用 running、pending 两次查询各取前 100 条合并,不分页。 - `/dashboard` 的 `automation.library{entities, movies, series, seasons, episodes, assets}` 与 `automation.slotSatisfiedRate`,首页媒体概览三个计数与满足率直接取用。
- 定时任务的运行记录(`scheduled_job_run`)没有对应引擎 run id,卡片与任务之间只能按 `business.kind=schedule` + key 关联;feeds.poll 这类只发事件的定时任务,触发的任务记在 Feed 名下。 - `/engine/runs?triggeredBy=`(单值或逗号多值,服务端逐页推断后筛选,一页可能少于 limit,以 `nextCursor` 为准,单次请求最多扫 2000 行);来源筛选改走服务端。
- `/engine/runs?status=running,pending` 多值;"进行中"页签改为一次查询 + 游标分页,不再两次各取 100 条合并。
仍未补:定时任务的运行记录(`scheduled_job_run`)没有对应引擎 run id,卡片与任务之间只能按 `business.kind=schedule` + key 关联;feeds.poll 这类只发事件的定时任务,触发的任务记在 Feed 名下。
+26 -1
View File
@@ -208,6 +208,28 @@ export type AuditEventQuery = z.infer<typeof AuditEventQuery>;
// ── 引擎面板(只读直查 openworkflow schema,仅 admin;docs/design/openworkflow-dashboard-reference.md) ── // ── 引擎面板(只读直查 openworkflow schema,仅 admin;docs/design/openworkflow-dashboard-reference.md) ──
/** 逗号分隔的枚举多选(查询串):`a,b` → ["a","b"],未知值报错 */
function csvEnum<T extends string>(e: z.ZodType<T>, name: string) {
return z
.string()
.max(200)
.transform((s, ctx) => {
const out: T[] = [];
for (const p of s
.split(",")
.map((x) => x.trim())
.filter(Boolean)) {
const r = e.safeParse(p);
if (!r.success) {
ctx.addIssue({ code: "custom", message: `未知 ${name}: ${p}` });
return z.NEVER;
}
if (!out.includes(r.data)) out.push(r.data);
}
return out;
});
}
/** 引擎 run 状态(含 legacy sleeping/succeeded) */ /** 引擎 run 状态(含 legacy sleeping/succeeded) */
export const EngineRunStatus = z.enum([ export const EngineRunStatus = z.enum([
"pending", "pending",
@@ -273,7 +295,8 @@ export type EngineRun = z.infer<typeof EngineRun>;
export const EngineRunQuery = z.object({ export const EngineRunQuery = z.object({
cursor: z.string().min(1).optional(), cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50), limit: z.coerce.number().int().min(1).max(200).default(50),
status: EngineRunStatus.optional(), /** 单值或逗号分隔多值,如 status=running,pending */
status: csvEnum(EngineRunStatus, "status").optional(),
workflowName: z.string().max(200).optional(), workflowName: z.string().max(200).optional(),
/** created_at 下界(含) */ /** created_at 下界(含) */
from: Timestamp.optional(), from: Timestamp.optional(),
@@ -283,6 +306,8 @@ export const EngineRunQuery = z.object({
businessKind: EngineBusinessKind.optional(), businessKind: EngineBusinessKind.optional(),
/** 按关联业务对象 id 筛选(可与 businessKind 同用) */ /** 按关联业务对象 id 筛选(可与 businessKind 同用) */
businessRefId: z.string().min(1).max(200).optional(), businessRefId: z.string().min(1).max(200).optional(),
/** 按触发来源筛选(单值或逗号分隔多值);来源在服务端逐页推断,一页可能少于 limit,以 nextCursor 为准 */
triggeredBy: csvEnum(EngineTriggeredBy, "triggeredBy").optional(),
}); });
export type EngineRunQuery = z.infer<typeof EngineRunQuery>; export type EngineRunQuery = z.infer<typeof EngineRunQuery>;
+11
View File
@@ -469,6 +469,17 @@ export const Dashboard = z.object({
}), }),
activeSubscriptions: NonNegInt, activeSubscriptions: NonNegInt,
slotCounts: SlotStatusCounts, slotCounts: SlotStatusCounts,
/** 版本目标满足率 = satisfied / 全部版本目标(0–1);没有版本目标时为 null */
slotSatisfiedRate: z.number().min(0).max(1).nullable(),
/** 本地账本计数:作品不含已合并;季 = content_unit.kind=season(不含 retired);资产 = active 的视频 / 原盘目录 */
library: z.object({
entities: NonNegInt,
movies: NonNegInt,
series: NonNegInt,
seasons: NonNegInt,
episodes: NonNegInt,
assets: NonNegInt,
}),
}), }),
currentWork: z.object({ currentWork: z.object({
downloading: NonNegInt, downloading: NonNegInt,