feat: add dataset pagination support for recipe executions

- Introduced backend changes to handle dataset pagination with limit, offset, and total row support.
- Updated frontend execution view with dataset pagination controls, including "Next" and "Prev" buttons.
- Extended recipe execution logic to manage dataset pagination details like page number, page size, and total records.
This commit is contained in:
Shine1i 2026-02-20 12:12:02 +01:00
commit f3296b1953
7 changed files with 184 additions and 22 deletions

View file

@ -194,8 +194,14 @@ class JobManager:
return None
return self._job.analysis
def get_dataset(self, job_id: str, *, limit: int) -> list[dict[str, Any]] | None:
"""Load job dataset rows for UI previews (limited head)."""
def get_dataset(
self,
job_id: str,
*,
limit: int,
offset: int = 0,
) -> dict[str, Any] | None:
"""Load dataset page (offset + limit) and include total rows."""
with self._lock:
if self._job is None or self._job.job_id != job_id:
return None
@ -203,7 +209,9 @@ class JobManager:
artifact_path = self._job.artifact_path
if in_memory_dataset is not None:
return in_memory_dataset[:limit]
total = len(in_memory_dataset)
rows = in_memory_dataset[offset:offset + limit]
return {"dataset": rows, "total": total}
if not artifact_path:
return None
@ -219,8 +227,9 @@ class JobManager:
dataset_name=base_dataset_path.name,
)
dataframe = storage.load_dataset()
rows = dataframe.head(limit).to_dict(orient="records")
return _to_jsonable(rows)
total = int(len(dataframe.index))
rows = dataframe.iloc[offset:offset + limit].to_dict(orient="records")
return {"dataset": _to_jsonable(rows), "total": total}
except Exception:
return None

View file

@ -130,12 +130,21 @@ def job_analysis(job_id: str):
@router.get("/jobs/{job_id}/dataset")
def job_dataset(job_id: str, limit: int = Query(default=20, ge=1, le=500)):
def job_dataset(
job_id: str,
limit: int = Query(default=20, ge=1, le=500),
offset: int = Query(default=0, ge=0),
):
mgr = get_job_manager()
dataset = mgr.get_dataset(job_id, limit=limit)
if dataset is None:
result = mgr.get_dataset(job_id, limit=limit, offset=offset)
if result is None:
raise HTTPException(status_code=404, detail="dataset not ready")
return {"dataset": dataset}
return {
"dataset": result["dataset"],
"total": result["total"],
"limit": limit,
"offset": offset,
}
@router.get("/jobs/{job_id}/events")

View file

