Feature/insights filters [CONSOLE-1607] (#7662)

This commit is contained in:
Jonathan Brennan 2026-02-25 04:43:52 -06:00 committed by GitHub
parent fd1aef16e2
commit cefdb600bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 9020 additions and 1196 deletions

View file

@ -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
}
}
}
}
`);

View file

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

View file

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

View file

@ -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');
});
});
});

View file

@ -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",

View file

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

View file

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

View file

@ -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')]
: []),

View file

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

View file

@ -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<typeof AuditLogModel>;

View file

@ -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'),

View file

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

View file

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

View file

@ -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<TargetSelector, 'targetId'>) {
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,
});
}

View file

@ -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<number> {
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<number> {
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<DurationMetrics> {
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);
}

View file

@ -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,
});
},
};

View file

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

View file

@ -148,6 +148,19 @@ export const permissionGroups: Array<PermissionGroup> = [
},
],
},
{
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',

View file

@ -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],
});

View file

@ -0,0 +1,5 @@
import type { SavedFilter } from '../../shared/entities';
export type SavedFilterMapper = SavedFilter & {
orgId?: string;
};

View file

@ -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!
}
`;

View file

@ -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<SavedFilter | null> {
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<SavedFilter> {
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<SavedFilter | null> {
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<string | null> {
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<void> {
await this.pool.query(sql`/* incrementSavedFilterViews */
UPDATE
"saved_filters"
SET
"views_count" = "views_count" + 1
WHERE
"id" = ${args.id}
`);
}
}

View file

@ -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<SavedFilter | null> {
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);
}
}

View file

@ -0,0 +1,43 @@
import { SavedFiltersProvider } from '../../providers/saved-filters.provider';
import type { MutationResolvers } from './../../../../__generated__/types';
export const createSavedFilter: NonNullable<MutationResolvers['createSavedFilter']> = 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,
},
};
};

View file

@ -0,0 +1,24 @@
import { SavedFiltersProvider } from '../../providers/saved-filters.provider';
import type { MutationResolvers } from './../../../../__generated__/types';
export const deleteSavedFilter: NonNullable<MutationResolvers['deleteSavedFilter']> = 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,
},
};
};

View file

@ -0,0 +1,24 @@
import { SavedFiltersProvider } from '../../providers/saved-filters.provider';
import type { MutationResolvers } from './../../../../__generated__/types';
export const trackSavedFilterView: NonNullable<MutationResolvers['trackSavedFilterView']> = 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,
},
};
};

View file

@ -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<MutationResolvers['updateSavedFilter']> = 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,
},
},
};
};

View file

@ -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);
},
};

View file

@ -0,0 +1,6 @@
import type { SavedFilterConnectionResolvers } from './../../../__generated__/types';
export const SavedFilterConnection: SavedFilterConnectionResolvers = {
edges: connection => connection.edges,
pageInfo: connection => connection.pageInfo,
};

View file

@ -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,
},
});
},
};

View file

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

View file

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

View file

@ -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<Promise<unknown>> = [];

View file

@ -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';

View file

@ -0,0 +1,3 @@
.ladle-background {
background: hsl(var(--color-neutral-2)) !important;
}

View file

@ -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",

View file

@ -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 (
<AutoSizer disableHeight>
@ -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',

View file

@ -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 (
<div className="flex items-center gap-3 p-8">
<Checkbox checked={checked} onCheckedChange={setChecked} />
<span className="text-neutral-11 text-sm">{checked ? 'Checked' : 'Unchecked'}</span>
</div>
);
};
export const Sizes: Story = () => {
const [values, setValues] = useState({ sm: false, md: true });
return (
<div className="flex items-center gap-6 p-8">
{(['sm', 'md'] as const).map(size => (
<div key={size} className="flex items-center gap-2">
<Checkbox
checked={values[size]}
onCheckedChange={v => setValues(prev => ({ ...prev, [size]: v }))}
size={size}
/>
<span className="text-neutral-11 text-sm">{size}</span>
</div>
))}
</div>
);
};
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 (
<div className="flex flex-col gap-3 p-8">
<div className="flex items-center gap-2">
<Checkbox checked={allChecked} indeterminate={indeterminate} onCheckedChange={toggleAll} />
<span className="text-neutral-11 text-sm font-medium">Select all</span>
</div>
<div className="ml-4 flex flex-col gap-2">
{items.map((checked, i) => (
<div key={i} className="flex items-center gap-2">
<Checkbox
size="sm"
checked={checked}
onCheckedChange={v =>
setItems(prev => prev.map((item, idx) => (idx === i ? !!v : item)))
}
/>
<span className="text-neutral-11 text-sm">Item {i + 1}</span>
</div>
))}
</div>
</div>
);
};
export const Disabled: Story = () => (
<div className="flex items-center gap-6 p-8">
<div className="flex items-center gap-2">
<Checkbox disabled checked={false} />
<span className="text-neutral-8 text-sm">Disabled unchecked</span>
</div>
<div className="flex items-center gap-2">
<Checkbox disabled checked />
<span className="text-neutral-8 text-sm">Disabled checked</span>
</div>
</div>
);

View file

