feat(app-deployments): add server-side sorting by created, activated, and last used (#7700)

This commit is contained in:
Adam Benhassen 2026-02-27 17:29:56 +02:00 committed by GitHub
parent f8aac8b98c
commit d777e3252a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 511 additions and 67 deletions

View file

@ -0,0 +1,5 @@
---
'hive': patch
---
Add server-side sorting to app deployments table (Created, Activated, Last Used).

View file

@ -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.

View file

@ -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<AppDeploymentRecord, number>(async args => {

View file

@ -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<number> {
return this.pool.oneFirst<number>(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<unknown>(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<unknown>(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<unknown>(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;

View file

@ -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 }) => {

View file

@ -4998,6 +4998,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');
}

View file

@ -229,8 +229,6 @@ function OperationsTable({
const { headers } = tableInstance.getHeaderGroups()[0];
const sortedColumnsById = tableInstance.getState().sorting.map(s => s.id);
return (
<div
className={clsx(
@ -258,7 +256,6 @@ function OperationsTable({
<Sortable
sortOrder={header.column.getIsSorted()}
onClick={header.column.getToggleSortingHandler()}
otherColumnSorted={sortedColumnsById.some(id => id !== header.id)}
>
{name}
</Sortable>

View file

@ -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: {
>
<div>{props.children}</div>
{props.sortOrder === 'asc' ? <TriangleUpIcon className="text-neutral-2 ml-2" /> : null}
{props.sortOrder === 'asc' ? <TriangleUpIcon className="text-neutral-10 ml-2" /> : null}
{props.sortOrder === 'desc' ? (
<TriangleUpIcon className="text-neutral-2 ml-2 rotate-180" />
<TriangleUpIcon className="text-neutral-10 ml-2 rotate-180" />
) : null}
</button>
</TooltipTrigger>

View file

@ -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<typeof TargetAppsSortSchema>;
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<string, unknown>) => ({
...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: {
) : (
<div>
<div className="rounded-md border">
<Table>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="hidden sm:table-cell">App@Version</TableHead>
<TableHead className="hidden text-center sm:table-cell">Status</TableHead>
<TableHead className="hidden text-center sm:table-cell">
Amount of Documents
<TableHead className="hidden w-[30%] sm:table-cell">App@Version</TableHead>
<TableHead className="hidden w-[15%] text-center sm:table-cell">Status</TableHead>
<TableHead className="hidden w-[5%] text-center sm:table-cell">
Documents
</TableHead>
<TableHead className="hidden text-center sm:table-cell">Created</TableHead>
<TableHead className="hidden text-center sm:table-cell">Activated</TableHead>
<TableHead className="hidden text-end sm:table-cell">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="cursor-help">Last used</TooltipTrigger>
<TooltipContent className="max-w-64 text-start">
Last time a request was sent for this app. Requires usage reporting being
set up.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TableHead className="hidden w-[10%] text-center sm:table-cell">
<Sortable
sortOrder={getSortOrder('CREATED_AT')}
onClick={() => handleSortClick('CREATED_AT')}
>
Created
</Sortable>
</TableHead>
<TableHead className="hidden w-[10%] text-center sm:table-cell">
<Sortable
sortOrder={getSortOrder('ACTIVATED_AT')}
onClick={() => handleSortClick('ACTIVATED_AT')}
>
Activated
</Sortable>
</TableHead>
<TableHead className="hidden w-[7%] text-end sm:table-cell">
<Sortable
sortOrder={getSortOrder('LAST_USED')}
onClick={() => handleSortClick('LAST_USED')}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="cursor-help">Last used</TooltipTrigger>
<TooltipContent className="max-w-64 text-start">
Last time a request was sent for this app. Requires usage reporting
being set up.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Sortable>
</TableHead>
</TableRow>
</TableHeader>
@ -328,11 +384,15 @@ function TargetAppsView(props: {
</TableBody>
</Table>
</div>
<div className="mt-2">
<div className="mt-2 flex items-center justify-between">
<span className="text-xs">
Showing {data.data?.target?.appDeployments?.edges.length ?? 0} of{' '}
{data.data?.target?.appDeployments?.total ?? 0} deployments
</span>
<Button
size="sm"
variant="outline"
className="ml-auto mr-0 flex"
className="flex"
disabled={!data?.data?.target?.appDeployments?.pageInfo?.hasNextPage || isLoadingMore}
onClick={() => {
if (
@ -346,6 +406,7 @@ function TargetAppsView(props: {
projectSlug: props.projectSlug,
targetSlug: props.targetSlug,
after: data?.data?.target?.appDeployments?.pageInfo?.endCursor,
sort: sortVariable,
})
.toPromise()
.finally(() => {
@ -373,6 +434,7 @@ export function TargetAppsPage(props: {
organizationSlug: string;
projectSlug: string;
targetSlug: string;
sorting: SortState;
}) {
return (
<>
@ -387,6 +449,7 @@ export function TargetAppsPage(props: {
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
targetSlug={props.targetSlug}
sorting={props.sorting}
/>
</TargetLayout>
</>

View file

@ -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';
@ -657,16 +657,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 (
<TargetAppsPage
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
sorting={sort}
/>
);
},