From cecffa936a82d9692d83ccc4a24cccf23cd2486e Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Fri, 21 Nov 2025 18:42:54 -0800 Subject: [PATCH 1/2] pagination wip --- taco/internal/pagination/pagination.go | 49 ++++++++ taco/internal/repositories/unit_repository.go | 3 + taco/internal/token_service/handler.go | 27 ++++- taco/internal/token_service/repository.go | 26 +++-- taco/internal/unit/handler.go | 30 ++++- ui/src/api/statesman_serverFunctions.ts | 12 +- ui/src/api/statesman_units.ts | 9 +- ui/src/api/tokens.ts | 9 +- ui/src/api/tokens_serverFunctions.ts | 8 +- .../_dashboard/dashboard/settings.tokens.tsx | 107 +++++++++++------- .../_dashboard/dashboard/units.index.tsx | 51 +++++++-- 11 files changed, 256 insertions(+), 75 deletions(-) create mode 100644 taco/internal/pagination/pagination.go diff --git a/taco/internal/pagination/pagination.go b/taco/internal/pagination/pagination.go new file mode 100644 index 000000000..e9e5dbb8c --- /dev/null +++ b/taco/internal/pagination/pagination.go @@ -0,0 +1,49 @@ +package pagination + +import ( + "strconv" + + "github.com/labstack/echo/v4" +) + +type Params struct { + Page int + PageSize int +} + +func (p Params) Offset() int { + if p.Page < 1 { + return 0 + } + return (p.Page - 1) * p.PageSize +} + +func Parse(c echo.Context, defaultSize, maxSize int) Params { + page := parseInt(c.QueryParam("page"), 1) + size := parseInt(c.QueryParam("page_size"), defaultSize) + + if page < 1 { + page = 1 + } + if size < 1 { + size = defaultSize + } + if maxSize > 0 && size > maxSize { + size = maxSize + } + + return Params{ + Page: page, + PageSize: size, + } +} + +func parseInt(val string, fallback int) int { + if val == "" { + return fallback + } + if parsed, err := strconv.Atoi(val); err == nil { + return parsed + } + return fallback +} diff --git a/taco/internal/repositories/unit_repository.go b/taco/internal/repositories/unit_repository.go index 06438777c..11e107780 100644 --- a/taco/internal/repositories/unit_repository.go +++ b/taco/internal/repositories/unit_repository.go @@ -175,6 +175,9 @@ func (r *UnitRepository) List(ctx context.Context, orgID, prefix string) ([]*sto query = query.Where("name LIKE ?", prefix+"%") } + // order by most recent update, stable tie-breaker on id for deterministic paging + query = query.Order("updated_at DESC").Order("id ASC") + if err := query.Find(&units).Error; err != nil { return nil, fmt.Errorf("failed to list units: %w", err) } diff --git a/taco/internal/token_service/handler.go b/taco/internal/token_service/handler.go index 7fbc3e28f..582a5e6d6 100644 --- a/taco/internal/token_service/handler.go +++ b/taco/internal/token_service/handler.go @@ -5,6 +5,7 @@ import ( "time" "github.com/diggerhq/digger/opentaco/internal/logging" + "github.com/diggerhq/digger/opentaco/internal/pagination" querytypes "github.com/diggerhq/digger/opentaco/cmd/token_service/query/types" "github.com/labstack/echo/v4" ) @@ -41,6 +42,14 @@ type TokenResponse struct { ExpiresAt *time.Time `json:"expires_at,omitempty"` } +type TokenListResponse struct { + Tokens []TokenResponse `json:"tokens"` + Count int `json:"count"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + // VerifyTokenRequest represents the request to verify a token type VerifyTokenRequest struct { Token string `json:"token" validate:"required"` @@ -89,17 +98,18 @@ func (h *Handler) CreateToken(c echo.Context) error { func (h *Handler) ListTokens(c echo.Context) error { userID := c.QueryParam("user_id") orgID := c.QueryParam("org_id") + pageParams := pagination.Parse(c, 25, 200) - tokens, err := h.repo.ListTokens(c.Request().Context(), userID, orgID) + tokens, total, err := h.repo.ListTokens(c.Request().Context(), userID, orgID, pageParams.Page, pageParams.PageSize) if err != nil { logger := logging.FromContext(c) logger.Error("Failed to list tokens", "user_id", userID, "org_id", orgID, "error", err) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to list tokens"}) } - responses := make([]TokenResponse, len(tokens)) - for i, token := range tokens { - responses[i] = toTokenResponseHidden(token) // Hide token hash + responses := make([]TokenResponse, 0, len(tokens)) + for _, token := range tokens { + responses = append(responses, toTokenResponseHidden(token)) // Hide token hash } // Prevent caching of token list responses @@ -107,7 +117,13 @@ func (h *Handler) ListTokens(c echo.Context) error { c.Response().Header().Set("Pragma", "no-cache") c.Response().Header().Set("Expires", "0") - return c.JSON(http.StatusOK, responses) + return c.JSON(http.StatusOK, TokenListResponse{ + Tokens: responses, + Count: len(responses), + Total: total, + Page: pageParams.Page, + PageSize: pageParams.PageSize, + }) } // DeleteToken deletes a token by ID @@ -206,4 +222,3 @@ func toTokenResponseHidden(token *querytypes.Token) TokenResponse { return resp } - diff --git a/taco/internal/token_service/repository.go b/taco/internal/token_service/repository.go index db756eaff..8cf715257 100644 --- a/taco/internal/token_service/repository.go +++ b/taco/internal/token_service/repository.go @@ -62,10 +62,18 @@ func (r *TokenRepository) CreateToken(ctx context.Context, userID, orgID, name s return token, nil } -// ListTokens returns all tokens for a given user ID and org -func (r *TokenRepository) ListTokens(ctx context.Context, userID, orgID string) ([]*types.Token, error) { +// ListTokens returns tokens for a given user ID and org with pagination. +func (r *TokenRepository) ListTokens(ctx context.Context, userID, orgID string, page, pageSize int) ([]*types.Token, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } + offset := (page - 1) * pageSize + var tokens []*types.Token - query := r.db.WithContext(ctx) + query := r.db.WithContext(ctx).Model(&types.Token{}) // Filter by userID if provided if userID != "" { @@ -77,11 +85,16 @@ func (r *TokenRepository) ListTokens(ctx context.Context, userID, orgID string) query = query.Where("org_id = ?", orgID) } - if err := query.Order("created_at DESC").Find(&tokens).Error; err != nil { - return nil, fmt.Errorf("failed to list tokens: %w", err) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count tokens: %w", err) } - return tokens, nil + if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&tokens).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list tokens: %w", err) + } + + return tokens, total, nil } // DeleteToken deletes a token by ID @@ -165,4 +178,3 @@ func hashToken(token string) string { hash := sha256.Sum256([]byte(token)) return hex.EncodeToString(hash[:]) } - diff --git a/taco/internal/unit/handler.go b/taco/internal/unit/handler.go index 4a52ea159..a0148c198 100644 --- a/taco/internal/unit/handler.go +++ b/taco/internal/unit/handler.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/diggerhq/digger/opentaco/internal/deps" "github.com/diggerhq/digger/opentaco/internal/domain" "github.com/diggerhq/digger/opentaco/internal/logging" + "github.com/diggerhq/digger/opentaco/internal/pagination" "github.com/diggerhq/digger/opentaco/internal/query" "github.com/diggerhq/digger/opentaco/internal/rbac" "github.com/diggerhq/digger/opentaco/internal/storage" @@ -281,6 +283,7 @@ func (h *Handler) ListUnits(c echo.Context) error { logger := logging.FromContext(c) ctx := c.Request().Context() prefix := c.QueryParam("prefix") + pageParams := pagination.Parse(c, 50, 200) // Get org UUID from domain context (set by middleware for both JWT and webhook routes) orgCtx, ok := domain.OrgFromContext(ctx) @@ -331,15 +334,34 @@ func (h *Handler) ListUnits(c echo.Context) error { LockInfo: convertLockInfo(u.LockInfo), }) } - domain.SortUnitsByID(domainUnits) + sort.Slice(domainUnits, func(i, j int) bool { + if domainUnits[i].Updated.Equal(domainUnits[j].Updated) { + return domainUnits[i].Name < domainUnits[j].Name + } + return domainUnits[i].Updated.After(domainUnits[j].Updated) + }) + + total := len(domainUnits) + start := pageParams.Offset() + if start > total { + start = total + } + end := start + pageParams.PageSize + if end > total { + end = total + } + pagedUnits := domainUnits[start:end] logger.Info("Units listed successfully", "operation", "list_units", - "count", len(domainUnits), + "count", len(pagedUnits), ) return c.JSON(http.StatusOK, map[string]interface{}{ - "units": domainUnits, - "count": len(domainUnits), + "units": pagedUnits, + "count": len(pagedUnits), + "total": total, + "page": pageParams.Page, + "page_size": pageParams.PageSize, }) } diff --git a/ui/src/api/statesman_serverFunctions.ts b/ui/src/api/statesman_serverFunctions.ts index 49642c339..5c86d6532 100644 --- a/ui/src/api/statesman_serverFunctions.ts +++ b/ui/src/api/statesman_serverFunctions.ts @@ -2,9 +2,15 @@ import { createServerFn } from "@tanstack/react-start" import { createUnit, getUnit, listUnits, getUnitVersions, unlockUnit, lockUnit, getUnitStatus, deleteUnit, downloadLatestState, forcePushState, restoreUnitStateVersion } from "./statesman_units" export const listUnitsFn = createServerFn({method: 'GET'}) - .inputValidator((data : {userId: string, organisationId: string, email: string}) => data) + .inputValidator((data : {userId: string, organisationId: string, email: string, page?: number, pageSize?: number}) => data) .handler(async ({ data }) => { - const units : any = await listUnits(data.organisationId, data.userId, data.email); + const units : any = await listUnits( + data.organisationId, + data.userId, + data.email, + data.page ?? 1, + data.pageSize ?? 20 + ); return units; }) @@ -124,4 +130,4 @@ export const deleteUnitFn = createServerFn({method: 'POST'}) .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) .handler(async ({ data }) => { await deleteUnit(data.organisationId, data.userId, data.email, data.unitId) -}) \ No newline at end of file +}) diff --git a/ui/src/api/statesman_units.ts b/ui/src/api/statesman_units.ts index dc03a7eda..c078a22b0 100644 --- a/ui/src/api/statesman_units.ts +++ b/ui/src/api/statesman_units.ts @@ -3,8 +3,11 @@ function generateRequestId(): string { return `ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } -export async function listUnits(orgId: string, userId: string, email: string) { - const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units`, { +export async function listUnits(orgId: string, userId: string, email: string, page: number = 1, pageSize: number = 20) { + const url = new URL(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units`); + url.searchParams.set('page', String(page)); + url.searchParams.set('page_size', String(pageSize)); + const response = await fetch(url.toString(), { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -261,4 +264,4 @@ export async function deleteUnit(orgId: string, userId: string, email: string, u throw new Error(`Failed to delete unit: ${response.statusText}`); } -} \ No newline at end of file +} diff --git a/ui/src/api/tokens.ts b/ui/src/api/tokens.ts index a0499d13e..6f5fe9606 100644 --- a/ui/src/api/tokens.ts +++ b/ui/src/api/tokens.ts @@ -3,8 +3,13 @@ function generateRequestId(): string { return `ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } -export const getTokens = async (organizationId: string, userId: string) => { - const query = new URLSearchParams({ org_id: organizationId, user_id: userId }); +export const getTokens = async (organizationId: string, userId: string, page: number = 1, pageSize: number = 20) => { + const query = new URLSearchParams({ + org_id: organizationId, + user_id: userId, + page: String(page), + page_size: String(pageSize), + }); const url = `${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens?${query.toString()}`; const response = await fetch(url, { method: 'GET', diff --git a/ui/src/api/tokens_serverFunctions.ts b/ui/src/api/tokens_serverFunctions.ts index 8c19db1b7..5b54e05bc 100644 --- a/ui/src/api/tokens_serverFunctions.ts +++ b/ui/src/api/tokens_serverFunctions.ts @@ -4,9 +4,9 @@ import { verifyToken } from "./tokens"; import { deleteToken } from "./tokens"; export const getTokensFn = createServerFn({method: 'GET'}) - .inputValidator((data: {organizationId: string, userId: string}) => data) - .handler(async ({data: {organizationId, userId}}) => { - return getTokens(organizationId, userId); + .inputValidator((data: {organizationId: string, userId: string, page?: number, pageSize?: number}) => data) + .handler(async ({data: {organizationId, userId, page = 1, pageSize = 20}}) => { + return getTokens(organizationId, userId, page, pageSize); }) export const createTokenFn = createServerFn({method: 'POST'}) @@ -25,4 +25,4 @@ export const deleteTokenFn = createServerFn({method: 'POST'}) .inputValidator((data: {organizationId: string, userId: string, tokenId: string}) => data) .handler(async ({data: {organizationId, userId, tokenId}}) => { return deleteToken(organizationId, userId, tokenId); -}) \ No newline at end of file +}) diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx index 869876550..393fd18db 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx @@ -16,7 +16,7 @@ export const Route = createFileRoute( component: RouteComponent, loader: async ({ context }) => { const { user, organisationId } = context; - const tokens = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}}) + const tokens = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || '', page: 1, pageSize: 20}}) return { tokens, user, organisationId } }, // Disable caching for token data - always fetch fresh @@ -26,13 +26,29 @@ export const Route = createFileRoute( function RouteComponent() { const { tokens, user, organisationId } = Route.useLoaderData() - const [tokenList, setTokenList] = useState(tokens) + const [tokenPage, setTokenPage] = useState(tokens) + const [currentPage, setCurrentPage] = useState(tokens?.page || 1) + const pageSize = tokens?.page_size || 20 const [newToken, setNewToken] = useState('') const [open, setOpen] = useState(false) const [nickname, setNickname] = useState('') const [expiry, setExpiry] = useState<'1_week' | '30_days' | 'no_expiry'>('1_week') const [submitting, setSubmitting] = useState(false) const { toast } = useToast() + const tokenList = (tokenPage?.tokens || []).slice().sort((a: any, b: any) => { + const aDate = a.created_at ? new Date(a.created_at).getTime() : 0 + const bDate = b.created_at ? new Date(b.created_at).getTime() : 0 + return bDate - aDate + }) + const total = tokenPage?.total || tokenList.length + const canGoPrev = currentPage > 1 + const canGoNext = currentPage * pageSize < total + + const loadPage = async (page: number) => { + const next = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || '', page, pageSize}}) + setTokenPage(next) + setCurrentPage(next?.page || page) + } const computeExpiry = (value: '1_week' | '30_days' | 'no_expiry'): string | null => { console.log('value', value) if (value === 'no_expiry') return null @@ -74,8 +90,7 @@ function RouteComponent() { setOpen(false) setNickname('') setExpiry('no_expiry') - const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}}) - setTokenList(newTokenList) + await loadPage(1) } finally { setSubmitting(false) } @@ -95,8 +110,7 @@ function RouteComponent() { }) }).finally(async () => { setSubmitting(false) - const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}}) - setTokenList(newTokenList) + await loadPage(currentPage) }) } return ( @@ -160,40 +174,55 @@ function RouteComponent() { {tokenList.length === 0 ? (

