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
@@ -5,7 +5,10 @@ import { qk } from "~/lib/query-keys.ts";
|
||||
import { errorCode } from "./parse.ts";
|
||||
|
||||
export type EngineRunFilters = {
|
||||
/** 单值或逗号分隔多值(running,pending) */
|
||||
status?: string;
|
||||
/** 触发来源,单值或逗号分隔多值(user,api_key) */
|
||||
triggeredBy?: string;
|
||||
workflowName?: string;
|
||||
businessKind?: EngineBusinessKind;
|
||||
businessRefId?: string;
|
||||
@@ -16,9 +19,10 @@ export type EngineRunFilters = {
|
||||
/** 终态:不能再 cancel */
|
||||
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({
|
||||
enabled,
|
||||
refetchInterval: refetchMs,
|
||||
queryKey: qk.list("engine", filters),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
|
||||
@@ -25,6 +25,13 @@ export const SOURCE_LABEL: Record<TaskSource, string> = {
|
||||
manual: "手动",
|
||||
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 {
|
||||
if (!t) return null;
|
||||
if (t === "user" || t === "api_key") return "manual";
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts
|
||||
import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
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 { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
runStatus,
|
||||
runTitle,
|
||||
SOURCE_LABEL,
|
||||
SOURCE_TRIGGERED_BY,
|
||||
sourceOf,
|
||||
type TaskSource,
|
||||
workflowLabel,
|
||||
@@ -132,26 +133,20 @@ export function TaskStream({
|
||||
businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined,
|
||||
businessRefId: filters.ref || undefined,
|
||||
workflowName: filters.workflow || undefined,
|
||||
triggeredBy: SOURCE_TRIGGERED_BY[filters.source as TaskSource] ?? undefined,
|
||||
from,
|
||||
};
|
||||
const active = tab === "active";
|
||||
// 进行中 = 进行中 + 排队(status 多值,服务端分页);来源筛选同样在服务端做
|
||||
const list = useEngineRuns(
|
||||
{
|
||||
...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 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 rows = list.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = Object.values(filters).some(Boolean);
|
||||
|
||||
return (
|
||||
@@ -212,17 +207,12 @@ export function TaskStream({
|
||||
<DataTable<EngineRun>
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={active ? running.isPending || pending.isPending : list.isPending}
|
||||
error={active ? (running.error ?? pending.error) : list.error}
|
||||
onRetry={() => {
|
||||
if (active) {
|
||||
void running.refetch();
|
||||
void pending.refetch();
|
||||
} else void list.refetch();
|
||||
}}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => void list.refetch()}
|
||||
onRowClick={(r) => onOpen(r.id)}
|
||||
selectedKey={selectedId}
|
||||
hasMore={!active && !!list.hasNextPage}
|
||||
hasMore={!!list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
empty={{
|
||||
@@ -234,7 +224,7 @@ export function TaskStream({
|
||||
: tab === "failed"
|
||||
? "没有失败的任务"
|
||||
: "还没有任务",
|
||||
description: filters.source && loaded?.length ? "来源筛选只作用于已加载的记录,可加载更多再看。" : undefined,
|
||||
description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可加载更多继续查找。" : undefined,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
|
||||
@@ -280,19 +280,18 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 媒体概览:版本目标满足率 + 今日产出(作品/季/资产计数待 /dashboard 提供) ───
|
||||
// ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ───
|
||||
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
|
||||
const c = a.slotCounts;
|
||||
const total = Object.values(c).reduce((x, y) => x + y, 0);
|
||||
const rate = total ? Math.round((c.satisfied / total) * 100) : null;
|
||||
const lib = (a as { library?: { entities?: number; seasons?: number; assets?: number } }).library;
|
||||
const num = (v: number | undefined) => (v === undefined ? "—" : v);
|
||||
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
|
||||
const lib = a.library;
|
||||
return (
|
||||
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile plain label="作品" value={num(lib?.entities)} />
|
||||
<StatTile plain label="季" value={num(lib?.seasons)} />
|
||||
<StatTile plain label="资产" value={num(lib?.assets)} />
|
||||
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
|
||||
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
|
||||
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
|
||||
Reference in new issue
Block a user