Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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
---

Introduces Shared Filters, enabling teams to pin and surface common filters across all members.
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
42 changes: 42 additions & 0 deletions packages/api/src/controllers/pinnedFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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),
});
}

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

return PinnedFilterModel.findOneAndUpdate(
filter,
{
...filter,
fields: data.fields,
filters: data.filters,
},
{ upsert: true, new: true },
);
}
49 changes: 49 additions & 0 deletions packages/api/src/models/pinnedFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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;
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',
},
fields: {
type: [String],
default: [],
},
filters: {
type: Schema.Types.Mixed,
default: {},
},
},
{
timestamps: true,
toJSON: { getters: true },
},
);

// One document per team+source combination
PinnedFilterSchema.index({ team: 1, source: 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 { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { Types } from 'mongoose';

import { getLoggedInAgent, getServer } from '@/fixtures';
import { Source } from '@/models/source';

const MOCK_SOURCE: Omit<Extract<TSource, { kind: 'log' }>, 'id'> = {
kind: SourceKind.Log,
name: 'Test Source',
connection: new Types.ObjectId().toString(),
from: { databaseName: 'test_db', tableName: 'test_table' },
timestampValueExpression: 'timestamp',
defaultTableSelectExpression: 'body',
};

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

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

beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
team = result.team;

// Create a real source owned by this team
const source = await Source.create({ ...MOCK_SOURCE, team: team._id });
sourceId = source._id.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);
});

it('returns 404 for a source not owned by the team', async () => {
const foreignSourceId = new Types.ObjectId().toString();
await agent.get(`/pinned-filters?source=${foreignSourceId}`).expect(404);
});
});

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 on repeated PUT', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);

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

it('returns 404 for a source not owned by the team', async () => {
const foreignSourceId = new Types.ObjectId().toString();
await agent
.put('/pinned-filters')
.send({ source: foreignSourceId, fields: [], filters: {} })
.expect(404);
});
});

describe('GET + PUT round-trip', () => {
it('returns 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 by sending empty fields and filters', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);

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('source scoping', () => {
it('pins are scoped to their source', async () => {
const source2 = await Source.create({ ...MOCK_SOURCE, team: team._id });

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

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

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

// Note: cross-team isolation (Team B cannot read Team A's pins) is enforced
// by the MongoDB query filtering on teamId AND the source ownership check
// (getSource validates source.team === teamId). Multi-team integration tests
// are not possible in this single-team environment (register returns 409).

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