Console 1903 is it possible to have a not rule in filters (#7768)

This commit is contained in:
Jonathan Brennan 2026-03-05 14:15:21 -06:00 committed by GitHub
parent 5eb8dee3ce
commit 6b22c3d9e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 882 additions and 254 deletions

View file

@ -0,0 +1,5 @@
---
'hive': patch
---
Enhanced Insights filters UI with include/exclude options

View file

@ -18,6 +18,8 @@ export const GetSavedFilterQuery = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
visibility
viewsCount
@ -93,6 +95,8 @@ export const CreateSavedFilterMutation = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
visibility
viewsCount
@ -129,6 +133,8 @@ export const UpdateSavedFilterMutation = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
visibility
viewsCount

View file

@ -1020,6 +1020,7 @@ export function initSeed() {
from: string,
to: string,
ttarget: TargetOverwrite = target,
filter: GraphQLSchema.OperationStatsFilterInput = {},
) {
const statsResult = await readOperationsStats(
{ byId: ttarget.id },
@ -1027,7 +1028,7 @@ export function initSeed() {
from,
to,
},
{},
filter,
ownerToken,
).then(r => r.expectNoGraphQLErrors());

View file

@ -431,4 +431,105 @@ describe('Saved Filters', () => {
expect(result.error?.message).toContain('Array must contain at most 100 element');
});
});
describe('Exclude filters', () => {
test.concurrent('create and retrieve saved filter with exclude fields', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createSavedFilter, getSavedFilter } = await createProject(ProjectType.Single);
const createResult = await createSavedFilter({
name: 'External Clients Only',
visibility: SavedFilterVisibilityType.Private,
insightsFilter: {
clientFilters: [{ name: 'internal-bot' }],
excludeOperations: false,
excludeClientFilters: true,
},
});
expect(createResult.error).toBeNull();
expect(createResult.ok?.savedFilter.filters.excludeOperations).toBe(false);
expect(createResult.ok?.savedFilter.filters.excludeClientFilters).toBe(true);
expect(createResult.ok?.savedFilter.filters.clientFilters).toEqual([
{ name: 'internal-bot', versions: null },
]);
// Verify persisted via fetch
const filterId = createResult.ok?.savedFilter.id!;
const fetched = await getSavedFilter({ filterId });
expect(fetched?.filters.excludeOperations).toBe(false);
expect(fetched?.filters.excludeClientFilters).toBe(true);
});
test.concurrent('update saved filter exclude fields', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createSavedFilter, updateSavedFilter, getSavedFilter } = await createProject(
ProjectType.Single,
);
// Create without exclude
const createResult = await createSavedFilter({
name: 'My Filter',
visibility: SavedFilterVisibilityType.Private,
insightsFilter: {
operationHashes: ['op1'],
excludeOperations: false,
excludeClientFilters: false,
},
});
expect(createResult.ok?.savedFilter.filters.excludeOperations).toBe(false);
expect(createResult.ok?.savedFilter.filters.excludeClientFilters).toBe(false);
const filterId = createResult.ok?.savedFilter.id!;
// Update to enable exclude
const updateResult = await updateSavedFilter({
filterId,
insightsFilter: {
operationHashes: ['op1'],
excludeOperations: true,
excludeClientFilters: false,
},
});
expect(updateResult.error).toBeNull();
expect(updateResult.ok?.savedFilter.filters.excludeOperations).toBe(true);
expect(updateResult.ok?.savedFilter.filters.excludeClientFilters).toBe(false);
// Verify persisted
const fetched = await getSavedFilter({ filterId });
expect(fetched?.filters.excludeOperations).toBe(true);
});
test.concurrent(
'existing filters without exclude fields default to false',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createSavedFilter, getSavedFilter } = await createProject(ProjectType.Single);
// Create without specifying exclude fields at all
const createResult = await createSavedFilter({
name: 'Legacy Filter',
visibility: SavedFilterVisibilityType.Private,
insightsFilter: {
operationHashes: ['op1'],
},
});
expect(createResult.error).toBeNull();
// Exclude fields should default to false
expect(createResult.ok?.savedFilter.filters.excludeOperations).toBe(false);
expect(createResult.ok?.savedFilter.filters.excludeClientFilters).toBe(false);
const fetched = await getSavedFilter({ filterId: createResult.ok?.savedFilter.id! });
expect(fetched?.filters.excludeOperations).toBe(false);
expect(fetched?.filters.excludeClientFilters).toBe(false);
},
);
});
});

View file

