mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Console 1903 is it possible to have a not rule in filters (#7768)
This commit is contained in:
parent
5eb8dee3ce
commit
6b22c3d9e2
33 changed files with 882 additions and 254 deletions
5
.changeset/lemon-planes-eat.md
Normal file
5
.changeset/lemon-planes-eat.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'hive': patch
|
||||
---
|
||||
|
||||
Enhanced Insights filters UI with include/exclude options
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue