From b020bcbdc667d12ae99e4da4b8f711d70b23665c Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 22:28:57 +0100 Subject: [PATCH 01/13] feat(app-deployments): add server-side sorting by created, activated, and last used --- .../modules/app-deployments/module.graphql.ts | 23 +- .../providers/app-deployments-manager.ts | 19 +- .../providers/app-deployments.ts | 255 ++++++++++++++++-- .../app-deployments/resolvers/Target.ts | 8 + packages/services/storage/src/index.ts | 31 +++ .../web/app/src/components/v2/sortable.tsx | 15 +- packages/web/app/src/pages/target-apps.tsx | 101 +++++-- packages/web/app/src/router.tsx | 14 +- 8 files changed, 415 insertions(+), 51 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/module.graphql.ts b/packages/services/api/src/modules/app-deployments/module.graphql.ts index f2f09d3ba96..019cedeae16 100644 --- a/packages/services/api/src/modules/app-deployments/module.graphql.ts +++ b/packages/services/api/src/modules/app-deployments/module.graphql.ts @@ -40,6 +40,23 @@ export default gql` retired } + """ + Fields available for sorting app deployments. + """ + enum AppDeploymentsSortField { + CREATED_AT + ACTIVATED_AT + LAST_USED + } + + """ + Sort configuration for app deployments. + """ + input AppDeploymentsSortInput { + field: AppDeploymentsSortField! + direction: SortDirectionType! + } + type GraphQLDocumentConnection { pageInfo: PageInfo! edges: [GraphQLDocumentEdge!]! @@ -108,7 +125,11 @@ export default gql` """ The app deployments for this target. """ - appDeployments(first: Int, after: String): AppDeploymentConnection + appDeployments( + first: Int + after: String + sort: AppDeploymentsSortInput + ): AppDeploymentConnection appDeployment(appName: String!, appVersion: String!): AppDeployment """ Whether the viewer can access the app deployments within a target. diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts index 3403d4a38f1..5200d3dc20c 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts @@ -222,12 +222,29 @@ export class AppDeploymentsManager { async getPaginatedAppDeploymentsForTarget( target: Target, - args: { cursor: string | null; first: number | null }, + args: { + cursor: string | null; + first: number | null; + sort: { + field: 'CREATED_AT' | 'ACTIVATED_AT' | 'LAST_USED'; + direction: 'ASC' | 'DESC'; + } | null; + }, ) { + if (args.sort?.field === 'LAST_USED') { + return await this.appDeployments.getPaginatedAppDeploymentsSortedByLastUsed({ + targetId: target.id, + cursor: args.cursor, + first: args.first, + direction: args.sort.direction, + }); + } + return await this.appDeployments.getPaginatedAppDeployments({ targetId: target.id, cursor: args.cursor, first: args.first, + sort: args.sort as { field: 'CREATED_AT' | 'ACTIVATED_AT'; direction: 'ASC' | 'DESC' } | null, }); } diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index 41b6b6768ca..d4be6d5b497 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -4,8 +4,10 @@ import { sql, UniqueIntegrityConstraintViolationError, type DatabasePool } from import { z } from 'zod'; import { buildAppDeploymentIsEnabledKey } from '@hive/cdn-script/artifact-storage-reader'; import { + decodeAppDeploymentSortCursor, decodeCreatedAtAndUUIDIdBasedCursor, decodeHashBasedCursor, + encodeAppDeploymentSortCursor, encodeCreatedAtAndUUIDIdBasedCursor, encodeHashBasedCursor, } from '@hive/storage'; @@ -772,9 +774,54 @@ export class AppDeployments { targetId: string; cursor: string | null; first: number | null; + sort: { field: 'CREATED_AT' | 'ACTIVATED_AT'; direction: 'ASC' | 'DESC' } | null; }) { + this.logger.debug( + 'get paginated app deployments (targetId=%s, cursor=%s, sort=%o)', + args.targetId, + args.cursor, + args.sort, + ); const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; - const cursor = args.cursor ? decodeCreatedAtAndUUIDIdBasedCursor(args.cursor) : null; + const sortField = args.sort?.field ?? 'CREATED_AT'; + const sortDirection = args.sort?.direction ?? 'DESC'; + + let cursor = args.cursor ? decodeAppDeploymentSortCursor(args.cursor) : null; + if (cursor && cursor.sortField !== sortField) { + this.logger.debug( + 'Cursor sort field mismatch (targetId=%s, cursorField=%s, requestedField=%s). Ignoring cursor.', + args.targetId, + cursor.sortField, + sortField, + ); + cursor = null; + } + + const col = sql.identifier([sortField === 'ACTIVATED_AT' ? 'activated_at' : 'created_at']); + const isNullable = sortField === 'ACTIVATED_AT'; + const isDesc = sortDirection === 'DESC'; + + let cursorCondition = sql``; + if (cursor) { + const cv = cursor.sortValue; + const tiebreakOp = isDesc ? sql`<` : sql`>`; + + if (cv === null) { + cursorCondition = sql`AND (${col} IS NULL AND "id" ${tiebreakOp} ${cursor.id})`; + } else if (isNullable) { + cursorCondition = isDesc + ? sql`AND ((${col} = ${cv} AND "id" < ${cursor.id}) OR ${col} < ${cv} OR ${col} IS NULL)` + : sql`AND ((${col} = ${cv} AND "id" > ${cursor.id}) OR ${col} > ${cv} OR ${col} IS NULL)`; + } else { + cursorCondition = isDesc + ? sql`AND ((${col} = ${cv} AND "id" < ${cursor.id}) OR ${col} < ${cv})` + : sql`AND ((${col} = ${cv} AND "id" > ${cursor.id}) OR ${col} > ${cv})`; + } + } + + const dirSql = isDesc ? sql`DESC` : sql`ASC`; + const nullsLast = isNullable ? sql`NULLS LAST` : sql``; + const orderBy = sql`ORDER BY ${col} ${dirSql} ${nullsLast}, "id" ${dirSql}`; const result = await this.pool.query(sql` SELECT @@ -783,28 +830,21 @@ export class AppDeployments { "app_deployments" WHERE "target_id" = ${args.targetId} - ${ - cursor - ? sql` - AND ( - ( - "created_at" = ${cursor.createdAt} - AND "id" < ${cursor.id} - ) - OR "created_at" < ${cursor.createdAt} - ) - ` - : sql`` - } - ORDER BY "created_at" DESC, "id" + ${cursorCondition} + ${orderBy} LIMIT ${limit + 1} `); let items = result.rows.map(row => { const node = AppDeploymentModel.parse(row); + const sortValue = sortField === 'ACTIVATED_AT' ? node.activatedAt : node.createdAt; return { - cursor: encodeCreatedAtAndUUIDIdBasedCursor(node), + cursor: encodeAppDeploymentSortCursor({ + sortField, + sortValue, + id: node.id, + }), node, }; }); @@ -824,6 +864,189 @@ export class AppDeployments { }; } + async getPaginatedAppDeploymentsSortedByLastUsed(args: { + targetId: string; + cursor: string | null; + first: number | null; + direction: 'ASC' | 'DESC'; + }) { + this.logger.debug( + 'get paginated app deployments sorted by last used (targetId=%s, cursor=%s, direction=%s)', + args.targetId, + args.cursor, + args.direction, + ); + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + const isDesc = args.direction === 'DESC'; + let cursor = args.cursor ? decodeAppDeploymentSortCursor(args.cursor) : null; + if (cursor && cursor.sortField !== 'LAST_USED') { + this.logger.debug( + 'Cursor sort field mismatch (targetId=%s, cursorField=%s, requestedField=LAST_USED). Ignoring cursor.', + args.targetId, + cursor.sortField, + ); + cursor = null; + } + + const cursorInNoUsageSection = cursor !== null && cursor.sortValue === null; + let usageForPage: Array<{ appName: string; appVersion: string; lastUsed: string }> = []; + + if (!cursorInNoUsageSection) { + const chDirSql = isDesc ? cSql`DESC` : cSql`ASC`; + let chCursorCondition = cSql``; + if (cursor && cursor.sortValue !== null) { + chCursorCondition = isDesc + ? cSql`HAVING lastUsed <= ${cursor.sortValue}` + : cSql`HAVING lastUsed >= ${cursor.sortValue}`; + } + + const chResult = await this.clickhouse.query({ + query: cSql` + SELECT + app_name AS appName, + app_version AS appVersion, + formatDateTimeInJodaSyntax(max(last_request), 'yyyy-MM-dd\\'T\\'HH:mm:ss.000000+00:00') AS lastUsed + FROM app_deployment_usage + WHERE target_id = ${args.targetId} + GROUP BY app_name, app_version + ${chCursorCondition} + ORDER BY lastUsed ${chDirSql} + LIMIT ${cSql.raw(String(limit + 1))} + `, + queryId: 'get-all-deployments-last-used-for-sorting', + timeout: 30_000, + }); + + const chModel = z.array( + z.object({ + appName: z.string(), + appVersion: z.string(), + lastUsed: z.string(), + }), + ); + usageForPage = chModel.parse(chResult.data); + } + + const usagePairsForPage = usageForPage.map(r => `${r.appName}:${r.appVersion}`); + let usageDeployments: Array = []; + if (usagePairsForPage.length > 0) { + const pgResult = await this.pool.query(sql` + SELECT ${appDeploymentFields} + FROM "app_deployments" + WHERE "target_id" = ${args.targetId} + AND ("name" || ':' || "version") = ANY(${sql.array(usagePairsForPage, 'text')}) + `); + usageDeployments = pgResult.rows.map(row => AppDeploymentModel.parse(row)); + } + + const deploymentByPair = new Map(); + for (const d of usageDeployments) { + deploymentByPair.set(`${d.name}:${d.version}`, d); + } + + let pageItems: Array<{ node: z.infer; lastUsed: string | null }> = []; + for (const usage of usageForPage) { + const node = deploymentByPair.get(`${usage.appName}:${usage.appVersion}`); + if (node) { + pageItems.push({ node, lastUsed: usage.lastUsed }); + } + } + + if (cursor && cursor.sortValue !== null) { + const cursorIdx = pageItems.findIndex(item => item.node.id === cursor.id); + if (cursorIdx !== -1) { + pageItems = pageItems.slice(cursorIdx + 1); + } else { + pageItems = pageItems.filter(item => { + const cmp = item.lastUsed!.localeCompare(cursor.sortValue!); + return isDesc ? cmp < 0 : cmp > 0; + }); + } + } + + const remaining = limit + 1 - pageItems.length; + let fillHasMore = false; + if (remaining > 0) { + const dirSql = isDesc ? sql`DESC` : sql`ASC`; + let fillCursorCondition = sql``; + if (cursorInNoUsageSection) { + fillCursorCondition = isDesc + ? sql`AND "id" < ${cursor!.id}` + : sql`AND "id" > ${cursor!.id}`; + } + + const excludeCurrentPage = + usagePairsForPage.length > 0 + ? sql`AND NOT (("name" || ':' || "version") = ANY(${sql.array(usagePairsForPage, 'text')}))` + : sql``; + + const batchSize = limit + 1; + const fillResult = await this.pool.query(sql` + SELECT ${appDeploymentFields} + FROM "app_deployments" + WHERE "target_id" = ${args.targetId} + ${excludeCurrentPage} + ${fillCursorCondition} + ORDER BY "id" ${dirSql} + LIMIT ${batchSize} + `); + + const candidates = fillResult.rows.map(row => AppDeploymentModel.parse(row)); + fillHasMore = candidates.length === batchSize; + + if (candidates.length > 0) { + const candidateTuples = candidates.map( + c => cSql`(${args.targetId}, ${c.name}, ${c.version})`, + ); + const usageCheckResult = await this.clickhouse.query({ + query: cSql` + SELECT DISTINCT app_name, app_version + FROM app_deployment_usage + WHERE (target_id, app_name, app_version) + IN (${candidateTuples.reduce((a, b) => cSql`${a}, ${b}`)}) + `, + queryId: 'check-candidates-have-usage', + timeout: 10_000, + }); + const pairsWithUsage = new Set( + z + .array(z.object({ app_name: z.string(), app_version: z.string() })) + .parse(usageCheckResult.data) + .map(r => `${r.app_name}:${r.app_version}`), + ); + + for (const candidate of candidates) { + if (pageItems.length >= limit + 1) break; + if (!pairsWithUsage.has(`${candidate.name}:${candidate.version}`)) { + pageItems.push({ node: candidate, lastUsed: null }); + } + } + } + } + + const hasNextPage = pageItems.length > limit || fillHasMore; + const finalItems = pageItems.slice(0, limit); + + const items = finalItems.map(item => ({ + cursor: encodeAppDeploymentSortCursor({ + sortField: 'LAST_USED', + sortValue: item.lastUsed, + id: item.node.id, + }), + node: item.node, + })); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + endCursor: items[items.length - 1]?.cursor ?? '', + startCursor: items[0]?.cursor ?? '', + }, + }; + } + async getPaginatedGraphQLDocuments(args: { appDeploymentId: string; cursor: string | null; diff --git a/packages/services/api/src/modules/app-deployments/resolvers/Target.ts b/packages/services/api/src/modules/app-deployments/resolvers/Target.ts index b8a5830738d..1ad8921cc05 100644 --- a/packages/services/api/src/modules/app-deployments/resolvers/Target.ts +++ b/packages/services/api/src/modules/app-deployments/resolvers/Target.ts @@ -24,9 +24,17 @@ export const Target: Pick< }); }, appDeployments: async (target, args, { injector }) => { + const sort = args.sort + ? { + field: args.sort.field as 'CREATED_AT' | 'ACTIVATED_AT' | 'LAST_USED', + direction: args.sort.direction as 'ASC' | 'DESC', + } + : null; + return injector.get(AppDeploymentsManager).getPaginatedAppDeploymentsForTarget(target, { cursor: args.after ?? null, first: args.first ?? null, + sort, }); }, viewerCanViewAppDeployments: async (target, _arg, { injector }) => { diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 23f35a33de5..4ab71a67cdb 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4997,6 +4997,37 @@ export function decodeCreatedAtAndUUIDIdBasedCursor(cursor: string) { }; } +export function encodeAppDeploymentSortCursor(cursor: { + sortField: string; + sortValue: string | null; + id: string; +}) { + const value = cursor.sortValue ?? ''; + return Buffer.from(`${cursor.sortField}:${value}|${cursor.id}`).toString('base64'); +} + +export function decodeAppDeploymentSortCursor(cursor: string) { + const decoded = Buffer.from(cursor, 'base64').toString('utf8'); + const pipeIndex = decoded.lastIndexOf('|'); + if (pipeIndex === -1) { + throw new Error('Invalid cursor'); + } + const id = decoded.slice(pipeIndex + 1); + const fieldAndValue = decoded.slice(0, pipeIndex); + const colonIndex = fieldAndValue.indexOf(':'); + if (colonIndex === -1) { + throw new Error('Invalid cursor'); + } + const sortField = fieldAndValue.slice(0, colonIndex); + const sortValue = fieldAndValue.slice(colonIndex + 1) || null; + + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) { + throw new Error('Invalid cursor'); + } + + return { sortField, sortValue, id }; +} + export function encodeHashBasedCursor(cursor: { id: string }) { return Buffer.from(cursor.id).toString('base64'); } diff --git a/packages/web/app/src/components/v2/sortable.tsx b/packages/web/app/src/components/v2/sortable.tsx index cb69d68a9ba..0b9cfabdaa4 100644 --- a/packages/web/app/src/components/v2/sortable.tsx +++ b/packages/web/app/src/components/v2/sortable.tsx @@ -6,20 +6,13 @@ import { SortDirection } from '@tanstack/react-table'; export function Sortable(props: { children: ReactNode; sortOrder: SortDirection | false; - /** - * Whether another column is sorted in addition to this one. - * It's used to show a different tooltip when sorting by multiple columns. - */ - otherColumnSorted?: boolean; onClick?: ComponentProps<'button'>['onClick']; }): ReactElement { const tooltipText = props.sortOrder === false - ? 'Click to sort descending' + props.otherColumnSorted - ? ' (hold shift to sort by multiple columns)' - : '' + ? 'Click to sort descending' : { - asc: 'Click to cancel sorting', + asc: 'Click to sort descending', desc: 'Click to sort ascending', }[props.sortOrder]; @@ -36,9 +29,9 @@ export function Sortable(props: { >
{props.children}
- {props.sortOrder === 'asc' ? : null} + {props.sortOrder === 'asc' ? : null} {props.sortOrder === 'desc' ? ( - + ) : null} diff --git a/packages/web/app/src/pages/target-apps.tsx b/packages/web/app/src/pages/target-apps.tsx index 01f90bb00e5..518c92b7c06 100644 --- a/packages/web/app/src/pages/target-apps.tsx +++ b/packages/web/app/src/pages/target-apps.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { format } from 'date-fns'; import { LoaderCircleIcon } from 'lucide-react'; import { useClient, useQuery } from 'urql'; +import { z } from 'zod'; import { Page, TargetLayout } from '@/components/layouts/target'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -22,11 +23,19 @@ import { TableRow, } from '@/components/ui/table'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { TimeAgo } from '@/components/v2'; +import { Sortable, TimeAgo } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; +import { AppDeploymentsSortField, SortDirectionType } from '@/gql/graphql'; import { useRedirect } from '@/lib/access/common'; import { TooltipProvider } from '@radix-ui/react-tooltip'; -import { Link } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; + +export const TargetAppsSortSchema = z.object({ + field: z.enum(['CREATED_AT', 'ACTIVATED_AT', 'LAST_USED']), + direction: z.enum(['ASC', 'DESC']), +}); + +export type SortState = z.output; const AppTableRow_AppDeploymentFragment = graphql(` fragment AppTableRow_AppDeploymentFragment on AppDeployment { @@ -48,6 +57,7 @@ const TargetAppsViewQuery = graphql(` $projectSlug: String! $targetSlug: String! $after: String + $sort: AppDeploymentsSortInput ) { organization: organizationBySlug(organizationSlug: $organizationSlug) { id @@ -71,7 +81,7 @@ const TargetAppsViewQuery = graphql(` type } viewerCanViewAppDeployments - appDeployments(first: 20, after: $after) { + appDeployments(first: 20, after: $after, sort: $sort) { pageInfo { hasNextPage endCursor @@ -93,6 +103,7 @@ const TargetAppsViewFetchMoreQuery = graphql(` $projectSlug: String! $targetSlug: String! $after: String! + $sort: AppDeploymentsSortInput ) { target( reference: { @@ -104,7 +115,7 @@ const TargetAppsViewFetchMoreQuery = graphql(` } ) { id - appDeployments(first: 20, after: $after) { + appDeployments(first: 20, after: $after, sort: $sort) { pageInfo { hasNextPage endCursor @@ -205,18 +216,42 @@ function TargetAppsView(props: { organizationSlug: string; projectSlug: string; targetSlug: string; + sorting: SortState; }) { + const navigate = useNavigate(); + const sortVariable = { + field: props.sorting.field as AppDeploymentsSortField, + direction: props.sorting.direction === 'DESC' ? SortDirectionType.Desc : SortDirectionType.Asc, + }; + const [data] = useQuery({ query: TargetAppsViewQuery, variables: { organizationSlug: props.organizationSlug, projectSlug: props.projectSlug, targetSlug: props.targetSlug, + sort: sortVariable, }, }); const client = useClient(); const [isLoadingMore, setIsLoadingMore] = useState(false); + function handleSortClick(field: SortState['field']) { + const newDirection = + props.sorting.field === field && props.sorting.direction === 'DESC' ? 'ASC' : 'DESC'; + void navigate({ + search: (prev: Record) => ({ + ...prev, + sort: { field, direction: newDirection }, + }), + }); + } + + function getSortOrder(field: SortState['field']): 'asc' | 'desc' | false { + if (props.sorting.field !== field) return false; + return props.sorting.direction === 'ASC' ? 'asc' : 'desc'; + } + const project = data.data?.target; useRedirect({ @@ -292,26 +327,47 @@ function TargetAppsView(props: { ) : (
- +
- App@Version - Status - - Amount of Documents + App@Version + + Status + + + Documents + + + handleSortClick('CREATED_AT')} + > + Created + + + + handleSortClick('ACTIVATED_AT')} + > + Activated + - Created - Activated - - - - Last used - - Last time a request was sent for this app. Requires usage reporting being - set up. - - - + + handleSortClick('LAST_USED')} + > + + + Last used + + Last time a request was sent for this app. Requires usage reporting + being set up. + + + + @@ -346,6 +402,7 @@ function TargetAppsView(props: { projectSlug: props.projectSlug, targetSlug: props.targetSlug, after: data?.data?.target?.appDeployments?.pageInfo?.endCursor, + sort: sortVariable, }) .toPromise() .finally(() => { @@ -373,6 +430,7 @@ export function TargetAppsPage(props: { organizationSlug: string; projectSlug: string; targetSlug: string; + sorting: SortState; }) { return ( <> @@ -387,6 +445,7 @@ export function TargetAppsPage(props: { organizationSlug={props.organizationSlug} projectSlug={props.projectSlug} targetSlug={props.targetSlug} + sorting={props.sorting} /> diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index b71d3d03230..0bf84c321d9 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -64,7 +64,7 @@ import { ProjectAlertsPage } from './pages/project-alerts'; import { ProjectSettingsPage, ProjectSettingsPageEnum } from './pages/project-settings'; import { TargetPage } from './pages/target'; import { TargetAppVersionPage } from './pages/target-app-version'; -import { TargetAppsPage } from './pages/target-apps'; +import { TargetAppsPage, TargetAppsSortSchema, type SortState } from './pages/target-apps'; import { TargetChecksPage } from './pages/target-checks'; import { TargetChecksAffectedDeploymentsPage } from './pages/target-checks-affected-deployments'; import { TargetChecksSinglePage } from './pages/target-checks-single'; @@ -656,16 +656,28 @@ const targetLaboratoryRoute = createRoute({ }, }); +const TargetAppsRouteSearch = z.object({ + sort: TargetAppsSortSchema.optional(), +}); + const targetAppsRoute = createRoute({ getParentRoute: () => targetRoute, path: 'apps', + validateSearch: TargetAppsRouteSearch.parse, component: function TargetAppsRoute() { const { organizationSlug, projectSlug, targetSlug } = targetAppsRoute.useParams(); + const { + sort = { + field: 'ACTIVATED_AT', + direction: 'DESC', + } satisfies SortState, + } = targetAppsRoute.useSearch(); return ( ); }, From bcd6119a99cff6569498d08f51cd1d0e1d2aeb24 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 22:35:36 +0100 Subject: [PATCH 02/13] error handling --- .../providers/app-deployments.ts | 102 +++++++++++++----- 1 file changed, 74 insertions(+), 28 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index d4be6d5b497..e29bf5e26ec 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -786,7 +786,20 @@ export class AppDeployments { const sortField = args.sort?.field ?? 'CREATED_AT'; const sortDirection = args.sort?.direction ?? 'DESC'; - let cursor = args.cursor ? decodeAppDeploymentSortCursor(args.cursor) : null; + let cursor = null; + if (args.cursor) { + try { + cursor = decodeAppDeploymentSortCursor(args.cursor); + } catch (error) { + this.logger.error( + 'Failed to decode cursor for getPaginatedAppDeployments (targetId=%s, cursor=%s): %s', + args.targetId, + args.cursor, + error instanceof Error ? error.message : String(error), + ); + throw new Error('Invalid cursor format for getPaginatedAppDeployments.'); + } + } if (cursor && cursor.sortField !== sortField) { this.logger.debug( 'Cursor sort field mismatch (targetId=%s, cursorField=%s, requestedField=%s). Ignoring cursor.', @@ -878,7 +891,20 @@ export class AppDeployments { ); const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; const isDesc = args.direction === 'DESC'; - let cursor = args.cursor ? decodeAppDeploymentSortCursor(args.cursor) : null; + let cursor = null; + if (args.cursor) { + try { + cursor = decodeAppDeploymentSortCursor(args.cursor); + } catch (error) { + this.logger.error( + 'Failed to decode cursor for getPaginatedAppDeploymentsSortedByLastUsed (targetId=%s, cursor=%s): %s', + args.targetId, + args.cursor, + error instanceof Error ? error.message : String(error), + ); + throw new Error('Invalid cursor format for getPaginatedAppDeploymentsSortedByLastUsed.'); + } + } if (cursor && cursor.sortField !== 'LAST_USED') { this.logger.debug( 'Cursor sort field mismatch (targetId=%s, cursorField=%s, requestedField=LAST_USED). Ignoring cursor.', @@ -900,22 +926,32 @@ export class AppDeployments { : cSql`HAVING lastUsed >= ${cursor.sortValue}`; } - const chResult = await this.clickhouse.query({ - query: cSql` - SELECT - app_name AS appName, - app_version AS appVersion, - formatDateTimeInJodaSyntax(max(last_request), 'yyyy-MM-dd\\'T\\'HH:mm:ss.000000+00:00') AS lastUsed - FROM app_deployment_usage - WHERE target_id = ${args.targetId} - GROUP BY app_name, app_version - ${chCursorCondition} - ORDER BY lastUsed ${chDirSql} - LIMIT ${cSql.raw(String(limit + 1))} - `, - queryId: 'get-all-deployments-last-used-for-sorting', - timeout: 30_000, - }); + let chResult; + try { + chResult = await this.clickhouse.query({ + query: cSql` + SELECT + app_name AS appName, + app_version AS appVersion, + formatDateTimeInJodaSyntax(max(last_request), 'yyyy-MM-dd\\'T\\'HH:mm:ss.000000+00:00') AS lastUsed + FROM app_deployment_usage + WHERE target_id = ${args.targetId} + GROUP BY app_name, app_version + ${chCursorCondition} + ORDER BY lastUsed ${chDirSql} + LIMIT ${cSql.raw(String(limit + 1))} + `, + queryId: 'get-all-deployments-last-used-for-sorting', + timeout: 30_000, + }); + } catch (error) { + this.logger.error( + 'Failed to query deployment last-used from ClickHouse (targetId=%s): %s', + args.targetId, + error instanceof Error ? error.message : String(error), + ); + throw error; + } const chModel = z.array( z.object({ @@ -998,16 +1034,26 @@ export class AppDeployments { const candidateTuples = candidates.map( c => cSql`(${args.targetId}, ${c.name}, ${c.version})`, ); - const usageCheckResult = await this.clickhouse.query({ - query: cSql` - SELECT DISTINCT app_name, app_version - FROM app_deployment_usage - WHERE (target_id, app_name, app_version) - IN (${candidateTuples.reduce((a, b) => cSql`${a}, ${b}`)}) - `, - queryId: 'check-candidates-have-usage', - timeout: 10_000, - }); + let usageCheckResult; + try { + usageCheckResult = await this.clickhouse.query({ + query: cSql` + SELECT DISTINCT app_name, app_version + FROM app_deployment_usage + WHERE (target_id, app_name, app_version) + IN (${candidateTuples.reduce((a, b) => cSql`${a}, ${b}`)}) + `, + queryId: 'check-candidates-have-usage', + timeout: 10_000, + }); + } catch (error) { + this.logger.error( + 'Failed to check candidate usage from ClickHouse (targetId=%s): %s', + args.targetId, + error instanceof Error ? error.message : String(error), + ); + throw error; + } const pairsWithUsage = new Set( z .array(z.object({ app_name: z.string(), app_version: z.string() })) From 62f2f57b61fd6e393c610f39b11a7a16e0f23f91 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 22:39:13 +0100 Subject: [PATCH 03/13] changes --- .../providers/app-deployments.ts | 18 ++++++++---------- packages/web/app/src/pages/target-apps.tsx | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index e29bf5e26ec..fb911bb9454 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -964,7 +964,8 @@ export class AppDeployments { } const usagePairsForPage = usageForPage.map(r => `${r.appName}:${r.appVersion}`); - let usageDeployments: Array = []; + const deploymentByPair = new Map(); + if (usagePairsForPage.length > 0) { const pgResult = await this.pool.query(sql` SELECT ${appDeploymentFields} @@ -972,12 +973,10 @@ export class AppDeployments { WHERE "target_id" = ${args.targetId} AND ("name" || ':' || "version") = ANY(${sql.array(usagePairsForPage, 'text')}) `); - usageDeployments = pgResult.rows.map(row => AppDeploymentModel.parse(row)); - } - - const deploymentByPair = new Map(); - for (const d of usageDeployments) { - deploymentByPair.set(`${d.name}:${d.version}`, d); + for (const row of pgResult.rows) { + const d = AppDeploymentModel.parse(row); + deploymentByPair.set(`${d.name}:${d.version}`, d); + } } let pageItems: Array<{ node: z.infer; lastUsed: string | null }> = []; @@ -1016,7 +1015,6 @@ export class AppDeployments { ? sql`AND NOT (("name" || ':' || "version") = ANY(${sql.array(usagePairsForPage, 'text')}))` : sql``; - const batchSize = limit + 1; const fillResult = await this.pool.query(sql` SELECT ${appDeploymentFields} FROM "app_deployments" @@ -1024,11 +1022,11 @@ export class AppDeployments { ${excludeCurrentPage} ${fillCursorCondition} ORDER BY "id" ${dirSql} - LIMIT ${batchSize} + LIMIT ${limit + 1} `); const candidates = fillResult.rows.map(row => AppDeploymentModel.parse(row)); - fillHasMore = candidates.length === batchSize; + fillHasMore = candidates.length > limit; if (candidates.length > 0) { const candidateTuples = candidates.map( diff --git a/packages/web/app/src/pages/target-apps.tsx b/packages/web/app/src/pages/target-apps.tsx index 518c92b7c06..92aa3b12f7b 100644 --- a/packages/web/app/src/pages/target-apps.tsx +++ b/packages/web/app/src/pages/target-apps.tsx @@ -221,7 +221,7 @@ function TargetAppsView(props: { const navigate = useNavigate(); const sortVariable = { field: props.sorting.field as AppDeploymentsSortField, - direction: props.sorting.direction === 'DESC' ? SortDirectionType.Desc : SortDirectionType.Asc, + direction: props.sorting.direction as SortDirectionType, }; const [data] = useQuery({ From 41bf13e8a91300005236978ff456c8c2874ee1ca Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 22:42:40 +0100 Subject: [PATCH 04/13] fixes --- .../modules/app-deployments/providers/app-deployments.ts | 1 + packages/services/storage/src/index.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index fb911bb9454..193df640fb3 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -994,6 +994,7 @@ export class AppDeployments { } else { pageItems = pageItems.filter(item => { const cmp = item.lastUsed!.localeCompare(cursor.sortValue!); + if (cmp === 0) return item.node.id !== cursor.id; return isDesc ? cmp < 0 : cmp > 0; }); } diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 4ab71a67cdb..a9df27f600e 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -5019,7 +5019,15 @@ export function decodeAppDeploymentSortCursor(cursor: string) { throw new Error('Invalid cursor'); } const sortField = fieldAndValue.slice(0, colonIndex); + const validSortFields = ['CREATED_AT', 'ACTIVATED_AT', 'LAST_USED']; + if (!validSortFields.includes(sortField)) { + throw new Error('Invalid cursor: unknown sort field'); + } + const sortValue = fieldAndValue.slice(colonIndex + 1) || null; + if (sortValue !== null && isNaN(new Date(sortValue).getTime())) { + throw new Error('Invalid cursor: sortValue is not a valid date'); + } if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) { throw new Error('Invalid cursor'); From da4ff0c4ca78e469ecf1367781a3c9b5dbd6c78e Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 22:43:44 +0100 Subject: [PATCH 05/13] do gemini suggestion --- .../providers/app-deployments-manager.ts | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts index 5200d3dc20c..e36b14aafc0 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts @@ -231,20 +231,32 @@ export class AppDeploymentsManager { } | null; }, ) { - if (args.sort?.field === 'LAST_USED') { - return await this.appDeployments.getPaginatedAppDeploymentsSortedByLastUsed({ - targetId: target.id, - cursor: args.cursor, - first: args.first, - direction: args.sort.direction, - }); + const { sort, cursor, first } = args; + if (sort) { + switch (sort.field) { + case 'LAST_USED': + return this.appDeployments.getPaginatedAppDeploymentsSortedByLastUsed({ + targetId: target.id, + cursor, + first, + direction: sort.direction, + }); + case 'CREATED_AT': + case 'ACTIVATED_AT': + return this.appDeployments.getPaginatedAppDeployments({ + targetId: target.id, + cursor, + first, + sort, + }); + } } - return await this.appDeployments.getPaginatedAppDeployments({ + return this.appDeployments.getPaginatedAppDeployments({ targetId: target.id, - cursor: args.cursor, - first: args.first, - sort: args.sort as { field: 'CREATED_AT' | 'ACTIVATED_AT'; direction: 'ASC' | 'DESC' } | null, + cursor, + first, + sort: null, }); } From 80a4307c7d34796b7679d69150ce7caac665f5db Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 22:51:30 +0100 Subject: [PATCH 06/13] pnpm prettier --- .../src/modules/app-deployments/providers/app-deployments.ts | 3 ++- packages/web/app/src/pages/target-apps.tsx | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index 193df640fb3..b1e616c4519 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -979,7 +979,8 @@ export class AppDeployments { } } - let pageItems: Array<{ node: z.infer; lastUsed: string | null }> = []; + let pageItems: Array<{ node: AppDeploymentRecord; lastUsed: string | null }> = + []; for (const usage of usageForPage) { const node = deploymentByPair.get(`${usage.appName}:${usage.appVersion}`); if (node) { diff --git a/packages/web/app/src/pages/target-apps.tsx b/packages/web/app/src/pages/target-apps.tsx index 92aa3b12f7b..e4affb8ed24 100644 --- a/packages/web/app/src/pages/target-apps.tsx +++ b/packages/web/app/src/pages/target-apps.tsx @@ -331,9 +331,7 @@ function TargetAppsView(props: { App@Version - - Status - + Status Documents From 11566c09eb2061be3131a6065a22d0d4721c3f9b Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 22:51:56 +0100 Subject: [PATCH 07/13] add changeset --- .changeset/long-lions-love.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/long-lions-love.md diff --git a/.changeset/long-lions-love.md b/.changeset/long-lions-love.md new file mode 100644 index 00000000000..d4e542b082f --- /dev/null +++ b/.changeset/long-lions-love.md @@ -0,0 +1,5 @@ +--- +'hive': patch +--- + +Add server-side sorting to app deployments table (Created, Activated, Last Used). From 24d361638030ab7470095bda6db21c97127e9d57 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 16 Feb 2026 23:08:09 +0100 Subject: [PATCH 08/13] add total count under table --- .../modules/app-deployments/module.graphql.ts | 1 + .../providers/app-deployments-manager.ts | 54 ++++++++++--------- .../providers/app-deployments.ts | 7 +++ packages/web/app/src/pages/target-apps.tsx | 9 +++- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/module.graphql.ts b/packages/services/api/src/modules/app-deployments/module.graphql.ts index 019cedeae16..4f19cc5a49d 100644 --- a/packages/services/api/src/modules/app-deployments/module.graphql.ts +++ b/packages/services/api/src/modules/app-deployments/module.graphql.ts @@ -80,6 +80,7 @@ export default gql` type AppDeploymentConnection { pageInfo: PageInfo! @tag(name: "public") edges: [AppDeploymentEdge!]! @tag(name: "public") + total: Int! @tag(name: "public") } type AppDeploymentEdge { diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts index e36b14aafc0..fd5cf8f48d1 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts @@ -232,32 +232,38 @@ export class AppDeploymentsManager { }, ) { const { sort, cursor, first } = args; - if (sort) { - switch (sort.field) { - case 'LAST_USED': - return this.appDeployments.getPaginatedAppDeploymentsSortedByLastUsed({ - targetId: target.id, - cursor, - first, - direction: sort.direction, - }); - case 'CREATED_AT': - case 'ACTIVATED_AT': - return this.appDeployments.getPaginatedAppDeployments({ - targetId: target.id, - cursor, - first, - sort, - }); - } + + let page; + switch (sort?.field) { + case 'LAST_USED': + page = await this.appDeployments.getPaginatedAppDeploymentsSortedByLastUsed({ + targetId: target.id, + cursor, + first, + direction: sort.direction, + }); + break; + case 'CREATED_AT': + case 'ACTIVATED_AT': + page = await this.appDeployments.getPaginatedAppDeployments({ + targetId: target.id, + cursor, + first, + sort: { field: sort.field, direction: sort.direction }, + }); + break; + default: + page = await this.appDeployments.getPaginatedAppDeployments({ + targetId: target.id, + cursor, + first, + sort: null, + }); + break; } - return this.appDeployments.getPaginatedAppDeployments({ - targetId: target.id, - cursor, - first, - sort: null, - }); + const total = await this.appDeployments.countAppDeployments(target.id); + return { ...page, total }; } async getActiveAppDeploymentsForTarget( diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index b1e616c4519..fb7efe34a12 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -770,6 +770,13 @@ export class AppDeployments { }; } + async countAppDeployments(targetId: string): Promise { + const result = await this.pool.oneFirst(sql` + SELECT count(*) FROM "app_deployments" WHERE "target_id" = ${targetId} + `); + return result; + } + async getPaginatedAppDeployments(args: { targetId: string; cursor: string | null; diff --git a/packages/web/app/src/pages/target-apps.tsx b/packages/web/app/src/pages/target-apps.tsx index e4affb8ed24..208034fbeb6 100644 --- a/packages/web/app/src/pages/target-apps.tsx +++ b/packages/web/app/src/pages/target-apps.tsx @@ -82,6 +82,7 @@ const TargetAppsViewQuery = graphql(` } viewerCanViewAppDeployments appDeployments(first: 20, after: $after, sort: $sort) { + total pageInfo { hasNextPage endCursor @@ -382,11 +383,15 @@ function TargetAppsView(props: {
-
+
+ + Showing {data.data?.target?.appDeployments?.edges.length ?? 0} of{' '} + {data.data?.target?.appDeployments?.total ?? 0} deployments +