No tokens generated yet

) : ( - - - - Name - Token - Expires - Created - Actions - - - - {tokenList.map((token, index) => ( - - {token.name} - •••••••••••{token.token.slice(-4)} - - {isTokenExpired(token) - ? This token has expired - : (token.expires_at ? formatDateString(token.expires_at) : 'No expiry')} - - {formatDateString(token.created_at)} - - - + <> +
+ + + Name + Token + Expires + Created + Actions - ))} - -
+ + + {tokenList.map((token, index) => ( + + {token.name} + •••••••••••{token.token.slice(-4)} + + {isTokenExpired(token) + ? This token has expired + : (token.expires_at ? formatDateString(token.expires_at) : 'No expiry')} + + {formatDateString(token.created_at)} + + + + + ))} + + +
+

+ Showing {tokenList.length} of {total} tokens (page {currentPage}, {pageSize} per page) +

+
+ + +
+
+ )} diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx index ff2d47915..be521ef97 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx @@ -39,7 +39,9 @@ export const Route = createFileRoute( data: { organisationId: organisationId || '', userId: user?.id || '', - email: user?.email || '' + email: user?.email || '', + page: 1, + pageSize: 20 } }); @@ -170,30 +172,56 @@ function CreateUnitModal({ onUnitCreated, onUnitOptimistic, onUnitFailed }: { function RouteComponent() { const { unitsData, organisationId, user } = Route.useLoaderData() - const [units, setUnits] = useState(unitsData?.units || []) + const [pageData, setPageData] = useState(unitsData) + const [currentPage, setCurrentPage] = useState(unitsData?.page || 1) + const pageSize = (pageData as any)?.page_size || 20 + const total = (pageData as any)?.total || (pageData as any)?.units?.length || 0 + const units = (pageData?.units || []).slice().sort((a: any, b: any) => { + const aDate = a.updated ? new Date(a.updated).getTime() : 0 + const bDate = b.updated ? new Date(b.updated).getTime() : 0 + return bDate - aDate + }) const navigate = Route.useNavigate() const router = useRouter() + const canGoPrev = currentPage > 1 + const canGoNext = currentPage * pageSize < total + + const loadPage = async (page: number) => { + const next = await listUnitsFn({ + data: { + organisationId: organisationId || '', + userId: user?.id || '', + email: user?.email || '', + page, + pageSize + } + }); + setPageData(next) + setCurrentPage(next?.page || page) + } // Handle optimistic update - add immediately function handleUnitOptimistic(tempUnit: any) { - setUnits(prev => [{ + setPageData(prev => { + const nextUnits = [{ ...tempUnit, locked: false, size: 0, updated: new Date(), isOptimistic: true - }, ...prev]) + }, ...(prev?.units || [])] + return { ...prev, units: nextUnits } + }) } // Handle actual creation - refresh from server async function handleUnitCreated() { - const unitsData = await listUnitsFn({data: {organisationId: organisationId, userId: user?.id || '', email: user?.email || ''}}) - setUnits(unitsData.units) + await loadPage(1) } // Handle failure - remove optimistic unit function handleUnitFailed() { - setUnits(prev => prev.filter((u: any) => !u.isOptimistic)) + setPageData(prev => ({ ...prev, units: (prev?.units || []).filter((u: any) => !u.isOptimistic) })) } return (<> @@ -280,6 +308,15 @@ function RouteComponent() { )} +
+

+ Showing {units.length} of {total} units (page {currentPage}, {pageSize} per page) +

+
+ + +
+
From d5bb21143fcd2d20319a940e3db69f0db2c70bd3 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Fri, 21 Nov 2025 19:04:31 -0800 Subject: [PATCH 2/2] name sort --- taco/internal/repositories/unit_repository.go | 4 ++-- taco/internal/unit/handler.go | 8 +++++--- .../_authenticated/_dashboard/dashboard/units.index.tsx | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/taco/internal/repositories/unit_repository.go b/taco/internal/repositories/unit_repository.go index 11e107780..7e6d62324 100644 --- a/taco/internal/repositories/unit_repository.go +++ b/taco/internal/repositories/unit_repository.go @@ -175,8 +175,8 @@ func (r *UnitRepository) List(ctx context.Context, orgID, prefix string) ([]*sto query = query.Where("name LIKE ?", prefix+"%") } - // order by most recent update, stable tie-breaker on id for deterministic paging - query = query.Order("updated_at DESC").Order("id ASC") + // order by name for deterministic paging; tie-break by id + query = query.Order("LOWER(name) ASC").Order("id ASC") if err := query.Find(&units).Error; err != nil { return nil, fmt.Errorf("failed to list units: %w", err) diff --git a/taco/internal/unit/handler.go b/taco/internal/unit/handler.go index a0148c198..36fe1b14d 100644 --- a/taco/internal/unit/handler.go +++ b/taco/internal/unit/handler.go @@ -335,10 +335,12 @@ func (h *Handler) ListUnits(c echo.Context) error { }) } sort.Slice(domainUnits, func(i, j int) bool { - if domainUnits[i].Updated.Equal(domainUnits[j].Updated) { - return domainUnits[i].Name < domainUnits[j].Name + in := strings.ToLower(domainUnits[i].Name) + jn := strings.ToLower(domainUnits[j].Name) + if in == jn { + return domainUnits[i].ID < domainUnits[j].ID } - return domainUnits[i].Updated.After(domainUnits[j].Updated) + return in < jn }) total := len(domainUnits) diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx index be521ef97..92dfa27ce 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx @@ -177,9 +177,10 @@ function RouteComponent() { const pageSize = (pageData as any)?.page_size || 20 const total = (pageData as any)?.total || (pageData as any)?.units?.length || 0 const units = (pageData?.units || []).slice().sort((a: any, b: any) => { - const aDate = a.updated ? new Date(a.updated).getTime() : 0 - const bDate = b.updated ? new Date(b.updated).getTime() : 0 - return bDate - aDate + const an = (a.name || '').toLowerCase() + const bn = (b.name || '').toLowerCase() + if (an === bn) return (a.id || '').localeCompare(b.id || '') + return an.localeCompare(bn) }) const navigate = Route.useNavigate() const router = useRouter()