diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 830ff976a37..0e4380af7c3 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -4,7 +4,7 @@ import LRU from 'lru-cache'; import { traceFn } from '@hive/service-common'; import type { DateRange } from '../../../shared/entities'; import type { Listify, Optional } from '../../../shared/helpers'; -import { cache } from '../../../shared/helpers'; +import { batchBy, cache } from '../../../shared/helpers'; import { Session } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import type { @@ -52,6 +52,27 @@ interface ReadFieldStatsOutput { percentage: number; } +export type DurationAndCountOverTime = Array<{ + date: number; + total: number; + totalOk: number; + duration: { + avg: number; + p75: number; + p90: number; + p95: number; + p99: number; + }; +}>; + +const emptyDurationMetrics = { + avg: 0, + p75: 0, + p90: 0, + p95: 0, + p99: 0, +}; + /** * Responsible for auth checks. * Talks to Storage. @@ -311,6 +332,66 @@ export class OperationsManager { }); } + async countRequestsByTargetIds({ + organizationId: organization, + projectId: project, + targetIds, + period, + }: { + targetIds: readonly string[]; + period: DateRange; + } & ProjectSelector): Promise> { + this.logger.info( + 'Counting requests by target (period=%o, project=%s, targets=%s)', + period, + project, + targetIds.join(';'), + ); + + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: organization, + params: { + organizationId: organization, + projectId: project, + }, + }); + + return this.reader.countRequestsByTarget({ + targets: targetIds, + period, + }); + } + + async countRequestsByTargetIdsOfOrganization({ + organizationId: organization, + targetIds, + period, + }: { + targetIds: readonly string[]; + period: DateRange; + } & OrganizationSelector): Promise> { + this.logger.info( + 'Counting requests by target for organization (period=%o, organization=%s, targets=%s)', + period, + organization, + targetIds.join(';'), + ); + + await this.session.assertPerformAction({ + action: 'organization:describe', + organizationId: organization, + params: { + organizationId: organization, + }, + }); + + return this.reader.countRequestsByTarget({ + targets: targetIds, + period, + }); + } + async countRequestsOfProject({ organizationId: organization, projectId: project, @@ -649,6 +730,58 @@ export class OperationsManager { }); } + readDurationAndCountOverTime = batchBy< + { + period: DateRange; + resolution: number; + operations?: readonly string[]; + clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; + } & TargetSelector, + DurationAndCountOverTime + >( + selector => + JSON.stringify({ + organizationId: selector.organizationId, + projectId: selector.projectId, + period: selector.period, + resolution: selector.resolution, + operations: selector.operations, + clients: selector.clients, + clientVersionFilters: selector.clientVersionFilters, + excludeOperations: selector.excludeOperations, + excludeClientVersionFilters: selector.excludeClientVersionFilters, + }), + async selectors => { + const selector = selectors[0]; + const targetIds = Array.from(new Set(selectors.map(selector => selector.targetId))); + + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: selector.organizationId, + params: { + organizationId: selector.organizationId, + projectId: selector.projectId, + }, + }); + + const results = await this.reader.durationAndCountOverTimeOfTargets({ + targets: targetIds, + period: selector.period, + resolution: selector.resolution, + operations: selector.operations, + clients: selector.clients, + clientVersionFilters: selector.clientVersionFilters, + excludeOperations: selector.excludeOperations, + excludeClientVersionFilters: selector.excludeClientVersionFilters, + }); + + return selectors.map(selector => Promise.resolve(results.get(selector.targetId) ?? [])); + }, + ); + async readFailuresOverTime({ period, resolution, @@ -743,44 +876,60 @@ export class OperationsManager { }); } - async readGeneralDurationPercentiles({ - period, - organizationId: organization, - projectId: project, - targetId: target, - operations, - clients, - clientVersionFilters, - excludeOperations, - excludeClientVersionFilters, - }: { - period: DateRange; - operations?: readonly string[]; - clients?: readonly string[]; - clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; - excludeOperations?: boolean; - excludeClientVersionFilters?: boolean; - } & TargetSelector) { - this.logger.info('Reading overall duration percentiles (period=%o, target=%s)', period, target); - await this.session.assertPerformAction({ - action: 'project:describe', - organizationId: organization, - params: { - organizationId: organization, - projectId: project, - }, - }); + readGeneralDurationPercentiles = batchBy< + { + period: DateRange; + operations?: readonly string[]; + clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; + } & TargetSelector, + typeof emptyDurationMetrics + >( + selector => + JSON.stringify({ + organizationId: selector.organizationId, + projectId: selector.projectId, + period: selector.period, + operations: selector.operations, + clients: selector.clients, + clientVersionFilters: selector.clientVersionFilters, + excludeOperations: selector.excludeOperations, + excludeClientVersionFilters: selector.excludeClientVersionFilters, + }), + async selectors => { + const selector = selectors[0]; + + this.logger.info( + 'Reading overall duration percentiles (period=%o, targets=%s)', + selector.period, + selectors.map(selector => selector.targetId).join(';'), + ); + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: selector.organizationId, + params: { + organizationId: selector.organizationId, + projectId: selector.projectId, + }, + }); - return this.reader.generalDurationPercentiles({ - target, - period, - operations, - clients, - clientVersionFilters, - excludeOperations, - excludeClientVersionFilters, - }); - } + const durations = await this.reader.generalDurationPercentilesOfTargets({ + targets: selectors.map(selector => selector.targetId), + period: selector.period, + operations: selector.operations, + clients: selector.clients, + clientVersionFilters: selector.clientVersionFilters, + excludeOperations: selector.excludeOperations, + excludeClientVersionFilters: selector.excludeClientVersionFilters, + }); + + return selectors.map(selector => + Promise.resolve(durations.get(selector.targetId) ?? emptyDurationMetrics), + ); + }, + ); @cache<{ period: DateRange } & TargetSelector>(selector => JSON.stringify(selector)) async readDetailedDurationMetrics({ diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index 3319d63c766..e497741e356 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -23,7 +23,7 @@ export function formatDate(date: Date): string { return format(addMinutes(date, date.getTimezoneOffset()), 'yyyy-MM-dd HH:mm:ss'); } -function toUnixTimestamp(utcDate: string): any { +function toUnixTimestamp(utcDate: string): number { // 2024-04-26 11:00:00 const [date, time] = utcDate.split(' '); const [year, month, day] = date.split('-'); @@ -521,6 +521,60 @@ export class OperationsReader { }; } + async countRequestsByTarget({ + targets, + period, + }: { + targets: readonly string[]; + period: DateRange; + }): Promise> { + if (targets.length === 0) { + return new Map(); + } + + const query = this.pickAggregationByPeriod({ + timeout: { + minutely: 10_000, + hourly: 15_000, + daily: 30_000, + }, + queryId: aggregation => `count_operations_by_target_${aggregation}`, + query: aggregationTableName => sql` + SELECT + target, + sum(total)::int as total + FROM ${aggregationTableName('operations')} + ${this.createFilter({ target: targets, period })} + GROUP BY target + `, + period, + }); + + const result = await this.clickHouse + .query<{ + target: string; + total: number; + }>(query) + .then( + z.object({ + data: z.array( + z.object({ + target: z.string(), + total: z.number(), + }), + ), + }).parse, + ); + + const counts = new Map(); + + for (const row of result.data) { + counts.set(row.target, ensureNumber(row.total)); + } + + return counts; + } + async countFailures({ target, period, @@ -1781,7 +1835,7 @@ export class OperationsReader { excludeClientVersionFilters?: boolean; schemaCoordinate?: string; }) { - const results = await this.getDurationAndCountOverTime({ + const results = await this.durationAndCountOverTime({ target, period, resolution, @@ -1818,7 +1872,7 @@ export class OperationsReader { excludeOperations?: boolean; excludeClientVersionFilters?: boolean; }) { - const result = await this.getDurationAndCountOverTime({ + const result = await this.durationAndCountOverTime({ target, period, resolution, @@ -1859,7 +1913,7 @@ export class OperationsReader { duration: DurationMetrics; }> > { - return this.getDurationAndCountOverTime({ + return this.durationAndCountOverTime({ target, period, resolution, @@ -1909,6 +1963,69 @@ export class OperationsReader { return toDurationMetrics(result.data[0].percentiles, result.data[0].average); } + async generalDurationPercentilesOfTargets({ + targets, + period, + operations, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + }: { + targets: readonly string[]; + period: DateRange; + operations?: readonly string[]; + clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; + }): Promise> { + if (targets.length === 0) { + return new Map(); + } + + const result = await this.clickHouse + .query<{ + target: string; + percentiles: [number, number, number, number]; + average: number; + }>( + this.pickAggregationByPeriod({ + query: aggregationTableName => sql` + SELECT + target, + avgMerge(duration_avg) as average, + quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles + FROM ${aggregationTableName('operations')} + ${this.createFilter({ target: targets, period, operations, clients, clientVersionFilters, excludeOperations, excludeClientVersionFilters })} + GROUP BY target + `, + queryId: aggregation => `general_duration_percentiles_by_target_${aggregation}`, + timeout: 15_000, + period, + }), + ) + .then( + z.object({ + data: z.array( + z.object({ + target: z.string(), + percentiles: z.tuple([z.number(), z.number(), z.number(), z.number()]), + average: z.number(), + }), + ), + }).parse, + ); + + const collection = new Map(); + + for (const row of result.data) { + collection.set(row.target, toDurationMetrics(row.percentiles, row.average)); + } + + return collection; + } + async durationMetrics({ target, period, @@ -2003,7 +2120,7 @@ export class OperationsReader { return result.data.map(row => row.client_name); } - private async getDurationAndCountOverTime({ + async durationAndCountOverTime({ target, period, resolution, @@ -2109,6 +2226,161 @@ export class OperationsReader { }); } + async durationAndCountOverTimeOfTargets({ + targets, + period, + resolution, + operations, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + }: { + targets: readonly string[]; + period: DateRange; + resolution: number; + operations?: readonly string[]; + clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; + }) { + if (targets.length === 0) { + return new Map< + string, + Array<{ + date: number; + total: number; + totalOk: number; + duration: DurationMetrics; + }> + >(); + } + + const interval = calculateTimeWindow({ period, resolution }); + const roundedPeriod = { + from: toStartOfInterval(period.from, interval.value, interval.unit), + to: toEndOfInterval(period.to, interval.value, interval.unit), + }; + const startDateTimeFormatted = formatDate(roundedPeriod.from); + const bucketCount = Math.max( + 1, + Math.ceil( + (roundedPeriod.to.getTime() - roundedPeriod.from.getTime()) / (interval.seconds * 1000), + ), + ); + + const query = this.pickAggregationByPeriod({ + timeout: 15_000, + period, + resolution, + queryId: aggregation => `duration_and_count_over_time_by_target_${aggregation}`, + query: aggregationTableName => sql` + WITH + ${sql.array(targets, 'String')} as targetIds, + toDateTime(${startDateTimeFormatted}, 'UTC') as startDate, + toUInt32(${String(interval.seconds)}) as intervalSeconds + SELECT + grid.date as date, + ifNull(aggregated.average, 0) as average, + if(empty(aggregated.percentiles), [0, 0, 0, 0], aggregated.percentiles) as percentiles, + ifNull(aggregated.total, 0)::int as total, + ifNull(aggregated.totalOk, 0)::int as totalOk, + grid.target as target + FROM ( + SELECT + targets.target, + startDate + toIntervalSecond(buckets.number * intervalSeconds) as date + FROM ( + SELECT arrayJoin(targetIds) as target + ) as targets + CROSS JOIN numbers(toUInt64(${String(bucketCount)})) as buckets + ) as grid + LEFT JOIN ( + SELECT + toDateTime( + intDiv( + toUnixTimestamp(timestamp), + toUInt32(${String(interval.seconds)}) + ) * toUInt32(${String(interval.seconds)}) + ) as date, + avgMerge(duration_avg) as average, + quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles, + sum(total) as total, + sum(total_ok) as totalOk, + target + FROM ${aggregationTableName('operations')} + ${this.createFilter({ + target: targets, + period: roundedPeriod, + operations, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + })} + GROUP BY target, date + ) as aggregated + ON aggregated.target = grid.target AND aggregated.date = grid.date + ORDER BY grid.target, grid.date + `, + }); + + const result = await this.clickHouse + .query<{ + date: string; + target: string; + total: number; + totalOk: number; + average: number; + percentiles: [number, number, number, number]; + }>(query) + .then( + z.object({ + data: z.array( + z.object({ + date: z.string(), + target: z.string(), + total: z.number(), + totalOk: z.number(), + average: z.number(), + percentiles: z.tuple([z.number(), z.number(), z.number(), z.number()]), + }), + ), + }).parse, + ); + + const collection = new Map< + string, + Array<{ + date: number; + total: number; + totalOk: number; + duration: DurationMetrics; + }> + >(); + + for (const row of result.data) { + let targetRows = collection.get(row.target); + + if (!targetRows) { + targetRows = []; + collection.set(row.target, targetRows); + } + + targetRows.push({ + date: toUnixTimestamp(row.date), + total: ensureNumber(row.total), + totalOk: ensureNumber(row.totalOk), + duration: toDurationMetrics(row.percentiles, row.average), + }); + + collection.set(row.target, targetRows); + } + + return collection; + } + // Every call to this method is part of the batching logic. // The `batch` function works similar to the DataLoader concept. // It gathers all function calls within the same event loop, diff --git a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts index 663dc4bd34b..5e5df71ef31 100644 --- a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts @@ -165,18 +165,26 @@ export const OperationsStats: OperationsStatsResolvers = { { resolution }, { injector }, ) => { - return injector.get(OperationsManager).readRequestsOverTime({ - targetId: target, - projectId: project, - organizationId: organization, - period, - resolution, - operations: operationsFilter, - clients, - clientVersionFilters, - excludeOperations, - excludeClientVersionFilters, - }); + return injector + .get(OperationsManager) + .readDurationAndCountOverTime({ + targetId: target, + projectId: project, + organizationId: organization, + period, + resolution, + operations: operationsFilter, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + }) + .then(result => + result.map(row => ({ + date: row.date, + value: row.total, + })), + ); }, failuresOverTime: ( { @@ -193,18 +201,26 @@ export const OperationsStats: OperationsStatsResolvers = { { resolution }, { injector }, ) => { - return injector.get(OperationsManager).readFailuresOverTime({ - targetId: target, - projectId: project, - organizationId: organization, - period, - resolution, - operations: operationsFilter, - clients, - clientVersionFilters, - excludeOperations, - excludeClientVersionFilters, - }); + return injector + .get(OperationsManager) + .readDurationAndCountOverTime({ + targetId: target, + projectId: project, + organizationId: organization, + period, + resolution, + operations: operationsFilter, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + }) + .then(result => + result.map(row => ({ + date: row.date, + value: row.total - row.totalOk, + })), + ); }, durationOverTime: ( { @@ -221,18 +237,26 @@ export const OperationsStats: OperationsStatsResolvers = { { resolution }, { injector }, ) => { - return injector.get(OperationsManager).readDurationOverTime({ - targetId: target, - projectId: project, - organizationId: organization, - period, - resolution, - operations: operationsFilter, - clients, - clientVersionFilters, - excludeOperations, - excludeClientVersionFilters, - }); + return injector + .get(OperationsManager) + .readDurationAndCountOverTime({ + targetId: target, + projectId: project, + organizationId: organization, + period, + resolution, + operations: operationsFilter, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + }) + .then(result => + result.map(row => ({ + date: row.date, + duration: row.duration, + })), + ); }, clients: async ( { diff --git a/packages/services/api/src/modules/project/index.ts b/packages/services/api/src/modules/project/index.ts index ffd19fe1e73..ae0c8681189 100644 --- a/packages/services/api/src/modules/project/index.ts +++ b/packages/services/api/src/modules/project/index.ts @@ -1,5 +1,6 @@ import { createModule } from 'graphql-modules'; import { ProjectManager } from './providers/project-manager'; +import { ProjectStats } from './providers/project-stats'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -8,5 +9,5 @@ export const projectModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [ProjectManager], + providers: [ProjectManager, ProjectStats], }); diff --git a/packages/services/api/src/modules/project/module.graphql.ts b/packages/services/api/src/modules/project/module.graphql.ts index f322070f094..3a2c8da354a 100644 --- a/packages/services/api/src/modules/project/module.graphql.ts +++ b/packages/services/api/src/modules/project/module.graphql.ts @@ -83,8 +83,34 @@ export default gql` SINGLE @tag(name: "public") } + enum ProjectsSortFieldType { + NAME + CREATED_AT + REQUESTS + SCHEMA_VERSIONS + } + + enum ProjectsSortDirectionType { + ASC + DESC + } + + input ProjectsSortInput { + field: ProjectsSortFieldType! + direction: ProjectsSortDirectionType! + """ + Required when sorting by REQUESTS or SCHEMA_VERSIONS. + """ + period: DateRangeInput + } + extend type Organization { - projects: ProjectConnection! @tag(name: "public") + projects( + first: Int + after: String + search: String + sort: ProjectsSortInput + ): ProjectConnection! @tag(name: "public") viewerCanCreateProject: Boolean! projectBySlug(projectSlug: String!): Project } diff --git a/packages/services/api/src/modules/project/providers/project-manager.ts b/packages/services/api/src/modules/project/providers/project-manager.ts index 6f5539c4b93..b5c2c4ee380 100644 --- a/packages/services/api/src/modules/project/providers/project-manager.ts +++ b/packages/services/api/src/modules/project/providers/project-manager.ts @@ -1,17 +1,37 @@ import { Injectable, Scope } from 'graphql-modules'; import * as GraphQLSchema from 'packages/libraries/core/src/client/__generated__/types'; import { z } from 'zod'; -import type { ProjectReferenceInput } from '../../../__generated__/types'; +import { + decodeCreatedAtAndUUIDIdBasedCursor, + decodeProjectSlugIdBasedCursor, + encodeCreatedAtAndUUIDIdBasedCursor, + encodeProjectSlugIdBasedCursor, +} from '@hive/storage'; +import type { DateRangeInput, ProjectReferenceInput } from '../../../__generated__/types'; import type { Organization, Project, ProjectType, Target } from '../../../shared/entities'; -import { cache } from '../../../shared/helpers'; +import { HiveError } from '../../../shared/errors'; +import { cache, parseDateRangeInput } from '../../../shared/helpers'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; +import { OperationsManager } from '../../operations/providers/operations-manager'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; -import { OrganizationSelector, ProjectSelector, Storage } from '../../shared/providers/storage'; +import { + OrganizationSelector, + ProjectSelector, + ProjectsStorageSort, + Storage, +} from '../../shared/providers/storage'; import { TargetManager } from '../../target/providers/target-manager'; import { TokenStorage } from '../../token/providers/token-storage'; import { ProjectSlugModel } from '../validation'; +import { ProjectStats } from './project-stats'; + +type ProjectsSortArgs = { + field: 'NAME' | 'CREATED_AT' | 'REQUESTS' | 'SCHEMA_VERSIONS'; + direction: 'ASC' | 'DESC'; + period?: DateRangeInput | null; +}; const reservedSlugs = ['view', 'new']; @@ -42,6 +62,8 @@ export class ProjectManager { private auditLog: AuditLogRecorder, private idTranslator: IdTranslator, private targetManager: TargetManager, + private operationsManager: OperationsManager, + private projectStats: ProjectStats, ) { this.logger = logger.child({ source: 'ProjectManager' }); } @@ -278,6 +300,300 @@ export class ProjectManager { return filteredProjects; } + async getPaginatedProjectsForOrganization( + organization: Organization, + args: { + first: number | null; + after: string | null; + search: string | null; + sort: ProjectsSortArgs | null; + }, + ) { + const sortField = args.sort?.field ?? 'CREATED_AT'; + + if (sortField === 'REQUESTS' || sortField === 'SCHEMA_VERSIONS') { + return this.getPaginatedProjectsSortedByMetric(organization, args); + } + + const storageSort = this.toStorageSort(args.sort); + const limit = args.first ? Math.min(args.first, 50) : null; + + if (limit === null) { + const projects = await this.storage.getProjects({ + organizationId: organization.id, + search: args.search, + sort: storageSort, + }); + const authorized = await this.filterAuthorizedProjects(organization, projects); + const nodes = authorized.slice( + this.resolveCursorStartIndex(authorized, args.after, storageSort), + ); + + return this.toProjectConnection(nodes, storageSort, { + hasNextPage: false, + hasPreviousPage: args.after !== null, + }); + } + + const authorized: Project[] = []; + let dbCursor = args.after; + let dbHasMore = true; + + while (authorized.length <= limit && dbHasMore) { + const batch = await this.storage.getPaginatedProjects({ + organizationId: organization.id, + first: limit + 1, + after: dbCursor, + search: args.search, + sort: storageSort, + }); + + for (const { node: project } of batch.edges) { + if ( + await this.session.canPerformAction({ + action: 'project:describe', + organizationId: organization.id, + params: { + organizationId: organization.id, + projectId: project.id, + }, + }) + ) { + authorized.push(project); + } + + if (authorized.length > limit) { + break; + } + } + + dbHasMore = batch.pageInfo.hasNextPage; + dbCursor = batch.pageInfo.endCursor; + + if (batch.edges.length === 0) { + break; + } + } + + return this.toProjectConnection(authorized.slice(0, limit), storageSort, { + hasNextPage: authorized.length > limit, + hasPreviousPage: args.after !== null, + }); + } + + private async getPaginatedProjectsSortedByMetric( + organization: Organization, + args: { + first: number | null; + after: string | null; + search: string | null; + sort: ProjectsSortArgs | null; + }, + ) { + if (!args.sort?.period) { + throw new HiveError( + 'period is required when sorting projects by REQUESTS or SCHEMA_VERSIONS', + ); + } + + const period = parseDateRangeInput(args.sort.period); + const limit = args.first ? Math.min(args.first, 50) : null; + const direction = args.sort.direction ?? 'DESC'; + const multiplier = direction === 'ASC' ? 1 : -1; + + const projects = await this.storage.getProjects({ + organizationId: organization.id, + search: args.search, + }); + const authorized = await this.filterAuthorizedProjects(organization, projects); + + const withMetrics = + args.sort.field === 'REQUESTS' + ? await this.projectStats + .getTargetIdsByProjectIds({ + organizationId: organization.id, + projectIds: authorized.map(project => project.id), + }) + .then(async targetIdsByProjectId => { + const countsByTargetId = + await this.operationsManager.countRequestsByTargetIdsOfOrganization({ + organizationId: organization.id, + targetIds: Array.from(targetIdsByProjectId.values()).flat(), + period, + }); + + return authorized.map(project => ({ + project, + value: (targetIdsByProjectId.get(project.id) ?? []).reduce( + (total, targetId) => total + (countsByTargetId.get(targetId) ?? 0), + 0, + ), + })); + }) + : await this.projectStats + .countSchemaVersionsByProjectIds({ + organizationId: organization.id, + projectIds: authorized.map(project => project.id), + period, + }) + .then(counts => + authorized.map(project => ({ + project, + value: counts.get(project.id) ?? 0, + })), + ); + + withMetrics.sort((left, right) => { + const diff = (left.value - right.value) * multiplier; + + if (diff !== 0) { + return diff; + } + + return left.project.slug.localeCompare(right.project.slug); + }); + + const sorted = withMetrics.map(entry => entry.project); + let startIndex = 0; + + if (args.after) { + const { id } = decodeCreatedAtAndUUIDIdBasedCursor(args.after); + const cursorIndex = sorted.findIndex(project => project.id === id); + + if (cursorIndex !== -1) { + startIndex = cursorIndex + 1; + } + } + + if (limit === null) { + return this.toProjectConnection(sorted.slice(startIndex), null, { + hasNextPage: false, + hasPreviousPage: args.after !== null, + }); + } + + const page = sorted.slice(startIndex, startIndex + limit + 1); + + return this.toProjectConnection(page.slice(0, limit), null, { + hasNextPage: page.length > limit, + hasPreviousPage: args.after !== null, + }); + } + + private async filterAuthorizedProjects(organization: Organization, projects: Project[]) { + const filteredProjects: Project[] = []; + + for (const project of projects) { + if ( + await this.session.canPerformAction({ + action: 'project:describe', + organizationId: organization.id, + params: { + organizationId: organization.id, + projectId: project.id, + }, + }) + ) { + filteredProjects.push(project); + } + } + + return filteredProjects; + } + + private resolveCursorStartIndex( + projects: Project[], + after: string | null, + sort: ProjectsStorageSort | null, + ) { + if (!after) { + return 0; + } + + const sortConfig = sort ?? { field: 'CREATED_AT', direction: 'DESC' }; + const isDesc = sortConfig.direction === 'DESC'; + const cursorIndex = projects.findIndex(project => { + if (sortConfig.field === 'NAME') { + const cursor = decodeProjectSlugIdBasedCursor(after); + + return project.slug === cursor.slug && project.id === cursor.id; + } + + const cursor = decodeCreatedAtAndUUIDIdBasedCursor(after); + + return project.createdAt === cursor.createdAt && project.id === cursor.id; + }); + + if (cursorIndex !== -1) { + return cursorIndex + 1; + } + + if (sortConfig.field === 'NAME') { + const cursor = decodeProjectSlugIdBasedCursor(after); + const startIndex = projects.findIndex(project => + isDesc + ? project.slug < cursor.slug || (project.slug === cursor.slug && project.id < cursor.id) + : project.slug > cursor.slug || (project.slug === cursor.slug && project.id > cursor.id), + ); + + return startIndex === -1 ? projects.length : startIndex; + } + + const cursor = decodeCreatedAtAndUUIDIdBasedCursor(after); + const startIndex = projects.findIndex(project => + isDesc + ? project.createdAt < cursor.createdAt || + (project.createdAt === cursor.createdAt && project.id < cursor.id) + : project.createdAt > cursor.createdAt || + (project.createdAt === cursor.createdAt && project.id > cursor.id), + ); + + return startIndex === -1 ? projects.length : startIndex; + } + + private toStorageSort(sort: ProjectsSortArgs | null): ProjectsStorageSort | null { + if (!sort) { + return null; + } + + return { + field: sort.field === 'NAME' ? 'NAME' : 'CREATED_AT', + direction: sort.direction, + }; + } + + private toProjectConnection( + nodes: Project[], + sort: ProjectsStorageSort | null, + pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }, + ) { + const sortConfig = sort ?? { field: 'CREATED_AT', direction: 'DESC' }; + const edges = nodes.map(node => ({ + node, + get cursor() { + if (sortConfig.field === 'NAME') { + return encodeProjectSlugIdBasedCursor({ slug: node.slug, id: node.id }); + } + + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + })); + + return { + edges, + pageInfo: { + hasNextPage: pageInfo.hasNextPage, + hasPreviousPage: pageInfo.hasPreviousPage, + get endCursor() { + return edges.at(-1)?.cursor ?? ''; + }, + get startCursor() { + return edges.at(0)?.cursor ?? ''; + }, + }, + }; + } + async updateSlug(input: { project: GraphQLSchema.ProjectReferenceInput; slug: string }): Promise< | { ok: true; diff --git a/packages/services/api/src/modules/project/providers/project-stats.ts b/packages/services/api/src/modules/project/providers/project-stats.ts new file mode 100644 index 00000000000..2372648eab9 --- /dev/null +++ b/packages/services/api/src/modules/project/providers/project-stats.ts @@ -0,0 +1,103 @@ +import { Injectable, Scope } from 'graphql-modules'; +import { z } from 'zod'; +import { PostgresDatabasePool, psql } from '@hive/postgres'; +import type { DateRange } from '../../../shared/entities'; + +@Injectable({ + scope: Scope.Operation, + global: true, +}) +export class ProjectStats { + constructor(private pool: PostgresDatabasePool) {} + + async getTargetIdsByProjectIds(args: { + organizationId: string; + projectIds: readonly string[]; + }): Promise> { + const result = new Map(); + + for (const projectId of args.projectIds) { + result.set(projectId, []); + } + + if (args.projectIds.length === 0) { + return result; + } + + const rows = await this.pool + .any( + psql`/* ProjectStats.getTargetIdsByProjectIds */ + SELECT + t.id as id, + t.project_id as project_id + FROM targets as t + LEFT JOIN projects as p ON (p.id = t.project_id) + WHERE + p.org_id = ${args.organizationId} + AND t.project_id = ANY(${psql.array(args.projectIds, 'uuid')}) + `, + ) + .then( + z.array( + z.object({ + id: z.string(), + project_id: z.string(), + }), + ).parse, + ); + + for (const row of rows) { + result.get(row.project_id)?.push(row.id); + } + + return result; + } + + async countSchemaVersionsByProjectIds(args: { + organizationId: string; + projectIds: readonly string[]; + period: DateRange; + }): Promise> { + const result = new Map(); + + for (const projectId of args.projectIds) { + result.set(projectId, 0); + } + + if (args.projectIds.length === 0) { + return result; + } + + const rows = await this.pool + .any( + psql`/* ProjectStats.countSchemaVersionsByProjectIds */ + SELECT + t.project_id as project_id, + COUNT(*)::int as total + FROM schema_versions as sv + LEFT JOIN targets as t ON (t.id = sv.target_id) + LEFT JOIN projects as p ON (p.id = t.project_id) + WHERE + p.org_id = ${args.organizationId} + AND t.project_id = ANY(${psql.array(args.projectIds, 'uuid')}) + AND sv.created_at >= ${args.period.from.toISOString()} + AND sv.created_at < ${args.period.to.toISOString()} + GROUP BY t.project_id + `, + ) + .then( + z.array( + z.object({ + project_id: z.string(), + total: z.number(), + }), + ).parse, + ); + + for (const row of rows) { + result.set(row.project_id, row.total); + } + + return result; + } +} diff --git a/packages/services/api/src/modules/project/resolvers/Organization.ts b/packages/services/api/src/modules/project/resolvers/Organization.ts index 6b5560d269a..9b5dfb75021 100644 --- a/packages/services/api/src/modules/project/resolvers/Organization.ts +++ b/packages/services/api/src/modules/project/resolvers/Organization.ts @@ -6,23 +6,13 @@ export const Organization: Pick< OrganizationResolvers, 'projectBySlug' | 'projects' | 'viewerCanCreateProject' > = { - projects: async (organization, _, { injector }) => { - const projects = await injector - .get(ProjectManager) - .getProjects({ organizationId: organization.id }); - - return { - edges: projects.map(node => ({ - cursor: '', - node, - })), - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - endCursor: '', - startCursor: '', - }, - }; + projects: (organization, args, { injector }) => { + return injector.get(ProjectManager).getPaginatedProjectsForOrganization(organization, { + first: args.first ?? null, + after: args.after ?? null, + search: args.search ?? null, + sort: args.sort ?? null, + }); }, viewerCanCreateProject: async (organization, _arg, { injector }) => { return injector.get(Session).canPerformAction({ diff --git a/packages/services/api/src/modules/schema/resolvers/Target.ts b/packages/services/api/src/modules/schema/resolvers/Target.ts index c1bb1694530..f4d877fbe02 100644 --- a/packages/services/api/src/modules/schema/resolvers/Target.ts +++ b/packages/services/api/src/modules/schema/resolvers/Target.ts @@ -1,5 +1,6 @@ import { parseDateRangeInput } from '../../../shared/helpers'; import { OperationsManager } from '../../operations/providers/operations-manager'; +import { TargetStats } from '../../target/providers/target-stats'; import { isFieldRequestedDeep } from '../lib/is-field-requested'; import { ContractsManager } from '../providers/contracts-manager'; import { SchemaManager } from '../providers/schema-manager'; @@ -98,9 +99,12 @@ export const Target: Pick< }; }, schemaVersionsCount: (target, { period }, { injector }) => { - return injector - .get(SchemaManager) - .countSchemaVersionsOfTarget(target, period ? parseDateRangeInput(period) : null); + return injector.get(TargetStats).countSchemaVersionsOfTarget({ + organizationId: target.orgId, + projectId: target.projectId, + targetId: target.id, + period: period ? parseDateRangeInput(period) : null, + }); }, contracts: async (target, args, { injector }) => { return await injector.get(ContractsManager).getPaginatedContractsForTarget({ diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 5e1fcc575e1..a16e98d1888 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -3,6 +3,8 @@ import type { PolicyConfigurationObject } from '@hive/policy'; import { PostgresDatabasePool } from '@hive/postgres'; import type { PaginatedOrganizationInvitationConnection, + PaginatedProjectConnection, + PaginatedTargetConnection, SchemaChangeType, SchemaCheck, SchemaCheckInput, @@ -48,6 +50,13 @@ export interface TargetSelector extends ProjectSelector { targetId: string; } +export type ProjectsStorageSort = { + field: 'CREATED_AT' | 'NAME'; + direction: 'ASC' | 'DESC'; +}; + +export type TargetsStorageSort = ProjectsStorageSort; + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface Storage { pool: PostgresDatabasePool; @@ -212,7 +221,21 @@ export interface Storage { getProjectBySlug(_: { slug: string } & OrganizationSelector): Promise; - getProjects(_: OrganizationSelector): Promise; + getProjects( + _: OrganizationSelector & { + search?: string | null; + sort?: ProjectsStorageSort | null; + }, + ): Promise; + + getPaginatedProjects( + _: OrganizationSelector & { + first: number; + after: string | null; + search?: string | null; + sort?: ProjectsStorageSort | null; + }, + ): Promise; getProjectById(projectId: string): Promise; @@ -311,7 +334,21 @@ export interface Storage { getTarget(_: TargetSelector): Promise; - getTargets(_: ProjectSelector): Promise; + getTargets( + _: ProjectSelector & { + search?: string | null; + sort?: TargetsStorageSort | null; + }, + ): Promise; + + getPaginatedTargets( + _: ProjectSelector & { + first: number; + after: string | null; + search?: string | null; + sort?: TargetsStorageSort | null; + }, + ): Promise; findTargetsByIds(args: { organizationId: string; diff --git a/packages/services/api/src/modules/target/index.ts b/packages/services/api/src/modules/target/index.ts index 44fdb680c13..c720d440b92 100644 --- a/packages/services/api/src/modules/target/index.ts +++ b/packages/services/api/src/modules/target/index.ts @@ -1,5 +1,6 @@ import { createModule } from 'graphql-modules'; import { TargetManager } from './providers/target-manager'; +import { TargetStats } from './providers/target-stats'; import { TargetsByIdCache } from './providers/targets-by-id-cache'; import { TargetsBySlugCache } from './providers/targets-by-slug-cache'; import { resolvers } from './resolvers.generated'; @@ -10,5 +11,5 @@ export const targetModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [TargetManager, TargetsByIdCache, TargetsBySlugCache], + providers: [TargetManager, TargetStats, TargetsByIdCache, TargetsBySlugCache], }); diff --git a/packages/services/api/src/modules/target/module.graphql.ts b/packages/services/api/src/modules/target/module.graphql.ts index 9b906151b95..08f00ee644c 100644 --- a/packages/services/api/src/modules/target/module.graphql.ts +++ b/packages/services/api/src/modules/target/module.graphql.ts @@ -3,7 +3,13 @@ import { gql } from 'graphql-modules'; export default gql` extend type Query { target(reference: TargetReferenceInput! @tag(name: "public")): Target @tag(name: "public") - targets(selector: ProjectSelectorInput!): TargetConnection! + targets( + selector: ProjectSelectorInput! + first: Int + after: String + search: String + sort: TargetsSortInput + ): TargetConnection! } extend type Mutation { @@ -257,10 +263,32 @@ export default gql` } extend type Project { - targets: TargetConnection! @tag(name: "public") + targets(first: Int, after: String, search: String, sort: TargetsSortInput): TargetConnection! + @tag(name: "public") targetBySlug(targetSlug: String!): Target } + enum TargetsSortFieldType { + NAME + CREATED_AT + REQUESTS + SCHEMA_VERSIONS + } + + enum TargetsSortDirectionType { + ASC + DESC + } + + input TargetsSortInput { + field: TargetsSortFieldType! + direction: TargetsSortDirectionType! + """ + Required when sorting by REQUESTS or SCHEMA_VERSIONS. + """ + period: DateRangeInput + } + type TargetEdge { node: Target! @tag(name: "public") cursor: String! @tag(name: "public") diff --git a/packages/services/api/src/modules/target/providers/target-manager.ts b/packages/services/api/src/modules/target/providers/target-manager.ts index 06786b1951c..c6ee18cd178 100644 --- a/packages/services/api/src/modules/target/providers/target-manager.ts +++ b/packages/services/api/src/modules/target/providers/target-manager.ts @@ -2,18 +2,39 @@ import { Injectable, Scope } from 'graphql-modules'; import { TargetReferenceInput } from 'packages/libraries/core/src/client/__generated__/types'; import * as zod from 'zod'; import { z } from 'zod'; +import { + decodeCreatedAtAndUUIDIdBasedCursor, + decodeProjectSlugIdBasedCursor, + encodeCreatedAtAndUUIDIdBasedCursor, + encodeProjectSlugIdBasedCursor, +} from '@hive/storage'; +import type { DateRangeInput } from '../../../__generated__/types'; import * as GraphQLSchema from '../../../__generated__/types'; import type { Project, Target, TargetSettings } from '../../../shared/entities'; -import { cache, share } from '../../../shared/helpers'; +import { HiveError } from '../../../shared/errors'; +import { cache, parseDateRangeInput, share } from '../../../shared/helpers'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; +import { OperationsManager } from '../../operations/providers/operations-manager'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; -import { ProjectSelector, Storage, TargetSelector } from '../../shared/providers/storage'; +import { + ProjectSelector, + Storage, + TargetSelector, + TargetsStorageSort, +} from '../../shared/providers/storage'; import { TokenStorage } from '../../token/providers/token-storage'; import { PercentageModel, TargetSlugModel } from '../validation'; +import { TargetStats } from './target-stats'; import { TargetsByIdCache } from './targets-by-id-cache'; +type TargetsSortArgs = { + field: 'NAME' | 'CREATED_AT' | 'REQUESTS' | 'SCHEMA_VERSIONS'; + direction: 'ASC' | 'DESC'; + period?: DateRangeInput | null; +}; + const reservedSlugs = ['view', 'new']; /** @@ -35,6 +56,8 @@ export class TargetManager { private idTranslator: IdTranslator, private auditLog: AuditLogRecorder, private targetsCache: TargetsByIdCache, + private operationsManager: OperationsManager, + private targetStats: TargetStats, ) { this.logger = logger.child({ source: 'TargetManager' }); } @@ -213,6 +236,256 @@ export class TargetManager { return this.storage.getTargets(selector); } + async getPaginatedTargetsForProject( + project: Project, + args: { + first: number | null; + after: string | null; + search: string | null; + sort: TargetsSortArgs | null; + }, + ) { + return this.getPaginatedTargets( + { + organizationId: project.orgId, + projectId: project.id, + }, + args, + ); + } + + async getPaginatedTargets( + selector: ProjectSelector, + args: { + first: number | null; + after: string | null; + search: string | null; + sort: TargetsSortArgs | null; + }, + ) { + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: selector.organizationId, + params: { + organizationId: selector.organizationId, + projectId: selector.projectId, + }, + }); + + const sortField = args.sort?.field ?? 'CREATED_AT'; + + if (sortField === 'REQUESTS' || sortField === 'SCHEMA_VERSIONS') { + return this.getPaginatedTargetsSortedByMetric(selector, args); + } + + const storageSort = this.toStorageSort(args.sort); + const limit = args.first ? Math.min(args.first, 50) : null; + + if (limit === null) { + const targets = await this.storage.getTargets({ + ...selector, + search: args.search, + sort: storageSort, + }); + const nodes = targets.slice(this.resolveCursorStartIndex(targets, args.after, storageSort)); + + return this.toTargetConnection(nodes, storageSort, { + hasNextPage: false, + hasPreviousPage: args.after !== null, + }); + } + + return this.storage.getPaginatedTargets({ + ...selector, + first: limit, + after: args.after, + search: args.search, + sort: storageSort, + }); + } + + private async getPaginatedTargetsSortedByMetric( + selector: ProjectSelector, + args: { + first: number | null; + after: string | null; + search: string | null; + sort: TargetsSortArgs | null; + }, + ) { + if (!args.sort?.period) { + throw new HiveError('period is required when sorting targets by REQUESTS or SCHEMA_VERSIONS'); + } + + const period = parseDateRangeInput(args.sort.period); + const limit = args.first ? Math.min(args.first, 50) : null; + const direction = args.sort.direction ?? 'DESC'; + const multiplier = direction === 'ASC' ? 1 : -1; + + const targets = await this.storage.getTargets({ + ...selector, + search: args.search, + }); + + const withMetrics = + args.sort.field === 'REQUESTS' + ? await this.operationsManager + .countRequestsByTargetIds({ + organizationId: selector.organizationId, + projectId: selector.projectId, + targetIds: targets.map(target => target.id), + period, + }) + .then(counts => + targets.map(target => ({ + target, + value: counts.get(target.id) ?? 0, + })), + ) + : await this.targetStats + .countSchemaVersionsByTargetIds({ + organizationId: selector.organizationId, + projectId: selector.projectId, + targetIds: targets.map(target => target.id), + period, + }) + .then(counts => + targets.map(target => ({ + target, + value: counts.get(target.id) ?? 0, + })), + ); + + withMetrics.sort((left, right) => { + const diff = (left.value - right.value) * multiplier; + + if (diff !== 0) { + return diff; + } + + return left.target.slug.localeCompare(right.target.slug); + }); + + const sorted = withMetrics.map(entry => entry.target); + let startIndex = 0; + + if (args.after) { + const { id } = decodeCreatedAtAndUUIDIdBasedCursor(args.after); + const cursorIndex = sorted.findIndex(target => target.id === id); + + if (cursorIndex !== -1) { + startIndex = cursorIndex + 1; + } + } + + if (limit === null) { + return this.toTargetConnection(sorted.slice(startIndex), null, { + hasNextPage: false, + hasPreviousPage: args.after !== null, + }); + } + + const page = sorted.slice(startIndex, startIndex + limit + 1); + + return this.toTargetConnection(page.slice(0, limit), null, { + hasNextPage: page.length > limit, + hasPreviousPage: args.after !== null, + }); + } + + private resolveCursorStartIndex( + targets: readonly Target[], + after: string | null, + sort: TargetsStorageSort | null, + ) { + if (!after) { + return 0; + } + + const sortConfig = sort ?? { field: 'CREATED_AT', direction: 'DESC' }; + const isDesc = sortConfig.direction === 'DESC'; + const cursorIndex = targets.findIndex(target => { + if (sortConfig.field === 'NAME') { + const cursor = decodeProjectSlugIdBasedCursor(after); + + return target.slug === cursor.slug && target.id === cursor.id; + } + + const cursor = decodeCreatedAtAndUUIDIdBasedCursor(after); + + return target.createdAt === cursor.createdAt && target.id === cursor.id; + }); + + if (cursorIndex !== -1) { + return cursorIndex + 1; + } + + if (sortConfig.field === 'NAME') { + const cursor = decodeProjectSlugIdBasedCursor(after); + const startIndex = targets.findIndex(target => + isDesc + ? target.slug < cursor.slug || (target.slug === cursor.slug && target.id < cursor.id) + : target.slug > cursor.slug || (target.slug === cursor.slug && target.id > cursor.id), + ); + + return startIndex === -1 ? targets.length : startIndex; + } + + const cursor = decodeCreatedAtAndUUIDIdBasedCursor(after); + const startIndex = targets.findIndex(target => + isDesc + ? target.createdAt < cursor.createdAt || + (target.createdAt === cursor.createdAt && target.id < cursor.id) + : target.createdAt > cursor.createdAt || + (target.createdAt === cursor.createdAt && target.id > cursor.id), + ); + + return startIndex === -1 ? targets.length : startIndex; + } + + private toStorageSort(sort: TargetsSortArgs | null): TargetsStorageSort | null { + if (!sort) { + return null; + } + + return { + field: sort.field === 'NAME' ? 'NAME' : 'CREATED_AT', + direction: sort.direction, + }; + } + + private toTargetConnection( + nodes: readonly Target[], + sort: TargetsStorageSort | null, + pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }, + ) { + const sortConfig = sort ?? { field: 'CREATED_AT', direction: 'DESC' }; + const edges = nodes.map(node => ({ + node, + get cursor() { + if (sortConfig.field === 'NAME') { + return encodeProjectSlugIdBasedCursor({ slug: node.slug, id: node.id }); + } + + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + })); + + return { + edges, + pageInfo: { + hasNextPage: pageInfo.hasNextPage, + hasPreviousPage: pageInfo.hasPreviousPage, + get endCursor() { + return edges.at(-1)?.cursor ?? ''; + }, + get startCursor() { + return edges.at(0)?.cursor ?? ''; + }, + }, + }; + } + async getTarget(selector: TargetSelector): Promise { this.logger.debug('Fetching target (selector=%o)', selector); await this.session.assertPerformAction({ diff --git a/packages/services/api/src/modules/target/providers/target-stats.ts b/packages/services/api/src/modules/target/providers/target-stats.ts new file mode 100644 index 00000000000..94659a41478 --- /dev/null +++ b/packages/services/api/src/modules/target/providers/target-stats.ts @@ -0,0 +1,95 @@ +import { Injectable, Scope } from 'graphql-modules'; +import { z } from 'zod'; +import { PostgresDatabasePool, psql } from '@hive/postgres'; +import type { DateRange } from '../../../shared/entities'; +import { batchBy } from '../../../shared/helpers'; + +@Injectable({ + scope: Scope.Operation, + global: true, +}) +export class TargetStats { + constructor(private pool: PostgresDatabasePool) {} + + async countSchemaVersionsByTargetIds(args: { + organizationId: string; + projectId: string; + targetIds: readonly string[]; + period: DateRange | null; + }): Promise> { + const result = new Map(); + + if (args.targetIds.length === 0) { + return result; + } + + const rows = await this.pool + .any( + psql`/* TargetStats.countSchemaVersionsByTargetIds */ + SELECT + sv.target_id as target_id, + COUNT(*) as total + FROM schema_versions as sv + LEFT JOIN targets as t ON (t.id = sv.target_id) + LEFT JOIN projects as p ON (p.id = t.project_id) + WHERE + p.org_id = ${args.organizationId} + AND t.project_id = ${args.projectId} + AND sv.target_id = ANY(${psql.array(args.targetIds, 'uuid')}) + ${ + args.period + ? psql` + AND sv.created_at >= ${args.period.from.toISOString()} + AND sv.created_at < ${args.period.to.toISOString()} + ` + : psql`` + } + GROUP BY sv.target_id + `, + ) + .then(result => { + return z + .array( + z.object({ + target_id: z.string(), + total: z.number(), + }), + ) + .parse(result); + }); + + for (const row of rows) { + result.set(row.target_id, row.total); + } + + return result; + } + + countSchemaVersionsOfTarget = batchBy< + { + organizationId: string; + projectId: string; + targetId: string; + period: DateRange | null; + }, + number + >( + item => + [ + item.organizationId, + item.projectId, + item.period?.from.toISOString() ?? 'all', + item.period?.to.toISOString() ?? 'all', + ].join(':'), + async items => { + const counts = await this.countSchemaVersionsByTargetIds({ + organizationId: items[0].organizationId, + projectId: items[0].projectId, + targetIds: items.map(item => item.targetId), + period: items[0].period, + }); + + return items.map(item => Promise.resolve(counts.get(item.targetId) ?? 0)); + }, + ); +} diff --git a/packages/services/api/src/modules/target/resolvers/Project.ts b/packages/services/api/src/modules/target/resolvers/Project.ts index 226ea203b13..018336d3362 100644 --- a/packages/services/api/src/modules/target/resolvers/Project.ts +++ b/packages/services/api/src/modules/target/resolvers/Project.ts @@ -2,24 +2,13 @@ import { TargetManager } from '../providers/target-manager'; import type { ProjectResolvers } from './../../../__generated__/types'; export const Project: Pick = { - targets: async (project, _, { injector }) => { - const targets = await injector.get(TargetManager).getTargets({ - projectId: project.id, - organizationId: project.orgId, + targets: (project, args, { injector }) => { + return injector.get(TargetManager).getPaginatedTargetsForProject(project, { + first: args.first ?? null, + after: args.after ?? null, + search: args.search ?? null, + sort: args.sort ?? null, }); - - return { - edges: targets.map(node => ({ - cursor: '', - node, - })), - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - endCursor: '', - startCursor: '', - }, - }; }, targetBySlug: (project, args, { injector }) => { return injector.get(TargetManager).getTargetBySlugForProject(project, args.targetSlug); diff --git a/packages/services/api/src/modules/target/resolvers/Query/targets.ts b/packages/services/api/src/modules/target/resolvers/Query/targets.ts index d99406850fc..c855e446859 100644 --- a/packages/services/api/src/modules/target/resolvers/Query/targets.ts +++ b/packages/services/api/src/modules/target/resolvers/Query/targets.ts @@ -2,32 +2,23 @@ import { IdTranslator } from '../../../shared/providers/id-translator'; import { TargetManager } from '../../providers/target-manager'; import type { QueryResolvers } from './../../../../__generated__/types'; -export const targets: NonNullable = async ( - _, - { selector }, - { injector }, -) => { +export const targets: NonNullable = async (_, args, { injector }) => { const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ - translator.translateOrganizationId(selector), - translator.translateProjectId(selector), + const [organizationId, projectId] = await Promise.all([ + translator.translateOrganizationId(args.selector), + translator.translateProjectId(args.selector), ]); - const targets = await injector.get(TargetManager).getTargets({ - organizationId: organization, - projectId: project, - }); - - return { - edges: targets.map(node => ({ - cursor: '', - node, - })), - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - endCursor: '', - startCursor: '', + return injector.get(TargetManager).getPaginatedTargets( + { + organizationId, + projectId, + }, + { + first: args.first ?? null, + after: args.after ?? null, + search: args.search ?? null, + sort: args.sort ?? null, }, - }; + ); }; diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 628196269c5..276fb8957c5 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -329,6 +329,7 @@ export interface Target { projectId: string; orgId: string; name: string; + createdAt: string; graphqlEndpointUrl: string | null; failDiffOnDangerousChange: boolean; } diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index e60dab7e493..0f0b46bf691 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -18,6 +18,7 @@ import { UniqueIntegrityConstraintViolationError, } from '@hive/postgres'; import { createSDLHash, ProjectType } from '../../api/src/shared/entities'; +import { HiveError } from '../../api/src/shared/errors'; import { batch, batchBy } from '../../api/src/shared/helpers'; import { type organizations } from './db'; import { @@ -1253,13 +1254,76 @@ export async function createStorage( ) .then(ProjectModel.nullable().parse); }, - async getProjects({ organizationId: organization }) { + async getProjects({ organizationId: organization, search, sort }) { + const sortConfig = resolveProjectsStorageSort(sort); + const searchTerm = search?.trim() ?? ''; + return pool .any( - psql`/* getProjects */ SELECT ${projectFields(psql``)} FROM projects WHERE org_id = ${organization} AND type != 'CUSTOM' ORDER BY created_at DESC`, + psql`/* getProjects */ + SELECT + ${projectFields(psql``)} + FROM + projects + WHERE + org_id = ${organization} + AND type != 'CUSTOM' + ${searchTerm.length > 0 ? psql`AND clean_id ILIKE ${'%' + searchTerm + '%'}` : psql``} + ORDER BY + ${projectsOrderBy(sortConfig)} + `, ) .then(z.array(ProjectModel).parse); }, + async getPaginatedProjects({ organizationId: organization, first, after, search, sort }) { + const sortConfig = resolveProjectsStorageSort(sort); + const searchTerm = search?.trim() ?? ''; + const limit = first > 0 ? Math.min(first, 50) : 50; + const cursor = after ? decodeProjectsStorageCursor(after, sortConfig) : null; + + const result = await pool.any(psql`/* getPaginatedProjects */ + SELECT + ${projectFields(psql``)} + FROM + projects + WHERE + org_id = ${organization} + AND type != 'CUSTOM' + ${searchTerm.length > 0 ? psql`AND clean_id ILIKE ${'%' + searchTerm + '%'}` : psql``} + ${projectsCursorCondition(cursor, sortConfig)} + ORDER BY + ${projectsOrderBy(sortConfig)} + LIMIT ${limit + 1} + `); + + let edges = result.map(row => { + const node = ProjectModel.parse(row); + + return { + node, + get cursor() { + return encodeProjectsStorageCursor(node, sortConfig); + }, + }; + }); + + const hasNextPage = edges.length > limit; + edges = edges.slice(0, limit); + + return { + edges, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return edges[edges.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return edges[0]?.cursor ?? ''; + }, + }, + }; + }, findProjectsByIds: batch<{ projectIds: Array }, Map>( async function FindProjectByIdsBatchHandler(args) { const allProjectIds = args.flatMap(args => args.projectIds); @@ -1583,26 +1647,81 @@ export async function createStorage( orgId: organization, }; }, - async getTargets({ organizationId, projectId }) { + async getTargets({ organizationId, projectId, search, sort }) { + const sortConfig = resolveProjectsStorageSort(sort); + const searchTerm = search?.trim() ?? ''; + const results = await pool .any( psql`/* getTargets */ + SELECT + ${targetSQLFields} + FROM + targets + WHERE + project_id = ${projectId} + ${searchTerm.length > 0 ? psql`AND clean_id ILIKE ${'%' + searchTerm + '%'}` : psql``} + ORDER BY + ${projectsOrderBy(sortConfig)} + `, + ) + .then(z.array(TargetModel).parse); + + return results.map(r => ({ + ...r, + orgId: organizationId, + })); + }, + async getPaginatedTargets({ organizationId, projectId, first, after, search, sort }) { + const sortConfig = resolveProjectsStorageSort(sort); + const searchTerm = search?.trim() ?? ''; + const limit = first > 0 ? Math.min(first, 50) : 50; + const cursor = after ? decodeProjectsStorageCursor(after, sortConfig) : null; + + const result = await pool.any(psql`/* getPaginatedTargets */ SELECT ${targetSQLFields} FROM targets WHERE project_id = ${projectId} + ${searchTerm.length > 0 ? psql`AND clean_id ILIKE ${'%' + searchTerm + '%'}` : psql``} + ${projectsCursorCondition(cursor, sortConfig)} ORDER BY - created_at DESC - `, - ) - .then(z.array(TargetModel).parse); + ${projectsOrderBy(sortConfig)} + LIMIT ${limit + 1} + `); - return results.map(r => ({ - ...r, - orgId: organizationId, - })); + let edges = result.map(row => { + const node = { + ...TargetModel.parse(row), + orgId: organizationId, + }; + + return { + node, + get cursor() { + return encodeProjectsStorageCursor(node, sortConfig); + }, + }; + }); + + const hasNextPage = edges.length > limit; + edges = edges.slice(0, limit); + + return { + edges, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return edges[edges.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return edges[0]?.cursor ?? ''; + }, + }, + }; }, findTargetsByIds: batchBy< { @@ -3924,6 +4043,125 @@ export async function createStorage( return storage; } +type ProjectsStorageSort = { + field: 'CREATED_AT' | 'NAME'; + direction: 'ASC' | 'DESC'; +}; + +function resolveProjectsStorageSort( + sort: ProjectsStorageSort | null | undefined, +): ProjectsStorageSort { + return { + field: sort?.field ?? 'CREATED_AT', + direction: sort?.direction ?? 'DESC', + }; +} + +function projectsOrderBy(sort: ProjectsStorageSort) { + if (sort.field === 'NAME') { + return sort.direction === 'DESC' ? psql`clean_id DESC, id DESC` : psql`clean_id ASC, id ASC`; + } + + return sort.direction === 'DESC' ? psql`created_at DESC, id DESC` : psql`created_at ASC, id ASC`; +} + +function projectsCursorCondition( + cursor: { createdAt: string; id: string } | { slug: string; id: string } | null, + sort: ProjectsStorageSort, +) { + if (!cursor) { + return psql``; + } + + if (sort.field === 'NAME' && 'slug' in cursor) { + if (sort.direction === 'DESC') { + return psql` + AND ( + ( + clean_id = ${cursor.slug} + AND id < ${cursor.id} + ) + OR clean_id < ${cursor.slug} + ) + `; + } + + return psql` + AND ( + ( + clean_id = ${cursor.slug} + AND id > ${cursor.id} + ) + OR clean_id > ${cursor.slug} + ) + `; + } + + const createdAtCursor = cursor as { createdAt: string; id: string }; + + if (sort.direction === 'DESC') { + return psql` + AND ( + ( + created_at = ${createdAtCursor.createdAt} + AND id < ${createdAtCursor.id} + ) + OR created_at < ${createdAtCursor.createdAt} + ) + `; + } + + return psql` + AND ( + ( + created_at = ${createdAtCursor.createdAt} + AND id > ${createdAtCursor.id} + ) + OR created_at > ${createdAtCursor.createdAt} + ) + `; +} + +export function encodeProjectSlugIdBasedCursor(cursor: { slug: string; id: string }) { + return Buffer.from(`${cursor.slug}|${cursor.id}`).toString('base64'); +} + +export function decodeProjectSlugIdBasedCursor(cursor: string) { + const [slug, id] = Buffer.from(cursor, 'base64').toString('utf8').split('|'); + + if ( + !slug || + id === undefined || + !/^[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 HiveError('Invalid cursor'); + } + + return { + slug, + id, + }; +} + +function encodeProjectsStorageCursor( + node: { createdAt: string; id: string; slug: string }, + sort: ProjectsStorageSort, +) { + if (sort.field === 'NAME') { + return encodeProjectSlugIdBasedCursor({ slug: node.slug, id: node.id }); + } + + return encodeCreatedAtAndUUIDIdBasedCursor(node); +} + +function decodeProjectsStorageCursor(cursor: string, sort: ProjectsStorageSort) { + if (sort.field === 'NAME') { + return decodeProjectSlugIdBasedCursor(cursor); + } + + return decodeCreatedAtAndUUIDIdBasedCursor(cursor); +} + export function encodeCreatedAtAndUUIDIdBasedCursor(cursor: { createdAt: string; id: string }) { return Buffer.from(`${cursor.createdAt}|${cursor.id}`).toString('base64'); } @@ -3935,7 +4173,7 @@ export function decodeCreatedAtAndUUIDIdBasedCursor(cursor: string) { id === undefined || !/^[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'); + throw new HiveError('Invalid cursor'); } return { @@ -4213,6 +4451,7 @@ const targetSQLFields = psql` "clean_id" as "slug", "name", "project_id" as "projectId", + to_json("created_at") as "createdAt", "graphql_endpoint_url" as "graphqlEndpointUrl", "fail_diff_on_dangerous_change" as "failDiffOnDangerousChange" `; @@ -4283,6 +4522,7 @@ const TargetModel = z.object({ slug: z.string(), name: z.string(), projectId: z.string(), + createdAt: z.string(), graphqlEndpointUrl: z.string().nullable(), failDiffOnDangerousChange: z.boolean(), }); @@ -4310,6 +4550,32 @@ export type PaginatedOrganizationInvitationConnection = Readonly<{ }>; }>; +export type PaginatedProjectConnection = Readonly<{ + edges: ReadonlyArray<{ + cursor: string; + node: Project; + }>; + pageInfo: Readonly<{ + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }>; +}>; + +export type PaginatedTargetConnection = Readonly<{ + edges: ReadonlyArray<{ + cursor: string; + node: Target; + }>; + pageInfo: Readonly<{ + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }>; +}>; + const getOrganizationInvitationId = (keys: { organizationId: string; email: string; diff --git a/packages/web/app/src/components/organization/TargetCard.tsx b/packages/web/app/src/components/organization/TargetCard.tsx new file mode 100644 index 00000000000..50e56f44a6f --- /dev/null +++ b/packages/web/app/src/components/organization/TargetCard.tsx @@ -0,0 +1,572 @@ +import { forwardRef, ReactElement, useMemo, useRef, useState } from 'react'; +import { differenceInMilliseconds, endOfDay, formatISO, startOfDay } from 'date-fns'; +import * as echarts from 'echarts'; +import ReactECharts from 'echarts-for-react'; +import { AlertCircleIcon, CircleQuestionMarkIcon, MoreHorizontal } from 'lucide-react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { useQuery } from 'urql'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { DocsLink } from '@/components/ui/docs-note'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { subDays } from '@/lib/date-time'; +import { toDecimal, useFormattedNumber, useFormattedThroughput } from '@/lib/hooks'; +import { useIsInView } from '@/lib/hooks/use-is-in-view'; +import { cn, hexToRgba, useChartStyles } from '@/lib/utils'; +import { Link } from '@tanstack/react-router'; + +export const TargetCardFragment = graphql(` + fragment TargetCardFragment on Target { + id + schemaVersionsCount(period: $period) + operationsStats(period: $period) { + failuresOverTime(resolution: $chartResolution) { + date + value + } + requestsOverTime(resolution: $chartResolution) { + date + value + } + duration { + p75 + p90 + p95 + p99 + } + } + } +`); + +const TargetCardQuery = graphql(` + query TargetCardQuery( + $organizationSlug: String! + $projectSlug: String! + $targetSlug: String! + $chartResolution: Int! + $period: DateRangeInput! + ) { + target: target( + reference: { + bySelector: { + organizationSlug: $organizationSlug + projectSlug: $projectSlug + targetSlug: $targetSlug + } + } + ) { + ...TargetCardFragment + } + } +`); + +export const TargetCardTitle = (props: TargetProps) => ( + +

{props.slug}

+
+ + + + + + + + Schema + + + Checks + + + Explorer + + + History + + + Insights + + + Traces + + + Apps + + + Laboratory + + + Settings + + + +
+ +); + +interface TargetProps { + id: string; + slug: string; + organizationSlug: string; + projectSlug: string; + className?: string; + highestNumberOfRequests?: number; + days: number; + data?: FragmentType; +} + +export const TargetCard = (props: TargetProps): ReactElement => { + const ref = useRef(null); + const isInView = useIsInView(ref); + + const period = useMemo( + () => ({ + from: formatISO(startOfDay(subDays(new Date(), props.days))), + to: formatISO(endOfDay(new Date())), + }), + [props.days], + ); + + const [query] = useQuery({ + query: TargetCardQuery, + variables: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.slug, + chartResolution: props.days, + period, + }, + requestPolicy: 'network-only', + pause: !isInView || !props.slug || !!props.data, + }); + + const target = useFragment(TargetCardFragment, props.data ?? query.data?.target); + + const requests = useMemo(() => { + if (target?.operationsStats?.requestsOverTime?.length) { + return target.operationsStats.requestsOverTime.map(node => [node.date, node.value]) as [ + string, + number, + ][]; + } + + return [ + [new Date(subDays(new Date(), props.days)).toISOString(), 0], + [new Date().toISOString(), 0], + ] as [string, number][]; + }, [target?.operationsStats?.requestsOverTime]); + + const failures = useMemo(() => { + if (target?.operationsStats?.failuresOverTime?.length) { + return target.operationsStats.failuresOverTime.map(node => [node.date, node.value]) as [ + string, + number, + ][]; + } + + return [ + [new Date(subDays(new Date(), props.days)).toISOString(), 0], + [new Date().toISOString(), 0], + ] as [string, number][]; + }, [target?.operationsStats?.failuresOverTime]); + + const totalNumberOfRequests = useMemo( + () => requests.reduce((acc, [_, value]) => acc + value, 0), + [requests], + ); + + const highestNumberOfRequests = useMemo( + () => Math.max(props.highestNumberOfRequests ?? 0, ...requests.map(([, value]) => value)), + [props.highestNumberOfRequests, requests], + ); + + const totalNumberOfFailures = useMemo( + () => failures.reduce((acc, [_, value]) => acc + value, 0), + [failures], + ); + + const totalNumberOfVersions = target?.schemaVersionsCount ?? 0; + const requestsInDateRange = useFormattedNumber(totalNumberOfRequests); + const schemaVersionsInDateRange = useFormattedNumber(totalNumberOfVersions); + const rpm = useFormattedThroughput({ + requests: totalNumberOfRequests, + window: differenceInMilliseconds(new Date(period.to), new Date(period.from)), + }); + + const successRate = useMemo(() => { + if (!totalNumberOfRequests) { + return '-'; + } + + return totalNumberOfRequests || totalNumberOfFailures + ? `${toDecimal(((totalNumberOfRequests - totalNumberOfFailures) * 100) / totalNumberOfRequests)}%` + : '-'; + }, [totalNumberOfRequests, totalNumberOfFailures]); + + const failureRate = useMemo(() => { + if (!totalNumberOfRequests) { + return '-'; + } + + return totalNumberOfRequests || totalNumberOfFailures + ? `${toDecimal((totalNumberOfFailures * 100) / totalNumberOfRequests)}%` + : '-'; + }, [totalNumberOfRequests, totalNumberOfFailures]); + + const { colors } = useChartStyles(); + + const [selectedLatencyKind, setSelectedLatencyKind] = useState<'p75' | 'p90' | 'p95' | 'p99'>( + 'p95', + ); + + return ( + +
+ {!totalNumberOfRequests && !schemaVersionsInDateRange ? ( + + ) : ( + <> + +
+
+
Schema Versions
+
+ {schemaVersionsInDateRange}{' '} + in the last {props.days} days +
+
+
+
+
+ Latency + +
+
+ {target?.operationsStats?.duration?.[selectedLatencyKind]}ms +
+
+
+
+
Requests
+
+ {requestsInDateRange} + {rpm} RPM +
+
+
+
+
+
Success rate
+
{successRate}
+
+
+
+
+
+
+
+ + {size => ( + + )} + +
+
+ + )} +
+ + ); +}; + +const TargetCardSkeletonContent = (props: Partial) => { + const isRealTarget = !!props.slug; + + return ( + <> + {isRealTarget ? ( + + ) : ( +
+
+
+ )} +
+
+
Schema Versions
+
+
+
+
+
Latency
+
+
+
+
+
Requests
+
+
+
+
+
+
Success rate
+
+
+
+
+
+
+ {isRealTarget && ( + + + + No data available. + + + + + + + Read about usage reporting in the documentation + + + + + + There is no data available for this target yet. + + + )} +
+ + ); +}; + +export const TargetCardSkeleton = forwardRef>((props, ref) => { + return ( +
+ + + +
+ ); +}); diff --git a/packages/web/app/src/lib/hooks/use-formatted-throughput.ts b/packages/web/app/src/lib/hooks/use-formatted-throughput.ts index 0ede3bfb09b..2cab84b6849 100644 --- a/packages/web/app/src/lib/hooks/use-formatted-throughput.ts +++ b/packages/web/app/src/lib/hooks/use-formatted-throughput.ts @@ -3,6 +3,10 @@ import { toDecimal } from './use-decimal'; import { formatNumber } from './use-formatted-number'; export function formatRpm(rpm: number) { + if (rpm === 0) { + return '0'; + } + if (rpm >= 1000) { return formatNumber(rpm); } diff --git a/packages/web/app/src/lib/hooks/use-is-in-view.ts b/packages/web/app/src/lib/hooks/use-is-in-view.ts new file mode 100644 index 00000000000..9a146f8ae3a --- /dev/null +++ b/packages/web/app/src/lib/hooks/use-is-in-view.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +export const useIsInView = (ref: React.RefObject) => { + const [isInView, setIsInView] = useState(false); + + useEffect(() => { + if (ref.current) { + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + setIsInView(entry.isIntersecting); + observer.disconnect(); + } + }); + + observer.observe(ref.current); + + return () => observer.disconnect(); + } + }, [ref]); + + return isInView; +}; diff --git a/packages/web/app/src/lib/utils.ts b/packages/web/app/src/lib/utils.ts index 5487fd2b518..e28bd37fbce 100644 --- a/packages/web/app/src/lib/utils.ts +++ b/packages/web/app/src/lib/utils.ts @@ -23,6 +23,16 @@ function hslToHex(h: number, s: number, l: number): string { return `#${f(0)}${f(8)}${f(4)}`; } +export const hexToRgba = (hex: string, alpha: number) => { + const fullHex = + hex.length === 4 ? '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] : hex; + const r = parseInt(fullHex.slice(1, 3), 16); + const g = parseInt(fullHex.slice(3, 5), 16); + const b = parseInt(fullHex.slice(5, 7), 16); + + return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; +}; + function readChartStyles() { const s = getComputedStyle(document.documentElement); const textColor = s.getPropertyValue('--color-neutral-12').trim(); diff --git a/packages/web/app/src/pages/organization.tsx b/packages/web/app/src/pages/organization.tsx index f81e00434ea..b1474a34ef2 100644 --- a/packages/web/app/src/pages/organization.tsx +++ b/packages/web/app/src/pages/organization.tsx @@ -1,14 +1,22 @@ -import { ChangeEvent, ReactElement, useCallback, useMemo, useRef } from 'react'; +import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { endOfDay, formatISO, startOfDay } from 'date-fns'; -import * as echarts from 'echarts'; -import ReactECharts from 'echarts-for-react'; -import { Globe, History, MoveDownIcon, MoveUpIcon, SearchIcon } from 'lucide-react'; -import AutoSizer from 'react-virtualized-auto-sizer'; +import { MoreHorizontal, MoveDownIcon, MoveUpIcon, SearchIcon } from 'lucide-react'; import { useQuery } from 'urql'; import { z } from 'zod'; import { OrganizationLayout, Page } from '@/components/layouts/organization'; +import { + TargetCard, + TargetCardFragment, + TargetCardSkeleton, +} from '@/components/organization/TargetCard'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { EmptyList } from '@/components/ui/empty-list'; import { Input } from '@/components/ui/input'; import { Meta } from '@/components/ui/meta'; @@ -16,12 +24,17 @@ import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { TooltipProvider } from '@/components/ui/tooltip'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { ProjectType } from '@/gql/graphql'; +import { + ProjectsSortDirectionType, + ProjectsSortFieldType, + ProjectType, + TargetsSortDirectionType, + TargetsSortFieldType, +} from '@/gql/graphql'; import { subDays } from '@/lib/date-time'; -import { useFormattedNumber } from '@/lib/hooks'; -import { pluralize } from '@/lib/utils'; +import { useIsInView } from '@/lib/hooks/use-is-in-view'; import { UTCDate } from '@date-fns/utc'; import { Link, useRouter } from '@tanstack/react-router'; @@ -33,11 +46,51 @@ export const OrganizationIndexRouteSearch = z.object({ type RouteSearchProps = z.infer; -const ProjectCard_ProjectFragment = graphql(` - fragment ProjectCard_ProjectFragment on Project { - id - slug - type +const OrganizationProjectsPageQuery = graphql(` + query OrganizationProjectsPageQuery( + $organizationSlug: String! + $search: String + $sort: ProjectsSortInput + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + slug + projects(search: $search, sort: $sort) { + edges { + node { + id + slug + type + } + } + } + } + } +`); + +const OrganizationProjectsPageProjectDataQuery = graphql(` + query OrganizationProjectsPageProjectDataQuery( + $organizationSlug: String! + $projectSlug: String! + $period: DateRangeInput! + $chartResolution: Int! + $targetsSort: TargetsSortInput! + ) { + project( + reference: { bySelector: { organizationSlug: $organizationSlug, projectSlug: $projectSlug } } + ) { + id + slug + targets(sort: $targetsSort) { + edges { + node { + id + slug + ...TargetCardFragment + } + } + } + } } `); @@ -48,209 +101,193 @@ const projectTypeFullNames = { }; const ProjectCard = (props: { - project: FragmentType | null; - cleanOrganizationId: string | null; + id: string; + slug: string; + type: ProjectType; + period: { + from: string; + to: string; + }; + sortKey: string; + sortOrder: 'asc' | 'desc'; highestNumberOfRequests: number; - requestsOverTime: { date: string; value: number }[] | null; - schemaVersionsCount: number | null; days: number; -}): ReactElement | null => { - const project = useFragment(ProjectCard_ProjectFragment, props.project); + organizationSlug: string; + projectSlug: string; + onRequestsMaxChange: (projectId: string, value: number | null) => void; +}) => { + const ref = useRef(null); + const isInView = useIsInView(ref); + const targetsSort = useMemo(() => { + let field: TargetsSortFieldType = TargetsSortFieldType.Name; + let direction: TargetsSortDirectionType = TargetsSortDirectionType.Asc; + + if (props.sortKey === 'requests') { + field = TargetsSortFieldType.Requests; + } else if (props.sortKey === 'versions') { + field = TargetsSortFieldType.SchemaVersions; + } - const { highestNumberOfRequests } = props; + if (props.sortOrder === 'desc') { + direction = TargetsSortDirectionType.Desc; + } + + return { field, direction, period: props.period }; + }, [props.sortKey, props.sortOrder, props.period]); - const requests = useMemo(() => { - if (props.requestsOverTime?.length) { - return props.requestsOverTime.map<[string, number]>(node => [node.date, node.value]); + const [query] = useQuery({ + query: OrganizationProjectsPageProjectDataQuery, + variables: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + chartResolution: props.days, + period: props.period, + targetsSort, + }, + pause: !isInView, + }); + + const targets = query.data?.project?.targets.edges.map(edge => edge.node) ?? []; + const targetCardData = useFragment(TargetCardFragment, targets); + + const projectHighestNumberOfRequests = useMemo(() => { + let highest = 0; + + for (const target of targetCardData) { + for (const dataPoint of target.operationsStats?.requestsOverTime ?? []) { + if (dataPoint.value > highest) { + highest = dataPoint.value; + } + } } - return [ - [new Date(subDays(new Date(), props.days)).toISOString(), 0], - [new Date().toISOString(), 0], - ] as [string, number][]; - }, [props.requestsOverTime]); + return highest; + }, [targetCardData]); - const totalNumberOfRequests = useMemo( - () => requests.reduce((acc, [_, value]) => acc + value, 0), - [requests], - ); - const totalNumberOfVersions = props.schemaVersionsCount ?? 0; + useEffect(() => { + if (query.fetching || targets.length === 0) { + return; + } - const requestsInDateRange = useFormattedNumber(totalNumberOfRequests); - const schemaVersionsInDateRange = useFormattedNumber(totalNumberOfVersions); + props.onRequestsMaxChange(props.id, projectHighestNumberOfRequests); + }, [ + props.id, + props.onRequestsMaxChange, + projectHighestNumberOfRequests, + query.fetching, + targets.length, + ]); + + useEffect(() => { + return () => { + props.onRequestsMaxChange(props.id, null); + }; + }, [props.id, props.onRequestsMaxChange]); return ( - - +
- -
-
-
- - {size => ( - - )} - -
-
- {project ? ( -
-

{project.slug}

-

{projectTypeFullNames[project.type]}

-
- ) : ( -
-
-
-
- )} -
- {project ? ( - <> - - -
- -
- {requestsInDateRange}{' '} - {pluralize(totalNumberOfRequests, 'request', 'requests')} -
-
-
- - Number of GraphQL requests in the last {props.days} days. - -
- - -
- -
- {schemaVersionsInDateRange}{' '} - {pluralize(totalNumberOfVersions, 'commit', 'commits')} -
-
-
- - Number of schemas pushed to this project in the last {props.days} days. - -
- - ) : ( - <> -
-
- - )} -
-
-
+ +
+

{props.slug}

+ {projectTypeFullNames[props.type]} +
+
+ + + + + + + + + Targets + + + + + Alerts + + + + + Settings + + + +
- - - + +
+ {targets.length === 0 || query.fetching + ? Array.from({ length: 3 }).map((_, index) => ( + + )) + : targets?.map(target => ( + } + /> + ))} +
+
+ ); }; -const OrganizationProjectsPageQuery = graphql(` - query OrganizationProjectsPageQuery( - $organizationSlug: String! - $chartResolution: Int! - $period: DateRangeInput! - ) { - organization: organizationBySlug(organizationSlug: $organizationSlug) { - id - slug - projects { - edges { - node { - id - slug - ...ProjectCard_ProjectFragment - totalRequests(period: $period) - requestsOverTime(resolution: $chartResolution, period: $period) { - date - value - } - schemaVersionsCount(period: $period) - } - } - } - } - } -`); +const ProjectCardSkeleton = () => { + return ( +
+
+
+
+ +
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+
+ ); +}; function OrganizationPageContent( props: { @@ -258,39 +295,53 @@ function OrganizationPageContent( } & RouteSearchProps, ) { const days = 14; + const now = useRef(new UTCDate()); + const period = useRef<{ from: string; to: string; - }>(); + }>({ + from: formatISO(startOfDay(subDays(now.current, days))), + to: formatISO(endOfDay(now.current)), + }); // Sort by requests by default const sortKey = props.sortBy ?? 'requests'; - const sortOrder = + const sortOrder: 'asc' | 'desc' = props.sortOrder === 'asc' - ? -1 + ? 'asc' : // if the sort order is not set, sort by name in ascending order by default !props.sortOrder && props.sortBy === 'name' - ? -1 + ? 'asc' : // if the sort order is not set, sort in descending order by default - 1; + 'desc'; - if (!period.current) { - const now = new UTCDate(); - const from = formatISO(startOfDay(subDays(now, days))); - const to = formatISO(endOfDay(now)); + const router = useRouter(); - period.current = { from, to }; - } + const sort = useMemo(() => { + let field: ProjectsSortFieldType = ProjectsSortFieldType.Name; + let direction: ProjectsSortDirectionType = ProjectsSortDirectionType.Asc; - const router = useRouter(); + if (sortKey === 'requests') { + field = ProjectsSortFieldType.Requests; + } else if (sortKey === 'versions') { + field = ProjectsSortFieldType.SchemaVersions; + } + + if (sortOrder === 'desc') { + direction = ProjectsSortDirectionType.Desc; + } + + return { field, direction, period: period.current }; + }, [sortKey, sortOrder, period.current]); const [query] = useQuery({ query: OrganizationProjectsPageQuery, variables: { organizationSlug: props.organizationSlug, - chartResolution: days, // 14 days = 14 data points - period: period.current, + search: props.search, + sort, }, requestPolicy: 'cache-and-network', }); @@ -298,56 +349,34 @@ function OrganizationPageContent( const currentOrganization = query.data?.organization; const projectsConnection = currentOrganization?.projects; - const highestNumberOfRequests = useMemo(() => { - let highest = 10; + const projects = projectsConnection?.edges.map(edge => edge.node); - if (projectsConnection?.edges.length) { - for (const edge of projectsConnection.edges) { - for (const dataPoint of edge.node.requestsOverTime) { - if (dataPoint.value > highest) { - highest = dataPoint.value; - } - } - } - } + const [projectRequestsMaxById, setProjectRequestsMaxById] = useState( + () => new Map(), + ); - return highest; - }, [projectsConnection]); + useEffect(() => { + setProjectRequestsMaxById(new Map()); + }, [props.organizationSlug, props.search, sortKey, sortOrder]); - const projects = useMemo(() => { - if (!projectsConnection) { - return []; - } + const onProjectRequestsMaxChange = useCallback((projectId: string, value: number | null) => { + setProjectRequestsMaxById(current => { + const next = new Map(current); - const searchPhrase = props.search; - const newProjects = searchPhrase - ? projectsConnection.edges.filter(edge => - edge.node.slug.toLowerCase().includes(searchPhrase.toLowerCase()), - ) - : projectsConnection.edges.slice(); - - return newProjects - .map(project => project.node) - .sort((a, b) => { - const diffRequests = b.totalRequests - a.totalRequests; - const diffVersions = b.schemaVersionsCount - a.schemaVersionsCount; - - if (sortKey === 'requests' && diffRequests !== 0) { - return diffRequests * sortOrder; - } - - if (sortKey === 'versions' && diffVersions !== 0) { - return diffVersions * sortOrder; - } + if (value === null) { + next.delete(projectId); + } else { + next.set(projectId, value); + } - if (sortKey === 'name') { - return a.slug.localeCompare(b.slug) * sortOrder * -1; - } + return next; + }); + }, []); - // falls back to sort by name in ascending order - return a.slug.localeCompare(b.slug); - }); - }, [projectsConnection, props.search, sortKey, sortOrder]); + const highestNumberOfRequests = useMemo( + () => Math.max(10, ...projectRequestsMaxById.values()), + [projectRequestsMaxById], + ); const onSearchChange = useCallback( (event: ChangeEvent) => { @@ -456,40 +485,37 @@ function OrganizationPageContent(
- {currentOrganization && projectsConnection ? ( - projectsConnection.edges.length === 0 ? ( + {currentOrganization && projects ? ( + projects?.length === 0 ? ( ) : ( -
- {projects.map(project => ( +
+ {projects?.map(project => ( ))}
) ) : ( -
+
{Array.from({ length: 4 }).map((_, index) => ( - + ))}
)} diff --git a/packages/web/app/src/pages/project.tsx b/packages/web/app/src/pages/project.tsx index 17a51dab5de..6b3477f0fde 100644 --- a/packages/web/app/src/pages/project.tsx +++ b/packages/web/app/src/pages/project.tsx @@ -1,12 +1,14 @@ import { ChangeEvent, ReactElement, useCallback, useMemo, useRef } from 'react'; import { endOfDay, formatISO, startOfDay } from 'date-fns'; -import * as echarts from 'echarts'; -import ReactECharts from 'echarts-for-react'; -import { Globe, History, MoveDownIcon, MoveUpIcon, SearchIcon } from 'lucide-react'; -import AutoSizer from 'react-virtualized-auto-sizer'; +import { MoveDownIcon, MoveUpIcon, SearchIcon } from 'lucide-react'; import { useQuery } from 'urql'; import { z } from 'zod'; import { Page, ProjectLayout } from '@/components/layouts/project'; +import { + TargetCard, + TargetCardFragment, + TargetCardSkeleton, +} from '@/components/organization/TargetCard'; import { Button } from '@/components/ui/button'; import { EmptyList } from '@/components/ui/empty-list'; import { Input } from '@/components/ui/input'; @@ -15,198 +17,12 @@ import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { Card } from '@/components/v2/card'; import { FragmentType, graphql, useFragment } from '@/gql'; +import { TargetsSortDirectionType, TargetsSortFieldType } from '@/gql/graphql'; import { subDays } from '@/lib/date-time'; -import { useFormattedNumber } from '@/lib/hooks'; -import { cn, pluralize } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { UTCDate } from '@date-fns/utc'; -import { Link, useRouter } from '@tanstack/react-router'; - -const TargetCard_TargetFragment = graphql(` - fragment TargetCard_TargetFragment on Target { - id - slug - } -`); - -const TargetCard = (props: { - target: FragmentType | null; - highestNumberOfRequests: number; - requestsOverTime: { date: string; value: number }[] | null; - schemaVersionsCount: number | null; - days: number; - organizationSlug: string; - projectSlug: string; -}): ReactElement => { - const target = useFragment(TargetCard_TargetFragment, props.target); - const { highestNumberOfRequests } = props; - const requests = useMemo(() => { - if (props.requestsOverTime?.length) { - return props.requestsOverTime.map<[string, number]>(node => [node.date, node.value]); - } - - return [ - [new Date(subDays(new Date(), props.days)).toISOString(), 0], - [new Date().toISOString(), 0], - ] as [string, number][]; - }, [props.requestsOverTime]); - - const totalNumberOfRequests = useMemo( - () => requests.reduce((acc, [_, value]) => acc + value, 0), - [requests], - ); - const totalNumberOfVersions = props.schemaVersionsCount ?? 0; - const requestsInDateRange = useFormattedNumber(totalNumberOfRequests); - const schemaVersionsInDateRange = useFormattedNumber(totalNumberOfVersions); - - return ( - - - -
-
-
- - {size => ( - - )} - -
-
-
- {target ? ( -

{target.slug}

- ) : ( -
- )} -
-
- {target ? ( - <> - - -
- -
- {requestsInDateRange}{' '} - {pluralize(totalNumberOfRequests, 'request', 'requests')} -
-
-
- - Number of GraphQL requests in the last {props.days} days. - -
- - -
- -
- {schemaVersionsInDateRange}{' '} - {pluralize(totalNumberOfVersions, 'commit', 'commits')} -
-
-
- - Number of schemas pushed to this project in the last {props.days} days. - -
- - ) : ( - <> -
-
- - )} -
-
-
-
- - - - ); -}; +import { useRouter } from '@tanstack/react-router'; export const ProjectIndexRouteSearch = z.object({ search: z.string().optional(), @@ -236,14 +52,32 @@ const ProjectsPageContent = ( // Sort by requests by default const sortKey = props.sortBy ?? 'requests'; - const sortOrder = + const sortOrder: 'asc' | 'desc' = props.sortOrder === 'asc' - ? -1 + ? 'asc' : // if the sort order is not set, sort by name in ascending order by default !props.sortOrder && props.sortBy === 'name' - ? -1 + ? 'asc' : // if the sort order is not set, sort in descending order by default - 1; + 'desc'; + + const sort = useMemo(() => { + let field: TargetsSortFieldType = TargetsSortFieldType.Name; + let direction: TargetsSortDirectionType = TargetsSortDirectionType.Asc; + + if (sortKey === 'requests') { + field = TargetsSortFieldType.Requests; + } else if (sortKey === 'versions') { + field = TargetsSortFieldType.SchemaVersions; + } + + if (sortOrder === 'desc') { + direction = TargetsSortDirectionType.Desc; + } + + return { field, direction, period: period.current }; + }, [sortKey, sortOrder, period.current]); + const router = useRouter(); const [query] = useQuery({ @@ -253,59 +87,30 @@ const ProjectsPageContent = ( projectSlug: props.projectSlug, chartResolution: days, // 14 days = 14 data points period: period.current, + sort, + search: props.search, }, requestPolicy: 'cache-and-network', }); - const targetConnection = query.data?.targets; - - const targets = useMemo(() => { - if (!targetConnection) { - return []; - } - - const searchPhrase = props.search; - const newTargets = searchPhrase - ? targetConnection.edges.filter(edge => - edge.node.slug.toLowerCase().includes(searchPhrase.toLowerCase()), - ) - : targetConnection.edges.slice(); - - return newTargets - .map(edge => edge.node) - .sort((a, b) => { - const diffRequests = b.totalRequests - a.totalRequests; - const diffVersions = b.schemaVersionsCount - a.schemaVersionsCount; - - if (sortKey === 'requests' && diffRequests !== 0) { - return diffRequests * sortOrder; - } - - if (sortKey === 'versions' && diffVersions !== 0) { - return diffVersions * sortOrder; - } - - if (sortKey === 'name') { - return a.slug.localeCompare(b.slug) * sortOrder * -1; - } - - // falls back to sort by name in ascending order - return a.slug.localeCompare(b.slug); - }); - }, [targetConnection, props.search, sortKey, sortOrder]); + const targets = query.data?.targets.edges.map(edge => edge.node); + const targetCardData = useFragment(TargetCardFragment, targets); const highestNumberOfRequests = useMemo(() => { - if (targetConnection?.edges?.length) { - return targetConnection.edges.reduce((max, edge) => { + if (targetCardData?.length) { + return targetCardData.reduce((max, target) => { return Math.max( max, - edge.node.requestsOverTime.reduce((max, { value }) => Math.max(max, value), 0), + target.operationsStats?.requestsOverTime?.reduce( + (max, { value }) => Math.max(max, value), + 0, + ) ?? 0, ); }, 100); } return 100; - }, [targetConnection?.edges]); + }, [targets]); const onSearchChange = useCallback( (event: ChangeEvent) => { @@ -417,13 +222,13 @@ const ProjectsPageContent = (
- {targetConnection ? ( - targetConnection?.edges.length === 0 ? ( + {targets ? ( + targets?.length === 0 ? ( ( } /> )) ) ) : ( <> - {Array.from({ length: 4 }).map((_, index) => ( - + {Array.from({ length: 3 }).map((_, index) => ( + ))} )} @@ -470,19 +266,19 @@ const ProjectOverviewPageQuery = graphql(` $projectSlug: String! $chartResolution: Int! $period: DateRangeInput! + $sort: TargetsSortInput! + $search: String ) { - targets(selector: { organizationSlug: $organizationSlug, projectSlug: $projectSlug }) { + targets( + selector: { organizationSlug: $organizationSlug, projectSlug: $projectSlug } + sort: $sort + search: $search + ) { edges { node { id slug - ...TargetCard_TargetFragment - totalRequests(period: $period) - requestsOverTime(resolution: $chartResolution, period: $period) { - date - value - } - schemaVersionsCount(period: $period) + ...TargetCardFragment } } }