Skip to content

feat(dashboards): smart views sidebar with tag-only rules (chained on #2379)#2382

Draft
alex-fedotyev wants to merge 9 commits into
alex/dashboards-multi-select-tag-filterfrom
alex/dashboards-smart-views-tag-only
Draft

feat(dashboards): smart views sidebar with tag-only rules (chained on #2379)#2382
alex-fedotyev wants to merge 9 commits into
alex/dashboards-multi-select-tag-filterfrom
alex/dashboards-smart-views-tag-only

Conversation

@alex-fedotyev
Copy link
Copy Markdown
Contributor

Summary

Save reusable filter combinations as named smart 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.

This PR is chained on #2379. Please review #2379 first; the Vercel preview here shows the cumulative UX (multi-select chips + smart-views sidebar working together). After #2379 merges I'll rebase this onto main and re-target the base.

Why this shape

The 1460-line diff classifies as Tier 4 by the auto-triager, but the history is structured into six self-contained commits so a reviewer can read each layer in isolation rather than a single big blob:

  1. feat(common-utils): SmartView Zod schemas (52 lines). Schema + types only.
  2. feat(api): SmartView model + CRUD router (452 lines). Mongoose model mirrors favorite.ts; controller + router mirror routers/api/favorites.ts. Router mounted at /smart-views.
  3. feat(app): useSmartViews hook + pure evaluateSmartView function (207 lines). React Query hook + pure rule evaluator with unit tests.
  4. feat(app): SmartViewsSidebar and SmartViewEditorDrawer components (497 lines). Sidebar (240px rail) + Mantine Drawer-based editor.
  5. feat(dashboards): wire Smart Views sidebar + apply-view URL state into the listing page (88 net lines). Touchpoints only in DashboardsListPage.tsx; the existing favorites + presets stay above the listing.

If a smaller-PR shape is preferred, the natural split is to land 1-3 on their own (backend + plumbing, no UI) and follow up with 4-5 as a UI PR. I left it as a single PR so the Vercel preview demonstrates the full flow, but happy to split if the reviewer would rather take it in two passes.

What's in v1

  • Per-user, per-resource SmartView storage scoped to (team, owner).
  • Tag-only rule kinds (tag-includes, tag-excludes, untagged).
  • Top-level combinator (all | any); no nested rule groups.
  • Sidebar with empty state, list, click-to-activate, kebab (Edit / Delete).
  • Editor drawer with name, optional icon, dynamic rule rows, combinator radio.
  • ?view=<id> URL state combines with ?tags=... and ?search=... (AND-combined).
  • Resource discriminator already covers savedSearch; Saved Searches sidebar parity drops in without a schema change.

What's deferred

  • Non-tag rule types (recency, has-active-alerts, created-by-me, provisioned, has-tile-type) and pre-built system views.
  • External API v2 + OpenAPI + MCP parity.
  • Customer docs (clickhouse-docs page).
  • Saved Searches sidebar parity.
  • Drag-to-reorder, team-shared views (isShared UI), nested rule groups.

I'll file tracking issues for each before this is marked ready.

Test plan

  • yarn jest on packages/app (the new RTL test for the active-view filter is in DashboardsListPage.test.tsx).
  • yarn jest on packages/common-utils (no schema-test additions needed; the api router tests round-trip the schema end-to-end).
  • yarn ci:lint clean on packages/app and packages/api.
  • yarn tsc --noEmit clean on both packages.
  • api integration tests (packages/api/src/routers/api/__tests__/smartViews.test.ts): will run in CI; covers POST -> GET round-trip, resource discriminator, 404 on cross-user access, validation rejection on bad rule kinds.
  • Local dev stack walkthrough on the cumulative preview (feat(dashboards): multi-select tag filter on Dashboards and Saved Searches #2379 + this PR): seed dashboards with mixed tagging, create a view, apply it, edit it, delete it, paste a shared ?view=<id> URL in a new tab.
  • Light + dark theme parity.
  • Narrow viewport (sidebar wraps below the listing on mobile).

Alex Fedotyev and others added 5 commits May 30, 2026 00:12
…iscriminator)

Adds the `SmartView*` schemas to common-utils ahead of the model,
router, and UI work. The rule discriminated union is intentionally
narrow in v1 (`tag-includes`, `tag-excludes`, `untagged`) so the
storage + sidebar plumbing can ship without dragging in non-tag rule
machinery; a follow-up widens the union with recency / has-alerts /
created-by-me / provisioned / has-tile-type kinds and existing
documents keep parsing because the extension is additive.

The `resource` discriminator already includes `savedSearch` so the
Saved Searches sidebar parity work drops in without a schema change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mongoose model mirrors `favorite.ts` for the per-user index pattern
and `dashboard.ts` for the Mixed-typed JSON column that stores the
rules array. The Zod schema in `@hyperdx/common-utils/dist/types`
is the source of truth for rule shape; widening the rule union
later does not require a model migration.

Controller exposes `getSmartViews(userId, teamId, resource?)`,
`getSmartView`, `createSmartView`, `updateSmartView`,
`deleteSmartView`, all scoped by `{ owner, team }`. Cross-user and
cross-team access fall through to a 404 in the router.

Router mounted at `/smart-views` next to `favoritesRouter` and
`savedSearchRouter`. Body and query validation via
zod-express-middleware against the Zod schemas. Tests round-trip
POST -> GET, exercise the resource discriminator filter, and confirm
that another user on the same team cannot patch or delete the
view (both 404).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`packages/app/src/smartView.ts` exposes `useSmartViews(resource)`,
`useCreateSmartView`, `useUpdateSmartView`, `useDeleteSmartView`.
React Query keys are `['smart-views', resource]` so a mutation that
changes one resource's views does not invalidate the other's cache.

`packages/app/src/utils/evaluateSmartView.ts` is a pure function
that takes `{ rules, combinator }` and an item with a `tags` array
and returns a boolean. An empty rule list matches every item.
Combinator `all` requires every rule to pass; `any` short-circuits
on the first success. The switch over rule kinds is exhaustive for
the tag-only v1 set; the rule-widening follow-up extends the switch.

Unit tests cover every rule kind, empty rules, both combinators,
multi-tag items, untagged items, and an `any` combinator that
combines an `untagged` rule with a `tag-includes` rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`SmartViewsSidebar` renders a 240px-wide rail with the current
user's smart views for a given resource. The empty state nudges
toward the "+ New Smart View" affordance; the populated state shows
each view as an UnstyledButton with optional icon, name, and a
kebab Menu (Edit / Delete). The Delete flow uses the existing
`useConfirm` for parity with the dashboard / saved-search delete
flows on the same page.

`SmartViewEditorDrawer` is a Mantine Drawer that opens on the right
and accepts: name, optional icon (free text, intended for an emoji
or short symbol), top-level combinator (`all` | `any`), and a
dynamic rule list. Each rule row picks a kind from the discriminated
union and (for the tag-includes / tag-excludes kinds) a tag from
the available-tags pool. Saving calls `useCreateSmartView` for a
fresh view and `useUpdateSmartView` for an existing one. Draft
rules with an empty tag are dropped on save, so the editor stays
forgiving while the persisted shape stays clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…o the listing page

DashboardsListPage now pulls smart views for the dashboard resource
and renders the sidebar in a left rail next to the existing listing
column. A new nuqs `?view=<id>` state drives the active view;
clicking a sidebar entry sets it and clicking the active entry
again clears it (shareable URL).

The `filteredDashboards` memo factors the active view's rules
through `evaluateSmartView` AFTER the manual tag and search
filters, so manual chips and the view's rule list AND-combine. The
existing favorites + preset sections sit above the listing and are
unchanged.

`SmartViewEditorDrawer` renders at the page level via
`useDisclosure`; the sidebar's "+ New" and per-item Edit open it
with the right initial state. The drawer reuses the page's
`allTags` memo as the tag pool so a user only picks from tags
already in their dashboards.

Extends the existing RTL test with a fourth case: when the mocked
`useQueryState('view')` returns a known smart-view id, only items
that pass that view's rules show up in the grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 30, 2026

🦋 Changeset detected

Latest commit: 5428dd8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Minor
@hyperdx/api Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment May 30, 2026 2:34am
hyperdx-storybook Ready Ready Preview, Comment May 30, 2026 2:34am

Request Review

…AL_MODE builds work

useSmartViews / useCreateSmartView / useUpdateSmartView /
useDeleteSmartView all unconditionally hit the API. On the Vercel
preview and any `IS_LOCAL_MODE` build there is no `/smart-views`
backend, so GETs 504 (sidebar shows "Loading..." for ~7s while
React Query retries before falling through to the empty state) and
POST/PATCH/DELETE 504 too (the editor drawer shows "Failed to
create smart view" but keeps the user staring at a useless modal).

Mirror the pattern used by `favorites.ts` and `dashboard.ts`:
each hook short-circuits to `createEntityStore<SmartView>(
'hdx-local-smart-views')` when `IS_LOCAL_MODE` is true. The
listing is filtered + sorted by `ordering` on the read path. The
React Query invalidation logic is unchanged so the sidebar
refreshes on create / update / delete.

Drive-by: switch the editor drawer's Cancel button from
`variant="default"` to `variant="secondary"` to satisfy
`agent_docs/code_style.md`'s Button variant rule (caught by
`no-restricted-syntax` after rebuild).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

Pushed a fix in commit 60e8871f. Root cause was that the four smartView.ts hooks (useSmartViews, useCreateSmartView, useUpdateSmartView, useDeleteSmartView) called the API unconditionally. The Vercel preview runs in IS_LOCAL_MODE with no backend, so every GET 504'd and React Query retried for ~7 seconds before falling through to the empty state, and every POST/PATCH/DELETE 504'd with a vague "Failed to create smart view" notification while the drawer stayed open.

Fix mirrors the pattern in favorites.ts and dashboard.ts: each hook short-circuits to createEntityStore<SmartView>('hdx-local-smart-views') when IS_LOCAL_MODE is true. The listing path filters by resource and sorts by ordering on the client; React Query invalidation logic stays the same so the sidebar refreshes on every mutation.

Verified on the redeployed preview:

  • Empty sidebar renders instantly (no /api/smart-views requests at all).
  • Create: type a name, click Create, the view shows up in the sidebar with a kebab menu.
  • Apply: click the view, URL gains ?view=<id>; click again, URL clears.
  • Delete: kebab -> Delete -> confirm; the view disappears AND the active ?view clears if the deleted view was the active one.

Drive-by: switched the editor drawer's Cancel button from variant="default" to variant="secondary" to satisfy agent_docs/code_style.md's Button rule (caught by no-restricted-syntax after rebuild).

…ed view does not crash the listing

Before this commit, a SmartView document with `rules` undefined or
null (server response that dropped a field, localStorage entry
written by an earlier draft, a `Mixed` Mongoose column that
returned a stray shape) crashed the listing in three places:

- `evaluateSmartView(view, item)` called `view.rules.length` and
  `view.rules.every`, throwing "Cannot read properties of
  undefined / null".
- `SmartViewEditorDrawer` seeded its draft from
  `existingView.rules.length`, throwing on open of the Edit menu.
- The unrelated nuqs URL state would still leave `?view=<id>`
  pointing at a view whose rules now blow up the filter memo,
  taking the entire page down with the Next.js client-side
  exception boundary.

Coerce defensively at every entry point:

- `evaluateSmartView` accepts `rules?: SmartViewRule[] | null` and
  treats anything non-array as empty (-> match-all). Same for
  `combinator`, which defaults to `all` if missing. Entries inside
  the array that are not objects with a `kind` field are filtered
  out before evaluation.
- `SmartViewEditorDrawer` seeds its draft via the same
  `Array.isArray` check + null-entry filter; missing `combinator`
  also defaults to `all`.
- `smartView.ts` exposes a private `normalizeSmartView` that runs
  on both the local-mode and server-mode read paths so the data
  React Query hands back to consumers always has the canonical
  shape (id/name/resource/combinator/ordering present, rules
  guaranteed array, isShared optional).

Added a regression test on `evaluateSmartView` for the
empty-view (rules undefined) and the explicit-null-rules cases.

Root-cause notes: the user's repro stack pointed at
`Cannot read properties of null (reading 'value')` inside a
useState chain. The exact `value` access lives inside Mantine's
Drawer / Select internals when a rule prop becomes null mid-render;
the upstream cause is the same `rules` shape mismatch fixed here.
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

alex-fedotyev commented May 30, 2026

Pushed 4ff23496. The earlier IS_LOCAL_MODE fix only covered the network-hang case; a SmartView whose rules field is undefined, null, or any non-array still crashed the listing.

Repro (verified on the redeployed preview before the fix): pre-seed [{ id: 'bad-1', name: 'Bad shape', resource: 'dashboard', ordering: 0 }] (no rules, no combinator) in localStorage.hdx-local-smart-views, navigate to /dashboards/list, kebab -> Edit. Page goes down with TypeError: Cannot read properties of undefined (reading 'length') via Next.js's client-side exception boundary. The same shape produces the earlier Cannot read properties of null (reading 'value') report when the URL carries ?view=<id> and Mantine's Select renders against a null rule prop further down the tree.

Three call sites called view.rules.length / .every unconditionally: evaluateSmartView, the editor drawer's seed effect, and the listing's filter memo.

Fix is defensive parsing on every entry point:

  1. evaluateSmartView now accepts rules?: SmartViewRule[] | null and combinator?: SmartViewCombinator | null, coerces non-array rules to [] (matches all), and filters out non-object rule entries before evaluation. Combinator defaults to all.
  2. SmartViewEditorDrawer seeds the draft via Array.isArray(existingView.rules) + a null-entry filter; missing combinator defaults to all.
  3. smartView.ts runs both the local-mode and server-mode read paths through a normalizeSmartView helper so React Query never hands a malformed view to consumers (id / name / resource / combinator / ordering always present, rules guaranteed array, isShared kept optional).
  4. Added a regression test on evaluateSmartView for rules: undefined and rules: null.

Reproduced and verified on the Vercel preview after the fix:

  • Pre-seeded a SmartView in localStorage with no rules and no combinator, navigated to /dashboards/list, opened the kebab -> Edit: no crash, drawer opens with one default tag-includes rule and combinator all.
  • Created a new view, removed the only rule, clicked Create (rules: []): no crash, view appears in sidebar.
  • Activated the rules-empty view (URL gains ?view=<id>), opened its kebab -> Edit: no crash.

…e synthetic-event crash

Typing into the smart-view editor's Name or Icon field crashed the
listing page after a few keystrokes with `TypeError: Cannot read
properties of null (reading 'value')` inside a useState update.

Root cause: both TextInput onChange handlers closed over the
synthetic event INSIDE a `setDraft(d => ({...d, name:
e.currentTarget.value}))` updater. React 18 nulls out
`event.currentTarget` after the event handler returns (and React
Compiler / concurrent rendering routinely defers / re-runs the
updater function). When the updater finally executes the
event-detach has already happened and `e.currentTarget` is null;
reading `.value` on it throws and Next.js's client-side exception
boundary takes the whole page down.

Fix: capture `e.currentTarget.value` into a local const inside the
synchronous event handler, then pass the const to the updater.

Reproducer that crashed reliably on the Vercel preview before this
commit: open `/dashboards/list`, click "+ New Smart View", type
into the Name field via real keystrokes (not a single setValue);
the page shows the Next.js client-side exception screen after a
few characters. Same pattern crashed the Icon field.

Documented in a code comment so the next person who writes
`onChange={e => setX(prev => ({...prev, foo: e.currentTarget.value}))}`
in this file doesn't re-introduce the same bug.
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

Pushed 0c7b2e8f. The earlier two fixes covered the IS_LOCAL_MODE hang and the malformed-view crash, but a third bug remained on the typing path.

Reproducer: open /dashboards/list -> "+ New Smart View" -> type into the Name field via real keystrokes (the bug requires multiple sequential input events; a single programmatic setValue does not trigger it). After a few characters the page goes down with TypeError: Cannot read properties of null (reading 'value') matching the original report.

Root cause: both TextInput onChange handlers in SmartViewEditorDrawer closed over the synthetic event INSIDE the setDraft updater:

onChange={e => setDraft(d => ({ ...d, name: e.currentTarget.value }))}

React 18 nulls out event.currentTarget once the synthetic-event handler returns, and React Compiler / concurrent rendering routinely defers or re-runs the updater function. When the updater finally executes the event has been detached and e.currentTarget is null; reading .value on it throws and Next.js's client-side exception boundary takes the whole page down.

Fix: capture e.currentTarget.value into a local const synchronously inside the event handler, then pass the const into the updater. Same change on both TextInputs (Name and Icon). Inline comment in the file so the pattern does not get re-introduced.

Verified on the redeployed preview: typed Test view name with several characters into the Name field via per-character keystrokes, then typed an emoji into the Icon field, clicked Create. View appears in the sidebar as "🚀 Test view name with several characters". Zero console errors.

…, accent active state

Polish for scale to hundreds of dashboards.

- Default `All Dashboards` row at the top of the sidebar. Always
  active when no smart view is selected, single-click clears any
  active view, and shows the total dashboard count so the catalog
  scope is legible at a glance.
- Count badge per smart view (`<name>  <count>`) computed against
  the same `dashboards` reference that drives the grid, so the
  badge and the visible result set move together.
- Stronger active state: 3px inset accent bar on the left edge
  (matches the AppNav rail) plus a subtle background tint and the
  label switches to weight 600. Reads as `you are here` from
  several feet away rather than a fragile bold-only cue.
- Quieter empty state: drop the `No smart views yet. Pin a tag
  filter combination to jump back to it.` paragraph. Now the
  sidebar shows the `SMART VIEWS` section header with its inline
  `+` affordance and a single subtle row-shaped `+ New Smart
  View` button. Nothing nags the user when no rules are
  configured.
- Density bump: 6/10px padding per row, 220px rail width (down
  from 240). Tighter than the previous gap-heavy layout and
  closer to professional catalog rails (Linear, Notion, Datadog
  monitor lists).
- Layout alignment: the page now wraps the sidebar + main column
  in a single `Container maw={1440}` so the rail no longer floats
  far-left while the content sits in a separately-centered 1200px
  column. Removes the visual `gap of dead space` between the two.

Sidebar is now a pure presentation component: counts and total
come in as props from the listing page, so the same evaluator
output drives both the grid and the badges. Keeps the component
reusable for Saved Searches in the followup PR-6 without an
internal `dashboards` import.
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

Pushed 5428dd85: catalog-clean polish on the sidebar so it scales to hundreds of dashboards without feeling noisy.

What changed:

  • All Dashboards default entry at the top of the rail. Always there, single-click clears any active smart view, shows the total dashboard count next to the label. The catalog scope is legible at a glance and users no longer have to know that "clicking the active view again clears it" is the way to deselect.
  • Per-view count badges. Each smart view shows <name> <count> where the count is computed off the same dashboards reference that drives the grid, so the badge and the result set never drift. Tabular numerals so columns of counts align cleanly.
  • Stronger active state. 3px inset accent bar on the left edge (mirrors AppNav's active-link pattern) plus a subtle background tint and the label switches to weight 600. Reads as "you are here" from across the screen rather than fragile bold-only emphasis.
  • Quiet empty state. No more "No smart views yet. Pin a tag filter combination to jump back to it." paragraph. The SMART VIEWS section header with its inline + is the primary affordance; a single subtle row-shaped + New Smart View button below it is the fallback. Nothing nags when zero views are configured.
  • Density bump. 6/10px padding per row, 220px rail width (down from 240). Closer to the catalog rails users expect from Linear, Notion, Datadog.
  • Layout alignment. The whole page is now inside a single Container maw={1440}, so the sidebar and the dashboard grid share one centered max-width instead of the sidebar floating far-left while the content sat in a separately-centered 1200px column. Closes the dead-space gap that was visible in the previous screenshot.

Sidebar is now a pure presentation layer. Counts and total come in as props from the listing page (totalCount, viewCounts: Record<string, number>), so the component stays reusable for the Saved Searches sidebar parity work without an internal dashboards import.

Out-of-scope for this PR but worth flagging for the catalog-at-scale direction:

  • Default view mode should probably flip to list when item count crosses a threshold (~25); the current grid eats a lot of vertical space at 100+.
  • Preset Dashboards (Services / ClickHouse / Kubernetes) is a top-of-fold three-card row that doesn't scale; folding those into a sidebar System section alongside the pre-built smart views feels right when the system-views work lands.
  • Virtualization or pagination on the team-dashboard grid once the listing endpoint returns hundreds.

Verified on the redeployed preview with a 5-dashboard / 2-smart-view seed mirroring the screenshot you shared.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant