Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions .changeset/dashboards-smart-views-tag-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@hyperdx/app': minor
'@hyperdx/api': minor
---

feat(dashboards): list views sidebar with tag-only rules on the Dashboards listing page

Save reusable filter combinations as named "views" pinned to a
left-rail sidebar on the Dashboards listing page. Rules in v1 are
tag-only (`tag includes X`, `tag excludes Y`, `is untagged`) with an
`all` / `any` combinator. Clicking a view applies its rules to the
listing and shares the URL via `?view=<id>`; clicking it again clears
the active view. Edit and delete actions live on a kebab menu per
sidebar entry.

Backed by a new `/list-views` CRUD endpoint scoped per user and per
resource. The resource discriminator (`dashboard` | `savedSearch`)
is in the schema so Saved Searches parity drops in without a schema
change.
2 changes: 2 additions & 0 deletions packages/api/src/api-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 listViewsRouter from './routers/api/listViews';
import pinnedFiltersRouter from './routers/api/pinnedFilters';
import savedSearchRouter from './routers/api/savedSearch';
import sourcesRouter from './routers/api/sources';
Expand Down Expand Up @@ -106,6 +107,7 @@ app.use('/connections', isUserAuthenticated, connectionsRouter);
app.use('/sources', isUserAuthenticated, sourcesRouter);
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
app.use('/favorites', isUserAuthenticated, favoritesRouter);
app.use('/list-views', isUserAuthenticated, listViewsRouter);
app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter);
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
if (config.IS_PROMQL_ENABLED) {
Expand Down
51 changes: 51 additions & 0 deletions packages/api/src/controllers/listView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
ListViewResource,
ListViewWithoutId,
} from '@hyperdx/common-utils/dist/types';

import ListView, { IListView } from '@/models/listView';

export function getListViews(
userId: string,
teamId: string,
resource?: ListViewResource,
) {
const filter: Record<string, unknown> = { owner: userId, team: teamId };
if (resource) filter.resource = resource;
return ListView.find(filter).sort({ ordering: 1, createdAt: 1 });
}

export function getListView(id: string, userId: string, teamId: string) {
return ListView.findOne({ _id: id, owner: userId, team: teamId });
}

export function createListView(
userId: string,
teamId: string,
view: ListViewWithoutId,
) {
return ListView.create({
...view,
owner: userId,
team: teamId,
});
}

export function updateListView(
id: string,
userId: string,
teamId: string,
patch: Partial<ListViewWithoutId>,
) {
return ListView.findOneAndUpdate(
{ _id: id, owner: userId, team: teamId },
patch,
{ new: true },
);
}

export function deleteListView(id: string, userId: string, teamId: string) {
return ListView.deleteOne({ _id: id, owner: userId, team: teamId });
}

export type ListViewExport = IListView;
83 changes: 83 additions & 0 deletions packages/api/src/models/listView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ListViewSchema } from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';
import { z } from 'zod';

import type { ObjectId } from '.';

export interface IListView extends z.infer<typeof ListViewSchema> {
_id: ObjectId;
team: ObjectId;
owner: ObjectId;
createdAt: Date;
updatedAt: Date;
}

export type ListViewDocument = mongoose.HydratedDocument<IListView>;

const listViewSchema = new Schema<IListView>(
{
name: {
type: String,
required: true,
maxlength: 120,
},
icon: {
type: String,
required: false,
maxlength: 64,
},
resource: {
type: String,
required: true,
enum: ['dashboard', 'savedSearch'],
},
// Stored as Mixed; the Zod schema in @hyperdx/common-utils is the
// source of truth for shape. PR-3 widens the rule union (non-tag
// kinds) additively; existing documents keep parsing.
rules: {
type: mongoose.Schema.Types.Mixed,
required: true,
default: [],
},
combinator: {
type: String,
required: true,
enum: ['all', 'any'],
default: 'all',
},
ordering: {
type: Number,
required: true,
default: 0,
},
isShared: {
type: Boolean,
required: true,
default: false,
},
team: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Team',
required: true,
},
owner: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
},
);

// The listing query is always scoped to (team, owner, resource) and
// sorted by ordering. Mirrors the unique index pattern from
// favorite.ts but without the unique constraint: a user can name two
// views the same intentionally (e.g. "checkout" dashboards vs
// "checkout" saved searches across resources, or two iterations
// during editing).
listViewSchema.index({ team: 1, owner: 1, resource: 1, ordering: 1 });

export default mongoose.model<IListView>('ListView', listViewSchema);
Loading