Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5248bf5
feat: store pinned filters in MongoDB for team-wide sharing (HDX-2300)
brandon-pereira Apr 2, 2026
07769c6
add changeset, fix knip
brandon-pereira Apr 2, 2026
203ce6c
Merge branch 'main' into brandon/global-pinned-filters
brandon-pereira Apr 2, 2026
c9210b5
fix spacing
brandon-pereira Apr 2, 2026
67e802c
Merge branch 'brandon/global-pinned-filters' of https://github.com/hy…
brandon-pereira Apr 2, 2026
96fe754
claude feedback
brandon-pereira Apr 2, 2026
7a2ff54
fix: fix failing E2E and integration tests for pinned filters
brandon-pereira Apr 2, 2026
68999fe
fix(e2e): increase assertion timeouts for slow CI
brandon-pereira Apr 2, 2026
5111013
improve ux on shared vs pinned icons
brandon-pereira Apr 8, 2026
cbe14bb
clean up reset logic
brandon-pereira Apr 8, 2026
9f2e735
separate clear icons
brandon-pereira Apr 8, 2026
e817a9c
attempting to reduce duplicate code
brandon-pereira Apr 8, 2026
a30acf9
Merge remote-tracking branch 'origin/main' into brandon/global-pinned…
brandon-pereira Apr 8, 2026
61ee88d
claude feedback / knip fixes / int test fixes
brandon-pereira Apr 8, 2026
16da565
remove scoping test since no longer needed
brandon-pereira Apr 8, 2026
c418004
more claude fixes
brandon-pereira Apr 8, 2026
6ecbed4
more feedback
brandon-pereira Apr 8, 2026
232baea
update changeset and fix e2e tests
brandon-pereira Apr 8, 2026
ce2b797
fix failing tests
brandon-pereira Apr 8, 2026
721f5d6
improve flakeyness of tests in ci
brandon-pereira Apr 8, 2026
657184a
more claude feedback :P
brandon-pereira Apr 8, 2026
9c8a0f0
attempt to improve test
brandon-pereira Apr 8, 2026
5a3a3bd
pr feedback
brandon-pereira Apr 10, 2026
8ab5199
Merge origin/main - resolve localStore conflict, drop unused hashCode…
brandon-pereira Apr 10, 2026
744bf54
ux feedback
brandon-pereira Apr 16, 2026
fb36c0c
fix: use spread syntax for Collapse 'in' prop to avoid TS factory typ…
brandon-pereira Apr 16, 2026
0d292db
fix: pass both 'in' and 'expanded' to Collapse for Mantine version co…
brandon-pereira Apr 16, 2026
ce2b4a2
Merge remote-tracking branch 'origin/main' into brandon/global-pinned…
brandon-pereira Apr 16, 2026
be3441c
fix: use Mantine 9 Collapse 'expanded' prop, remove compat workaround
brandon-pereira Apr 16, 2026
9376712
move refresh icon
brandon-pereira Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/serious-chicken-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/api": minor
"@hyperdx/app": minor
---

Add support for global filter pinning (now persisted in database instead of local storage) enabling filters to be shared across members of team
2 changes: 2 additions & 0 deletions packages/api/src/api-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import routers from './routers/api';
import clickhouseProxyRouter from './routers/api/clickhouseProxy';
import connectionsRouter from './routers/api/connections';
import favoritesRouter from './routers/api/favorites';
import pinnedFiltersRouter from './routers/api/pinnedFilters';
import savedSearchRouter from './routers/api/savedSearch';
import sourcesRouter from './routers/api/sources';
import externalRoutersV2 from './routers/external-api/v2';
Expand Down Expand Up @@ -101,6 +102,7 @@ app.use('/connections', isUserAuthenticated, connectionsRouter);
app.use('/sources', isUserAuthenticated, sourcesRouter);
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
app.use('/favorites', isUserAuthenticated, favoritesRouter);
app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter);
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
// ---------------------------------------------------------------------

Expand Down
44 changes: 44 additions & 0 deletions packages/api/src/controllers/pinnedFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
import mongoose from 'mongoose';

import type { ObjectId } from '@/models';
import PinnedFilterModel from '@/models/pinnedFilter';

/**
* Get team-level pinned filters for a team+source combination.
*/
export async function getPinnedFilters(
teamId: string | ObjectId,
sourceId: string | ObjectId,
) {
return PinnedFilterModel.findOne({
team: new mongoose.Types.ObjectId(teamId),
source: new mongoose.Types.ObjectId(sourceId),
user: null,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that the code includes the capability to store user-level pinned filters, but I don't see that implemented anywhere.

IMO, we shouldn't remove user-specific pinned filters, since different users are likely to care about different filter keys and values. Having no special UI for "pin for team" vs "pin for me" is likely to result in various teammates modifying the filters for the entire team without realizing they're doing so.

It's also odd that the migration from local --> team/db filters is automatic, and applies to the entire team. The first user to login after deploying this will have their filters set for the entire team, possibly unexpectedly.

I would suggest we implement both user-level and team-level filters, and have the migration create user-level pinned filters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pulpdrew you're right, I was trying to keep the scope small and minimize UX changes, but it didn't make sense.

I have changed the UX of this so that clicking the pin icons opens a menu where you can select between pin and share. I have updated the PR description with a video.

Previously localStorage was still used for local mode, but now localStorage is used for pinning and mongo is used for sharing with team.

I have also removed the migration since it's not needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, though with user-specific pinned filters always using local storage, should we remove the user field from this model now?

});
}

