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:
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.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 });
|
||||
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=沙丘");
|
||||
expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 });
|
||||
const det = await h.api("GET", `/library/${movieId}`);
|
||||
|
||||
@@ -63,6 +63,26 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
|
||||
attention: 0,
|
||||
};
|
||||
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
|
||||
.selectFrom("download_task")
|
||||
@@ -203,6 +223,8 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
|
||||
},
|
||||
activeSubscriptions: today.subs,
|
||||
slotCounts,
|
||||
slotSatisfiedRate: slotTotal ? slotCounts.satisfied / slotTotal : null,
|
||||
library,
|
||||
},
|
||||
currentWork: {
|
||||
downloading: count("downloading"),
|
||||
|
||||
@@ -114,7 +114,7 @@ const RUN_COLS = sql.raw(`id, workflow_name, version, status, attempts, worker_i
|
||||
export interface EngineQueries {
|
||||
listRuns(
|
||||
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>;
|
||||
stats(): Promise<z.input<typeof EngineStats>>;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQu
|
||||
async listRuns(q) {
|
||||
const cur: Cursor | null = decodeCursor(q.cursor);
|
||||
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.from) conds.push(sql`created_at >= ${new Date(q.from)}`);
|
||||
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),
|
||||
nextCursor:
|
||||
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 } = {}) {
|
||||
let q: EngineQueries | null = null;
|
||||
const queries = () => {
|
||||
@@ -251,8 +257,32 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: {
|
||||
// W9-I:列表与详情补 business 字段(engine-business.ts;查询失败降级为 null)
|
||||
const schema = opts.schema ?? "openworkflow";
|
||||
endpoint(app, D.runs, async ({ query }) => {
|
||||
const page = await queries().listRuns(query);
|
||||
return { ...page, items: await attachBusiness(ctx.db, schema, page.items) };
|
||||
const triggered = query.triggeredBy?.length ? new Set(query.triggeredBy) : null;
|
||||
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 }) => {
|
||||
const r = await queries().getRun(params.id);
|
||||
|
||||
Reference in new issue
Block a user