mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat(app-deployments): add server-side sorting by created, activated, and last used (#7700)
This commit is contained in:
parent
f8aac8b98c
commit
d777e3252a
10 changed files with 511 additions and 67 deletions
5
.changeset/long-lions-love.md
Normal file
5
.changeset/long-lions-love.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'hive': patch
|
||||
---
|
||||
|
||||
Add server-side sorting to app deployments table (Created, Activated, Last Used).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue