Skip to content

Commit 5885d47

Browse files
[HDX-2300] introduce Shared Filters for team-wide filter visibility and discoverability (#2047)
## Summary Introduces a "Shared Filters" feature (in addition to locally pinned items in HyperDX. Especially helpful for teams with lots of filters and team members, allows users to highlight the top filters easily for all members. This has been one of the most requested features we have received from enterprise customers. \ > **Note:** - currently any user on a team can modify shared filters - we may want/need to introduce role limits to this, but that is oos ### Screenshots or video https://github.com/user-attachments/assets/9613d37c-d8d6-4aeb-9e47-1ad25532a862 ### How to test locally or on Vercel 1. Start the dev server (`yarn dev`) 2. Navigate to the Search page 3. Pin a filter field using the 📌 icon on any filter group header —you should be asked to pin (existing) or add to shared filters. 4. Share a specific value by hovering over a filter checkbox row and clicking the pin icon — it should also appear in Shared Filters 5. Reload the page — pins should persist (MongoDB-backed) 6. Open a second browser/incognito window with the same team — pins should be visible there too 7. Click the ⚙ gear icon next to "Filters" — toggle "Show Shared Filters" off/on 8. Click "Reset Shared Filters" in the gear popover to clear all team pins ### References - Linear Issue: https://linear.app/clickhouse/issue/HDX-2300/sailpoint-neara-global-filter-pinning - Related PRs: Previous WIP branch `brandon/shared-filters-ui` (superseded by this implementation)
1 parent 5149fab commit 5885d47

20 files changed

Lines changed: 2302 additions & 420 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": minor
3+
"@hyperdx/api": minor
4+
"@hyperdx/app": minor
5+
---
6+
7+
Introduces Shared Filters, enabling teams to pin and surface common filters across all members.

packages/api/src/api-app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import routers from './routers/api';
1313
import clickhouseProxyRouter from './routers/api/clickhouseProxy';
1414
import connectionsRouter from './routers/api/connections';
1515
import favoritesRouter from './routers/api/favorites';
16+
import pinnedFiltersRouter from './routers/api/pinnedFilters';
1617
import savedSearchRouter from './routers/api/savedSearch';
1718
import sourcesRouter from './routers/api/sources';
1819
import externalRoutersV2 from './routers/external-api/v2';
@@ -105,6 +106,7 @@ app.use('/connections', isUserAuthenticated, connectionsRouter);
105106
app.use('/sources', isUserAuthenticated, sourcesRouter);
106107
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
107108
app.use('/favorites', isUserAuthenticated, favoritesRouter);
109+
app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter);
108110
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
109111
// ---------------------------------------------------------------------
110112

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
2+
import mongoose from 'mongoose';
3+
4+
import type { ObjectId } from '@/models';
5+
import PinnedFilterModel from '@/models/pinnedFilter';
6+
7+
/**
8+
* Get team-level pinned filters for a team+source combination.
9+
*/
10+
export async function getPinnedFilters(
11+
teamId: string | ObjectId,
12+
sourceId: string | ObjectId,
13+
) {
14+
return PinnedFilterModel.findOne({
15+
team: new mongoose.Types.ObjectId(teamId),
16+
source: new mongoose.Types.ObjectId(sourceId),
17+
});
18+
}
19+
20+
/**
21+
* Upsert team-level pinned filters for a team+source.
22+
*/
23+
export async function updatePinnedFilters(
24+
teamId: string | ObjectId,
25+
sourceId: string | ObjectId,
26+
data: { fields: string[]; filters: PinnedFiltersValue },
27+
) {
28+
const filter = {
29+
team: new mongoose.Types.ObjectId(teamId),
30+
source: new mongoose.Types.ObjectId(sourceId),
31+
};
32+
33+
return PinnedFilterModel.findOneAndUpdate(
34+
filter,
35+
{
36+
...filter,
37+
fields: data.fields,
38+
filters: data.filters,
39+
},
40+
{ upsert: true, new: true },
41+
);
42+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
2+
import mongoose, { Schema } from 'mongoose';
3+
4+
import type { ObjectId } from '.';
5+
6+
interface IPinnedFilter {
7+
_id: ObjectId;
8+
team: ObjectId;
9+
source: ObjectId;
10+
fields: string[];
11+
filters: PinnedFiltersValue;
12+
createdAt: Date;
13+
updatedAt: Date;
14+
}
15+
16+
const PinnedFilterSchema = new Schema<IPinnedFilter>(
17+
{
18+
team: {
19+
type: mongoose.Schema.Types.ObjectId,
20+
required: true,
21+
ref: 'Team',
22+
},
23+
source: {
24+
type: mongoose.Schema.Types.ObjectId,
25+
required: true,
26+
ref: 'Source',
27+
},
28+
fields: {
29+
type: [String],
30+
default: [],
31+
},
32+
filters: {
33+
type: Schema.Types.Mixed,
34+
default: {},
35+
},
36+
},
37+
{
38+
timestamps: true,
39+
toJSON: { getters: true },
40+
},
41+
);
42+
43+
// One document per team+source combination
44+
PinnedFilterSchema.index({ team: 1, source: 1 }, { unique: true });
45+
46+
export default mongoose.model<IPinnedFilter>(
47+
'PinnedFilter',
48+
PinnedFilterSchema,
49+
);
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
2+
import { Types } from 'mongoose';
3+
4+
import { getLoggedInAgent, getServer } from '@/fixtures';
5+
import { Source } from '@/models/source';
6+
7+
const MOCK_SOURCE: Omit<Extract<TSource, { kind: 'log' }>, 'id'> = {
8+
kind: SourceKind.Log,
9+
name: 'Test Source',
10+
connection: new Types.ObjectId().toString(),
11+
from: { databaseName: 'test_db', tableName: 'test_table' },
12+
timestampValueExpression: 'timestamp',
13+
defaultTableSelectExpression: 'body',
14+
};
15+
16+
describe('pinnedFilters router', () => {
17+
const server = getServer();
18+
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
19+
let team: Awaited<ReturnType<typeof getLoggedInAgent>>['team'];
20+
let sourceId: string;
21+
22+
beforeAll(async () => {
23+
await server.start();
24+
});
25+
26+
beforeEach(async () => {
27+
const result = await getLoggedInAgent(server);
28+
agent = result.agent;
29+
team = result.team;
30+
31+
// Create a real source owned by this team
32+
const source = await Source.create({ ...MOCK_SOURCE, team: team._id });
33+
sourceId = source._id.toString();
34+
});
35+
36+
afterEach(async () => {
37+
await server.clearDBs();
38+
});
39+
40+
afterAll(async () => {
41+
await server.stop();
42+
});
43+
44+
describe('GET /pinned-filters', () => {
45+
it('returns null when no pinned filters exist', async () => {
46+
const res = await agent
47+
.get(`/pinned-filters?source=${sourceId}`)
48+
.expect(200);
49+
50+
expect(res.body.team).toBeNull();
51+
});
52+
53+
it('rejects invalid source id', async () => {
54+
await agent.get('/pinned-filters?source=not-an-objectid').expect(400);
55+
});
56+
57+
it('rejects missing source param', async () => {
58+
await agent.get('/pinned-filters').expect(400);
59+
});
60+
61+
it('returns 404 for a source not owned by the team', async () => {
62+
const foreignSourceId = new Types.ObjectId().toString();
63+
await agent.get(`/pinned-filters?source=${foreignSourceId}`).expect(404);
64+
});
65+
});
66+
67+
describe('PUT /pinned-filters', () => {
68+
it('can create pinned filters', async () => {
69+
const res = await agent
70+
.put('/pinned-filters')
71+
.send({
72+
source: sourceId,
73+
fields: ['ServiceName', 'SeverityText'],
74+
filters: { ServiceName: ['web', 'api'] },
75+
})
76+
.expect(200);
77+
78+
expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
79+
expect(res.body.filters).toEqual({ ServiceName: ['web', 'api'] });
80+
expect(res.body.id).toBeDefined();
81+
});
82+
83+
it('upserts on repeated PUT', async () => {
84+
await agent
85+
.put('/pinned-filters')
86+
.send({
87+
source: sourceId,
88+
fields: ['ServiceName'],
89+
filters: { ServiceName: ['web'] },
90+
})
91+
.expect(200);
92+
93+
const res = await agent
94+
.put('/pinned-filters')
95+
.send({
96+
source: sourceId,
97+
fields: ['ServiceName', 'SeverityText'],
98+
filters: { ServiceName: ['web', 'api'], SeverityText: ['error'] },
99+
})
100+
.expect(200);
101+
102+
expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
103+
expect(res.body.filters).toEqual({
104+
ServiceName: ['web', 'api'],
105+
SeverityText: ['error'],
106+
});
107+
});
108+
109+
it('rejects invalid source id', async () => {
110+
await agent
111+
.put('/pinned-filters')
112+
.send({ source: 'not-valid', fields: [], filters: {} })
113+
.expect(400);
114+
});
115+
116+
it('returns 404 for a source not owned by the team', async () => {
117+
const foreignSourceId = new Types.ObjectId().toString();
118+
await agent
119+
.put('/pinned-filters')
120+
.send({ source: foreignSourceId, fields: [], filters: {} })
121+
.expect(404);
122+
});
123+
});
124+
125+
describe('GET + PUT round-trip', () => {
126+
it('returns data after PUT', async () => {
127+
await agent
128+
.put('/pinned-filters')
129+
.send({
130+
source: sourceId,
131+
fields: ['ServiceName'],
132+
filters: { ServiceName: ['web'] },
133+
})
134+
.expect(200);
135+
136+
const res = await agent
137+
.get(`/pinned-filters?source=${sourceId}`)
138+
.expect(200);
139+
140+
expect(res.body.team).not.toBeNull();
141+
expect(res.body.team.fields).toEqual(['ServiceName']);
142+
expect(res.body.team.filters).toEqual({ ServiceName: ['web'] });
143+
});
144+
145+
it('can reset by sending empty fields and filters', async () => {
146+
await agent
147+
.put('/pinned-filters')
148+
.send({
149+
source: sourceId,
150+
fields: ['ServiceName'],
151+
filters: { ServiceName: ['web'] },
152+
})
153+
.expect(200);
154+
155+
await agent
156+
.put('/pinned-filters')
157+
.send({ source: sourceId, fields: [], filters: {} })
158+
.expect(200);
159+
160+
const res = await agent
161+
.get(`/pinned-filters?source=${sourceId}`)
162+
.expect(200);
163+
164+
expect(res.body.team).not.toBeNull();
165+
expect(res.body.team.fields).toEqual([]);
166+
expect(res.body.team.filters).toEqual({});
167+
});
168+
});
169+
170+
describe('source scoping', () => {
171+
it('pins are scoped to their source', async () => {
172+
const source2 = await Source.create({ ...MOCK_SOURCE, team: team._id });
173+
174+
await agent
175+
.put('/pinned-filters')
176+
.send({
177+
source: sourceId,
178+
fields: ['ServiceName'],
179+
filters: { ServiceName: ['web'] },
180+
})
181+
.expect(200);
182+
183+
const res = await agent
184+
.get(`/pinned-filters?source=${source2._id}`)
185+
.expect(200);
186+
187+
expect(res.body.team).toBeNull();
188+
});
189+
});
190+
191+
// Note: cross-team isolation (Team B cannot read Team A's pins) is enforced
192+
// by the MongoDB query filtering on teamId AND the source ownership check
193+
// (getSource validates source.team === teamId). Multi-team integration tests
194+
// are not possible in this single-team environment (register returns 409).
195+
196+
describe('filter values with booleans', () => {
197+
it('supports boolean values in filters', async () => {
198+
await agent
199+
.put('/pinned-filters')
200+
.send({
201+
source: sourceId,
202+
fields: ['isRootSpan'],
203+
filters: { isRootSpan: [true, false] },
204+
})
205+
.expect(200);
206+
207+
const res = await agent
208+
.get(`/pinned-filters?source=${sourceId}`)
209+
.expect(200);
210+
211+
expect(res.body.team.filters).toEqual({ isRootSpan: [true, false] });
212+
});
213+
});
214+
});

0 commit comments

Comments
 (0)