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). 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..67d549b65a1 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!]! @@ -63,6 +80,10 @@ export default gql` type AppDeploymentConnection { pageInfo: PageInfo! @tag(name: "public") edges: [AppDeploymentEdge!]! @tag(name: "public") + """ + The total number of app deployments for this target. + """ + total: Int! @tag(name: "public") } type AppDeploymentEdge { @@ -108,7 +129,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..ccbd2f53630 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,13 +222,38 @@ export class AppDeploymentsManager { async getPaginatedAppDeploymentsForTarget( target: Target, - args: { cursor: string | null; first: number | null }, + args: { + cursor: string | null; + first: number | null; + sort: { + field: GraphQLSchema.AppDeploymentsSortField; + direction: GraphQLSchema.SortDirectionType; + } | null; + }, ) { - return await this.appDeployments.getPaginatedAppDeployments({ - targetId: target.id, - cursor: args.cursor, - first: args.first, - }); + const { sort, cursor, first } = args; + + const pagePromise = + sort?.field === 'LAST_USED' + ? this.appDeployments.getPaginatedAppDeploymentsSortedByLastUsed({ + targetId: target.id, + cursor, + first, + direction: sort.direction, + }) + : this.appDeployments.getPaginatedAppDeployments({ + targetId: target.id, + cursor, + first, + sort: sort ? { field: sort.field, direction: sort.direction } : null, + }); + + const [page, total] = await Promise.all([ + pagePromise, + this.appDeployments.countAppDeployments(target.id), + ]); + + return { ...page, total }; } async getActiveAppDeploymentsForTarget( @@ -243,12 +268,17 @@ export class AppDeploymentsManager { }; }, ) { - return await this.appDeployments.getActiveAppDeployments({ - targetId: target.id, - cursor: args.cursor, - first: args.first, - filter: args.filter, - }); + const [page, total] = await Promise.all([ + this.appDeployments.getActiveAppDeployments({ + targetId: target.id, + cursor: args.cursor, + first: args.first, + filter: args.filter, + }), + this.appDeployments.countAppDeployments(target.id), + ]); + + return { ...page, total }; } getDocumentCountForAppDeployment = batch(async args => { 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..2d0c8857bf9 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'; @@ -768,13 +770,76 @@ export class AppDeployments { }; } + async countAppDeployments(targetId: string): Promise { + return this.pool.oneFirst(sql` + SELECT count(*) FROM "app_deployments" WHERE "target_id" = ${targetId} + `); + } + async getPaginatedAppDeployments(args: { 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 = null; + if (args.cursor) { + try { + cursor = decodeAppDeploymentSortCursor(args.cursor); + } catch (error) { + this.logger.debug( + 'Failed to decode cursor for getPaginatedAppDeployments (targetId=%s, cursor=%s): %s', + args.targetId, + args.cursor, + error instanceof Error ? error.message : String(error), + ); + } + } + 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 +848,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 +882,220 @@ 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 = null; + if (args.cursor) { + try { + cursor = decodeAppDeploymentSortCursor(args.cursor); + } catch (error) { + this.logger.debug( + 'Failed to decode cursor for getPaginatedAppDeploymentsSortedByLastUsed (targetId=%s, cursor=%s): %s', + args.targetId, + args.cursor, + error instanceof Error ? error.message : String(error), + ); + } + } + 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}`; + } + + 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({ + appName: z.string(), + appVersion: z.string(), + lastUsed: z.string(), + }), + ); + usageForPage = chModel.parse(chResult.data); + } + + const usagePairsForPage = usageForPage.map(r => `${r.appName}:${r.appVersion}`); + const deploymentByPair = new Map(); + + 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')}) + `); + for (const row of pgResult.rows) { + const d = AppDeploymentModel.parse(row); + deploymentByPair.set(`${d.name}:${d.version}`, d); + } + } + + let pageItems: Array<{ node: AppDeploymentRecord; 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!); + if (cmp === 0) return item.node.id !== cursor.id; + 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 fillResult = await this.pool.query(sql` + SELECT ${appDeploymentFields} + FROM "app_deployments" + WHERE "target_id" = ${args.targetId} + ${excludeCurrentPage} + ${fillCursorCondition} + ORDER BY "id" ${dirSql} + LIMIT ${limit + 1} + `); + + const candidates = fillResult.rows.map(row => AppDeploymentModel.parse(row)); + fillHasMore = candidates.length > limit; + + if (candidates.length > 0) { + const candidateTuples = candidates.map( + c => cSql`(${args.targetId}, ${c.name}, ${c.version})`, + ); + 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() })) + .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..44abaab1243 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, + direction: args.sort.direction, + } + : 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..f0339c91112 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4997,6 +4997,45 @@ 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 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 && Number.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'); + } + + 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/target/insights/List.tsx b/packages/web/app/src/components/target/insights/List.tsx index d2790cc4246..90a99bff1fd 100644 --- a/packages/web/app/src/components/target/insights/List.tsx +++ b/packages/web/app/src/components/target/insights/List.tsx @@ -231,8 +231,6 @@ function OperationsTable({ const { headers } = tableInstance.getHeaderGroups()[0]; - const sortedColumnsById = tableInstance.getState().sorting.map(s => s.id); - return (
id !== header.id)} > {name} 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..7b80e37f7eb 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,8 @@ const TargetAppsViewQuery = graphql(` type } viewerCanViewAppDeployments - appDeployments(first: 20, after: $after) { + appDeployments(first: 20, after: $after, sort: $sort) { + total pageInfo { hasNextPage endCursor @@ -93,6 +104,7 @@ const TargetAppsViewFetchMoreQuery = graphql(` $projectSlug: String! $targetSlug: String! $after: String! + $sort: AppDeploymentsSortInput ) { target( reference: { @@ -104,7 +116,8 @@ const TargetAppsViewFetchMoreQuery = graphql(` } ) { id - appDeployments(first: 20, after: $after) { + appDeployments(first: 20, after: $after, sort: $sort) { + total pageInfo { hasNextPage endCursor @@ -205,18 +218,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 as SortDirectionType, + }; + 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 +329,45 @@ 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. + + + + @@ -328,11 +384,15 @@ function TargetAppsView(props: {
-
+
+ + Showing {data.data?.target?.appDeployments?.edges.length ?? 0} of{' '} + {data.data?.target?.appDeployments?.total ?? 0} deployments +