@ -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<BaseCheckbox.Root.Props, 'children' | 'className'> &
VariantProps<typeof checkboxVariants> & {
/** 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 (
<BaseCheckbox.Root
className={checkboxVariants({ size, variant })}
{...(visual
? {
tabIndex: -1,
'aria-hidden': true,
style: { cursor: 'default' as const },
onCheckedChange: undefined,
}
: {})}
{...props}
>
<BaseCheckbox.Indicator className="flex items-center justify-center text-current">
{props.indeterminate ? (
<Minus className={iconClass} strokeWidth={3} />
) : (
<Check className={iconClass} strokeWidth={3} />
)}
</BaseCheckbox.Indicator>
</BaseCheckbox.Root>
);
}

View file

@ -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<HTMLDivElement>(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<Set<string>>(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 (
<div role="group">
<FilterListSearch label={label} onSearch={setSearch} value={search} />
{/* Note about unavailable items */}
{items.some(item => item.unavailable) && (
<div className="text-neutral-11 mt-2 px-4 py-1 text-xs">
<span className="line-through">Struck-through</span> items are not found in the selected
date range.
</div>
)}
{/* Item list */}
{filteredItems.length > 0 ? (
<div
ref={scrollRef}
className="[&>div>div>div>*]:mt-0!"
style={{
height: listHeight,
overflow: 'auto',
scrollbarColor: 'var(--color-neutral-7) transparent',
scrollbarWidth: 'thin',
}}
>
<div style={{ height: virtualizer.getTotalSize() }}>
<div
style={{
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`,
}}
>
{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 (
<div key={getKey(item)} style={{ height: virtualItem.size }}>
<ItemRow
item={item}
selected={selected}
indeterminate={hasPartialValues}
onToggle={toggleItem}
selection={selection}
onValuesChange={updateItemValues}
valuesLabel={valuesLabel}
unavailable={item.unavailable}
/>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="text-neutral-10 pb-2 pt-4 text-center text-[13px] italic">
No items found
</div>
)}
</div>
);
}

View file

@ -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<FilterDropdownProps, 'onChange' | 'onRemove'>) {
const [value, setValue] = useState<FilterSelection[]>(initialValue);
return (
<div className="p-8">
<div className="mb-6">
<div className="text-neutral-8 mb-2 text-xs font-medium uppercase tracking-wider">
Active filters
</div>
{value.length === 0 ? (
<div className="text-neutral-8 text-sm">No filters active</div>
) : (
<ul className="space-y-1 text-sm">
{value.map(selection => (
<li key={selection.name} className="text-neutral-11">
<span className="text-neutral-12 font-medium">{selection.name}</span>
{' — '}
{selection.values === null ? (
<span className="text-neutral-8 italic">all {valuesLabel}</span>
) : (
<span className="text-neutral-9">{selection.values.join(', ')}</span>
)}
</li>
))}
</ul>
)}
</div>
<FilterDropdown
items={items}
label={label}
onChange={setValue}
onRemove={() => setValue([])}
selectedItems={value}
valuesLabel={valuesLabel}
/>
</div>
);
}
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 = () => (
<StoryWrapper label="Client" items={mockClients} selectedItems={[]} valuesLabel="versions" />
);
export const WithSelections: Story = () => (
<StoryWrapper
label="Client"
items={mockClients}
valuesLabel="versions"
selectedItems={[
{ name: 'Hive CLI', values: ['0.12.1', '0.12.3'] },
{ name: 'hive-gateway', values: null },
]}
/>
);
export const CustomLabel: Story = () => (
<StoryWrapper
label="Region"
items={[
{ name: 'US East', values: ['us-east-1', 'us-east-2'] },
{ name: 'US West', values: ['us-west-1', 'us-west-2'] },
{ name: 'EU', values: ['eu-west-1', 'eu-central-1', 'eu-north-1'] },
{ name: 'Asia Pacific', values: ['ap-southeast-1', 'ap-northeast-1'] },
{ name: 'Global', values: [] },
]}
selectedItems={[]}
valuesLabel="zones"
/>
);
export const SubPanel: Story = () => {
const [selectedValues, setSelectedValues] = useState<string[] | null>(null);
const values = ['0.12.0', '0.12.1', '0.12.2', '0.12.3', '0.12.4', '0.12.5'];
return (
<div className="p-8">
<BaseMenu.Root open modal={false}>
<div
className={cn(
'w-56 rounded-md border p-2 shadow-md',
'bg-neutral-1 border-neutral-4 dark:bg-neutral-4 dark:border-neutral-5',
)}
>
<ValuesSubPanel
itemName="Hive CLI"
values={values}
selectedValues={selectedValues}
onValuesChange={setSelectedValues}
valuesLabel="versions"
/>
</div>
</BaseMenu.Root>
<pre className="text-neutral-9 mt-4 text-xs">{JSON.stringify(selectedValues, null, 2)}</pre>
</div>
);
};
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<FilterSelection[]>([]);
const [operationSelections, setOperationSelections] = useState<FilterSelection[]>([]);
const allSelections = [
...operationSelections.map(s => ({ ...s, category: 'Operation' })),
...clientSelections.map(s => ({ ...s, category: 'Client' })),
];
return (
<div className="p-8">
<div className="mb-6">
<div className="text-neutral-8 mb-2 text-xs font-medium uppercase tracking-wider">
Active filters
</div>
{allSelections.length === 0 ? (
<div className="text-neutral-8 text-sm">No filters active</div>
) : (
<ul className="space-y-1 text-sm">
{allSelections.map(selection => (
<li key={`${selection.category}:${selection.name}`} className="text-neutral-11">
<span className="text-neutral-9">{selection.category}:</span>{' '}
<span className="text-neutral-12 font-medium">{selection.name}</span>
{selection.values !== null && selection.values.length > 0 && (
<>
{' — '}
<span className="text-neutral-9">{selection.values.join(', ')}</span>
</>
)}
</li>
))}
</ul>
)}
</div>
<InsightsFilters
clientFilterItems={mockClients}
clientFilterSelections={clientSelections}
operationFilterItems={mockOperations}
operationFilterSelections={operationSelections}
setClientSelections={setClientSelections}
setOperationSelections={setOperationSelections}
privateSavedFilterViews={[
{
id: '1',
name: 'My production filter',
viewerCanUpdate: true,
filters: { operationHashes: [], clientFilters: [], dateRange: null },
},
]}
sharedSavedFilterViews={[]}
onApplySavedFilters={() => {}}
/>
</div>
);
};

View file

@ -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 (
<Menu
trigger={
<TriggerButton
accessoryInformation={selectedCount > 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={[
<FilterContent
key="content"
label={label}
items={items}
selectedItems={selectedItems}
onChange={onChange}
valuesLabel={valuesLabel}
/>,
<MenuItem
key="remove"
variant="destructiveAction"
onClick={() => {
onRemove();
setOpen(false);
}}
>
Remove filter
</MenuItem>,
]}
/>
);
}

View file

@ -0,0 +1,26 @@
type FilterListSearchProps = {
label: string;
onSearch: (value: string) => void;
value: string;
};
export function FilterListSearch({ label, onSearch, value }: FilterListSearchProps) {
return (
<div className="relative -mx-2">
<input
type="text"
role="searchbox"
aria-label={`Search ${label.toLowerCase()}`}
placeholder="Search..."
value={value}
onChange={e => 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"
/>
</div>
);
}

View file

@ -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 (
<span
className={`flex-1 truncate ${unavailable ? 'text-neutral-8 line-through' : ''}`}
title={name}
>
{name}
</span>
);
}
export const ItemRow = memo(function ItemRow({
item,
selected,
indeterminate,
onToggle,
selection,
onValuesChange,
valuesLabel,
unavailable,
}: ItemRowProps) {
const hasValues = item.values.length > 0;
if (!hasValues) {
return (
<MenuItem closeOnClick={false} onClick={() => onToggle(item)}>
<Checkbox checked={selected} indeterminate={indeterminate} size="sm" visual />
<ItemName name={item.name} unavailable={unavailable} />
</MenuItem>
);
}
return (
<Menu
trigger={
<MenuItem onClick={() => onToggle(item)}>
<Checkbox checked={selected} indeterminate={indeterminate} size="sm" visual />
<ItemName name={item.name} unavailable={unavailable} />
</MenuItem>
}
openOnHover
delay={100}
closeDelay={150}
sections={[
<ValuesSubPanel
key="values"
itemName={item.name}
values={item.values}
selectedValues={selected ? (selection?.values ?? null) : []}
onValuesChange={values => onValuesChange(item, values)}
valuesLabel={valuesLabel}
/>,
]}
/>
);
});

View file

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

View file

@ -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 (
<div>
<FilterListSearch
label={`Search ${valuesLabel} for ${itemName}`}
onSearch={setSearch}
value={search}
/>
{/* Values list */}
<div className="max-h-64 overflow-y-auto [scrollbar-color:var(--color-neutral-7)_transparent] [scrollbar-width:thin]">
{/* All values toggle */}
<MenuItem closeOnClick={false} onClick={toggleAllValues}>
<Checkbox checked={allSelected} size="sm" visual />
<span>All {valuesLabel}</span>
</MenuItem>
{/* Individual values */}
{filteredValues.map(val => {
const checked = isValueSelected(val);
return (
<MenuItem key={val} closeOnClick={false} onClick={() => toggleValue(val)}>
<Checkbox checked={checked} size="sm" visual />
<span className="truncate">{val}</span>
</MenuItem>
);
})}
{filteredValues.length === 0 && (
<div className="text-neutral-8 px-2 py-4 text-center text-sm">No {valuesLabel} found</div>
)}
</div>
</div>
);
}

View file

@ -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 <MenuItem disabled>{emptyMessage}</MenuItem>;
}
return (
<>
{savedFilters.map(savedFilter => (
<MenuItem key={savedFilter.id} onClick={() => onApplySavedFilters(savedFilter)}>
{savedFilter.name}
</MenuItem>
))}
</>
);
}
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 (
<Menu
trigger={
<TriggerButton
label={activeViewName ?? 'Filter'}
variant="default"
rightIcon={
activeViewName && onClearActiveView
? {
icon: X,
action: onClearActiveView,
label: 'Clear active view',
withSeparator: true,
}
: { icon: ListFilter, withSeparator: true }
}
/>
}
open={open}
onOpenChange={setOpen}
modal={false}
lockScroll
side="bottom"
align="start"
sections={[
[
<Menu
key="operations"
trigger={<MenuItem>Operations</MenuItem>}
maxWidth="lg"
stableWidth
sections={[
<FilterContent
key="content"
label="operations"
items={operationFilterItems}
selectedItems={operationFilterSelections}
onChange={setOperationSelections}
/>,
]}
/>,
<Menu
key="clients"
trigger={<MenuItem>Clients</MenuItem>}
maxWidth="lg"
stableWidth
sections={[
<FilterContent
key="content"
label="clients"
items={clientFilterItems}
selectedItems={clientFilterSelections}
onChange={setClientSelections}
valuesLabel="versions"
/>,
]}
/>,
],
[
<Menu
key="private"
trigger={<MenuItem>My saved filters</MenuItem>}
sections={[
<SavedFiltersList
key="list"
savedFilters={privateSavedFilterViews}
emptyMessage="No saved private views"
onApplySavedFilters={handleApplySavedFilter}
/>,
]}
/>,
<Menu
key="shared"
trigger={<MenuItem>Shared saved filters</MenuItem>}
sections={[
<SavedFiltersList
key="list"
savedFilters={sharedSavedFilterViews}
emptyMessage="No saved shared views"
onApplySavedFilters={handleApplySavedFilter}
/>,
]}
/>,
],
<MenuItem key="manage" variant="navigationLink" onClick={onManageSavedFilters}>
Manage saved filters
</MenuItem>,
]}
/>
);
}

View file

@ -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 = () => (
<Flex>
<Menu
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
Open Menu
</button>
}
sections={[
[
<MenuItem key="settings" onClick={() => console.log('settings')}>
<Settings className="mr-2 size-4" />
Settings
</MenuItem>,
<MenuItem key="docs" onClick={() => console.log('docs')}>
<FileText className="mr-2 size-4" />
Documentation
</MenuItem>,
],
<MenuItem key="logout" onClick={() => console.log('logout')}>
<LogOut className="mr-2 size-4" />
Log out
</MenuItem>,
]}
/>
</Flex>
);
export const WithSubmenu: Story = () => (
<Flex>
<Menu
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
User Menu
</button>
}
align="start"
sections={[
[
<Menu
key="org"
trigger={
<MenuItem>
<Users className="mr-2 size-4" />
Switch organization
</MenuItem>
}
sections={[
[
<MenuItem key="acme" active>
acme-corp
</MenuItem>,
<MenuItem key="personal">personal</MenuItem>,
<MenuItem key="test">test-org</MenuItem>,
],
<MenuItem key="create">
<Plus className="mr-2 size-4" />
Create organization
</MenuItem>,
]}
/>,
<MenuItem key="settings">
<Settings className="mr-2 size-4" />
Profile settings
</MenuItem>,
],
<MenuItem key="logout">
<LogOut className="mr-2 size-4" />
Log out
</MenuItem>,
]}
/>
</Flex>
);
export const WithCheckboxItems: Story = () => {
const [selected, setSelected] = useState<Set<string>>(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 (
<Flex>
<Menu
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
Languages ({selected.size})
</button>
}
sections={[
items.map(item => (
<MenuItem key={item} closeOnClick={false} onClick={() => toggle(item)}>
<Checkbox checked={selected.has(item)} size="sm" visual />
<span className="ml-2">{item}</span>
</MenuItem>
)),
]}
/>
</Flex>
);
};

View file

@ -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<SubmenuTriggerContextValue>(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<typeof menuItemVariants>['variant'];
},
) {
return menuItemVariants({
variant,
highlighted: state.highlighted,
disabled: state.disabled,
active: active ?? false,
});
}
function renderSections(sections: Array<ReactNode | ReactNode[]>): 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(
<div key={`sep-${keyCounter++}`} role="separator" className="bg-neutral-5 my-2 h-px" />,
);
}
for (const item of filtered) {
result.push(<Fragment key={`item-${keyCounter++}`}>{item}</Fragment>);
}
}
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<ResizeObserver | null>(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<ReactNode | ReactNode[]>;
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 = (
<MenuDepthContext.Provider value={contentDepth}>
{renderSections(sections)}
</MenuDepthContext.Provider>
);
if (isNested) {
return (
<BaseMenu.SubmenuRoot>
<SubmenuTriggerContext.Provider value={{ openOnHover, delay, closeDelay }}>
{trigger}
</SubmenuTriggerContext.Provider>
<BaseMenu.Portal>
<BaseMenu.Positioner
side={resolvedSide}
align={resolvedAlign}
sideOffset={resolvedSideOffset}
className="outline-none"
>
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth })}>
{popupContent}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.SubmenuRoot>
);
}
return (
<BaseMenu.Root open={open} onOpenChange={onOpenChange} modal={modal}>
<BaseMenu.Trigger render={trigger} />
<BaseMenu.Portal>
<BaseMenu.Positioner
side={resolvedSide}
align={resolvedAlign}
sideOffset={resolvedSideOffset}
className="outline-none"
>
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth })}>
{popupContent}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
// --- MenuItem ---
type MenuItemProps = Omit<BaseMenu.Item.Props, 'className'> & {
active?: boolean;
variant?: VariantProps<typeof menuItemVariants>['variant'];
};
function MenuItem({ active, variant, children, ...props }: MenuItemProps) {
const submenuTriggerCtx = useContext(SubmenuTriggerContext);
if (submenuTriggerCtx) {
return (
<BaseMenu.SubmenuTrigger
className={(state: BaseMenu.SubmenuTrigger.State) =>
menuItemClassName(state, { active, variant })
}
openOnHover={submenuTriggerCtx.openOnHover}
delay={submenuTriggerCtx.delay}
closeDelay={submenuTriggerCtx.closeDelay}
{...(props as Omit<BaseMenu.SubmenuTrigger.Props, 'className'>)}
>
{children}
<ChevronRight className="ml-auto size-3.5" />
</BaseMenu.SubmenuTrigger>
);
}
return (
<BaseMenu.Item
className={(state: BaseMenu.Item.State) => menuItemClassName(state, { active, variant })}
{...(props as Omit<BaseMenu.Item.Props, 'className'>)}
>
{children}
{variant === 'navigationLink' && <ArrowRight className="ml-1 size-3.5" />}
</BaseMenu.Item>
);
}
export { Menu, MenuItem };

View file

@ -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 = () => (
<div className="flex items-center justify-center p-16">
<Popover
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
Open popover
</button>
}
content={<p className="text-neutral-11 text-sm">This is a popover panel.</p>}
/>
</div>
);
export const WithArrow: Story = () => (
<Flex>
{(['top', 'right', 'bottom', 'left'] as const).map(side => (
<Popover
key={side}
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
{side}
</button>
}
content={<p className="text-neutral-11 text-sm">Arrow on {side}.</p>}
side={side}
arrow
/>
))}
</Flex>
);
export const Controlled: Story = () => {
const [open, setOpen] = useState(false);
return (
<div className="flex items-center gap-4 p-16">
<Popover
open={open}
onOpenChange={setOpen}
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
{open ? 'Close' : 'Open'}
</button>
}
content={
<div>
<p className="text-neutral-11 text-sm">Controlled popover.</p>
<button
onClick={() => setOpen(false)}
className="border-neutral-5 text-neutral-11 mt-2 rounded-sm border px-2 py-1 text-xs"
>
Close
</button>
</div>
}
/>
<span className="text-neutral-8 text-xs">State: {open ? 'open' : 'closed'}</span>
</div>
);
};
export const Placement: Story = () => (
<div className="flex items-center justify-center gap-4 p-32">
{(['top', 'right', 'bottom', 'left'] as const).map(side => (
<Popover
key={side}
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
{side}
</button>
}
content={<p className="text-neutral-11 text-sm">Placed on {side}.</p>}
side={side}
/>
))}
</div>
);
export const WithTitle: Story = () => {
const [open, setOpen] = useState(false);
return (
<div className="flex items-center justify-center p-16">
<Popover
open={open}
onOpenChange={setOpen}
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
Open form popover
</button>
}
title="Save to filter collections"
content={
<div className="space-y-3">
<input
className="border-neutral-5 bg-neutral-3 text-neutral-12 w-full rounded-md border px-3 py-1.5 text-sm"
placeholder="Name this filter collection"
/>
<button className="bg-accent text-neutral-1 w-full rounded-md px-3 py-1.5 text-sm">
Save filter
</button>
</div>
}
/>
</div>
);
};
export const WithTitleAndDescription: Story = () => {
const [open, setOpen] = useState(false);
return (
<div className="flex items-center justify-center p-16">
<Popover
open={open}
onOpenChange={setOpen}
trigger={
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
Open confirmation popover
</button>
}
title="Update saved filter"
description="This will overwrite the current configuration with your current filter selections."
content={
<div className="flex gap-2">
<button className="bg-accent text-neutral-1 flex-1 rounded-md px-3 py-1.5 text-sm">
Update filter
</button>
<button
onClick={() => setOpen(false)}
className="border-neutral-5 text-neutral-11 flex-1 rounded-md border px-3 py-1.5 text-sm"
>
Cancel
</button>
</div>
}
/>
</div>
);
};

View file

@ -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<typeof popoverPopupVariants>['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 = (
<div className={cn(widthClass, 'p-4')}>
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium">{props.title}</span>
{!props.hideCloseButton && (
<button
onClick={() => onOpenChange?.(false)}
className="text-neutral-10 hover:text-neutral-12 rounded-sm p-0.5"
aria-label="Close"
>
<X className="size-4" />
</button>
)}
</div>
{props.description && <p className="text-neutral-11 mb-3 text-sm">{props.description}</p>}
{props.content}
</div>
);
} else {
inner = props.content;
}
return (
<BasePopover.Root open={open} onOpenChange={onOpenChange}>
<BasePopover.Trigger render={trigger} />
<BasePopover.Portal>
<BasePopover.Positioner
side={side}
align={align}
sideOffset={sideOffset}
className="outline-none"
>
<BasePopover.Popup className={popoverPopupVariants({ variant })}>
{arrow && <PopoverArrow />}
{inner}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
</BasePopover.Root>
);
}
function PopoverArrow() {
return (
<BasePopover.Arrow
className={cn(
'group',
'data-[side=bottom]:bottom-[calc(100%-2px)]',
'data-[side=top]:top-[calc(100%-2px)]',
'data-[side=left]:left-[calc(100%-8px)]',
'data-[side=right]:right-[calc(100%-8px)]',
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="8"
fill="none"
viewBox="0 0 20 8"
className={cn(
'block',
'group-data-[side=top]:rotate-180',
'group-data-[side=left]:rotate-90',
'group-data-[side=right]:-rotate-90',
)}
>
<path
className="fill-neutral-2 dark:fill-neutral-4"
d="M9.664.602 4.808 4.973A4 4 0 0 1 2.132 6H0v2h20V6h-1.465a4 4 0 0 1-2.676-1.027L11.002.603a1 1 0 0 0-1.338 0"
/>
<path
className="fill-neutral-5"
d="M10.333 1.345 5.477 5.716A5 5 0 0 1 2.132 7H0V6h2.132a4 4 0 0 0 2.676-1.027L9.664.603a1 1 0 0 1 1.338 0l4.857 4.37A4 4 0 0 0 18.535 6H20v1h-1.465a5 5 0 0 1-3.345-1.284z"
/>
</svg>
</BasePopover.Arrow>
);
}

View file

@ -0,0 +1,9 @@
// Helpful layout utils for stories
type ChildrenProp = {
children: React.ReactNode;
};
export function Flex({ children }: ChildrenProp) {
return <div className="flex items-center justify-center gap-4 p-32">{children}</div>;
}

View file

@ -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 = () => (
<Flex>
<TriggerButton label="Last 7 days" rightIcon={{ icon: ChevronDown, withSeparator: true }} />
<TriggerButton label="Filter" rightIcon={{ icon: ListFilter, withSeparator: true }} />
<TriggerButton label="Filter" rightIcon={{ icon: ListFilter, withSeparator: false }} />
</Flex>
);
export const Active: Story = () => (
<Flex>
<TriggerButton
label="Client"
accessoryInformation="2"
variant="active"
rightIcon={{ icon: ChevronDown, withSeparator: true }}
/>
<TriggerButton
label="Operation"
accessoryInformation="O9SwSomeOperationName"
variant="default"
rightIcon={{
icon: X,
action: () => alert('Cleared!'),
label: 'Clear filter',
withSeparator: true,
}}
/>
</Flex>
);
export const Action: Story = () => (
<Flex>
<TriggerButton label="Save this filter view" variant="action" />
<TriggerButton
label="Save this filter view"
variant="action"
rightIcon={{ icon: ChevronDown, withSeparator: false }}
/>
</Flex>
);
export const IconOnly: Story = () => (
<Flex>
<TriggerButton layout="iconOnly" icon={RefreshCw} aria-label="Refresh" />
<TriggerButton layout="iconOnly" icon={RefreshCw} aria-label="Refresh" variant="active" />
<TriggerButton layout="iconOnly" icon={RefreshCw} aria-label="Refresh" variant="action" />
</Flex>
);
export const WithMenu: Story = () => {
const [count, setCount] = useState(0);
return (
<Flex>
<Menu
trigger={
<TriggerButton
accessoryInformation={count > 0 ? count.toString() : undefined}
label="Client"
variant={count > 0 ? 'active' : 'default'}
rightIcon={{ icon: ChevronDown, withSeparator: true }}
/>
}
align="start"
sections={[
[
<MenuItem key="add" onClick={() => setCount(c => c + 1)}>
Add selection
</MenuItem>,
<MenuItem key="clear" onClick={() => setCount(0)}>
Clear
</MenuItem>,
],
]}
/>
</Flex>
);
};

View file

@ -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<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'style'> &
VariantProps<typeof triggerButtonVariants> & {
/** 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<HTMLButtonElement, TriggerButtonProps>(
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<string, unknown>;
delete domProps.layout;
delete domProps.label;
delete domProps.rightIcon;
delete domProps.accessoryInformation;
delete domProps.icon;
return (
<button
ref={ref}
className={triggerButtonVariants({ variant })}
disabled={disabled}
style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
{...domProps}
>
{props.layout === 'iconOnly' ? (
<span className="flex items-center p-1.5">
<props.icon className="size-4" />
</span>
) : (
<>
<span className="px-3 py-1.5 text-[13px]">{props.label}</span>
{props.accessoryInformation != null && (
<span className={`${separatorClass} px-3 py-1.5`}>{props.accessoryInformation}</span>
)}
{props.rightIcon && (
<span
role={props.rightIcon.action ? 'button' : undefined}
tabIndex={props.rightIcon.action ? 0 : undefined}
aria-label={props.rightIcon.label ?? undefined}
onPointerDown={
props.rightIcon.action
? e => {
e.stopPropagation();
e.preventDefault();
}
: undefined
}
onClick={
props.rightIcon.action
? e => {
e.stopPropagation();
props.rightIcon!.action!();
}
: undefined
}
onKeyDown={
props.rightIcon.action
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
props.rightIcon!.action!();
}
}
: undefined
}
className={`${props.rightIcon.withSeparator && separatorClass} text-neutral-8 ${props.rightIcon.action ? 'hover:text-neutral-11' : 'group-hover:text-neutral-12'} flex items-center px-2 py-1.5 transition-colors`}
>
<props.rightIcon.icon className="size-4" />
</span>
)}
</>
)}
</button>
);
},
);

View file

@ -246,6 +246,7 @@ export const TargetLayout = ({
projectSlug: props.projectSlug,
targetSlug: props.targetSlug,
}}
search={{}}
>
Insights
</Link>

View file

@ -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<typeof OperationsFilter_OperationStatsValuesConnectionFragment>
| 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<string[]>(() =>
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<HTMLInputElement>) => {
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<ComponentType<ListChildComponentProps>>(
({ index, style }) => {
const operation = visibleOperations[index].node;
const clientOpStats = clientFilteredOperations?.edges.find(
e => e.node.operationHash === operation.operationHash,
)?.node;
return (
<OperationRow
style={style}
key={operation.id}
operationStats={operation}
clientOperationStats={clientFilteredOperations == null ? false : clientOpStats}
selected={selectedItems.includes(operation.operationHash || '')}
onSelect={onSelect}
/>
);
},
[visibleOperations, selectedItems, onSelect, clientFilteredOperations],
);
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="w-[500px] sm:max-w-none">
<SheetHeader>
<SheetTitle>Filter by operation</SheetTitle>
</SheetHeader>
<div className="flex h-full flex-col space-y-3 py-4">
<Input
size="medium"
placeholder="Search for operation..."
onChange={onChange}
value={searchTerm}
onClear={() => {
setSearchTerm('');
setVisibleOperations(operations.edges);
}}
/>
<div className="flex w-full items-center gap-2">
<Button variant="link" onClick={selectAll}>
All
</Button>
<Button variant="link" onClick={selectNone}>
None
</Button>
<Button className="ml-auto" onClick={selectAll}>
Reset
</Button>
<Button
variant="primary"
disabled={selectedItems.length === 0}
onClick={() => {
onFilter(selectedItems);
onClose();
}}
>
Save
</Button>
</div>
<div className="grow pl-1">
{clientFilteredOperations && (
<div className="text-neutral-8 text-right text-xs">
<span className="text-neutral-10">selected</span> / all clients
</div>
)}
<AutoSizer>
{({ height, width }) =>
!height || !width ? (
<></>
) : (
<FixedSizeList
height={height}
width={width}
itemCount={visibleOperations.length}
itemSize={24}
overscanCount={5}
>
{renderRow}
</FixedSizeList>
)
}
</AutoSizer>
</div>
</div>
</SheetContent>
</Sheet>
);
}
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 <Spinner />;
}
const { target } = query.data;
return (
<OperationsFilter
operationStatsConnection={target.operationsStats.operations}
clientOperationStatsConnection={target.clientOperationStats?.operations}
selected={selected}
isOpen={isOpen}
onClose={onClose}
onFilter={hashes => {
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<typeof OperationRow_OperationStatsValuesFragment>;
/** Stats for the operation filtered by the selected clients */
clientOperationStats?:
| FragmentType<typeof OperationRow_OperationStatsValuesFragment>
| 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 (
<div className="text-neutral-10 flex shrink-0 text-right">
<span>{clientsRequests === '-' ? 0 : clientsRequests}</span>
<span className="text-neutral-8 ml-1 truncate">/ {requests}</span>
</div>
);
}
return <div className="text-neutral-8 shrink-0 text-right">{requests}</div>;
};
return (
<div style={style} className="flex items-center gap-4 truncate">
<Checkbox checked={selected} onCheckedChange={change} id={hash} />
<label
htmlFor={hash}
className="flex w-full cursor-pointer items-center justify-between gap-4 overflow-hidden"
>
<span className="grow overflow-hidden text-ellipsis">{operation.name}</span>
<Totals />
</label>
</div>
);
}
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 (
<>
<Button variant="outline" className="bg-neutral-2" onClick={toggle}>
<span>Operations ({selected?.length || 'all'})</span>
<FilterIcon className="ml-2 size-4" />
</Button>
<OperationsFilterContainer
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
isOpen={isOpen}
onClose={toggle}
period={period}
selected={selected}
onFilter={onFilter}
clientNames={clientNames}
/>
</>
);
}
const ClientRow_ClientStatsValuesFragment = graphql(`
fragment ClientRow_ClientStatsValuesFragment on ClientStatsValues {
name
count
}
`);
function ClientRow({
selected,
onSelect,
style,
...props
}: {
client: FragmentType<typeof ClientRow_ClientStatsValuesFragment>;
clientOperationStats:
| FragmentType<typeof ClientRow_ClientStatsValuesFragment>
| 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 (
<div className="text-neutral-10 flex shrink-0 text-right">
<span>{clientOperation?.count ?? 0}</span>
<span className="text-neutral-8 ml-1 truncate">/ {requests}</span>
</div>
);
}
return <div className="text-neutral-8 shrink-0 text-right">{requests}</div>;
};
return (
<div style={style} className="flex items-center gap-4 truncate">
<Checkbox checked={selected} onCheckedChange={change} id={hash} />
<label
htmlFor={hash}
className="flex w-full cursor-pointer items-center justify-between gap-4 overflow-hidden"
>
<span className="grow overflow-hidden text-ellipsis">{client.name}</span>
<Totals />
</label>
</div>
);
}
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<typeof ClientsFilter_ClientStatsValuesConnectionFragment>;
operationStatsConnection?:
| FragmentType<typeof ClientsFilter_ClientStatsValuesConnectionFragment>
| undefined;
selected?: string[];
}): ReactElement {
const clientConnection = useFragment(
ClientsFilter_ClientStatsValuesConnectionFragment,
clientStatsConnection,
);
function getClientNames() {
return clientConnection.edges.map(edge => edge.node.name);
}
const [selectedItems, setSelectedItems] = useState<string[]>(() =>
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<HTMLInputElement>) => {
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<ComponentType<ListChildComponentProps>>(
({ index, style }) => {
const client = visibleOperations[index].node;
const operationStats =
operationConnection == null
? false
: operationConnection.edges.find(e => e.node.name === client.name)?.node;
return (
<ClientRow
style={style}
key={client.name}
client={client}
clientOperationStats={operationStats}
selected={selectedItems.includes(client.name || '')}
onSelect={onSelect}
/>
);
},
[visibleOperations, selectedItems, onSelect, operationConnection],
);
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="w-[500px] sm:max-w-none">
<SheetHeader>
<SheetTitle>Filter by client</SheetTitle>
</SheetHeader>
<div className="flex h-full flex-col space-y-3 py-4">
<Input
size="medium"
placeholder="Search for operation..."
onChange={onChange}
value={searchTerm}
onClear={() => {
setSearchTerm('');
setVisibleOperations(clientConnection.edges);
}}
/>
<div className="flex w-full items-center gap-2">
<Button variant="link" onClick={selectAll}>
All
</Button>
<Button variant="link" onClick={selectNone}>
None
</Button>
<Button className="ml-auto" onClick={selectAll}>
Reset
</Button>
<Button
variant="primary"
disabled={selectedItems.length === 0}
onClick={() => {
onFilter(selectedItems);
onClose();
}}
>
Save
</Button>
</div>
<div className="grow pl-1">
{operationStatsConnection && (
<div className="text-neutral-8 text-right text-xs">
<span className="text-neutral-10">selected</span> / all operations
</div>
)}
<AutoSizer>
{({ height, width }) =>
!height || !width ? (
<></>
) : (
<FixedSizeList
height={height}
width={width}
itemCount={visibleOperations.length}
itemSize={24}
overscanCount={5}
>
{renderRow}
</FixedSizeList>
)
}
</AutoSizer>
</div>
</div>
</SheetContent>
</Sheet>
);
}
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 <Spinner />;
}
const allClients = query.data.target?.operationsStats?.clients.edges ?? [];
return (
<ClientsFilter
clientStatsConnection={query.data.target.operationsStats.clients}
operationStatsConnection={query.data.target.filteredOperationStats?.clients}
selected={selected}
isOpen={isOpen}
onClose={onClose}
onFilter={clientNames => {
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 (
<>
<Button variant="outline" className="bg-neutral-2" onClick={toggle}>
<span>Clients ({selected?.length || 'all'})</span>
<FilterIcon className="ml-2 size-4" />
</Button>
<ClientsFilterContainer
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
isOpen={isOpen}
onClose={toggle}
period={period}
selected={selected}
selectedOperationIds={selectedOperationIds}
onFilter={onFilter}
/>
</>
);
}

View file

@ -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}
</Link>
@ -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<string | null>(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 (
<OperationsFallback

View file

@ -16,8 +16,8 @@ import { useQuery } from 'urql';
import { Section } from '@/components/common';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CHART_PRIMARY_COLOR } from '@/constants';
import { FragmentType, graphql, useFragment } from '@/gql';
import { OperationStatsFilterInput } from '@/gql/graphql';
import { createAdaptiveTimeFormatter } from '@/lib/date-time';
import {
formatDuration,
@ -270,7 +270,7 @@ function OverTimeStats({
const { failuresOverTime = [], requestsOverTime = [] } =
useFragment(OverTimeStats_OperationsStatsFragment, operationStats) ?? {};
const styles = useChartStyles();
const { styles, colors } = useChartStyles();
const requests = useMemo(() => {
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<typeof LatencyOverTimeStats_OperationStatsFragment> | 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<typeof RpmOverTimeStats_OperationStatsFragment> | 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<string>;
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;

View file

@ -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 (
<UpdateFilterButton
activeView={activeView}
currentFilters={currentFilters}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
onUpdated={onUpdated}
/>
);
}
if (viewerCanCreate) {
return (
<SaveFilterPopover
viewerCanShare={viewerCanShare}
currentFilters={currentFilters}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
onSaved={onSaved}
/>
);
}
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>(
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 (
<Popover
open={open}
onOpenChange={setOpen}
align="start"
title="Save to filter collections"
trigger={<TriggerButton label="Save this filter view" variant="action" />}
content={
<div className="space-y-3">
<div>
<Input
placeholder="Name this filter collection"
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && name.trim()) {
void handleSave();
}
}}
/>
</div>
<div>
<Select
value={visibility}
onValueChange={v => setVisibility(v as SavedFilterVisibilityType)}
>
<SelectTrigger variant="inset">
<SelectValue placeholder="Save location" />
</SelectTrigger>
<SelectContent variant="inset">
<SelectItem value={SavedFilterVisibilityType.Private}>My views</SelectItem>
{viewerCanShare && (
<SelectItem value={SavedFilterVisibilityType.Shared}>Shared views</SelectItem>
)}
</SelectContent>
</Select>
</div>
<Button
variant="primary"
className="w-full"
onClick={() => void handleSave()}
disabled={!name.trim() || createResult.fetching}
>
Save filter
</Button>
</div>
}
/>
);
}

View file

@ -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 (
<Popover
open={confirmOpen}
onOpenChange={setConfirmOpen}
align="start"
title="Update saved filter"
description={`This will overwrite the current configuration of "${activeView.name}" with your current filter selections.`}
trigger={<TriggerButton label={`Update "${activeView.name}" filter`} variant="action" />}
content={
<div className="flex gap-2">
<Button
variant="primary"
className="flex-1"
onClick={() => void handleUpdate()}
disabled={updateResult.fetching}
>
Update filter
</Button>
<Button variant="outline" className="flex-1" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
</div>
}
/>
);
}

View file

@ -15,7 +15,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'bg-neutral-1.01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
'bg-neutral-1_01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
className,
)}
{...props}

View file

@ -0,0 +1,130 @@
import { useState } from 'react';
import type { Story, StoryDefault } from '@ladle/react';
import { DateRangePicker, presetLast1Day, presetLast7Days, type Preset } from './date-range-picker';
export default {
title: 'UI / DateRangePicker',
} satisfies StoryDefault;
/** Default with all presets and Last 7 days selected (most common usage) */
export const Default: Story = () => {
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast7Days.range);
return (
<DateRangePicker
selectedRange={selectedRange}
onUpdate={args => setSelectedRange(args.preset.range)}
/>
);
};
/** With Last 24 hours selected (used in operation detail views) */
export const Last24Hours: Story = () => {
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast1Day.range);
return (
<DateRangePicker
selectedRange={selectedRange}
onUpdate={args => setSelectedRange(args.preset.range)}
/>
);
};
/** With restricted valid units — excludes minutes (used in operation insights) */
export const RestrictedUnits: Story = () => {
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast7Days.range);
return (
<DateRangePicker
validUnits={['y', 'M', 'w', 'd', 'h']}
selectedRange={selectedRange}
onUpdate={args => 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<Preset['range']>(presetLast7Days.range);
const startDate = new Date();
startDate.setFullYear(startDate.getFullYear() - 1);
return (
<DateRangePicker
validUnits={['y', 'M', 'w', 'd', 'h', 'm']}
selectedRange={selectedRange}
startDate={startDate}
align="end"
onUpdate={args => 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<Preset['range']>(absoluteRange);
return (
<DateRangePicker
selectedRange={selectedRange}
onUpdate={args => 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<Preset['range']>(customPresets[0].range);
return (
<DateRangePicker
presets={customPresets}
selectedRange={selectedRange}
onUpdate={args => setSelectedRange(args.preset.range)}
/>
);
};
/** With no initial selection */
export const NoSelection: Story = () => {
const [selectedRange, setSelectedRange] = useState<Preset['range'] | null>(null);
return (
<DateRangePicker
selectedRange={selectedRange}
onUpdate={args => 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<Preset['range']>(presetLast1Day.range);
return (
<DateRangePicker
selectedRange={selectedRange}
startDate={startDate}
onUpdate={args => setSelectedRange(args.preset.range)}
/>
);
};

View file

@ -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 {
</Button>
);
},
[props.startDate],
[props.startDate, props.onClose],
);
const dynamicPresets = useMemo(() => {
@ -341,190 +386,226 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
return [];
}, [quickRangeFilter, validUnits]);
return (
<div className="flex h-[380px]">
<Popover modal open={showCalendar} onOpenChange={setShowCalendar}>
<PopoverAnchor asChild>
<div className="flex flex-col py-2">
<div className="flex flex-col items-center justify-end gap-2 lg:flex-row lg:items-start">
<div className="flex flex-col gap-1 pl-3">
<div className="mb-2 mt-1 text-sm">Absolute date range</div>
<div className="space-y-2">
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="from" className="text-neutral-10 text-xs">
From
</Label>
<div className="flex w-full max-w-sm items-center space-x-2">
<div className="relative flex w-full">
<Input
type="text"
id="from"
value={fromValue}
onChange={ev => {
setFromValue(ev.target.value);
}}
className="font-mono text-xs"
/>
<Button
variant="ghost"
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
onClick={() => setShowCalendar(true)}
>
<CalendarDays className="size-3.5" />
</Button>
</div>
</div>
<div className="text-red-500">
{hasInvalidUnitRegex?.test(fromValue) ? (
<>Only allowed units are {validUnits.join(', ')}</>
) : !fromParsed ? (
<>Invalid date string</>
) : null}
</div>
</div>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="to" className="text-neutral-10 text-xs">
To
</Label>
<div className="flex w-full max-w-sm items-center space-x-2">
<div className="relative flex w-full">
<Input
type="text"
id="to"
value={toValue}
onChange={ev => {
setToValue(ev.target.value);
}}
className="font-mono text-xs"
/>
<Button
variant="ghost"
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
onClick={() => setShowCalendar(true)}
>
<CalendarDays className="size-3.5" />
</Button>
</div>
</div>
<div className="text-red-500">
{hasInvalidUnitRegex?.test(toValue) ? (
<>Only allowed units are {validUnits.join(', ')}</>
) : !toParsed ? (
<>Invalid date string</>
) : fromParsed && toParsed && fromParsed.getTime() > toParsed.getTime() ? (
<div className="text-red-500">To cannot be before from.</div>
) : null}
</div>
</div>
<Button
variant="primary"
className="w-full text-center"
onClick={() => {
const fromWithoutWhitespace = fromValue.trim();
const toWithoutWhitespace = toValue.trim();
const resolvedRange = resolveRange(fromValue, toValue);
if (resolvedRange) {
setActivePreset(
() =>
findMatchingPreset(
{
from: fromWithoutWhitespace,
to: toWithoutWhitespace,
},
availablePresets,
) ?? {
name: `${fromWithoutWhitespace}_${toWithoutWhitespace}`,
label: buildDateRangeString(resolvedRange),
range: { from: fromWithoutWhitespace, to: toWithoutWhitespace },
},
);
setShowCalendar(false);
setQuickRangeFilter('');
props.onClose?.();
}
}}
disabled={
!toParsed ||
!fromParsed ||
(activePreset?.range.from === fromValue.trim() &&
activePreset.range.to === toValue.trim())
}
>
Apply date range
</Button>
</div>
</div>
</div>
</div>
</PopoverAnchor>
<PopoverContent side="left" sideOffset={4} collisionPadding={8} className="w-auto">
<Button
variant="ghost"
size="icon-sm"
className="absolute right-2 top-1 rounded-sm bg-transparent opacity-70 transition-opacity hover:bg-transparent hover:opacity-100 focus:outline-none"
onClick={() => setShowCalendar(false)}
>
<Cross1Icon className="size-2" />
</Button>
<Calendar
id="selectedRange"
mode="range"
defaultMonth={subMonths(new Date(), 1)}
numberOfMonths={2}
selected={range}
onSelect={range => {
if (range?.from && range.to) {
setFromValue(formatDateToString(range.from));
setToValue(formatDateToString(endOfDay(range.to)));
}
setRange(range);
}}
disabled={disabledDays}
/>
</PopoverContent>
</Popover>
<div className="ml-3 flex flex-col gap-1 border-l py-2 pl-3 pr-2">
<div className="relative flex items-center">
<MagnifyingGlassIcon className="absolute left-2" />
<Input
placeholder="Filter quick ranges"
className="w-full pl-7"
value={quickRangeFilter}
onChange={ev => setQuickRangeFilter(ev.target.value)}
/>
</div>
<div className="flex w-full flex-1 flex-col items-start gap-1 overflow-y-scroll pb-2 pt-1">
{dynamicPresets.length > 0
? dynamicPresets
.filter(preset =>
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
)
.map(preset => <PresetButton key={preset.name} preset={preset} />)
: staticPresets
.filter(preset =>
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
)
.map(preset => <PresetButton key={preset.name} preset={preset} />)}
</div>
</div>
</div>
);
}
/** 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 (
<Popover
modal
open={isOpen}
onOpenChange={(open: boolean) => {
if (!open) {
resetValues();
}
setIsOpen(open);
}}
>
<PopoverTrigger asChild>
<Button variant="outline">
{activePreset?.label}
<div className="-mr-2 scale-125 pl-1 opacity-60">
{isOpen ? <ChevronUpIcon width={24} /> : <ChevronDownIcon width={24} />}
</div>
</Button>
</PopoverTrigger>
<PopoverContent align={props.align} className="mt-1 flex h-[380px] w-auto p-0">
<div className="flex flex-col py-2">
<div className="flex flex-col items-center justify-end gap-2 lg:flex-row lg:items-start">
<div className="flex flex-col gap-1 pl-3">
<div className="mb-2 text-sm">Absolute date range</div>
<div className="space-y-2">
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="from" className="text-neutral-10 text-xs">
From
</Label>
<div className="flex w-full max-w-sm items-center space-x-2">
<div className="relative flex w-full">
<Input
type="text"
id="from"
value={fromValue}
onChange={ev => {
setFromValue(ev.target.value);
}}
className="font-mono"
/>
<Button
variant="ghost"
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
onClick={() => setShowCalendar(true)}
>
<CalendarDays className="size-3.5" />
</Button>
</div>
</div>
<div className="text-red-500">
{hasInvalidUnitRegex?.test(fromValue) ? (
<>Only allowed units are {validUnits.join(', ')}</>
) : !fromParsed ? (
<>Invalid date string</>
) : null}
</div>
</div>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="to" className="text-neutral-10 text-xs">
To
</Label>
<div className="flex w-full max-w-sm items-center space-x-2">
<div className="relative flex w-full">
<Input
type="text"
id="to"
value={toValue}
onChange={ev => {
setToValue(ev.target.value);
}}
className="font-mono"
/>
<Button
variant="ghost"
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
onClick={() => setShowCalendar(true)}
>
<CalendarDays className="size-3.5" />
</Button>
</div>
</div>
<div className="text-red-500">
{hasInvalidUnitRegex?.test(toValue) ? (
<>Only allowed units are {validUnits.join(', ')}</>
) : !toParsed ? (
<>Invalid date string</>
) : fromParsed && toParsed && fromParsed.getTime() > toParsed.getTime() ? (
<div className="text-red-500">To cannot be before from.</div>
) : null}
</div>
</div>
<Button
className="w-full text-center"
onClick={() => {
const fromWithoutWhitespace = fromValue.trim();
const toWithoutWhitespace = toValue.trim();
const resolvedRange = resolveRange(fromValue, toValue);
if (resolvedRange) {
setActivePreset(
() =>
findMatchingPreset(
{
from: fromWithoutWhitespace,
to: toWithoutWhitespace,
},
availablePresets,
) ?? {
name: `${fromWithoutWhitespace}_${toWithoutWhitespace}`,
label: buildDateRangeString(resolvedRange),
range: { from: fromWithoutWhitespace, to: toWithoutWhitespace },
},
);
setIsOpen(false);
setShowCalendar(false);
setQuickRangeFilter('');
}
}}
disabled={
!toParsed ||
!fromParsed ||
(activePreset?.range.from === fromValue.trim() &&
activePreset.range.to === toValue.trim())
}
>
Apply date range
</Button>
</div>
{props.trigger ? (
<PopoverTrigger asChild>{props.trigger}</PopoverTrigger>
) : (
<PopoverTrigger asChild>
<Button variant="outline">
{label}
<div className="-mr-2 scale-125 pl-1 opacity-60">
{isOpen ? <ChevronUpIcon width={24} /> : <ChevronDownIcon width={24} />}
</div>
</div>
</div>
<div className="ml-3 flex flex-col gap-1 border-l py-2 pl-3 pr-2">
<div className="relative flex items-center">
<MagnifyingGlassIcon className="absolute left-2" />
<Input
placeholder="Filter quick ranges"
className="w-full pl-7"
value={quickRangeFilter}
onChange={ev => setQuickRangeFilter(ev.target.value)}
/>
</div>
<div className="flex w-full flex-1 flex-col items-start gap-1 overflow-y-scroll pb-2 pt-1">
{dynamicPresets.length > 0
? dynamicPresets
.filter(preset =>
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
)
.map(preset => <PresetButton key={preset.name} preset={preset} />)
: staticPresets
.filter(preset =>
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
)
.map(preset => <PresetButton key={preset.name} preset={preset} />)}
</div>
</div>
{showCalendar && (
<div className="absolute left-0 top-[4px] -translate-x-full">
<div className="bg-neutral-4 mr-1 rounded-md border p-4">
<Button
variant="ghost"
size="icon-sm"
className="absolute right-2 top-1 rounded-sm bg-transparent opacity-70 transition-opacity hover:bg-transparent hover:opacity-100 focus:outline-none"
onClick={() => setShowCalendar(false)}
>
<Cross1Icon className="size-2" />
</Button>
<Calendar
id="selectedRange"
mode="range"
defaultMonth={subMonths(new Date(), 1)}
numberOfMonths={2}
selected={range}
onSelect={range => {
if (range?.from && range.to) {
setFromValue(formatDateToString(range.from));
setToValue(formatDateToString(endOfDay(range.to)));
}
setRange(range);
}}
disabled={disabledDays}
/>
</div>
</div>
)}
</Button>
</PopoverTrigger>
)}
<PopoverContent align={props.align} side={props.side} className="mt-1 w-auto p-0">
<DateRangePickerPanel
presets={props.presets}
selectedRange={props.selectedRange}
onUpdate={props.onUpdate}
startDate={props.startDate}
validUnits={props.validUnits}
onClose={() => setIsOpen(false)}
/>
</PopoverContent>
</Popover>
);

View file

@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'bg-neutral-1.01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
'bg-neutral-1_01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
className,
)}
{...props}

File diff suppressed because it is too large Load diff

View file

@ -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 };

View file

@ -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<typeof SelectPrimitive.Content> &
VariantProps<typeof selectContentVariants>;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
SelectContentProps
>(({ className, children, position = 'popper', variant, ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'border-neutral-4 bg-neutral-3 dark:bg-neutral-4 dark:border-neutral-5 animate-in fade-in-80 relative z-50 min-w-[8rem] cursor-pointer overflow-hidden rounded-md border shadow-md',
selectContentVariants({ variant }),
position === 'popper' && 'translate-y-1',
className,
)}

View file

@ -19,7 +19,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'bg-neutral-1.01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
'bg-neutral-1_01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
className,
)}
{...props}

View file

@ -0,0 +1,83 @@
import type { Story, StoryDefault } from '@ladle/react';
import {
Toast,
ToastAction,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from './toast';
export default {
title: 'Old / Toast',
} satisfies StoryDefault;
function ToastDemo({
variant,
title,
description,
action,
}: {
variant?: 'default' | 'success' | 'destructive' | 'warning';
title: string;
description?: string;
action?: boolean;
}) {
return (
<ToastProvider>
<Toast variant={variant} open forceMount>
<div className="grid gap-1">
<ToastTitle>{title}</ToastTitle>
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action && <ToastAction altText="Undo">Undo</ToastAction>}
<ToastClose />
</Toast>
<ToastViewport />
</ToastProvider>
);
}
export const Default: Story = () => (
<ToastDemo title="Default toast" description="This is a default toast message." />
);
export const Success: Story = () => (
<ToastDemo variant="success" title="Success" description="The saved filter has been updated." />
);
export const Destructive: Story = () => (
<ToastDemo
variant="destructive"
title="Error"
description="Something went wrong. Please try again."
/>
);
export const Warning: Story = () => (
<ToastDemo
variant="warning"
title="Warning"
description="This action may have unintended consequences."
/>
);
export const WithAction: Story = () => (
<ToastDemo title="Filter deleted" description="The saved filter has been deleted." action />
);
export const TitleOnly: Story = () => <ToastDemo title="Changes saved" />;
export const AllVariants: Story = () => (
<div className="flex flex-col gap-4">
<ToastDemo title="Default" description="This is the default variant." />
<ToastDemo variant="success" title="Success" description="This is the success variant." />
<ToastDemo
variant="destructive"
title="Destructive"
description="This is the destructive variant."
/>
<ToastDemo variant="warning" title="Warning" description="This is the warning variant." />
</div>
);

View file

@ -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',
},

View file

@ -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<HTMLDivElement>(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 (
<FixedSizeList
width="100%"
height={maxHeight}
itemCount={children.length}
itemSize={height}
initialScrollOffset={initialOffset}
>
{({ index, style }) => <div style={style}>{children[index]}</div>}
</FixedSizeList>
<div ref={scrollRef} style={{ maxHeight, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{children[virtualItem.index]}
</div>
))}
</div>
</div>
);
}

View file

@ -1,3 +1 @@
export const LAST_VISITED_ORG_KEY = 'lastVisitedOrganization_v2';
export const CHART_PRIMARY_COLOR = 'rgb(234, 179, 8)';

View file

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

View file

@ -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,
};

View file

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

View file

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

View file

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

View file

@ -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',

View file

@ -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',

View file

@ -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<typeof ManageFilters_SavedFiltersQuery>['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 (
<>
<TableRow className="cursor-pointer" onClick={onToggleExpand}>
<TableCell className="w-8">
<ChevronIcon className="text-neutral-10 size-4" />
</TableCell>
<TableCell
className="font-medium"
onClick={isRenaming ? e => e.stopPropagation() : undefined}
>
{isRenaming ? (
<span className="flex items-center gap-2">
<Input
value={renameValue}
onChange={e => 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"
/>
<Button
variant="primary"
size="sm"
onClick={() => void handleRename()}
disabled={
updateResult.fetching || renameValue.trim() === filter.name || !renameValue.trim()
}
>
Save
</Button>
</span>
) : (
filter.name
)}
</TableCell>
<TableCell>{filter.viewsCount.toLocaleString()}</TableCell>
<TableCell>{formatDate(filter.createdAt, 'MMM d, yyyy')}</TableCell>
<TableCell>{formatDate(filter.updatedAt, 'MMM d, yyyy')}</TableCell>
<TableCell>
<span className="flex items-center gap-1.5">
{filter.visibility === SavedFilterVisibilityType.Shared ? (
<>
<Users className="text-neutral-10 size-4" />
Shared
</>
) : (
<>
<Lock className="text-neutral-10 size-4" />
Private
</>
)}
</span>
</TableCell>
<TableCell
className="w-12 text-right"
onClick={e => {
e.stopPropagation();
}}
>
{(filter.viewerCanUpdate || filter.viewerCanDelete) && (
<Menu
trigger={
<Button variant="ghost" className="flex size-8 p-0">
<MoreVertical className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
}
align="end"
sections={[
[
<MenuItem
key="view"
render={
<Link
to="/$organizationSlug/$projectSlug/$targetSlug/insights"
params={{ organizationSlug, projectSlug, targetSlug }}
search={{
operations:
filter.filters.operationHashes.length > 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
</MenuItem>,
filter.viewerCanUpdate && (
<MenuItem
key="rename"
onClick={() => {
setRenameValue(filter.name);
setIsRenaming(true);
}}
>
Rename
</MenuItem>
),
filter.viewerCanDelete && (
<MenuItem key="delete" variant="destructiveAction" onClick={handleDelete}>
Delete
</MenuItem>
),
],
]}
/>
)}
</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={7} className="px-10 py-4">
<SavedFilterRowFilters
filter={filter}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
dataRetentionInDays={dataRetentionInDays}
/>
</TableCell>
</TableRow>
)}
</>
);
}
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<Preset>(() => {
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<string, string>();
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<FilterItem[]>(() => {
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<FilterSelection[]>(
() =>
operationHashes.map(hash => ({
id: hash,
name: hashToNameMap.get(hash) ?? hash,
values: null,
})),
[operationHashes, hashToNameMap],
);
const savedClientSelections = useMemo<FilterSelection[]>(
() =>
clientFilters.map(c => ({
name: c.name,
values: c.versions?.length ? [...c.versions] : null,
})),
[clientFilters],
);
// Mutable selections (initialized from saved data)
const [operationSelections, setOperationSelections] =
useState<FilterSelection[]>(savedOperationSelections);
const [clientSelections, setClientSelections] =
useState<FilterSelection[]>(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 (
<div>
<div className="flex flex-wrap items-center gap-2">
<DateRangePicker
trigger={
<TriggerButton
label={selectedPreset.label}
variant="default"
rightIcon={{ icon: ChevronDown, withSeparator: true }}
/>
}
selectedRange={dateRange}
onUpdate={({ preset }) => setDateRange(preset.range)}
startDate={startDate}
validUnits={['y', 'M', 'w', 'd', 'h']}
align="start"
/>
{showOperationFilter && operationItems.length > 0 && (
<FilterDropdown
label="Operation"
items={operationItems}
selectedItems={operationSelections}
onChange={setOperationSelections}
onRemove={() => {
setShowOperationFilter(false);
setOperationSelections([]);
}}
disabled={loading}
/>
)}
{showClientFilter && clientItems.length > 0 && (
<FilterDropdown
label="Client"
items={clientItems}
selectedItems={clientSelections}
onChange={setClientSelections}
onRemove={() => {
setShowClientFilter(false);
setClientSelections([]);
}}
valuesLabel="versions"
disabled={loading}
/>
)}
{loading && <Spinner />}
</div>
{filter.viewerCanUpdate && (
<div className="mt-3 flex gap-2">
<Button
variant={hasChanges ? 'primary' : 'default'}
size="sm"
onClick={handleSave}
disabled={updateResult.fetching || !hasChanges}
>
Save changes
</Button>
<Button
variant="default"
size="sm"
onClick={handleCancel}
disabled={updateResult.fetching || !hasChanges}
>
Cancel
</Button>
</div>
)}
</div>
);
}
function ManageFiltersContent(props: {
organizationSlug: string;
projectSlug: string;
targetSlug: string;
}) {
const [expandedRows, setExpandedRows] = useState<Set<string>>(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 (
<QueryError
organizationSlug={props.organizationSlug}
error={query.error}
showLogoutButton={false}
/>
);
}
if (!query.data?.target) {
return (
<div className="flex h-fit flex-1 items-center justify-center py-28">
<Spinner />
</div>
);
}
if (edges.length === 0) {
return (
<div className="py-8">
<EmptyList
title="No saved filters"
description="You haven't created any saved filters yet. Create filters from the Insights page to save and share them."
/>
</div>
);
}
return (
<>
<div className="mt-6 grid grid-cols-3 gap-4">
<StatCard label="Total Filters" value={stats.total} />
<StatCard label="Shared Filters" value={stats.shared} />
<StatCard label="Total Views" value={stats.totalViews} />
</div>
<div className="mt-8">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Name</TableHead>
<TableHead>Views</TableHead>
<TableHead>Created</TableHead>
<TableHead>Modified</TableHead>
<TableHead>Visibility</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{edges.map(edge => (
<SavedFilterRow
key={edge.node.id}
filter={edge.node}
expanded={expandedRows.has(edge.node.id)}
onToggleExpand={() => toggleRow(edge.node.id)}
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
targetSlug={props.targetSlug}
dataRetentionInDays={dataRetentionInDays}
/>
))}
</TableBody>
</Table>
</div>
</>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-neutral-10 text-sm font-medium">{label}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
</CardContent>
</Card>
);
}
export function TargetInsightsManageFiltersPage(props: {
organizationSlug: string;
projectSlug: string;
targetSlug: string;
}): ReactElement {
return (
<>
<Meta title="Manage saved filters" />
<TargetLayout
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
targetSlug={props.targetSlug}
page={Page.Insights}
>
<div className="py-6">
<Link
to="/$organizationSlug/$projectSlug/$targetSlug/insights"
params={{
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
targetSlug: props.targetSlug,
}}
search={{}}
className="text-neutral-10 hover:text-neutral-12 mb-4 inline-flex items-center gap-1 text-sm transition-colors"
>
<ArrowLeft className="size-4" />
Back to Insights
</Link>
<Title>Manage saved filters</Title>
<Subtitle>View and manage your saved filter views</Subtitle>
</div>
<ManageFiltersContent {...props} />
</TargetLayout>
</>
);
}

View file

@ -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<string[]>('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({
</div>
{!result.fetching && isNotNoQueryOrMutation === false && (
<div className="flex justify-end gap-x-2">
<ClientsFilterTrigger
period={dateRangeController.resolvedRange}
selected={selectedClients}
selectedOperationIds={[operationHash]}
onFilter={setSelectedClients}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
/>
<DateRangePicker
validUnits={['y', 'M', 'w', 'd', 'h']}
selectedRange={dateRangeController.selectedPreset.range}
@ -124,8 +112,7 @@ function OperationView({
targetSlug={targetSlug}
period={dateRangeController.resolvedRange}
dateRangeText={dateRangeController.selectedPreset.label}
operationsFilter={operationsList}
clientNamesFilter={selectedClients}
filter={operationFilter}
mode="operation-page"
resolution={dateRangeController.resolution}
/>

View file

@ -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<typeof InsightsFilterSearch>;
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<string[]>(
'operations',
[],
);
const [selectedClients, setSelectedClients] = useSearchParamsFilter<string[]>('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<string, string>();
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 (
<>
<div className="flex flex-row items-center justify-between py-6">
<div className="py-6">
<div>
<Title>Insights</Title>
<Subtitle>Observe GraphQL requests and see how the API is consumed.</Subtitle>
</div>
<div className="flex justify-end gap-x-4">
<OperationsFilterTrigger
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
period={dateRangeController.resolvedRange}
selected={selectedOperations}
onFilter={setSelectedOperations}
clientNames={selectedClients}
/>
<ClientsFilterTrigger
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
period={dateRangeController.resolvedRange}
selected={selectedClients}
selectedOperationIds={selectedOperations}
onFilter={setSelectedClients}
/>
<DateRangePicker
validUnits={['y', 'M', 'w', 'd', 'h']}
selectedRange={dateRangeController.selectedPreset.range}
startDate={dateRangeController.startDate}
align="end"
onUpdate={args => dateRangeController.setSelectedPreset(args.preset)}
/>
<Button variant="outline" onClick={() => dateRangeController.refreshResolvedRange()}>
<RefreshCw className="size-4" />
</Button>
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center gap-x-2">
<InsightsFilters
operationFilterItems={operationFilterItems}
operationFilterSelections={operationFilterSelections}
clientFilterItems={clientFilterItems}
clientFilterSelections={clientFilterSelections}
privateSavedFilterViews={privateSavedFilterViews}
sharedSavedFilterViews={sharedSavedFilterViews}
onApplySavedFilters={handleApplySavedFilter}
activeViewId={search.viewId}
onClearActiveView={() => {
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,
}),
});
}}
/>
<DateRangePicker
trigger={
<TriggerButton
label={dateRangeController.selectedPreset.label}
variant="default"
rightIcon={{ icon: ChevronDown, withSeparator: true }}
/>
}
selectedRange={dateRangeController.selectedPreset.range}
onUpdate={args => dateRangeController.setSelectedPreset(args.preset)}
startDate={dateRangeController.startDate}
validUnits={['y', 'M', 'w', 'd', 'h']}
align="start"
/>
{operationFilterSelections.length > 0 && (
<FilterDropdown
label="Operation"
items={operationFilterItems}
selectedItems={operationFilterSelections}
onChange={selections => {
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 && (
<FilterDropdown
label="Client"
items={clientFilterItems}
selectedItems={clientFilterSelections}
valuesLabel="versions"
onChange={selections => {
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 && (
<SaveFilterButton
activeView={activeView}
viewerCanCreate={viewerCanCreate}
viewerCanShare={viewerCanShare}
currentFilters={{
operations: search.operations ?? [],
clients: (search.clients ?? []).map(c => ({
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' });
}}
/>
)}
</div>
<div className="flex items-center gap-x-2">
<TriggerButton
layout="iconOnly"
icon={RefreshCw}
aria-label="Refresh"
onClick={() => dateRangeController.refreshResolvedRange()}
/>
</div>
</div>
</div>
<OperationsStats
@ -82,8 +461,7 @@ function OperationsView({
projectSlug={projectSlug}
targetSlug={targetSlug}
period={dateRangeController.resolvedRange}
operationsFilter={selectedOperations}
clientNamesFilter={selectedClients}
filter={filter}
dateRangeText={dateRangeController.selectedPreset.label}
mode="operation-list"
resolution={dateRangeController.resolution}
@ -94,8 +472,7 @@ function OperationsView({
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
operationsFilter={selectedOperations}
clientNamesFilter={selectedClients}
filter={filter}
selectedPeriod={dateRangeController.selectedPreset.range}
/>
</>

View file

@ -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 (
<TargetInsightsManageFiltersPage
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
/>
);
},
});
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,

View file

@ -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: {}

906
scripts/seed-insights.mts Normal file
View file

@ -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<any>({
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 030 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<string> {
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); // 38 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<string, string>();
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<string, unknown>;
}) {
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);
});