feat(external-api): round-trip dashboard containers, tabs, and tile container/tab refs#2201
feat(external-api): round-trip dashboard containers, tabs, and tile container/tab refs#2201alex-fedotyev wants to merge 6 commits intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 815a063 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🔴 Tier 4 — CriticalTouches auth, data models, config, tasks, OTel pipeline, ClickHouse, or CI/CD. Why this tier:
Review process: Deep review from a domain expert. Synchronous walkthrough may be required. Stats
|
PR Review✅ No critical issues found. This is a well-scoped follow-up to #2015 that wires A few non-blocking observations for the team's consideration:
The split into structure-only + tile-ref helpers ( Out-of-scope items (MCP, heatmap, repeat, onClick) are tracked in linked issues per the PR description. |
E2E Test Results✅ All tests passed • 165 passed • 3 skipped • 1162s
Tests ran across 4 shards in parallel. |
After reading notes/principles/external-api-audit.md and walking through the UI surface (useDashboardContainers.tsx, DashboardContainer.tsx, GroupTabBar.tsx), three gaps were caught that the initial implementation missed. - OpenAPI parity: TileBase.containerId / tabId now declare minLength: 1 to match the Zod schema's z.string().min(1).optional(). The Zod fix landed in the previous commit but the OpenAPI didn't pick up the constraint until JSDoc was updated and openapi.json regenerated. - Test gap: explicit empty containers: [] now has its own round-trip test. The conversion normalizes [] back to absent on read (the existing length-guard makes this work), but the behavior wasn't asserted. - Test gap: tile.containerId or tile.tabId set to an empty string is now explicitly rejected. Previously this would have failed cross-field validation only because no real container has id "", not because the tile-level rule fired. UI invariants the API stays permissive about (auto-fixed by the UI rather than rejected) are documented in the per-feature code map under notes/repo-conventions/hyperdx/dashboards-containers-tabs.md in the workspace.
Compound Engineering ReviewFive specialist reviewers (TypeScript-quality, security, performance, architecture, simplicity) ran in parallel against the diff vs. base P1 — should resolve before merge
P2 — worth addressing
Verified non-issues
Reviewers run
|
Compound-review feedback on #2201: - Tighten internal `TileSchema.containerId`/`tabId` to `min(1).optional()` so an empty string isn't a valid id (would otherwise silently pass `tile.containerId !== undefined` checks). - Add `.max()` bounds on internal schemas: `id`/`title` capped at 256 chars (`DashboardContainerSchema`, `DashboardContainerTabSchema`), `tabs` capped at `DASHBOARD_CONTAINER_MAX_TABS = 20`, and dashboard-level `containers` capped at `DASHBOARD_MAX_CONTAINERS = 50`. The external API body schema now also caps `containers` so a client can't submit thousands of containers and trigger O(n*m) refine cost. - Collapse the three sequential `containers.forEach` passes (container-id uniqueness, tab-id uniqueness, container-by-id map) into a single pass. The map is now built INSIDE the duplicate-id guard so duplicates aren't masked by last-write-wins. A new short-circuit returns before tile-resolution if container ids weren't unique, so the user fixes the container layer first instead of getting cascading "unknown containerId" errors on top. - Extract `EXTERNAL_DASHBOARD_PROJECTION` constant in v2/dashboards.ts so the GET-list and GET-by-id projections stay in sync (this PR added `containers: 1` to both, the next field shouldn't have to). - Add three missing test cases: - PUT-path duplicate-container-id rejection. - Tile with `containerId` set when the dashboard omits the `containers` field entirely (was previously a NPE-by-coincidence on `data.containers ?? []`). - Tile in a tabbed container that omits `tabId` (renders in the container shell, not under any tab); guards that the schema doesn't accidentally force `tabId` onto every tile in a tabbed container. Cross-schema invariant lifting (the largest item the bot raised) is deferred to a follow-up so this PR stays scoped to the external API plus narrow internal-schema tightening.
|
P2 items addressed in 574ee8e:
Cross-schema invariant lifting + |
Deep Review✅ No critical issues found. The PR has already been through several compound-engineering review rounds (commits "tighten container/tab invariants per compound-review", "deep-review followups", "preserve containers on PUT and self-heal orphan refs"). Cross-reviewer attention turned up no P0/P1 defects, but agent-surface parity and a concurrent-PUT silent-self-heal interaction are worth surfacing. 🟡 P2 — recommended
🔵 P3 nitpicks (6)
Reviewers (10): correctness, testing, maintainability, project-standards, agent-native, learnings-researcher, security, api-contract, adversarial, kieran-typescript. Testing gaps:
|
Deep-review feedback on #2201, mechanical items from the May 7 pass: - Cap external tile `containerId`/`tabId` at 256 chars to mirror the internal `DashboardContainer` schema. The constant `DASHBOARD_CONTAINER_ID_MAX` is now exported from `@hyperdx/common-utils` so the external schema and the internal one pull from one source of truth. - Cap a single dashboard payload at 500 tiles via the new `DASHBOARD_MAX_TILES` constant. Without the cap, an external API caller could push a payload tens of MB into Mongo in one request. - Type the PUT setPayload as `Partial<IDashboard>` instead of `Record<string, unknown>` so a misnamed field fails at compile time. - Treat empty-string `containerId`/`tabId` on legacy Mongo docs as absent on read so dashboards predating the containers feature still round-trip through the now-stricter external schema (which enforces `min(1)`). Added a regression test that mutates Mongo directly to simulate the legacy state. - Replace `pick(externalTile, [...])` in `convertToInternalTileConfig` with explicit destructuring (mirroring the pattern in `convertTileToExternalChart`). The picked `name` was a stale top- level field on the resulting Tile (Tile has no top-level `name`); the rendered config still carries the name on `config.name`. - Extract `validateDashboardContainersConsistency` into `@hyperdx/common-utils/dist/types` so the canonical schema and the external-API request body schema agree on what a valid `{containers, tiles}` payload is. The external body's `superRefine` now delegates to the helper. - Drop the export on `DASHBOARD_CONTAINER_MAX_TABS` (used only by the schema definition next to it). - OpenAPI now publishes matching `maxLength: 256` on container/tab ids, `maxItems: 20` on `DashboardContainer.tabs`, `maxItems: 50` on the request `containers` array, and `maxItems: 500` on the request `tiles` array. Regenerated `openapi.json`. Boundary tests cover 256-char ids vs 257, 500-tile payloads vs 501, and the legacy empty-string read path. Helper has standalone unit tests in `v2/utils/__tests__/dashboards.test.ts`.
…ontainer/tab refs (#2150) PR #2015 added a dashboard organization layer (containers with optional tabs, tiles join a container via containerId and a tab via tabId) but the v2 external API was not updated to round-trip the new fields. External integrations that build dashboards programmatically had no way to use the new layer. This wires the full set of fields through CREATE / GET / LIST / UPDATE. Dashboards saved without containers round-trip unchanged (Mongoose returns an empty array for missing containers, so the conversion only emits the field when at least one container is present). The body schema validates that: - container ids are unique within the dashboard - tab ids are unique within their container - tile.containerId resolves to a real container - tile.tabId resolves to a tab inside the tile's container - tile.tabId requires tile.containerId to be set Tests cover create + get round-trip, update round-trip with re-homing tiles and dropping a container, optional-field defaults, all five validation rejections, and the no-containers backward-compat case. The conversion utilities also pick up containerId / tabId on the tile itself: convertToInternalTileConfig now extends its pick list (was the specific bug v2 of the plan missed) and the legacy series translator in externalApi.ts also propagates the fields so both code paths preserve them. Refs #2150, follows up on #2015 (7665fbe).
Empty-string values previously passed per-field validation and only hit the cross-field check (no container has id ''). Adding .min(1) matches the shared DashboardContainerSchema pattern and surfaces a field-level error instead.
After reading notes/principles/external-api-audit.md and walking through the UI surface (useDashboardContainers.tsx, DashboardContainer.tsx, GroupTabBar.tsx), three gaps were caught that the initial implementation missed. - OpenAPI parity: TileBase.containerId / tabId now declare minLength: 1 to match the Zod schema's z.string().min(1).optional(). The Zod fix landed in the previous commit but the OpenAPI didn't pick up the constraint until JSDoc was updated and openapi.json regenerated. - Test gap: explicit empty containers: [] now has its own round-trip test. The conversion normalizes [] back to absent on read (the existing length-guard makes this work), but the behavior wasn't asserted. - Test gap: tile.containerId or tile.tabId set to an empty string is now explicitly rejected. Previously this would have failed cross-field validation only because no real container has id "", not because the tile-level rule fired. UI invariants the API stays permissive about (auto-fixed by the UI rather than rejected) are documented in the per-feature code map under notes/repo-conventions/hyperdx/dashboards-containers-tabs.md in the workspace.
Compound-review feedback on #2201: - Tighten internal `TileSchema.containerId`/`tabId` to `min(1).optional()` so an empty string isn't a valid id (would otherwise silently pass `tile.containerId !== undefined` checks). - Add `.max()` bounds on internal schemas: `id`/`title` capped at 256 chars (`DashboardContainerSchema`, `DashboardContainerTabSchema`), `tabs` capped at `DASHBOARD_CONTAINER_MAX_TABS = 20`, and dashboard-level `containers` capped at `DASHBOARD_MAX_CONTAINERS = 50`. The external API body schema now also caps `containers` so a client can't submit thousands of containers and trigger O(n*m) refine cost. - Collapse the three sequential `containers.forEach` passes (container-id uniqueness, tab-id uniqueness, container-by-id map) into a single pass. The map is now built INSIDE the duplicate-id guard so duplicates aren't masked by last-write-wins. A new short-circuit returns before tile-resolution if container ids weren't unique, so the user fixes the container layer first instead of getting cascading "unknown containerId" errors on top. - Extract `EXTERNAL_DASHBOARD_PROJECTION` constant in v2/dashboards.ts so the GET-list and GET-by-id projections stay in sync (this PR added `containers: 1` to both, the next field shouldn't have to). - Add three missing test cases: - PUT-path duplicate-container-id rejection. - Tile with `containerId` set when the dashboard omits the `containers` field entirely (was previously a NPE-by-coincidence on `data.containers ?? []`). - Tile in a tabbed container that omits `tabId` (renders in the container shell, not under any tab); guards that the schema doesn't accidentally force `tabId` onto every tile in a tabbed container. Cross-schema invariant lifting (the largest item the bot raised) is deferred to a follow-up so this PR stays scoped to the external API plus narrow internal-schema tightening.
Deep-review feedback on #2201, mechanical items from the May 7 pass: - Cap external tile `containerId`/`tabId` at 256 chars to mirror the internal `DashboardContainer` schema. The constant `DASHBOARD_CONTAINER_ID_MAX` is now exported from `@hyperdx/common-utils` so the external schema and the internal one pull from one source of truth. - Cap a single dashboard payload at 500 tiles via the new `DASHBOARD_MAX_TILES` constant. Without the cap, an external API caller could push a payload tens of MB into Mongo in one request. - Type the PUT setPayload as `Partial<IDashboard>` instead of `Record<string, unknown>` so a misnamed field fails at compile time. - Treat empty-string `containerId`/`tabId` on legacy Mongo docs as absent on read so dashboards predating the containers feature still round-trip through the now-stricter external schema (which enforces `min(1)`). Added a regression test that mutates Mongo directly to simulate the legacy state. - Replace `pick(externalTile, [...])` in `convertToInternalTileConfig` with explicit destructuring (mirroring the pattern in `convertTileToExternalChart`). The picked `name` was a stale top- level field on the resulting Tile (Tile has no top-level `name`); the rendered config still carries the name on `config.name`. - Extract `validateDashboardContainersConsistency` into `@hyperdx/common-utils/dist/types` so the canonical schema and the external-API request body schema agree on what a valid `{containers, tiles}` payload is. The external body's `superRefine` now delegates to the helper. - Drop the export on `DASHBOARD_CONTAINER_MAX_TABS` (used only by the schema definition next to it). - OpenAPI now publishes matching `maxLength: 256` on container/tab ids, `maxItems: 20` on `DashboardContainer.tabs`, `maxItems: 50` on the request `containers` array, and `maxItems: 500` on the request `tiles` array. Regenerated `openapi.json`. Boundary tests cover 256-char ids vs 257, 500-tile payloads vs 501, and the legacy empty-string read path. Helper has standalone unit tests in `v2/utils/__tests__/dashboards.test.ts`.
2e727a9 to
7b8b8ad
Compare
Deep-review feedback on #2201, P0/P1 + critical P2 items: - Move tile-level container/tab ref resolution out of the request body schema and into the POST and PUT handlers. The schema-level superRefine called the helper with `data.containers ?? []`, which rejected any tile that referenced a real container when the PUT body omitted `containers` (the documented preserve-on-omit branch). The handler now resolves against the effective container set (body containers OR existing dashboard containers), so a PUT that updates only `tiles` and keeps a tile homed in a preserved container succeeds. - Split `validateDashboardContainersConsistency` into a structure-only pass and a tile-ref-only pass; keep the composite for backward compatibility. The body schema now calls the structure-only helper; handlers run the tile-ref pass via a new `collectTileContainerRefIssues` wrapper that returns formatted `path: message` strings consistent with `validateRequestWithEnhancedErrors`. - Self-heal orphan tile.containerId / tile.tabId on read. A doc may carry a tile pointing at a container that has since been removed, or a tab that no longer exists in its container; round-trip these as if the ref were absent so a subsequent PUT validates instead of failing schema with "Tile references unknown containerId". Each drop is logged with the dashboard id, tile id, and offending ref. The PUT projection now fetches `containers` from Mongo so the fallback can resolve. - Document in OpenAPI that PUT does not support optimistic concurrency; concurrent PUTs may silently overwrite each other. Adding ETag-style concurrency would be a breaking change to the request shape and is left for a follow-up. Tests: - 4 integration tests at the request layer: - PUT that omits `containers` with tiles homed in preserved containers; expects 200 + containers preserved on response. - PUT that omits `containers` and references an unknown containerId; expects 400. - GET on a doc whose tile.containerId no longer matches; expects `containerId` and `tabId` absent on response. - GET on a doc whose tile.tabId no longer matches the container's tabs; expects `tabId` absent, `containerId` preserved. - 3 unit tests on `collectTileContainerRefIssues` for the empty, unknown-containerId, and tabId-without-containerId paths. - 4 unit tests on `convertToExternalDashboard` for each orphan-heal branch plus the full-resolution case.
Summary
PR #2015 added a dashboard organization layer (containers with optional tabs, plus per-tile
containerIdandtabId) but the v2 external API was not updated to round-trip the new fields. External integrations that build dashboards programmatically had no way to use the new layer.This wires the full set of fields through
CREATE/GET/LIST/UPDATEon/api/v2/dashboards. Dashboards saved without containers round-trip unchanged.Closes #2150. Follow-up to #2015 (commit 7665fbe).
What's in scope
containers: DashboardContainer[]?(imported from@hyperdx/common-utils) and the tile schema gainscontainerId?andtabId?.convertToExternalDashboardnow emitscontainers(only when at least one is present, so dashboards without the layer round-trip with the field absent).convertTileToExternalChartandconvertToInternalTileConfigpropagatecontainerIdandtabId. The legacyseries-format translator inexternalApi.tsalso propagates them so both code paths preserve the fields.containers: 1projection is added to the MongoosefindandfindOnecalls.containerIdresolves to a real containertabIdresolves to a tab inside that containertabIdrequirescontainerIdto be setDashboardContainer,DashboardContainerTab, the new tile fields, and the new dashboard field onDashboard/CreateDashboardRequest/UpdateDashboardRequest.openapi.jsonregenerated.Out of scope
Each item below has a tracking issue so the gap is visible after merge.
type: 'section'discriminator removed in refactor: Unify section/group into single Group with collapsible/bordered options #2015 (commit 7665fbe). Not emitted, not validated against. Legacy documents parse cleanly via Zod's strip-unknown. No follow-up needed; documenting for clarity.repeatfield (future work per refactor: Unify section/group into single Group with collapsible/bordered options #2015 review notes). Tracked in External Dashboards API: support container repeat field #2213.hyperdx_save_dashboardtool is unchanged in this PR. Its inlineinputSchemadoes not yet exposecontainers, and adding it there is its own follow-up; doing both at once would tangle two surfaces. Tracked in External Dashboards API: expose containers and tabs in MCP hyperdx_save_dashboard #2212.onClickdrill-down round-trip is tracked in External Dashboards API: support per-tile onClick drill-down #2214.Tier
The triage classifier marks
packages/api/src/routers/external-api/v2/*as critical-path, so this lands as Tier 4 by directory rule, even though the diff is small (~284 prod lines) and additive. Splitting further would separate the body schema, the conversion utilities, and the route wiring from each other and not actually reduce review burden. Happy to break this up if there's a preferred way to slice it.Test plan
yarn ci:lint(lint + tsc + spectral) on@hyperdx/common-utils,@hyperdx/api,@hyperdx/appyarn knip(no new unused exports)yarn jest dashboards.test.ts -t "Containers and tabs", all 8 new tests passyarn jest dashboards.test.ts, 86/86 tests pass (no regressions in old or new format suites)yarn jest src/mcp/__tests__/dashboards.test.ts, 19/19 MCP dashboard tests pass (the MCP body schema shares with the external API body schema, so this confirms the new validations don't break the MCP path)openapi.jsonregenerated and committed; spectral lint passes