@ -55,6 +55,9 @@ export type JobStatusResponse = {
export type JobDatasetResponse = {
dataset?: unknown[];
total?: number;
limit?: number;
offset?: number;
};
export type JobEvent = {
@ -184,9 +187,16 @@ export async function getRecipeJobAnalysis(
export async function getRecipeJobDataset(
jobId: string,
limit = 20,
options?: {
limit?: number;
offset?: number;
},
): Promise<JobDatasetResponse> {
return getJson<JobDatasetResponse>(`/jobs/${jobId}/dataset?limit=${limit}`);
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
return getJson<JobDatasetResponse>(
`/jobs/${jobId}/dataset?limit=${limit}&offset=${offset}`,
);
}
export async function cancelRecipeJob(jobId: string): Promise<JobStatusResponse> {

View file

@ -31,6 +31,7 @@ type ExecutionsViewProps = {
onRunPreview: () => void;
onRunFull: () => void;
onCancelExecution: (id: string) => void;
onLoadDatasetPage: (id: string, page: number) => void;
};
type AnalysisColumnStat = {
@ -158,6 +159,7 @@ export function ExecutionsView({
onRunPreview,
onRunFull,
onCancelExecution,
onLoadDatasetPage,
}: ExecutionsViewProps): ReactElement {
const [detailTab, setDetailTab] = useState("overview");
const [showRaw, setShowRaw] = useState(false);
@ -224,6 +226,12 @@ export function ExecutionsView({
const canCancel = Boolean(
selectedExecution?.jobId && isInProgress(selectedExecution.status),
);
const datasetPage = selectedExecution?.datasetPage ?? 1;
const datasetPageSize = selectedExecution?.datasetPageSize ?? 20;
const datasetTotal = selectedExecution?.datasetTotal ?? 0;
const totalPages = Math.max(1, Math.ceil(datasetTotal / datasetPageSize));
const canPageDataset =
Boolean(selectedExecution?.jobId) && selectedExecution?.kind === "full";
return (
<div className="flex h-full min-h-0">
@ -511,7 +519,41 @@ export function ExecutionsView({
</TabsContent>
<TabsContent value="data" className="mt-3">
<div className="rounded-xl border p-3">
<p className="mb-2 text-sm font-semibold">Dataset sample</p>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold">Dataset sample</p>
{canPageDataset && selectedExecution && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
Page {datasetPage}/{totalPages}
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={
isInProgress(selectedExecution.status) || datasetPage <= 1
}
onClick={() =>
onLoadDatasetPage(selectedExecution.id, datasetPage - 1)}
>
Prev
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={
isInProgress(selectedExecution.status) ||
datasetPage >= totalPages
}
onClick={() =>
onLoadDatasetPage(selectedExecution.id, datasetPage + 1)}
>
Next
</Button>
</div>
)}
</div>
{selectedExecution.dataset.length === 0 ? (
<p className="text-xs text-muted-foreground">No rows returned.</p>
) : (

View file

@ -55,6 +55,9 @@ export type RecipeExecutionRecord = {
// biome-ignore lint/style/useNamingConvention: backend schema
artifact_path: string | null;
dataset: Record<string, unknown>[];
datasetTotal: number;
datasetPage: number;
datasetPageSize: number;
analysis: RecipeExecutionAnalysis | null;
// biome-ignore lint/style/useNamingConvention: api schema
processor_artifacts: Record<string, unknown> | null;

View file

@ -69,10 +69,13 @@ type UseRecipeStudioActionsResult = {
runPreview: () => Promise<boolean>;
runFull: () => Promise<boolean>;
cancelExecution: (id: string) => Promise<void>;
loadExecutionDatasetPage: (id: string, page: number) => Promise<void>;
copyRecipe: () => Promise<void>;
importRecipe: (value: string) => string | null;
};
const DATASET_PAGE_SIZE = 20;
function buildSignature(name: string, payload: RecipePayload): string {
return JSON.stringify({ name, payload });
}
@ -164,6 +167,32 @@ function sortExecutions(records: RecipeExecutionRecord[]): RecipeExecutionRecord
return next;
}
function withExecutionDefaults(
record: RecipeExecutionRecord,
): RecipeExecutionRecord {
const dataset = Array.isArray(record.dataset) ? record.dataset : [];
const datasetPageSize =
typeof record.datasetPageSize === "number" && record.datasetPageSize > 0
? record.datasetPageSize
: DATASET_PAGE_SIZE;
const datasetPage =
typeof record.datasetPage === "number" && record.datasetPage > 0
? record.datasetPage
: 1;
const datasetTotal =
typeof record.datasetTotal === "number" && record.datasetTotal >= 0
? record.datasetTotal
: dataset.length;
return {
...record,
dataset,
datasetTotal,
datasetPage,
datasetPageSize,
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
@ -280,7 +309,7 @@ export function useRecipeStudioActions({
if (cancelled) {
return;
}
const sortedRecords = sortExecutions(records);
const sortedRecords = sortExecutions(records.map(withExecutionDefaults));
setExecutions(sortedRecords);
setSelectedExecutionId(sortedRecords[0]?.id ?? null);
} catch (error) {
@ -296,12 +325,13 @@ export function useRecipeStudioActions({
}, [recipeId]);
const upsertExecution = useCallback((record: RecipeExecutionRecord): void => {
const normalizedRecord = withExecutionDefaults(record);
setExecutions((current) => {
const withoutCurrent = current.filter((item) => item.id !== record.id);
return sortExecutions([record, ...withoutCurrent]);
const withoutCurrent = current.filter((item) => item.id !== normalizedRecord.id);
return sortExecutions([normalizedRecord, ...withoutCurrent]);
});
setSelectedExecutionId(record.id);
void saveRecipeExecution(record).catch((error) => {
setSelectedExecutionId(normalizedRecord.id);
void saveRecipeExecution(normalizedRecord).catch((error) => {
console.error("Save recipe execution failed:", error);
});
}, []);
@ -380,6 +410,9 @@ export function useRecipeStudioActions({
lastEventId: null,
artifact_path: null,
dataset: [],
datasetTotal: 0,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
analysis: null,
processor_artifacts: null,
error: null,
@ -413,11 +446,15 @@ export function useRecipeStudioActions({
}
const result = await previewRecipe(previewPayload);
const dataset = normalizeDatasetRows(result.dataset);
upsertExecution({
...baseExecution,
status: "completed",
finishedAt: Date.now(),
dataset: normalizeDatasetRows(result.dataset),
dataset,
datasetTotal: dataset.length,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
analysis: normalizeAnalysis(result.analysis),
processor_artifacts: normalizeObject(result.processor_artifacts),
error: null,
@ -483,6 +520,9 @@ export function useRecipeStudioActions({
lastEventId: null,
artifact_path: null,
dataset: [],
datasetTotal: 0,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
analysis: null,
processor_artifacts: null,
error: null,
@ -548,22 +588,32 @@ export function useRecipeStudioActions({
if (lastStatus === "completed") {
const [analysisResult, datasetResult] = await Promise.allSettled([
getRecipeJobAnalysis(jobId),
getRecipeJobDataset(jobId, 20),
getRecipeJobDataset(jobId, { limit: DATASET_PAGE_SIZE, offset: 0 }),
]);
const analysis =
analysisResult.status === "fulfilled"
? normalizeAnalysis(analysisResult.value)
: latestExecution.analysis;
const dataset =
const datasetResponse =
datasetResult.status === "fulfilled"
? normalizeDatasetRows(datasetResult.value.dataset)
: latestExecution.dataset;
? datasetResult.value
: null;
const dataset = datasetResponse
? normalizeDatasetRows(datasetResponse.dataset)
: latestExecution.dataset;
const datasetTotal =
datasetResponse && typeof datasetResponse.total === "number"
? datasetResponse.total
: latestExecution.datasetTotal;
upsertExecution({
...latestExecution,
status: "completed",
analysis,
dataset,
datasetTotal,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
error: null,
finishedAt: latestExecution.finishedAt ?? Date.now(),
});
@ -630,6 +680,40 @@ export function useRecipeStudioActions({
}
}, [executions, upsertExecution]);
const loadExecutionDatasetPage = useCallback(
async (id: string, page: number): Promise<void> => {
const execution = executions.find((entry) => entry.id === id);
if (!execution || execution.kind !== "full" || !execution.jobId) {
return;
}
if (page < 1) {
return;
}
const pageSize = execution.datasetPageSize || DATASET_PAGE_SIZE;
const offset = (page - 1) * pageSize;
try {
const response = await getRecipeJobDataset(execution.jobId, {
limit: pageSize,
offset,
});
const dataset = normalizeDatasetRows(response.dataset);
const total =
typeof response.total === "number" ? response.total : execution.datasetTotal;
upsertExecution({
...execution,
dataset,
datasetTotal: total,
datasetPage: page,
});
} catch (error) {
const message = toErrorMessage(error, "Could not load dataset page.");
toastError("Dataset page failed", message);
}
},
[executions, upsertExecution],
);
const selectExecution = useCallback((id: string): void => {
setSelectedExecutionId(id);
}, []);
@ -693,6 +777,7 @@ export function useRecipeStudioActions({
runPreview,
runFull,
cancelExecution,
loadExecutionDatasetPage,
copyRecipe,
importRecipe,
};

View file

@ -282,6 +282,7 @@ export function RecipeStudioPage({
runPreview,
runFull,
cancelExecution,
loadExecutionDatasetPage,
copyRecipe,
importRecipe,
} = useRecipeStudioActions({
@ -405,6 +406,9 @@ export function RecipeStudioPage({
onCancelExecution={(executionId) => {
void cancelExecution(executionId);
}}
onLoadDatasetPage={(executionId, page) => {
void loadExecutionDatasetPage(executionId, page);
}}
/>
)}
</div>