diff --git a/integration-tests/testkit/saved-filters.ts b/integration-tests/testkit/saved-filters.ts new file mode 100644 index 000000000..97498109b --- /dev/null +++ b/integration-tests/testkit/saved-filters.ts @@ -0,0 +1,174 @@ +import { graphql } from './gql'; + +export const GetSavedFilterQuery = graphql(` + query GetSavedFilter($selector: TargetSelectorInput!, $id: ID!) { + target(reference: { bySelector: $selector }) { + id + savedFilter(id: $id) { + id + name + description + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + visibility + viewsCount + createdAt + updatedAt + createdBy { + id + displayName + } + viewerCanUpdate + viewerCanDelete + } + } + } +`); + +export const GetSavedFiltersQuery = graphql(` + query GetSavedFilters( + $selector: TargetSelectorInput! + $first: Int! + $after: String + $visibility: SavedFilterVisibilityType + $search: String + ) { + target(reference: { bySelector: $selector }) { + id + savedFilters(first: $first, after: $after, visibility: $visibility, search: $search) { + edges { + cursor + node { + id + name + description + visibility + viewsCount + createdAt + createdBy { + id + displayName + } + viewerCanUpdate + viewerCanDelete + } + } + pageInfo { + hasNextPage + endCursor + } + } + viewerCanCreateSavedFilter + } + } +`); + +export const CreateSavedFilterMutation = graphql(` + mutation CreateSavedFilter($input: CreateSavedFilterInput!) { + createSavedFilter(input: $input) { + error { + message + } + ok { + savedFilter { + id + name + description + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + visibility + viewsCount + createdAt + createdBy { + id + } + viewerCanUpdate + viewerCanDelete + } + } + } + } +`); + +export const UpdateSavedFilterMutation = graphql(` + mutation UpdateSavedFilter($input: UpdateSavedFilterInput!) { + updateSavedFilter(input: $input) { + error { + message + } + ok { + savedFilter { + id + name + description + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + visibility + viewsCount + updatedAt + updatedBy { + id + } + viewerCanUpdate + viewerCanDelete + } + } + } + } +`); + +export const DeleteSavedFilterMutation = graphql(` + mutation DeleteSavedFilter($input: DeleteSavedFilterInput!) { + deleteSavedFilter(input: $input) { + error { + message + } + ok { + deletedId + } + } + } +`); + +export const TrackSavedFilterViewMutation = graphql(` + mutation TrackSavedFilterView($input: TrackSavedFilterViewInput!) { + trackSavedFilterView(input: $input) { + error { + message + } + ok { + savedFilter { + id + viewsCount + } + } + } + } +`); diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 098097458..76742afc2 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -58,6 +58,14 @@ import { TargetAccessScope, } from './gql/graphql'; import { execute } from './graphql'; +import { + CreateSavedFilterMutation, + DeleteSavedFilterMutation, + GetSavedFilterQuery, + GetSavedFiltersQuery, + TrackSavedFilterViewMutation, + UpdateSavedFilterMutation, +} from './saved-filters'; import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from './schema-policy'; import { collect, CollectedOperation, legacyCollect } from './usage'; import { generateUnique, getServiceHost } from './utils'; @@ -490,6 +498,187 @@ export function initSeed() { return result.updateOperationInDocumentCollection; }, + async getSavedFilter({ + filterId, + token = ownerToken, + }: { + filterId: string; + token?: string; + }) { + const result = await execute({ + document: GetSavedFilterQuery, + variables: { + id: filterId, + selector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.target?.savedFilter; + }, + async getSavedFilters({ + first = 20, + after, + visibility, + search, + token = ownerToken, + }: { + first?: number; + after?: string; + visibility?: GraphQLSchema.SavedFilterVisibilityType; + search?: string; + token?: string; + }) { + const result = await execute({ + document: GetSavedFiltersQuery, + variables: { + first, + after, + visibility, + search, + selector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return { + savedFilters: result.target?.savedFilters, + viewerCanCreateSavedFilter: result.target?.viewerCanCreateSavedFilter, + }; + }, + async createSavedFilter({ + name, + description, + visibility, + insightsFilter, + token = ownerToken, + }: { + name: string; + description?: string; + visibility: GraphQLSchema.SavedFilterVisibilityType; + insightsFilter?: GraphQLSchema.InsightsFilterConfigurationInput; + token?: string; + }) { + const result = await execute({ + document: CreateSavedFilterMutation, + variables: { + input: { + target: { + bySelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + }, + name, + description, + visibility, + insightsFilter, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.createSavedFilter; + }, + async updateSavedFilter({ + filterId, + name, + description, + visibility, + insightsFilter, + token = ownerToken, + }: { + filterId: string; + name?: string; + description?: string; + visibility?: GraphQLSchema.SavedFilterVisibilityType; + insightsFilter?: GraphQLSchema.InsightsFilterConfigurationInput; + token?: string; + }) { + const result = await execute({ + document: UpdateSavedFilterMutation, + variables: { + input: { + target: { + bySelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + }, + id: filterId, + name, + description, + visibility, + insightsFilter, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.updateSavedFilter; + }, + async deleteSavedFilter({ + filterId, + token = ownerToken, + }: { + filterId: string; + token?: string; + }) { + const result = await execute({ + document: DeleteSavedFilterMutation, + variables: { + input: { + target: { + bySelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + }, + id: filterId, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.deleteSavedFilter; + }, + async trackSavedFilterView({ + filterId, + token = ownerToken, + }: { + filterId: string; + token?: string; + }) { + const result = await execute({ + document: TrackSavedFilterViewMutation, + variables: { + input: { + target: { + bySelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + }, + id: filterId, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.trackSavedFilterView; + }, async addAlert( input: { token?: string; diff --git a/integration-tests/tests/api/organization/members.spec.ts b/integration-tests/tests/api/organization/members.spec.ts index a28a57d03..c1e6c7d56 100644 --- a/integration-tests/tests/api/organization/members.spec.ts +++ b/integration-tests/tests/api/organization/members.spec.ts @@ -30,6 +30,7 @@ test.concurrent('owner of an organization should have all scopes', async ({ expe project:delete, project:modifySettings, projectAccessToken:modify, + sharedSavedFilter:modify, schemaLinting:modifyProjectRules, target:create, alert:modify, diff --git a/integration-tests/tests/api/saved-filters/saved-filters.spec.ts b/integration-tests/tests/api/saved-filters/saved-filters.spec.ts new file mode 100644 index 000000000..50a3edd70 --- /dev/null +++ b/integration-tests/tests/api/saved-filters/saved-filters.spec.ts @@ -0,0 +1,434 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +import { ProjectType, SavedFilterVisibilityType } from 'testkit/gql/graphql'; +import { initSeed } from '../../../testkit/seed'; + +describe('Saved Filters', () => { + describe('CRUD', () => { + test.concurrent('create, update and delete a saved filter', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter, updateSavedFilter, deleteSavedFilter, getSavedFilter } = + await createProject(ProjectType.Single); + + // Create a filter + const createResult = await createSavedFilter({ + name: 'My Filter', + description: 'Filter for high-traffic operations', + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { + operationHashes: ['op1', 'op2'], + clientFilters: [{ name: 'Hive CLI', versions: ['0.12.1', '0.12.3'] }], + }, + }); + + expect(createResult.error).toBeNull(); + expect(createResult.ok?.savedFilter.id).toBeDefined(); + expect(createResult.ok?.savedFilter.name).toBe('My Filter'); + expect(createResult.ok?.savedFilter.description).toBe('Filter for high-traffic operations'); + + expect(createResult.ok?.savedFilter.visibility).toBe('PRIVATE'); + expect(createResult.ok?.savedFilter.viewsCount).toBe(0); + expect(createResult.ok?.savedFilter.filters.operationHashes).toEqual(['op1', 'op2']); + expect(createResult.ok?.savedFilter.filters.clientFilters).toEqual([ + { name: 'Hive CLI', versions: ['0.12.1', '0.12.3'] }, + ]); + expect(createResult.ok?.savedFilter.viewerCanUpdate).toBe(true); + expect(createResult.ok?.savedFilter.viewerCanDelete).toBe(true); + + const filterId = createResult.ok?.savedFilter.id!; + + // Verify filter can be fetched + const fetchedFilter = await getSavedFilter({ filterId }); + expect(fetchedFilter?.id).toBe(filterId); + expect(fetchedFilter?.name).toBe('My Filter'); + + // Update the filter + const updateResult = await updateSavedFilter({ + filterId, + name: 'Updated Filter Name', + description: 'Updated description', + visibility: SavedFilterVisibilityType.Shared, + insightsFilter: { + operationHashes: ['op3'], + clientFilters: [{ name: 'Gateway' }], + }, + }); + + expect(updateResult.error).toBeNull(); + expect(updateResult.ok?.savedFilter.id).toBe(filterId); + expect(updateResult.ok?.savedFilter.name).toBe('Updated Filter Name'); + expect(updateResult.ok?.savedFilter.description).toBe('Updated description'); + expect(updateResult.ok?.savedFilter.visibility).toBe('SHARED'); + expect(updateResult.ok?.savedFilter.filters.operationHashes).toEqual(['op3']); + expect(updateResult.ok?.savedFilter.filters.clientFilters).toEqual([ + { name: 'Gateway', versions: null }, + ]); + expect(updateResult.ok?.savedFilter.updatedBy?.id).toBeDefined(); + + // Delete the filter + const deleteResult = await deleteSavedFilter({ filterId }); + expect(deleteResult.error).toBeNull(); + expect(deleteResult.ok?.deletedId).toBe(filterId); + + // Verify filter is deleted + const deletedFilter = await getSavedFilter({ filterId }); + expect(deletedFilter).toBeNull(); + }); + + test.concurrent('list saved filters with pagination and filtering', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter, getSavedFilters } = await createProject(ProjectType.Single); + + // Create multiple filters + await createSavedFilter({ + name: 'Private Filter 1', + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { operationHashes: ['op1'] }, + }); + + await createSavedFilter({ + name: 'Private Filter 2', + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { operationHashes: ['op2'] }, + }); + + await createSavedFilter({ + name: 'Shared Filter 1', + + visibility: SavedFilterVisibilityType.Shared, + insightsFilter: { operationHashes: ['op3'] }, + }); + + // List all filters (private + shared for owner) + const allFilters = await getSavedFilters({ + first: 10, + }); + + expect(allFilters.savedFilters?.edges.length).toBe(3); + expect(allFilters.viewerCanCreateSavedFilter).toBe(true); + + // List only private filters + const privateFilters = await getSavedFilters({ + first: 10, + visibility: SavedFilterVisibilityType.Private, + }); + + expect(privateFilters.savedFilters?.edges.length).toBe(2); + expect(privateFilters.savedFilters?.edges.every(e => e.node.visibility === 'PRIVATE')).toBe( + true, + ); + + // List only shared filters + const sharedFilters = await getSavedFilters({ + first: 10, + visibility: SavedFilterVisibilityType.Shared, + }); + + expect(sharedFilters.savedFilters?.edges.length).toBe(1); + expect(sharedFilters.savedFilters?.edges[0].node.visibility).toBe('SHARED'); + + // Search by name + const searchResults = await getSavedFilters({ + first: 10, + search: 'Private', + }); + + expect(searchResults.savedFilters?.edges.length).toBe(2); + }); + + test.concurrent('track filter views', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter, trackSavedFilterView, getSavedFilter } = await createProject( + ProjectType.Single, + ); + + // Create a filter + const createResult = await createSavedFilter({ + name: 'Trackable Filter', + + visibility: SavedFilterVisibilityType.Shared, + insightsFilter: { operationHashes: ['op1'] }, + }); + + const filterId = createResult.ok?.savedFilter.id!; + expect(createResult.ok?.savedFilter.viewsCount).toBe(0); + + // Track views + const trackResult1 = await trackSavedFilterView({ filterId }); + expect(trackResult1.error).toBeNull(); + expect(trackResult1.ok?.savedFilter.viewsCount).toBe(1); + + const trackResult2 = await trackSavedFilterView({ filterId }); + expect(trackResult2.ok?.savedFilter.viewsCount).toBe(2); + + // Verify count persisted + const filter = await getSavedFilter({ filterId }); + expect(filter?.viewsCount).toBe(2); + }); + }); + + describe('Visibility and Permissions', () => { + test.concurrent('private filters should only be visible to the creator', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, inviteAndJoinMember } = await createOrg(); + const { createSavedFilter, getSavedFilter, getSavedFilters } = await createProject( + ProjectType.Single, + ); + + // Create a private filter as owner + const createResult = await createSavedFilter({ + name: 'Owner Private Filter', + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { operationHashes: ['op1'] }, + }); + + const filterId = createResult.ok?.savedFilter.id!; + + // Invite and join a member + const { memberToken } = await inviteAndJoinMember(); + + // Owner can see the filter + const ownerFilter = await getSavedFilter({ filterId, token: ownerToken }); + expect(ownerFilter?.id).toBe(filterId); + + // Member cannot see private filter + const memberFilter = await getSavedFilter({ filterId, token: memberToken }); + expect(memberFilter).toBeNull(); + + // Member cannot see private filter in list + const memberFilters = await getSavedFilters({ + first: 10, + token: memberToken, + }); + expect(memberFilters.savedFilters?.edges.length).toBe(0); + }); + + test.concurrent( + 'shared filters should be visible to all project members', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, inviteAndJoinMember } = await createOrg(); + const { createSavedFilter, getSavedFilter, getSavedFilters } = await createProject( + ProjectType.Single, + ); + + // Create a shared filter as owner + const createResult = await createSavedFilter({ + name: 'Shared Filter', + + visibility: SavedFilterVisibilityType.Shared, + insightsFilter: { operationHashes: ['op1'] }, + }); + + const filterId = createResult.ok?.savedFilter.id!; + + // Invite and join a member + const { memberToken } = await inviteAndJoinMember(); + + // Member can see the shared filter + const memberFilter = await getSavedFilter({ filterId, token: memberToken }); + expect(memberFilter?.id).toBe(filterId); + expect(memberFilter?.name).toBe('Shared Filter'); + + // Member can see shared filter in list + const memberFilters = await getSavedFilters({ + first: 10, + token: memberToken, + }); + expect(memberFilters.savedFilters?.edges.length).toBe(1); + }, + ); + + test.concurrent( + 'shared filters should be updatable by any project member with sharedSavedFilter:modify permission', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, inviteAndJoinMember } = await createOrg(); + const { createSavedFilter, updateSavedFilter, getSavedFilter } = await createProject( + ProjectType.Single, + ); + + // Create a shared filter as owner + const createResult = await createSavedFilter({ + name: 'Shared Filter', + + visibility: SavedFilterVisibilityType.Shared, + insightsFilter: { operationHashes: ['op1'] }, + }); + + const filterId = createResult.ok?.savedFilter.id!; + + // First invite a member to get access to createMemberRole + const { createMemberRole } = await inviteAndJoinMember(); + + // Create a role with sharedSavedFilter:modify permission (without project:modifySettings) + const role = await createMemberRole([ + 'organization:describe', + 'project:describe', + 'sharedSavedFilter:modify', + ]); + + // Invite and join a member with the sharedSavedFilter:modify role + const { memberToken } = await inviteAndJoinMember({ memberRoleId: role.id }); + + // Member can see the filter + const memberView = await getSavedFilter({ filterId, token: memberToken }); + expect(memberView?.viewerCanUpdate).toBe(true); + expect(memberView?.viewerCanDelete).toBe(true); + + // Member can update shared filter + const updateResult = await updateSavedFilter({ + filterId, + name: 'Updated by Member', + token: memberToken, + }); + + expect(updateResult.error).toBeNull(); + expect(updateResult.ok?.savedFilter.name).toBe('Updated by Member'); + }, + ); + + test.concurrent( + 'private filters can be managed by any project member without sharedSavedFilter:modify', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, inviteAndJoinMember } = await createOrg(); + const { createSavedFilter, updateSavedFilter, deleteSavedFilter } = await createProject( + ProjectType.Single, + ); + + // First invite a member to get access to createMemberRole + const { createMemberRole } = await inviteAndJoinMember(); + + // Create a role with only project:describe (no sharedSavedFilter:modify) + const role = await createMemberRole(['organization:describe', 'project:describe']); + + // Invite and join a member with the viewer-like role + const { memberToken } = await inviteAndJoinMember({ memberRoleId: role.id }); + + // Member can create a private filter + const createResult = await createSavedFilter({ + name: 'My Private Filter', + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { operationHashes: ['op1'] }, + token: memberToken, + }); + + expect(createResult.error).toBeNull(); + expect(createResult.ok?.savedFilter.name).toBe('My Private Filter'); + expect(createResult.ok?.savedFilter.visibility).toBe('PRIVATE'); + + const filterId = createResult.ok?.savedFilter.id!; + + // Member can update their own private filter + const updateResult = await updateSavedFilter({ + filterId, + name: 'Updated Private Filter', + token: memberToken, + }); + + expect(updateResult.error).toBeNull(); + expect(updateResult.ok?.savedFilter.name).toBe('Updated Private Filter'); + + // Member can delete their own private filter + const deleteResult = await deleteSavedFilter({ filterId, token: memberToken }); + expect(deleteResult.error).toBeNull(); + expect(deleteResult.ok?.deletedId).toBe(filterId); + }, + ); + + test.concurrent( + 'create shared filter: failure without sharedSavedFilter:modify permission', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, inviteAndJoinMember } = await createOrg(); + const { createSavedFilter } = await createProject(ProjectType.Single); + + // First invite a member to get access to createMemberRole + const { createMemberRole } = await inviteAndJoinMember(); + + // Create a role with only project:describe (no sharedSavedFilter:modify) + const role = await createMemberRole(['organization:describe', 'project:describe']); + + // Invite and join a member with the viewer-like role + const { memberToken } = await inviteAndJoinMember({ memberRoleId: role.id }); + + // Try to create a shared filter - should fail + await expect( + createSavedFilter({ + name: 'Unauthorized Shared Filter', + + visibility: SavedFilterVisibilityType.Shared, + insightsFilter: { operationHashes: ['op1'] }, + token: memberToken, + }), + ).rejects.toEqual( + expect.objectContaining({ + message: expect.stringContaining( + `No access (reason: "Missing permission for performing 'sharedSavedFilter:modify' on resource")`, + ), + }), + ); + }, + ); + }); + + describe('Validation', () => { + test.concurrent('create: failure with empty name', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter } = await createProject(ProjectType.Single); + + const result = await createSavedFilter({ + name: '', + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { operationHashes: ['op1'] }, + }); + + expect(result.error).not.toBeNull(); + expect(result.error?.message).toContain('String must contain at least 1 character'); + }); + + test.concurrent('create: failure with name exceeding 100 characters', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter } = await createProject(ProjectType.Single); + + const result = await createSavedFilter({ + name: 'a'.repeat(101), + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { operationHashes: ['op1'] }, + }); + + expect(result.error).not.toBeNull(); + expect(result.error?.message).toContain('String must contain at most 100 character'); + }); + + test.concurrent('create: failure with too many operations', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter } = await createProject(ProjectType.Single); + + const result = await createSavedFilter({ + name: 'Too many ops', + + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { + operationHashes: Array.from({ length: 101 }, (_, i) => `op${i}`), + }, + }); + + expect(result.error).not.toBeNull(); + expect(result.error?.message).toContain('Array must contain at most 100 element'); + }); + }); +}); diff --git a/package.json b/package.json index e524a037a..20b43e8e1 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "release:docs:update-version": "tsx scripts/sync-docker-image-tag-docs.ts", "release:version": "changeset version && pnpm --filter hive-apollo-router-plugin sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme && pnpm run release:docs:update-version", "seed:app-deployments": "tsx scripts/seed-app-deployments.mts", + "seed:insights": "tsx scripts/seed-insights.mts", "seed:org": "tsx scripts/seed-organization.mts", "seed:schemas": "tsx scripts/seed-schemas.ts", "seed:usage": "tsx scripts/seed-usage.ts", diff --git a/packages/migrations/src/actions/2026.02.07T00-00-00.saved-filters.ts b/packages/migrations/src/actions/2026.02.07T00-00-00.saved-filters.ts new file mode 100644 index 000000000..d0341ee86 --- /dev/null +++ b/packages/migrations/src/actions/2026.02.07T00-00-00.saved-filters.ts @@ -0,0 +1,40 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2026.02.07T00-00-00.saved-filters.ts', + run: ({ sql }) => sql` +CREATE TYPE "saved_filter_visibility" AS ENUM ('private', 'shared'); + +CREATE TABLE "saved_filters" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "project_id" uuid NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "created_by_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "updated_by_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "name" text NOT NULL, + "description" text, + "filters" jsonb NOT NULL, + "visibility" "saved_filter_visibility" NOT NULL, + "views_count" integer NOT NULL DEFAULT 0, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("id") +); + +CREATE INDEX "saved_filters_project_visibility_pagination" ON "saved_filters" ( + "project_id" ASC, + "visibility" ASC, + "created_at" DESC, + "id" DESC +); + +CREATE INDEX "saved_filters_user_project" ON "saved_filters" ( + "created_by_user_id" ASC, + "project_id" ASC +); + +CREATE INDEX "saved_filters_project_views" ON "saved_filters" ( + "project_id" ASC, + "views_count" DESC +); +`, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/actions/2026.02.19T00-00-00.saved-filter-permission.ts b/packages/migrations/src/actions/2026.02.19T00-00-00.saved-filter-permission.ts new file mode 100644 index 000000000..8e6bb3046 --- /dev/null +++ b/packages/migrations/src/actions/2026.02.19T00-00-00.saved-filter-permission.ts @@ -0,0 +1,12 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2026.02.19T00-00-00.saved-filter-permission.ts', + run: ({ sql }) => sql` + UPDATE "organization_member_roles" + SET "permissions" = array_append("permissions", 'sharedSavedFilter:modify') + WHERE "permissions" @> ARRAY['project:modifySettings'] + AND NOT ("permissions" @> ARRAY['sharedSavedFilter:modify']) + AND "locked" = false; + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index dd60565e5..f888f2954 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -181,9 +181,11 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2026.01.25T00-00-00.checks-proposals-changes'), await import('./actions/2026.01.27T00-00-00.app-deployment-protection'), await import('./actions/2026.01.09T00-00-00.email-verifications'), + await import('./actions/2026.02.07T00-00-00.saved-filters'), await import('./actions/2026.01.30T00-00-00.account-linking'), await import('./actions/2026.02.06T00-00-00.zendesk-unique'), await import('./actions/2026.01.30T10-00-00.oidc-require-invitation'), + await import('./actions/2026.02.19T00-00-00.saved-filter-permission'), ...(env.useSupertokensAtHome ? [await import('./actions/2026.02.18T00-00-00.ensure-supertokens-tables')] : []), diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index 1e2bd5365..b418631ac 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -39,6 +39,7 @@ import { import { projectModule } from './modules/project'; import { proposalsModule } from './modules/proposals'; import { SCHEMA_PROPOSALS_ENABLED } from './modules/proposals/providers/schema-proposals-enabled-token'; +import { savedFiltersModule } from './modules/saved-filters'; import { schemaModule } from './modules/schema'; import { ArtifactStorageWriter } from './modules/schema/providers/artifact-storage-writer'; import { provideSchemaModuleConfig, SchemaModuleConfig } from './modules/schema/providers/config'; @@ -89,6 +90,7 @@ const modules = [ oidcIntegrationsModule, schemaPolicyModule, collectionModule, + savedFiltersModule, appDeploymentsModule, auditLogsModule, proposalsModule, diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts index 31106c084..bb3cc882f 100644 --- a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts @@ -354,6 +354,30 @@ export const AuditLogModel = z.union([ organizationAccessTokenId: z.string().uuid(), }), }), + z.object({ + eventType: z.literal('SAVED_FILTER_CREATED'), + metadata: z.object({ + filterId: z.string().uuid(), + filterName: z.string(), + visibility: z.string(), + projectId: z.string().uuid(), + }), + }), + z.object({ + eventType: z.literal('SAVED_FILTER_UPDATED'), + metadata: z.object({ + filterId: z.string().uuid(), + filterName: z.string(), + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('SAVED_FILTER_DELETED'), + metadata: z.object({ + filterId: z.string().uuid(), + filterName: z.string(), + }), + }), ]); export type AuditLogSchemaEvent = z.infer; diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 83500c800..4603676fb 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -404,6 +404,7 @@ const permissionsByLevel = { z.literal('schemaLinting:modifyProjectRules'), z.literal('target:create'), z.literal('projectAccessToken:modify'), + z.literal('sharedSavedFilter:modify'), ], target: [ z.literal('targetAccessToken:modify'), diff --git a/packages/services/api/src/modules/operations/module.graphql.mappers.ts b/packages/services/api/src/modules/operations/module.graphql.mappers.ts index 4ee7799a9..2781f8b7c 100644 --- a/packages/services/api/src/modules/operations/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/operations/module.graphql.mappers.ts @@ -36,6 +36,7 @@ export interface OperationsStatsMapper { period: DateRange; operations: readonly string[]; clients: readonly string[]; + clientVersionFilters: readonly { clientName: string; versions: readonly string[] | null }[]; } export interface DurationValuesMapper { avg: number | null; diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index dbd1dbeed..7d469b8b9 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -9,6 +9,21 @@ export default gql` monthlyUsage(selector: OrganizationSelectorInput!): [MonthlyUsage!]! } + """ + Filter by specific client name + version combinations. + """ + input ClientVersionFilterInput { + """ + The client name to filter by. + """ + clientName: String! @tag(name: "public") + """ + Specific versions of this client to include. + When null, all versions of this client are included. + """ + versions: [String!] @tag(name: "public") + } + input OperationStatsFilterInput { """ Filter by only showing operations with a specific id. @@ -17,7 +32,16 @@ export default gql` """ Filter by only showing operations performed by specific clients. """ - clientNames: [String!] @tag(name: "public") + clientNames: [String!] + @tag(name: "public") + @deprecated( + reason: "Use 'clientVersionFilters' instead for more precise filtering by client name and version." + ) + """ + Filter by specific client name + version combinations. + More precise than clientNames - allows filtering to specific versions. + """ + clientVersionFilters: [ClientVersionFilterInput!] @tag(name: "public") } extend type Target { 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 1c3a99255..a1a9d608c 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -138,10 +138,12 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; } & TargetSelector) { this.logger.info('Counting unique operations (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -158,6 +160,7 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, }); } @@ -244,10 +247,12 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; } & Listify) { this.logger.info('Counting requests and failures (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -265,6 +270,7 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, }) .then(r => r.total); } @@ -320,10 +326,12 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; } & TargetSelector) { this.logger.info('Counting failures (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -340,6 +348,7 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, }); } @@ -435,11 +444,13 @@ export class OperationsManager { targetId: target, operations, clients, + clientVersionFilters, schemaCoordinate, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; } & TargetSelector) { this.logger.info('Reading operations stats (period=%o, target=%s)', period, target); @@ -458,6 +469,7 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, schemaCoordinate, }); } @@ -571,12 +583,14 @@ export class OperationsManager { targetId: target, operations, clients, + clientVersionFilters, schemaCoordinate, }: { period: DateRange; resolution: number; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; } & TargetSelector) { this.logger.info( @@ -600,6 +614,7 @@ export class OperationsManager { resolution, operations, clients, + clientVersionFilters, schemaCoordinate, }); } @@ -612,11 +627,13 @@ export class OperationsManager { targetId: target, operations, clients, + clientVersionFilters, }: { period: DateRange; resolution: number; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; } & TargetSelector) { this.logger.info( 'Reading failures over time (period=%o, resolution=%s, target=%s)', @@ -639,6 +656,7 @@ export class OperationsManager { resolution, operations, clients, + clientVersionFilters, }); } @@ -650,11 +668,13 @@ export class OperationsManager { targetId: target, operations, clients, + clientVersionFilters, }: { period: DateRange; resolution: number; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; } & TargetSelector) { this.logger.info( 'Reading duration over time (period=%o, resolution=%s, target=%s)', @@ -677,6 +697,7 @@ export class OperationsManager { resolution, operations, clients, + clientVersionFilters, }); } @@ -687,10 +708,12 @@ export class OperationsManager { targetId: target, operations, clients, + clientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; } & TargetSelector) { this.logger.info('Reading overall duration percentiles (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -707,6 +730,7 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, }); } @@ -718,11 +742,13 @@ export class OperationsManager { targetId: target, operations, clients, + clientVersionFilters, schemaCoordinate, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; } & TargetSelector) { this.logger.info( @@ -745,6 +771,7 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, schemaCoordinate, }); } @@ -756,11 +783,13 @@ export class OperationsManager { targetId: target, operations, clients, + clientVersionFilters, schemaCoordinate, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; } & TargetSelector) { this.logger.info('Counting unique clients (period=%o, target=%s)', period, target); @@ -778,6 +807,7 @@ export class OperationsManager { period, operations, clients, + clientVersionFilters, schemaCoordinate, }); } 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 1b1d666bd..021c5f88a 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -455,12 +455,14 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, schemaCoordinate, }: { target: string | readonly string[]; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; }): Promise<{ total: number; @@ -481,6 +483,7 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -517,13 +520,17 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; }): Promise { - return this.countRequests({ target, period, operations, clients }).then(r => r.notOk); + return this.countRequests({ target, period, operations, clients, clientVersionFilters }).then( + r => r.notOk, + ); } async countUniqueDocuments({ @@ -531,11 +538,13 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; }): Promise { const query = this.pickAggregationByPeriod({ period, @@ -551,6 +560,7 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, }, )}`, queryId: aggregation => `count_unique_documents_${aggregation}`, @@ -568,12 +578,14 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, schemaCoordinate, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; }): Promise< Array<{ @@ -600,6 +612,7 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -643,6 +656,7 @@ export class OperationsReader { target, period, operations, + clientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${sql.raw('coordinates_' + query.queryType)} ${this.createFilter( @@ -796,12 +810,14 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, schemaCoordinate, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; }): Promise< Array<{ @@ -832,6 +848,7 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -1713,6 +1730,7 @@ export class OperationsReader { resolution, operations, clients, + clientVersionFilters, schemaCoordinate, }: { target: string; @@ -1720,6 +1738,7 @@ export class OperationsReader { resolution: number; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; }) { const results = await this.getDurationAndCountOverTime({ @@ -1728,6 +1747,7 @@ export class OperationsReader { resolution, operations, clients, + clientVersionFilters, schemaCoordinate, }); @@ -1743,12 +1763,14 @@ export class OperationsReader { resolution, operations, clients, + clientVersionFilters, }: { target: string; period: DateRange; resolution: number; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; }) { const result = await this.getDurationAndCountOverTime({ target, @@ -1756,6 +1778,7 @@ export class OperationsReader { resolution, operations, clients, + clientVersionFilters, }); return result.map(row => ({ @@ -1770,10 +1793,12 @@ export class OperationsReader { resolution, operations, clients, + clientVersionFilters, }: { target: string; period: DateRange; resolution: number; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; operations?: readonly string[]; clients?: readonly string[]; }): Promise< @@ -1788,6 +1813,7 @@ export class OperationsReader { resolution, operations, clients, + clientVersionFilters, }); } @@ -1796,11 +1822,13 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; }): Promise { const result = await this.clickHouse.query<{ percentiles: [number, number, number, number]; @@ -1812,7 +1840,7 @@ export class OperationsReader { avgMerge(duration_avg) as average, quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles FROM ${aggregationTableName('operations')} - ${this.createFilter({ target, period, operations, clients })} + ${this.createFilter({ target, period, operations, clients, clientVersionFilters })} `, queryId: aggregation => `general_duration_percentiles_${aggregation}`, timeout: 15_000, @@ -1828,8 +1856,10 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, schemaCoordinate, }: { + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; target: string; period: DateRange; operations?: readonly string[]; @@ -1853,6 +1883,7 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -1914,6 +1945,7 @@ export class OperationsReader { resolution, operations, clients, + clientVersionFilters, schemaCoordinate, }: { target: string; @@ -1921,6 +1953,7 @@ export class OperationsReader { resolution: number; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; schemaCoordinate?: string; }) { const interval = calculateTimeWindow({ period, resolution }); @@ -1963,6 +1996,7 @@ export class OperationsReader { period: roundedPeriod, operations, clients, + clientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -2255,6 +2289,7 @@ export class OperationsReader { period, operations, clients, + clientVersionFilters, extra = [], skipWhere = false, namespace, @@ -2263,6 +2298,7 @@ export class OperationsReader { period?: DateRange; operations?: readonly string[]; clients?: readonly string[]; + clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; extra?: SqlValue[]; skipWhere?: boolean; namespace?: string; @@ -2294,6 +2330,19 @@ export class OperationsReader { where.push(sql`${sql.raw(namespace ?? '')}client_name IN (${sql.array(clients, 'String')})`); } + if (clientVersionFilters?.length) { + // Build OR conditions for each client+versions combination + const versionConditions = clientVersionFilters.map(filter => { + const clientName = filter.clientName === 'unknown' ? '' : filter.clientName; + if (!filter.versions?.length) { + // null/empty versions = all versions of this client + return sql`(${columnPrefix}client_name = ${clientName})`; + } + return sql`(${columnPrefix}client_name = ${clientName} AND ${columnPrefix}client_version IN (${sql.array(filter.versions, 'String')}))`; + }); + where.push(sql`(${sql.join(versionConditions, ' OR ')})`); + } + if (extra.length) { where.push(...extra); } diff --git a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts index 259dccb82..506a4b8a0 100644 --- a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts @@ -4,7 +4,15 @@ import type { OperationsStatsResolvers } from './../../../__generated__/types'; export const OperationsStats: OperationsStatsResolvers = { operations: async ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, _, { injector }, ) => { @@ -17,6 +25,7 @@ export const OperationsStats: OperationsStatsResolvers = { period, operations: operationsFilter, clients, + clientVersionFilters, }), operationsManager.readDetailedDurationMetrics({ organizationId: organization, @@ -25,6 +34,7 @@ export const OperationsStats: OperationsStatsResolvers = { period, operations: operationsFilter, clients, + clientVersionFilters, }), ]); @@ -54,7 +64,7 @@ export const OperationsStats: OperationsStatsResolvers = { }; }, totalRequests: ( - { organization, project, target, period, operations, clients }, + { organization, project, target, period, operations, clients, clientVersionFilters }, _, { injector }, ) => { @@ -65,10 +75,19 @@ export const OperationsStats: OperationsStatsResolvers = { period, operations, clients, + clientVersionFilters, }); }, totalFailures: ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, _, { injector }, ) => { @@ -79,10 +98,19 @@ export const OperationsStats: OperationsStatsResolvers = { period, operations: operationsFilter, clients, + clientVersionFilters, }); }, totalOperations: ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, _, { injector }, ) => { @@ -93,10 +121,19 @@ export const OperationsStats: OperationsStatsResolvers = { period, operations: operationsFilter, clients, + clientVersionFilters, }); }, requestsOverTime: ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, { resolution }, { injector }, ) => { @@ -108,10 +145,19 @@ export const OperationsStats: OperationsStatsResolvers = { resolution, operations: operationsFilter, clients, + clientVersionFilters, }); }, failuresOverTime: ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, { resolution }, { injector }, ) => { @@ -123,10 +169,19 @@ export const OperationsStats: OperationsStatsResolvers = { resolution, operations: operationsFilter, clients, + clientVersionFilters, }); }, durationOverTime: ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, { resolution }, { injector }, ) => { @@ -138,10 +193,19 @@ export const OperationsStats: OperationsStatsResolvers = { resolution, operations: operationsFilter, clients, + clientVersionFilters, }); }, clients: async ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, _, { injector }, ) => { @@ -152,6 +216,7 @@ export const OperationsStats: OperationsStatsResolvers = { period, operations: operationsFilter, clients, + clientVersionFilters, }); return { @@ -165,7 +230,15 @@ export const OperationsStats: OperationsStatsResolvers = { }; }, duration: ( - { organization, project, target, period, operations: operationsFilter, clients }, + { + organization, + project, + target, + period, + operations: operationsFilter, + clients, + clientVersionFilters, + }, _, { injector }, ) => { @@ -176,6 +249,7 @@ export const OperationsStats: OperationsStatsResolvers = { period, operations: operationsFilter, clients, + clientVersionFilters, }); }, }; diff --git a/packages/services/api/src/modules/operations/resolvers/Target.ts b/packages/services/api/src/modules/operations/resolvers/Target.ts index 1481c626d..5ec919786 100644 --- a/packages/services/api/src/modules/operations/resolvers/Target.ts +++ b/packages/services/api/src/modules/operations/resolvers/Target.ts @@ -1,8 +1,12 @@ +import { GraphQLError } from 'graphql'; import { parseDateRangeInput } from '../../../shared/helpers'; import { OperationsManager } from '../providers/operations-manager'; import { Traces } from '../providers/traces'; import type { TargetResolvers } from './../../../__generated__/types'; +const MAX_CLIENT_VERSION_FILTERS = 50; +const MAX_VERSIONS_PER_FILTER = 100; + export const Target: Pick< TargetResolvers, | 'clientStats' @@ -54,6 +58,23 @@ export const Target: Pick< }; }, operationsStats: async (target, args, _ctx) => { + // Validate clientVersionFilters size limits to prevent DoS via large SQL IN clauses + const clientVersionFilters = args.filter?.clientVersionFilters; + if (clientVersionFilters) { + if (clientVersionFilters.length > MAX_CLIENT_VERSION_FILTERS) { + throw new GraphQLError( + `'OperationStatsFilterInput.clientVersionFilters' must contain at most ${MAX_CLIENT_VERSION_FILTERS} elements`, + ); + } + for (const filter of clientVersionFilters) { + if (filter.versions && filter.versions.length > MAX_VERSIONS_PER_FILTER) { + throw new GraphQLError( + `'ClientVersionFilterInput.versions' must contain at most ${MAX_VERSIONS_PER_FILTER} elements`, + ); + } + } + } + return { period: parseDateRangeInput(args.period), organization: target.orgId, @@ -64,6 +85,11 @@ export const Target: Pick< // TODO: figure out if the mapping should actually happen here :thinking: args.filter?.clientNames?.map(clientName => (clientName === 'unknown' ? '' : clientName)) ?? [], + clientVersionFilters: + clientVersionFilters?.map(f => ({ + clientName: f.clientName === 'unknown' ? '' : f.clientName, + versions: f.versions ? [...f.versions] : null, + })) ?? [], }; }, schemaCoordinateStats: async (target, args, _ctx) => { diff --git a/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts index de778674d..66b99cb19 100644 --- a/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts +++ b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts @@ -148,6 +148,19 @@ export const permissionGroups: Array = [ }, ], }, + { + id: 'saved-filters', + title: 'Saved Filters', + permissions: [ + { + id: 'sharedSavedFilter:modify', + title: 'Manage shared saved filters', + description: + 'Member can create, update, and delete shared saved filters. All members can manage their own private filters.', + dependsOn: 'project:describe', + }, + ], + }, { id: 'schema-linting', title: 'Schema Linting', diff --git a/packages/services/api/src/modules/saved-filters/index.ts b/packages/services/api/src/modules/saved-filters/index.ts new file mode 100644 index 000000000..533393071 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/index.ts @@ -0,0 +1,14 @@ +import { createModule } from 'graphql-modules'; +import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager'; +import { SavedFiltersStorage } from './providers/saved-filters-storage'; +import { SavedFiltersProvider } from './providers/saved-filters.provider'; +import { resolvers } from './resolvers.generated'; +import { typeDefs } from './module.graphql'; + +export const savedFiltersModule = createModule({ + id: 'saved-filters', + dirname: __dirname, + typeDefs, + resolvers, + providers: [SavedFiltersProvider, SavedFiltersStorage, AuditLogManager], +}); diff --git a/packages/services/api/src/modules/saved-filters/module.graphql.mappers.ts b/packages/services/api/src/modules/saved-filters/module.graphql.mappers.ts new file mode 100644 index 000000000..890d81135 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/module.graphql.mappers.ts @@ -0,0 +1,5 @@ +import type { SavedFilter } from '../../shared/entities'; + +export type SavedFilterMapper = SavedFilter & { + orgId?: string; +}; diff --git a/packages/services/api/src/modules/saved-filters/module.graphql.ts b/packages/services/api/src/modules/saved-filters/module.graphql.ts new file mode 100644 index 000000000..c09b57f01 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/module.graphql.ts @@ -0,0 +1,163 @@ +import { gql } from 'graphql-modules'; + +export const typeDefs = gql` + enum SavedFilterVisibilityType { + PRIVATE + SHARED + } + + type SavedFilter { + id: ID! + name: String! + description: String + filters: InsightsFilterConfiguration! + visibility: SavedFilterVisibilityType! + viewsCount: Int! + createdAt: DateTime! + updatedAt: DateTime! + createdBy: User + updatedBy: User + viewerCanUpdate: Boolean! + viewerCanDelete: Boolean! + } + + type InsightsFilterConfiguration { + operationHashes: [String!]! + clientFilters: [ClientFilter!]! + dateRange: InsightsDateRange + } + + type ClientFilter { + name: String! + versions: [String!] + } + + type InsightsDateRange { + from: String! + to: String! + } + + type SavedFilterEdge { + node: SavedFilter! + cursor: String! + } + + type SavedFilterConnection { + edges: [SavedFilterEdge!]! + pageInfo: PageInfo! + } + + input CreateSavedFilterInput { + target: TargetReferenceInput! + name: String! + description: String + visibility: SavedFilterVisibilityType! + insightsFilter: InsightsFilterConfigurationInput + } + + input UpdateSavedFilterInput { + target: TargetReferenceInput! + id: ID! + name: String + description: String + visibility: SavedFilterVisibilityType + insightsFilter: InsightsFilterConfigurationInput + } + + input DeleteSavedFilterInput { + target: TargetReferenceInput! + id: ID! + } + + input TrackSavedFilterViewInput { + target: TargetReferenceInput! + id: ID! + } + + input InsightsFilterConfigurationInput { + operationHashes: [String!] + clientFilters: [ClientFilterInput!] + dateRange: InsightsDateRangeInput + } + + input InsightsDateRangeInput { + from: String! + to: String! + } + + input ClientFilterInput { + name: String! + versions: [String!] + } + + extend type Target { + savedFilter(id: ID!): SavedFilter + savedFilters( + first: Int = 50 + after: String + visibility: SavedFilterVisibilityType + search: String + ): SavedFilterConnection! + viewerCanCreateSavedFilter: Boolean! + viewerCanShareSavedFilter: Boolean! + } + + extend type Mutation { + createSavedFilter(input: CreateSavedFilterInput!): CreateSavedFilterResult! + updateSavedFilter(input: UpdateSavedFilterInput!): UpdateSavedFilterResult! + deleteSavedFilter(input: DeleteSavedFilterInput!): DeleteSavedFilterResult! + trackSavedFilterView(input: TrackSavedFilterViewInput!): TrackSavedFilterViewResult! + } + + type SavedFilterError implements Error { + message: String! + } + + """ + @oneOf + """ + type CreateSavedFilterResult { + ok: CreateSavedFilterOkPayload + error: SavedFilterError + } + + type CreateSavedFilterOkPayload { + savedFilter: SavedFilter! + } + + """ + @oneOf + """ + type UpdateSavedFilterResult { + ok: UpdateSavedFilterOkPayload + error: SavedFilterError + } + + type UpdateSavedFilterOkPayload { + savedFilter: SavedFilter! + } + + """ + @oneOf + """ + type DeleteSavedFilterResult { + ok: DeleteSavedFilterOkPayload + error: SavedFilterError + } + + type DeleteSavedFilterOkPayload { + deletedId: ID! + } + + """ + @oneOf + """ + type TrackSavedFilterViewResult { + ok: TrackSavedFilterViewOkPayload + error: SavedFilterError + } + + type TrackSavedFilterViewOkPayload { + savedFilter: SavedFilter! + } +`; diff --git a/packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts b/packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts new file mode 100644 index 000000000..2a6cf3055 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts @@ -0,0 +1,297 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import * as zod from 'zod'; +import { + decodeCreatedAtAndUUIDIdBasedCursor, + encodeCreatedAtAndUUIDIdBasedCursor, +} from '@hive/storage'; +import type { + InsightsFilterData, + SavedFilter, + SavedFilterVisibility, +} from '../../../shared/entities'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; + +const SavedFilterModel = zod.object({ + id: zod.string(), + projectId: zod.string(), + createdByUserId: zod.string(), + updatedByUserId: zod.string().nullable(), + name: zod.string(), + description: zod.string().nullable(), + filters: zod.object({ + operationHashes: zod.array(zod.string()), + clientFilters: zod.array( + zod.object({ + name: zod.string(), + versions: zod.array(zod.string()).nullable(), + }), + ), + dateRange: zod + .object({ + from: zod.string(), + to: zod.string(), + }) + .nullable(), + }), + visibility: zod.enum(['private', 'shared']), + viewsCount: zod.number(), + createdAt: zod.string(), + updatedAt: zod.string(), +}); + +@Injectable({ + scope: Scope.Operation, +}) +export class SavedFiltersStorage { + constructor(@Inject(PG_POOL_CONFIG) private pool: DatabasePool) {} + + async getSavedFilter(args: { id: string }): Promise { + const result = await this.pool.maybeOne(sql`/* getSavedFilter */ + SELECT + "id" + , "project_id" as "projectId" + , "created_by_user_id" as "createdByUserId" + , "updated_by_user_id" as "updatedByUserId" + , "name" + , "description" + , "filters" + , "visibility" + , "views_count" as "viewsCount" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + FROM + "saved_filters" + WHERE + "id" = ${args.id} + `); + + if (result === null) { + return null; + } + + return SavedFilterModel.parse(result) as SavedFilter; + } + + async getPaginatedSavedFiltersForProject(args: { + projectId: string; + userId: string; + visibility: SavedFilterVisibility | null; + search: string | null; + first: number; + cursor: string | null; + }) { + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 50) : 50) : 50; + + if (args.cursor) { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor); + } + + // Build visibility condition based on requested filter + // When a specific visibility is requested, we can use a simpler condition + // that allows better index usage than OR conditions + const visibilityCondition = + args.visibility === 'shared' + ? sql`"visibility" = 'shared'` + : args.visibility === 'private' + ? sql`"visibility" = 'private' AND "created_by_user_id" = ${args.userId}` + : sql`( + "visibility" = 'shared' + OR ("visibility" = 'private' AND "created_by_user_id" = ${args.userId}) + )`; + + const result = await this.pool.any(sql`/* getPaginatedSavedFiltersForProject */ + SELECT + "id" + , "project_id" as "projectId" + , "created_by_user_id" as "createdByUserId" + , "updated_by_user_id" as "updatedByUserId" + , "name" + , "description" + , "filters" + , "visibility" + , "views_count" as "viewsCount" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + FROM + "saved_filters" + WHERE + "project_id" = ${args.projectId} + AND ${visibilityCondition} + ${ + args.search + ? sql` + AND ( + "name" ILIKE ${'%' + args.search + '%'} + OR "description" ILIKE ${'%' + args.search + '%'} + ) + ` + : sql`` + } + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + "project_id" ASC + , "created_at" DESC + , "id" DESC + LIMIT ${limit + 1} + `); + + let items = result.map(row => { + const node = SavedFilterModel.parse(row) as SavedFilter; + + return { + node, + get cursor() { + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + }; + }); + + const hasNextPage = items.length > limit; + + items = items.slice(0, limit); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return items[items.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return items[0]?.cursor ?? ''; + }, + }, + }; + } + + async createSavedFilter(args: { + projectId: string; + createdByUserId: string; + name: string; + description: string | null; + filters: InsightsFilterData; + visibility: SavedFilterVisibility; + }): Promise { + const result = await this.pool.one(sql`/* createSavedFilter */ + INSERT INTO "saved_filters" ( + "project_id" + , "created_by_user_id" + , "name" + , "description" + , "filters" + , "visibility" + ) + VALUES ( + ${args.projectId} + , ${args.createdByUserId} + , ${args.name} + , ${args.description} + , ${JSON.stringify(args.filters)} + , ${args.visibility} + ) + RETURNING + "id" + , "project_id" as "projectId" + , "created_by_user_id" as "createdByUserId" + , "updated_by_user_id" as "updatedByUserId" + , "name" + , "description" + , "filters" + , "visibility" + , "views_count" as "viewsCount" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + `); + + return SavedFilterModel.parse(result) as SavedFilter; + } + + async updateSavedFilter(args: { + id: string; + updatedByUserId: string; + name: string | null; + description: string | null; + filters: InsightsFilterData | null; + visibility: SavedFilterVisibility | null; + }): Promise { + const result = await this.pool.maybeOne(sql`/* updateSavedFilter */ + UPDATE + "saved_filters" + SET + "name" = COALESCE(${args.name}, "name") + , "description" = COALESCE(${args.description}, "description") + , "filters" = COALESCE(${args.filters ? JSON.stringify(args.filters) : null}, "filters") + , "visibility" = COALESCE(${args.visibility}, "visibility") + , "updated_by_user_id" = ${args.updatedByUserId} + , "updated_at" = NOW() + WHERE + "id" = ${args.id} + RETURNING + "id" + , "project_id" as "projectId" + , "created_by_user_id" as "createdByUserId" + , "updated_by_user_id" as "updatedByUserId" + , "name" + , "description" + , "filters" + , "visibility" + , "views_count" as "viewsCount" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + `); + + if (result === null) { + return null; + } + + return SavedFilterModel.parse(result) as SavedFilter; + } + + async deleteSavedFilter(args: { id: string }): Promise { + const result = await this.pool.maybeOneFirst(sql`/* deleteSavedFilter */ + DELETE + FROM + "saved_filters" + WHERE + "id" = ${args.id} + RETURNING + "id" + `); + + if (result == null) { + return null; + } + + return zod.string().parse(result); + } + + async incrementSavedFilterViews(args: { id: string }): Promise { + await this.pool.query(sql`/* incrementSavedFilterViews */ + UPDATE + "saved_filters" + SET + "views_count" = "views_count" + 1 + WHERE + "id" = ${args.id} + `); + } +} diff --git a/packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts b/packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts new file mode 100644 index 000000000..5661b43fd --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts @@ -0,0 +1,536 @@ +import { Injectable, Scope } from 'graphql-modules'; +import * as zod from 'zod'; +import type { TargetReferenceInput } from '../../../__generated__/types'; +import type { + InsightsFilterData, + SavedFilter, + SavedFilterVisibility, + Target, +} from '../../../shared/entities'; +import { isUUID } from '../../../shared/is-uuid'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; +import { Session } from '../../auth/lib/authz'; +import { IdTranslator } from '../../shared/providers/id-translator'; +import { Logger } from '../../shared/providers/logger'; +import { SavedFiltersStorage } from './saved-filters-storage'; + +const InsightsFilterConfigurationModel = zod.object({ + // Use nullish() + transform because the resolver may pass null instead of undefined + operationHashes: zod + .array(zod.string()) + .max(100) + .nullish() + .transform(v => v ?? []), + clientFilters: zod + .array( + zod.object({ + name: zod.string().min(1).max(100), + versions: zod.array(zod.string()).max(100).nullable().optional(), + }), + ) + .max(50) + .nullish() + .transform(v => v ?? []), + dateRange: zod + .object({ + from: zod.string().min(1).max(100), + to: zod.string().min(1).max(100), + }) + .nullish() + .transform(v => v ?? null), +}); + +// Transform GraphQL uppercase enum values to lowercase for database storage +const visibilityEnum = zod + .enum(['PRIVATE', 'SHARED']) + .transform(v => v.toLowerCase() as 'private' | 'shared'); + +const CreateSavedFilterInputModel = zod.object({ + name: zod.string().min(1).max(100), + description: zod.string().max(500).nullable().optional(), + visibility: visibilityEnum, + insightsFilter: InsightsFilterConfigurationModel.nullable().optional(), +}); + +const UpdateSavedFilterInputModel = zod.object({ + name: zod.string().min(1).max(100).nullish(), + description: zod.string().max(500).nullable().optional(), + // nullish() because resolver passes null when visibility is not provided + visibility: visibilityEnum.nullish(), + insightsFilter: InsightsFilterConfigurationModel.nullable().optional(), +}); + +@Injectable({ + global: true, + scope: Scope.Operation, +}) +export class SavedFiltersProvider { + private logger: Logger; + + constructor( + logger: Logger, + private savedFiltersStorage: SavedFiltersStorage, + private session: Session, + private idTranslator: IdTranslator, + private auditLog: AuditLogRecorder, + ) { + this.logger = logger.child({ source: 'SavedFiltersProvider' }); + } + + async getSavedFilter(target: Target, filterId: string): Promise { + this.logger.debug( + 'Fetching saved filter (filterId=%s, projectId=%s)', + filterId, + target.projectId, + ); + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: target.orgId, + params: { + organizationId: target.orgId, + projectId: target.projectId, + }, + }); + + if (!isUUID(filterId)) { + return null; + } + + const filter = await this.savedFiltersStorage.getSavedFilter({ id: filterId }); + + if (!filter || filter.projectId !== target.projectId) { + return null; + } + + // Check visibility: private filters are only visible to the creator + const currentUser = await this.session.getViewer(); + if (filter.visibility === 'private' && filter.createdByUserId !== currentUser.id) { + return null; + } + + return filter; + } + + async getSavedFilters( + target: Target, + first: number, + cursor: string | null, + visibility: SavedFilterVisibility | null, + search: string | null, + ) { + this.logger.debug( + 'Listing saved filters (projectId=%s, visibility=%s, search=%s)', + target.projectId, + visibility, + search, + ); + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: target.orgId, + params: { + organizationId: target.orgId, + projectId: target.projectId, + }, + }); + + const currentUser = await this.session.getViewer(); + + return this.savedFiltersStorage.getPaginatedSavedFiltersForProject({ + projectId: target.projectId, + userId: currentUser.id, + visibility, + search, + first, + cursor, + }); + } + + async createSavedFilter( + target: TargetReferenceInput, + input: { + name: string; + description: string | null; + visibility: string; + insightsFilter?: { + operationHashes?: string[] | null; + clientFilters?: Array<{ name: string; versions?: string[] | null }> | null; + dateRange?: { from: string; to: string } | null; + } | null; + }, + ): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> { + this.logger.info( + 'Creating saved filter (name=%s, visibility=%s)', + input.name, + input.visibility, + ); + + const resolved = await this.idTranslator.resolveTargetReference({ reference: target }); + if (resolved === null) { + return { type: 'error', message: 'Target not found' }; + } + const { organizationId, projectId } = resolved; + + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId, + params: { + organizationId, + projectId, + }, + }); + + const validationResult = CreateSavedFilterInputModel.safeParse(input); + if (!validationResult.success) { + return { + type: 'error', + message: validationResult.error.errors[0].message, + }; + } + + const data = validationResult.data; + + // Shared filters require the sharedSavedFilter:modify permission + if (data.visibility === 'shared') { + await this.session.assertPerformAction({ + action: 'sharedSavedFilter:modify', + organizationId, + params: { + organizationId, + projectId, + }, + }); + } + + const currentUser = await this.session.getViewer(); + + const filters: InsightsFilterData = { + operationHashes: data.insightsFilter?.operationHashes ?? [], + clientFilters: + data.insightsFilter?.clientFilters?.map(cf => ({ + name: cf.name, + versions: cf.versions ?? null, + })) ?? [], + dateRange: data.insightsFilter?.dateRange ?? null, + }; + + const savedFilter = await this.savedFiltersStorage.createSavedFilter({ + projectId, + createdByUserId: currentUser.id, + name: data.name, + description: data.description ?? null, + filters, + visibility: data.visibility as SavedFilterVisibility, + }); + + await this.auditLog.record({ + eventType: 'SAVED_FILTER_CREATED', + organizationId, + metadata: { + filterId: savedFilter.id, + filterName: savedFilter.name, + visibility: savedFilter.visibility, + projectId, + }, + }); + + return { + type: 'success', + savedFilter, + }; + } + + async updateSavedFilter( + target: TargetReferenceInput, + filterId: string, + input: { + name?: string | null; + description?: string | null; + visibility?: string | null; + insightsFilter?: { + operationHashes?: string[] | null; + clientFilters?: Array<{ name: string; versions?: string[] | null }> | null; + dateRange?: { from: string; to: string } | null; + } | null; + }, + ): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> { + this.logger.info('Updating saved filter (filterId=%s)', filterId); + + const resolved = await this.idTranslator.resolveTargetReference({ reference: target }); + if (resolved === null) { + return { type: 'error', message: 'Target not found' }; + } + const { organizationId, projectId } = resolved; + + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId, + params: { + organizationId, + projectId, + }, + }); + + if (!isUUID(filterId)) { + return { + type: 'error', + message: 'Saved filter not found', + }; + } + + const existingFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId }); + + if (!existingFilter || existingFilter.projectId !== projectId) { + return { + type: 'error', + message: 'Saved filter not found', + }; + } + + const currentUser = await this.session.getViewer(); + + // Check if user can update this filter + if (!this.canUserModifyFilter(existingFilter, currentUser.id)) { + return { + type: 'error', + message: 'You do not have permission to update this filter', + }; + } + + const validationResult = UpdateSavedFilterInputModel.safeParse(input); + if (!validationResult.success) { + return { + type: 'error', + message: validationResult.error.errors[0].message, + }; + } + + const data = validationResult.data; + + // Shared filters (or changing visibility to shared) require the sharedSavedFilter:modify permission + const newVisibility = (data.visibility as SavedFilterVisibility) ?? null; + if (existingFilter.visibility === 'shared' || newVisibility === 'shared') { + await this.session.assertPerformAction({ + action: 'sharedSavedFilter:modify', + organizationId, + params: { + organizationId, + projectId, + }, + }); + } + + const filters: InsightsFilterData | null = data.insightsFilter + ? { + operationHashes: data.insightsFilter.operationHashes ?? [], + clientFilters: + data.insightsFilter.clientFilters?.map(cf => ({ + name: cf.name, + versions: cf.versions ?? null, + })) ?? [], + dateRange: data.insightsFilter.dateRange ?? null, + } + : null; + + const savedFilter = await this.savedFiltersStorage.updateSavedFilter({ + id: filterId, + updatedByUserId: currentUser.id, + name: data.name ?? null, + description: data.description ?? null, + filters, + visibility: (data.visibility as SavedFilterVisibility) ?? null, + }); + + if (!savedFilter) { + return { + type: 'error', + message: 'Failed to update saved filter', + }; + } + + await this.auditLog.record({ + eventType: 'SAVED_FILTER_UPDATED', + organizationId, + metadata: { + filterId: savedFilter.id, + filterName: savedFilter.name, + updatedFields: JSON.stringify({ + name: data.name, + description: data.description, + visibility: data.visibility, + filters: filters ? true : false, + }), + }, + }); + + return { + type: 'success', + savedFilter, + }; + } + + async deleteSavedFilter( + target: TargetReferenceInput, + filterId: string, + ): Promise<{ type: 'success'; deletedId: string } | { type: 'error'; message: string }> { + this.logger.info('Deleting saved filter (filterId=%s)', filterId); + + const resolved = await this.idTranslator.resolveTargetReference({ reference: target }); + if (resolved === null) { + return { type: 'error', message: 'Target not found' }; + } + const { organizationId, projectId } = resolved; + + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId, + params: { + organizationId, + projectId, + }, + }); + + if (!isUUID(filterId)) { + return { + type: 'error', + message: 'Saved filter not found', + }; + } + + const existingFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId }); + + if (!existingFilter || existingFilter.projectId !== projectId) { + return { + type: 'error', + message: 'Saved filter not found', + }; + } + + // Shared filters require the sharedSavedFilter:modify permission + if (existingFilter.visibility === 'shared') { + await this.session.assertPerformAction({ + action: 'sharedSavedFilter:modify', + organizationId, + params: { + organizationId, + projectId, + }, + }); + } + + const currentUser = await this.session.getViewer(); + + // Check if user can delete this filter + if (!this.canUserModifyFilter(existingFilter, currentUser.id)) { + return { + type: 'error', + message: 'You do not have permission to delete this filter', + }; + } + + const deletedId = await this.savedFiltersStorage.deleteSavedFilter({ id: filterId }); + + if (!deletedId) { + return { + type: 'error', + message: 'Failed to delete saved filter', + }; + } + + await this.auditLog.record({ + eventType: 'SAVED_FILTER_DELETED', + organizationId, + metadata: { + filterId: existingFilter.id, + filterName: existingFilter.name, + }, + }); + + return { + type: 'success', + deletedId, + }; + } + + async trackView( + target: TargetReferenceInput, + filterId: string, + ): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> { + this.logger.debug('Tracking saved filter view (filterId=%s)', filterId); + + const resolved = await this.idTranslator.resolveTargetReference({ reference: target }); + if (resolved === null) { + return { type: 'error', message: 'Target not found' }; + } + const { organizationId, projectId } = resolved; + + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId, + params: { + organizationId, + projectId, + }, + }); + + if (!isUUID(filterId)) { + return { + type: 'error', + message: 'Saved filter not found', + }; + } + + const existingFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId }); + + if (!existingFilter || existingFilter.projectId !== projectId) { + return { + type: 'error', + message: 'Saved filter not found', + }; + } + + const currentUser = await this.session.getViewer(); + + // Check visibility + if ( + existingFilter.visibility === 'private' && + existingFilter.createdByUserId !== currentUser.id + ) { + return { + type: 'error', + message: 'Saved filter not found', + }; + } + + await this.savedFiltersStorage.incrementSavedFilterViews({ id: filterId }); + + // Fetch the updated filter + const savedFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId }); + + if (!savedFilter) { + return { + type: 'error', + message: 'Failed to track view', + }; + } + + return { + type: 'success', + savedFilter, + }; + } + + canUserModifyFilter(filter: SavedFilter, userId: string): boolean { + // Private filters can only be modified by the creator + if (filter.visibility === 'private') { + return filter.createdByUserId === userId; + } + // Shared filters can be modified by anyone with project:modify permission + // The permission check is done at the method level + return true; + } + + canUserDeleteFilter(filter: SavedFilter, userId: string): boolean { + // Same logic as canUserModifyFilter + return this.canUserModifyFilter(filter, userId); + } +} diff --git a/packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts new file mode 100644 index 000000000..52bb2d3b2 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts @@ -0,0 +1,43 @@ +import { SavedFiltersProvider } from '../../providers/saved-filters.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createSavedFilter: NonNullable = async ( + _parent, + { input }, + { injector }, +) => { + const result = await injector.get(SavedFiltersProvider).createSavedFilter(input.target, { + name: input.name, + description: input.description ?? null, + visibility: input.visibility, + insightsFilter: input.insightsFilter + ? { + operationHashes: input.insightsFilter.operationHashes + ? [...input.insightsFilter.operationHashes] + : null, + clientFilters: + input.insightsFilter.clientFilters?.map(cf => ({ + name: cf.name, + versions: cf.versions ? [...cf.versions] : null, + })) ?? null, + dateRange: input.insightsFilter.dateRange + ? { from: input.insightsFilter.dateRange.from, to: input.insightsFilter.dateRange.to } + : null, + } + : null, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + savedFilter: result.savedFilter, + }, + }; +}; diff --git a/packages/services/api/src/modules/saved-filters/resolvers/Mutation/deleteSavedFilter.ts b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/deleteSavedFilter.ts new file mode 100644 index 000000000..d20e53fc4 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/deleteSavedFilter.ts @@ -0,0 +1,24 @@ +import { SavedFiltersProvider } from '../../providers/saved-filters.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const deleteSavedFilter: NonNullable = async ( + _parent, + { input }, + { injector }, +) => { + const result = await injector.get(SavedFiltersProvider).deleteSavedFilter(input.target, input.id); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + deletedId: result.deletedId, + }, + }; +}; diff --git a/packages/services/api/src/modules/saved-filters/resolvers/Mutation/trackSavedFilterView.ts b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/trackSavedFilterView.ts new file mode 100644 index 000000000..2a49138b0 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/trackSavedFilterView.ts @@ -0,0 +1,24 @@ +import { SavedFiltersProvider } from '../../providers/saved-filters.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const trackSavedFilterView: NonNullable = async ( + _parent, + { input }, + { injector }, +) => { + const result = await injector.get(SavedFiltersProvider).trackView(input.target, input.id); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + savedFilter: result.savedFilter, + }, + }; +}; diff --git a/packages/services/api/src/modules/saved-filters/resolvers/Mutation/updateSavedFilter.ts b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/updateSavedFilter.ts new file mode 100644 index 000000000..1614dc7cb --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/updateSavedFilter.ts @@ -0,0 +1,57 @@ +import { IdTranslator } from '../../../shared/providers/id-translator'; +import { SavedFiltersProvider } from '../../providers/saved-filters.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const updateSavedFilter: NonNullable = async ( + _parent, + { input }, + { injector }, +) => { + const result = await injector + .get(SavedFiltersProvider) + .updateSavedFilter(input.target, input.id, { + name: input.name ?? null, + description: input.description, + visibility: input.visibility ?? null, + insightsFilter: input.insightsFilter + ? { + operationHashes: input.insightsFilter.operationHashes + ? [...input.insightsFilter.operationHashes] + : null, + clientFilters: + input.insightsFilter.clientFilters?.map(cf => ({ + name: cf.name, + versions: cf.versions ? [...cf.versions] : null, + })) ?? null, + dateRange: input.insightsFilter.dateRange + ? { + from: input.insightsFilter.dateRange.from, + to: input.insightsFilter.dateRange.to, + } + : null, + } + : null, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + // Attach org context so viewerCanUpdate/viewerCanDelete can check permissions + const resolved = await injector + .get(IdTranslator) + .resolveTargetReference({ reference: input.target }); + + return { + ok: { + savedFilter: { + ...result.savedFilter, + orgId: resolved?.organizationId, + }, + }, + }; +}; diff --git a/packages/services/api/src/modules/saved-filters/resolvers/SavedFilter.ts b/packages/services/api/src/modules/saved-filters/resolvers/SavedFilter.ts new file mode 100644 index 000000000..ebde0475e --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/resolvers/SavedFilter.ts @@ -0,0 +1,66 @@ +import { Storage } from '../../shared/providers/storage'; +import { SavedFiltersProvider } from '../providers/saved-filters.provider'; +import type { SavedFilterResolvers } from './../../../__generated__/types'; + +export const SavedFilter: SavedFilterResolvers = { + id: filter => filter.id, + name: filter => filter.name, + description: filter => filter.description, + filters: filter => { + const filters = filter.filters as { + operationHashes?: string[]; + clientFilters?: Array<{ name: string; versions?: string[] | null }>; + dateRange?: { from: string; to: string } | null; + }; + return { + operationHashes: filters.operationHashes ?? [], + clientFilters: + filters.clientFilters?.map(cf => ({ + name: cf.name, + versions: cf.versions ?? null, + })) ?? [], + dateRange: filters.dateRange ?? null, + }; + }, + visibility: filter => (filter.visibility === 'private' ? 'PRIVATE' : 'SHARED'), + viewsCount: filter => filter.viewsCount, + createdAt: filter => filter.createdAt, + updatedAt: filter => filter.updatedAt, + createdBy: async (filter, _args, { injector }) => { + return injector.get(Storage).getUserById({ id: filter.createdByUserId }); + }, + updatedBy: async (filter, _args, { injector }) => { + if (!filter.updatedByUserId) { + return null; + } + return injector.get(Storage).getUserById({ id: filter.updatedByUserId }); + }, + viewerCanUpdate: async (filter, _args, { injector, session }) => { + // For shared filters, check sharedSavedFilter:modify permission + if (filter.visibility === 'shared' && filter.orgId) { + const canModifyShared = await session.canPerformAction({ + action: 'sharedSavedFilter:modify', + organizationId: filter.orgId, + params: { organizationId: filter.orgId, projectId: filter.projectId }, + }); + if (!canModifyShared) return false; + } + // Check ownership (private filters can only be modified by creator) + const currentUser = await session.getViewer(); + return injector.get(SavedFiltersProvider).canUserModifyFilter(filter, currentUser.id); + }, + viewerCanDelete: async (filter, _args, { injector, session }) => { + // For shared filters, check sharedSavedFilter:modify permission + if (filter.visibility === 'shared' && filter.orgId) { + const canModifyShared = await session.canPerformAction({ + action: 'sharedSavedFilter:modify', + organizationId: filter.orgId, + params: { organizationId: filter.orgId, projectId: filter.projectId }, + }); + if (!canModifyShared) return false; + } + // Check ownership (private filters can only be modified by creator) + const currentUser = await session.getViewer(); + return injector.get(SavedFiltersProvider).canUserDeleteFilter(filter, currentUser.id); + }, +}; diff --git a/packages/services/api/src/modules/saved-filters/resolvers/SavedFilterConnection.ts b/packages/services/api/src/modules/saved-filters/resolvers/SavedFilterConnection.ts new file mode 100644 index 000000000..551e92ec1 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/resolvers/SavedFilterConnection.ts @@ -0,0 +1,6 @@ +import type { SavedFilterConnectionResolvers } from './../../../__generated__/types'; + +export const SavedFilterConnection: SavedFilterConnectionResolvers = { + edges: connection => connection.edges, + pageInfo: connection => connection.pageInfo, +}; diff --git a/packages/services/api/src/modules/saved-filters/resolvers/Target.ts b/packages/services/api/src/modules/saved-filters/resolvers/Target.ts new file mode 100644 index 000000000..64974b5b8 --- /dev/null +++ b/packages/services/api/src/modules/saved-filters/resolvers/Target.ts @@ -0,0 +1,57 @@ +import type { SavedFilterVisibilityType as GqlVisibility } from '../../../__generated__/types'; +import type { SavedFilterVisibility } from '../../../shared/entities'; +import { SavedFiltersProvider } from '../providers/saved-filters.provider'; +import type { TargetResolvers } from './../../../__generated__/types'; + +function mapVisibility(visibility: GqlVisibility | null): SavedFilterVisibility | null { + if (visibility === null) return null; + return visibility === 'PRIVATE' ? 'private' : 'shared'; +} + +export const Target: Pick< + TargetResolvers, + 'savedFilter' | 'savedFilters' | 'viewerCanCreateSavedFilter' | 'viewerCanShareSavedFilter' +> = { + savedFilter: async (target, args, { injector }) => { + const filter = await injector.get(SavedFiltersProvider).getSavedFilter(target, args.id); + return filter ? { ...filter, orgId: target.orgId } : null; + }, + savedFilters: async (target, args, { injector }) => { + const result = await injector + .get(SavedFiltersProvider) + .getSavedFilters( + target, + args.first, + args.after ?? null, + mapVisibility(args.visibility ?? null), + args.search ?? null, + ); + return { + ...result, + edges: result.edges.map(edge => ({ + ...edge, + node: { ...edge.node, orgId: target.orgId }, + })), + }; + }, + viewerCanCreateSavedFilter: (target, _args, { session }) => { + return session.canPerformAction({ + action: 'project:describe', + organizationId: target.orgId, + params: { + organizationId: target.orgId, + projectId: target.projectId, + }, + }); + }, + viewerCanShareSavedFilter: (target, _args, { session }) => { + return session.canPerformAction({ + action: 'sharedSavedFilter:modify', + organizationId: target.orgId, + params: { + organizationId: target.orgId, + projectId: target.projectId, + }, + }); + }, +}; diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 763e789b3..245b7c11c 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -297,6 +297,41 @@ export type PaginatedDocumentCollectionOperations = Readonly<{ }>; }>; +export type SavedFilterVisibility = 'private' | 'shared'; + +export interface InsightsFilterData { + operationHashes: string[]; + clientFilters: Array<{ name: string; versions: string[] | null }>; + dateRange: { from: string; to: string } | null; +} + +export interface SavedFilter { + id: string; + projectId: string; + createdByUserId: string; + updatedByUserId: string | null; + name: string; + description: string | null; + filters: InsightsFilterData; + visibility: SavedFilterVisibility; + viewsCount: number; + createdAt: string; + updatedAt: string; +} + +export type PaginatedSavedFilters = Readonly<{ + edges: ReadonlyArray<{ + node: SavedFilter; + cursor: string; + }>; + pageInfo: Readonly<{ + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }>; +}>; + export interface Project { id: string; slug: string; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 24d4dc3fe..ae52d5ead 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -10,6 +10,7 @@ export type alert_channel_type = 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; export type alert_type = 'SCHEMA_CHANGE_NOTIFICATIONS'; export type breaking_change_formula = 'PERCENTAGE' | 'REQUEST_COUNT'; +export type saved_filter_visibility = 'private' | 'shared'; export type schema_policy_resource = 'ORGANIZATION' | 'PROJECT'; export type schema_proposal_stage = 'APPROVED' | 'CLOSED' | 'DRAFT' | 'IMPLEMENTED' | 'OPEN'; export type user_role = 'ADMIN' | 'MEMBER'; @@ -270,6 +271,20 @@ export interface projects { validation_url: string | null; } +export interface saved_filters { + created_at: Date; + created_by_user_id: string; + description: string | null; + filters: any; + id: string; + name: string; + project_id: string; + updated_at: Date; + updated_by_user_id: string | null; + views_count: number; + visibility: saved_filter_visibility; +} + export interface schema_change_approvals { context_id: string; created_at: Date; @@ -520,6 +535,7 @@ export interface DBTables { organizations: organizations; organizations_billing: organizations_billing; projects: projects; + saved_filters: saved_filters; schema_change_approvals: schema_change_approvals; schema_checks: schema_checks; schema_coordinate_status: schema_coordinate_status; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 23f35a33d..3d09aa06b 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4100,6 +4100,7 @@ export async function createStorage( return DocumentCollectionDocumentModel.parse(result); }, + async createSchemaCheck(args) { const result = await tracedTransaction('createSchemaCheck', pool, async trx => { const sdlStoreInserts: Array> = []; diff --git a/packages/web/app/.ladle/components.tsx b/packages/web/app/.ladle/components.tsx index 83ccadf8d..d43a2eb43 100644 --- a/packages/web/app/.ladle/components.tsx +++ b/packages/web/app/.ladle/components.tsx @@ -1,4 +1,5 @@ import '../src/index.css'; +import './ladle.css'; import { useEffect } from 'react'; import type { GlobalProvider } from '@ladle/react'; import { ThemeProvider, useTheme } from '../src/components/theme/theme-provider'; diff --git a/packages/web/app/.ladle/ladle.css b/packages/web/app/.ladle/ladle.css new file mode 100644 index 000000000..8d25af5c0 --- /dev/null +++ b/packages/web/app/.ladle/ladle.css @@ -0,0 +1,3 @@ +.ladle-background { + background: hsl(var(--color-neutral-2)) !important; +} diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 8b62a9224..078612470 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@base-ui/react": "^1.1.0", "@date-fns/utc": "2.1.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -69,6 +70,7 @@ "@tanstack/react-router": "1.34.9", "@tanstack/react-router-devtools": "^1.139.13", "@tanstack/react-table": "8.20.6", + "@tanstack/react-virtual": "^3.13.18", "@tanstack/router-devtools": "1.34.9", "@tanstack/zod-adapter": "1.120.5", "@theguild/editor": "1.2.5", @@ -82,7 +84,6 @@ "@types/react-dom": "18.3.5", "@types/react-highlight-words": "0.20.0", "@types/react-virtualized-auto-sizer": "1.0.4", - "@types/react-window": "1.8.8", "@urql/core": "5.0.3", "@urql/exchange-auth": "2.2.0", "@urql/exchange-graphcache": "7.1.0", @@ -133,7 +134,6 @@ "react-toastify": "10.0.6", "react-virtualized-auto-sizer": "1.0.25", "react-virtuoso": "4.12.3", - "react-window": "1.8.11", "recharts": "2.15.1", "regenerator-runtime": "0.14.1", "snarkdown": "2.0.0", diff --git a/packages/web/app/src/components/admin/AdminStats.tsx b/packages/web/app/src/components/admin/AdminStats.tsx index 84a08e2fc..19e29627d 100644 --- a/packages/web/app/src/components/admin/AdminStats.tsx +++ b/packages/web/app/src/components/admin/AdminStats.tsx @@ -22,7 +22,6 @@ import { Tooltip, Tr, } from '@/components/v2'; -import { CHART_PRIMARY_COLOR } from '@/constants'; import { env } from '@/env/frontend'; import { DocumentType, FragmentType, graphql, useFragment } from '@/gql'; import { theme } from '@/lib/charts'; @@ -85,7 +84,7 @@ function CollectedOperationsOverTime(props: { const dataRef = useRef<[string, number][]>(); dataRef.current ||= operations.map(node => [node.date, node.count]); const data = dataRef.current; - const chartStyles = useChartStyles(); + const { styles: chartStyles, colors } = useChartStyles(); return ( @@ -117,7 +116,7 @@ function CollectedOperationsOverTime(props: { min: 0, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -132,7 +131,7 @@ function CollectedOperationsOverTime(props: { name: 'Collected operations', showSymbol: false, smooth: true, - color: CHART_PRIMARY_COLOR, + color: colors.primary, areaStyle: {}, emphasis: { focus: 'series', diff --git a/packages/web/app/src/components/base/checkbox/checkbox.stories.tsx b/packages/web/app/src/components/base/checkbox/checkbox.stories.tsx new file mode 100644 index 000000000..0aeee1f25 --- /dev/null +++ b/packages/web/app/src/components/base/checkbox/checkbox.stories.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import type { Story, StoryDefault } from '@ladle/react'; +import { Checkbox } from './checkbox'; + +export default { + title: 'Base / Checkbox', +} satisfies StoryDefault; + +export const Default: Story = () => { + const [checked, setChecked] = useState(false); + return ( +
+ + {checked ? 'Checked' : 'Unchecked'} +
+ ); +}; + +export const Sizes: Story = () => { + const [values, setValues] = useState({ sm: false, md: true }); + return ( +
+ {(['sm', 'md'] as const).map(size => ( +
+ setValues(prev => ({ ...prev, [size]: v }))} + size={size} + /> + {size} +
+ ))} +
+ ); +}; + +export const Indeterminate: Story = () => { + const [items, setItems] = useState([true, false, true]); + + const allChecked = items.every(Boolean); + const noneChecked = items.every(v => !v); + const indeterminate = !allChecked && !noneChecked; + + const toggleAll = () => { + if (allChecked || indeterminate) { + setItems([false, false, false]); + } else { + setItems([true, true, true]); + } + }; + + return ( +
+
+ + Select all +
+
+ {items.map((checked, i) => ( +
+ + setItems(prev => prev.map((item, idx) => (idx === i ? !!v : item))) + } + /> + Item {i + 1} +
+ ))} +
+
+ ); +}; + +export const Disabled: Story = () => ( +
+
+ + Disabled unchecked +
+
+ + Disabled checked +
+
+); diff --git a/packages/web/app/src/components/base/checkbox/checkbox.tsx b/packages/web/app/src/components/base/checkbox/checkbox.tsx new file mode 100644 index 000000000..13e302667 --- /dev/null +++ b/packages/web/app/src/components/base/checkbox/checkbox.tsx @@ -0,0 +1,68 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { Check, Minus } from 'lucide-react'; +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; + +const checkboxVariants = cva( + 'inline-flex shrink-0 items-center justify-center rounded-sm border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50', + { + variants: { + size: { + sm: 'size-3.5', + md: 'size-4.5', + }, + variant: { + default: [ + 'border-neutral-6', + 'data-[checked]:bg-accent_30 data-[checked]:border-accent_30 data-[checked]:text-accent', + 'hover:bg-neutral-6 hover:border-accent_30', + 'data-[checked]:hover:bg-accent_10', + 'data-[indeterminate]:bg-accent_30 data-[indeterminate]:border-accent_30 data-[indeterminate]:text-accent', + ], + }, + }, + defaultVariants: { + size: 'md', + variant: 'default', + }, + }, +); + +const iconSizeMap = { + sm: 'size-2.5', + md: 'size-3', +} as const; + +export function Checkbox({ + size = 'md', + variant, + visual, + ...props +}: Omit & + VariantProps & { + /** When true, the checkbox is a non-interactive visual indicator (preserves hover styles, sets tabIndex: -1, aria-hidden) */ + visual?: boolean; + }) { + const iconClass = iconSizeMap[size ?? 'md']; + return ( + + + {props.indeterminate ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/web/app/src/components/base/filter-dropdown/filter-content.tsx b/packages/web/app/src/components/base/filter-dropdown/filter-content.tsx new file mode 100644 index 000000000..44884206e --- /dev/null +++ b/packages/web/app/src/components/base/filter-dropdown/filter-content.tsx @@ -0,0 +1,175 @@ +import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'; +import { FilterListSearch } from '@/components/base/filter-dropdown/filter-list-search'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { ItemRow } from './item-row'; +import type { FilterItem, FilterSelection } from './types'; + +const ITEM_HEIGHT = 28; // h-7 +const MAX_LIST_HEIGHT = 256; // max-h-64 + +function getKey(item: FilterItem | FilterSelection): string { + return item.id ?? item.name; +} + +function isItemSelected(item: FilterItem, selectedItems: FilterSelection[]): boolean { + const key = getKey(item); + return selectedItems.some(s => getKey(s) === key); +} + +function getItemSelection( + item: FilterItem, + selectedItems: FilterSelection[], +): FilterSelection | null { + const key = getKey(item); + return selectedItems.find(s => getKey(s) === key) ?? null; +} + +export type FilterContentProps = { + /** Used in the search input's aria-label */ + label: string; + /** Available items and their sub-values */ + items: FilterItem[]; + /** Currently selected items */ + selectedItems: FilterSelection[]; + /** Called when selection changes */ + onChange: (value: FilterSelection[]) => void; + /** Label for the sub-values (e.g. "versions", "endpoints"). Used in accessibility labels. */ + valuesLabel?: string; +}; + +export function FilterContent({ + label, + items, + selectedItems, + onChange, + valuesLabel = 'values', +}: FilterContentProps) { + const [search, setSearch] = useState(''); + const deferredSearch = useDeferredValue(search); + const scrollRef = useRef(null); + + // Snapshot which items were selected when the dropdown opened. + // This sort order is frozen — toggling items won't move them. + // Resets naturally when the portal unmounts on close and remounts on reopen. + const initialSelectedKeys = useRef>(null!); + if (initialSelectedKeys.current === null) { + initialSelectedKeys.current = new Set(selectedItems.map(getKey)); + } + + const filteredItems = useMemo(() => { + const matched = items.filter(item => + item.name.toLowerCase().includes(deferredSearch.toLowerCase()), + ); + return matched.sort((a, b) => { + // Initially-selected items first + const aSelected = initialSelectedKeys.current.has(getKey(a)) ? 0 : 1; + const bSelected = initialSelectedKeys.current.has(getKey(b)) ? 0 : 1; + if (aSelected !== bSelected) return aSelected - bSelected; + // Unavailable items to the bottom within each group + return (a.unavailable ? 1 : 0) - (b.unavailable ? 1 : 0); + }); + }, [items, deferredSearch]); + + const virtualizer = useVirtualizer({ + count: filteredItems.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, + paddingStart: 8, + }); + + // Keep a ref to selectedItems so callbacks below stay stable across renders. + const selectedItemsRef = useRef(selectedItems); + selectedItemsRef.current = selectedItems; + + const toggleItem = useCallback( + (item: FilterItem) => { + const key = getKey(item); + const current = selectedItemsRef.current; + if (current.some(s => getKey(s) === key)) { + onChange(current.filter(s => getKey(s) !== key)); + } else { + onChange([...current, { id: item.id, name: item.name, values: null }]); + } + }, + [onChange], + ); + + const updateItemValues = useCallback( + (item: FilterItem, values: string[] | null) => { + const key = getKey(item); + const current = selectedItemsRef.current; + const existing = current.find(s => getKey(s) === key); + if (existing) { + onChange(current.map(s => (getKey(s) === key ? { ...s, values } : s))); + } else { + onChange([...current, { id: item.id, name: item.name, values }]); + } + }, + [onChange], + ); + + const listHeight = Math.min(virtualizer.getTotalSize(), MAX_LIST_HEIGHT); + + return ( +
+ + {/* Note about unavailable items */} + {items.some(item => item.unavailable) && ( +
+ Struck-through items are not found in the selected + date range. +
+ )} + + {/* Item list */} + {filteredItems.length > 0 ? ( +
+
+
+ {virtualizer.getVirtualItems().map(virtualItem => { + const item = filteredItems[virtualItem.index]; + const selected = isItemSelected(item, selectedItems); + const selection = getItemSelection(item, selectedItems); + const hasPartialValues = + selected && selection?.values !== null && (selection?.values?.length ?? 0) > 0; + + return ( +
+ +
+ ); + })} +
+
+
+ ) : ( +
+ No items found +
+ )} +
+ ); +} diff --git a/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.stories.tsx b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.stories.tsx new file mode 100644 index 000000000..e632b3755 --- /dev/null +++ b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.stories.tsx @@ -0,0 +1,226 @@ +import { useState } from 'react'; +import { InsightsFilters } from '@/components/base/insights-filters'; +import { cn } from '@/lib/utils'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import type { Story, StoryDefault } from '@ladle/react'; +import { FilterDropdown, type FilterDropdownProps } from './filter-dropdown'; +import type { FilterItem, FilterSelection } from './types'; +import { ValuesSubPanel } from './values-sub-panel'; + +export default { + title: 'UI / FilterDropdown', +} satisfies StoryDefault; + +function StoryWrapper({ + items, + label, + selectedItems: initialValue, + valuesLabel, +}: Omit) { + const [value, setValue] = useState(initialValue); + return ( +
+
+
+ Active filters +
+ {value.length === 0 ? ( +
No filters active
+ ) : ( +
    + {value.map(selection => ( +
  • + {selection.name} + {' — '} + {selection.values === null ? ( + all {valuesLabel} + ) : ( + {selection.values.join(', ')} + )} +
  • + ))} +
+ )} +
+ setValue([])} + selectedItems={value} + valuesLabel={valuesLabel} + /> +
+ ); +} + +const mockClients: FilterItem[] = [ + { + name: 'Hive CLI', + values: [ + '0.12.0', + '0.12.1', + '0.12.2', + '0.12.3', + '0.12.4', + '0.12.5', + '0.12.6', + '0.12.7', + '0.12.8', + '0.12.9', + '0.12.10', + '0.12.11', + '0.12.12', + '0.12.13', + '0.12.14', + '0.12.15', + '0.12.16', + '0.12.17', + ], + }, + { name: 'Hive Client', values: ['1.0.0', '1.0.1', '1.1.0'] }, + { name: 'unknown', values: [] }, + { name: 'hive-app', values: ['2.0.0', '2.1.0'] }, + { name: 'hive-public-api', values: ['1.0.0'] }, + { name: 'hive-client-yoga', values: ['3.0.0', '3.1.0', '3.2.0'] }, + { name: 'hive-gateway', values: ['0.1.0', '0.2.0', '0.3.0', '1.0.0'] }, + { name: 'hive-go-cli', values: ['0.5.0', '0.6.0'] }, + { name: 'hive-rust-sdk', values: ['0.1.0', '0.2.0'] }, + { name: 'apollo-rover', values: ['0.23.0', '0.24.0', '0.25.0'] }, + { name: 'graphql-mesh', values: ['1.0.0', '1.1.0', '1.2.0', '1.3.0'] }, + { name: 'federation-gateway', values: ['2.0.0', '2.1.0'] }, + { name: 'stellate-edge', values: ['0.9.0', '0.10.0'] }, + { name: 'grafbase-cli', values: ['0.4.0', '0.5.0', '0.6.0'] }, + { name: 'cosmo-router', values: ['0.1.0', '0.2.0', '0.3.0'] }, + { name: 'graphql-yoga', values: ['5.0.0', '5.1.0', '5.2.0', '5.3.0'] }, + { name: 'envelop-plugin', values: ['4.0.0', '4.1.0'] }, + { name: 'graphql-codegen', values: ['5.0.0', '5.1.0', '5.2.0'] }, + { name: 'hive-python-sdk', values: ['0.3.0', '0.4.0'] }, + { name: 'hive-dotnet-sdk', values: ['0.1.0'] }, + { name: 'schema-registry-action', values: ['1.0.0', '1.1.0', '1.2.0'] }, +]; + +export const Default: Story = () => ( + +); + +export const WithSelections: Story = () => ( + +); + +export const CustomLabel: Story = () => ( + +); + +export const SubPanel: Story = () => { + const [selectedValues, setSelectedValues] = useState(null); + const values = ['0.12.0', '0.12.1', '0.12.2', '0.12.3', '0.12.4', '0.12.5']; + + return ( +
+ +
+ +
+
+
{JSON.stringify(selectedValues, null, 2)}
+
+ ); +}; + +const mockOperations: FilterItem[] = [ + { name: 'GetUser', values: [] }, + { name: 'ListProducts', values: [] }, + { name: 'CreateOrder', values: [] }, + { name: 'UpdateCart', values: [] }, + { name: 'DeleteItem', values: [] }, + { name: 'SearchInventory', values: [] }, + { name: 'GetRecommendations', values: [] }, + { name: 'ProcessPayment', values: [] }, +]; + +export const InsightsFiltersDropdown: Story = () => { + const [clientSelections, setClientSelections] = useState([]); + const [operationSelections, setOperationSelections] = useState([]); + const allSelections = [ + ...operationSelections.map(s => ({ ...s, category: 'Operation' })), + ...clientSelections.map(s => ({ ...s, category: 'Client' })), + ]; + + return ( +
+
+
+ Active filters +
+ {allSelections.length === 0 ? ( +
No filters active
+ ) : ( +
    + {allSelections.map(selection => ( +
  • + {selection.category}:{' '} + {selection.name} + {selection.values !== null && selection.values.length > 0 && ( + <> + {' — '} + {selection.values.join(', ')} + + )} +
  • + ))} +
+ )} +
+ + {}} + /> +
+ ); +}; diff --git a/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.tsx b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.tsx new file mode 100644 index 000000000..289a11ba8 --- /dev/null +++ b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { Menu, MenuItem } from '@/components/base/menu/menu'; +import { TriggerButton } from '@/components/base/trigger-button'; +import { FilterContent } from './filter-content'; +import type { FilterItem, FilterSelection } from './types'; + +export type FilterDropdownProps = { + /** Label shown on the trigger button */ + label: string; + /** Available items and their sub-values */ + items: FilterItem[]; + /** Currently selected items */ + selectedItems: FilterSelection[]; + /** Called when selection changes */ + onChange: (value: FilterSelection[]) => void; + /** Called when the entire filter is removed */ + onRemove: () => void; + /** Label for the sub-values (e.g. "versions", "endpoints"). Used in accessibility labels. */ + valuesLabel?: string; + /** When true, the trigger is visually dimmed and the menu cannot be opened */ + disabled?: boolean; +}; + +export function FilterDropdown({ + label, + items, + selectedItems, + onChange, + onRemove, + valuesLabel = 'values', + disabled, +}: FilterDropdownProps) { + const [open, setOpen] = useState(false); + + const selectedCount = selectedItems.length; + + return ( + 0 ? selectedCount.toString() : undefined} + disabled={disabled} + label={label} + rightIcon={{ icon: ChevronDown, withSeparator: true }} + /> + } + open={open} + onOpenChange={setOpen} + modal={false} + lockScroll + side="bottom" + align="start" + maxWidth="lg" + stableWidth + sections={[ + , + { + onRemove(); + setOpen(false); + }} + > + Remove filter + , + ]} + /> + ); +} diff --git a/packages/web/app/src/components/base/filter-dropdown/filter-list-search.tsx b/packages/web/app/src/components/base/filter-dropdown/filter-list-search.tsx new file mode 100644 index 000000000..7c30e0af1 --- /dev/null +++ b/packages/web/app/src/components/base/filter-dropdown/filter-list-search.tsx @@ -0,0 +1,26 @@ +type FilterListSearchProps = { + label: string; + onSearch: (value: string) => void; + value: string; +}; + +export function FilterListSearch({ label, onSearch, value }: FilterListSearchProps) { + return ( +
+ onSearch(e.target.value)} + onKeyDown={e => { + if (e.key !== 'Escape') { + e.stopPropagation(); + } + }} + className="border-neutral-5 text-neutral-11 placeholder:text-neutral-8 w-full rounded-t-md border-b py-2 pl-4 pr-2 outline-none" + /> +
+ ); +} diff --git a/packages/web/app/src/components/base/filter-dropdown/item-row.tsx b/packages/web/app/src/components/base/filter-dropdown/item-row.tsx new file mode 100644 index 000000000..0a50f2e17 --- /dev/null +++ b/packages/web/app/src/components/base/filter-dropdown/item-row.tsx @@ -0,0 +1,73 @@ +import { memo } from 'react'; +import { Checkbox } from '@/components/base/checkbox/checkbox'; +import { Menu, MenuItem } from '@/components/base/menu/menu'; +import type { FilterItem, FilterSelection } from './types'; +import { ValuesSubPanel } from './values-sub-panel'; + +interface ItemRowProps { + item: FilterItem; + selected: boolean; + indeterminate: boolean; + onToggle: (item: FilterItem) => void; + selection: FilterSelection | null; + onValuesChange: (item: FilterItem, values: string[] | null) => void; + valuesLabel: string; + unavailable?: boolean; +} + +function ItemName({ name, unavailable }: { name: string; unavailable?: boolean }) { + return ( + + {name} + + ); +} + +export const ItemRow = memo(function ItemRow({ + item, + selected, + indeterminate, + onToggle, + selection, + onValuesChange, + valuesLabel, + unavailable, +}: ItemRowProps) { + const hasValues = item.values.length > 0; + + if (!hasValues) { + return ( + onToggle(item)}> + + + + ); + } + + return ( + onToggle(item)}> + + + + } + openOnHover + delay={100} + closeDelay={150} + sections={[ + onValuesChange(item, values)} + valuesLabel={valuesLabel} + />, + ]} + /> + ); +}); diff --git a/packages/web/app/src/components/base/filter-dropdown/types.ts b/packages/web/app/src/components/base/filter-dropdown/types.ts new file mode 100644 index 000000000..c8a8b6cd0 --- /dev/null +++ b/packages/web/app/src/components/base/filter-dropdown/types.ts @@ -0,0 +1,15 @@ +export interface FilterItem { + /** Optional unique identifier. When provided, used for matching instead of name. */ + id?: string; + name: string; + values: string[]; + /** When true, the item is not found in the current date range stats. */ + unavailable?: boolean; +} + +export interface FilterSelection { + /** Optional unique identifier. When provided, used for matching instead of name. */ + id?: string; + name: string; + values: string[] | null; // null = all values +} diff --git a/packages/web/app/src/components/base/filter-dropdown/values-sub-panel.tsx b/packages/web/app/src/components/base/filter-dropdown/values-sub-panel.tsx new file mode 100644 index 000000000..52a790136 --- /dev/null +++ b/packages/web/app/src/components/base/filter-dropdown/values-sub-panel.tsx @@ -0,0 +1,89 @@ +import { useMemo, useState } from 'react'; +import { Checkbox } from '@/components/base/checkbox/checkbox'; +import { FilterListSearch } from '@/components/base/filter-dropdown/filter-list-search'; +import { MenuItem } from '@/components/base/menu/menu'; + +type ValuesSubPanelProps = { + itemName: string; + values: string[]; + selectedValues: string[] | null; // null = all values + onValuesChange: (values: string[] | null) => void; + valuesLabel: string; +}; + +export function ValuesSubPanel({ + itemName, + values, + selectedValues, + onValuesChange, + valuesLabel, +}: ValuesSubPanelProps) { + const [search, setSearch] = useState(''); + + const allSelected = selectedValues === null; + + const filteredValues = useMemo( + () => values.filter(v => v.toLowerCase().includes(search.toLowerCase())), + [values, search], + ); + + function isValueSelected(val: string) { + if (selectedValues === null) return true; + return selectedValues.includes(val); + } + + function toggleValue(val: string) { + if (selectedValues === null) { + // Was "all" → deselect this one, select all others + onValuesChange(values.filter(v => v !== val)); + } else if (selectedValues.includes(val)) { + const next = selectedValues.filter(v => v !== val); + onValuesChange(next.length === 0 ? [] : next); + } else { + const next = [...selectedValues, val]; + // If all selected, switch back to null (all) + onValuesChange(next.length === values.length ? null : next); + } + } + + function toggleAllValues() { + if (allSelected) { + onValuesChange([]); + } else { + onValuesChange(null); + } + } + + return ( +
+ + + {/* Values list */} +
+ {/* All values toggle */} + + + All {valuesLabel} + + + {/* Individual values */} + {filteredValues.map(val => { + const checked = isValueSelected(val); + return ( + toggleValue(val)}> + + {val} + + ); + })} + {filteredValues.length === 0 && ( +
No {valuesLabel} found
+ )} +
+
+ ); +} diff --git a/packages/web/app/src/components/base/insights-filters.tsx b/packages/web/app/src/components/base/insights-filters.tsx new file mode 100644 index 000000000..0ad0f8624 --- /dev/null +++ b/packages/web/app/src/components/base/insights-filters.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import { ListFilter, X } from 'lucide-react'; +import { FilterContent } from '@/components/base/filter-dropdown/filter-content'; +import { FilterItem, FilterSelection } from '@/components/base/filter-dropdown/types'; +import { Menu, MenuItem } from '@/components/base/menu/menu'; +import { TriggerButton } from '@/components/base/trigger-button'; + +export type SavedFilterView = { + id: string; + name: string; + viewerCanUpdate: boolean; + filters: { + operationHashes: string[]; + clientFilters: Array<{ name: string; versions: string[] | null }>; + dateRange: { from: string; to: string } | null; + }; +}; + +type InsightsFiltersProps = { + clientFilterItems: FilterItem[]; + clientFilterSelections: FilterSelection[]; + operationFilterItems: FilterItem[]; + operationFilterSelections: FilterSelection[]; + privateSavedFilterViews: SavedFilterView[]; + sharedSavedFilterViews: SavedFilterView[]; + setClientSelections: (value: FilterSelection[]) => void; + setOperationSelections: (value: FilterSelection[]) => void; + onApplySavedFilters: (view: SavedFilterView) => void; + onManageSavedFilters?: () => void; + activeViewId?: string; + onClearActiveView?: () => void; +}; + +function SavedFiltersList({ + savedFilters, + emptyMessage, + onApplySavedFilters, +}: { + savedFilters: SavedFilterView[]; + emptyMessage: string; + onApplySavedFilters: (view: SavedFilterView) => void; +}) { + if (savedFilters.length === 0) { + return {emptyMessage}; + } + + return ( + <> + {savedFilters.map(savedFilter => ( + onApplySavedFilters(savedFilter)}> + {savedFilter.name} + + ))} + + ); +} + +export function InsightsFilters({ + clientFilterItems, + clientFilterSelections, + operationFilterItems, + operationFilterSelections, + privateSavedFilterViews, + sharedSavedFilterViews, + setClientSelections, + setOperationSelections, + onApplySavedFilters, + onManageSavedFilters, + activeViewId, + onClearActiveView, +}: InsightsFiltersProps) { + const [open, setOpen] = useState(false); + + const activeViewName = activeViewId + ? [...privateSavedFilterViews, ...sharedSavedFilterViews].find(v => v.id === activeViewId)?.name + : undefined; + + const handleApplySavedFilter = (view: SavedFilterView) => { + setOpen(false); + // Defer navigation to next frame so the menu portals unmount first + requestAnimationFrame(() => { + onApplySavedFilters(view); + }); + }; + + return ( + + } + open={open} + onOpenChange={setOpen} + modal={false} + lockScroll + side="bottom" + align="start" + sections={[ + [ + Operations} + maxWidth="lg" + stableWidth + sections={[ + , + ]} + />, + Clients} + maxWidth="lg" + stableWidth + sections={[ + , + ]} + />, + ], + [ + My saved filters} + sections={[ + , + ]} + />, + Shared saved filters} + sections={[ + , + ]} + />, + ], + + Manage saved filters + , + ]} + /> + ); +} diff --git a/packages/web/app/src/components/base/menu/menu.stories.tsx b/packages/web/app/src/components/base/menu/menu.stories.tsx new file mode 100644 index 000000000..b7197b6cc --- /dev/null +++ b/packages/web/app/src/components/base/menu/menu.stories.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { FileText, LogOut, Plus, Settings, Users } from 'lucide-react'; +import { Checkbox } from '@/components/base/checkbox/checkbox'; +import { Menu, MenuItem } from '@/components/base/menu/menu'; +import type { Story, StoryDefault } from '@ladle/react'; +import { Flex } from '../story-utils'; + +export default { + title: 'UI / Menu', +} satisfies StoryDefault; + +export const Default: Story = () => ( + + + Open Menu + + } + sections={[ + [ + console.log('settings')}> + + Settings + , + console.log('docs')}> + + Documentation + , + ], + console.log('logout')}> + + Log out + , + ]} + /> + +); + +export const WithSubmenu: Story = () => ( + + + User Menu + + } + align="start" + sections={[ + [ + + + Switch organization + + } + sections={[ + [ + + acme-corp + , + personal, + test-org, + ], + + + Create organization + , + ]} + />, + + + Profile settings + , + ], + + + Log out + , + ]} + /> + +); + +export const WithCheckboxItems: Story = () => { + const [selected, setSelected] = useState>(new Set(['typescript'])); + + function toggle(item: string) { + setSelected(prev => { + const next = new Set(prev); + if (next.has(item)) next.delete(item); + else next.add(item); + return next; + }); + } + + const items = ['typescript', 'javascript', 'python', 'rust']; + + return ( + + + Languages ({selected.size}) + + } + sections={[ + items.map(item => ( + toggle(item)}> + + {item} + + )), + ]} + /> + + ); +}; diff --git a/packages/web/app/src/components/base/menu/menu.tsx b/packages/web/app/src/components/base/menu/menu.tsx new file mode 100644 index 000000000..d0795b931 --- /dev/null +++ b/packages/web/app/src/components/base/menu/menu.tsx @@ -0,0 +1,320 @@ +import { + createContext, + Fragment, + useCallback, + useContext, + useEffect, + useRef, + type ReactNode, +} from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { ArrowRight, ChevronRight } from 'lucide-react'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; + +// --- Contexts --- + +const MenuDepthContext = createContext(0); + +type SubmenuTriggerContextValue = { + openOnHover?: boolean; + delay?: number; + closeDelay?: number; +} | null; + +const SubmenuTriggerContext = createContext(null); + +// --- Styles --- + +const menuVariants = cva( + 'px-2 pb-2 z-50 min-w-[12rem] text-[13px] rounded-md border shadow-md shadow-neutral-1/30 outline-none bg-neutral-2 border-neutral-5 dark:bg-neutral-4 dark:border-neutral-5', + { + variants: { + maxWidth: { + default: 'max-w-75', // 300px + none: 'max-w-none', + sm: 'max-w-60', // 240px + lg: 'max-w-[380px]', + }, + }, + defaultVariants: { + maxWidth: 'default', + }, + }, +); + +const menuItemVariants = cva( + 'flex h-7 cursor-pointer select-none items-center rounded-sm outline-none gap-2.5 first:mt-2', + { + variants: { + variant: { + default: 'pl-2 text-neutral-10', + navigationLink: 'hover:text-accent text-accent_80 justify-end pr-2 hover:bg-transparent', + action: 'pl-2 hover:bg-accent_10 hover:text-accent text-accent_80', + destructiveAction: 'pl-2 text-red-400 hover:bg-red-300/10', + }, + highlighted: { + true: '', + false: '', + }, + active: { + true: '', + false: '', + }, + disabled: { + true: 'pointer-events-none opacity-50', + false: '', + }, + }, + compoundVariants: [ + { highlighted: true, className: 'bg-neutral-5 text-neutral-12' }, + { active: true, className: 'bg-neutral-5 text-neutral-12' }, + ], + defaultVariants: { + variant: 'default', + highlighted: false, + active: false, + disabled: false, + }, + }, +); + +// --- Helpers --- + +function menuItemClassName( + state: { highlighted: boolean; disabled: boolean }, + { + active, + variant, + }: { + active?: boolean; + variant?: VariantProps['variant']; + }, +) { + return menuItemVariants({ + variant, + highlighted: state.highlighted, + disabled: state.disabled, + active: active ?? false, + }); +} + +function renderSections(sections: Array): ReactNode { + const result: ReactNode[] = []; + let keyCounter = 0; + + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + const items = Array.isArray(section) ? section : [section]; + const filtered = items.filter(Boolean); + if (filtered.length === 0) continue; + + if (result.length > 0) { + result.push( +
, + ); + } + + for (const item of filtered) { + result.push({item}); + } + } + + return result; +} + +// --- Hooks --- + +/** + * Returns a callback ref that prevents a popup from shrinking while open. + * Uses a ResizeObserver to track the widest size seen and sets it as min-width, + * so the popup can grow as wider content scrolls into view but never jumps narrower. + * Resets when the element unmounts (i.e. popup closes). + */ +function useStableWidth(enabled: boolean) { + const observerRef = useRef(null); + const maxWidthSeen = useRef(0); + + return useCallback( + (node: HTMLElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + maxWidthSeen.current = 0; + + if (!node || !enabled) return; + + const observer = new ResizeObserver(() => { + const w = node.offsetWidth; + if (w > maxWidthSeen.current) { + maxWidthSeen.current = w; + node.style.minWidth = `${w}px`; + } + }); + + observer.observe(node); + observerRef.current = observer; + }, + [enabled], + ); +} + +// --- Menu --- + +type MenuProps = { + trigger: React.ReactElement; + sections: Array; + open?: boolean; + onOpenChange?: (open: boolean) => void; + modal?: boolean; + side?: 'top' | 'bottom' | 'left' | 'right'; + align?: 'start' | 'center' | 'end'; + sideOffset?: number; + /** Controls the max-width of the popup. Defaults to 300px. */ + maxWidth?: 'default' | 'none' | 'sm' | 'lg'; + /** Open the submenu when the trigger is hovered (only relevant for nested menus) */ + openOnHover?: boolean; + /** Delay in ms before the submenu opens on hover */ + delay?: number; + /** Delay in ms before the submenu closes when the pointer leaves */ + closeDelay?: number; + /** + * Prevent page scroll while the menu is open. Only applies to root menus. + * Compensates for scrollbar width to avoid layout shift. + */ + lockScroll?: boolean; + /** + * Prevent the popup from shrinking while open. The width ratchets upward + * as wider content scrolls into view (e.g. virtualized lists) but never + * jumps narrower. Resets each time the popup reopens. + */ + stableWidth?: boolean; +}; + +function Menu({ + trigger, + sections, + open, + onOpenChange, + modal, + side, + align, + sideOffset, + maxWidth, + openOnHover, + delay, + closeDelay, + lockScroll, + stableWidth, +}: MenuProps) { + const parentDepth = useContext(MenuDepthContext); + const isNested = parentDepth > 0; + const contentDepth = parentDepth + 1; + + // Lock page scroll when a root menu is open to prevent scroll-through + // (wheel events on the popup propagating to the page behind it). + useEffect(() => { + if (!lockScroll || isNested || !open) return; + + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + document.documentElement.style.overflow = 'hidden'; + document.documentElement.style.paddingRight = `${scrollbarWidth}px`; + + return () => { + document.documentElement.style.overflow = ''; + document.documentElement.style.paddingRight = ''; + }; + }, [lockScroll, isNested, open]); + + const popupRef = useStableWidth(stableWidth ?? false); + + const resolvedSide = side ?? (isNested ? 'right' : 'bottom'); + const resolvedAlign = align ?? (isNested ? 'start' : undefined); + const resolvedSideOffset = sideOffset ?? (isNested ? 6 : 8); + + const popupContent = ( + + {renderSections(sections)} + + ); + + if (isNested) { + return ( + + + {trigger} + + + + + {popupContent} + + + + + ); + } + + return ( + + + + + + {popupContent} + + + + + ); +} + +// --- MenuItem --- + +type MenuItemProps = Omit & { + active?: boolean; + variant?: VariantProps['variant']; +}; + +function MenuItem({ active, variant, children, ...props }: MenuItemProps) { + const submenuTriggerCtx = useContext(SubmenuTriggerContext); + + if (submenuTriggerCtx) { + return ( + + menuItemClassName(state, { active, variant }) + } + openOnHover={submenuTriggerCtx.openOnHover} + delay={submenuTriggerCtx.delay} + closeDelay={submenuTriggerCtx.closeDelay} + {...(props as Omit)} + > + {children} + + + ); + } + + return ( + menuItemClassName(state, { active, variant })} + {...(props as Omit)} + > + {children} + {variant === 'navigationLink' && } + + ); +} + +export { Menu, MenuItem }; diff --git a/packages/web/app/src/components/base/popover/popover.stories.tsx b/packages/web/app/src/components/base/popover/popover.stories.tsx new file mode 100644 index 000000000..fc924f04f --- /dev/null +++ b/packages/web/app/src/components/base/popover/popover.stories.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { Flex } from '@/components/base/story-utils'; +import type { Story, StoryDefault } from '@ladle/react'; +import { Popover } from './popover'; + +export default { + title: 'Base / Popover', +} satisfies StoryDefault; + +export const Default: Story = () => ( +
+ + Open popover + + } + content={

This is a popover panel.

} + /> +
+); + +export const WithArrow: Story = () => ( + + {(['top', 'right', 'bottom', 'left'] as const).map(side => ( + + {side} + + } + content={

Arrow on {side}.

} + side={side} + arrow + /> + ))} +
+); + +export const Controlled: Story = () => { + const [open, setOpen] = useState(false); + return ( +
+ + {open ? 'Close' : 'Open'} + + } + content={ +
+

Controlled popover.

+ +
+ } + /> + State: {open ? 'open' : 'closed'} +
+ ); +}; + +export const Placement: Story = () => ( +
+ {(['top', 'right', 'bottom', 'left'] as const).map(side => ( + + {side} + + } + content={

Placed on {side}.

} + side={side} + /> + ))} +
+); + +export const WithTitle: Story = () => { + const [open, setOpen] = useState(false); + return ( +
+ + Open form popover + + } + title="Save to filter collections" + content={ +
+ + +
+ } + /> +
+ ); +}; + +export const WithTitleAndDescription: Story = () => { + const [open, setOpen] = useState(false); + return ( +
+ + Open confirmation popover + + } + title="Update saved filter" + description="This will overwrite the current configuration with your current filter selections." + content={ +
+ + +
+ } + /> +
+ ); +}; diff --git a/packages/web/app/src/components/base/popover/popover.tsx b/packages/web/app/src/components/base/popover/popover.tsx new file mode 100644 index 000000000..4ccfc0ad1 --- /dev/null +++ b/packages/web/app/src/components/base/popover/popover.tsx @@ -0,0 +1,165 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Popover as BasePopover } from '@base-ui/react/popover'; + +const popoverPopupVariants = cva( + 'z-50 rounded-md border shadow-md shadow-neutral-1/30 outline-none', + { + variants: { + variant: { + default: 'bg-neutral-2 border-neutral-5 dark:bg-neutral-4 dark:border-neutral-5', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const widthMap = { + sm: 'w-64', + md: 'w-80', + lg: 'w-96', +} as const; + +type PopoverCommonProps = { + /** Element that triggers the popover on click */ + trigger: React.ReactElement; + /** Which side of the trigger to position on */ + side?: 'top' | 'bottom' | 'left' | 'right'; + /** Alignment along the side */ + align?: 'start' | 'center' | 'end'; + /** Gap between trigger and popup in px */ + sideOffset?: number; + /** Visual variant */ + variant?: VariantProps['variant']; + /** Show an arrow pointing to the trigger */ + arrow?: boolean; + /** Controlled open state */ + open?: boolean; + /** Callback when open state changes */ + onOpenChange?: (open: boolean) => void; +}; + +/** Raw mode: full control over content */ +type PopoverRawProps = PopoverCommonProps & { + /** Content rendered directly inside the popup */ + content: React.ReactNode; + title?: never; +}; + +/** Structured mode: auto-renders header with title and close button */ +type PopoverStructuredProps = PopoverCommonProps & { + /** Title text shown in the header */ + title: string; + /** Body content rendered below the header */ + content: React.ReactNode; + /** Description text rendered between header and content */ + description?: React.ReactNode; + /** Width of the popover panel */ + width?: 'sm' | 'md' | 'lg'; + /** Hide the close button in the header */ + hideCloseButton?: boolean; +}; + +export type PopoverProps = PopoverRawProps | PopoverStructuredProps; + +function isStructured(props: PopoverProps): props is PopoverStructuredProps { + return 'title' in props && props.title !== undefined; +} + +export function Popover(props: PopoverProps) { + const { + trigger, + side = 'bottom', + align, + sideOffset = 8, + variant, + arrow, + open, + onOpenChange, + } = props; + + let inner: React.ReactNode; + + if (isStructured(props)) { + const widthClass = widthMap[props.width ?? 'md']; + inner = ( +
+
+ {props.title} + {!props.hideCloseButton && ( + + )} +
+ {props.description &&

{props.description}

} + {props.content} +
+ ); + } else { + inner = props.content; + } + + return ( + + + + + + {arrow && } + {inner} + + + + + ); +} + +function PopoverArrow() { + return ( + + + + + + + ); +} diff --git a/packages/web/app/src/components/base/story-utils.tsx b/packages/web/app/src/components/base/story-utils.tsx new file mode 100644 index 000000000..a75595b12 --- /dev/null +++ b/packages/web/app/src/components/base/story-utils.tsx @@ -0,0 +1,9 @@ +// Helpful layout utils for stories + +type ChildrenProp = { + children: React.ReactNode; +}; + +export function Flex({ children }: ChildrenProp) { + return
{children}
; +} diff --git a/packages/web/app/src/components/base/trigger-button.stories.tsx b/packages/web/app/src/components/base/trigger-button.stories.tsx new file mode 100644 index 000000000..20a742128 --- /dev/null +++ b/packages/web/app/src/components/base/trigger-button.stories.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { ChevronDown, ListFilter, RefreshCw, X } from 'lucide-react'; +import { Menu, MenuItem } from '@/components/base/menu/menu'; +import type { Story, StoryDefault } from '@ladle/react'; +import { Flex } from './story-utils'; +import { TriggerButton } from './trigger-button'; + +export default { + title: 'Base / TriggerButton', +} satisfies StoryDefault; + +export const Default: Story = () => ( + + + + + +); + +export const Active: Story = () => ( + + + alert('Cleared!'), + label: 'Clear filter', + withSeparator: true, + }} + /> + +); + +export const Action: Story = () => ( + + + + +); + +export const IconOnly: Story = () => ( + + + + + +); + +export const WithMenu: Story = () => { + const [count, setCount] = useState(0); + return ( + + 0 ? count.toString() : undefined} + label="Client" + variant={count > 0 ? 'active' : 'default'} + rightIcon={{ icon: ChevronDown, withSeparator: true }} + /> + } + align="start" + sections={[ + [ + setCount(c => c + 1)}> + Add selection + , + setCount(0)}> + Clear + , + ], + ]} + /> + + ); +}; diff --git a/packages/web/app/src/components/base/trigger-button.tsx b/packages/web/app/src/components/base/trigger-button.tsx new file mode 100644 index 000000000..91487a72a --- /dev/null +++ b/packages/web/app/src/components/base/trigger-button.tsx @@ -0,0 +1,134 @@ +import { forwardRef } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { type LucideIcon } from 'lucide-react'; + +const triggerButtonVariants = cva( + 'group inline-flex items-center rounded-sm border text-xs font-medium transition-colors', + { + variants: { + variant: { + default: [ + 'bg-neutral-2 border-neutral-5 hover:bg-neutral-1 hover:border-neutral-5 text-neutral-9 hover:text-neutral-11', + 'dark:text-neutral-11 dark:bg-neutral-3 dark:border-neutral-4 dark:hover:bg-neutral-4 dark:hover:border-neutral-5 ', + ], + active: [ + 'border-neutral-5 dark:border-neutral-6 text-neutral-12', + 'bg-neutral-3 dark:bg-neutral-5', + ], + action: [ + 'border-dashed border-accent_30 text-accent_80 bg-accent_08', + 'hover:border-accent_80 hover:text-accent hover:bg-accent_10', + ], + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const separatorClass = 'border-l [border-left-color:inherit]'; + +type CommonProps = Omit, 'className' | 'style'> & + VariantProps & { + /** When true, the button is visually dimmed and non-interactive */ + disabled?: boolean; + }; + +type LabelLayout = CommonProps & { + layout?: 'label'; + /** Button label text (always shown) */ + label: string; + /** Icon rendered to the right of label */ + rightIcon?: { + action?: () => void; + icon: LucideIcon; + label?: string; + withSeparator: boolean; + }; + /** Accessory information to be displayed after the button label and before any actions */ + accessoryInformation?: string; +}; + +type IconOnlyLayout = CommonProps & { + layout: 'iconOnly'; + icon: LucideIcon; + 'aria-label': string; +}; + +type TriggerButtonProps = LabelLayout | IconOnlyLayout; + +export const TriggerButton = forwardRef( + function TriggerButton(props, ref) { + const { variant, disabled, ...rest } = props; + + // Remove custom props so they don't get spread onto the DOM element + const domProps = rest as Record; + delete domProps.layout; + delete domProps.label; + delete domProps.rightIcon; + delete domProps.accessoryInformation; + delete domProps.icon; + + return ( + + ); + }, +); diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index e90c43a92..c8b881c92 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -246,6 +246,7 @@ export const TargetLayout = ({ projectSlug: props.projectSlug, targetSlug: props.targetSlug, }} + search={{}} > Insights diff --git a/packages/web/app/src/components/target/insights/Filters.tsx b/packages/web/app/src/components/target/insights/Filters.tsx deleted file mode 100644 index 53fe0764e..000000000 --- a/packages/web/app/src/components/target/insights/Filters.tsx +++ /dev/null @@ -1,779 +0,0 @@ -import { ChangeEvent, ComponentType, ReactElement, useCallback, useEffect, useState } from 'react'; -import { FilterIcon } from 'lucide-react'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { FixedSizeList, ListChildComponentProps } from 'react-window'; -import { useQuery } from 'urql'; -import { useDebouncedCallback } from 'use-debounce'; -import { Button } from '@/components/ui/button'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { Spinner } from '@/components/ui/spinner'; -import { Checkbox, Input } from '@/components/v2'; -import { FragmentType, graphql, useFragment } from '@/gql'; -import { DateRangeInput } from '@/gql/graphql'; -import { useFormattedNumber, useToggle } from '@/lib/hooks'; - -const OperationsFilter_OperationStatsValuesConnectionFragment = graphql(` - fragment OperationsFilter_OperationStatsValuesConnectionFragment on OperationStatsValuesConnection { - edges { - node { - id - operationHash - name - ...OperationRow_OperationStatsValuesFragment - } - } - } -`); - -function OperationsFilter({ - onClose, - isOpen, - onFilter, - operationStatsConnection, - selected, - clientOperationStatsConnection, -}: { - onClose(): void; - onFilter(keys: string[]): void; - isOpen: boolean; - operationStatsConnection: FragmentType< - typeof OperationsFilter_OperationStatsValuesConnectionFragment - >; - clientOperationStatsConnection?: - | FragmentType - | undefined; - selected?: string[]; -}): ReactElement { - const operations = useFragment( - OperationsFilter_OperationStatsValuesConnectionFragment, - operationStatsConnection, - ); - - const clientFilteredOperations = useFragment( - OperationsFilter_OperationStatsValuesConnectionFragment, - clientOperationStatsConnection, - ); - - function getOperationHashes() { - const items: string[] = []; - for (const { node: op } of operations.edges) { - if (op.operationHash) { - items.push(op.operationHash); - } - } - return items; - } - - const [selectedItems, setSelectedItems] = useState(() => - getOperationHashes().filter(hash => selected?.includes(hash) ?? true), - ); - - const onSelect = useCallback( - (operationHash: string, selected: boolean) => { - const itemAt = selectedItems.findIndex(hash => hash === operationHash); - const exists = itemAt > -1; - - if (selected && !exists) { - setSelectedItems([...selectedItems, operationHash]); - } else if (!selected && exists) { - setSelectedItems(selectedItems.filter(hash => hash !== operationHash)); - } - }, - [selectedItems, setSelectedItems], - ); - const [searchTerm, setSearchTerm] = useState(''); - const debouncedFilter = useDebouncedCallback((value: string) => { - setVisibleOperations( - operations.edges.filter(({ node: op }) => - op.name.toLocaleLowerCase().includes(value.toLocaleLowerCase()), - ), - ); - }, 500); - - const onChange = useCallback( - (event: ChangeEvent) => { - const { value } = event.currentTarget; - - setSearchTerm(value); - debouncedFilter(value); - }, - [setSearchTerm, debouncedFilter], - ); - - const [visibleOperations, setVisibleOperations] = useState(operations.edges); - - const selectAll = useCallback(() => { - setSelectedItems(getOperationHashes()); - }, [operations]); - const selectNone = useCallback(() => { - setSelectedItems([]); - }, [setSelectedItems]); - - const renderRow = useCallback>( - ({ index, style }) => { - const operation = visibleOperations[index].node; - const clientOpStats = clientFilteredOperations?.edges.find( - e => e.node.operationHash === operation.operationHash, - )?.node; - - return ( - - ); - }, - [visibleOperations, selectedItems, onSelect, clientFilteredOperations], - ); - - return ( - - - - Filter by operation - - -
- { - setSearchTerm(''); - setVisibleOperations(operations.edges); - }} - /> -
- - - - -
-
- {clientFilteredOperations && ( -
- selected / all clients -
- )} - - {({ height, width }) => - !height || !width ? ( - <> - ) : ( - - {renderRow} - - ) - } - -
-
-
-
- ); -} - -const OperationsFilterContainer_OperationStatsQuery = graphql(` - query OperationsFilterContainer_OperationStatsQuery( - $targetSelector: TargetSelectorInput! - $period: DateRangeInput! - $filter: OperationStatsFilterInput - $hasFilter: Boolean! - ) { - target(reference: { bySelector: $targetSelector }) { - id - operationsStats(period: $period) { - operations { - edges { - __typename - } - ...OperationsFilter_OperationStatsValuesConnectionFragment - } - } - clientOperationStats: operationsStats(period: $period, filter: $filter) - @include(if: $hasFilter) { - operations { - edges { - __typename - } - ...OperationsFilter_OperationStatsValuesConnectionFragment - } - } - } - } -`); - -function OperationsFilterContainer({ - period, - isOpen, - onClose, - onFilter, - selected, - organizationSlug, - projectSlug, - targetSlug, - clientNames, -}: { - onFilter(keys: string[]): void; - onClose(): void; - isOpen: boolean; - period: DateRangeInput; - selected?: string[]; - organizationSlug: string; - projectSlug: string; - targetSlug: string; - clientNames?: string[]; -}): ReactElement | null { - const [query, refresh] = useQuery({ - query: OperationsFilterContainer_OperationStatsQuery, - variables: { - targetSelector: { - organizationSlug, - projectSlug, - targetSlug, - }, - period, - filter: clientNames ? { clientNames } : undefined, - hasFilter: !!clientNames?.length, - }, - }); - - useEffect(() => { - if (!query.fetching) { - refresh({ requestPolicy: 'network-only' }); - } - }, [period]); - - if (!isOpen) { - return null; - } - - if (query.fetching || query.error || !query.data?.target) { - return ; - } - - const { target } = query.data; - - return ( - { - onFilter(hashes.length === target.operationsStats.operations.edges.length ? [] : hashes); - }} - /> - ); -} - -const OperationRow_OperationStatsValuesFragment = graphql(` - fragment OperationRow_OperationStatsValuesFragment on OperationStatsValues { - id - name - operationHash - count - } -`); - -function OperationRow({ - operationStats, - clientOperationStats, - selected, - onSelect, - style, -}: { - operationStats: FragmentType; - /** Stats for the operation filtered by the selected clients */ - clientOperationStats?: - | FragmentType - | null - | false; - selected: boolean; - onSelect(id: string, selected: boolean): void; - style: any; -}): ReactElement { - const operation = useFragment(OperationRow_OperationStatsValuesFragment, operationStats); - const requests = useFormattedNumber(operation.count); - const clientsOperation = useFragment( - OperationRow_OperationStatsValuesFragment, - clientOperationStats || null, - ); - const hasClientOperation = clientOperationStats !== false; - const clientsRequests = useFormattedNumber(clientsOperation?.count); - const hash = operation.operationHash || ''; - const change = useCallback(() => { - if (hash) { - onSelect(hash, !selected); - } - }, [onSelect, hash, selected]); - - const Totals = () => { - if (hasClientOperation) { - return ( -
- {clientsRequests === '-' ? 0 : clientsRequests} - / {requests} -
- ); - } - return
{requests}
; - }; - - return ( -
- - -
- ); -} - -export function OperationsFilterTrigger({ - period, - onFilter, - selected, - organizationSlug, - projectSlug, - targetSlug, - clientNames, -}: { - period: DateRangeInput; - onFilter(keys: string[]): void; - selected?: string[]; - organizationSlug: string; - projectSlug: string; - targetSlug: string; - clientNames?: string[]; -}): ReactElement { - const [isOpen, toggle] = useToggle(); - - return ( - <> - - - - ); -} - -const ClientRow_ClientStatsValuesFragment = graphql(` - fragment ClientRow_ClientStatsValuesFragment on ClientStatsValues { - name - count - } -`); - -function ClientRow({ - selected, - onSelect, - style, - ...props -}: { - client: FragmentType; - clientOperationStats: - | FragmentType - | false - | undefined; - selected: boolean; - onSelect(id: string, selected: boolean): void; - style: any; -}): ReactElement { - const client = useFragment(ClientRow_ClientStatsValuesFragment, props.client); - const clientOperation = useFragment( - ClientRow_ClientStatsValuesFragment, - props.clientOperationStats || null, - ); - const requests = useFormattedNumber(client.count); - const hash = client.name; - const change = useCallback(() => { - if (hash) { - onSelect(hash, !selected); - } - }, [onSelect, hash, selected]); - - const Totals = () => { - if (props.clientOperationStats !== false) { - return ( -
- {clientOperation?.count ?? 0} - / {requests} -
- ); - } - return
{requests}
; - }; - - return ( -
- - -
- ); -} - -const ClientsFilter_ClientStatsValuesConnectionFragment = graphql(` - fragment ClientsFilter_ClientStatsValuesConnectionFragment on ClientStatsValuesConnection { - edges { - node { - name - ...ClientRow_ClientStatsValuesFragment - } - } - } -`); - -function ClientsFilter({ - onClose, - isOpen, - onFilter, - clientStatsConnection, - operationStatsConnection, - selected, -}: { - onClose(): void; - onFilter(keys: string[]): void; - isOpen: boolean; - clientStatsConnection: FragmentType; - operationStatsConnection?: - | FragmentType - | undefined; - selected?: string[]; -}): ReactElement { - const clientConnection = useFragment( - ClientsFilter_ClientStatsValuesConnectionFragment, - clientStatsConnection, - ); - function getClientNames() { - return clientConnection.edges.map(edge => edge.node.name); - } - - const [selectedItems, setSelectedItems] = useState(() => - getClientNames().filter(name => selected?.includes(name) ?? true), - ); - - const onSelect = useCallback( - (operationHash: string, selected: boolean) => { - const itemAt = selectedItems.findIndex(hash => hash === operationHash); - const exists = itemAt > -1; - - if (selected && !exists) { - setSelectedItems([...selectedItems, operationHash]); - } else if (!selected && exists) { - setSelectedItems(selectedItems.filter(hash => hash !== operationHash)); - } - }, - [selectedItems, setSelectedItems], - ); - const [searchTerm, setSearchTerm] = useState(''); - const debouncedFilter = useDebouncedCallback((value: string) => { - setVisibleOperations( - clientConnection.edges.filter(edge => - edge.node.name.toLocaleLowerCase().includes(value.toLocaleLowerCase()), - ), - ); - }, 500); - - const onChange = useCallback( - (event: ChangeEvent) => { - const { value } = event.currentTarget; - - setSearchTerm(value); - debouncedFilter(value); - }, - [setSearchTerm, debouncedFilter], - ); - - const [visibleOperations, setVisibleOperations] = useState(clientConnection.edges); - - const selectAll = useCallback(() => { - setSelectedItems(getClientNames()); - }, [clientConnection.edges]); - const selectNone = useCallback(() => { - setSelectedItems([]); - }, [setSelectedItems]); - - const operationConnection = useFragment( - ClientsFilter_ClientStatsValuesConnectionFragment, - operationStatsConnection ?? null, - ); - - const renderRow = useCallback>( - ({ index, style }) => { - const client = visibleOperations[index].node; - const operationStats = - operationConnection == null - ? false - : operationConnection.edges.find(e => e.node.name === client.name)?.node; - - return ( - - ); - }, - [visibleOperations, selectedItems, onSelect, operationConnection], - ); - - return ( - - - - Filter by client - - -
- { - setSearchTerm(''); - setVisibleOperations(clientConnection.edges); - }} - /> -
- - - - -
-
- {operationStatsConnection && ( -
- selected / all operations -
- )} - - {({ height, width }) => - !height || !width ? ( - <> - ) : ( - - {renderRow} - - ) - } - -
-
-
-
- ); -} - -const ClientsFilterContainer_ClientStatsQuery = graphql(` - query ClientsFilterContainer_ClientStats( - $targetSelector: TargetSelectorInput! - $period: DateRangeInput! - $filter: OperationStatsFilterInput - $hasFilter: Boolean! - ) { - target(reference: { bySelector: $targetSelector }) { - id - operationsStats(period: $period) { - clients { - ...ClientsFilter_ClientStatsValuesConnectionFragment - edges { - node { - __typename - } - } - } - } - filteredOperationStats: operationsStats(period: $period, filter: $filter) - @include(if: $hasFilter) { - clients { - ...ClientsFilter_ClientStatsValuesConnectionFragment - edges { - node { - __typename - } - } - } - } - } - } -`); - -function ClientsFilterContainer({ - period, - isOpen, - onClose, - onFilter, - selected, - selectedOperationIds, - organizationSlug, - projectSlug, - targetSlug, -}: { - onFilter(keys: string[]): void; - onClose(): void; - isOpen: boolean; - period: DateRangeInput; - selected?: string[]; - organizationSlug: string; - projectSlug: string; - targetSlug: string; - selectedOperationIds?: string[]; -}): ReactElement | null { - const [query, refresh] = useQuery({ - query: ClientsFilterContainer_ClientStatsQuery, - variables: { - targetSelector: { - organizationSlug, - projectSlug, - targetSlug, - }, - period, - filter: selectedOperationIds ? { operationIds: selectedOperationIds } : undefined, - hasFilter: !!selectedOperationIds?.length, - }, - }); - - useEffect(() => { - if (!query.fetching) { - refresh({ requestPolicy: 'network-only' }); - } - }, [period]); - - if (!isOpen) { - return null; - } - - if (query.fetching || query.error || !query.data?.target) { - return ; - } - - const allClients = query.data.target?.operationsStats?.clients.edges ?? []; - - return ( - { - onFilter(clientNames.length === allClients.length ? [] : clientNames); - }} - /> - ); -} - -export function ClientsFilterTrigger({ - period, - onFilter, - selected, - organizationSlug, - projectSlug, - targetSlug, - selectedOperationIds, -}: { - period: DateRangeInput; - onFilter(keys: string[]): void; - selected?: string[]; - selectedOperationIds?: string[]; - organizationSlug: string; - projectSlug: string; - targetSlug: string; -}): ReactElement { - const [isOpen, toggle] = useToggle(); - - return ( - <> - - - - ); -} diff --git a/packages/web/app/src/components/target/insights/List.tsx b/packages/web/app/src/components/target/insights/List.tsx index d2790cc42..f9bcea43c 100644 --- a/packages/web/app/src/components/target/insights/List.tsx +++ b/packages/web/app/src/components/target/insights/List.tsx @@ -11,9 +11,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { Sortable, Table, TBody, Td, Th, THead, Tr } from '@/components/v2'; import { env } from '@/env/frontend'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { DateRangeInput } from '@/gql/graphql'; +import { DateRangeInput, OperationStatsFilterInput } from '@/gql/graphql'; import { useDecimal, useFormattedDuration, useFormattedNumber } from '@/lib/hooks'; -import { pick } from '@/lib/object'; import { ChevronUpIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { createColumnHelper, @@ -79,11 +78,10 @@ function OperationRow({ operationName: operation.name, operationHash: operation.hash, }} - search={searchParams => ({ - ...pick(searchParams, ['clients']), + search={{ from: selectedPeriod?.from ? encodeURIComponent(selectedPeriod.from) : undefined, to: selectedPeriod?.to ? encodeURIComponent(selectedPeriod.to) : undefined, - })} + }} > {operation.name} @@ -501,8 +499,7 @@ export function OperationsList({ projectSlug, targetSlug, period, - operationsFilter = [], - clientNamesFilter = [], + filter, selectedPeriod, }: { className?: string; @@ -510,9 +507,7 @@ export function OperationsList({ projectSlug: string; targetSlug: string; period: DateRangeInput; - /** Operation IDs to filter on */ - operationsFilter: string[]; - clientNamesFilter: string[]; + filter: OperationStatsFilterInput; selectedPeriod: null | { to: string; from: string }; }): ReactElement { const [clientFilter, setClientFilter] = useState(null); @@ -525,10 +520,7 @@ export function OperationsList({ targetSlug, }, period, - filter: { - operationIds: operationsFilter, - clientNames: clientNamesFilter, - }, + filter, }, }); @@ -538,7 +530,7 @@ export function OperationsList({ if (!query.fetching) { refetch(); } - }, [period]); + }, [period, filter]); return ( { if (requestsOverTime?.length) { @@ -327,7 +327,7 @@ function OverTimeStats({ min: 0, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -340,7 +340,7 @@ function OverTimeStats({ min: 0, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -355,7 +355,7 @@ function OverTimeStats({ name: 'Requests', showSymbol: false, smooth: false, - color: CHART_PRIMARY_COLOR, + color: colors.primary, areaStyle: {}, emphasis: { focus: 'series', @@ -368,7 +368,7 @@ function OverTimeStats({ name: 'Failures', showSymbol: false, smooth: false, - color: '#ef4444', + color: colors.error, areaStyle: {}, emphasis: { focus: 'series', @@ -434,7 +434,7 @@ function ClientsStats(props: { targetSlug: string; }): ReactElement { const router = useRouter(); - const styles = useChartStyles(); + const { styles, colors } = useChartStyles(); const operationStats = useFragment(ClientsStats_OperationsStatsFragment, props.operationStats); const sortedClients = useMemo(() => { return operationStats?.clients.edges?.length @@ -626,7 +626,7 @@ function ClientsStats(props: { type: 'value', splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -643,7 +643,7 @@ function ClientsStats(props: { { type: 'bar', data: byClient.values, - color: CHART_PRIMARY_COLOR, + color: colors.primary, }, ], }} @@ -667,7 +667,7 @@ function ClientsStats(props: { type: 'value', splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -683,7 +683,7 @@ function ClientsStats(props: { { type: 'bar', data: byVersion.values, - color: CHART_PRIMARY_COLOR, + color: colors.primary, }, ], }} @@ -744,7 +744,8 @@ function ClientsStats(props: { a: { padding: 4, height: 15, - backgroundColor: 'rgba(0,0,0,0.7)', + color: colors.overlayText, + backgroundColor: colors.overlayBg, }, }, }, @@ -752,18 +753,18 @@ function ClientsStats(props: { show: true, height: 30, formatter: '{b}', - color: '#fff', + color: styles.textStyle.color, backgroundColor: 'transparent', padding: 5, fontWeight: 'bold', overflow: 'none', }, itemStyle: { - borderColor: 'rgba(255, 255, 255, 0.2)', + borderColor: colors.overlayBorder, }, levels: getLevelOption(), data: byClientAndVersion, - color: CHART_PRIMARY_COLOR, + color: colors.primary, }, ], }} @@ -806,7 +807,7 @@ function LatencyOverTimeStats({ }: { operationStats?: FragmentType | null; }): ReactElement { - const styles = useChartStyles(); + const { styles, colors } = useChartStyles(); const { durationOverTime: duration = [] } = useFragment(LatencyOverTimeStats_OperationStatsFragment, operationStats) ?? {}; const p75 = useMemo(() => { @@ -852,10 +853,10 @@ function LatencyOverTimeStats({ } const series = [ - createSeries('p75', '#10b981', p75), - createSeries('p90', '#0ea5e9', p90), - createSeries('p95', '#8b5cf6', p95), - createSeries('p99', '#ec4899', p99), + createSeries('p75', colors.p75, p75), + createSeries('p90', colors.p90, p90), + createSeries('p95', colors.p95, p95), + createSeries('p99', colors.p99, p99), ]; return ( @@ -887,7 +888,7 @@ function LatencyOverTimeStats({ boundaryGap: false, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -903,7 +904,7 @@ function LatencyOverTimeStats({ min: 0, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -942,7 +943,7 @@ function RpmOverTimeStats({ resolution: number; operationStats: FragmentType | null; }): ReactElement { - const styles = useChartStyles(); + const { styles, colors } = useChartStyles(); const { requestsOverTime: requests = [] } = useFragment(RpmOverTimeStats_OperationStatsFragment, operationStats) ?? {}; @@ -985,7 +986,7 @@ function RpmOverTimeStats({ boundaryGap: false, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -1008,7 +1009,7 @@ function RpmOverTimeStats({ }, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -1021,12 +1022,12 @@ function RpmOverTimeStats({ symbol: 'none', smooth: false, areaStyle: { - color: CHART_PRIMARY_COLOR, + color: colors.primary, }, lineStyle: { - color: CHART_PRIMARY_COLOR, + color: colors.primary, }, - color: CHART_PRIMARY_COLOR, + color: colors.primary, large: true, data: rpmOverTime, }, @@ -1044,8 +1045,7 @@ export function OperationsStats({ projectSlug, targetSlug, period, - operationsFilter, - clientNamesFilter, + filter, resolution, mode, dateRangeText, @@ -1059,8 +1059,7 @@ export function OperationsStats({ }; dateRangeText: string; resolution: number; - operationsFilter: string[]; - clientNamesFilter: Array; + filter: OperationStatsFilterInput; mode: 'operation-page' | 'operation-list'; }): ReactElement { const [query, refetchQuery] = useQuery({ @@ -1072,10 +1071,7 @@ export function OperationsStats({ targetSlug, }, period, - filter: { - operationIds: operationsFilter, - clientNames: clientNamesFilter, - }, + filter, resolution, }, }); @@ -1090,7 +1086,7 @@ export function OperationsStats({ if (!query.fetching) { refetch(); } - }, [period]); + }, [period, filter]); const isFetching = query.fetching; const isError = !!query.error; diff --git a/packages/web/app/src/components/target/insights/save-filter-button.tsx b/packages/web/app/src/components/target/insights/save-filter-button.tsx new file mode 100644 index 000000000..d00da21d6 --- /dev/null +++ b/packages/web/app/src/components/target/insights/save-filter-button.tsx @@ -0,0 +1,233 @@ +import { useCallback, useState } from 'react'; +import { useMutation } from 'urql'; +import type { SavedFilterView } from '@/components/base/insights-filters'; +import { Popover } from '@/components/base/popover/popover'; +import { TriggerButton } from '@/components/base/trigger-button'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/components/ui/use-toast'; +import { graphql } from '@/gql'; +import { SavedFilterVisibilityType } from '@/gql/graphql'; +import { UpdateFilterButton } from './update-filter-button'; + +const InsightsCreateSavedFilter_Mutation = graphql(` + mutation InsightsCreateSavedFilter($input: CreateSavedFilterInput!) { + createSavedFilter(input: $input) { + error { + message + } + ok { + savedFilter { + id + name + visibility + viewerCanUpdate + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + } + } + } + } +`); + +export type CurrentFilters = { + operations: string[]; + clients: Array<{ name: string; versions: string[] | null }>; + dateRange: { from: string; to: string }; +}; + +type SaveFilterButtonProps = { + activeView: SavedFilterView | null; + viewerCanCreate: boolean; + viewerCanShare: boolean; + currentFilters: CurrentFilters; + organizationSlug: string; + projectSlug: string; + targetSlug: string; + onSaved: (viewId: string) => void; + onUpdated: () => void; +}; + +export function SaveFilterButton({ + activeView, + viewerCanCreate, + viewerCanShare, + currentFilters, + organizationSlug, + projectSlug, + targetSlug, + onSaved, + onUpdated, +}: SaveFilterButtonProps) { + if (activeView?.viewerCanUpdate) { + return ( + + ); + } + + if (viewerCanCreate) { + return ( + + ); + } + + return null; +} + +function SaveFilterPopover({ + viewerCanShare, + currentFilters, + organizationSlug, + projectSlug, + targetSlug, + onSaved, +}: { + viewerCanShare: boolean; + currentFilters: CurrentFilters; + organizationSlug: string; + projectSlug: string; + targetSlug: string; + onSaved: (viewId: string) => void; +}) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [visibility, setVisibility] = useState( + SavedFilterVisibilityType.Private, + ); + const [createResult, createSavedFilter] = useMutation(InsightsCreateSavedFilter_Mutation); + const { toast } = useToast(); + + const handleSave = useCallback(async () => { + const trimmed = name.trim(); + if (!trimmed) return; + + const result = await createSavedFilter({ + input: { + target: { bySelector: { organizationSlug, projectSlug, targetSlug } }, + name: trimmed, + visibility, + insightsFilter: { + operationHashes: currentFilters.operations, + clientFilters: currentFilters.clients.map(c => ({ + name: c.name, + versions: c.versions, + })), + dateRange: { + from: currentFilters.dateRange.from, + to: currentFilters.dateRange.to, + }, + }, + }, + }); + + if (result.data?.createSavedFilter.ok) { + toast({ + title: 'Filter saved', + description: `"${trimmed}" has been saved.`, + }); + setOpen(false); + setName(''); + setVisibility(SavedFilterVisibilityType.Private); + onSaved(result.data.createSavedFilter.ok.savedFilter.id); + } else { + toast({ + variant: 'destructive', + title: 'Error', + description: + result.data?.createSavedFilter.error?.message ?? + result.error?.message ?? + 'Failed to save filter.', + }); + } + }, [ + name, + visibility, + currentFilters, + organizationSlug, + projectSlug, + targetSlug, + createSavedFilter, + toast, + onSaved, + ]); + + return ( + } + content={ +
+
+ setName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && name.trim()) { + void handleSave(); + } + }} + /> +
+
+ +
+ +
+ } + /> + ); +} diff --git a/packages/web/app/src/components/target/insights/update-filter-button.tsx b/packages/web/app/src/components/target/insights/update-filter-button.tsx new file mode 100644 index 000000000..23dc071de --- /dev/null +++ b/packages/web/app/src/components/target/insights/update-filter-button.tsx @@ -0,0 +1,163 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useMutation } from 'urql'; +import type { SavedFilterView } from '@/components/base/insights-filters'; +import { Popover } from '@/components/base/popover/popover'; +import { TriggerButton } from '@/components/base/trigger-button'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/use-toast'; +import { graphql } from '@/gql'; +import type { CurrentFilters } from './save-filter-button'; + +const InsightsUpdateSavedFilter_Mutation = graphql(` + mutation InsightsUpdateSavedFilter($input: UpdateSavedFilterInput!) { + updateSavedFilter(input: $input) { + error { + message + } + ok { + savedFilter { + id + name + visibility + viewerCanUpdate + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + } + } + } + } +`); + +type UpdateFilterButtonProps = { + activeView: SavedFilterView; + currentFilters: CurrentFilters; + organizationSlug: string; + projectSlug: string; + targetSlug: string; + onUpdated: () => void; +}; + +export function UpdateFilterButton({ + activeView, + currentFilters, + organizationSlug, + projectSlug, + targetSlug, + onUpdated, +}: UpdateFilterButtonProps) { + const [confirmOpen, setConfirmOpen] = useState(false); + const [updateResult, updateSavedFilter] = useMutation(InsightsUpdateSavedFilter_Mutation); + const { toast } = useToast(); + + const hasUnsavedChanges = useMemo(() => { + const savedOps = [...activeView.filters.operationHashes].sort(); + const currentOps = [...currentFilters.operations].sort(); + + if (JSON.stringify(currentOps) !== JSON.stringify(savedOps)) return true; + + const normalizeClients = (arr: Array<{ name: string; versions: string[] | null }>) => + JSON.stringify( + arr + .map(c => ({ name: c.name, versions: c.versions })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + if ( + normalizeClients(currentFilters.clients) !== + normalizeClients(activeView.filters.clientFilters) + ) + return true; + + const savedFrom = activeView.filters.dateRange?.from; + const savedTo = activeView.filters.dateRange?.to; + if (currentFilters.dateRange.from !== savedFrom || currentFilters.dateRange.to !== savedTo) + return true; + + return false; + }, [activeView, currentFilters]); + + const handleUpdate = useCallback(async () => { + const result = await updateSavedFilter({ + input: { + id: activeView.id, + target: { bySelector: { organizationSlug, projectSlug, targetSlug } }, + insightsFilter: { + operationHashes: currentFilters.operations, + clientFilters: currentFilters.clients.map(c => ({ + name: c.name, + versions: c.versions, + })), + dateRange: { + from: currentFilters.dateRange.from, + to: currentFilters.dateRange.to, + }, + }, + }, + }); + + if (result.data?.updateSavedFilter.ok) { + toast({ + title: 'Filter updated', + description: `"${activeView.name}" has been updated.`, + }); + setConfirmOpen(false); + onUpdated(); + } else { + toast({ + variant: 'destructive', + title: 'Error', + description: + result.data?.updateSavedFilter.error?.message ?? + result.error?.message ?? + 'Failed to update filter.', + }); + } + }, [ + activeView, + currentFilters, + organizationSlug, + projectSlug, + targetSlug, + updateSavedFilter, + toast, + onUpdated, + ]); + + if (!hasUnsavedChanges) { + return null; + } + + return ( + } + content={ +
+ + +
+ } + /> + ); +} diff --git a/packages/web/app/src/components/ui/alert-dialog.tsx b/packages/web/app/src/components/ui/alert-dialog.tsx index 1aa9d3f56..2462512f2 100644 --- a/packages/web/app/src/components/ui/alert-dialog.tsx +++ b/packages/web/app/src/components/ui/alert-dialog.tsx @@ -15,7 +15,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( { + const [selectedRange, setSelectedRange] = useState(presetLast7Days.range); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; + +/** With Last 24 hours selected (used in operation detail views) */ +export const Last24Hours: Story = () => { + const [selectedRange, setSelectedRange] = useState(presetLast1Day.range); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; + +/** With restricted valid units — excludes minutes (used in operation insights) */ +export const RestrictedUnits: Story = () => { + const [selectedRange, setSelectedRange] = useState(presetLast7Days.range); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; + +/** All units enabled with startDate and end-aligned popover (used in manage/admin pages) */ +export const AllUnitsWithStartDate: Story = () => { + const [selectedRange, setSelectedRange] = useState(presetLast7Days.range); + const startDate = new Date(); + startDate.setFullYear(startDate.getFullYear() - 1); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; + +/** With a custom absolute date range selected */ +export const AbsoluteDateRange: Story = () => { + const from = new Date(); + from.setDate(from.getDate() - 3); + const to = new Date(); + + const absoluteRange = { + from: from.toISOString(), + to: to.toISOString(), + }; + + const [selectedRange, setSelectedRange] = useState(absoluteRange); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; + +/** With custom presets instead of the defaults */ +export const CustomPresets: Story = () => { + const customPresets: Preset[] = [ + { name: 'last1h', label: 'Last 1 hour', range: { from: 'now-1h', to: 'now' } }, + { name: 'last6h', label: 'Last 6 hours', range: { from: 'now-6h', to: 'now' } }, + presetLast1Day, + presetLast7Days, + ]; + + const [selectedRange, setSelectedRange] = useState(customPresets[0].range); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; + +/** With no initial selection */ +export const NoSelection: Story = () => { + const [selectedRange, setSelectedRange] = useState(null); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; + +/** With a startDate that disables older presets */ +export const RecentStartDate: Story = () => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 7); + + const [selectedRange, setSelectedRange] = useState(presetLast1Day.range); + + return ( + setSelectedRange(args.preset.range)} + /> + ); +}; diff --git a/packages/web/app/src/components/ui/date-range-picker.tsx b/packages/web/app/src/components/ui/date-range-picker.tsx index 26501f70c..9210fcc93 100644 --- a/packages/web/app/src/components/ui/date-range-picker.tsx +++ b/packages/web/app/src/components/ui/date-range-picker.tsx @@ -14,7 +14,7 @@ import { Button } from './button'; import { Calendar } from './calendar'; import { Input } from './input'; import { Label } from './label'; -import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './popover'; export interface DateRangePickerProps { presets?: Preset[]; @@ -24,12 +24,30 @@ export interface DateRangePickerProps { onUpdate?: (values: { preset: Preset }) => void; /** Alignment of popover */ align?: 'start' | 'center' | 'end'; + /** Side of the trigger to place the popover content */ + side?: 'top' | 'bottom' | 'left' | 'right'; /** Option for locale */ locale?: string; /** Date after which a range can be picked. */ startDate?: Date; /** valid units allowed */ validUnits?: DurationUnit[]; + /** Custom trigger element. Must forward ref. Replaces the default Button trigger. */ + trigger?: React.ReactElement; +} + +export interface DateRangePickerPanelProps { + presets?: Preset[]; + /** the active selected/custom preset */ + selectedRange?: { from: string; to: string } | null; + /** Click handler for applying the updates from DateRangePicker. */ + onUpdate?: (values: { preset: Preset }) => void; + /** Date after which a range can be picked. */ + startDate?: Date; + /** valid units allowed */ + validUnits?: DurationUnit[]; + /** Called when a selection is made. Parent should close the container (popover, submenu, etc). */ + onClose?: () => void; } interface ResolvedDateRange { @@ -154,8 +172,40 @@ export function findMatchingPreset( }); } -/** The DateRangePicker component allows a user to select a range of dates */ -export function DateRangePicker(props: DateRangePickerProps): JSX.Element { +function getDisplayLabel( + selectedRange: { from: string; to: string } | null | undefined, + presets: Preset[], + validUnits: DurationUnit[], +): string { + if (!selectedRange) { + return presets.at(0)?.label ?? 'Select range'; + } + + const staticMatch = findMatchingPreset(selectedRange, presets); + if (staticMatch) return staticMatch.label; + + if (selectedRange.from.startsWith('now-')) { + const number = parseInt(selectedRange.from.replace(/\D/g, ''), 10); + if (!Number.isNaN(number)) { + const dynamicMatch = findMatchingPreset( + selectedRange, + createQuickRangePresets(number, validUnits), + ); + if (dynamicMatch) return dynamicMatch.label; + } + } + + const resolved = resolveRange(selectedRange.from, selectedRange.to); + if (resolved) return buildDateRangeString(resolved); + + return presets.at(0)?.label ?? 'Select range'; +} + +/** + * The standalone date range picker panel containing all picker UI and state. + * Can be rendered inside a Popover, a menu submenu, or any other container. + */ +export function DateRangePickerPanel(props: DateRangePickerPanelProps) { const validUnits = props.validUnits ?? units; const disallowedUnits = units.filter(unit => !validUnits.includes(unit)); const hasInvalidUnitRegex = disallowedUnits?.length @@ -183,7 +233,6 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element { }); } - const [isOpen, setIsOpen] = useState(false); const [showCalendar, setShowCalendar] = useState(false); function getInitialPreset() { @@ -272,10 +321,6 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element { lastPreset.current = activePreset; }, [activePreset]); - const resetValues = (): void => { - setActivePreset(getInitialPreset()); - }; - const PresetButton = useMemo( () => function PresetButton({ preset }: { preset: Preset }): React.ReactNode { @@ -306,8 +351,8 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element { setToValue(preset.range.to); setRange(undefined); setShowCalendar(false); - setIsOpen(false); setQuickRangeFilter(''); + props.onClose?.(); }} disabled={isDisabled} className="w-full justify-start text-left" @@ -316,7 +361,7 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element { ); }, - [props.startDate], + [props.startDate, props.onClose], ); const dynamicPresets = useMemo(() => { @@ -341,190 +386,226 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element { return []; }, [quickRangeFilter, validUnits]); + return ( +
+ + +
+
+
+
Absolute date range
+
+
+ +
+
+ { + setFromValue(ev.target.value); + }} + className="font-mono text-xs" + /> + +
+
+
+ {hasInvalidUnitRegex?.test(fromValue) ? ( + <>Only allowed units are {validUnits.join(', ')} + ) : !fromParsed ? ( + <>Invalid date string + ) : null} +
+
+
+ +
+
+ { + setToValue(ev.target.value); + }} + className="font-mono text-xs" + /> + +
+
+
+ {hasInvalidUnitRegex?.test(toValue) ? ( + <>Only allowed units are {validUnits.join(', ')} + ) : !toParsed ? ( + <>Invalid date string + ) : fromParsed && toParsed && fromParsed.getTime() > toParsed.getTime() ? ( +
To cannot be before from.
+ ) : null} +
+
+ + +
+
+
+
+
+ + + { + if (range?.from && range.to) { + setFromValue(formatDateToString(range.from)); + setToValue(formatDateToString(endOfDay(range.to))); + } + setRange(range); + }} + disabled={disabledDays} + /> + +
+
+
+ + setQuickRangeFilter(ev.target.value)} + /> +
+
+ {dynamicPresets.length > 0 + ? dynamicPresets + .filter(preset => + preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()), + ) + .map(preset => ) + : staticPresets + .filter(preset => + preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()), + ) + .map(preset => )} +
+
+
+ ); +} + +/** The DateRangePicker component allows a user to select a range of dates */ +export function DateRangePicker(props: DateRangePickerProps): JSX.Element { + const [isOpen, setIsOpen] = useState(false); + + const validUnits = props.validUnits ?? units; + const disallowedUnits = units.filter(unit => !validUnits.includes(unit)); + const hasInvalidUnitRegex = disallowedUnits?.length + ? new RegExp(`[0-9]+(${disallowedUnits.join('|')})`) + : null; + + let staticPresets = props.presets ?? availablePresets; + if (hasInvalidUnitRegex) { + staticPresets = staticPresets.filter( + preset => + !hasInvalidUnitRegex.test(preset.range.from) && !hasInvalidUnitRegex.test(preset.range.to), + ); + } + + const label = getDisplayLabel(props.selectedRange, staticPresets, validUnits); + return ( { - if (!open) { - resetValues(); - } setIsOpen(open); }} > - - - - -
-
-
-
Absolute date range
-
-
- -
-
- { - setFromValue(ev.target.value); - }} - className="font-mono" - /> - -
-
-
- {hasInvalidUnitRegex?.test(fromValue) ? ( - <>Only allowed units are {validUnits.join(', ')} - ) : !fromParsed ? ( - <>Invalid date string - ) : null} -
-
-
- -
-
- { - setToValue(ev.target.value); - }} - className="font-mono" - /> - -
-
-
- {hasInvalidUnitRegex?.test(toValue) ? ( - <>Only allowed units are {validUnits.join(', ')} - ) : !toParsed ? ( - <>Invalid date string - ) : fromParsed && toParsed && fromParsed.getTime() > toParsed.getTime() ? ( -
To cannot be before from.
- ) : null} -
-
- - -
+ {props.trigger ? ( + {props.trigger} + ) : ( + +
-
-
-
- - setQuickRangeFilter(ev.target.value)} - /> -
-
- {dynamicPresets.length > 0 - ? dynamicPresets - .filter(preset => - preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()), - ) - .map(preset => ) - : staticPresets - .filter(preset => - preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()), - ) - .map(preset => )} -
-
- {showCalendar && ( -
-
- - { - if (range?.from && range.to) { - setFromValue(formatDateToString(range.from)); - setToValue(formatDateToString(endOfDay(range.to))); - } - setRange(range); - }} - disabled={disabledDays} - /> -
-
- )} + + + )} + + setIsOpen(false)} + /> ); diff --git a/packages/web/app/src/components/ui/dialog.tsx b/packages/web/app/src/components/ui/dialog.tsx index a4f8bae80..28515d05b 100644 --- a/packages/web/app/src/components/ui/dialog.tsx +++ b/packages/web/app/src/components/ui/dialog.tsx @@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef< ( +
+

+ Conditional breaking changes detected in production,{' '} + staging,{' '} + development + {' and '} + + + + + +

+

All Targets

+ +
+ {MOCK_TARGETS.map((target, index) => ( +
+
{target}
+
+ ))} +
+
+
+ + +

+
+); + +// --------------------------------------------------------------------------- +// 2. target-checks-single / "Approve Failed Schema Check" +// --------------------------------------------------------------------------- +export const TargetChecksSingle_ApproveSchemaCheck: Story = () => { + const [approvalOpen, setApprovalOpen] = useState(false); + return ( +
+ + + + + + +
+

Approve Failed Schema Check

+

+ This will mark the schema check as approved despite having breaking changes. +

+
+ + +
+
+ + +
+
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 3. errors-and-changes / Operation hash link +// --------------------------------------------------------------------------- +const MOCK_OP_TARGETS = [ + { slug: 'production', targetSlug: 'production' }, + { slug: 'staging', targetSlug: 'staging' }, +]; + +export const ErrorsAndChanges_OperationHashLink: Story = () => ( +
+ + + + + + + + + + {[ + { hash: 'abc123def456', name: 'GetUser', count: '12,345', pct: '45.2%' }, + { hash: 'xyz789ghi012', name: 'ListOrders', count: '8,901', pct: '32.6%' }, + ].map(op => ( + + + + + + ))} + +
Operation NameTotal Requests% of traffic
+ + + {op.hash.substring(0, 4)}_{op.name} + + +
+ View live usage on + {MOCK_OP_TARGETS.map((target, i) => ( +

+ e.preventDefault()} + > + {target.slug} + {' '} + target +

+ ))} +
+ +
+
+
{op.count}{op.pct}
+
+); + +// --------------------------------------------------------------------------- +// 4. errors-and-changes / Affected app deployment operations +// --------------------------------------------------------------------------- +const MOCK_AFFECTED_OPS = [ + { hash: 'op1hash', name: 'GetUser' }, + { hash: 'op2hash', name: 'ListProducts' }, + { hash: 'op3hash', name: '' }, + { hash: 'op4hash', name: 'UpdateCart' }, +]; + +export const ErrorsAndChanges_AffectedDeploymentOps: Story = () => ( +
+ + + + + + + + + + + + + + + +
App NameVersionAffected Operations
+ + my-web-app + + v2.3.1 + + + + + +
+
Affected Operations
+
    + {MOCK_AFFECTED_OPS.map(op => ( +
  • + {op.name || `[anonymous] (${op.hash.substring(0, 8)}...)`} +
  • + ))} +
+ + Show all ({MOCK_AFFECTED_OPS.length}) affected operations + +
+ +
+
+
+
+); + +// --------------------------------------------------------------------------- +// 5. explorer/common / GraphQL type link +// --------------------------------------------------------------------------- +export const SchemaExplorer_GraphQLTypeLink: Story = () => ( +
+

+ Field type:{' '} + + + [User!]! + + +

+

+ + Visit in Explorer + + - displays a full type +

+

+ + Visit in Insights + + - usage insights +

+
+ + + +

+
+); + +// --------------------------------------------------------------------------- +// 6. changelog / Changelog popover +// --------------------------------------------------------------------------- +const MOCK_CHANGES = [ + { + title: 'Schema Proposals', + description: 'Collaborate on schema changes with proposals workflow.', + href: '#1', + date: '2025-01-15', + }, + { + title: 'App Deployments Tracking', + description: 'Track which operations are used by specific app versions.', + href: '#2', + date: '2025-01-08', + }, + { + title: 'Conditional Breaking Changes', + description: 'Detect breaking changes that only affect unused operations.', + href: '#3', + date: '2024-12-20', + }, +]; + +export const Changelog_LatestChanges: Story = () => { + const [isOpen, setIsOpen] = useState(false); + return ( +
+ + + + + + +
+
+

+ What's new in GraphQL Hive +

+

+ Find out about the newest features, and enhancements +

+
+
    + {MOCK_CHANGES.map((change, index) => ( +
  1. + +

    + {change.title} +

    +
    + {change.description} +
    +
  2. + ))} +
+
+ +
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 7. organization/members/common / Role Selector +// --------------------------------------------------------------------------- +const MOCK_ROLES = [ + { id: '1', name: 'Admin', description: 'Full access to the organization' }, + { id: '2', name: 'Member', description: 'Can view and manage projects' }, + { id: '3', name: 'Viewer', description: 'Read-only access to all resources' }, + { id: '4', name: 'Billing Manager', description: 'Manage billing and subscription' }, +]; + +export const OrgMembers_RoleSelector: Story = () => { + const [open, setOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(MOCK_ROLES[1]); + + return ( +
+ + + + + + + + + No roles found. + + {MOCK_ROLES.map(role => ( + { + setSelectedRole(role); + setOpen(false); + }} + className="flex cursor-pointer flex-col items-start space-y-1 px-4 py-2" + > +

{role.name}

+

{role.description}

+
+ ))} +
+
+
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 8. date-range-picker / Date Range Picker +// --------------------------------------------------------------------------- +const MOCK_PRESETS = [ + { name: '1h', label: 'Last 1 hour' }, + { name: '6h', label: 'Last 6 hours' }, + { name: '12h', label: 'Last 12 hours' }, + { name: '1d', label: 'Last 1 day' }, + { name: '7d', label: 'Last 7 days' }, + { name: '14d', label: 'Last 14 days' }, + { name: '30d', label: 'Last 30 days' }, +]; + +export const DateRangePicker_Presets: Story = () => { + const [isOpen, setIsOpen] = useState(false); + const [activePreset, setActivePreset] = useState(MOCK_PRESETS[3]); + + return ( +
+ { + setIsOpen(open); + }} + > + + + + +
+
+
+
Absolute date range
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+
+
+
+
+ +
+
+ {MOCK_PRESETS.map(preset => ( + + ))} +
+
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 9. target.tsx / Service selector (Schema View) +// --------------------------------------------------------------------------- +const MOCK_SERVICES = [ + { service: 'users', url: 'https://api.example.com/users' }, + { service: 'products', url: 'https://api.example.com/products' }, + { service: 'orders', url: 'https://api.example.com/orders' }, + { service: 'payments', url: 'https://api.example.com/payments' }, + { service: 'notifications', url: 'https://api.example.com/notifications' }, + { service: 'analytics', url: 'https://api.example.com/analytics' }, +]; + +export const TargetSchemaView_ServiceSelector: Story = () => { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); + + return ( +
+ + + + + {selected ? ( + + ) : null} + + + + No results. + + + {MOCK_SERVICES.map(schema => ( + { + setSelected(val); + setOpen(false); + }} + className={cn( + 'flex cursor-pointer flex-col items-start space-y-1 px-4 py-2', + selected === schema.service && 'bg-neutral-4', + )} + > +
+
{schema.service}
+
{schema.url}
+
+
+ ))} +
+
+
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 10. target-traces-filter / Timeline calendar filter +// --------------------------------------------------------------------------- +export const TracesFilter_TimelineCalendar: Story = () => { + const [isOpen, setIsOpen] = useState(false); + const now = new Date(); + const yesterday = new Date(now.getTime() - 86_400_000); + + return ( +
+ + + + + +
+

[Calendar placeholder]

+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 11. schema-contracts / Tag autocomplete (include + exclude) +// --------------------------------------------------------------------------- +const MOCK_TAGS = ['public', 'internal', 'deprecated', 'experimental', 'stable']; + +export const SchemaContracts_TagAutocomplete: Story = () => { + const [includeTags, setIncludeTags] = useState(['public']); + const [excludeTags, setExcludeTags] = useState([]); + const [includeInput, setIncludeInput] = useState(''); + const [excludeInput, setExcludeInput] = useState(''); + + const addIncludeTag = () => { + if (includeInput && !includeTags.includes(includeInput)) { + setIncludeTags(prev => [...prev, includeInput]); + setIncludeInput(''); + } + }; + + const addExcludeTag = () => { + if (excludeInput && !excludeTags.includes(excludeInput)) { + setExcludeTags(prev => [...prev, excludeInput]); + setExcludeInput(''); + } + }; + + return ( +
+ {/* Include Tags */} +
+ +
+
+ + +
+ setIncludeInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + addIncludeTag(); + } + }} + placeholder="Add included tag" + /> + +
+
+ ev.preventDefault()}> + + + + {MOCK_TAGS.map(value => ( + { + setIncludeTags(prev => + prev.includes(currentValue) + ? prev.filter(v => v !== currentValue) + : [...prev, currentValue], + ); + }} + className="cursor-pointer" + > + + {value} + + ))} + + + + +
+
+
+ {includeTags.map(value => ( + setIncludeTags(prev => prev.filter(v => v !== value))} + > + {value} + + + ))} +
+
+
+ + {/* Exclude Tags */} +
+ +
+
+ + +
+ setExcludeInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + addExcludeTag(); + } + }} + placeholder="Add excluded tag" + /> + +
+
+ ev.preventDefault()}> + + + + {MOCK_TAGS.map(value => ( + { + setExcludeTags(prev => + prev.includes(currentValue) + ? prev.filter(v => v !== currentValue) + : [...prev, currentValue], + ); + }} + className="cursor-pointer" + > + + {value} + + ))} + + + + +
+
+
+ {excludeTags.map(value => ( + setExcludeTags(prev => prev.filter(v => v !== value))} + > + {value} + + + ))} +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 12. proposals / Stage Transition Select +// --------------------------------------------------------------------------- +const MOCK_STAGES = [ + { value: 'DRAFT', label: 'REVERT TO DRAFT' }, + { value: 'APPROVED', label: 'APPROVE FOR IMPLEMENTING' }, + { value: 'CLOSED', label: 'CANCEL PROPOSAL' }, +]; + +export const Proposals_StageTransitionSelect: Story = () => { + const [open, setOpen] = useState(false); + const [currentStage, setCurrentStage] = useState('READY FOR REVIEW'); + + return ( +
+ + + + + + + + + {MOCK_STAGES.map(s => ( + { + setCurrentStage(s.label); + setOpen(false); + }} + className="cursor-pointer truncate" + > +
+ {s.label} +
+
+ ))} +
+
+
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 13. proposals / Version Select +// --------------------------------------------------------------------------- +const MOCK_VERSIONS = [ + { id: 'v1', cursor: 'c0', commit: 'abc1234', createdAt: '2025-01-15T10:00:00Z', author: 'alice' }, + { id: 'v2', cursor: 'c1', commit: 'def5678', createdAt: '2025-01-14T08:30:00Z', author: 'bob' }, + { + id: 'v3', + cursor: 'c2', + commit: 'ghi9012', + createdAt: '2025-01-13T14:15:00Z', + author: 'charlie', + }, +]; + +export const Proposals_VersionSelect: Story = () => { + const [open, setOpen] = useState(false); + const [selectedCursor, setSelectedCursor] = useState('c0'); + const selectedVersion = MOCK_VERSIONS.find(v => v.cursor === selectedCursor); + + return ( +
+ + + + + + + + + {MOCK_VERSIONS.map(version => ( + { + setSelectedCursor(version.cursor); + setOpen(false); + }} + className="cursor-pointer truncate" + > +
+
{version.commit}
+
+ ({new Date(version.createdAt).toLocaleDateString()}) +
+
+ by {version.author} +
+
+
+ ))} +
+
+
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 14. proposals / User Filter (multi-select with checkboxes) +// --------------------------------------------------------------------------- +const MOCK_USERS = [ + { id: 'u1', displayName: 'Alice Johnson' }, + { id: 'u2', displayName: 'Bob Smith' }, + { id: 'u3', displayName: 'Charlie Brown' }, + { id: 'u4', displayName: 'Diana Prince' }, + { id: 'u5', displayName: 'Eve Wilson' }, +]; + +export const Proposals_UserFilter: Story = () => { + const [open, setOpen] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + const hasSelection = selectedUsers.length !== 0; + + const selectedUserNames = selectedUsers.map(id => { + const match = MOCK_USERS.find(u => u.id === id); + return match?.displayName ?? 'Unknown'; + }); + + return ( +
+ + + + + + + + No results. + + + {MOCK_USERS.map(user => ( + { + const selectedUserId = selectedUser.split(' ')[0]; + setSelectedUsers(prev => { + const idx = prev.findIndex(u => u === selectedUserId); + if (idx >= 0) { + return prev.filter((_, i) => i !== idx); + } + return [...prev, selectedUserId]; + }); + }} + className="cursor-pointer truncate" + > +
+ + {user.displayName} +
+
+ ))} +
+
+
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 15. proposals / Stage Filter (multi-select with checkboxes + "All") +// --------------------------------------------------------------------------- +const MOCK_PROPOSAL_STAGES = ['open', 'draft', 'approved', 'closed', 'implemented']; + +export const Proposals_StageFilter: Story = () => { + const [open, setOpen] = useState(false); + const [selectedStages, setSelectedStages] = useState([]); + const hasSelection = selectedStages.length !== 0; + + return ( +
+ + + + + + + + + { + const allSelected = MOCK_PROPOSAL_STAGES.every(s => selectedStages.includes(s)); + setSelectedStages(allSelected ? [] : [...MOCK_PROPOSAL_STAGES]); + }} + className="cursor-pointer truncate border-b" + > +
+ selectedStages.includes(s))} + /> +
All
+
+
+ {MOCK_PROPOSAL_STAGES.map(stage => ( + { + setSelectedStages(prev => { + const idx = prev.findIndex(s => s === selectedStage); + if (idx >= 0) { + return prev.filter((_, i) => i !== idx); + } + return [...prev, selectedStage]; + }); + }} + className="cursor-pointer truncate" + > +
+ +
{stage}
+
+
+ ))} +
+
+
+
+
+
+ ); +}; diff --git a/packages/web/app/src/components/ui/popover.tsx b/packages/web/app/src/components/ui/popover.tsx index b5a2fb47e..212a104b3 100644 --- a/packages/web/app/src/components/ui/popover.tsx +++ b/packages/web/app/src/components/ui/popover.tsx @@ -49,4 +49,6 @@ const PopoverArrow = React.forwardRef< PopoverArrow.displayName = PopoverPrimitive.Arrow.displayName; -export { Popover, PopoverTrigger, PopoverContent, PopoverArrow }; +const PopoverAnchor = PopoverPrimitive.Anchor; + +export { Popover, PopoverTrigger, PopoverContent, PopoverArrow, PopoverAnchor }; diff --git a/packages/web/app/src/components/ui/select.tsx b/packages/web/app/src/components/ui/select.tsx index 50c4701fc..764654926 100644 --- a/packages/web/app/src/components/ui/select.tsx +++ b/packages/web/app/src/components/ui/select.tsx @@ -17,6 +17,8 @@ const selectVariants = cva( variant: { default: 'hover:text-neutral-12 text-neutral-11 hover:bg-neutral-3 dark:hover:bg-neutral-5 bg-neutral-2 dark:bg-neutral-4 hover:border-neutral-6 border-neutral-5 border ring-offset-neutral-2 placeholder:text-neutral-10 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1', + inset: + 'bg-transparent hover:bg-neutral-3 dark:hover:bg-neutral-5 text-neutral-11 hover:text-neutral-12 border-neutral-5 border ring-offset-neutral-2 placeholder:text-neutral-10 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1', ghost: '', }, }, @@ -46,15 +48,33 @@ const SelectTrigger = React.forwardRef< )); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; +const selectContentVariants = cva( + 'animate-in fade-in-80 relative z-50 min-w-[8rem] cursor-pointer overflow-hidden rounded-md border shadow-md', + { + variants: { + variant: { + default: 'border-neutral-4 bg-neutral-3 dark:bg-neutral-4 dark:border-neutral-5', + inset: 'border-neutral-4 bg-neutral-3 dark:bg-neutral-3 dark:border-neutral-4', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +type SelectContentProps = React.ComponentPropsWithoutRef & + VariantProps; + const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = 'popper', ...props }, ref) => ( + SelectContentProps +>(({ className, children, position = 'popper', variant, ...props }, ref) => ( (({ className, ...props }, ref) => ( + +
+ {title} + {description && {description}} +
+ {action && Undo} + +
+ + + ); +} + +export const Default: Story = () => ( + +); + +export const Success: Story = () => ( + +); + +export const Destructive: Story = () => ( + +); + +export const Warning: Story = () => ( + +); + +export const WithAction: Story = () => ( + +); + +export const TitleOnly: Story = () => ; + +export const AllVariants: Story = () => ( +
+ + + + +
+); diff --git a/packages/web/app/src/components/ui/toast.tsx b/packages/web/app/src/components/ui/toast.tsx index f5b4bb7fd..134abaf5c 100644 --- a/packages/web/app/src/components/ui/toast.tsx +++ b/packages/web/app/src/components/ui/toast.tsx @@ -27,6 +27,7 @@ const toastVariants = cva( variants: { variant: { default: 'border bg-neutral-3 text-neutral-11', + success: 'group border-green-600 bg-green-600 text-neutral-1', destructive: 'destructive group border-red-500 bg-red-500 text-neutral-12', warning: 'group border-orange-600 bg-orange-500 text-neutral-1', }, diff --git a/packages/web/app/src/components/v2/autocomplete.tsx b/packages/web/app/src/components/v2/autocomplete.tsx index eabc0cca4..0eec46987 100644 --- a/packages/web/app/src/components/v2/autocomplete.tsx +++ b/packages/web/app/src/components/v2/autocomplete.tsx @@ -1,4 +1,4 @@ -import { ComponentPropsWithRef, ReactElement } from 'react'; +import { ComponentPropsWithRef, ReactElement, useEffect, useRef } from 'react'; import { ChevronDown } from 'lucide-react'; import Highlighter from 'react-highlight-words'; import Select, { @@ -9,26 +9,50 @@ import Select, { Props as SelectProps, StylesConfig, } from 'react-select'; -import { FixedSizeList } from 'react-window'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { SelectOption } from './radix-select'; -const height = 40; +const ITEM_HEIGHT = 40; function MenuList(props: any): ReactElement { const { options, children, maxHeight, getValue } = props; const [value] = getValue(); - const initialOffset = options.indexOf(value) * height; + const scrollRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: children.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, + }); + + useEffect(() => { + const index = options.indexOf(value); + if (index > 0) { + virtualizer.scrollToIndex(index); + } + }, []); return ( - - {({ index, style }) =>
{children[index]}
} -
+
+
+ {virtualizer.getVirtualItems().map(virtualItem => ( +
+ {children[virtualItem.index]} +
+ ))} +
+
); } diff --git a/packages/web/app/src/constants.ts b/packages/web/app/src/constants.ts index 2d9c79b78..6a31d52be 100644 --- a/packages/web/app/src/constants.ts +++ b/packages/web/app/src/constants.ts @@ -1,3 +1 @@ export const LAST_VISITED_ORG_KEY = 'lastVisitedOrganization_v2'; - -export const CHART_PRIMARY_COLOR = 'rgb(234, 179, 8)'; diff --git a/packages/web/app/src/index.css b/packages/web/app/src/index.css index 129d7990d..3f122921c 100644 --- a/packages/web/app/src/index.css +++ b/packages/web/app/src/index.css @@ -236,7 +236,7 @@ :root { color-scheme: light dark; - @apply font-sans; + @apply font-sans subpixel-antialiased; --header-height: 84px; --tabs-navbar-height: 47px; @@ -257,8 +257,13 @@ --accent: 206 96% 35%; - --chart-1: 173 58% 39%; - --chart-2: 12 76% 61%; + --chart-1: 45 93% 47%; + --chart-2: 0 86% 60%; + --chart-3: 160 82% 39%; + --chart-4: 199 89% 48%; + --chart-5: 258 90% 66%; + --chart-6: 330 81% 60%; + --chart-grid: 0 0% 35%; --ring: 216 58% 49%; } @@ -279,8 +284,13 @@ --accent: 48 100% 83%; - --chart-1: 220 70% 50%; - --chart-2: 340 75% 55%; + --chart-1: 45 93% 52%; + --chart-2: 0 86% 65%; + --chart-3: 160 82% 49%; + --chart-4: 199 89% 58%; + --chart-5: 258 90% 72%; + --chart-6: 330 81% 68%; + --chart-grid: 0 0% 45%; } .hive-laboratory { diff --git a/packages/web/app/src/lib/urql-cache.ts b/packages/web/app/src/lib/urql-cache.ts index 7b678b3d2..59d08bb27 100644 --- a/packages/web/app/src/lib/urql-cache.ts +++ b/packages/web/app/src/lib/urql-cache.ts @@ -16,6 +16,10 @@ import { CollectionsQuery } from '@/lib/hooks/laboratory/use-collections'; import type { CreateOrganizationMutation } from '@/pages/organization-new'; import type { DeleteOrganizationDocument } from '@/pages/organization-settings'; import type { DeleteProjectMutation } from '@/pages/project-settings'; +import { + ManageFilters_SavedFiltersQuery, + type ManageFilters_DeleteSavedFilterMutation, +} from '@/pages/target-insights-manage-filters'; import { TokensDocument, type DeleteTargetMutation, @@ -348,6 +352,41 @@ const createOperationInDocumentCollection: TypedDocumentNodeUpdateResolver< ); }; +const deleteSavedFilter: TypedDocumentNodeUpdateResolver< + typeof ManageFilters_DeleteSavedFilterMutation +> = ({ deleteSavedFilter }, args, cache) => { + if (!deleteSavedFilter.ok) { + return; + } + + const selector = args.input.target.bySelector; + if (!selector) { + return; + } + + updateQuery( + cache, + { + query: ManageFilters_SavedFiltersQuery, + variables: { + organizationSlug: selector.organizationSlug, + selector: { + organizationSlug: selector.organizationSlug, + projectSlug: selector.projectSlug, + targetSlug: selector.targetSlug, + }, + }, + }, + data => { + if (data.target) { + data.target.savedFilters.edges = data.target.savedFilters.edges.filter( + edge => edge.node.id !== deleteSavedFilter.ok!.deletedId, + ); + } + }, + ); +}; + // UpdateResolver export const Mutation = { createOrganization, @@ -365,4 +404,5 @@ export const Mutation = { deleteDocumentCollection, deleteOperationInDocumentCollection, createOperationInDocumentCollection, + deleteSavedFilter, }; diff --git a/packages/web/app/src/lib/urql.ts b/packages/web/app/src/lib/urql.ts index 3d04ceb5e..f92424cbc 100644 --- a/packages/web/app/src/lib/urql.ts +++ b/packages/web/app/src/lib/urql.ts @@ -51,6 +51,8 @@ export const urqlClient = createClient({ SchemaCoordinateStats: noKey, ClientStats: noKey, ClientStatsValues: noKey, + ClientVersionStatsValues: noKey, + InsightsDateRange: noKey, OperationsStats: noKey, OperationStatsValues: noKey, DurationValues: noKey, @@ -90,6 +92,8 @@ export const urqlClient = createClient({ ProjectTargetsResourceAssignment: noKey, ProjectResourceAssignment: noKey, BillingConfiguration: noKey, + InsightsFilterConfiguration: noKey, + ClientFilter: noKey, SchemaChangeMeta: noKey, SchemaCheckMeta: noKey, FieldArgumentDescriptionChanged: noKey, diff --git a/packages/web/app/src/lib/utils.ts b/packages/web/app/src/lib/utils.ts index 46cc273d7..fac1aabf8 100644 --- a/packages/web/app/src/lib/utils.ts +++ b/packages/web/app/src/lib/utils.ts @@ -8,34 +8,83 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -function getNeutralColor() { - return getComputedStyle(document.documentElement).getPropertyValue('--color-neutral-12').trim(); +/** Convert HSL values (h in degrees, s and l in percent) to a hex string. */ +function hslToHex(h: number, s: number, l: number): string { + s /= 100; + l /= 100; + const a = s * Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} + +function readChartStyles() { + const s = getComputedStyle(document.documentElement); + const textColor = s.getPropertyValue('--color-neutral-12').trim(); + + // Convert HSL CSS variables (e.g. "45 93% 47%") to hex strings. + // ECharts can't parse modern comma-less HSL like hsl(45 93% 47%), + // so we convert to hex which it handles natively. + const hex = (name: string) => { + const raw = s.getPropertyValue(name).trim(); + if (!raw) return '#888'; + const [h, sVal, l] = raw.split(' ').map(v => parseFloat(v)); + return hslToHex(h, sVal, l); + }; + + /** Convert a raw HSL CSS variable to an rgba() string with the given alpha. */ + const rgba = (name: string, alpha: number) => { + const raw = s.getPropertyValue(name).trim(); + if (!raw) return `rgba(128,128,128,${alpha})`; + const [h, sVal, l] = raw.split(' ').map(v => parseFloat(v)); + const hexColor = hslToHex(h, sVal, l); + const r = parseInt(hexColor.slice(1, 3), 16); + const g = parseInt(hexColor.slice(3, 5), 16); + const b = parseInt(hexColor.slice(5, 7), 16); + return `rgba(${r},${g},${b},${alpha})`; + }; + + return { + styles: { + backgroundColor: 'transparent' as const, + textStyle: { color: textColor }, + legend: { textStyle: { color: textColor } }, + }, + colors: { + primary: hex('--chart-1'), + error: hex('--chart-2'), + p75: hex('--chart-3'), + p90: hex('--chart-4'), + p95: hex('--chart-5'), + p99: hex('--chart-6'), + grid: hex('--chart-grid'), + /** Semi-transparent text color overlay — for label pills on colored surfaces. */ + overlayBg: rgba('--neutral-12', 0.7), + /** Page-background color — for text on overlayBg pills. */ + overlayText: hex('--neutral-1'), + /** Semi-transparent text color — for subtle borders on colored surfaces. */ + overlayBorder: rgba('--neutral-12', 0.2), + }, + }; } export function useChartStyles() { const { resolvedTheme } = useTheme(); - const [textColor, setTextColor] = useState(() => { - // Read CSS variable on initial mount - return getNeutralColor(); - }); + const [value, setValue] = useState(() => readChartStyles()); useLayoutEffect(() => { - // Use requestAnimationFrame to ensure DOM updates are complete const rafId = requestAnimationFrame(() => { - const color = getNeutralColor(); - setTextColor(color); + setValue(readChartStyles()); }); - return () => cancelAnimationFrame(rafId); }, [resolvedTheme]); - return { - backgroundColor: 'transparent', - textStyle: { color: textColor }, - legend: { - textStyle: { color: textColor }, - }, - }; + return value; } // Strings diff --git a/packages/web/app/src/pages/organization-subscription.tsx b/packages/web/app/src/pages/organization-subscription.tsx index d32bacade..2ec6a5221 100644 --- a/packages/web/app/src/pages/organization-subscription.tsx +++ b/packages/web/app/src/pages/organization-subscription.tsx @@ -86,7 +86,7 @@ function SubscriptionPageContent(props: { organizationSlug: string }) { const organization = useFragment(SubscriptionPage_OrganizationFragment, currentOrganization); const queryForBilling = useFragment(SubscriptionPage_QueryFragment, query.data); - const styles = useChartStyles(); + const { styles } = useChartStyles(); const monthlyUsage = query.data?.monthlyUsage ?? []; const monthlyUsagePoints: [string, number][] = useMemo( diff --git a/packages/web/app/src/pages/target-insights-client.tsx b/packages/web/app/src/pages/target-insights-client.tsx index 6e799eb31..08c30185c 100644 --- a/packages/web/app/src/pages/target-insights-client.tsx +++ b/packages/web/app/src/pages/target-insights-client.tsx @@ -12,7 +12,6 @@ import { EmptyList } from '@/components/ui/empty-list'; import { Meta } from '@/components/ui/meta'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; -import { CHART_PRIMARY_COLOR } from '@/constants'; import { graphql } from '@/gql'; import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; @@ -62,7 +61,7 @@ function ClientView(props: { projectSlug: string; targetSlug: string; }) { - const styles = useChartStyles(); + const { styles, colors } = useChartStyles(); const dateRangeController = useDateRangeController({ dataRetentionInDays: props.dataRetentionInDays, defaultPreset: presetLast7Days, @@ -236,7 +235,7 @@ function ClientView(props: { min: 0, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -251,7 +250,7 @@ function ClientView(props: { name: 'Requests', showSymbol: false, smooth: false, - color: CHART_PRIMARY_COLOR, + color: colors.primary, areaStyle: {}, emphasis: { focus: 'series', diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index 909113147..f69aa45bd 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -22,7 +22,6 @@ import { Link as LegacyLink } from '@/components/ui/link'; import { Meta } from '@/components/ui/meta'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; -import { CHART_PRIMARY_COLOR } from '@/constants'; import { graphql } from '@/gql'; import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; @@ -87,7 +86,7 @@ function SchemaCoordinateView(props: { projectSlug: string; targetSlug: string; }) { - const styles = useChartStyles(); + const { styles, colors } = useChartStyles(); const dateRangeController = useDateRangeController({ dataRetentionInDays: props.dataRetentionInDays, defaultPreset: presetLast7Days, @@ -294,7 +293,7 @@ function SchemaCoordinateView(props: { min: 0, splitLine: { lineStyle: { - color: '#595959', + color: colors.grid, type: 'dashed', }, }, @@ -309,7 +308,7 @@ function SchemaCoordinateView(props: { name: 'Requests', showSymbol: false, smooth: false, - color: CHART_PRIMARY_COLOR, + color: colors.primary, areaStyle: {}, emphasis: { focus: 'series', diff --git a/packages/web/app/src/pages/target-insights-manage-filters.tsx b/packages/web/app/src/pages/target-insights-manage-filters.tsx new file mode 100644 index 000000000..bcc4025ca --- /dev/null +++ b/packages/web/app/src/pages/target-insights-manage-filters.tsx @@ -0,0 +1,896 @@ +import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import { formatDate, formatISO, subDays } from 'date-fns'; +import { ArrowLeft, ChevronDown, ChevronRight, Lock, MoreVertical, Users } from 'lucide-react'; +import { useMutation, useQuery } from 'urql'; +import { FilterDropdown } from '@/components/base/filter-dropdown/filter-dropdown'; +import type { FilterItem, FilterSelection } from '@/components/base/filter-dropdown/types'; +import { Menu, MenuItem } from '@/components/base/menu/menu'; +import { TriggerButton } from '@/components/base/trigger-button'; +import { Page, TargetLayout } from '@/components/layouts/target'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + availablePresets, + buildDateRangeString, + DateRangePicker, + type Preset, +} from '@/components/ui/date-range-picker'; +import { EmptyList } from '@/components/ui/empty-list'; +import { Input } from '@/components/ui/input'; +import { Meta } from '@/components/ui/meta'; +import { Subtitle, Title } from '@/components/ui/page'; +import { QueryError } from '@/components/ui/query-error'; +import { Spinner } from '@/components/ui/spinner'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useToast } from '@/components/ui/use-toast'; +import { graphql } from '@/gql'; +import { SavedFilterVisibilityType } from '@/gql/graphql'; +import { parse } from '@/lib/date-math'; +import type { ResultOf } from '@graphql-typed-document-node/core'; +import { Link } from '@tanstack/react-router'; + +export const ManageFilters_SavedFiltersQuery = graphql(` + query ManageFilters_SavedFiltersQuery( + $selector: TargetSelectorInput! + $organizationSlug: String! + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + usageRetentionInDays + } + target(reference: { bySelector: $selector }) { + id + savedFilters(first: 50) { + edges { + cursor + node { + id + name + description + visibility + viewsCount + createdAt + updatedAt + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + createdBy { + id + displayName + } + viewerCanUpdate + viewerCanDelete + } + } + pageInfo { + hasNextPage + endCursor + } + } + viewerCanCreateSavedFilter + viewerCanShareSavedFilter + } + } +`); + +export const ManageFilters_DeleteSavedFilterMutation = graphql(` + mutation ManageFilters_DeleteSavedFilter($input: DeleteSavedFilterInput!) { + deleteSavedFilter(input: $input) { + error { + message + } + ok { + deletedId + } + } + } +`); + +const ManageFilters_UpdateSavedFilterMutation = graphql(` + mutation ManageFilters_UpdateSavedFilter($input: UpdateSavedFilterInput!) { + updateSavedFilter(input: $input) { + error { + message + } + ok { + savedFilter { + id + name + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + } + } + } + } +`); + +const ManageFilters_OperationStatsQuery = graphql(` + query ManageFilters_OperationStats($selector: TargetSelectorInput!, $period: DateRangeInput!) { + target(reference: { bySelector: $selector }) { + id + operationsStats(period: $period) { + operations { + edges { + node { + id + name + operationHash + } + } + } + clients { + edges { + node { + name + versions { + version + } + } + } + } + } + } + } +`); + +const DEFAULT_DATE_RANGE = { from: 'now-7d', to: 'now' }; + +type SavedFilterNode = NonNullable< + ResultOf['target'] +>['savedFilters']['edges'][number]['node']; + +function SavedFilterRow({ + filter, + expanded, + onToggleExpand, + organizationSlug, + projectSlug, + targetSlug, + dataRetentionInDays, +}: { + filter: SavedFilterNode; + expanded: boolean; + onToggleExpand: () => void; + organizationSlug: string; + projectSlug: string; + targetSlug: string; + dataRetentionInDays: number; +}) { + const [, deleteSavedFilter] = useMutation(ManageFilters_DeleteSavedFilterMutation); + const [updateResult, updateSavedFilter] = useMutation(ManageFilters_UpdateSavedFilterMutation); + const { toast } = useToast(); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(filter.name); + + const handleRename = useCallback(async () => { + const trimmed = renameValue.trim(); + if (!trimmed || trimmed === filter.name) return; + + const result = await updateSavedFilter({ + input: { + id: filter.id, + target: { + bySelector: { organizationSlug, projectSlug, targetSlug }, + }, + name: trimmed, + }, + }); + + if (result.error || result.data?.updateSavedFilter.error) { + toast({ + variant: 'destructive', + title: 'Error', + description: result.error?.message || result.data?.updateSavedFilter.error?.message, + }); + } else { + toast({ + title: 'Filter renamed', + description: 'The saved filter has been renamed.', + }); + setIsRenaming(false); + } + }, [ + renameValue, + filter.name, + filter.id, + organizationSlug, + projectSlug, + targetSlug, + updateSavedFilter, + toast, + ]); + + const handleDelete = useCallback(() => { + void deleteSavedFilter({ + input: { + target: { + bySelector: { + organizationSlug, + projectSlug, + targetSlug, + }, + }, + id: filter.id, + }, + }).then(result => { + if (result.error || result.data?.deleteSavedFilter.error) { + toast({ + variant: 'destructive', + title: 'Error', + description: result.error?.message || result.data?.deleteSavedFilter.error?.message, + }); + } else { + toast({ + title: 'Filter deleted', + description: 'The saved filter has been deleted.', + }); + } + }); + }, [deleteSavedFilter, filter.id, organizationSlug, projectSlug, targetSlug, toast]); + + const ChevronIcon = expanded ? ChevronDown : ChevronRight; + + return ( + <> + + + + + e.stopPropagation() : undefined} + > + {isRenaming ? ( + + setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + void handleRename(); + } else if (e.key === 'Escape') { + setIsRenaming(false); + setRenameValue(filter.name); + } + }} + className="h-8" + /> + + + ) : ( + filter.name + )} + + {filter.viewsCount.toLocaleString()} + {formatDate(filter.createdAt, 'MMM d, yyyy')} + {formatDate(filter.updatedAt, 'MMM d, yyyy')} + + + {filter.visibility === SavedFilterVisibilityType.Shared ? ( + <> + + Shared + + ) : ( + <> + + Private + + )} + + + { + e.stopPropagation(); + }} + > + {(filter.viewerCanUpdate || filter.viewerCanDelete) && ( + + + Open menu + + } + align="end" + sections={[ + [ + 0 + ? filter.filters.operationHashes + : undefined, + clients: + filter.filters.clientFilters.length > 0 + ? filter.filters.clientFilters.map(c => ({ + name: c.name, + versions: c.versions ?? null, + })) + : undefined, + from: filter.filters.dateRange?.from, + to: filter.filters.dateRange?.to, + viewId: filter.id, + }} + /> + } + > + View in Insights + , + filter.viewerCanUpdate && ( + { + setRenameValue(filter.name); + setIsRenaming(true); + }} + > + Rename + + ), + filter.viewerCanDelete && ( + + Delete + + ), + ], + ]} + /> + )} + + + {expanded && ( + + + + + + )} + + ); +} + +function SavedFilterRowFilters({ + filter, + organizationSlug, + projectSlug, + targetSlug, + dataRetentionInDays, +}: { + filter: SavedFilterNode; + organizationSlug: string; + projectSlug: string; + targetSlug: string; + dataRetentionInDays: number; +}) { + const { operationHashes, clientFilters } = filter.filters; + + // Date range state (relative strings like 'now-7d') + const savedDateRange = filter.filters.dateRange ?? DEFAULT_DATE_RANGE; + const [dateRange, setDateRange] = useState(savedDateRange); + + const startDate = useMemo(() => subDays(new Date(), dataRetentionInDays), [dataRetentionInDays]); + + const selectedPreset = useMemo(() => { + const match = availablePresets.find( + p => p.range.from === dateRange.from && p.range.to === dateRange.to, + ); + if (match) return match; + + const from = parse(dateRange.from); + const to = parse(dateRange.to); + if (from && to) { + return { + name: `${dateRange.from}_${dateRange.to}`, + label: buildDateRangeString({ from, to }), + range: dateRange, + }; + } + + return { name: 'last7d', label: 'Last 7 days', range: DEFAULT_DATE_RANGE }; + }, [dateRange]); + + // Resolve relative date range to ISO strings for the operationsStats query + const resolvedPeriod = useMemo(() => { + const from = parse(dateRange.from); + const to = parse(dateRange.to); + if (from && to) { + return { from: formatISO(from), to: formatISO(to) }; + } + const fallbackFrom = parse('now-7d')!; + const fallbackTo = parse('now')!; + return { from: formatISO(fallbackFrom), to: formatISO(fallbackTo) }; + }, [dateRange]); + + // Fetch all operations and clients for the date range + const [opsQuery] = useQuery({ + query: ManageFilters_OperationStatsQuery, + variables: { + selector: { organizationSlug, projectSlug, targetSlug }, + period: resolvedPeriod, + }, + }); + + // Build merged operation items (all from stats + any saved hashes not in stats) + const { allOperationItems, hashToNameMap } = useMemo(() => { + const map = new Map(); + const statsOps = opsQuery.data?.target?.operationsStats?.operations?.edges ?? []; + + for (const edge of statsOps) { + if (edge.node.operationHash) { + map.set(edge.node.operationHash, edge.node.name); + } + } + + const items: FilterItem[] = statsOps + .filter(e => e.node.operationHash != null) + .map(e => ({ + id: e.node.operationHash!, + name: e.node.name, + values: [], + })); + + // Add any saved hashes that aren't in the current stats (fallback: show hash as name) + for (const hash of operationHashes) { + if (!map.has(hash)) { + items.push({ id: hash, name: hash, values: [], unavailable: true }); + } + } + + return { allOperationItems: items, hashToNameMap: map }; + }, [opsQuery.data, operationHashes]); + + // Build merged client items (all from stats + any saved clients not in stats) + const allClientItems = useMemo(() => { + const statsClients = opsQuery.data?.target?.operationsStats?.clients?.edges ?? []; + const clientNameSet = new Set(statsClients.map(e => e.node.name)); + + const items: FilterItem[] = statsClients.map(e => ({ + name: e.node.name, + values: e.node.versions.map(v => v.version), + })); + + // Add saved clients not in current stats, preserving their versions + for (const cf of clientFilters) { + if (!clientNameSet.has(cf.name)) { + items.push({ name: cf.name, values: cf.versions ?? [], unavailable: true }); + } + } + + return items; + }, [opsQuery.data, clientFilters]); + + // While stats are loading, use saved data as fallback for items + const operationItems = opsQuery.fetching + ? operationHashes.map(hash => ({ id: hash, name: hash, values: [] as string[] })) + : allOperationItems; + const clientItems = opsQuery.fetching + ? clientFilters.map(c => ({ name: c.name, values: c.versions ?? [] })) + : allClientItems; + + // Compute the "saved" selections (what's persisted in the database) + const savedOperationSelections = useMemo( + () => + operationHashes.map(hash => ({ + id: hash, + name: hashToNameMap.get(hash) ?? hash, + values: null, + })), + [operationHashes, hashToNameMap], + ); + const savedClientSelections = useMemo( + () => + clientFilters.map(c => ({ + name: c.name, + values: c.versions?.length ? [...c.versions] : null, + })), + [clientFilters], + ); + + // Mutable selections (initialized from saved data) + const [operationSelections, setOperationSelections] = + useState(savedOperationSelections); + const [clientSelections, setClientSelections] = + useState(savedClientSelections); + const [showOperationFilter, setShowOperationFilter] = useState(operationHashes.length > 0); + const [showClientFilter, setShowClientFilter] = useState(clientFilters.length > 0); + + // Sync state when saved filter data changes (e.g. after mutation updates cache) + const filterDataKey = JSON.stringify(filter.filters); + useEffect(() => { + setOperationSelections(savedOperationSelections); + setClientSelections(savedClientSelections); + setShowOperationFilter(operationHashes.length > 0); + setShowClientFilter(clientFilters.length > 0); + setDateRange(savedDateRange); + }, [filterDataKey]); + + // Detect changes from saved state (compare by identifiers, not display names) + const hasChanges = useMemo(() => { + if (showOperationFilter !== operationHashes.length > 0) return true; + if (showClientFilter !== clientFilters.length > 0) return true; + if (dateRange.from !== savedDateRange.from || dateRange.to !== savedDateRange.to) return true; + + const normalizeOps = (sels: FilterSelection[]) => + sels + .map(s => s.id ?? s.name) + .sort() + .join('\0'); + if (normalizeOps(operationSelections) !== normalizeOps(savedOperationSelections)) return true; + + const normalizeClients = (sels: FilterSelection[]) => + JSON.stringify( + sels + .map(s => ({ name: s.name, values: s.values })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + if (normalizeClients(clientSelections) !== normalizeClients(savedClientSelections)) return true; + + return false; + }, [ + operationSelections, + clientSelections, + showOperationFilter, + showClientFilter, + dateRange, + savedOperationSelections, + savedClientSelections, + operationHashes, + clientFilters, + savedDateRange, + ]); + + // Cancel → reset to saved state + const handleCancel = useCallback(() => { + setOperationSelections(savedOperationSelections); + setClientSelections(savedClientSelections); + setShowOperationFilter(operationHashes.length > 0); + setShowClientFilter(clientFilters.length > 0); + setDateRange(savedDateRange); + }, [ + savedOperationSelections, + savedClientSelections, + operationHashes, + clientFilters, + savedDateRange, + ]); + + // Update mutation + const [updateResult, updateSavedFilter] = useMutation(ManageFilters_UpdateSavedFilterMutation); + const { toast } = useToast(); + + const handleSave = useCallback(async () => { + // Extract hashes from selections (id is the hash, fallback to name for backwards compat) + const newOperationHashes = showOperationFilter + ? operationSelections.map(s => s.id ?? s.name) + : []; + + const newClientFilters = showClientFilter + ? clientSelections.map(s => ({ + name: s.name, + versions: + s.values === null ? (clientItems.find(i => i.name === s.name)?.values ?? []) : s.values, + })) + : []; + + await updateSavedFilter({ + input: { + id: filter.id, + target: { + bySelector: { organizationSlug, projectSlug, targetSlug }, + }, + insightsFilter: { + operationHashes: newOperationHashes, + clientFilters: newClientFilters, + dateRange: { from: dateRange.from, to: dateRange.to }, + }, + }, + }).then(result => { + if (result.error || result.data?.updateSavedFilter.error) { + toast({ + variant: 'destructive', + title: 'Error', + description: result.error?.message || result.data?.updateSavedFilter.error?.message, + }); + } else { + toast({ + variant: 'success', + title: 'Filter updated', + description: 'The saved filter has been updated.', + }); + } + }); + }, [ + showOperationFilter, + showClientFilter, + operationSelections, + clientSelections, + clientItems, + dateRange, + filter.id, + organizationSlug, + projectSlug, + targetSlug, + updateSavedFilter, + toast, + ]); + + const loading = opsQuery.fetching; + + return ( +
+
+ + } + selectedRange={dateRange} + onUpdate={({ preset }) => setDateRange(preset.range)} + startDate={startDate} + validUnits={['y', 'M', 'w', 'd', 'h']} + align="start" + /> + {showOperationFilter && operationItems.length > 0 && ( + { + setShowOperationFilter(false); + setOperationSelections([]); + }} + disabled={loading} + /> + )} + {showClientFilter && clientItems.length > 0 && ( + { + setShowClientFilter(false); + setClientSelections([]); + }} + valuesLabel="versions" + disabled={loading} + /> + )} + {loading && } +
+ {filter.viewerCanUpdate && ( +
+ + +
+ )} +
+ ); +} + +function ManageFiltersContent(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}) { + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [query] = useQuery({ + query: ManageFilters_SavedFiltersQuery, + variables: { + organizationSlug: props.organizationSlug, + selector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + }); + + const dataRetentionInDays = query.data?.organization?.usageRetentionInDays ?? 30; + + const toggleRow = useCallback((id: string) => { + setExpandedRows(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const edges = query.data?.target?.savedFilters.edges ?? []; + + const stats = useMemo(() => { + const filters = edges.map(e => e.node); + return { + total: filters.length, + shared: filters.filter(f => f.visibility === SavedFilterVisibilityType.Shared).length, + totalViews: filters.reduce((sum, f) => sum + f.viewsCount, 0), + }; + }, [edges]); + + if (query.error) { + return ( + + ); + } + + if (!query.data?.target) { + return ( +
+ +
+ ); + } + + if (edges.length === 0) { + return ( +
+ +
+ ); + } + + return ( + <> +
+ + + +
+ +
+ + + + + Name + Views + Created + Modified + Visibility + + + + + {edges.map(edge => ( + toggleRow(edge.node.id)} + organizationSlug={props.organizationSlug} + projectSlug={props.projectSlug} + targetSlug={props.targetSlug} + dataRetentionInDays={dataRetentionInDays} + /> + ))} + +
+
+ + ); +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( + + + {label} + + +
{value.toLocaleString()}
+
+
+ ); +} + +export function TargetInsightsManageFiltersPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}): ReactElement { + return ( + <> + + +
+ + + Back to Insights + + Manage saved filters + View and manage your saved filter views +
+ +
+ + ); +} diff --git a/packages/web/app/src/pages/target-insights-operation.tsx b/packages/web/app/src/pages/target-insights-operation.tsx index cb5ed5a38..5cd658ca8 100644 --- a/packages/web/app/src/pages/target-insights-operation.tsx +++ b/packages/web/app/src/pages/target-insights-operation.tsx @@ -4,7 +4,6 @@ import { useQuery } from 'urql'; import { Section } from '@/components/common'; import { GraphQLHighlight } from '@/components/common/GraphQLSDLBlock'; import { Page, TargetLayout } from '@/components/layouts/target'; -import { ClientsFilterTrigger } from '@/components/target/insights/Filters'; import { OperationsStats } from '@/components/target/insights/Stats'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -16,7 +15,6 @@ import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { FragmentType, graphql, useFragment } from '@/gql'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters'; const GraphQLOperationBody_OperationFragment = graphql(` fragment GraphQLOperationBody_OperationFragment on Operation { @@ -67,8 +65,7 @@ function OperationView({ dataRetentionInDays, defaultPreset: presetLast1Day, }); - const [selectedClients, setSelectedClients] = useSearchParamsFilter('clients', []); - const operationsList = useMemo(() => [operationHash], [operationHash]); + const operationFilter = useMemo(() => ({ operationIds: [operationHash] }), [operationHash]); const [result] = useQuery({ query: Operation_View_OperationBodyQuery, @@ -95,15 +92,6 @@ function OperationView({
{!result.fetching && isNotNoQueryOrMutation === false && (
- diff --git a/packages/web/app/src/pages/target-insights.tsx b/packages/web/app/src/pages/target-insights.tsx index 8daf1ae11..ad54d2ea9 100644 --- a/packages/web/app/src/pages/target-insights.tsx +++ b/packages/web/app/src/pages/target-insights.tsx @@ -1,22 +1,117 @@ -import { ReactElement } from 'react'; -import { RefreshCw } from 'lucide-react'; -import { useQuery } from 'urql'; +import { ReactElement, useCallback, useEffect, useMemo } from 'react'; +import { ChevronDown, RefreshCw } from 'lucide-react'; +import { useMutation, useQuery } from 'urql'; +import { z } from 'zod'; +import { FilterDropdown } from '@/components/base/filter-dropdown/filter-dropdown'; +import type { FilterItem, FilterSelection } from '@/components/base/filter-dropdown/types'; +import type { SavedFilterView } from '@/components/base/insights-filters'; +import { InsightsFilters } from '@/components/base/insights-filters'; +import { TriggerButton } from '@/components/base/trigger-button'; import { Page, TargetLayout } from '@/components/layouts/target'; -import { - ClientsFilterTrigger, - OperationsFilterTrigger, -} from '@/components/target/insights/Filters'; import { OperationsList } from '@/components/target/insights/List'; +import { SaveFilterButton } from '@/components/target/insights/save-filter-button'; import { OperationsStats } from '@/components/target/insights/Stats'; -import { Button } from '@/components/ui/button'; import { DateRangePicker, presetLast7Days } from '@/components/ui/date-range-picker'; import { EmptyList } from '@/components/ui/empty-list'; import { Meta } from '@/components/ui/meta'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { graphql } from '@/gql'; +import { OperationStatsFilterInput, SavedFilterVisibilityType } from '@/gql/graphql'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters'; +import { useNavigate, useSearch } from '@tanstack/react-router'; + +const InsightsClientFilter = z.object({ + name: z.string(), + versions: z.array(z.string()).nullable().default(null), +}); + +export const InsightsFilterSearch = z.object({ + operations: z.array(z.string()).optional(), + clients: z.array(InsightsClientFilter).optional(), + from: z.string().optional(), + to: z.string().optional(), + viewId: z.string().optional(), +}); + +type InsightsFilterState = z.infer; + +function buildGraphQLFilter(state: InsightsFilterState): OperationStatsFilterInput { + return { + operationIds: state.operations?.length ? state.operations : undefined, + clientVersionFilters: state.clients?.length + ? state.clients.map(c => ({ + clientName: c.name, + versions: c.versions, + })) + : undefined, + }; +} + +const InsightsFilterPicker_Query = graphql(` + query InsightsFilterPicker($selector: TargetSelectorInput!, $period: DateRangeInput!) { + target(reference: { bySelector: $selector }) { + id + viewerCanCreateSavedFilter + viewerCanShareSavedFilter + operationsStats(period: $period) { + operations { + edges { + node { + id + name + operationHash + } + } + } + clients { + edges { + node { + name + versions { + version + } + } + } + } + } + savedFilters(first: 50) { + edges { + node { + id + name + visibility + viewerCanUpdate + filters { + operationHashes + clientFilters { + name + versions + } + dateRange { + from + to + } + } + } + } + } + } + } +`); + +const InsightsTrackView_Mutation = graphql(` + mutation InsightsTrackView($input: TrackSavedFilterViewInput!) { + trackSavedFilterView(input: $input) { + ok { + savedFilter { + id + viewsCount + } + } + } + } +`); function OperationsView({ organizationSlug, @@ -29,52 +124,336 @@ function OperationsView({ targetSlug: string; dataRetentionInDays: number; }): ReactElement { - const [selectedOperations, setSelectedOperations] = useSearchParamsFilter( - 'operations', - [], - ); - const [selectedClients, setSelectedClients] = useSearchParamsFilter('clients', []); + const search = useSearch({ + from: '/authenticated/$organizationSlug/$projectSlug/$targetSlug/insights', + }); + const navigate = useNavigate(); const dateRangeController = useDateRangeController({ dataRetentionInDays, defaultPreset: presetLast7Days, }); + // Populate URL with the default date range on initial load so the URL always reflects the active range. + // Skipped when from/to are already present (e.g. shared link or saved filter). + useEffect(() => { + if (search.from === undefined && search.to === undefined) { + void navigate({ + search: prev => ({ + ...prev, + from: presetLast7Days.range.from, + to: presetLast7Days.range.to, + }), + replace: true, + }); + } + }, []); + + const [pickerQuery, reexecutePickerQuery] = useQuery({ + query: InsightsFilterPicker_Query, + variables: { + selector: { organizationSlug, projectSlug, targetSlug }, + period: dateRangeController.resolvedRange, + }, + }); + + const operationFilterItems: FilterItem[] = useMemo(() => { + const edges = pickerQuery.data?.target?.operationsStats?.operations?.edges ?? []; + return edges + .filter(e => e.node.operationHash != null) + .map(e => ({ + id: e.node.operationHash!, + name: e.node.name, + values: [], + })); + }, [pickerQuery.data]); + + const clientFilterItems: FilterItem[] = useMemo(() => { + const edges = pickerQuery.data?.target?.operationsStats?.clients?.edges ?? []; + return edges.map(e => ({ + name: e.node.name, + values: e.node.versions.map(v => v.version), + })); + }, [pickerQuery.data]); + + // Build a hash→name map for converting search state hashes back to display names + const hashToNameMap = useMemo(() => { + const map = new Map(); + for (const item of operationFilterItems) { + if (item.id) { + map.set(item.id, item.name); + } + } + return map; + }, [operationFilterItems]); + + const operationFilterSelections: FilterSelection[] = useMemo( + () => + (search.operations ?? []).map(hash => ({ + id: hash, + name: hashToNameMap.get(hash) ?? hash, + values: null, + })), + [search.operations, hashToNameMap], + ); + + const clientFilterSelections: FilterSelection[] = useMemo( + () => + (search.clients ?? []).map(c => ({ + name: c.name, + values: c.versions, + })), + [search.clients], + ); + + const { privateSavedFilterViews, sharedSavedFilterViews } = useMemo(() => { + const edges = pickerQuery.data?.target?.savedFilters?.edges ?? []; + const privateSavedFilterViews: SavedFilterView[] = []; + const sharedSavedFilterViews: SavedFilterView[] = []; + + for (const edge of edges) { + const node = edge.node; + const view: SavedFilterView = { + id: node.id, + name: node.name, + viewerCanUpdate: node.viewerCanUpdate, + filters: { + operationHashes: node.filters.operationHashes, + clientFilters: node.filters.clientFilters.map(c => ({ + name: c.name, + versions: c.versions ?? null, + })), + dateRange: node.filters.dateRange ?? null, + }, + }; + + if (node.visibility === SavedFilterVisibilityType.Private) { + privateSavedFilterViews.push(view); + } else { + sharedSavedFilterViews.push(view); + } + } + + return { privateSavedFilterViews, sharedSavedFilterViews }; + }, [pickerQuery.data]); + + const [, trackView] = useMutation(InsightsTrackView_Mutation); + + const handleApplySavedFilter = useCallback( + (view: SavedFilterView) => { + void navigate({ + search: prev => ({ + ...prev, + operations: + view.filters.operationHashes.length > 0 ? view.filters.operationHashes : undefined, + clients: + view.filters.clientFilters.length > 0 + ? view.filters.clientFilters.map(c => ({ + name: c.name, + versions: c.versions, + })) + : undefined, + from: view.filters.dateRange?.from, + to: view.filters.dateRange?.to, + viewId: view.id, + }), + }); + }, + [navigate, organizationSlug, projectSlug, targetSlug], + ); + + const viewerCanCreate = pickerQuery.data?.target?.viewerCanCreateSavedFilter ?? false; + const viewerCanShare = pickerQuery.data?.target?.viewerCanShareSavedFilter ?? false; + + const activeView = useMemo(() => { + if (!search.viewId) return null; + const allViews = [...privateSavedFilterViews, ...sharedSavedFilterViews]; + return allViews.find(v => v.id === search.viewId) ?? null; + }, [search.viewId, privateSavedFilterViews, sharedSavedFilterViews]); + + useEffect(() => { + if (search.viewId) { + void trackView({ + input: { + target: { bySelector: { organizationSlug, projectSlug, targetSlug } }, + id: search.viewId, + }, + }); + } + }, [search.viewId]); + + const hasActiveFilters = useMemo( + () => + (search.operations && search.operations.length > 0) || + (search.clients && search.clients.length > 0) || + (search.from !== undefined && search.from !== presetLast7Days.range.from) || + (search.to !== undefined && search.to !== presetLast7Days.range.to), + [search.operations, search.clients, search.from, search.to], + ); + + const filter = useMemo(() => buildGraphQLFilter(search), [search]); + return ( <> -
+
Insights Observe GraphQL requests and see how the API is consumed.
-
- - - dateRangeController.setSelectedPreset(args.preset)} - /> - +
+
+ { + void navigate({ + search: prev => ({ + ...prev, + viewId: undefined, + operations: undefined, + clients: undefined, + from: presetLast7Days.range.from, + to: presetLast7Days.range.to, + }), + }); + }} + onManageSavedFilters={() => { + void navigate({ + to: '/$organizationSlug/$projectSlug/$targetSlug/insights/manage-filters', + params: { organizationSlug, projectSlug, targetSlug }, + }); + }} + setOperationSelections={selections => { + void navigate({ + search: prev => ({ + ...prev, + operations: + selections.length > 0 ? selections.map(s => s.id ?? s.name) : undefined, + }), + }); + }} + setClientSelections={selections => { + void navigate({ + search: prev => ({ + ...prev, + clients: + selections.length > 0 + ? selections.map(s => ({ + name: s.name, + versions: s.values, + })) + : undefined, + }), + }); + }} + /> + + } + selectedRange={dateRangeController.selectedPreset.range} + onUpdate={args => dateRangeController.setSelectedPreset(args.preset)} + startDate={dateRangeController.startDate} + validUnits={['y', 'M', 'w', 'd', 'h']} + align="start" + /> + {operationFilterSelections.length > 0 && ( + { + void navigate({ + search: prev => ({ + ...prev, + operations: + selections.length > 0 ? selections.map(s => s.id ?? s.name) : undefined, + }), + }); + }} + onRemove={() => { + void navigate({ + search: prev => ({ ...prev, operations: undefined }), + }); + }} + /> + )} + {clientFilterSelections.length > 0 && ( + { + void navigate({ + search: prev => ({ + ...prev, + clients: + selections.length > 0 + ? selections.map(s => ({ + name: s.name, + versions: s.values, + })) + : undefined, + }), + }); + }} + onRemove={() => { + void navigate({ + search: prev => ({ ...prev, clients: undefined }), + }); + }} + /> + )} + {hasActiveFilters && ( + ({ + name: c.name, + versions: c.versions, + })), + dateRange: { + from: search.from ?? dateRangeController.selectedPreset.range.from, + to: search.to ?? dateRangeController.selectedPreset.range.to, + }, + }} + organizationSlug={organizationSlug} + projectSlug={projectSlug} + targetSlug={targetSlug} + onSaved={viewId => { + void navigate({ + search: prev => ({ ...prev, viewId }), + }); + reexecutePickerQuery({ requestPolicy: 'network-only' }); + }} + onUpdated={() => { + // Refetch to get updated saved filter data + reexecutePickerQuery({ requestPolicy: 'network-only' }); + }} + /> + )} +
+
+ dateRangeController.refreshResolvedRange()} + /> +
diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index b71d3d032..3e06825f1 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -74,9 +74,10 @@ import { TargetExplorerTypePage } from './pages/target-explorer-type'; import { TargetExplorerUnusedPage } from './pages/target-explorer-unused'; import { TargetHistoryPage } from './pages/target-history'; import { TargetHistoryVersionPage } from './pages/target-history-version'; -import { TargetInsightsPage } from './pages/target-insights'; +import { InsightsFilterSearch, TargetInsightsPage } from './pages/target-insights'; import { TargetInsightsClientPage } from './pages/target-insights-client'; import { TargetInsightsCoordinatePage } from './pages/target-insights-coordinate'; +import { TargetInsightsManageFiltersPage } from './pages/target-insights-manage-filters'; import { TargetInsightsOperationPage } from './pages/target-insights-operation'; import { TargetLaboratoryPage } from './pages/target-laboratory'; import { TargetLaboratoryPage as TargetLaboratoryPageNew } from './pages/target-laboratory-new'; @@ -692,9 +693,10 @@ const targetAppVersionRoute = createRoute({ }, }); -const targetInsightsRoute = createRoute({ +export const targetInsightsRoute = createRoute({ getParentRoute: () => targetRoute, path: 'insights', + validateSearch: InsightsFilterSearch.parse, component: function TargetInsightsRoute() { const { organizationSlug, projectSlug, targetSlug } = targetInsightsRoute.useParams(); return ( @@ -707,6 +709,22 @@ const targetInsightsRoute = createRoute({ }, }); +const targetInsightsManageFiltersRoute = createRoute({ + getParentRoute: () => targetRoute, + path: 'insights/manage-filters', + component: function TargetInsightsManageFiltersRoute() { + const { organizationSlug, projectSlug, targetSlug } = + targetInsightsManageFiltersRoute.useParams(); + return ( + + ); + }, +}); + const TargetTracesRouteSearch = z.object({ filter: TargetTracesFilterState.optional(), sort: TargetTracesSort.shape.optional(), @@ -1107,6 +1125,7 @@ const routeTree = root.addChildren([ targetLaboratoryRoute, targetHistoryRoute.addChildren([targetHistoryVersionRoute]), targetInsightsRoute, + targetInsightsManageFiltersRoute, targetTraceRoute, targetTracesRoute, targetInsightsCoordinateRoute, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a41d0da0e..d68cabc9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1692,6 +1692,9 @@ importers: packages/web/app: devDependencies: + '@base-ui/react': + specifier: ^1.1.0 + version: 1.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@date-fns/utc': specifier: 2.1.1 version: 2.1.1 @@ -1866,6 +1869,9 @@ importers: '@tanstack/react-table': specifier: 8.20.6 version: 8.20.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.18 + version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/router-devtools': specifier: 1.34.9 version: 1.34.9(@tanstack/react-router@1.34.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1905,9 +1911,6 @@ importers: '@types/react-virtualized-auto-sizer': specifier: 1.0.4 version: 1.0.4 - '@types/react-window': - specifier: 1.8.8 - version: 1.8.8 '@urql/core': specifier: 5.0.3 version: 5.0.3(graphql@16.9.0) @@ -2058,9 +2061,6 @@ importers: react-virtuoso: specifier: 4.12.3 version: 4.12.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-window: - specifier: 1.8.11 - version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) recharts: specifier: 2.15.1 version: 2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2159,7 +2159,7 @@ importers: version: 19.2.4 react-avatar: specifier: 5.0.3 - version: 5.0.3(@babel/runtime@7.26.10)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4) + version: 5.0.3(@babel/runtime@7.28.6)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4) react-countup: specifier: 6.5.3 version: 6.5.3(react@19.2.4) @@ -3240,6 +3240,10 @@ packages: resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.26.9': resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==} engines: {node: '>=6.9.0'} @@ -3263,6 +3267,27 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@base-ui/react@1.1.0': + resolution: {integrity: sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui/utils@0.2.4': + resolution: {integrity: sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bentocache/plugin-prometheus@0.2.0': resolution: {integrity: sha512-ZaWtexpwDf6cSy2dZaRl36BAZi1eSM8QDnGeJQ0qN7rJ6TEvrP3v0egH70Gxc5mdHY7xhh0Zppf+kAoTgJZx3A==} peerDependencies: @@ -3961,21 +3986,36 @@ packages: '@floating-ui/core@1.2.6': resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==} + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + '@floating-ui/dom@1.2.9': resolution: {integrity: sha512-sosQxsqgxMNkV3C+3UqTS6LxP7isRLwX8WMepp843Rb3/b0Wz8+MdUkxJksByip3C2WwLugLHN1b4ibn//zKwQ==} + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + '@floating-ui/react-dom@2.1.0': resolution: {integrity: sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/react@0.26.16': resolution: {integrity: sha512-HEf43zxZNAI/E781QIVpYSF3K2VH4TTYZpqecjdsFkjsaU1EbaWcM++kw0HXFffj7gDUcBFevX8s0rQGQpxkow==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.2': resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==} @@ -9359,11 +9399,11 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.8.1': - resolution: {integrity: sha512-dP5a7giEM4BQWLJ7K07ToZv8rF51mzbrBMkf0scg1QNYuFx3utnPUBPUHdzaowZhIez1K2XS78amuzD+YGRA5Q==} + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@tanstack/router-core@1.139.13': resolution: {integrity: sha512-vqBEBiFHHGt82fdtMqGRQFs9BRE8UKI17pVoYurEpIxafI7t8go1LoIxYbva2l8Q+44z0NZNQ2kqVZJwtEwgkg==} @@ -9404,8 +9444,8 @@ packages: resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.8.1': - resolution: {integrity: sha512-uNtAwenT276M9QYCjTBoHZ8X3MUeCRoGK59zPi92hMIxdfS9AyHjkDWJ94WroDxnv48UE+hIeo21BU84jKc8aQ==} + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} '@tanstack/zod-adapter@1.120.5': resolution: {integrity: sha512-EXFVr2rw9qy5Ad9fogxo++A10A555XrhNyKZ7pnPV84HU/Xy3C2zP8UaaoTlTDr+/BJ2yzyyM47yK62a03ofbA==} @@ -9906,9 +9946,6 @@ packages: '@types/react-virtualized-auto-sizer@1.0.4': resolution: {integrity: sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==} - '@types/react-window@1.8.8': - resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} - '@types/react@18.3.18': resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==} @@ -16815,13 +16852,6 @@ packages: react: '>=16 || >=17 || >= 18' react-dom: '>=16 || >=17 || >= 18' - react-window@1.8.11: - resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -17063,6 +17093,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -17884,6 +17917,9 @@ packages: tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -21284,6 +21320,8 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.28.6': {} + '@babel/template@7.26.9': dependencies: '@babel/code-frame': 7.27.1 @@ -21320,6 +21358,31 @@ snapshots: '@balena/dockerignore@1.0.2': {} + '@base-ui/react@1.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@base-ui/utils': 0.2.4(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.10 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + tabbable: 6.4.0 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + + '@base-ui/utils@0.2.4(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@floating-ui/utils': 0.2.10 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@bentocache/plugin-prometheus@0.2.0(bentocache@1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2))(prom-client@15.1.3)': dependencies: bentocache: 1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2) @@ -22165,10 +22228,19 @@ snapshots: '@floating-ui/core@1.2.6': {} + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + '@floating-ui/dom@1.2.9': dependencies: '@floating-ui/core': 1.2.6 + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + '@floating-ui/react-dom@2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.2.9 @@ -22181,6 +22253,12 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@floating-ui/react-dom@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react@0.26.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -22197,6 +22275,8 @@ snapshots: react-dom: 19.2.4(react@19.2.4) tabbable: 6.2.0 + '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.2': {} '@formatjs/intl-localematcher@0.5.10': @@ -24817,7 +24897,7 @@ snapshots: '@headlessui/react@1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/react-virtual': 3.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) client-only: 0.0.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -24827,7 +24907,7 @@ snapshots: '@floating-ui/react': 0.26.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-aria/focus': 3.17.1(react@18.3.1) '@react-aria/interactions': 3.21.3(react@18.3.1) - '@tanstack/react-virtual': 3.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -24836,7 +24916,7 @@ snapshots: '@floating-ui/react': 0.26.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/focus': 3.17.1(react@19.2.4) '@react-aria/interactions': 3.21.3(react@19.2.4) - '@tanstack/react-virtual': 3.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -30193,15 +30273,15 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tanstack/react-virtual@3.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/virtual-core': 3.8.1 + '@tanstack/virtual-core': 3.13.18 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tanstack/react-virtual@3.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/virtual-core': 3.8.1 + '@tanstack/virtual-core': 3.13.18 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -30259,7 +30339,7 @@ snapshots: '@tanstack/table-core@8.20.5': {} - '@tanstack/virtual-core@3.8.1': {} + '@tanstack/virtual-core@3.13.18': {} '@tanstack/zod-adapter@1.120.5(@tanstack/react-router@1.34.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(zod@3.25.76)': dependencies: @@ -30893,10 +30973,6 @@ snapshots: dependencies: '@types/react': 18.3.18 - '@types/react-window@1.8.8': - dependencies: - '@types/react': 18.3.18 - '@types/react@18.3.18': dependencies: '@types/prop-types': 15.7.5 @@ -39039,9 +39115,9 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-avatar@5.0.3(@babel/runtime@7.26.10)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4): + react-avatar@5.0.3(@babel/runtime@7.28.6)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4): dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.28.6 core-js-pure: 3.37.1 is-retina: 1.0.3 md5: 2.3.0 @@ -39261,13 +39337,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.26.10 - memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -39628,6 +39697,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.1.1: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -40651,6 +40722,8 @@ snapshots: tabbable@6.2.0: {} + tabbable@6.4.0: {} + tagged-tag@1.0.0: {} tailwind-csstree@0.1.4: {} diff --git a/scripts/seed-insights.mts b/scripts/seed-insights.mts new file mode 100644 index 000000000..1842a2abf --- /dev/null +++ b/scripts/seed-insights.mts @@ -0,0 +1,906 @@ +/** + * Seeds a complete Insights development environment from scratch. + * + * Creates: owner account, org, project, target, schema, usage data (30 days), + * and saved filters with view counts. + * + * Prerequisites: + * - Docker Compose is running (pnpm local:setup) + * - Services are running (pnpm dev:hive) + * + * Usage: + * bun scripts/seed-insights.mts + */ + +import * as readline from 'node:readline/promises'; +import { createTRPCProxyClient, httpLink } from '@trpc/client'; +import type { CollectedOperation } from '../integration-tests/testkit/usage'; + +process.env.RUN_AGAINST_LOCAL_SERVICES = '1'; +await import('../integration-tests/local-dev.ts'); + +const { ensureEnv } = await import('../integration-tests/testkit/env'); +const { createOrganization, createProject, createToken, publishSchema } = await import( + '../integration-tests/testkit/flow' +); +const { execute } = await import('../integration-tests/testkit/graphql'); +const { legacyCollect } = await import('../integration-tests/testkit/usage'); +const { generateUnique, getServiceHost } = await import('../integration-tests/testkit/utils'); +const { TargetAccessScope, ProjectType, SavedFilterVisibilityType } = await import( + '../integration-tests/testkit/gql/graphql' +); +const { CreateSavedFilterMutation, TrackSavedFilterViewMutation } = await import( + '../integration-tests/testkit/saved-filters' +); + +// --------------------------------------------------------------------------- +// Auth helper — handles both new and existing SuperTokens users +// --------------------------------------------------------------------------- + +const password = 'ilikebigturtlesandicannotlie47'; + +async function signInOrSignUp( + email: string, +): Promise<{ access_token: string; refresh_token: string }> { + const supertokensUri = ensureEnv('SUPERTOKENS_CONNECTION_URI'); + const apiKey = ensureEnv('SUPERTOKENS_API_KEY'); + const headers = { + 'content-type': 'application/json; charset=UTF-8', + 'api-key': apiKey, + 'cdi-version': '4.0', + }; + const body = JSON.stringify({ email, password }); + + // Try signup first + let res = await fetch(`${supertokensUri}/appid-public/public/recipe/signup`, { + method: 'POST', + headers, + body, + }); + let data = (await res.json()) as { status: string; user?: { id: string; emails: string[] } }; + + // If user already exists, look them up by email (avoids needing their password) + if (data.status === 'EMAIL_ALREADY_EXISTS_ERROR') { + res = await fetch( + `${supertokensUri}/appid-public/public/recipe/user?email=${encodeURIComponent(email)}`, + { headers }, + ); + const lookupData = (await res.json()) as { + status: string; + user?: { id: string; emails: string[] }; + }; + if (lookupData.status !== 'OK' || !lookupData.user) { + throw new Error(`User lookup failed for ${email}: ${JSON.stringify(lookupData)}`); + } + data = { status: 'OK', user: lookupData.user }; + } + + if (data.status !== 'OK' || !data.user) { + throw new Error(`Auth failed for ${email}: ${JSON.stringify(data)}`); + } + + const superTokensUserId = data.user.id; + + // Ensure user exists in Hive DB + const graphqlAddress = await getServiceHost('server', 8082); + const internalApi = createTRPCProxyClient({ + links: [httpLink({ url: `http://${graphqlAddress}/trpc`, fetch })], + }); + const ensureUserResult = await internalApi.ensureUser.mutate({ + superTokensUserId, + email, + oidcIntegrationId: null, + firstName: null, + lastName: null, + }); + if (!ensureUserResult.ok) { + throw new Error(`ensureUser failed: ${ensureUserResult.reason}`); + } + + // Create session + const sessionPayload = { + version: '2', + superTokensUserId, + userId: ensureUserResult.user.id, + oidcIntegrationId: null, + email, + }; + const sessionRes = await fetch(`${supertokensUri}/appid-public/public/recipe/session`, { + method: 'POST', + headers: { ...headers, rid: 'session' }, + body: JSON.stringify({ + enableAntiCsrf: false, + userId: superTokensUserId, + userDataInDatabase: sessionPayload, + userDataInJWT: sessionPayload, + }), + }); + const sessionData = (await sessionRes.json()) as { + accessToken?: { token: string }; + refreshToken?: { token: string }; + }; + + if (!sessionData.accessToken?.token || !sessionData.refreshToken?.token) { + throw new Error(`Session creation failed: ${JSON.stringify(sessionData)}`); + } + + return { + access_token: sessionData.accessToken.token, + refresh_token: sessionData.refreshToken.token, + }; +} + +// --------------------------------------------------------------------------- +// 1. Operations — ~1000 distinct queries/mutations against the Star Wars schema +// --------------------------------------------------------------------------- + +type OperationDef = { + operation: string; + operationName: string; + fields: string[]; +}; + +const EPISODES = ['NEWHOPE', 'EMPIRE', 'JEDI'] as const; + +// Field selection templates for Character +const CHARACTER_SELECTIONS = [ + { body: 'name', fields: ['Character', 'Character.name'] }, + { body: 'name appearsIn', fields: ['Character', 'Character.name', 'Character.appearsIn'] }, + { body: 'name friends { name }', fields: ['Character', 'Character.name', 'Character.friends'] }, + { + body: 'name appearsIn friends { name }', + fields: ['Character', 'Character.name', 'Character.appearsIn', 'Character.friends'], + }, + { + body: 'name friends { name appearsIn }', + fields: ['Character', 'Character.name', 'Character.friends', 'Character.appearsIn'], + }, + { + body: 'name friends { name friends { name } }', + fields: ['Character', 'Character.name', 'Character.friends'], + }, +]; + +// Inline fragment templates +const HUMAN_SELECTIONS = [ + { + body: '... on Human { name starships { name } }', + fields: ['Human', 'Human.name', 'Human.starships', 'Starship', 'Starship.name'], + }, + { + body: '... on Human { name totalCredits }', + fields: ['Human', 'Human.name', 'Human.totalCredits'], + }, + { + body: '... on Human { name starships { name length } }', + fields: [ + 'Human', + 'Human.name', + 'Human.starships', + 'Starship', + 'Starship.name', + 'Starship.length', + ], + }, + { + body: '... on Human { name totalCredits starships { name } }', + fields: [ + 'Human', + 'Human.name', + 'Human.totalCredits', + 'Human.starships', + 'Starship', + 'Starship.name', + ], + }, +]; + +const DROID_SELECTIONS = [ + { + body: '... on Droid { name primaryFunction }', + fields: ['Droid', 'Droid.name', 'Droid.primaryFunction'], + }, + { + body: '... on Droid { name }', + fields: ['Droid', 'Droid.name'], + }, +]; + +const REVIEW_SELECTIONS = [ + { body: 'stars', fields: ['Review', 'Review.stars'] }, + { body: 'stars commentary', fields: ['Review', 'Review.stars', 'Review.commentary'] }, + { body: 'episode stars', fields: ['Review', 'Review.episode', 'Review.stars'] }, + { + body: 'episode stars commentary', + fields: ['Review', 'Review.episode', 'Review.stars', 'Review.commentary'], + }, +]; + +function generateOperations(): OperationDef[] { + const ops: OperationDef[] = []; + let idx = 0; + + // 1. Simple hero queries per episode x character selection (3 × 6 = 18) + for (const ep of EPISODES) { + for (const sel of CHARACTER_SELECTIONS) { + const name = `GetHero_${idx++}`; + ops.push({ + operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`, + operationName: name, + fields: ['Query', 'Query.hero', ...sel.fields], + }); + } + } + + // 2. Human fragment queries per episode (3 × 4 = 12) + for (const ep of EPISODES) { + for (const sel of HUMAN_SELECTIONS) { + const name = `GetHuman_${idx++}`; + ops.push({ + operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`, + operationName: name, + fields: ['Query', 'Query.hero', ...sel.fields], + }); + } + } + + // 3. Droid fragment queries per episode (3 × 2 = 6) + for (const ep of EPISODES) { + for (const sel of DROID_SELECTIONS) { + const name = `GetDroid_${idx++}`; + ops.push({ + operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`, + operationName: name, + fields: ['Query', 'Query.hero', ...sel.fields], + }); + } + } + + // 4. Combined Human + Droid queries per episode (3 × 4 × 2 = 24) + for (const ep of EPISODES) { + for (const hSel of HUMAN_SELECTIONS) { + for (const dSel of DROID_SELECTIONS) { + const name = `GetCharacterDetails_${idx++}`; + ops.push({ + operation: `query ${name} { hero(episode: ${ep}) { name ${hSel.body} ${dSel.body} } }`, + operationName: name, + fields: [ + 'Query', + 'Query.hero', + 'Character', + 'Character.name', + ...hSel.fields, + ...dSel.fields, + ], + }); + } + } + } + + // 5. Multi-alias queries — different episode combos (3 choose 2 = 3, with varying selections = ~18) + const epPairs: [string, string][] = [ + ['NEWHOPE', 'EMPIRE'], + ['NEWHOPE', 'JEDI'], + ['EMPIRE', 'JEDI'], + ]; + for (const [ep1, ep2] of epPairs) { + for (const sel of CHARACTER_SELECTIONS) { + const name = `Compare_${idx++}`; + ops.push({ + operation: `query ${name} { a: hero(episode: ${ep1}) { ${sel.body} } b: hero(episode: ${ep2}) { ${sel.body} } }`, + operationName: name, + fields: ['Query', 'Query.hero', ...sel.fields], + }); + } + } + + // 6. Triple-alias queries (1 × 6 = 6) + for (const sel of CHARACTER_SELECTIONS) { + const name = `AllEpisodeHeroes_${idx++}`; + ops.push({ + operation: `query ${name} { newhope: hero(episode: NEWHOPE) { ${sel.body} } empire: hero(episode: EMPIRE) { ${sel.body} } jedi: hero(episode: JEDI) { ${sel.body} } }`, + operationName: name, + fields: ['Query', 'Query.hero', ...sel.fields], + }); + } + + // 7. Mutation variations — createReview per episode x review selection (3 × 4 = 12) + const STARS = [1, 2, 3, 4, 5]; + for (const ep of EPISODES) { + for (const sel of REVIEW_SELECTIONS) { + const name = `CreateReview_${idx++}`; + const stars = STARS[idx % STARS.length]; + ops.push({ + operation: `mutation ${name} { createReview(episode: ${ep}, review: { stars: ${stars} }) { ${sel.body} } }`, + operationName: name, + fields: ['Mutation', 'Mutation.createReview', ...sel.fields], + }); + } + } + + // 8. Generate more unique queries to reach ~1000 by varying naming patterns + // Simulate realistic operation names like a real codebase would have + // Mix of short and long prefixes to test truncation at varying widths + const PREFIXES = [ + 'Dashboard', + 'Settings', + 'Profile', + 'Admin', + 'Search', + 'Feed', + 'Sync', + 'OrganizationBillingSubscriptionDetails', + 'TargetSchemaVersionComparison', + 'ProjectAccessTokenPermissionsManagement', + 'UserNotificationPreferencesUpdate', + 'SchemaRegistryExplorerTypeDetails', + 'IntegrationWebhookDeliveryStatus', + 'AlertChannelConfigurationValidation', + 'PersistedOperationCollectionSync', + 'GraphQLEndpointLatencyPercentiles', + 'CDNAccessTokenRotation', + 'SchemaContractCompositionValidation', + 'MemberRoleAssignmentAuditLog', + 'OperationBodyNormalizationPreview', + ]; + const SUFFIXES = [ + 'Query', + 'Fetch', + 'Load', + 'Get', + 'List', + 'Detail', + 'Summary', + 'Overview', + 'Stats', + 'Count', + 'WithPaginationAndFilters', + 'ByOrganizationSlug', + 'ForDateRangeComparison', + ]; + + while (ops.length < 1000) { + const prefix = PREFIXES[idx % PREFIXES.length]; + const suffix = SUFFIXES[Math.floor(idx / PREFIXES.length) % SUFFIXES.length]; + const ep = EPISODES[idx % 3]; + const charSel = CHARACTER_SELECTIONS[idx % CHARACTER_SELECTIONS.length]; + const name = `${prefix}${suffix}_${idx++}`; + ops.push({ + operation: `query ${name} { hero(episode: ${ep}) { ${charSel.body} } }`, + operationName: name, + fields: ['Query', 'Query.hero', ...charSel.fields], + }); + } + + return ops; +} + +const OPERATIONS = generateOperations(); + +// --------------------------------------------------------------------------- +// 2. Clients — 7 clients with 0–30 versions each +// --------------------------------------------------------------------------- + +interface ClientDef { + name: string; + versions: string[]; + weight: number; // relative traffic weight +} + +function generateVersions(prefix: string, count: number): string[] { + return Array.from({ length: count }, (_, i) => `${prefix}.${i}.0`); +} + +// Mix of short and long client names to test truncation at varying widths +const CLIENTS: ClientDef[] = [ + { name: 'web-app', versions: generateVersions('1', 15), weight: 30 }, + { name: 'ios', versions: generateVersions('2', 25), weight: 25 }, + { name: 'android', versions: generateVersions('3', 10), weight: 15 }, + { name: 'graphql-playground', versions: [], weight: 5 }, + { name: 'admin-dashboard-internal-tools', versions: generateVersions('1', 5), weight: 8 }, + { + name: 'mobile-backend-for-frontend-service', + versions: generateVersions('0', 30), + weight: 12, + }, + { name: 'analytics-pipeline-worker-v2', versions: generateVersions('1', 8), weight: 5 }, +]; + +const TOTAL_WEIGHT = CLIENTS.reduce((s, c) => s + c.weight, 0); + +function pickWeightedClient(): { name: string; version: string | undefined } { + let r = Math.random() * TOTAL_WEIGHT; + for (const client of CLIENTS) { + r -= client.weight; + if (r <= 0) { + const version = + client.versions.length > 0 + ? client.versions[Math.floor(Math.random() * client.versions.length)] + : undefined; + return { name: client.name, version }; + } + } + // fallback + return { name: CLIENTS[0].name, version: CLIENTS[0].versions[0] }; +} + +function randomDuration(): number { + // 50ms to 2s in nanoseconds, with most clustering around 100-500ms + const base = 50 + Math.random() * 450; // 50-500ms + const spike = Math.random() < 0.1 ? Math.random() * 1500 : 0; // 10% chance of slow + return Math.round((base + spike) * 1_000_000); // convert ms → ns +} + +function randomOperation() { + return OPERATIONS[Math.floor(Math.random() * OPERATIONS.length)]; +} + +// --------------------------------------------------------------------------- +// 3. Schema SDL (Star Wars mono — matches scripts/seed-schemas/mono.graphql) +// --------------------------------------------------------------------------- + +const SCHEMA_SDL = ` +interface Node { id: ID! } +interface Character implements Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! } +type Human implements Character & Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! starships: [Starship] totalCredits: Int } +type Droid implements Character & Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! primaryFunction: String } +type Starship { id: ID! name: String! length(unit: LengthUnit = METER): Float } +enum LengthUnit { METER LIGHT_YEAR } +enum Episode { NEWHOPE EMPIRE JEDI } +type Query { hero(episode: Episode): Character } +type Review { episode: Episode stars: Int! commentary: String } +input ReviewInput { stars: Int! commentary: String } +type Mutation { createReview(episode: Episode, review: ReviewInput!): Review } +`.trim(); + +// --------------------------------------------------------------------------- +// 4. Prompt + Main +// --------------------------------------------------------------------------- + +const BATCH_SIZE = 500; +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +const ONE_HOUR_MS = 60 * 60 * 1000; + +async function promptForEmail(): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const email = await rl.question('Enter owner email (or press Enter to auto-generate): '); + return email.trim(); + } finally { + rl.close(); + } +} + +async function main() { + const inputEmail = await promptForEmail(); + const ownerEmail = inputEmail || `${generateUnique()}-${Date.now()}@localhost.localhost`; + + console.log(`\n🚀 Creating owner (${ownerEmail}), org, project...`); + const auth = await signInOrSignUp(ownerEmail); + const ownerToken = auth.access_token; + + // Create organization + const orgSlug = generateUnique(); + const orgResult = await createOrganization({ slug: orgSlug }, ownerToken).then(r => + r.expectNoGraphQLErrors(), + ); + const organization = orgResult.createOrganization.ok!.createdOrganizationPayload.organization; + + // Create project + const projectResult = await createProject( + { + organization: { bySelector: { organizationSlug: organization.slug } }, + type: ProjectType.Single, + slug: generateUnique(), + }, + ownerToken, + ).then(r => r.expectNoGraphQLErrors()); + + const project = projectResult.createProject.ok!.createdProject; + const target = projectResult.createProject.ok!.createdTargets[0]; + + console.log(` Org: ${organization.slug}`); + console.log(` Project: ${project.slug}`); + console.log(` Target: ${target.slug}`); + + // Create access token + console.log('📝 Publishing schema...'); + const tokenResult = await createToken( + { + name: generateUnique(), + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + organizationScopes: [], + projectScopes: [], + targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], + }, + ownerToken, + ).then(r => r.expectNoGraphQLErrors()); + + const secret = tokenResult.createToken.ok!.secret; + + const publishResult = await publishSchema( + { + author: 'seed-insights', + commit: 'seed', + sdl: SCHEMA_SDL, + force: true, + }, + secret, + 'authorization', + ); + if (publishResult.rawBody.errors?.length) { + console.error('Schema publish failed:', publishResult.rawBody.errors); + process.exit(1); + } + console.log(' Schema published successfully.'); + + // Generate operations spread across 30 days + console.log('📊 Generating usage data (30 days)...'); + const now = Date.now(); + const allOperations: CollectedOperation[] = []; + + for (let t = now - THIRTY_DAYS_MS; t <= now; t += ONE_HOUR_MS) { + const opsThisHour = 3 + Math.floor(Math.random() * 6); // 3–8 per hour + for (let i = 0; i < opsThisHour; i++) { + const op = randomOperation(); + const client = pickWeightedClient(); + const ok = Math.random() > 0.05; + allOperations.push({ + timestamp: t + Math.floor(Math.random() * ONE_HOUR_MS), + operation: op.operation, + operationName: op.operationName, + fields: op.fields, + execution: { + ok, + duration: randomDuration(), + errorsTotal: ok ? 0 : 1, + }, + metadata: { + client: { + name: client.name, + ...(client.version ? { version: client.version } : {}), + }, + }, + }); + } + } + + console.log(` Generated ${allOperations.length} operations across 30 days.`); + + // Send in batches + const totalBatches = Math.ceil(allOperations.length / BATCH_SIZE); + for (let i = 0; i < allOperations.length; i += BATCH_SIZE) { + const batch = allOperations.slice(i, i + BATCH_SIZE); + const batchNum = Math.floor(i / BATCH_SIZE) + 1; + process.stdout.write(` Sending batch ${batchNum}/${totalBatches}...`); + const result = await legacyCollect({ + operations: batch, + token: secret, + authorizationHeader: 'authorization', + }); + if (result.status !== 200) { + console.error(` FAILED (status ${result.status}):`, result.body); + } else { + const body = result.body as { operations: { accepted: number; rejected: number } }; + console.log(` ✓ ${body.operations.accepted} accepted, ${body.operations.rejected} rejected`); + } + } + + // Wait for ingestion + console.log('⏳ Waiting for usage ingestion (15s)...'); + await new Promise(resolve => setTimeout(resolve, 15_000)); + + // Helper for saved filter operations + const targetSelector = { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }; + + // Fetch actual operation hashes from ingested data + console.log('🔍 Fetching operation hashes...'); + const { parse } = await import('graphql'); + const opsResult = await execute({ + document: parse(/* GraphQL */ ` + query SeedGetOperationHashes($target: TargetReferenceInput!, $period: DateRangeInput!) { + target(reference: $target) { + operationsStats(period: $period) { + operations { + edges { + node { + name + operationHash + } + } + } + } + } + } + `) as any, + variables: { + target: { bySelector: targetSelector }, + period: { + from: new Date(now - THIRTY_DAYS_MS).toISOString(), + to: new Date(now).toISOString(), + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + type OpsResult = { + target?: { + operationsStats?: { + operations?: { + edges: Array<{ node: { name: string; operationHash: string | null } }>; + }; + }; + }; + }; + const operationEdges = (opsResult as OpsResult).target?.operationsStats?.operations?.edges ?? []; + const operationHashMap = new Map(); + for (const edge of operationEdges) { + if (edge.node.operationHash && edge.node.name) { + // API returns names as "{hashPrefix}_{operationName}", extract the operationName part + const underscoreIdx = edge.node.name.indexOf('_'); + const operationName = + underscoreIdx >= 0 ? edge.node.name.slice(underscoreIdx + 1) : edge.node.name; + operationHashMap.set(operationName, edge.node.operationHash); + } + } + console.log( + ` Found ${operationHashMap.size} operations: ${[...operationHashMap.keys()].join(', ')}`, + ); + + async function createSavedFilter(input: { + name: string; + description?: string; + visibility: (typeof SavedFilterVisibilityType)[keyof typeof SavedFilterVisibilityType]; + insightsFilter?: Record; + }) { + const result = await execute({ + document: CreateSavedFilterMutation, + variables: { + input: { + target: { bySelector: targetSelector }, + name: input.name, + description: input.description, + visibility: input.visibility, + insightsFilter: input.insightsFilter, + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + return result.createSavedFilter; + } + + async function trackSavedFilterView(filterId: string) { + await execute({ + document: TrackSavedFilterViewMutation, + variables: { + input: { + target: { bySelector: targetSelector }, + id: filterId, + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + } + + // Create saved filters using real operation hashes + console.log('💾 Creating saved filters...'); + + // Helper to resolve operation names to hashes + function resolveOps(names: string[]): string[] { + return names.map(name => operationHashMap.get(name)).filter((h): h is string => h != null); + } + + // Collect all known operation names for building saved filters + const allOpNames = [...operationHashMap.keys()]; + console.log(` Using ${allOpNames.length} resolved operations for saved filters`); + + // Define all saved filters (using slices of the resolved operation names) + const savedFilterDefs: Array<{ + name: string; + description?: string; + visibility: (typeof SavedFilterVisibilityType)[keyof typeof SavedFilterVisibilityType]; + operationNames: string[]; + clientFilters?: Array<{ name: string; versions?: string[] }>; + dateRange?: { from: string; to: string }; + views: number; + }> = [ + { + name: 'High Traffic Operations', + description: 'Most frequently called queries', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.slice(0, 20), + clientFilters: [{ name: 'web-app' }, { name: 'ios' }], + dateRange: { from: 'now-7d', to: 'now' }, + views: 372, + }, + { + name: 'Mobile Clients', + description: 'iOS and Android traffic', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.slice(10, 25), + clientFilters: [ + { name: 'ios', versions: ['2.0.0', '2.5.0', '2.10.0', '2.20.0'] }, + { name: 'android', versions: ['3.0.0', '3.5.0', '3.9.0'] }, + ], + dateRange: { from: 'now-30d', to: 'now' }, + views: 156, + }, + { + name: 'My Debug View', + description: 'Debugging slow mutations', + visibility: SavedFilterVisibilityType.Private, + operationNames: allOpNames.slice(30, 35), + clientFilters: [{ name: 'graphql-playground' }], + dateRange: { from: 'now-1d', to: 'now' }, + views: 23, + }, + { + name: 'Web App — All Queries', + description: 'All query operations from the web app', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.slice(0, 50), + clientFilters: [{ name: 'web-app' }], + dateRange: { from: 'now-7d', to: 'now' }, + views: 241, + }, + { + name: 'Mutations Only', + description: 'All mutation operations across all clients', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.filter(n => n.startsWith('CreateReview')), + dateRange: { from: 'now-14d', to: 'now' }, + views: 89, + }, + { + name: 'Admin Dashboard', + description: 'Operations from the admin dashboard client', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.filter(n => n.startsWith('Dashboard')), + clientFilters: [{ name: 'admin-dashboard-internal-tools' }], + dateRange: { from: 'now-7d', to: 'now' }, + views: 64, + }, + { + name: 'BFF Layer', + description: 'Mobile BFF service traffic', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.slice(50, 80), + clientFilters: [{ name: 'mobile-backend-for-frontend-service' }], + dateRange: { from: 'now-30d', to: 'now' }, + views: 118, + }, + { + name: 'Error Investigation', + description: 'Operations with known error patterns', + visibility: SavedFilterVisibilityType.Private, + operationNames: allOpNames.slice(80, 90), + clientFilters: [{ name: 'android', versions: ['3.0.0'] }], + dateRange: { from: 'now-7d', to: 'now' }, + views: 7, + }, + { + name: 'Character Detail Queries', + description: 'All character detail operations', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.filter(n => n.startsWith('GetCharacterDetails')), + dateRange: { from: 'now-90d', to: 'now' }, + views: 195, + }, + { + name: 'Deep Queries', + description: 'Queries with nested friend relationships', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.slice(3, 8), + dateRange: { from: 'now-14d', to: 'now' }, + views: 43, + }, + { + name: 'iOS Latest Versions', + description: 'Recent iOS app versions only', + visibility: SavedFilterVisibilityType.Private, + operationNames: allOpNames.slice(0, 15), + clientFilters: [{ name: 'ios', versions: ['2.20.0', '2.24.0'] }], + dateRange: { from: 'now-7d', to: 'now' }, + views: 31, + }, + { + name: 'Analytics Worker', + description: 'Background analytics service operations', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.filter(n => n.startsWith('Analytics')), + clientFilters: [{ name: 'analytics-pipeline-worker-v2' }], + dateRange: { from: 'now-30d', to: 'now' }, + views: 12, + }, + { + name: 'Playground Exploration', + description: 'Ad-hoc queries from GraphQL Playground', + visibility: SavedFilterVisibilityType.Private, + operationNames: allOpNames.slice(100, 120), + clientFilters: [{ name: 'graphql-playground' }], + dateRange: { from: 'now-1d', to: 'now' }, + views: 5, + }, + { + name: 'Droid & Human Types', + description: 'Operations touching Droid and Human types', + visibility: SavedFilterVisibilityType.Shared, + operationNames: [ + ...allOpNames.filter(n => n.startsWith('GetDroid')), + ...allOpNames.filter(n => n.startsWith('GetHuman')), + ], + dateRange: { from: 'now-14d', to: 'now' }, + views: 77, + }, + { + name: 'Production Canary', + description: 'Canary release monitoring — latest client versions', + visibility: SavedFilterVisibilityType.Shared, + operationNames: allOpNames.slice(0, 10), + clientFilters: [ + { name: 'web-app', versions: ['1.14.0'] }, + { name: 'ios', versions: ['2.24.0'] }, + { name: 'android', versions: ['3.9.0'] }, + ], + dateRange: { from: 'now-7d', to: 'now' }, + views: 203, + }, + ]; + + // Create all filters and collect results + const createdFilters: Array<{ name: string; id: string; views: number }> = []; + for (const def of savedFilterDefs) { + const ops = resolveOps(def.operationNames); + const result = await createSavedFilter({ + name: def.name, + description: def.description, + visibility: def.visibility, + insightsFilter: { + operationHashes: ops, + ...(def.clientFilters ? { clientFilters: def.clientFilters } : {}), + ...(def.dateRange ? { dateRange: def.dateRange } : {}), + }, + }); + if (result.ok) { + createdFilters.push({ name: def.name, id: result.ok.savedFilter.id, views: def.views }); + console.log( + ` Created: "${def.name}" (${result.ok.savedFilter.id}) — ${ops.length} ops, ${def.visibility}`, + ); + } else { + console.error(` Failed to create "${def.name}"`); + } + } + + // Track views to populate view counts + console.log(`👁️ Tracking views for ${createdFilters.length} filters...`); + for (const filter of createdFilters) { + for (let i = 0; i < filter.views; i++) { + await trackSavedFilterView(filter.id); + } + console.log(` "${filter.name}": ${filter.views} views`); + } + + console.log(` +✅ Seed complete! + +Credentials: + Email: ${ownerEmail} + Password: ${password} + +Navigate to: + http://localhost:3000/${organization.slug}/${project.slug}/${target.slug}/insights + http://localhost:3000/${organization.slug}/${project.slug}/${target.slug}/insights/manage-filters +`); +} + +main().catch(err => { + console.error('Seed failed:', err); + process.exit(1); +});