diff --git a/src/components/AsyncOperations/api.ts b/src/components/AsyncOperations/api.ts index fbaafe1..a71e8ea 100644 --- a/src/components/AsyncOperations/api.ts +++ b/src/components/AsyncOperations/api.ts @@ -4,11 +4,11 @@ import type { AsyncOperationStatusResponse, AsyncOperationsListResponse, ListQuery, + StatusQuery, } from "./types"; const LIST_KEY = ["async-operations", "list"] as const; const STATUS_KEY = (id: string) => ["async-operations", "status", id] as const; -const LIST_COUNT = 5000; function buildListUrl(q: ListQuery): string { const params = new URLSearchParams(); @@ -16,7 +16,8 @@ function buildListUrl(q: ListQuery): string { if (q.taskName.trim()) params.set("task-name", q.taskName.trim()); params.set("sort", q.sortField); params.set("order", q.sortOrder); - params.set("_count", String(LIST_COUNT)); + params.set("_count", String(q.pageSize)); + params.set("_offset", String((q.page - 1) * q.pageSize)); const qs = params.toString(); return qs ? `/$async-operations?${qs}` : "/$async-operations"; } @@ -31,7 +32,10 @@ export function useAsyncOperationsList(query: ListQuery) { query.taskName.trim(), query.sortField, query.sortOrder, + query.page, + query.pageSize, ], + placeholderData: (previousData) => previousData, queryFn: async () => { const result = await client.request({ method: "GET", @@ -47,26 +51,46 @@ export function useAsyncOperationsList(query: ListQuery) { }); } -export function useAsyncOperationStatus(operationId: string | undefined) { +function buildStatusUrl(operationId: string, q: StatusQuery): string { + const params = new URLSearchParams(); + params.set("_count", String(q.pageSize)); + params.set("_offset", String((q.page - 1) * q.pageSize)); + params.set("sort", q.sortField); + params.set("order", q.sortOrder); + return `/$async-operations/${encodeURIComponent(operationId)}?${params.toString()}`; +} + +export function useAsyncOperationStatus( + operationId: string | undefined, + query: StatusQuery, +) { const client = useAidboxClient(); return useQuery({ queryKey: operationId - ? STATUS_KEY(operationId) + ? [ + ...STATUS_KEY(operationId), + query.page, + query.pageSize, + query.sortField, + query.sortOrder, + ] : ["async-operations", "status", "none"], enabled: !!operationId, + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[2] === operationId ? previousData : undefined, queryFn: async () => { const result = await client.request({ method: "GET", - url: `/$async-operations/${encodeURIComponent(operationId ?? "")}`, + url: buildStatusUrl(operationId ?? "", query), }); if (result.isOk()) { return result.value.resource; } throw new Error("Failed to fetch async operation status"); }, - refetchInterval: (query) => { - const data = query.state.data as AsyncOperationStatusResponse | undefined; + refetchInterval: (q) => { + const data = q.state.data as AsyncOperationStatusResponse | undefined; return data?.status === "in-progress" ? 5_000 : false; }, refetchOnWindowFocus: false, diff --git a/src/components/AsyncOperations/detail.tsx b/src/components/AsyncOperations/detail.tsx index fc4e568..69e10e0 100644 --- a/src/components/AsyncOperations/detail.tsx +++ b/src/components/AsyncOperations/detail.tsx @@ -9,53 +9,45 @@ import { ResizablePanel, ResizablePanelGroup, } from "@health-samurai/react-components"; -import { AlertCircle, Ban, Check, Loader2, X } from "lucide-react"; -import { useState } from "react"; +import { + AlertCircle, + Ban, + Check, + Clock, + Loader2, + RefreshCw, + X, +} from "lucide-react"; +import { useRef, useState } from "react"; +import { useLocalStorage } from "../../hooks"; import { ConfirmDialog } from "../confirm-dialog"; import { DataTable } from "../data-table/data-table"; +import { DataTableFooter } from "../data-table/footer"; import type { ColumnDef, SortState } from "../data-table/types"; import { useAsyncOperationStatus, useCancelAsyncOperation } from "./api"; import { StatusBadge } from "./status-badge"; import type { AsyncOperationTask } from "./types"; +import { displayStatus } from "./utils"; + +const DEFAULT_PAGE_SIZE = 30; +const PAGE_SIZE_STORAGE_KEY = "async-operations:tasks-page-size"; -const CANCEL_MARKER = "async-api.operation/cancel-marker"; +type TaskState = "succeeded" | "failed" | "running" | "pending"; -function aggregate(tasks: AsyncOperationTask[]) { - const real = tasks.filter((t) => t.task_name !== CANCEL_MARKER); - const total = real.length; - const succeeded = real.filter((t) => t.success === true).length; - const failed = real.filter((t) => t.success === false).length; - const pending = total - succeeded - failed; - return { total, succeeded, failed, pending }; +function taskState(task: AsyncOperationTask): TaskState { + if (task.success === true) return "succeeded"; + if (task.success === false) return "failed"; + const started = + task.picked === true || + Boolean(task.picked_by) || + Boolean(task.time_started); + return started ? "running" : "pending"; } function parseMs(value: string | null | undefined): number | null { return value ? new Date(value).getTime() : null; } -function timeBounds(tasks: AsyncOperationTask[]) { - let startMs: number | null = null; - let startRaw: string | null = null; - let endMs: number | null = null; - let endRaw: string | null = null; - for (const t of tasks) { - if (t.task_name === CANCEL_MARKER) continue; - const sRaw = t.time_started ?? t.execution_time ?? null; - const s = parseMs(sRaw); - if (s !== null && (startMs === null || s < startMs)) { - startMs = s; - startRaw = sRaw; - } - const eRaw = t.time_done ?? t.last_heartbeat ?? null; - const e = parseMs(eRaw); - if (e !== null && (endMs === null || e > endMs)) { - endMs = e; - endRaw = eRaw; - } - } - return { startMs, startRaw, endMs, endRaw }; -} - function formatDuration(ms: number): string { const sec = Math.max(0, Math.floor(ms / 1000)); if (sec < 60) return `${sec}s`; @@ -64,6 +56,7 @@ function formatDuration(ms: number): string { } function taskDurationMs(task: AsyncOperationTask): number | null { + if (taskState(task) === "pending") return null; const start = parseMs(task.time_started ?? task.execution_time); if (start === null) return null; const end = @@ -71,33 +64,9 @@ function taskDurationMs(task: AsyncOperationTask): number | null { return end === null ? null : end - start; } -function getString( - obj: Record | null, - key: string, -): string | null { - if (!obj) return null; - const v = obj[key]; - return typeof v === "string" && v.length > 0 ? v : null; -} - -function findFailureMessage(tasks: AsyncOperationTask[]): { - taskInstance: string; - message: string; -} | null { - for (const t of tasks) { - if (t.success === false) { - const msg = - getString(t.task_outcome, "message") ?? - getString(t.task_outcome, "error") ?? - "Task failed (no error message recorded)"; - return { taskInstance: t.task_instance, message: msg }; - } - } - return null; -} - function TaskStatus({ task }: { task: AsyncOperationTask }) { - if (task.success === true) { + const state = taskState(task); + if (state === "succeeded") { return ( @@ -105,7 +74,7 @@ function TaskStatus({ task }: { task: AsyncOperationTask }) { ); } - if (task.success === false) { + if (state === "failed") { return ( @@ -113,6 +82,14 @@ function TaskStatus({ task }: { task: AsyncOperationTask }) { ); } + if (state === "pending") { + return ( + + + Pending + + ); + } return ( @@ -121,29 +98,6 @@ function TaskStatus({ task }: { task: AsyncOperationTask }) { ); } -function taskSortValue( - task: AsyncOperationTask, - column: string, -): string | number { - switch (column) { - case "status": - return task.success === false ? 0 : task.success === true ? 1 : 2; - case "started": - return task.time_started ?? task.execution_time ?? ""; - case "done": - return task.time_done ?? ""; - case "duration": - return taskDurationMs(task) ?? 0; - default: { - const v = (task as unknown as Record)[column]; - if (typeof v === "number") return v; - if (typeof v === "boolean") return v ? 1 : 0; - if (typeof v === "string") return v; - return 0; - } - } -} - const TASK_COLUMNS: ColumnDef[] = [ { id: "status", @@ -178,7 +132,10 @@ const TASK_COLUMNS: ColumnDef[] = [ header: "Started", sortable: true, maxSize: 260, - cell: (t) => t.time_started ?? t.execution_time ?? "—", + cell: (t) => + taskState(t) === "pending" + ? "—" + : (t.time_started ?? t.execution_time ?? "—"), }, { id: "done", @@ -263,14 +220,38 @@ const TASK_COLUMNS: ColumnDef[] = [ ]; export function AsyncOperationDetail({ operationId }: { operationId: string }) { - const { data, isLoading, refetch, error } = - useAsyncOperationStatus(operationId); const cancel = useCancelAsyncOperation(); const [terminateOpen, setTerminateOpen] = useState(false); const [sort, setSort] = useState({ column: "started", direction: "desc", }); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useLocalStorage({ + key: PAGE_SIZE_STORAGE_KEY, + getInitialValueInEffect: false, + defaultValue: DEFAULT_PAGE_SIZE, + }); + + const { data, isLoading, isFetching, refetch, error } = + useAsyncOperationStatus(operationId, { + page, + pageSize, + sortField: sort?.column ?? "started", + sortOrder: sort?.direction ?? "desc", + }); + + const prevOperationId = useRef(operationId); + if (prevOperationId.current !== operationId) { + prevOperationId.current = operationId; + setPage(1); + } + + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (total > 0 && page > totalPages) { + setPage(totalPages); + } if (isLoading) { return ( @@ -294,54 +275,61 @@ export function AsyncOperationDetail({ operationId }: { operationId: string }) { const status = data.status; const isActive = status === "in-progress"; const tasks = data.tasks ?? []; - const visibleTasks = tasks.filter((t) => t.task_name !== CANCEL_MARKER); - const sortedTasks = sort - ? [...visibleTasks].sort((a, b) => { - const dir = sort.direction === "asc" ? 1 : -1; - const av = taskSortValue(a, sort.column); - const bv = taskSortValue(b, sort.column); - if (typeof av === "string" && typeof bv === "string") { - return dir * av.localeCompare(bv); - } - return dir * ((av as number) - (bv as number)); - }) - : visibleTasks; + const counts = data.counts ?? { + total: 0, + succeeded: 0, + failed: 0, + running: 0, + pending: 0, + }; + const failure = data.failure; + const taskName = data["task-name"] ?? "—"; + const createdLabel = data["created-at"] ?? "—"; + const updatedLabel = data["last-updated"] ?? "—"; + const startMs = parseMs(data["started-at"]); + const endMs = parseMs(data["last-updated"]); + const durationEnd = isActive ? Date.now() : endMs; + const durationLabel = + startMs !== null && durationEnd !== null + ? formatDuration(durationEnd - startMs) + : "—"; const handleSortToggle = (column: string) => { + setPage(1); setSort((prev) => prev?.column === column ? { column, direction: prev.direction === "asc" ? "desc" : "asc" } : { column, direction: "asc" }, ); }; - const counts = aggregate(tasks); - const taskName = - tasks.find((t) => t.task_name !== CANCEL_MARKER)?.task_name ?? "—"; - const { startMs, startRaw, endMs, endRaw } = timeBounds(tasks); - const createdLabel = startRaw ?? "—"; - const updatedLabel = endRaw ?? "—"; - const durationEnd = status === "in-progress" ? Date.now() : endMs; - const durationLabel = - startMs !== null && durationEnd !== null - ? formatDuration(durationEnd - startMs) - : "—"; const metrics: { label: string; value: string | number }[] = [ { label: "Duration", value: durationLabel }, { label: "Total", value: counts.total }, { label: "Succeeded", value: counts.succeeded }, ...(counts.failed > 0 ? [{ label: "Failed", value: counts.failed }] : []), + ...(counts.running > 0 + ? [{ label: "Running", value: counts.running }] + : []), ...(counts.pending > 0 ? [{ label: "Pending", value: counts.pending }] : []), { label: "Created", value: createdLabel }, { label: "Updated", value: updatedLabel }, ]; - const failure = - status === "failed" || counts.failed > 0 ? findFailureMessage(tasks) : null; return (
- {isActive ? ( -
+
+ + {isActive ? ( -
- ) : null} + ) : null} +
- +

- {failure.taskInstance} + {failure["task-instance"]} ) : null}
- data={sortedTasks} + data={tasks} columns={TASK_COLUMNS} rowKey={(t) => t.task_instance} resizable @@ -456,6 +450,19 @@ export function AsyncOperationDetail({ operationId }: { operationId: string }) { } />
+ {total > 0 ? ( + { + setPageSize(size); + setPage(1); + }} + /> + ) : null} = { created: "created-at", @@ -57,18 +60,31 @@ export function AsyncOperationsPage() { sortField: DEFAULT_LIST_QUERY.sortField, sortOrder: DEFAULT_LIST_QUERY.sortOrder, }); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useLocalStorage({ + key: "async-operations:list-page-size", + getInitialValueInEffect: false, + defaultValue: DEFAULT_LIST_QUERY.pageSize, + }); const query: ListQuery = { statusFilter: search.status ?? "all", taskName: search.task ?? "", sortField: sort.sortField, sortOrder: sort.sortOrder, + page, + pageSize, }; const { data, isLoading } = useAsyncOperationsList(query); const operations = data?.operations ?? []; const taskNames = data?.["task-names"] ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (total > 0 && page > totalPages) { + setPage(totalPages); + } const [now, setNow] = useState(() => Date.now()); useEffect(() => { @@ -77,6 +93,21 @@ export function AsyncOperationsPage() { return () => clearInterval(t); }, [operations]); + const setStatusFilter = (v: string) => { + setPage(1); + navigate({ + search: (prev) => ({ + ...prev, + status: v === "all" || v === "" ? undefined : (v as DisplayStatus), + }), + }); + }; + + const setTaskFilter = (v: string | undefined) => { + setPage(1); + navigate({ search: (prev) => ({ ...prev, task: v || undefined }) }); + }; + const tableSort: SortState = { column: SORT_FIELD_TO_COLUMN[query.sortField], direction: query.sortOrder, @@ -85,6 +116,7 @@ export function AsyncOperationsPage() { const handleSortToggle = (columnId: string) => { const field = COLUMN_TO_SORT_FIELD[columnId]; if (!field) return; + setPage(1); setSort((prev) => prev.sortField === field ? { ...prev, sortOrder: prev.sortOrder === "asc" ? "desc" : "asc" } @@ -115,7 +147,9 @@ export function AsyncOperationsPage() { id: "status", header: "Status", maxSize: 180, - cell: (op) => , + cell: (op) => ( + + ), }, { id: "task", @@ -128,10 +162,11 @@ export function AsyncOperationsPage() { header: "Duration", maxSize: 160, cell: (op) => { - const start = parseTs(op["created-at"]); + const start = parseTs(op["started-at"]); + if (start === null) return "—"; const end = op.status === "in-progress" ? now : parseTs(op["last-updated"]); - if (start === null || end === null) return "—"; + if (end === null) return "—"; return ( {formatDuration(end - start)} @@ -203,17 +238,7 @@ export function AsyncOperationsPage() {