From 089687fe5a27c3a99c0cd4246f0d321265d727e0 Mon Sep 17 00:00:00 2001 From: Seb Date: Wed, 26 Nov 2025 15:15:32 +0000 Subject: [PATCH 1/8] feat: enrich model endpoints --- dashboard/src/api/control-layer/types.ts | 19 ++- .../features/models/Models/ModelsContent.tsx | 33 +---- .../features/models/Models/StatusRow.tsx | 6 +- dwctl/src/api/handlers/deployments.rs | 12 +- .../src/api/models/deployments/enrichment.rs | 121 ++++++++++++++++-- dwctl/src/api/models/deployments/mod.rs | 14 +- 6 files changed, 155 insertions(+), 50 deletions(-) diff --git a/dashboard/src/api/control-layer/types.ts b/dashboard/src/api/control-layer/types.ts index 51858c913..41700778d 100644 --- a/dashboard/src/api/control-layer/types.ts +++ b/dashboard/src/api/control-layer/types.ts @@ -76,6 +76,7 @@ export interface Model { metrics?: ModelMetrics; // only present when include=metrics status?: ModelProbeStatus; // only present when include=status pricing?: TokenPricing; // only present when include=pricing + endpoint?: Endpoint; // only present when include=endpoints } export interface Endpoint { @@ -158,17 +159,33 @@ export type ModelsInclude = | "metrics" | "status" | "pricing" + | "endpoints" | "groups,metrics" | "groups,status" | "groups,pricing" + | "groups,endpoints" | "metrics,status" | "metrics,pricing" + | "metrics,endpoints" | "status,pricing" + | "status,endpoints" + | "pricing,endpoints" | "groups,metrics,status" | "groups,metrics,pricing" + | "groups,metrics,endpoints" | "groups,status,pricing" + | "groups,status,endpoints" + | "groups,pricing,endpoints" | "metrics,status,pricing" - | "groups,metrics,status,pricing"; + | "metrics,status,endpoints" + | "metrics,pricing,endpoints" + | "status,pricing,endpoints" + | "groups,metrics,status,pricing" + | "groups,metrics,status,endpoints" + | "groups,metrics,pricing,endpoints" + | "groups,status,pricing,endpoints" + | "metrics,status,pricing,endpoints" + | "groups,metrics,status,pricing,endpoints"; export type GroupsInclude = "users" | "models" | "users,models"; export type UsersInclude = "groups"; diff --git a/dashboard/src/components/features/models/Models/ModelsContent.tsx b/dashboard/src/components/features/models/Models/ModelsContent.tsx index 4d47e4df8..adadb0e2c 100644 --- a/dashboard/src/components/features/models/Models/ModelsContent.tsx +++ b/dashboard/src/components/features/models/Models/ModelsContent.tsx @@ -21,8 +21,6 @@ import { useModels, type Model, type ModelsInclude, - useEndpoints, - type Endpoint, useProbes, } from "../../../../api/control-layer"; import { AccessManagementModal } from "../../../modals"; @@ -83,7 +81,7 @@ export const ModelsContent: React.FC = ({ const [itemsPerPage] = useState(12); const includeParam = useMemo(() => { - const parts: string[] = ["status"]; + const parts: string[] = ["status", "endpoints"]; if (canManageGroups) parts.push("groups"); if (canViewAnalytics) parts.push("metrics"); if (showPricing) parts.push("pricing"); @@ -102,30 +100,12 @@ export const ModelsContent: React.FC = ({ search: searchQuery || undefined, }); - const { - data: endpointsData, - isLoading: endpointsLoading, - error: endpointsError, - } = useEndpoints(); - const { data: probesData } = useProbes(); const models = rawModelsData?.data || []; - const endpoints = endpointsData || []; - const endpointsRecord = endpoints.reduce( - (acc: Record, endpoint: Endpoint) => { - acc[endpoint.id] = endpoint; - return acc; - }, - {} as Record, - ); - const loading = modelsLoading || endpointsLoading; - const error = modelsError - ? (modelsError as Error).message - : endpointsError - ? (endpointsError as Error).message - : null; + const loading = modelsLoading; + const error = modelsError ? (modelsError as Error).message : null; // TODO: filter providers on the server-side const filteredModels = models.filter((model) => { @@ -134,8 +114,7 @@ export const ModelsContent: React.FC = ({ } const matchesProvider = - filterProvider === "all" || - endpointsRecord[model.hosted_on]?.name === filterProvider; + filterProvider === "all" || model.endpoint?.name === filterProvider; return matchesProvider; }); @@ -233,7 +212,6 @@ export const ModelsContent: React.FC = ({ key={model.id} model={model} probesData={probesData} - endpointsRecord={endpointsRecord} onNavigate={(modelId: string) => navigate( `/models/${modelId}?from=${encodeURIComponent("/models?view=status")}`, @@ -594,8 +572,7 @@ export const ModelsContent: React.FC = ({ {(() => { const endpointName = - endpointsRecord[model.hosted_on]?.name || - "Unknown endpoint"; + model.endpoint?.name || "Unknown endpoint"; return endpointName.length > 25 ? ( diff --git a/dashboard/src/components/features/models/Models/StatusRow.tsx b/dashboard/src/components/features/models/Models/StatusRow.tsx index dadce9790..0d16a5bc1 100644 --- a/dashboard/src/components/features/models/Models/StatusRow.tsx +++ b/dashboard/src/components/features/models/Models/StatusRow.tsx @@ -1,20 +1,18 @@ import React from "react"; import { useProbeResults } from "@/api/control-layer/hooks"; import { ProbeTimeline } from "../ModelInfo/ProbeTimeline"; -import type { Endpoint, Model, Probe } from "@/api/control-layer/types"; +import type { Model, Probe } from "@/api/control-layer/types"; // StatusRow component for status page layout interface StatusRowProps { model: Model; probesData?: Probe[]; - endpointsRecord: Record; onNavigate: (modelId: string) => void; } export const StatusRow: React.FC = ({ model, probesData, - endpointsRecord, onNavigate, }) => { const probe = probesData?.find((p) => p.deployment_id === model.id); @@ -50,7 +48,7 @@ export const StatusRow: React.FC = ({ {model.alias}
- {endpointsRecord[model.hosted_on]?.name || "Unknown"} + {model.endpoint?.name || "Unknown"}
diff --git a/dwctl/src/api/handlers/deployments.rs b/dwctl/src/api/handlers/deployments.rs index 04dd558dd..f9d591df4 100644 --- a/dwctl/src/api/handlers/deployments.rs +++ b/dwctl/src/api/handlers/deployments.rs @@ -33,7 +33,7 @@ use sqlx::Acquire; params( ("endpoint" = Option, Query, description = "Filter by inference endpoint ID"), ("accessible" = Option, Query, description = "Filter to only models the current user can access (defaults to false for admins, true for users)"), - ("include" = Option, Query, description = "Include additional data (comma-separated: 'groups', 'metrics', 'status', 'pricing'). Only platform managers can include groups. Status shows probe monitoring information. Pricing shows simple customer rates for regular users, full pricing structure for users with Pricing::ReadAll permission."), + ("include" = Option, Query, description = "Include additional data (comma-separated: 'groups', 'metrics', 'status', 'pricing', 'endpoints'). Only platform managers can include groups. Status shows probe monitoring information. Pricing shows simple customer rates for regular users, full pricing structure for users with Pricing::ReadAll permission. Endpoints includes full inference endpoint details."), ("deleted" = Option, Query, description = "Show deleted models when true (admin only), non-deleted models when false, and all models when not specified"), ("inactive" = Option, Query, description = "Show inactive models when true (admin only)"), ("limit" = Option, Query, description = "Maximum number of items to return (default: 10, max: 100)"), @@ -156,8 +156,12 @@ pub async fn list_deployed_models( includes.push(include); } } + "endpoints" => { + // Endpoints are public information, allow for all users + includes.push(include); + } _ => { - // Other includes (like pricing) are allowed for all users + // Other includes (like pricing, status) are allowed for all users includes.push(include); } } @@ -181,6 +185,7 @@ pub async fn list_deployed_models( let include_metrics = includes.contains(&"metrics"); let include_status = includes.contains(&"status"); let include_pricing = includes.contains(&"pricing"); + let include_endpoints = includes.contains(&"endpoints"); // Use ModelEnricher to add requested data let enricher = DeployedModelEnricher { @@ -189,6 +194,7 @@ pub async fn list_deployed_models( include_metrics, include_status, include_pricing, + include_endpoints, can_read_pricing, can_read_rate_limits, }; @@ -392,6 +398,7 @@ pub async fn get_deployed_model( let include_metrics = include_params.contains("metrics"); let include_status = include_params.contains("status"); let include_pricing = include_params.contains("pricing"); + let include_endpoints = include_params.contains("endpoints"); // Build base response let pricing = model.pricing.clone(); @@ -404,6 +411,7 @@ pub async fn get_deployed_model( include_metrics, include_status, include_pricing, + include_endpoints, can_read_pricing, can_read_rate_limits, }; diff --git a/dwctl/src/api/models/deployments/enrichment.rs b/dwctl/src/api/models/deployments/enrichment.rs index b878a8f29..3ac94a71b 100644 --- a/dwctl/src/api/models/deployments/enrichment.rs +++ b/dwctl/src/api/models/deployments/enrichment.rs @@ -5,16 +5,19 @@ //! model endpoints to maintain consistency. use crate::{ - api::models::deployments::{DeployedModelResponse, ModelMetrics, ModelProbeStatus}, + api::models::{ + deployments::{DeployedModelResponse, ModelMetrics, ModelProbeStatus}, + inference_endpoints::InferenceEndpointResponse, + }, db::{ - handlers::{Groups, Repository, analytics::get_model_metrics}, + handlers::{Groups, InferenceEndpoints, Repository, analytics::get_model_metrics}, models::{deployments::ModelPricing, groups::GroupDBResponse}, }, errors::{Error, Result}, - types::{DeploymentId, GroupId}, + types::{DeploymentId, GroupId, InferenceEndpointId}, }; use chrono::{DateTime, Utc}; -use sqlx::{Acquire, PgPool}; +use sqlx::PgPool; use std::collections::HashMap; use uuid::Uuid; @@ -30,6 +33,8 @@ pub struct DeployedModelEnricher<'a> { pub include_status: bool, /// Whether to include pricing information pub include_pricing: bool, + /// Whether to include endpoint information + pub include_endpoints: bool, /// Whether the user can read full pricing details pub can_read_pricing: bool, /// Whether the user can read rate limiting information @@ -61,16 +66,13 @@ impl<'a> DeployedModelEnricher<'a> { let model_ids: Vec = models.iter().map(|(m, _)| m.id).collect(); let model_aliases: Vec = models.iter().map(|(m, _)| m.alias.clone()).collect(); - // Start a transaction for atomic reads - let mut tx = self.db.begin().await.map_err(|e| Error::Database(e.into()))?; - // Fetch all includes in parallel for maximum performance - let (groups_result, status_map, metrics_map) = tokio::join!( + let (groups_result, status_map, metrics_map, endpoints_map) = tokio::join!( // Groups query async { if self.include_groups { - let groups_conn = tx.acquire().await.map_err(|e| Error::Database(e.into())).ok()?; - let mut groups_repo = Groups::new(&mut *groups_conn); + let mut groups_conn = self.db.acquire().await.map_err(|e| Error::Database(e.into())).ok()?; + let mut groups_repo = Groups::new(&mut groups_conn); let model_groups_map = groups_repo.get_deployments_groups_bulk(&model_ids).await.ok()?; @@ -112,6 +114,31 @@ impl<'a> DeployedModelEnricher<'a> { } else { None } + }, + // Endpoints query + async { + if self.include_endpoints { + let mut endpoints_conn = self.db.acquire().await.map_err(|e| Error::Database(e.into())).ok()?; + let mut endpoints_repo = InferenceEndpoints::new(&mut endpoints_conn); + + // Collect all unique endpoint IDs + let endpoint_ids: Vec = models + .iter() + .map(|(m, _)| m.hosted_on) + .collect::>() + .into_iter() + .collect(); + + let endpoints_db = endpoints_repo.get_bulk(endpoint_ids).await.ok()?; + + // Convert DB responses to API responses + let endpoints_map: HashMap = + endpoints_db.into_iter().map(|(id, endpoint)| (id, endpoint.into())).collect(); + + Some(endpoints_map) + } else { + None + } } ); @@ -144,6 +171,11 @@ impl<'a> DeployedModelEnricher<'a> { model_response = Self::apply_pricing(model_response, model_pricing, self.can_read_pricing); } + // Add endpoint if requested and available + if self.include_endpoints { + model_response = Self::apply_endpoint(model_response, &endpoints_map); + } + // Mask rate limiting info for users without ModelRateLimits permission if !self.can_read_rate_limits { model_response = model_response.mask_rate_limiting(); @@ -152,9 +184,6 @@ impl<'a> DeployedModelEnricher<'a> { enriched_models.push(model_response); } - // Commit the transaction to ensure all reads were atomic - tx.commit().await.map_err(|e| Error::Database(e.into()))?; - Ok(enriched_models) } @@ -257,6 +286,19 @@ impl<'a> DeployedModelEnricher<'a> { } model } + + /// Apply endpoint to a model response + fn apply_endpoint( + mut model: DeployedModelResponse, + endpoints_map: &Option>, + ) -> DeployedModelResponse { + if let Some(endpoints_map) = endpoints_map { + if let Some(endpoint) = endpoints_map.get(&model.hosted_on) { + model = model.with_endpoint(endpoint.clone()); + } + } + model + } } #[cfg(test)] @@ -295,6 +337,7 @@ mod tests { status: None, pricing: None, downstream_pricing: None, + endpoint: None, } } @@ -496,4 +539,56 @@ mod tests { // Capacity is not a rate limit, should remain assert_eq!(masked.capacity, Some(50)); } + + #[test] + fn test_apply_endpoint_with_data() { + let model = create_test_model(); + let endpoint_id = model.hosted_on; + + let mut endpoints_map = HashMap::new(); + endpoints_map.insert( + endpoint_id, + InferenceEndpointResponse { + id: endpoint_id, + name: "Test Endpoint".to_string(), + description: Some("Test endpoint description".to_string()), + url: "https://api.example.com".to_string(), + model_filter: None, + requires_api_key: true, + auth_header_name: "Authorization".to_string(), + auth_header_prefix: "Bearer ".to_string(), + created_by: Uuid::new_v4().into(), + created_at: Utc::now(), + updated_at: Utc::now(), + }, + ); + + let result = DeployedModelEnricher::apply_endpoint(model, &Some(endpoints_map)); + + assert!(result.endpoint.is_some()); + let endpoint = result.endpoint.unwrap(); + assert_eq!(endpoint.name, "Test Endpoint"); + assert_eq!(endpoint.url, "https://api.example.com"); + } + + #[test] + fn test_apply_endpoint_no_data() { + let model = create_test_model(); + let endpoints_map = HashMap::new(); + + let result = DeployedModelEnricher::apply_endpoint(model, &Some(endpoints_map)); + + // No endpoint found for this model, should remain None + assert!(result.endpoint.is_none()); + } + + #[test] + fn test_apply_endpoint_not_requested() { + let model = create_test_model(); + + let result = DeployedModelEnricher::apply_endpoint(model, &None); + + // Endpoints not requested, should remain None + assert!(result.endpoint.is_none()); + } } diff --git a/dwctl/src/api/models/deployments/mod.rs b/dwctl/src/api/models/deployments/mod.rs index c6d091231..067d8d912 100644 --- a/dwctl/src/api/models/deployments/mod.rs +++ b/dwctl/src/api/models/deployments/mod.rs @@ -25,7 +25,7 @@ pub struct ListModelsQuery { #[param(value_type = Option, format = "uuid")] #[schema(value_type = Option, format = "uuid")] pub endpoint: Option, - /// Include related data (comma-separated: "groups", "metrics") + /// Include related data (comma-separated: "groups", "metrics", "status", "pricing", "endpoints") pub include: Option, /// Show deleted models when true, non-deleted when false, all when not specified (admin only for deleted=true) pub deleted: Option, @@ -44,7 +44,7 @@ pub struct GetModelQuery { pub deleted: Option, /// Show inactive model when true, 404 when false/unspecified if model is inactive pub inactive: Option, - /// Include related data (comma-separated: "groups", "metrics") + /// Include related data (comma-separated: "groups", "metrics", "status", "pricing", "endpoints") pub include: Option, } @@ -195,6 +195,9 @@ pub struct DeployedModelResponse { /// Provider/downstream pricing details (only included if requested and user has Pricing::ReadAll) #[serde(skip_serializing_if = "Option::is_none")] pub downstream_pricing: Option, + /// Inference endpoint information (only included if requested) + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, } impl From for DeployedModelResponse { @@ -219,6 +222,7 @@ impl From for DeployedModelResponse { status: None, // By default, probe status is not included pricing: None, // By default, pricing is not included (opt-in via include) downstream_pricing: None, // By default, downstream pricing is not included + endpoint: None, // By default, endpoint is not included } } } @@ -260,4 +264,10 @@ impl DeployedModelResponse { self.burst_size = None; self } + + /// Create a response with endpoint information included + pub fn with_endpoint(mut self, endpoint: super::inference_endpoints::InferenceEndpointResponse) -> Self { + self.endpoint = Some(endpoint); + self + } } From 14521e9881afa8e856ba4721080f7f727c96b6d4 Mon Sep 17 00:00:00 2001 From: Seb Date: Wed, 26 Nov 2025 16:44:20 +0000 Subject: [PATCH 2/8] fix: collapse conditions --- dwctl/src/api/models/deployments/enrichment.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dwctl/src/api/models/deployments/enrichment.rs b/dwctl/src/api/models/deployments/enrichment.rs index 3ac94a71b..cee27dbcd 100644 --- a/dwctl/src/api/models/deployments/enrichment.rs +++ b/dwctl/src/api/models/deployments/enrichment.rs @@ -292,10 +292,10 @@ impl<'a> DeployedModelEnricher<'a> { mut model: DeployedModelResponse, endpoints_map: &Option>, ) -> DeployedModelResponse { - if let Some(endpoints_map) = endpoints_map { - if let Some(endpoint) = endpoints_map.get(&model.hosted_on) { - model = model.with_endpoint(endpoint.clone()); - } + if let Some(endpoints_map) = endpoints_map + && let Some(endpoint) = endpoints_map.get(&model.hosted_on) + { + model = model.with_endpoint(endpoint.clone()); } model } From 9e5207eeac5fc9e51bf5a775124645f8f49dd2c8 Mon Sep 17 00:00:00 2001 From: Seb Date: Wed, 26 Nov 2025 17:31:54 +0000 Subject: [PATCH 3/8] feat: remove nested paragraphs in transaction modal --- .../UserTransactionsModal.tsx | 42 +++++++------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/dashboard/src/components/modals/UserTransactionsModal/UserTransactionsModal.tsx b/dashboard/src/components/modals/UserTransactionsModal/UserTransactionsModal.tsx index a06c3226f..64bf9fe04 100644 --- a/dashboard/src/components/modals/UserTransactionsModal/UserTransactionsModal.tsx +++ b/dashboard/src/components/modals/UserTransactionsModal/UserTransactionsModal.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { X, Plus } from "lucide-react"; +import { Plus } from "lucide-react"; import { Dialog, DialogContent, @@ -32,42 +32,28 @@ export function UserTransactionsModal({ return ( <> - +
-
+
Transaction History -

+ Viewing transactions for {user.name} ( {user.email}) -

-
-
- - +
+ +
- -

- Viewing transactions for {user.name} ( - {user.email}) -

-
From b82e640b7eba78d5000bfc663faf2c89637bdad7 Mon Sep 17 00:00:00 2001 From: Seb Date: Wed, 26 Nov 2025 17:55:50 +0000 Subject: [PATCH 4/8] feat: standardised cursor pagination and implemented into batches table --- .../features/batches/Batches/Batches.tsx | 115 +++++------------- .../src/components/ui/cursor-pagination.tsx | 103 ++++++++++++++++ dashboard/src/components/ui/data-table.tsx | 45 ++----- 3 files changed, 143 insertions(+), 120 deletions(-) create mode 100644 dashboard/src/components/ui/cursor-pagination.tsx diff --git a/dashboard/src/components/features/batches/Batches/Batches.tsx b/dashboard/src/components/features/batches/Batches/Batches.tsx index 34be44007..d665e957f 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.tsx @@ -20,6 +20,7 @@ import { SelectValue, } from "../../../ui/select"; import { DataTable } from "../../../ui/data-table"; +import { CursorPagination } from "../../../ui/cursor-pagination"; import { createFileColumns } from "../FilesTable/columns"; import { createBatchColumns } from "../BatchesTable/columns"; import { useFiles, useBatches } from "../../../../api/control-layer/hooks"; @@ -597,48 +598,21 @@ export function Batches({
} /> - {/* Server-side pagination controls */} -
-
- Showing {filesPage * filesPageSize + 1} -{" "} - {filesPage * filesPageSize + files.length} - {filesHasMore && " of many"} -
-
- {filesPage > 1 && ( - - )} - - - Page {filesPage + 1} - - -
-
+ { + setFilesAfterCursor(undefined); + filesCursorHistory.current = []; + updateFilesPagination(0, filesPageSize); + }} + hasNextPage={filesHasMore} + hasPrevPage={filesPage > 0} + currentPageItemCount={files.length} + itemName="files" + /> )} @@ -726,48 +700,21 @@ export function Batches({
} /> - {/* Server-side pagination controls */} -
-
- Showing {batchesPage * batchesPageSize + 1} -{" "} - {batchesPage * batchesPageSize + filteredBatches.length} - {batchesHasMore && " of many"} -
-
- {batchesPage > 1 && ( - - )} - - - Page {batchesPage + 1} - - -
-
+ { + setBatchesAfterCursor(undefined); + batchesCursorHistory.current = []; + updateBatchesPagination(0, batchesPageSize); + }} + hasNextPage={batchesHasMore} + hasPrevPage={batchesPage > 0} + currentPageItemCount={filteredBatches.length} + itemName="batches" + /> )} diff --git a/dashboard/src/components/ui/cursor-pagination.tsx b/dashboard/src/components/ui/cursor-pagination.tsx new file mode 100644 index 000000000..a0a1bf335 --- /dev/null +++ b/dashboard/src/components/ui/cursor-pagination.tsx @@ -0,0 +1,103 @@ +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "./pagination"; + +interface CursorPaginationProps { + currentPage: number; + itemsPerPage: number; + onNextPage: () => void; + onPrevPage: () => void; + onFirstPage?: () => void; + hasNextPage: boolean; + hasPrevPage: boolean; + currentPageItemCount: number; + itemName?: string; + className?: string; +} + +export function CursorPagination({ + currentPage, + itemsPerPage, + onNextPage, + onPrevPage, + onFirstPage, + hasNextPage, + hasPrevPage, + currentPageItemCount, + itemName = "items", + className = "", +}: CursorPaginationProps) { + return ( + <> + + + {/* First page button - only show if we're beyond page 2 */} + {onFirstPage && currentPage > 1 && ( + + { + e.preventDefault(); + onFirstPage(); + }} + > + 1 + + + )} + + {/* Previous button */} + + { + e.preventDefault(); + if (hasPrevPage) onPrevPage(); + }} + className={ + !hasPrevPage + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + {/* Current page indicator */} + + + {currentPage} + + + + {/* Next button */} + + { + e.preventDefault(); + if (hasNextPage) onNextPage(); + }} + className={ + !hasNextPage + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + + + {/* Item count display */} +
+ Showing {itemsPerPage * (currentPage - 1) + 1}- + {itemsPerPage * (currentPage - 1) + currentPageItemCount} + {hasNextPage && " of many"} {itemName} +
+ + ); +} diff --git a/dashboard/src/components/ui/data-table.tsx b/dashboard/src/components/ui/data-table.tsx index 4ef586472..06d6829bb 100644 --- a/dashboard/src/components/ui/data-table.tsx +++ b/dashboard/src/components/ui/data-table.tsx @@ -13,7 +13,7 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table"; -import { ChevronLeft, ChevronRight, Search } from "lucide-react"; +import { Search } from "lucide-react"; import { Table, @@ -31,6 +31,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "./dropdown-menu"; +import { TablePagination } from "./table-pagination"; interface DataTableProps { columns: ColumnDef[]; @@ -303,41 +304,13 @@ export function DataTable({ {showPagination && ( -
-
- Showing{" "} - {table.getState().pagination.pageIndex * - table.getState().pagination.pageSize + - 1}{" "} - to{" "} - {Math.min( - (table.getState().pagination.pageIndex + 1) * - table.getState().pagination.pageSize, - table.getFilteredRowModel().rows.length, - )}{" "} - of {table.getFilteredRowModel().rows.length} results -
-
- - -
-
+ table.setPageIndex(page - 1)} + totalItems={table.getFilteredRowModel().rows.length} + itemName="results" + /> )} ); From 3c36123d2279b28aae282c51832e0e52689faa08 Mon Sep 17 00:00:00 2001 From: Seb Date: Wed, 26 Nov 2025 18:03:01 +0000 Subject: [PATCH 5/8] fix: FileRequests cursor UI --- .../batches/FileRequests/FileRequests.tsx | 117 ++++++++---------- .../src/components/ui/cursor-pagination.tsx | 5 +- 2 files changed, 51 insertions(+), 71 deletions(-) diff --git a/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx b/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx index 2fb6388a9..45a385a74 100644 --- a/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx +++ b/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx @@ -11,6 +11,7 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "../../../ui/button"; import { DataTable } from "../../../ui/data-table"; +import { CursorPagination } from "../../../ui/cursor-pagination"; import { Select, SelectContent, @@ -50,17 +51,33 @@ export function FileRequests() { useState(null); const [requestBodyModalOpen, setRequestBodyModalOpen] = useState(false); - // Get pagination from URL or use defaults - const page = parseInt(searchParams.get("page") || "0", 10); - const pageSize = parseInt(searchParams.get("pageSize") || "10", 10); + // Pagination state (1-based like Models) + const [currentPage, setCurrentPage] = useState( + Number(searchParams.get("page")) || 1, + ); + const [pageSize, setPageSize] = useState( + Number(searchParams.get("pageSize")) || 10, + ); + + // Load pagination from URL params on mount + useEffect(() => { + const page = searchParams.get("page"); + const size = searchParams.get("pageSize"); - // Update URL when pagination changes - const updatePagination = (newPage: number, newPageSize: number) => { + if (page) setCurrentPage(Number(page)); + if (size) setPageSize(Number(size)); + }, [searchParams]); + + // Sync pagination to URL params + useEffect(() => { const params = new URLSearchParams(searchParams); - params.set("page", newPage.toString()); - params.set("pageSize", newPageSize.toString()); + params.set("page", String(currentPage)); + params.set("pageSize", String(pageSize)); + // Preserve returnTab + if (returnTab) params.set("returnTab", returnTab); + setSearchParams(params, { replace: true }); - }; + }, [currentPage, pageSize, returnTab, searchParams, setSearchParams]); // Get file details - works for input, output, or error files const { data: file } = useFile(fileId || ""); @@ -79,13 +96,13 @@ export function FileRequests() { ["validating", "in_progress", "finalizing"].includes(b.status), ); - // Fetch file content with pagination + // Fetch file content with pagination (convert 1-based page to 0-based offset) const { data, isLoading } = useQuery({ - queryKey: ["file-content", fileId, page, pageSize], + queryKey: ["file-content", fileId, currentPage, pageSize], queryFn: () => dwctlApi.files.getContent(fileId || "", { limit: pageSize, - offset: page * pageSize, + offset: (currentPage - 1) * pageSize, }), enabled: !!fileId, }); @@ -94,15 +111,15 @@ export function FileRequests() { useEffect(() => { if (fileId && data?.incomplete) { queryClient.prefetchQuery({ - queryKey: ["file-content", fileId, page + 1, pageSize], + queryKey: ["file-content", fileId, currentPage + 1, pageSize], queryFn: () => dwctlApi.files.getContent(fileId, { limit: pageSize, - offset: (page + 1) * pageSize, + offset: currentPage * pageSize, }), }); } - }, [fileId, page, pageSize, data?.incomplete, queryClient]); + }, [fileId, currentPage, pageSize, data?.incomplete, queryClient]); // Parse JSONL into requests (could be templates or responses) const requests: FileRequestOrResponse[] = data?.content @@ -136,7 +153,7 @@ export function FileRequests() {
)} @@ -223,10 +230,11 @@ export function FileRequests() { { - setPageSize(Number(value)); - setCurrentPage(1); // Reset to first page when changing page size + updatePagination(1, Number(value)); // Reset to first page when changing page size }} > @@ -252,9 +221,11 @@ export function FileRequests() { setCurrentPage(currentPage + 1)} - onPrevPage={() => setCurrentPage(Math.max(1, currentPage - 1))} - onFirstPage={() => setCurrentPage(1)} + onNextPage={() => updatePagination(currentPage + 1, pageSize)} + onPrevPage={() => + updatePagination(Math.max(1, currentPage - 1), pageSize) + } + onFirstPage={() => updatePagination(1, pageSize)} hasNextPage={hasMore} hasPrevPage={currentPage > 1} currentPageItemCount={requests.length} From 1dab207c1459dbfd44e2cede3bf74aa7ca150392 Mon Sep 17 00:00:00 2001 From: Seb Date: Wed, 26 Nov 2025 18:32:35 +0000 Subject: [PATCH 7/8] fix: tests --- .../batches/Batches/Batches.pagination.test.tsx | 13 ++++++++----- .../FileRequestsModal/ViewFileRequestsModal.tsx | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx index 56147ffaf..893a550e4 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx @@ -185,12 +185,15 @@ describe("Batches - Pagination", () => { ).toBeInTheDocument(); }); - // Verify we're on page 1 - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + // Verify we're on page 1 - look for the active pagination link + const activePage = within(container).getByRole("link", { + current: "page", + }); + expect(activePage).toHaveTextContent("1"); - // Click Next button - const nextButton = within(container).getByRole("button", { - name: /Next/i, + // Click Next button - uses aria-label "Go to next page" + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); diff --git a/dashboard/src/components/modals/FileRequestsModal/ViewFileRequestsModal.tsx b/dashboard/src/components/modals/FileRequestsModal/ViewFileRequestsModal.tsx index 37d3b79b4..e2230ad4f 100644 --- a/dashboard/src/components/modals/FileRequestsModal/ViewFileRequestsModal.tsx +++ b/dashboard/src/components/modals/FileRequestsModal/ViewFileRequestsModal.tsx @@ -76,9 +76,9 @@ export function ViewFileRequestsModal({ const { data, isLoading } = useQuery({ queryKey: ["file-content", file?.id, page], queryFn: () => - dwctlApi.files.getContent(file?.id || "", { + dwctlApi.files.getFileContent(file?.id || "", { limit: pageSize, - offset: page * pageSize, + skip: page * pageSize, }), enabled: !!file?.id && isOpen, }); From 1aae51e036f7a061a64e297e08e162140cb66b0d Mon Sep 17 00:00:00 2001 From: Seb Date: Thu, 27 Nov 2025 09:59:17 +0000 Subject: [PATCH 8/8] fix: tests --- .../Batches/Batches.pagination.test.tsx | 148 ++++++++++++------ 1 file changed, 97 insertions(+), 51 deletions(-) diff --git a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx index 893a550e4..bc4b16f3f 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx @@ -199,7 +199,10 @@ describe("Batches - Pagination", () => { // Verify page 2 is shown await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Verify the correct cursor was used (last item from page 1) @@ -283,28 +286,34 @@ describe("Batches - Pagination", () => { }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Now click Previous - should go back to page 1 using cursor history - const prevButton = within(container).getByRole("button", { - name: /Previous/i, + const prevButton = within(container).getByRole("link", { + name: /go to previous page/i, }); await user.click(prevButton); // Should be back on page 1 await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); - // The previous button should now be disabled (we're on page 0) - expect(prevButton).toBeDisabled(); + // The previous button should have pointer-events-none class (disabled) + expect(prevButton).toHaveClass("pointer-events-none"); }); it("should show First button only when on page 2 or higher", async () => { @@ -388,37 +397,43 @@ describe("Batches - Pagination", () => { // Page 1: No First button expect( - within(container).queryByRole("button", { name: /First/i }), + within(container).queryByRole("link", { name: /First/i }), ).not.toBeInTheDocument(); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); - // Page 2: Still no First button (only shows on page 3+) + // Page 2: First button should now appear (currentPage > 1) expect( - within(container).queryByRole("button", { name: /First/i }), - ).not.toBeInTheDocument(); + within(container).getByRole("link", { name: /First/i }), + ).toBeInTheDocument(); // Navigate to page 3 - const nextButton2 = within(container).getByRole("button", { - name: /Next/i, + const nextButton2 = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton2); await waitFor(() => { - expect(within(container).getByText(/Page 3/i)).toBeInTheDocument(); + const activePage3 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage3).toHaveTextContent("3"); }); - // Page 3: First button should appear + // Page 3: First button should still appear expect( - within(container).getByRole("button", { name: /First/i }), + within(container).getByRole("link", { name: /First/i }), ).toBeInTheDocument(); }); @@ -515,36 +530,45 @@ describe("Batches - Pagination", () => { }); // Navigate to page 2, then page 3 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); - const nextButton2 = within(container).getByRole("button", { - name: /Next/i, + const nextButton2 = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton2); await waitFor(() => { - expect(within(container).getByText(/Page 3/i)).toBeInTheDocument(); + const activePage3 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage3).toHaveTextContent("3"); }); // Click First button - const firstButton = within(container).getByRole("button", { + const firstButton = within(container).getByRole("link", { name: /First/i, }); await user.click(firstButton); // Should be back on page 1 await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); // First button should no longer be visible expect( - within(container).queryByRole("button", { name: /First/i }), + within(container).queryByRole("link", { name: /First/i }), ).not.toBeInTheDocument(); }); @@ -619,13 +643,16 @@ describe("Batches - Pagination", () => { }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Change page size by clicking the combobox trigger @@ -646,13 +673,17 @@ describe("Batches - Pagination", () => { // Should reset to page 1 after changing page size await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); - // Previous button should be disabled (back at page 1) - expect( - within(container).getByRole("button", { name: /Previous/i }), - ).toBeDisabled(); + // Previous button should have pointer-events-none class (disabled) + const prevButton = within(container).getByRole("link", { + name: /go to previous page/i, + }); + expect(prevButton).toHaveClass("pointer-events-none"); }); }); @@ -717,17 +748,23 @@ describe("Batches - Pagination", () => { ); await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage = within(container).getByRole("link", { + current: "page", + }); + expect(activePage).toHaveTextContent("1"); }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); }); @@ -791,31 +828,40 @@ describe("Batches - Pagination", () => { ); await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage = within(container).getByRole("link", { + current: "page", + }); + expect(activePage).toHaveTextContent("1"); }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Navigate back to page 1 - const prevButton = within(container).getByRole("button", { - name: /Previous/i, + const prevButton = within(container).getByRole("link", { + name: /go to previous page/i, }); await user.click(prevButton); await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); - // Previous button should be disabled on page 1 - expect(prevButton).toBeDisabled(); + // Previous button should have pointer-events-none class (disabled) on page 1 + expect(prevButton).toHaveClass("pointer-events-none"); }); }); });