/**
* Upsert team-level pinned filters for a team+source.
*/
export async function updatePinnedFilters(
teamId: string | ObjectId,
sourceId: string | ObjectId,
data: { fields: string[]; filters: PinnedFiltersValue },
) {
const filter = {
team: new mongoose.Types.ObjectId(teamId),
source: new mongoose.Types.ObjectId(sourceId),
user: null,
};

return PinnedFilterModel.findOneAndUpdate(
filter,
{
...filter,
fields: data.fields,
filters: data.filters,
},
{ upsert: true, new: true },
);
}
56 changes: 56 additions & 0 deletions packages/api/src/models/pinnedFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';

import type { ObjectId } from '.';

interface IPinnedFilter {
_id: ObjectId;
team: ObjectId;
source: ObjectId;
user: ObjectId | null; // null = team-level, non-null = personal
fields: string[];
filters: PinnedFiltersValue;
createdAt: Date;
updatedAt: Date;
}

const PinnedFilterSchema = new Schema<IPinnedFilter>(
{
team: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Team',
},
source: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Source',
},
user: {
type: mongoose.Schema.Types.ObjectId,
default: null,
ref: 'User',
},
fields: {
type: [String],
default: [],
},
filters: {
type: Schema.Types.Mixed,
default: {},
},
},
{
timestamps: true,
toJSON: { getters: true },
},
);

// One document per team+source+user combination
// user=null means team-level pins
PinnedFilterSchema.index({ team: 1, source: 1, user: 1 }, { unique: true });

export default mongoose.model<IPinnedFilter>(
'PinnedFilter',
PinnedFilterSchema,
);
214 changes: 214 additions & 0 deletions packages/api/src/routers/api/__tests__/pinnedFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import mongoose from 'mongoose';

import { getLoggedInAgent, getServer } from '@/fixtures';
import PinnedFilter from '@/models/pinnedFilter';

describe('pinnedFilters router', () => {
const server = getServer();
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
let sourceId: string;

beforeAll(async () => {
await server.start();
});

beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
sourceId = new mongoose.Types.ObjectId().toString();
});

afterEach(async () => {
await server.clearDBs();
});

afterAll(async () => {
await server.stop();
});

describe('GET /pinned-filters', () => {
it('returns null when no pinned filters exist', async () => {
const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);

expect(res.body.team).toBeNull();
});

it('rejects invalid source id', async () => {
await agent.get('/pinned-filters?source=not-an-objectid').expect(400);
});

it('rejects missing source param', async () => {
await agent.get('/pinned-filters').expect(400);
});
});

describe('PUT /pinned-filters', () => {
it('can create pinned filters', async () => {
const res = await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName', 'SeverityText'],
filters: { ServiceName: ['web', 'api'] },
})
.expect(200);

expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
expect(res.body.filters).toEqual({ ServiceName: ['web', 'api'] });
expect(res.body.id).toBeDefined();
});

it('upserts pinned filters on repeated PUT', async () => {
// First write
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);

// Second write overwrites
const res = await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName', 'SeverityText'],
filters: { ServiceName: ['web', 'api'], SeverityText: ['error'] },
})
.expect(200);

expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
expect(res.body.filters).toEqual({
ServiceName: ['web', 'api'],
SeverityText: ['error'],
});
});

it('rejects invalid source id', async () => {
await agent
.put('/pinned-filters')
.send({
source: 'not-valid',
fields: [],
filters: {},
})
.expect(400);
});
});

describe('GET + PUT round-trip', () => {
it('returns team data after PUT', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);

const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);

expect(res.body.team).not.toBeNull();
expect(res.body.team.fields).toEqual(['ServiceName']);
expect(res.body.team.filters).toEqual({ ServiceName: ['web'] });
});

it('can reset pinned filters by sending empty fields and filters', async () => {
// First set some pins
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);

// Reset
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: [],
filters: {},
})
.expect(200);

const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);

expect(res.body.team).not.toBeNull();
expect(res.body.team.fields).toEqual([]);
expect(res.body.team.filters).toEqual({});
});
});

describe('team scoping', () => {
it('does not leak pinned filters between teams', async () => {
// Insert a pinned filter belonging to a different team directly in DB
const otherTeamId = new mongoose.Types.ObjectId();
await PinnedFilter.create({
team: otherTeamId,
source: new mongoose.Types.ObjectId(sourceId),
user: null,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
});

// The logged-in user (different team) should not see the other team's pins
const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);

expect(res.body.team).toBeNull();
});
});

describe('source scoping', () => {
it('pins are scoped to their source', async () => {
const sourceId2 = new mongoose.Types.ObjectId().toString();

await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);

const res = await agent
.get(`/pinned-filters?source=${sourceId2}`)
.expect(200);

expect(res.body.team).toBeNull();
});
});

describe('filter values with booleans', () => {
it('supports boolean values in filters', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['isRootSpan'],
filters: { isRootSpan: [true, false] },
})
.expect(200);

const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);

expect(res.body.team.filters).toEqual({ isRootSpan: [true, false] });
});
});
});
Loading
Loading