@ -3567,3 +3567,133 @@ test.concurrent('collect an operation from undefined client', async ({ expect })
}
`);
});
describe('exclude filters', () => {
test.concurrent(
'excludeOperations filters out matching operations from stats',
{ timeout: 30_000 },
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createTargetAccessToken, readOperationsStats, waitForOperationsCollected } =
await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
// Collect two different operations
const collectResult = await token.collectLegacyOperations([
{
timestamp: Date.now(),
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: { ok: true, duration: 200_000_000, errorsTotal: 0 },
metadata: { client: { name: 'web', version: '1.0' } },
},
{
timestamp: Date.now(),
operation: 'query me { me }',
operationName: 'me',
fields: ['Query', 'Query.me'],
execution: { ok: true, duration: 100_000_000, errorsTotal: 0 },
metadata: { client: { name: 'web', version: '1.0' } },
},
]);
expect(collectResult.status).toEqual(200);
await waitForOperationsCollected(2);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
// Without filter: both operations present
const allStats = await readOperationsStats(from, to);
expect(allStats.totalOperations).toBe(2);
// Get the hash of the 'ping' operation
const pingOp = allStats.operations.edges.find(e => e.node.name.endsWith('_ping'));
expect(pingOp).toBeDefined();
const pingHash = pingOp!.node.operationHash!;
// Include filter: only 'ping' operation
const includeStats = await readOperationsStats(from, to, undefined, {
operationIds: [pingHash],
excludeOperations: false,
});
expect(includeStats.totalOperations).toBe(1);
expect(includeStats.operations.edges[0].node.name).toContain('_ping');
// Exclude filter: everything except 'ping' → only 'me'
const excludeStats = await readOperationsStats(from, to, undefined, {
operationIds: [pingHash],
excludeOperations: true,
});
expect(excludeStats.totalOperations).toBe(1);
expect(excludeStats.operations.edges[0].node.name).toContain('_me');
},
);
test.concurrent(
'excludeClientVersionFilters filters out matching clients from stats',
{ timeout: 30_000 },
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createTargetAccessToken, readOperationsStats, waitForRequestsCollected } =
await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
// Collect operations from three different clients
const collectResult = await token.collectLegacyOperations([
{
timestamp: Date.now(),
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: { ok: true, duration: 200_000_000, errorsTotal: 0 },
metadata: { client: { name: 'web', version: '1.0' } },
},
{
timestamp: Date.now(),
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: { ok: true, duration: 200_000_000, errorsTotal: 0 },
metadata: { client: { name: 'internal-bot', version: '2.0' } },
},
{
timestamp: Date.now(),
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: { ok: true, duration: 200_000_000, errorsTotal: 0 },
metadata: { client: { name: 'mobile', version: '3.0' } },
},
]);
expect(collectResult.status).toEqual(200);
await waitForRequestsCollected(3);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
// Without filter: all 3 clients visible
const allStats = await readOperationsStats(from, to);
expect(allStats.clients.edges).toHaveLength(3);
// Include filter: only 'internal-bot' client
const includeStats = await readOperationsStats(from, to, undefined, {
clientVersionFilters: [{ clientName: 'internal-bot' }],
excludeClientVersionFilters: false,
});
expect(includeStats.clients.edges).toHaveLength(1);
expect(includeStats.clients.edges[0].node.name).toBe('internal-bot');
// Exclude filter: everything except 'internal-bot' → 'web' and 'mobile'
const excludeStats = await readOperationsStats(from, to, undefined, {
clientVersionFilters: [{ clientName: 'internal-bot' }],
excludeClientVersionFilters: true,
});
expect(excludeStats.clients.edges).toHaveLength(2);
const excludedClientNames = excludeStats.clients.edges.map(e => e.node.name).sort();
expect(excludedClientNames).toEqual(['mobile', 'web']);
},
);
});

View file

@ -37,6 +37,8 @@ export interface OperationsStatsMapper {
operations: readonly string[];
clients: readonly string[];
clientVersionFilters: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
}
export interface DurationValuesMapper {
avg: number | null;

View file

@ -42,6 +42,14 @@ export default gql`
More precise than clientNames - allows filtering to specific versions.
"""
clientVersionFilters: [ClientVersionFilterInput!] @tag(name: "public")
"""
When true, the operationIds filter is negated matching operations are excluded instead of included.
"""
excludeOperations: Boolean @tag(name: "public")
"""
When true, the clientVersionFilters filter is negated matching clients are excluded instead of included.
"""
excludeClientVersionFilters: Boolean @tag(name: "public")
}
extend type Target {

View file

@ -139,11 +139,15 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
} & TargetSelector) {
this.logger.info('Counting unique operations (period=%o, target=%s)', period, target);
await this.session.assertPerformAction({
@ -161,6 +165,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
}
@ -248,11 +254,15 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
} & Listify<TargetSelector, 'targetId'>) {
this.logger.info('Counting requests and failures (period=%o, target=%s)', period, target);
await this.session.assertPerformAction({
@ -271,6 +281,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
})
.then(r => r.total);
}
@ -327,11 +339,15 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
} & TargetSelector) {
this.logger.info('Counting failures (period=%o, target=%s)', period, target);
await this.session.assertPerformAction({
@ -349,6 +365,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
}
@ -445,12 +463,16 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
} & TargetSelector) {
this.logger.info('Reading operations stats (period=%o, target=%s)', period, target);
@ -470,6 +492,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
});
}
@ -584,6 +608,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
period: DateRange;
@ -591,6 +617,8 @@ export class OperationsManager {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
} & TargetSelector) {
this.logger.info(
@ -615,6 +643,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
});
}
@ -628,12 +658,16 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
period: DateRange;
resolution: number;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
} & TargetSelector) {
this.logger.info(
'Reading failures over time (period=%o, resolution=%s, target=%s)',
@ -657,6 +691,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
}
@ -669,12 +705,16 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
period: DateRange;
resolution: number;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
} & TargetSelector) {
this.logger.info(
'Reading duration over time (period=%o, resolution=%s, target=%s)',
@ -698,6 +738,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
}
@ -709,11 +751,15 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
} & TargetSelector) {
this.logger.info('Reading overall duration percentiles (period=%o, target=%s)', period, target);
await this.session.assertPerformAction({
@ -731,6 +777,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
}
@ -743,12 +791,16 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
} & TargetSelector) {
this.logger.info(
@ -772,6 +824,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
});
}
@ -784,12 +838,16 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
} & TargetSelector) {
this.logger.info('Counting unique clients (period=%o, target=%s)', period, target);
@ -808,6 +866,8 @@ export class OperationsManager {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
});
}

View file

@ -456,6 +456,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
target: string | readonly string[];
@ -463,6 +465,8 @@ export class OperationsReader {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
}): Promise<{
total: number;
@ -484,6 +488,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
extra: schemaCoordinate
? [
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
@ -521,16 +527,26 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
target: string;
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
}): Promise<number> {
return this.countRequests({ target, period, operations, clients, clientVersionFilters }).then(
r => r.notOk,
);
return this.countRequests({
target,
period,
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}).then(r => r.notOk);
}
async countUniqueDocuments({
@ -539,12 +555,16 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
target: string;
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
}): Promise<number> {
const query = this.pickAggregationByPeriod({
period,
@ -561,6 +581,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
)}`,
queryId: aggregation => `count_unique_documents_${aggregation}`,
@ -579,6 +601,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
target: string;
@ -586,6 +610,8 @@ export class OperationsReader {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
}): Promise<
Array<{
@ -613,6 +639,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
extra: schemaCoordinate
? [
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
@ -657,6 +685,8 @@ export class OperationsReader {
period,
operations,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
extra: schemaCoordinate
? [
sql`hash IN (SELECT hash FROM ${sql.raw('coordinates_' + query.queryType)} ${this.createFilter(
@ -811,6 +841,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
target: string;
@ -818,6 +850,8 @@ export class OperationsReader {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
}): Promise<
Array<{
@ -849,6 +883,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
extra: schemaCoordinate
? [
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
@ -1731,6 +1767,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
target: string;
@ -1739,6 +1777,8 @@ export class OperationsReader {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
}) {
const results = await this.getDurationAndCountOverTime({
@ -1748,6 +1788,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
});
@ -1764,6 +1806,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
target: string;
period: DateRange;
@ -1771,6 +1815,8 @@ export class OperationsReader {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
}) {
const result = await this.getDurationAndCountOverTime({
target,
@ -1779,6 +1825,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
return result.map(row => ({
@ -1794,11 +1842,15 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
target: string;
period: DateRange;
resolution: number;
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
operations?: readonly string[];
clients?: readonly string[];
}): Promise<
@ -1814,6 +1866,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
}
@ -1823,12 +1877,16 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}: {
target: string;
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
}): Promise<DurationMetrics> {
const result = await this.clickHouse.query<{
percentiles: [number, number, number, number];
@ -1840,7 +1898,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, clientVersionFilters })}
${this.createFilter({ target, period, operations, clients, clientVersionFilters, excludeOperations, excludeClientVersionFilters })}
`,
queryId: aggregation => `general_duration_percentiles_${aggregation}`,
timeout: 15_000,
@ -1857,9 +1915,13 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
target: string;
period: DateRange;
operations?: readonly string[];
@ -1884,6 +1946,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
extra: schemaCoordinate
? [
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
@ -1946,6 +2010,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
schemaCoordinate,
}: {
target: string;
@ -1954,6 +2020,8 @@ export class OperationsReader {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
schemaCoordinate?: string;
}) {
const interval = calculateTimeWindow({ period, resolution });
@ -1997,6 +2065,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
extra: schemaCoordinate
? [
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
@ -2290,6 +2360,8 @@ export class OperationsReader {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
extra = [],
skipWhere = false,
namespace,
@ -2299,6 +2371,8 @@ export class OperationsReader {
operations?: readonly string[];
clients?: readonly string[];
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
excludeOperations?: boolean;
excludeClientVersionFilters?: boolean;
extra?: SqlValue[];
skipWhere?: boolean;
namespace?: string;
@ -2323,7 +2397,11 @@ export class OperationsReader {
}
if (operations?.length) {
where.push(sql`(${columnPrefix}hash) IN (${sql.array(operations, 'String')})`);
if (excludeOperations) {
where.push(sql`(${columnPrefix}hash) NOT IN (${sql.array(operations, 'String')})`);
} else {
where.push(sql`(${columnPrefix}hash) IN (${sql.array(operations, 'String')})`);
}
}
if (clients?.length) {
@ -2340,7 +2418,11 @@ export class OperationsReader {
}
return sql`(${columnPrefix}client_name = ${clientName} AND ${columnPrefix}client_version IN (${sql.array(filter.versions, 'String')}))`;
});
where.push(sql`(${sql.join(versionConditions, ' OR ')})`);
if (excludeClientVersionFilters) {
where.push(sql`NOT (${sql.join(versionConditions, ' OR ')})`);
} else {
where.push(sql`(${sql.join(versionConditions, ' OR ')})`);
}
}
if (extra.length) {

View file

@ -12,6 +12,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
_,
{ injector },
@ -26,6 +28,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}),
operationsManager.readDetailedDurationMetrics({
organizationId: organization,
@ -35,6 +39,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
}),
]);
@ -64,7 +70,17 @@ export const OperationsStats: OperationsStatsResolvers = {
};
},
totalRequests: (
{ organization, project, target, period, operations, clients, clientVersionFilters },
{
organization,
project,
target,
period,
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
_,
{ injector },
) => {
@ -76,6 +92,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
},
totalFailures: (
@ -87,6 +105,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
_,
{ injector },
@ -99,6 +119,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
},
totalOperations: (
@ -110,6 +132,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
_,
{ injector },
@ -122,6 +146,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
},
requestsOverTime: (
@ -133,6 +159,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
{ resolution },
{ injector },
@ -146,6 +174,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
},
failuresOverTime: (
@ -157,6 +187,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
{ resolution },
{ injector },
@ -170,6 +202,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
},
durationOverTime: (
@ -181,6 +215,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
{ resolution },
{ injector },
@ -194,6 +230,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
},
clients: async (
@ -205,6 +243,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
_,
{ injector },
@ -217,6 +257,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
return {
@ -238,6 +280,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
},
_,
{ injector },
@ -250,6 +294,8 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
clientVersionFilters,
excludeOperations,
excludeClientVersionFilters,
});
},
};

View file

@ -90,6 +90,8 @@ export const Target: Pick<
clientName: f.clientName === 'unknown' ? '' : f.clientName,
versions: f.versions ? [...f.versions] : null,
})) ?? [],
excludeOperations: args.filter?.excludeOperations ?? false,
excludeClientVersionFilters: args.filter?.excludeClientVersionFilters ?? false,
};
},
schemaCoordinateStats: async (target, args, _ctx) => {

View file

@ -25,6 +25,8 @@ export const typeDefs = gql`
operationHashes: [String!]!
clientFilters: [ClientFilter!]!
dateRange: InsightsDateRange
excludeOperations: Boolean
excludeClientFilters: Boolean
}
type ClientFilter {
@ -78,6 +80,8 @@ export const typeDefs = gql`
operationHashes: [String!]
clientFilters: [ClientFilterInput!]
dateRange: InsightsDateRangeInput
excludeOperations: Boolean
excludeClientFilters: Boolean
}
input InsightsDateRangeInput {

View file

@ -33,6 +33,8 @@ const SavedFilterModel = zod.object({
to: zod.string(),
})
.nullable(),
excludeOperations: zod.boolean().optional().default(false),
excludeClientFilters: zod.boolean().optional().default(false),
}),
visibility: zod.enum(['private', 'shared']),
viewsCount: zod.number(),

View file

@ -38,6 +38,8 @@ const InsightsFilterConfigurationModel = zod.object({
})
.nullish()
.transform(v => v ?? null),
excludeOperations: zod.boolean().optional().default(false),
excludeClientFilters: zod.boolean().optional().default(false),
});
// Transform GraphQL uppercase enum values to lowercase for database storage
@ -155,6 +157,8 @@ export class SavedFiltersProvider {
operationHashes?: string[] | null;
clientFilters?: Array<{ name: string; versions?: string[] | null }> | null;
dateRange?: { from: string; to: string } | null;
excludeOperations?: boolean;
excludeClientFilters?: boolean;
} | null;
},
): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> {
@ -211,6 +215,8 @@ export class SavedFiltersProvider {
versions: cf.versions ?? null,
})) ?? [],
dateRange: data.insightsFilter?.dateRange ?? null,
excludeOperations: data.insightsFilter?.excludeOperations ?? false,
excludeClientFilters: data.insightsFilter?.excludeClientFilters ?? false,
};
const savedFilter = await this.savedFiltersStorage.createSavedFilter({
@ -250,6 +256,8 @@ export class SavedFiltersProvider {
operationHashes?: string[] | null;
clientFilters?: Array<{ name: string; versions?: string[] | null }> | null;
dateRange?: { from: string; to: string } | null;
excludeOperations?: boolean;
excludeClientFilters?: boolean;
} | null;
},
): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> {
@ -328,6 +336,8 @@ export class SavedFiltersProvider {
versions: cf.versions ?? null,
})) ?? [],
dateRange: data.insightsFilter.dateRange ?? null,
excludeOperations: data.insightsFilter.excludeOperations ?? false,
excludeClientFilters: data.insightsFilter.excludeClientFilters ?? false,
}
: null;

View file

@ -23,6 +23,8 @@ export const createSavedFilter: NonNullable<MutationResolvers['createSavedFilter
dateRange: input.insightsFilter.dateRange
? { from: input.insightsFilter.dateRange.from, to: input.insightsFilter.dateRange.to }
: null,
excludeOperations: input.insightsFilter.excludeOperations ?? false,
excludeClientFilters: input.insightsFilter.excludeClientFilters ?? false,
}
: null,
});

View file

@ -29,6 +29,8 @@ export const updateSavedFilter: NonNullable<MutationResolvers['updateSavedFilter
to: input.insightsFilter.dateRange.to,
}
: null,
excludeOperations: input.insightsFilter.excludeOperations ?? false,
excludeClientFilters: input.insightsFilter.excludeClientFilters ?? false,
}
: null,
});

View file

@ -11,6 +11,8 @@ export const SavedFilter: SavedFilterResolvers = {
operationHashes?: string[];
clientFilters?: Array<{ name: string; versions?: string[] | null }>;
dateRange?: { from: string; to: string } | null;
excludeOperations?: boolean;
excludeClientFilters?: boolean;
};
return {
operationHashes: filters.operationHashes ?? [],
@ -20,6 +22,8 @@ export const SavedFilter: SavedFilterResolvers = {
versions: cf.versions ?? null,
})) ?? [],
dateRange: filters.dateRange ?? null,
excludeOperations: filters.excludeOperations ?? false,
excludeClientFilters: filters.excludeClientFilters ?? false,
};
},
visibility: filter => (filter.visibility === 'private' ? 'PRIVATE' : 'SHARED'),

View file

@ -303,6 +303,8 @@ export interface InsightsFilterData {
operationHashes: string[];
clientFilters: Array<{ name: string; versions: string[] | null }>;
dateRange: { from: string; to: string } | null;
excludeOperations?: boolean;
excludeClientFilters?: boolean;
}
export interface SavedFilter {

View file

@ -172,55 +172,66 @@ const mockOperations: FilterItem[] = [
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' })),
];
const [excludeOperations, setExcludeOperations] = useState(false);
const [excludeClients, setExcludeClients] = useState(false);
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 className="flex flex-wrap items-center gap-2">
<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,
excludeOperations: false,
excludeClientFilters: false,
},
},
]}
sharedSavedFilterViews={[]}
onApplySavedFilters={() => {}}
/>
{operationSelections.length > 0 && (
<FilterDropdown
label="Operation"
items={mockOperations}
selectedItems={operationSelections}
onChange={setOperationSelections}
onRemove={() => {
setOperationSelections([]);
setExcludeOperations(false);
}}
excludeMode={excludeOperations}
onExcludeModeChange={setExcludeOperations}
/>
)}
{clientSelections.length > 0 && (
<FilterDropdown
label="Client"
items={mockClients}
selectedItems={clientSelections}
onChange={setClientSelections}
onRemove={() => {
setClientSelections([]);
setExcludeClients(false);
}}
valuesLabel="versions"
excludeMode={excludeClients}
onExcludeModeChange={setExcludeClients}
/>
)}
</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

@ -1,7 +1,6 @@
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { X } 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';
@ -20,8 +19,25 @@ export type FilterDropdownProps = {
valuesLabel?: string;
/** When true, the trigger is visually dimmed and the menu cannot be opened */
disabled?: boolean;
/** When true, selected items are excluded instead of included */
excludeMode?: boolean;
/** Called when the exclude mode changes */
onExcludeModeChange?: (exclude: boolean) => void;
};
const chipClass =
'inline-flex items-center rounded-sm border text-xs font-medium bg-neutral-2 border-neutral-5 text-neutral-9 dark:text-neutral-11 dark:bg-neutral-3 dark:border-neutral-4';
const segmentSeparator = 'border-l [border-left-color:inherit]';
const segmentButton =
'px-2.5 py-1.5 text-[13px] transition-colors cursor-pointer hover:bg-neutral-4/50 hover:text-neutral-12';
function pluralize(count: number, singular: string): string {
const lower = singular.toLowerCase();
return `${count} ${count === 1 ? lower : `${lower}s`}`;
}
export function FilterDropdown({
label,
items,
@ -30,49 +46,97 @@ export function FilterDropdown({
onRemove,
valuesLabel = 'values',
disabled,
excludeMode,
onExcludeModeChange,
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
const [filterOpen, setFilterOpen] = 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 }}
<div
role="group"
aria-label={`${label} filter`}
className={chipClass}
style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
>
{/* Label — static */}
<span className="px-2.5 py-1.5 text-[13px]">{label}</span>
{/* Operator — dropdown for "is" / "is not" */}
{onExcludeModeChange && (
<span className={segmentSeparator}>
<Menu
trigger={
<button
type="button"
className={segmentButton}
onPointerDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{excludeMode ? 'is not' : 'is'}
</button>
}
modal={false}
side="bottom"
align="start"
maxWidth="sm"
minWidth="none"
sections={[
[
<MenuItem key="is" onClick={() => onExcludeModeChange(false)}>
is
</MenuItem>,
<MenuItem key="is-not" onClick={() => onExcludeModeChange(true)}>
is not
</MenuItem>,
],
]}
/>
</span>
)}
{/* Count — opens the main filter dropdown */}
<span className={segmentSeparator}>
<Menu
trigger={
<button type="button" className={segmentButton}>
{pluralize(selectedCount, label)}
</button>
}
open={filterOpen}
onOpenChange={setFilterOpen}
modal={false}
lockScroll
side="bottom"
align="start"
maxWidth="lg"
stableWidth
sections={[
<FilterContent
key="content"
label={label}
items={items}
selectedItems={selectedItems}
onChange={onChange}
valuesLabel={valuesLabel}
/>,
]}
/>
}
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>,
]}
/>
</span>
{/* Remove button */}
<button
type="button"
className={`${segmentSeparator} text-neutral-8 hover:text-neutral-12 flex cursor-pointer items-center px-2 py-1.5 transition-colors`}
aria-label={`Remove ${label} filter`}
onClick={onRemove}
>
<X className="size-3.5" />
</button>
</div>
);
}

View file

@ -13,6 +13,8 @@ export type SavedFilterView = {
operationHashes: string[];
clientFilters: Array<{ name: string; versions: string[] | null }>;
dateRange: { from: string; to: string } | null;
excludeOperations: boolean;
excludeClientFilters: boolean;
};
};

View file

@ -58,9 +58,7 @@ export const WithSubmenu: Story = () => (
}
sections={[
[
<MenuItem key="acme" active>
acme-corp
</MenuItem>,
<MenuItem key="acme">acme-corp</MenuItem>,
<MenuItem key="personal">personal</MenuItem>,
<MenuItem key="test">test-org</MenuItem>,
],

View file

@ -26,7 +26,7 @@ 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',
'px-2 pb-2 z-50 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: {
@ -35,9 +35,14 @@ const menuVariants = cva(
sm: 'max-w-60', // 240px
lg: 'max-w-[380px]',
},
minWidth: {
default: 'min-w-[12rem]',
none: 'min-w-0',
},
},
defaultVariants: {
maxWidth: 'default',
minWidth: 'default',
},
},
);
@ -47,7 +52,7 @@ const menuItemVariants = cva(
{
variants: {
variant: {
default: 'pl-2 text-neutral-10',
default: 'px-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',
@ -56,23 +61,15 @@ const menuItemVariants = cva(
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' },
],
compoundVariants: [{ highlighted: true, className: 'bg-neutral-5 text-neutral-12' }],
defaultVariants: {
variant: 'default',
highlighted: false,
active: false,
disabled: false,
},
},
@ -83,10 +80,8 @@ const menuItemVariants = cva(
function menuItemClassName(
state: { highlighted: boolean; disabled: boolean },
{
active,
variant,
}: {
active?: boolean;
variant?: VariantProps<typeof menuItemVariants>['variant'];
},
) {
@ -94,7 +89,6 @@ function menuItemClassName(
variant,
highlighted: state.highlighted,
disabled: state.disabled,
active: active ?? false,
});
}
@ -168,6 +162,8 @@ type MenuProps = {
sideOffset?: number;
/** Controls the max-width of the popup. Defaults to 300px. */
maxWidth?: 'default' | 'none' | 'sm' | 'lg';
/** Controls the min-width of the popup. Defaults to 12rem. Use 'none' for compact menus. */
minWidth?: 'default' | 'none';
/** Open the submenu when the trigger is hovered (only relevant for nested menus) */
openOnHover?: boolean;
/** Delay in ms before the submenu opens on hover */
@ -197,6 +193,7 @@ function Menu({
align,
sideOffset,
maxWidth,
minWidth,
openOnHover,
delay,
closeDelay,
@ -247,7 +244,7 @@ function Menu({
sideOffset={resolvedSideOffset}
className="outline-none"
>
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth })}>
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth, minWidth })}>
{popupContent}
</BaseMenu.Popup>
</BaseMenu.Positioner>
@ -266,7 +263,7 @@ function Menu({
sideOffset={resolvedSideOffset}
className="outline-none"
>
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth })}>
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth, minWidth })}>
{popupContent}
</BaseMenu.Popup>
</BaseMenu.Positioner>
@ -278,19 +275,16 @@ function Menu({
// --- MenuItem ---
type MenuItemProps = Omit<BaseMenu.Item.Props, 'className'> & {
active?: boolean;
variant?: VariantProps<typeof menuItemVariants>['variant'];
};
function MenuItem({ active, variant, children, ...props }: MenuItemProps) {
function MenuItem({ variant, children, ...props }: MenuItemProps) {
const submenuTriggerCtx = useContext(SubmenuTriggerContext);
if (submenuTriggerCtx) {
return (
<BaseMenu.SubmenuTrigger
className={(state: BaseMenu.SubmenuTrigger.State) =>
menuItemClassName(state, { active, variant })
}
className={(state: BaseMenu.SubmenuTrigger.State) => menuItemClassName(state, { variant })}
openOnHover={submenuTriggerCtx.openOnHover}
delay={submenuTriggerCtx.delay}
closeDelay={submenuTriggerCtx.closeDelay}
@ -304,7 +298,7 @@ function MenuItem({ active, variant, children, ...props }: MenuItemProps) {
return (
<BaseMenu.Item
className={(state: BaseMenu.Item.State) => menuItemClassName(state, { active, variant })}
className={(state: BaseMenu.Item.State) => menuItemClassName(state, { variant })}
{...(props as Omit<BaseMenu.Item.Props, 'className'>)}
>
{children}

View file

@ -88,7 +88,7 @@ export function Popover(props: PopoverProps) {
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>
<span className="text-neutral-12 text-sm">{props.title}</span>
{!props.hideCloseButton && (
<button
onClick={() => onOpenChange?.(false)}

View file

@ -19,6 +19,9 @@ const triggerButtonVariants = cva(
'border-dashed border-accent_30 text-accent_80 bg-accent_08',
'hover:border-accent_80 hover:text-accent hover:bg-accent_10',
],
'muted-action': [
'border-dashed hover:bg-neutral-3 hover:border-neutral-7 hover:text-neutral-12',
],
},
},
defaultVariants: {

View file

@ -16,6 +16,7 @@ import { useToast } from '@/components/ui/use-toast';
import { graphql } from '@/gql';
import { SavedFilterVisibilityType } from '@/gql/graphql';
import { UpdateFilterButton } from './update-filter-button';
import { hasUnsavedChanges, toInsightsFilterInput, type CurrentFilters } from './utils';
const InsightsCreateSavedFilter_Mutation = graphql(`
mutation InsightsCreateSavedFilter($input: CreateSavedFilterInput!) {
@ -39,6 +40,8 @@ const InsightsCreateSavedFilter_Mutation = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
}
}
@ -46,12 +49,6 @@ const InsightsCreateSavedFilter_Mutation = graphql(`
}
`);
export type CurrentFilters = {
operations: string[];
clients: Array<{ name: string; versions: string[] | null }>;
dateRange: { from: string; to: string };
};
type SaveFilterButtonProps = {
activeView: SavedFilterView | null;
viewerCanCreate: boolean;
@ -75,22 +72,39 @@ export function SaveFilterButton({
onSaved,
onUpdated,
}: SaveFilterButtonProps) {
// When viewing a saved filter without modifications, don't show any save UI.
if (activeView && !hasUnsavedChanges(activeView, currentFilters)) {
return null;
}
if (activeView?.viewerCanUpdate) {
return (
<UpdateFilterButton
activeView={activeView}
currentFilters={currentFilters}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
onUpdated={onUpdated}
/>
<div className="flex items-center gap-x-2">
<UpdateFilterButton
activeView={activeView}
currentFilters={currentFilters}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
onUpdated={onUpdated}
/>
{viewerCanCreate && (
<CreateFilterButton
viewerCanShare={viewerCanShare}
currentFilters={currentFilters}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
onSaved={onSaved}
/>
)}
</div>
);
}
if (viewerCanCreate) {
return (
<SaveFilterPopover
<CreateFilterButton
viewerCanShare={viewerCanShare}
currentFilters={currentFilters}
organizationSlug={organizationSlug}
@ -104,7 +118,7 @@ export function SaveFilterButton({
return null;
}
function SaveFilterPopover({
function CreateFilterButton({
viewerCanShare,
currentFilters,
organizationSlug,
@ -136,17 +150,7 @@ function SaveFilterPopover({
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,
},
},
insightsFilter: toInsightsFilterInput(currentFilters),
},
});

View file

@ -23,6 +23,8 @@ export function savedFilterToSearchParams(filter: {
operationHashes: string[];
clientFilters: ReadonlyArray<{ name: string; versions?: string[] | null }>;
dateRange?: { from: string; to: string } | null;
excludeOperations?: boolean;
excludeClientFilters?: boolean;
};
}) {
return {
@ -34,6 +36,8 @@ export function savedFilterToSearchParams(filter: {
stripNullValues({ name: c.name, versions: c.versions ?? null }),
)
: undefined,
excludeOperations: filter.filters.excludeOperations || undefined,
excludeClients: filter.filters.excludeClientFilters || undefined,
from: filter.filters.dateRange?.from,
to: filter.filters.dateRange?.to,
viewId: filter.id,

View file

@ -6,7 +6,7 @@ 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';
import { hasUnsavedChanges, toInsightsFilterInput, type CurrentFilters } from './utils';
const InsightsUpdateSavedFilter_Mutation = graphql(`
mutation InsightsUpdateSavedFilter($input: UpdateSavedFilterInput!) {
@ -30,6 +30,8 @@ const InsightsUpdateSavedFilter_Mutation = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
}
}
@ -58,48 +60,17 @@ export function UpdateFilterButton({
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 hasChanges = useMemo(
() => hasUnsavedChanges(activeView, currentFilters),
[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,
},
},
insightsFilter: toInsightsFilterInput(currentFilters),
},
});
@ -131,7 +102,7 @@ export function UpdateFilterButton({
onUpdated,
]);
if (!hasUnsavedChanges) {
if (!hasChanges) {
return null;
}
@ -142,7 +113,7 @@ export function UpdateFilterButton({
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" />}
trigger={<TriggerButton label={`Update "${activeView.name}"`} variant="muted-action" />}
content={
<div className="flex gap-2">
<Button

View file

@ -1,3 +1,57 @@
import type { SavedFilterView } from '@/components/base/insights-filters';
export type CurrentFilters = {
operations: string[];
clients: Array<{ name: string; versions: string[] | null }>;
dateRange: { from: string; to: string };
excludeOperations: boolean;
excludeClientFilters: boolean;
};
export function toInsightsFilterInput(currentFilters: CurrentFilters) {
return {
operationHashes: currentFilters.operations,
clientFilters: currentFilters.clients.map(c => ({
name: c.name,
versions: c.versions,
})),
dateRange: {
from: currentFilters.dateRange.from,
to: currentFilters.dateRange.to,
},
excludeOperations: currentFilters.excludeOperations,
excludeClientFilters: currentFilters.excludeClientFilters,
};
}
function normalizeClients(arr: Array<{ name: string; versions: string[] | null }>) {
return JSON.stringify(
arr
.map(c => ({ name: c.name, versions: c.versions }))
.sort((a, b) => a.name.localeCompare(b.name)),
);
}
export function hasUnsavedChanges(activeView: SavedFilterView, currentFilters: CurrentFilters) {
if (
JSON.stringify([...currentFilters.operations].sort()) !==
JSON.stringify([...activeView.filters.operationHashes].sort())
)
return true;
if (
normalizeClients(currentFilters.clients) !== normalizeClients(activeView.filters.clientFilters)
)
return true;
if (
currentFilters.dateRange.from !== activeView.filters.dateRange?.from ||
currentFilters.dateRange.to !== activeView.filters.dateRange?.to
)
return true;
if (currentFilters.excludeOperations !== activeView.filters.excludeOperations) return true;
if (currentFilters.excludeClientFilters !== activeView.filters.excludeClientFilters) return true;
return false;
}
export function resolutionToMilliseconds(
resolution: number,
period: {

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import { endOfDay, endOfToday, formatDate, subMonths } from 'date-fns';
import { CalendarDays } from 'lucide-react';
import { DateRange, Matcher } from 'react-day-picker';
@ -297,30 +297,6 @@ export function DateRangePickerPanel(props: DateRangePickerPanelProps) {
const fromParsed = parse(fromValue);
const toParsed = parse(toValue);
const lastPreset = useRef<Preset | null>(activePreset);
useEffect(() => {
if (!activePreset || !props.onUpdate) {
return;
}
const fromParsed = parse(activePreset.range.from);
const toParsed = parse(activePreset.range.to);
if (fromParsed && toParsed) {
const resolvedRange = resolveRange(fromValue, toValue);
if (resolvedRange && lastPreset.current?.name !== activePreset.name) {
props.onUpdate({
preset: activePreset,
});
}
}
}, [activePreset]);
useEffect(() => {
lastPreset.current = activePreset;
}, [activePreset]);
const PresetButton = useMemo(
() =>
function PresetButton({ preset }: { preset: Preset }): React.ReactNode {
@ -352,6 +328,7 @@ export function DateRangePickerPanel(props: DateRangePickerPanelProps) {
setRange(undefined);
setShowCalendar(false);
setQuickRangeFilter('');
props.onUpdate?.({ preset });
props.onClose?.();
}}
disabled={isDisabled}
@ -470,22 +447,21 @@ export function DateRangePickerPanel(props: DateRangePickerPanelProps) {
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 },
},
);
const preset = findMatchingPreset(
{
from: fromWithoutWhitespace,
to: toWithoutWhitespace,
},
availablePresets,
) ?? {
name: `${fromWithoutWhitespace}_${toWithoutWhitespace}`,
label: buildDateRangeString(resolvedRange),
range: { from: fromWithoutWhitespace, to: toWithoutWhitespace },
};
setActivePreset(preset);
setShowCalendar(false);
setQuickRangeFilter('');
props.onUpdate?.({ preset });
props.onClose?.();
}
}}

View file

@ -69,6 +69,8 @@ export const ManageFilters_SavedFiltersQuery = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
createdBy {
id
@ -122,6 +124,8 @@ const ManageFilters_UpdateSavedFilterMutation = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
}
}
@ -339,7 +343,14 @@ function SavedFilterRow({
<Link
to="/$organizationSlug/$projectSlug/$targetSlug/insights"
params={{ organizationSlug, projectSlug, targetSlug }}
search={savedFilterToSearchParams(filter)}
search={savedFilterToSearchParams({
...filter,
filters: {
...filter.filters,
excludeOperations: filter.filters.excludeOperations ?? undefined,
excludeClientFilters: filter.filters.excludeClientFilters ?? undefined,
},
})}
/>
}
>
@ -527,6 +538,12 @@ function SavedFilterRowFilters({
useState<FilterSelection[]>(savedClientSelections);
const [showOperationFilter, setShowOperationFilter] = useState(operationHashes.length > 0);
const [showClientFilter, setShowClientFilter] = useState(clientFilters.length > 0);
const [excludeOperations, setExcludeOperations] = useState(
filter.filters.excludeOperations ?? false,
);
const [excludeClientFilters, setExcludeClientFilters] = useState(
filter.filters.excludeClientFilters ?? false,
);
// Sync state when saved filter data changes (e.g. after mutation updates cache)
const filterDataKey = JSON.stringify(filter.filters);
@ -536,6 +553,8 @@ function SavedFilterRowFilters({
setShowOperationFilter(operationHashes.length > 0);
setShowClientFilter(clientFilters.length > 0);
setDateRange(savedDateRange);
setExcludeOperations(filter.filters.excludeOperations ?? false);
setExcludeClientFilters(filter.filters.excludeClientFilters ?? false);
}, [filterDataKey]);
// Detect changes from saved state (compare by identifiers, not display names)
@ -559,6 +578,9 @@ function SavedFilterRowFilters({
);
if (normalizeClients(clientSelections) !== normalizeClients(savedClientSelections)) return true;
if (excludeOperations !== (filter.filters.excludeOperations ?? false)) return true;
if (excludeClientFilters !== (filter.filters.excludeClientFilters ?? false)) return true;
return false;
}, [
operationSelections,
@ -571,6 +593,10 @@ function SavedFilterRowFilters({
operationHashes,
clientFilters,
savedDateRange,
excludeOperations,
excludeClientFilters,
filter.filters.excludeOperations,
filter.filters.excludeClientFilters,
]);
// Cancel → reset to saved state
@ -580,12 +606,16 @@ function SavedFilterRowFilters({
setShowOperationFilter(operationHashes.length > 0);
setShowClientFilter(clientFilters.length > 0);
setDateRange(savedDateRange);
setExcludeOperations(filter.filters.excludeOperations ?? false);
setExcludeClientFilters(filter.filters.excludeClientFilters ?? false);
}, [
savedOperationSelections,
savedClientSelections,
operationHashes,
clientFilters,
savedDateRange,
filter.filters.excludeOperations,
filter.filters.excludeClientFilters,
]);
// Update mutation
@ -616,6 +646,8 @@ function SavedFilterRowFilters({
operationHashes: newOperationHashes,
clientFilters: newClientFilters,
dateRange: { from: dateRange.from, to: dateRange.to },
excludeOperations,
excludeClientFilters,
},
},
}).then(result => {
@ -640,6 +672,8 @@ function SavedFilterRowFilters({
clientSelections,
clientItems,
dateRange,
excludeOperations,
excludeClientFilters,
filter.id,
organizationSlug,
projectSlug,
@ -676,8 +710,11 @@ function SavedFilterRowFilters({
onRemove={() => {
setShowOperationFilter(false);
setOperationSelections([]);
setExcludeOperations(false);
}}
disabled={loading}
excludeMode={excludeOperations}
onExcludeModeChange={setExcludeOperations}
/>
)}
{showClientFilter && clientItems.length > 0 && (
@ -689,9 +726,12 @@ function SavedFilterRowFilters({
onRemove={() => {
setShowClientFilter(false);
setClientSelections([]);
setExcludeClientFilters(false);
}}
valuesLabel="versions"
disabled={loading}
excludeMode={excludeClientFilters}
onExcludeModeChange={setExcludeClientFilters}
/>
)}
{loading && <Spinner />}

View file

@ -34,6 +34,8 @@ const InsightsClientFilter = z.object({
export const InsightsFilterSearch = z.object({
operations: z.array(z.string()).optional(),
clients: z.array(InsightsClientFilter).optional(),
excludeOperations: z.boolean().optional(),
excludeClients: z.boolean().optional(),
from: z.string().optional(),
to: z.string().optional(),
viewId: z.string().optional(),
@ -50,6 +52,8 @@ function buildGraphQLFilter(state: InsightsFilterState): OperationStatsFilterInp
versions: c.versions,
}))
: undefined,
excludeOperations: state.excludeOperations ?? undefined,
excludeClientVersionFilters: state.excludeClients ?? undefined,
};
}
@ -97,6 +101,8 @@ const InsightsFilterPicker_Query = graphql(`
from
to
}
excludeOperations
excludeClientFilters
}
}
}
@ -191,25 +197,6 @@ function OperationsView({
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[] = [];
@ -228,6 +215,8 @@ function OperationsView({
versions: c.versions ?? null,
})),
dateRange: node.filters.dateRange ?? null,
excludeOperations: node.filters.excludeOperations ?? false,
excludeClientFilters: node.filters.excludeClientFilters ?? false,
},
};
@ -272,6 +261,25 @@ function OperationsView({
}
}, [search.viewId]);
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 hasActiveFilters = useMemo(
() =>
(search.operations && search.operations.length > 0) ||
@ -308,6 +316,8 @@ function OperationsView({
viewId: undefined,
operations: undefined,
clients: undefined,
excludeOperations: undefined,
excludeClients: undefined,
from: presetLast7Days.range.from,
to: presetLast7Days.range.to,
}),
@ -356,7 +366,20 @@ function OperationsView({
}}
onRemove={() => {
void navigate({
search: prev => ({ ...prev, operations: undefined }),
search: prev => ({
...prev,
operations: undefined,
excludeOperations: undefined,
}),
});
}}
excludeMode={search.excludeOperations ?? false}
onExcludeModeChange={exclude => {
void navigate({
search: prev => ({
...prev,
excludeOperations: exclude || undefined,
}),
});
}}
/>
@ -374,7 +397,20 @@ function OperationsView({
}}
onRemove={() => {
void navigate({
search: prev => ({ ...prev, clients: undefined }),
search: prev => ({
...prev,
clients: undefined,
excludeClients: undefined,
}),
});
}}
excludeMode={search.excludeClients ?? false}
onExcludeModeChange={exclude => {
void navigate({
search: prev => ({
...prev,
excludeClients: exclude || undefined,
}),
});
}}
/>
@ -394,6 +430,8 @@ function OperationsView({
from: search.from ?? dateRangeController.selectedPreset.range.from,
to: search.to ?? dateRangeController.selectedPreset.range.to,
},
excludeOperations: search.excludeOperations ?? false,
excludeClientFilters: search.excludeClients ?? false,
}}
organizationSlug={organizationSlug}
projectSlug={projectSlug}

View file

@ -30594,8 +30594,8 @@ snapshots:
'@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3)
eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)
eslint-config-prettier: 9.1.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-jsonc: 2.11.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-mdx: 3.0.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
@ -33689,13 +33689,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)):
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)):
dependencies:
debug: 4.4.3(supports-color@8.1.1)
enhanced-resolve: 5.17.1
eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
fast-glob: 3.3.2
get-tsconfig: 4.7.5
is-core-module: 2.13.1
@ -33726,14 +33726,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)):
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)):
dependencies:
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3)
eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
transitivePeerDependencies:
- supports-color
@ -33765,7 +33765,7 @@ snapshots:
eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)
eslint-compat-utils: 0.1.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.3
@ -33775,7 +33775,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3