diff --git a/.changeset/add-accesskey-index.md b/.changeset/add-accesskey-index.md new file mode 100644 index 0000000000..4a9b44d73c --- /dev/null +++ b/.changeset/add-accesskey-index.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": minor +--- + +Add unique MongoDB index on accessKey field in User model to eliminate full collection scans during API key authentication. This could cause startup failures if any existing users share duplicate accessKey values. diff --git a/.changeset/good-swans-hammer.md b/.changeset/good-swans-hammer.md new file mode 100644 index 0000000000..a5ba3e0de5 --- /dev/null +++ b/.changeset/good-swans-hammer.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/common-utils": patch +--- + +Adding filters to dashboard exports. Implemented validation on dashboard imports to catch potential issues with generated JSON or manually tweaked JSON. diff --git a/.changeset/mcp-dashboard-improvements.md b/.changeset/mcp-dashboard-improvements.md new file mode 100644 index 0000000000..375224e649 --- /dev/null +++ b/.changeset/mcp-dashboard-improvements.md @@ -0,0 +1,21 @@ +--- +'@hyperdx/api': patch +--- + +feat(mcp): add patch_dashboard, get_dashboard_tile, search_dashboards tools + +Add three new MCP dashboard tools for granular operations: + +- `hyperdx_get_dashboard_tile` — retrieve a single tile by tileId +- `hyperdx_patch_dashboard` — update name/tags and/or replace one tile + without resubmitting the full dashboard +- `hyperdx_search_dashboards` — search by name and/or tags + +Fix empty parameter schema on patch/search tools caused by Zod +`.refine()` wrapping. Document Lucene substring matching limitations +prominently in tool descriptions and query guide prompt. + +**Breaking (minor):** Tile `name` on `hyperdx_save_dashboard` now requires +at least 1 character (`.min(1)`). Previously empty string `""` was accepted +and silently persisted as a blank title. Callers sending `name: ""` will +now receive a validation error. diff --git a/.changeset/page-header-z-index-below-drawers.md b/.changeset/page-header-z-index-below-drawers.md new file mode 100644 index 0000000000..31d4dd178e --- /dev/null +++ b/.changeset/page-header-z-index-below-drawers.md @@ -0,0 +1,20 @@ +--- +'@hyperdx/app': patch +--- + +fix(z-index): keep sticky header below drawers and drawers above the fullscreen tile modal + +Two related z-index regressions: + +- `PageHeader` was pinned at `z-index: 100`, but app drawers opt into a + much lower stack via `ZIndexContext` (`contextZIndex + 10`, so a + top-level drawer renders at `z-index: 10`). The sticky header therefore + floated above the drawer overlay. The header now sits at `z-index: 2` so + drawer overlays reliably cover the page chrome while the header still + wins against normal scrolling content. +- `FullscreenPanelModal` used Mantine's default modal z-index (`200`) and + didn't propagate it through `ZIndexContext`. Clicking a row in a + fullscreen search tile opened a `DBRowSidePanel` drawer at `z-index: 10` + that was hidden behind the modal. The modal now follows the existing + `contextZIndex + 10` pattern and wraps its children in a + `ZIndexContext.Provider`, so child drawers stack on top of it. diff --git a/.changeset/page-migrate-dashboard.md b/.changeset/page-migrate-dashboard.md new file mode 100644 index 0000000000..dc70c2ec0a --- /dev/null +++ b/.changeset/page-migrate-dashboard.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': patch +--- + +chore: migrate the custom Dashboard page to shared `PageLayout` / `PageHeader`. Breadcrumbs, the editable dashboard name, dashboard actions (Favorite, Tags, Menu), and the "Created by … Updated …" meta now live in a single page header, while the query toolbar (SQL/Lucene WHERE, time range, granularity, Live, refresh, edit filters, Run) is pinned to the top of the scroll container as a dedicated sticky row — the chrome above scrolls away and only the toolbar follows the user. The "Updated …" meta moves to the right side of the breadcrumbs row instead of sitting as a separate body line. + +`PageHeader` gains a `stickyRow` slot that any page can use to declare a single row that should be the only pinned element, with the rest of the header treated as scrolling chrome. Other pages are unaffected — a `PageHeader` without `stickyRow` keeps the existing fully-sticky behavior. diff --git a/.changeset/rename-mcp-tools-to-clickstack.md b/.changeset/rename-mcp-tools-to-clickstack.md new file mode 100644 index 0000000000..70347fa33c --- /dev/null +++ b/.changeset/rename-mcp-tools-to-clickstack.md @@ -0,0 +1,10 @@ +--- +"@hyperdx/api": patch +"@hyperdx/common-utils": patch +--- + +refactor(mcp): rename all MCP tool prefixes from `hyperdx_` to `clickstack_` + +Rename the MCP server name from `hyperdx` to `clickstack` and update all 19 +tool names (e.g. `hyperdx_search` → `clickstack_search`), along with +descriptions, prompts, error messages, and test references. diff --git a/.changeset/source-chip-kebab-menu.md b/.changeset/source-chip-kebab-menu.md new file mode 100644 index 0000000000..9ec99239ab --- /dev/null +++ b/.changeset/source-chip-kebab-menu.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +feat(source-picker): chip + kebab menu UX diff --git a/.github/actions/e2e-setup/action.yml b/.github/actions/e2e-setup/action.yml new file mode 100644 index 0000000000..c2d2c80c5b --- /dev/null +++ b/.github/actions/e2e-setup/action.yml @@ -0,0 +1,91 @@ +name: E2E Setup +description: | + Shared setup for E2E test workflows: sets the HDX_E2E_* port env vars and + common Playwright env vars, then runs Node.js setup, dependency install, + build, Playwright browser install, and starts the E2E Docker Compose stack + (MongoDB + ClickHouse) with health checks. The caller is responsible for + `actions/checkout`, running Playwright, uploading artifacts, and tearing + down the Docker stack. +runs: + using: composite + steps: + # E2E port configuration (slot 0 defaults — must match scripts/test-e2e.sh) + - name: Set E2E environment variables + shell: bash + run: | + MONGO_PORT=21100 + echo "HDX_E2E_SLOT=0" >> $GITHUB_ENV + echo "HDX_E2E_OPAMP_PORT=20320" >> $GITHUB_ENV + echo "HDX_E2E_CH_PORT=20500" >> $GITHUB_ENV + echo "HDX_E2E_CH_NATIVE_PORT=20600" >> $GITHUB_ENV + echo "HDX_E2E_API_PORT=21000" >> $GITHUB_ENV + echo "HDX_E2E_MONGO_PORT=$MONGO_PORT" >> $GITHUB_ENV + echo "HDX_E2E_APP_LOCAL_PORT=21200" >> $GITHUB_ENV + echo "HDX_E2E_APP_PORT=21300" >> $GITHUB_ENV + echo "MONGO_URI=mongodb://localhost:$MONGO_PORT/hyperdx-e2e" >> $GITHUB_ENV + echo "E2E_FULLSTACK=true" >> $GITHUB_ENV + echo "E2E_UNIQUE_USER=true" >> $GITHUB_ENV + echo "E2E_API_HEALTH_CHECK_MAX_RETRIES=60" >> $GITHUB_ENV + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache-dependency-path: yarn.lock + cache: yarn + + - name: Install dependencies + shell: bash + run: yarn install + + - name: Build dependencies + shell: bash + run: npx nx run-many -t ci:build + + - name: Install Playwright browsers + shell: bash + run: cd packages/app && npx playwright install --with-deps chromium + + - name: Start E2E Docker Compose + shell: bash + run: | + # Pre-pull images with retries to handle transient Docker Hub timeouts + for attempt in 1 2 3; do + if docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml pull; then + echo "Docker images pulled successfully on attempt $attempt" + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to pull Docker images after 3 attempts" + exit 1 + fi + echo "Docker pull failed (attempt $attempt/3), retrying in 10s..." + sleep 10 + done + docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml up -d + echo "Waiting for MongoDB..." + for i in $(seq 1 30); do + if docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml exec -T db mongosh --quiet --eval "db.adminCommand({ping:1})" >/dev/null 2>&1; then + echo "MongoDB is ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "MongoDB failed to become ready after 30 seconds" + exit 1 + fi + echo "Waiting for MongoDB... ($i/30)" + sleep 1 + done + echo "Waiting for ClickHouse..." + for i in $(seq 1 60); do + if curl -sf http://localhost:${HDX_E2E_CH_PORT}/ping >/dev/null 2>&1; then + echo "ClickHouse is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "ClickHouse failed to become ready after 60 seconds" + exit 1 + fi + echo "Waiting for ClickHouse... ($i/60)" + sleep 1 + done diff --git a/.github/actions/e2e-teardown/action.yml b/.github/actions/e2e-teardown/action.yml new file mode 100644 index 0000000000..b9e78e3140 --- /dev/null +++ b/.github/actions/e2e-teardown/action.yml @@ -0,0 +1,35 @@ +name: E2E Teardown +description: | + Upload Playwright artifacts and stop the E2E Docker Compose stack. + Always runs (even on failure) so containers are cleaned up and reports + are available for failed runs. +inputs: + artifact-suffix: + description: > + Suffix appended to artifact names, e.g. "1" → playwright-report-1. + required: true +runs: + using: composite + steps: + - name: Upload Playwright report + uses: actions/upload-artifact@v7 + if: always() + with: + name: playwright-report-${{ inputs.artifact-suffix }} + path: packages/app/playwright-report/ + retention-days: 30 + + - name: Upload test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-${{ inputs.artifact-suffix }} + path: packages/app/test-results/ + retention-days: 30 + + - name: Stop E2E containers + if: always() + shell: bash + run: + docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml + down -v diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 5eb9f24904..9cd34cb1fd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -14,109 +14,20 @@ jobs: matrix: shard: [1, 2, 3, 4] - # E2E port configuration (slot 0 defaults — must match scripts/test-e2e.sh) - env: - HDX_E2E_SLOT: '0' - HDX_E2E_OPAMP_PORT: '20320' - HDX_E2E_CH_PORT: '20500' - HDX_E2E_CH_NATIVE_PORT: '20600' - HDX_E2E_API_PORT: '21000' - HDX_E2E_MONGO_PORT: '21100' - HDX_E2E_APP_LOCAL_PORT: '21200' - HDX_E2E_APP_PORT: '21300' - steps: - name: Checkout uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache-dependency-path: 'yarn.lock' - cache: 'yarn' - - - name: Install dependencies - run: yarn install - - - name: Build dependencies - run: npx nx run-many -t ci:build - - - name: Install Playwright browsers - run: cd packages/app && npx playwright install --with-deps chromium - - - name: Start E2E Docker Compose - run: | - # Pre-pull images with retries to handle transient Docker Hub timeouts - for attempt in 1 2 3; do - if docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml pull; then - echo "Docker images pulled successfully on attempt $attempt" - break - fi - if [ "$attempt" -eq 3 ]; then - echo "Failed to pull Docker images after 3 attempts" - exit 1 - fi - echo "Docker pull failed (attempt $attempt/3), retrying in 10s..." - sleep 10 - done - docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml up -d - echo "Waiting for MongoDB..." - for i in $(seq 1 30); do - if docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml exec -T db mongosh --quiet --eval "db.adminCommand({ping:1})" >/dev/null 2>&1; then - echo "MongoDB is ready" - break - fi - if [ "$i" -eq 30 ]; then - echo "MongoDB failed to become ready after 30 seconds" - exit 1 - fi - echo "Waiting for MongoDB... ($i/30)" - sleep 1 - done - echo "Waiting for ClickHouse..." - for i in $(seq 1 60); do - if curl -sf http://localhost:${HDX_E2E_CH_PORT}/ping >/dev/null 2>&1; then - echo "ClickHouse is ready" - break - fi - if [ "$i" -eq 60 ]; then - echo "ClickHouse failed to become ready after 60 seconds" - exit 1 - fi - echo "Waiting for ClickHouse... ($i/60)" - sleep 1 - done + - name: E2E setup + uses: ./.github/actions/e2e-setup - name: Run Playwright tests (full-stack mode) - env: - E2E_FULLSTACK: 'true' - E2E_UNIQUE_USER: 'true' - E2E_API_HEALTH_CHECK_MAX_RETRIES: '60' - MONGO_URI: - mongodb://localhost:${{ env.HDX_E2E_MONGO_PORT }}/hyperdx-e2e run: | cd packages/app yarn test:e2e --shard=${{ matrix.shard }}/4 - - name: Upload Playwright report - uses: actions/upload-artifact@v7 - if: always() - with: - name: playwright-report-${{ matrix.shard }} - path: packages/app/playwright-report/ - retention-days: 30 - - - name: Upload test results - uses: actions/upload-artifact@v7 + - name: E2E teardown if: always() + uses: ./.github/actions/e2e-teardown with: - name: test-results-${{ matrix.shard }} - path: packages/app/test-results/ - retention-days: 30 - - - name: Stop E2E containers - if: always() - run: - docker compose -p e2e-0 -f packages/app/tests/e2e/docker-compose.yml - down -v + artifact-suffix: ${{ matrix.shard }} diff --git a/AGENTS.md b/AGENTS.md index 6455078d16..70dbe826a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,19 +71,6 @@ and lint issues across all packages. Pre-commit hooks handle this when committing, but if you finish edits without committing, run `yarn lint:fix` before stopping. -## Before Writing New Utility Functions - -Before implementing a new parsing, formatting, or transformation function, -search for existing functions that already do the same thing or something close. -Common places to check: - -- `packages/common-utils/src/core/metadata.ts` — key/column parsing utilities - (e.g. `parseKeyPath`) -- `packages/common-utils/src/queryParser.ts` — Lucene parsing (`parse`, - `decodeSpecialTokens`), AST type guards, serialization helpers -- `packages/common-utils/src/filters.ts` — filter state serialization and - parsing (`filtersToQuery`, `parseLuceneFilter`) - ## Key Principles 1. **Multi-tenancy**: All data is scoped to `Team` - ensure proper filtering diff --git a/MCP.md b/MCP.md index 7f8e89af8c..3616e5c9a6 100644 --- a/MCP.md +++ b/MCP.md @@ -1,4 +1,4 @@ -# HyperDX MCP Server +# ClickStack MCP Server HyperDX exposes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that @@ -41,7 +41,7 @@ instance URL (e.g. `http://localhost:8080`). ### Claude Code ```bash -claude mcp add --transport http hyperdx /api/mcp \ +claude mcp add --transport http clickstack /api/mcp \ --header "Authorization: Bearer " ``` @@ -51,7 +51,7 @@ Add the following to your [OpenCode config](https://opencode.ai/docs/config): ```json "mcp": { - "hyperdx": { + "clickstack": { "enabled": true, "type": "remote", "url": "/api/mcp", @@ -70,7 +70,7 @@ settings): ```json { "mcpServers": { - "hyperdx": { + "clickstack": { "url": "/api/mcp", "headers": { "Authorization": "Bearer " @@ -108,22 +108,22 @@ with: | Tool | Description | | ----------------------------- | -------------------------------------------------------------------------------------------- | -| `hyperdx_list_sources` | List all data sources and connections as a lightweight catalog (IDs, names, kinds) | -| `hyperdx_describe_source` | Full column schema, attribute keys, and sampled low-cardinality values for a single source | -| `hyperdx_timeseries` | Plot metrics over time as a line or stacked bar chart | -| `hyperdx_table` | Compute aggregated metrics as a table, single number, or pie chart | -| `hyperdx_search` | Browse individual log, event, or trace rows | -| `hyperdx_event_patterns` | Discover the most common log messages and event patterns using Drain clustering | -| `hyperdx_event_deltas` | Compare two row groups and rank properties by how their value distributions differ | -| `hyperdx_sql` | Execute raw ClickHouse SQL for advanced queries (JOINs, CTEs, sub-queries) | -| `hyperdx_get_dashboard` | List all dashboards or get full detail for a specific dashboard | -| `hyperdx_save_dashboard` | Create or update a dashboard with tiles (charts, tables, numbers, search, markdown) | -| `hyperdx_delete_dashboard` | Permanently delete a dashboard and its attached alerts | -| `hyperdx_query_tile` | Execute the query for a specific dashboard tile to validate results | -| `hyperdx_get_saved_search` | List all saved searches or get full detail for a specific saved search | -| `hyperdx_save_saved_search` | Create or update a saved search (reusable query against a data source) | -| `hyperdx_get_alert` | List alerts (summary) or get full detail with evaluation history; filter by state | -| `hyperdx_save_alert` | Create a new alert or update an existing one | -| `hyperdx_trace_waterfall` | Fetch all spans in a single trace as a parent/child waterfall tree with optional correlated logs | -| `hyperdx_trace_top_time_consuming_operations` | Aggregate breakdown of child operations by cumulative time across matching parent traces | -| `hyperdx_get_webhook` | List available webhook destinations for use as alert notification channels | +| `clickstack_list_sources` | List all data sources and connections as a lightweight catalog (IDs, names, kinds) | +| `clickstack_describe_source` | Full column schema, attribute keys, and sampled low-cardinality values for a single source | +| `clickstack_timeseries` | Plot metrics over time as a line or stacked bar chart | +| `clickstack_table` | Compute aggregated metrics as a table, single number, or pie chart | +| `clickstack_search` | Browse individual log, event, or trace rows | +| `clickstack_event_patterns` | Discover the most common log messages and event patterns using Drain clustering | +| `clickstack_event_deltas` | Compare two row groups and rank properties by how their value distributions differ | +| `clickstack_sql` | Execute raw ClickHouse SQL for advanced queries (JOINs, CTEs, sub-queries) | +| `clickstack_get_dashboard` | List all dashboards or get full detail for a specific dashboard | +| `clickstack_save_dashboard` | Create or update a dashboard with tiles (charts, tables, numbers, search, markdown) | +| `clickstack_delete_dashboard` | Permanently delete a dashboard and its attached alerts | +| `clickstack_query_tile` | Execute the query for a specific dashboard tile to validate results | +| `clickstack_get_saved_search` | List all saved searches or get full detail for a specific saved search | +| `clickstack_save_saved_search` | Create or update a saved search (reusable query against a data source) | +| `clickstack_get_alert` | List alerts (summary) or get full detail with evaluation history; filter by state | +| `clickstack_save_alert` | Create a new alert or update an existing one | +| `clickstack_trace_waterfall` | Fetch all spans in a single trace as a parent/child waterfall tree with optional correlated logs | +| `clickstack_trace_top_time_consuming_operations` | Aggregate breakdown of child operations by cumulative time across matching parent traces | +| `clickstack_get_webhook` | List available webhook destinations for use as alert notification channels | diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js index 8fc45f0f9d..bfb2f9c832 100644 --- a/packages/api/jest.config.js +++ b/packages/api/jest.config.js @@ -10,7 +10,7 @@ module.exports = { testEnvironment: 'node', verbose: true, rootDir: './src', - testMatch: ['**/__tests__/*.test.ts?(x)'], + testMatch: ['**/__tests__/**/*.test.ts?(x)'], testTimeout: 30000, moduleNameMapper: { '@/(.*)$': '/$1', diff --git a/packages/api/src/mcp/__tests__/alerts.test.ts b/packages/api/src/mcp/__tests__/alerts.test.ts index 05c39a3daf..d31d851258 100644 --- a/packages/api/src/mcp/__tests__/alerts.test.ts +++ b/packages/api/src/mcp/__tests__/alerts.test.ts @@ -124,15 +124,15 @@ describe('MCP Alert Tools', () => { }).save(); } - // ─── hyperdx_get_alert ──────────────────────────────────────────────────── + // ─── clickstack_get_alert ──────────────────────────────────────────────────── - describe('hyperdx_get_alert', () => { + describe('clickstack_get_alert', () => { describe('list (no id)', () => { it('should list all alerts with slim summary fields', async () => { await createTestAlert({ name: 'Alert 1' }); await createTestAlert({ name: 'Alert 2' }); - const result = await callTool(client, 'hyperdx_get_alert', {}); + const result = await callTool(client, 'clickstack_get_alert', {}); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -153,7 +153,7 @@ describe('MCP Alert Tools', () => { }); it('should return empty array when no alerts exist', async () => { - const result = await callTool(client, 'hyperdx_get_alert', {}); + const result = await callTool(client, 'clickstack_get_alert', {}); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -165,7 +165,7 @@ describe('MCP Alert Tools', () => { await createTestAlert({ name: 'OK', state: AlertState.OK }); await createTestAlert({ name: 'Disabled', state: AlertState.DISABLED }); - const result = await callTool(client, 'hyperdx_get_alert', { + const result = await callTool(client, 'clickstack_get_alert', { state: 'ALERT', }); @@ -179,7 +179,7 @@ describe('MCP Alert Tools', () => { it('should return empty array when no alerts match state filter', async () => { await createTestAlert({ name: 'All Good', state: AlertState.OK }); - const result = await callTool(client, 'hyperdx_get_alert', { + const result = await callTool(client, 'clickstack_get_alert', { state: 'ALERT', }); @@ -198,7 +198,7 @@ describe('MCP Alert Tools', () => { const client2 = await createTestClient(otherTeamContext); // List should be empty for the other team - const listResult = await callTool(client2, 'hyperdx_get_alert', {}); + const listResult = await callTool(client2, 'clickstack_get_alert', {}); const output = JSON.parse(getFirstText(listResult)); expect(output).toHaveLength(0); @@ -220,7 +220,7 @@ describe('MCP Alert Tools', () => { // no name set }).save(); - const result = await callTool(client, 'hyperdx_get_alert', {}); + const result = await callTool(client, 'clickstack_get_alert', {}); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -244,7 +244,7 @@ describe('MCP Alert Tools', () => { // no name set }).save(); - const result = await callTool(client, 'hyperdx_get_alert', {}); + const result = await callTool(client, 'clickstack_get_alert', {}); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -257,7 +257,7 @@ describe('MCP Alert Tools', () => { it('should get full alert detail with history when valid id is provided', async () => { const alert = await createTestAlert({ name: 'Detail Test' }); - const result = await callTool(client, 'hyperdx_get_alert', { + const result = await callTool(client, 'clickstack_get_alert', { id: alert._id.toString(), }); @@ -274,7 +274,7 @@ describe('MCP Alert Tools', () => { }); it('should return error for invalid ObjectId format', async () => { - const result = await callTool(client, 'hyperdx_get_alert', { + const result = await callTool(client, 'clickstack_get_alert', { id: 'not-a-valid-id', }); @@ -284,7 +284,7 @@ describe('MCP Alert Tools', () => { it('should return error for non-existent alert id', async () => { const fakeId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_get_alert', { + const result = await callTool(client, 'clickstack_get_alert', { id: fakeId, }); @@ -301,7 +301,7 @@ describe('MCP Alert Tools', () => { }; const client2 = await createTestClient(otherTeamContext); - const getResult = await callTool(client2, 'hyperdx_get_alert', { + const getResult = await callTool(client2, 'clickstack_get_alert', { id: alert._id.toString(), }); expect(getResult.isError).toBe(true); @@ -312,15 +312,15 @@ describe('MCP Alert Tools', () => { }); }); - // ─── hyperdx_save_alert ─────────────────────────────────────────────────── + // ─── clickstack_save_alert ─────────────────────────────────────────────────── - describe('hyperdx_save_alert', () => { + describe('clickstack_save_alert', () => { describe('create', () => { it('should create a saved-search alert', async () => { const savedSearch = await createTestSavedSearch(); const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'saved_search', savedSearchId: savedSearch._id.toString(), threshold: 50, @@ -346,7 +346,7 @@ describe('MCP Alert Tools', () => { const dashboard = await createTestDashboardWithTile(); const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'tile', dashboardId: dashboard._id.toString(), tileId: 'tile-1', @@ -371,7 +371,7 @@ describe('MCP Alert Tools', () => { it('should reject tile source without dashboardId', async () => { const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'tile', tileId: 'tile-1', threshold: 100, @@ -391,7 +391,7 @@ describe('MCP Alert Tools', () => { const dashboard = await createTestDashboardWithTile(); const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'tile', dashboardId: dashboard._id.toString(), threshold: 100, @@ -410,7 +410,7 @@ describe('MCP Alert Tools', () => { it('should reject saved_search source without savedSearchId', async () => { const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'saved_search', threshold: 100, thresholdType: 'above', @@ -429,7 +429,7 @@ describe('MCP Alert Tools', () => { const webhook = await createTestWebhook(); const fakeId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'saved_search', savedSearchId: fakeId, threshold: 100, @@ -448,7 +448,7 @@ describe('MCP Alert Tools', () => { it('should reject webhook channel without webhookId', async () => { const savedSearch = await createTestSavedSearch(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'saved_search', savedSearchId: savedSearch._id.toString(), threshold: 100, @@ -467,7 +467,7 @@ describe('MCP Alert Tools', () => { const savedSearch = await createTestSavedSearch(); const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'saved_search', savedSearchId: savedSearch._id.toString(), threshold: 100, @@ -487,7 +487,7 @@ describe('MCP Alert Tools', () => { const savedSearch = await createTestSavedSearch(); const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { source: 'saved_search', savedSearchId: savedSearch._id.toString(), threshold: 75, @@ -532,7 +532,7 @@ describe('MCP Alert Tools', () => { name: 'Original Name', }).save(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { id: alert._id.toString(), source: 'saved_search', savedSearchId: savedSearch._id.toString(), @@ -559,7 +559,7 @@ describe('MCP Alert Tools', () => { const webhook = await createTestWebhook(); const fakeId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { id: fakeId, source: 'saved_search', savedSearchId: savedSearch._id.toString(), @@ -580,7 +580,7 @@ describe('MCP Alert Tools', () => { const savedSearch = await createTestSavedSearch(); const webhook = await createTestWebhook(); - const result = await callTool(client, 'hyperdx_save_alert', { + const result = await callTool(client, 'clickstack_save_alert', { id: '!!!', source: 'saved_search', savedSearchId: savedSearch._id.toString(), @@ -599,9 +599,9 @@ describe('MCP Alert Tools', () => { }); }); - // ─── hyperdx_get_webhook ────────────────────────────────────────────────── + // ─── clickstack_get_webhook ────────────────────────────────────────────────── - describe('hyperdx_get_webhook', () => { + describe('clickstack_get_webhook', () => { it('should list all webhooks with slim fields', async () => { await Webhook.create({ team: team._id, @@ -616,7 +616,7 @@ describe('MCP Alert Tools', () => { url: 'https://example.com/hook2', }); - const result = await callTool(client, 'hyperdx_get_webhook', {}); + const result = await callTool(client, 'clickstack_get_webhook', {}); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -635,7 +635,7 @@ describe('MCP Alert Tools', () => { }); it('should return empty array when no webhooks exist', async () => { - const result = await callTool(client, 'hyperdx_get_webhook', {}); + const result = await callTool(client, 'clickstack_get_webhook', {}); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -656,7 +656,7 @@ describe('MCP Alert Tools', () => { }; const client2 = await createTestClient(otherTeamContext); - const listResult = await callTool(client2, 'hyperdx_get_webhook', {}); + const listResult = await callTool(client2, 'clickstack_get_webhook', {}); const output = JSON.parse(getFirstText(listResult)); expect(output).toHaveLength(0); diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts deleted file mode 100644 index 6ed04e2ff9..0000000000 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ /dev/null @@ -1,2509 +0,0 @@ -import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import mongoose from 'mongoose'; - -import * as config from '@/config'; -import { - DEFAULT_DATABASE, - DEFAULT_TRACES_TABLE, - getLoggedInAgent, - getServer, -} from '@/fixtures'; -import Connection from '@/models/connection'; -import Dashboard from '@/models/dashboard'; -import { Source } from '@/models/source'; -import type { ExternalDashboardTileWithId } from '@/utils/zod'; - -import { - buildCreateDashboardPrompt, - buildDashboardExamplesPrompt, - buildQueryGuidePrompt, -} from '../prompts/dashboards/content'; -import { buildSourceSummary } from '../prompts/dashboards/helpers'; -import { McpContext } from '../tools/types'; -import { callTool, createTestClient, getFirstText } from './mcpTestUtils'; - -describe('MCP Dashboard Tools', () => { - const server = getServer(); - let team: any; - let user: any; - let traceSource: any; - let connection: any; - let client: Client; - - beforeAll(async () => { - await server.start(); - }); - - beforeEach(async () => { - const result = await getLoggedInAgent(server); - team = result.team; - user = result.user; - - connection = await Connection.create({ - team: team._id, - name: 'Default', - host: config.CLICKHOUSE_HOST, - username: config.CLICKHOUSE_USER, - password: config.CLICKHOUSE_PASSWORD, - }); - - traceSource = await Source.create({ - kind: SourceKind.Trace, - team: team._id, - from: { - databaseName: DEFAULT_DATABASE, - tableName: DEFAULT_TRACES_TABLE, - }, - timestampValueExpression: 'Timestamp', - connection: connection._id, - name: 'Traces', - }); - - const context: McpContext = { - teamId: team._id.toString(), - userId: user._id.toString(), - }; - client = await createTestClient(context); - }); - - afterEach(async () => { - await client?.close(); - await server.clearDBs(); - }); - - afterAll(async () => { - await server.stop(); - }); - - describe('hyperdx_get_dashboard', () => { - it('should list all dashboards when no id provided', async () => { - await new Dashboard({ - name: 'Dashboard 1', - tiles: [], - team: team._id, - tags: ['tag1'], - }).save(); - await new Dashboard({ - name: 'Dashboard 2', - tiles: [], - team: team._id, - tags: ['tag2'], - }).save(); - - const result = await callTool(client, 'hyperdx_get_dashboard', {}); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output).toHaveLength(2); - expect(output[0]).toHaveProperty('id'); - expect(output[0]).toHaveProperty('name'); - expect(output[0]).toHaveProperty('tags'); - }); - - it('should get dashboard detail when id is provided', async () => { - const dashboard = await new Dashboard({ - name: 'My Dashboard', - tiles: [], - team: team._id, - tags: ['test'], - }).save(); - - const result = await callTool(client, 'hyperdx_get_dashboard', { - id: dashboard._id.toString(), - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.id).toBe(dashboard._id.toString()); - expect(output.name).toBe('My Dashboard'); - expect(output.tags).toEqual(['test']); - expect(output.tiles).toEqual([]); - }); - - it('should return error for non-existent dashboard id', async () => { - const fakeId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_get_dashboard', { - id: fakeId, - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('not found'); - }); - }); - - describe('hyperdx_save_dashboard', () => { - it('should create a new dashboard with tiles', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'New MCP Dashboard', - tiles: [ - { - name: 'Line Chart', - x: 0, - y: 0, - w: 12, - h: 4, - config: { - displayType: 'line', - sourceId, - select: [{ aggFn: 'count', where: '' }], - }, - }, - ], - tags: ['mcp-test'], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.id).toBeDefined(); - expect(output.name).toBe('New MCP Dashboard'); - expect(output.tiles).toHaveLength(1); - expect(output.tiles[0].config.displayType).toBe('line'); - expect(output.tags).toEqual(['mcp-test']); - - // Verify in database - const dashboard = await Dashboard.findById(output.id); - expect(dashboard).not.toBeNull(); - expect(dashboard?.name).toBe('New MCP Dashboard'); - }); - - it('should create a dashboard with a markdown tile', async () => { - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Markdown Dashboard', - tiles: [ - { - name: 'Notes', - config: { - displayType: 'markdown', - markdown: '# Hello World', - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles).toHaveLength(1); - expect(output.tiles[0].config.displayType).toBe('markdown'); - }); - - it('should update an existing dashboard', async () => { - const sourceId = traceSource._id.toString(); - - // Create first - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Original Name', - tiles: [ - { - name: 'Tile 1', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - const created = JSON.parse(getFirstText(createResult)); - - // Update - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'Updated Name', - tiles: [ - { - name: 'Updated Tile', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - tags: ['updated'], - }); - - expect(updateResult.isError).toBeFalsy(); - const updated = JSON.parse(getFirstText(updateResult)); - expect(updated.id).toBe(created.id); - expect(updated.name).toBe('Updated Name'); - expect(updated.tiles).toHaveLength(1); - expect(updated.tiles[0].name).toBe('Updated Tile'); - expect(updated.tiles[0].config.displayType).toBe('table'); - }); - - it('should return error for missing source ID', async () => { - const fakeSourceId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Bad Dashboard', - tiles: [ - { - name: 'Bad Tile', - config: { - displayType: 'line', - sourceId: fakeSourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('source'); - }); - - it('should return error when updating non-existent dashboard', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - id: '000000000000000000000000', - name: 'Ghost Dashboard', - tiles: [ - { - name: 'Tile', - config: { - displayType: 'line', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('not found'); - }); - - it('should create a dashboard with multiple tile types', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Multi-tile Dashboard', - tiles: [ - { - name: 'Line', - x: 0, - y: 0, - w: 12, - h: 4, - config: { - displayType: 'line', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - { - name: 'Table', - x: 0, - y: 4, - w: 12, - h: 4, - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - groupBy: 'SpanName', - groupByColumnsOnLeft: true, - }, - }, - { - name: 'Number', - x: 0, - y: 8, - w: 6, - h: 3, - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - { - name: 'Pie', - x: 6, - y: 8, - w: 6, - h: 3, - config: { - displayType: 'pie', - sourceId, - select: [{ aggFn: 'count' }], - groupBy: 'SpanName', - }, - }, - { - name: 'Notes', - x: 0, - y: 11, - w: 12, - h: 2, - config: { displayType: 'markdown', markdown: '# Dashboard Notes' }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles).toHaveLength(5); - - const tableTile = output.tiles.find( - (t: { name: string }) => t.name === 'Table', - ); - expect(tableTile).toBeDefined(); - expect(tableTile.config.groupByColumnsOnLeft).toBe(true); - }); - - it('should create a dashboard with a raw SQL tile', async () => { - const connectionId = connection._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'SQL Dashboard', - tiles: [ - { - name: 'Raw SQL', - config: { - configType: 'sql', - displayType: 'table', - connectionId, - sqlTemplate: 'SELECT 1 AS value LIMIT 1', - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles).toHaveLength(1); - }); - - it('should create a dashboard with a heatmap tile on a Trace source', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Heatmap Dashboard', - tiles: [ - { - name: 'Latency Heatmap', - x: 0, - y: 0, - w: 12, - h: 4, - config: { - displayType: 'heatmap', - sourceId, - select: [ - { - valueExpression: 'Duration', - }, - ], - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles).toHaveLength(1); - expect(output.tiles[0].config.displayType).toBe('heatmap'); - expect(output.tiles[0].config.select[0]).toMatchObject({ - valueExpression: 'Duration', - }); - }); - - it('should round-trip every MCP-specific heatmap field through save, get, update, and re-get', async () => { - const sourceId = traceSource._id.toString(); - - const createConfig = { - displayType: 'heatmap' as const, - sourceId, - select: [ - { - valueExpression: 'Duration', - countExpression: 'count()', - heatmapScaleType: 'log' as const, - }, - ], - where: 'level:error', - whereLanguage: 'lucene' as const, - numberFormat: { output: 'duration' as const, factor: 1e-9 }, - }; - // Mutate select + where on update; numberFormat, whereLanguage, - // and sourceId carry forward via the spread. Re-asserting against - // updatedConfig catches a regression where PUT silently drops any - // carried-forward field. - const updatedConfig = { - ...createConfig, - select: [ - { - valueExpression: "SpanAttributes['http.duration']", - heatmapScaleType: 'linear' as const, - }, - ], - where: 'level:error AND service:checkout', - }; - - const saveResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Heatmap Full Round-Trip', - tiles: [ - { - name: 'Latency Heatmap', - x: 0, - y: 0, - w: 12, - h: 4, - config: createConfig, - }, - ], - }); - expect(saveResult.isError).toBeFalsy(); - const saved = JSON.parse(getFirstText(saveResult)); - expect(saved.tiles).toHaveLength(1); - expect(saved.tiles[0].config).toMatchObject(createConfig); - - const getResult = await callTool(client, 'hyperdx_get_dashboard', { - id: saved.id, - }); - expect(getResult.isError).toBeFalsy(); - const fetched = JSON.parse(getFirstText(getResult)); - expect(fetched.tiles[0].config).toMatchObject(createConfig); - - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: saved.id, - name: 'Heatmap Full Round-Trip', - tiles: [ - { - ...fetched.tiles[0], - config: { ...fetched.tiles[0].config, ...updatedConfig }, - }, - ], - }); - expect(updateResult.isError).toBeFalsy(); - const updated = JSON.parse(getFirstText(updateResult)); - expect(updated.tiles[0].config).toMatchObject(updatedConfig); - - const getAfterUpdate = await callTool(client, 'hyperdx_get_dashboard', { - id: saved.id, - }); - expect(getAfterUpdate.isError).toBeFalsy(); - const refetched = JSON.parse(getFirstText(getAfterUpdate)); - expect(refetched.tiles[0].config).toMatchObject(updatedConfig); - }); - - it('should reject heatmap tile with empty valueExpression at the schema layer', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Bad Heatmap', - tiles: [ - { - name: 'Heatmap', - config: { - displayType: 'heatmap', - sourceId, - select: [{ valueExpression: '' }], - }, - }, - ], - }); - - expect(result.isError).toBe(true); - }); - - it.each([ - { output: 'duration', factor: 1e-9 }, - { output: 'data_rate' }, - { output: 'throughput' }, - ] as const)( - 'should round-trip numberFormat output "$output" through save and get', - async numberFormat => { - const sourceId = traceSource._id.toString(); - - const saveResult = await callTool(client, 'hyperdx_save_dashboard', { - name: `NumberFormat ${numberFormat.output}`, - tiles: [ - { - name: 'Number Tile', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - numberFormat, - }, - }, - { - name: 'Line Tile', - config: { - displayType: 'line', - sourceId, - select: [ - { aggFn: 'count' }, - { - aggFn: 'avg', - valueExpression: 'Duration', - numberFormat, - }, - ], - }, - }, - ], - }); - - expect(saveResult.isError).toBeFalsy(); - const saved = JSON.parse(getFirstText(saveResult)); - - const getResult = await callTool(client, 'hyperdx_get_dashboard', { - id: saved.id, - }); - expect(getResult.isError).toBeFalsy(); - const fetched = JSON.parse(getFirstText(getResult)); - - const numberTile = fetched.tiles.find( - (t: { name: string }) => t.name === 'Number Tile', - ); - expect(numberTile.config.numberFormat).toEqual(numberFormat); - - const lineTile = fetched.tiles.find( - (t: { name: string }) => t.name === 'Line Tile', - ); - expect(lineTile.config.select[1].numberFormat).toEqual(numberFormat); - }, - ); - - it('should reject numberFormat with an unknown output value', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Bad NumberFormat', - tiles: [ - { - name: 'Number Tile', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - numberFormat: { output: 'not_a_real_output' }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - }); - - it('should reject heatmap tile on a non-Trace source', async () => { - // Create a Log source so the heatmap source-kind gate has - // something to reject. The schema accepts the tile shape (the - // sourceId is valid), so this exercises the runtime check that - // the REST POST path also runs. - const logSource = await Source.create({ - kind: SourceKind.Log, - team: team._id, - from: { - databaseName: DEFAULT_DATABASE, - tableName: 'otel_logs', - }, - timestampValueExpression: 'Timestamp', - connection: connection._id, - name: 'Logs', - }); - - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Heatmap on Log Source', - tiles: [ - { - name: 'Heatmap', - config: { - displayType: 'heatmap', - sourceId: logSource._id.toString(), - select: [{ valueExpression: 'Duration' }], - }, - }, - ], - }); - - expect(result.isError).toBe(true); - const text = getFirstText(result); - expect(text).toContain('Trace source'); - expect(text).toContain(logSource._id.toString()); - }); - - // Exercises the update-side source-kind gate via filterChangedHeatmapTiles - // (displayType changed to heatmap on an existing tile). - it('should reject update that changes a tile to heatmap on a non-Trace source', async () => { - const logSource = await Source.create({ - kind: SourceKind.Log, - team: team._id, - from: { databaseName: DEFAULT_DATABASE, tableName: 'otel_logs' }, - timestampValueExpression: 'Timestamp', - connection: connection._id, - name: 'Logs', - }); - - const created = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Line on Log Source', - tiles: [ - { - name: 'Line', - config: { - displayType: 'line', - sourceId: logSource._id.toString(), - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - expect(created.isError).toBeFalsy(); - const saved = JSON.parse(getFirstText(created)); - - const update = await callTool(client, 'hyperdx_save_dashboard', { - id: saved.id, - name: 'Line on Log Source', - tiles: [ - { - ...saved.tiles[0], - config: { - ...saved.tiles[0].config, - displayType: 'heatmap', - select: [{ valueExpression: 'Duration' }], - }, - }, - ], - }); - expect(update.isError).toBe(true); - const text = getFirstText(update); - expect(text).toContain('Trace source'); - expect(text).toContain(logSource._id.toString()); - }); - - // Exercises the update-side source-kind gate via filterChangedHeatmapTiles - // (sourceId changed on an existing heatmap tile). - it('should reject update that changes a heatmap tile source to a non-Trace source', async () => { - const logSource = await Source.create({ - kind: SourceKind.Log, - team: team._id, - from: { databaseName: DEFAULT_DATABASE, tableName: 'otel_logs' }, - timestampValueExpression: 'Timestamp', - connection: connection._id, - name: 'Logs', - }); - - const created = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Heatmap re-pointed at Log', - tiles: [ - { - name: 'Heatmap', - config: { - displayType: 'heatmap', - sourceId: traceSource._id.toString(), - select: [{ valueExpression: 'Duration' }], - }, - }, - ], - }); - expect(created.isError).toBeFalsy(); - const saved = JSON.parse(getFirstText(created)); - - const update = await callTool(client, 'hyperdx_save_dashboard', { - id: saved.id, - name: 'Heatmap re-pointed at Log', - tiles: [ - { - ...saved.tiles[0], - config: { - ...saved.tiles[0].config, - sourceId: logSource._id.toString(), - }, - }, - ], - }); - expect(update.isError).toBe(true); - const text = getFirstText(update); - expect(text).toContain('Trace source'); - expect(text).toContain(logSource._id.toString()); - }); - - // Asserts each tile's config survives the serializer/deserializer cycle - // independently when mixed with other displayTypes on the same dashboard. - it('should round-trip a heatmap alongside line and number tiles in one dashboard', async () => { - const sourceId = traceSource._id.toString(); - - const heatmapConfig = { - displayType: 'heatmap' as const, - sourceId, - select: [ - { valueExpression: 'Duration', heatmapScaleType: 'log' as const }, - ], - }; - const lineConfig = { - displayType: 'line' as const, - sourceId, - select: [{ aggFn: 'count' as const }], - groupBy: "SpanAttributes['service.name']", - }; - const numberConfig = { - displayType: 'number' as const, - sourceId, - select: [{ aggFn: 'count' as const }], - numberFormat: { output: 'number' as const, average: true }, - }; - - const save = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Mixed Tile Round-Trip', - tiles: [ - { name: 'Heatmap Tile', config: heatmapConfig }, - { name: 'Line Tile', config: lineConfig }, - { name: 'Number Tile', config: numberConfig }, - ], - }); - expect(save.isError).toBeFalsy(); - const saved = JSON.parse(getFirstText(save)); - expect(saved.tiles).toHaveLength(3); - - const byName: Record = - Object.fromEntries( - saved.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), - ); - expect(byName['Heatmap Tile'].config).toMatchObject(heatmapConfig); - expect(byName['Line Tile'].config).toMatchObject(lineConfig); - expect(byName['Number Tile'].config).toMatchObject(numberConfig); - - const fetched = JSON.parse( - getFirstText( - await callTool(client, 'hyperdx_get_dashboard', { id: saved.id }), - ), - ); - const fetchedByName: Record = - Object.fromEntries( - fetched.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), - ); - expect(fetchedByName['Heatmap Tile'].config).toMatchObject(heatmapConfig); - expect(fetchedByName['Line Tile'].config).toMatchObject(lineConfig); - expect(fetchedByName['Number Tile'].config).toMatchObject(numberConfig); - }); - }); - - describe('hyperdx_save_dashboard - containers and tabs', () => { - // Mirrors the v2 external-API "Containers and tabs" describe block so - // the MCP path enforces the same 5 cross-field rules: container id - // unique, tab id unique within a container, tile.containerId - // resolves, tile.tabId resolves to a tab on that container, and - // tile.tabId requires tile.containerId. - const buildTile = ( - sourceId: string, - overrides: Record, - ) => ({ - name: 'Tile', - x: 0, - y: 0, - w: 6, - h: 3, - config: { - displayType: 'line' as const, - sourceId, - select: [{ aggFn: 'count' as const, where: '' }], - }, - ...overrides, - }); - - it('should round-trip containers, tabs, and tile containerId/tabId on create and update', async () => { - const sourceId = traceSource._id.toString(); - const containers = [ - { - id: 'service-health', - title: 'Service Health', - collapsed: false, - collapsible: true, - bordered: true, - tabs: [ - { id: 'errors', title: 'Errors' }, - { id: 'latency', title: 'Latency' }, - ], - }, - { - id: 'overview', - title: 'Overview', - collapsed: true, - }, - ]; - - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Containers MCP Round-Trip', - tiles: [ - buildTile(sourceId, { - name: 'In Group, Tab A', - containerId: 'service-health', - tabId: 'errors', - }), - buildTile(sourceId, { - name: 'In Group, Tab B', - containerId: 'service-health', - tabId: 'latency', - }), - // Tile inside a tabbed container without a tabId renders in - // the container shell rather than under a tab. Guards that the - // schema does not accidentally require tabId for every tile in - // a tabbed container. - buildTile(sourceId, { - name: 'In Tabbed Group, No Tab', - containerId: 'service-health', - }), - buildTile(sourceId, { - name: 'In Plain Group', - containerId: 'overview', - }), - buildTile(sourceId, { name: 'Ungrouped' }), - ], - tags: ['containers-mcp'], - containers, - }); - - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - expect(created.containers).toEqual(containers); - - // Typed as ExternalDashboardTileWithId so a typo on .containerId - // would fail typecheck instead of silently asserting undefined. - const createdTilesByName: Record = - Object.fromEntries( - created.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), - ); - expect(createdTilesByName['In Group, Tab A']).toMatchObject({ - containerId: 'service-health', - tabId: 'errors', - }); - expect(createdTilesByName['In Group, Tab B']).toMatchObject({ - containerId: 'service-health', - tabId: 'latency', - }); - expect(createdTilesByName['In Tabbed Group, No Tab']).toMatchObject({ - containerId: 'service-health', - }); - expect( - createdTilesByName['In Tabbed Group, No Tab'].tabId, - ).toBeUndefined(); - expect(createdTilesByName['In Plain Group']).toMatchObject({ - containerId: 'overview', - }); - expect(createdTilesByName['In Plain Group'].tabId).toBeUndefined(); - expect(createdTilesByName.Ungrouped.containerId).toBeUndefined(); - expect(createdTilesByName.Ungrouped.tabId).toBeUndefined(); - - // Verify a fresh GET via the get tool also returns the structure. - const getResult = await callTool(client, 'hyperdx_get_dashboard', { - id: created.id, - }); - const fetched = JSON.parse(getFirstText(getResult)); - expect(fetched.containers).toEqual(containers); - - // Update: rename a tab, drop the second container, re-home tiles. - const updatedContainers = [ - { - id: 'service-health', - title: 'Service Health', - collapsed: true, - tabs: [ - { id: 'errors', title: 'Error Rate' }, - { id: 'latency', title: 'Latency' }, - ], - }, - ]; - const reHomedUngrouped = { - ...createdTilesByName.Ungrouped, - containerId: 'service-health', - tabId: 'errors', - }; - const droppedContainerTile = { - ...createdTilesByName['In Plain Group'], - containerId: undefined, - tabId: undefined, - }; - - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'Containers MCP Round-Trip', - tiles: [ - createdTilesByName['In Group, Tab A'], - createdTilesByName['In Group, Tab B'], - createdTilesByName['In Tabbed Group, No Tab'], - droppedContainerTile, - reHomedUngrouped, - ], - tags: ['containers-mcp'], - containers: updatedContainers, - }); - - expect(updateResult.isError).toBeFalsy(); - const updated = JSON.parse(getFirstText(updateResult)); - expect(updated.containers).toEqual(updatedContainers); - const updatedTilesByName: Record = - Object.fromEntries( - updated.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), - ); - expect(updatedTilesByName['In Plain Group'].containerId).toBeUndefined(); - expect(updatedTilesByName.Ungrouped).toMatchObject({ - containerId: 'service-health', - tabId: 'errors', - }); - expect(updatedTilesByName['In Tabbed Group, No Tab'].containerId).toBe( - 'service-health', - ); - expect( - updatedTilesByName['In Tabbed Group, No Tab'].tabId, - ).toBeUndefined(); - }); - - it('should reject duplicate container ids', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Duplicate Container Ids', - tiles: [buildTile(sourceId, {})], - containers: [ - { id: 'dupe', title: 'A', collapsed: false }, - { id: 'dupe', title: 'B', collapsed: false }, - ], - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('Container IDs must be unique'); - }); - - it('should reject duplicate tab ids within a container', async () => { - // Structural rules (duplicate container ids, duplicate tab ids) fire - // through the body schema, which is reported as a stringified issue - // array. Cross-tile-ref rules (covered below) fire through - // collectTileContainerRefIssues and produce plain prose. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Duplicate Tab Ids', - tiles: [buildTile(sourceId, {})], - containers: [ - { - id: 'service-health', - title: 'Service Health', - collapsed: false, - tabs: [ - { id: 'errors', title: 'Errors' }, - { id: 'errors', title: 'Errors Two' }, - ], - }, - ], - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('Duplicate tab id'); - expect(getFirstText(result)).toContain('errors'); - expect(getFirstText(result)).toContain('service-health'); - }); - - it('should reject a tile that supplies tabId without containerId', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Tab Without Container', - tiles: [buildTile(sourceId, { tabId: 'errors' })], - containers: [ - { - id: 'service-health', - title: 'Service Health', - collapsed: false, - tabs: [{ id: 'errors', title: 'Errors' }], - }, - ], - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain( - 'tabId requires containerId to be set', - ); - }); - - it('should reject a tile that references an unknown containerId', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Unknown Container', - tiles: [buildTile(sourceId, { containerId: 'does-not-exist' })], - containers: [{ id: 'real', title: 'Real', collapsed: false }], - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain( - 'unknown containerId "does-not-exist"', - ); - }); - - it('should reject a tile that references an unknown tabId within a container', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Unknown Tab', - tiles: [ - buildTile(sourceId, { - containerId: 'service-health', - tabId: 'ghost', - }), - ], - containers: [ - { - id: 'service-health', - title: 'Service Health', - collapsed: false, - tabs: [{ id: 'errors', title: 'Errors' }], - }, - ], - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('unknown tabId "ghost"'); - }); - - it('should preserve existing containers on update when body omits the field', async () => { - // Exercises the `effectiveContainers = parsedContainers ?? existingDashboard.containers ?? []` - // fallback in the MCP update handler. Without it, a PUT that - // updates only `tiles` would reject because the tile's - // containerId reference would resolve against an empty array. - const sourceId = traceSource._id.toString(); - const containers = [ - { - id: 'service-health', - title: 'Service Health', - collapsed: false, - tabs: [{ id: 'errors', title: 'Errors' }], - }, - ]; - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'PUT-without-containers fallback', - tiles: [ - buildTile(sourceId, { - name: 'In Group', - containerId: 'service-health', - tabId: 'errors', - }), - ], - containers, - }); - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'PUT-without-containers fallback', - tiles: created.tiles, - // containers intentionally omitted - }); - expect(updateResult.isError).toBeFalsy(); - const updated = JSON.parse(getFirstText(updateResult)); - expect(updated.containers).toEqual(containers); - expect(updated.tiles[0]).toMatchObject({ - containerId: 'service-health', - tabId: 'errors', - }); - }); - - it('should wipe persisted containers when update body sets containers: []', async () => { - // Mirror the v2 behavior: an explicit empty array clears the - // persisted containers, and the response normalizes [] back to - // absent on read. - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Wipe containers', - tiles: [buildTile(sourceId, { name: 'Tile' })], - containers: [{ id: 'overview', title: 'Overview', collapsed: false }], - }); - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - expect(created.containers).toHaveLength(1); - - const wipedTile = { - ...created.tiles[0], - containerId: undefined, - tabId: undefined, - }; - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'Wipe containers', - tiles: [wipedTile], - containers: [], - }); - expect(updateResult.isError).toBeFalsy(); - const updated = JSON.parse(getFirstText(updateResult)); - expect(updated.containers).toBeUndefined(); - - const getResult = await callTool(client, 'hyperdx_get_dashboard', { - id: created.id, - }); - const fetched = JSON.parse(getFirstText(getResult)); - expect(fetched.containers).toBeUndefined(); - }); - - // Bounds mirror DASHBOARD_CONTAINER_ID_MAX (256) on the tile-level - // containerId / tabId. 256 chars must accept; 257 must reject. - // 257 trips the inputSchema's `.max(256)` and surfaces back as the - // MCP SDK's "Input validation error" envelope. - it('should accept a 256-char tile.containerId and reject 257', async () => { - const sourceId = traceSource._id.toString(); - const idAtMax = 'c'.repeat(256); - const okResult = await callTool(client, 'hyperdx_save_dashboard', { - name: '256-char containerId', - tiles: [buildTile(sourceId, { containerId: idAtMax })], - containers: [{ id: idAtMax, title: 'Max', collapsed: false }], - }); - expect(okResult.isError).toBeFalsy(); - - const idTooLong = 'c'.repeat(257); - const tooLongResult = await callTool(client, 'hyperdx_save_dashboard', { - name: '257-char containerId', - tiles: [buildTile(sourceId, { containerId: idTooLong })], - containers: [{ id: idTooLong, title: 'Too long', collapsed: false }], - }); - expect(tooLongResult.isError).toBe(true); - expect(getFirstText(tooLongResult)).toContain( - 'String must contain at most 256 character(s)', - ); - expect(getFirstText(tooLongResult)).toContain('containerId'); - }); - }); - - describe('hyperdx_save_dashboard - dashboard filters', () => { - // The MCP input schema delegates to createDashboardBodySchema / - // updateDashboardBodySchema for the filter shape. These tests guard - // that the MCP path lights up the same filter round-trip the v2 REST - // path already covers: filters are saved verbatim, get back with an - // assigned id, survive an update, and reject obvious bad inputs. - - const traceTile = (sourceId: string) => ({ - name: 'Volume', - x: 0, - y: 0, - w: 6, - h: 3, - config: { - displayType: 'line' as const, - sourceId, - select: [{ aggFn: 'count' as const, where: '' }], - }, - }); - - it('should round-trip filters on create, get, and update', async () => { - const sourceId = traceSource._id.toString(); - - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Service Detail (MCP filter round-trip)', - tiles: [traceTile(sourceId)], - filters: [ - { - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId, - }, - { - type: 'QUERY_EXPRESSION', - name: 'Environment', - expression: 'deployment.environment', - sourceId, - where: "deployment.environment = 'production'", - whereLanguage: 'sql', - }, - ], - }); - - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - expect(Array.isArray(created.filters)).toBe(true); - expect(created.filters).toHaveLength(2); - // The body schema assigns an id to each filter on create. Capture - // them so the update payload can include the same ids and the - // filter array round-trips identically (instead of being treated - // as wholesale replacement with new ids). - const [serviceFilter, envFilter] = created.filters; - expect(serviceFilter).toMatchObject({ - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId, - }); - expect(typeof serviceFilter.id).toBe('string'); - expect(serviceFilter.id.length).toBeGreaterThan(0); - expect(envFilter).toMatchObject({ - type: 'QUERY_EXPRESSION', - name: 'Environment', - expression: 'deployment.environment', - sourceId, - where: "deployment.environment = 'production'", - whereLanguage: 'sql', - }); - - // Fetch and assert the same shape. - const getResult = await callTool(client, 'hyperdx_get_dashboard', { - id: created.id, - }); - const fetched = JSON.parse(getFirstText(getResult)); - expect(fetched.filters).toEqual(created.filters); - - // Update: rename the first filter and drop the second. The first - // filter keeps its id, the second is dropped (not preserved). - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'Service Detail (MCP filter round-trip)', - tiles: [traceTile(sourceId)], - filters: [ - { - id: serviceFilter.id, - type: 'QUERY_EXPRESSION', - name: 'Service (renamed)', - expression: 'ServiceName', - sourceId, - }, - ], - }); - - expect(updateResult.isError).toBeFalsy(); - const updated = JSON.parse(getFirstText(updateResult)); - expect(updated.filters).toHaveLength(1); - expect(updated.filters[0]).toMatchObject({ - id: serviceFilter.id, - type: 'QUERY_EXPRESSION', - name: 'Service (renamed)', - expression: 'ServiceName', - sourceId, - }); - }); - - it('should accept a create payload with stray filter ids (copy-paste round-trip)', async () => { - // An LLM that copies a filter out of hyperdx_get_dashboard and - // back into hyperdx_save_dashboard for a NEW dashboard ships an - // `id` on the filter. The body schema for create is - // `.strict()` and rejects `id`, so saveDashboard normalizes by - // stripping ids before validation. Without that normalization - // this payload would 4xx with a confusing strict-validation - // error. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Create with stray filter id', - tiles: [traceTile(sourceId)], - filters: [ - { - id: '999999999999999999999999', // simulates a copied id - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId, - }, - ], - }); - expect(result.isError).toBeFalsy(); - const dashboard = JSON.parse(getFirstText(result)); - expect(dashboard.filters).toHaveLength(1); - // The new dashboard gets a fresh id; the copied one is discarded. - expect(dashboard.filters[0].id).not.toBe('999999999999999999999999'); - expect(dashboard.filters[0]).toMatchObject({ - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId, - }); - }); - - it('should assign ids to new filters added during update (no-id round-trip)', async () => { - // An LLM updating a dashboard to ADD a brand-new filter ships - // a filter without an `id`. The body schema for update requires - // id, so saveDashboard fills one in. Without that normalization - // this payload would 4xx complaining that id is missing. - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Update adds new filter', - tiles: [traceTile(sourceId)], - filters: [ - { - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId, - }, - ], - }); - const created = JSON.parse(getFirstText(createResult)); - const existingFilterId = created.filters[0].id; - - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'Update adds new filter', - tiles: [traceTile(sourceId)], - filters: [ - // Existing filter, preserved by id. - { - id: existingFilterId, - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId, - }, - // Brand-new filter, no id. saveDashboard assigns one. - { - type: 'QUERY_EXPRESSION', - name: 'Environment', - expression: 'deployment.environment', - sourceId, - }, - ], - }); - expect(updateResult.isError).toBeFalsy(); - const updated = JSON.parse(getFirstText(updateResult)); - expect(updated.filters).toHaveLength(2); - const envFilter = updated.filters.find( - (f: { name: string }) => f.name === 'Environment', - ); - expect(envFilter).toBeDefined(); - expect(typeof envFilter.id).toBe('string'); - expect(envFilter.id.length).toBeGreaterThan(0); - expect(envFilter.id).not.toBe(existingFilterId); - }); - - it('should round-trip a table tile that uses a having clause', async () => { - // mcpTableTileSchema exposes `having` so the service_detail - // example's "Top Error Messages" pattern (groupBy StatusMessage - // with having: "StatusMessage != ''") survives MCP authoring. - // Without `having` on the schema, Zod would strip it and the - // saved tile would include empty-message rows instead of the - // filtered set the prompt promises. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Top Error Messages', - tiles: [ - { - name: 'Top Error Messages', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count', alias: 'Count' }], - groupBy: 'StatusMessage', - having: "StatusMessage != ''", - orderBy: 'Count DESC', - groupByColumnsOnLeft: true, - }, - }, - ], - }); - expect(result.isError).toBeFalsy(); - const dashboard = JSON.parse(getFirstText(result)); - expect(dashboard.tiles[0].config.having).toBe("StatusMessage != ''"); - }); - }); - - describe('hyperdx_save_dashboard - table onClick linking', () => { - // Mirrors the v2 external-API onClick tests so the MCP path - // enforces the same drill-down rules: row-click can target /search - // for a log/trace source or another dashboard, by concrete ID or - // by templated name, with optional whereTemplate/filters. - it('should round-trip a table tile with a search onClick by ID', async () => { - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick search by id', - tiles: [ - { - name: 'Errors by Service', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - displayType: 'table', - sourceId, - groupBy: "ResourceAttributes['service.name']", - select: [{ aggFn: 'count' }], - onClick: { - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }, - }, - }, - ], - }); - - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - expect(created.tiles[0].config.onClick).toEqual({ - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }); - - const getResult = await callTool(client, 'hyperdx_get_dashboard', { - id: created.id, - }); - const fetched = JSON.parse(getFirstText(getResult)); - expect(fetched.tiles[0].config.onClick).toEqual( - created.tiles[0].config.onClick, - ); - }); - - it('should round-trip a table tile with a dashboard onClick by ID', async () => { - const sourceId = traceSource._id.toString(); - // Create the target dashboard the onClick will link to so the - // server-side `getMissingOnClickDashboards` check resolves. - const targetDashboard = await new Dashboard({ - name: 'Service Detail', - tiles: [], - team: team._id, - }).save(); - - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick dashboard by id', - tiles: [ - { - name: 'Top Services', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - displayType: 'table', - sourceId, - groupBy: "ResourceAttributes['service.name']", - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { - mode: 'id', - id: targetDashboard._id.toString(), - }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toEqual({ - type: 'dashboard', - target: { mode: 'id', id: targetDashboard._id.toString() }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }); - }); - - it('should round-trip a templated dashboard onClick (mode=template)', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick dashboard by template', - tiles: [ - { - name: 'Service Picker', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - displayType: 'table', - sourceId, - groupBy: 'SpanName', - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { - mode: 'template', - template: '{{TargetDashboard}}', - }, - whereLanguage: 'lucene', - whereTemplate: - "service.name:{{ResourceAttributes['service.name']}}", - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toEqual({ - type: 'dashboard', - target: { mode: 'template', template: '{{TargetDashboard}}' }, - whereLanguage: 'lucene', - whereTemplate: "service.name:{{ResourceAttributes['service.name']}}", - }); - }); - - it('should round-trip onClick on a raw SQL table tile', async () => { - const connectionId = connection._id.toString(); - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'SQL onClick', - tiles: [ - { - name: 'Raw SQL Table', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - configType: 'sql', - displayType: 'table', - connectionId, - sqlTemplate: - 'SELECT ServiceName, count() AS c FROM otel_traces GROUP BY ServiceName LIMIT 50', - onClick: { - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: '{{ServiceName}}', - }, - ], - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toEqual({ - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: '{{ServiceName}}', - }, - ], - }); - }); - - it('should reject a table tile onClick referencing a non-existent dashboard', async () => { - const sourceId = traceSource._id.toString(); - const ghostDashboardId = new mongoose.Types.ObjectId().toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick missing dashboard', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { mode: 'id', id: ghostDashboardId }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - const text = getFirstText(result); - expect(text).toContain('onClick dashboard'); - expect(text).toContain(ghostDashboardId); - }); - - it('should reject a table tile onClick search target with a non-log/trace source', async () => { - // /search only renders log/trace sources; targeting a metric - // source must be rejected at save time, matching the REST POST - // path. Create the metric source inline so this test is - // self-contained. - const metricSource = await Source.create({ - kind: SourceKind.Metric, - team: team._id, - from: { - databaseName: DEFAULT_DATABASE, - tableName: '', - }, - metricTables: { - [MetricsDataType.Gauge.toLowerCase()]: 'otel_metrics_gauge', - [MetricsDataType.Sum.toLowerCase()]: 'otel_metrics_sum', - [MetricsDataType.Histogram.toLowerCase()]: 'otel_metrics_histogram', - }, - timestampValueExpression: 'TimeUnix', - connection: connection._id, - name: 'Metrics', - }); - const sourceId = traceSource._id.toString(); - - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick metric source', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'search', - target: { - mode: 'id', - id: metricSource._id.toString(), - }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - const text = getFirstText(result); - expect(text).toContain('log or trace'); - expect(text).toContain(metricSource._id.toString()); - }); - - it('should reject a table tile onClick with an invalid target.id', async () => { - // target.id must be a valid ObjectId. Bad shape is caught by the - // input schema and surfaces as the SDK's "Input validation error" - // envelope rather than a custom 400 message. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick bad object id', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { mode: 'id', id: 'not-a-valid-object-id' }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - }); - - it('should accept onClick without whereLanguage (it is optional at the schema layer)', async () => { - // SearchConditionLanguageSchema is `.optional()` in common-utils, - // so omitting whereLanguage is allowed. Pin that behavior so a - // future tightening of the schema doesn't quietly change it - // without updating the docs that callers rely on. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick no whereLanguage', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'search', - target: { mode: 'id', id: sourceId }, - // whereLanguage intentionally omitted - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toMatchObject({ - type: 'search', - target: { mode: 'id', id: sourceId }, - }); - }); - - it('should reject an unknown onClick.type', async () => { - // The schema is a discriminated union on `type`; a bogus type - // must be rejected up front. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick bad type', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'navigate-to-mars', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - }); - - it('should validate onClick on update too', async () => { - // Round-trip a tile with no onClick, then update it to add an - // onClick pointing at a non-existent dashboard. The PUT path - // mirrors POST: missing dashboards are rejected with the same - // message. - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick update validation', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - - const ghostDashboardId = new mongoose.Types.ObjectId().toString(); - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'OnClick update validation', - tiles: [ - { - ...created.tiles[0], - config: { - ...created.tiles[0].config, - onClick: { - type: 'dashboard', - target: { mode: 'id', id: ghostDashboardId }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(updateResult.isError).toBe(true); - const text = getFirstText(updateResult); - expect(text).toContain('onClick dashboard'); - expect(text).toContain(ghostDashboardId); - }); - }); - - describe('hyperdx_delete_dashboard', () => { - it('should delete an existing dashboard', async () => { - const dashboard = await new Dashboard({ - name: 'To Delete', - tiles: [], - team: team._id, - }).save(); - - const result = await callTool(client, 'hyperdx_delete_dashboard', { - id: dashboard._id.toString(), - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.deleted).toBe(true); - expect(output.id).toBe(dashboard._id.toString()); - - // Verify deleted from database - const found = await Dashboard.findById(dashboard._id); - expect(found).toBeNull(); - }); - - it('should return error for non-existent dashboard', async () => { - const result = await callTool(client, 'hyperdx_delete_dashboard', { - id: '000000000000000000000000', - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('not found'); - }); - }); - - describe('hyperdx_query_tile', () => { - it('should return error for non-existent dashboard', async () => { - const result = await callTool(client, 'hyperdx_query_tile', { - dashboardId: '000000000000000000000000', - tileId: 'some-tile-id', - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('not found'); - }); - - it('should return error for non-existent tile', async () => { - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Tile Query Test', - tiles: [ - { - name: 'My Tile', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - const dashboard = JSON.parse(getFirstText(createResult)); - - const result = await callTool(client, 'hyperdx_query_tile', { - dashboardId: dashboard.id, - tileId: 'non-existent-tile-id', - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('Tile not found'); - }); - - it('should return error for invalid time range', async () => { - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Time Range Test', - tiles: [ - { - name: 'Tile', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - const dashboard = JSON.parse(getFirstText(createResult)); - - const result = await callTool(client, 'hyperdx_query_tile', { - dashboardId: dashboard.id, - tileId: dashboard.tiles[0].id, - startTime: 'not-a-date', - }); - - expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('Invalid'); - }); - - it('should execute query for a valid tile', async () => { - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Query Tile Test', - tiles: [ - { - name: 'Count Tile', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - const dashboard = JSON.parse(getFirstText(createResult)); - - const result = await callTool(client, 'hyperdx_query_tile', { - dashboardId: dashboard.id, - tileId: dashboard.tiles[0].id, - startTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), - endTime: new Date().toISOString(), - }); - - // Should succeed (may have empty results since no data inserted) - expect(result.isError).toBeFalsy(); - expect(result.content).toHaveLength(1); - }); - }); - - describe('buildQueryGuidePrompt', () => { - it('documents heatmap in tile-type, constraints, mistakes, and aggFn sections', () => { - const prompt = buildQueryGuidePrompt(); - // Ensure each section the bot called out has a heatmap entry so - // the LLM cannot skip the constraints when producing a heatmap - // tile through hyperdx_save_dashboard. - const sections = [ - '== AGGREGATION FUNCTIONS (aggFn) ==', - '== PER-TILE TYPE CONSTRAINTS ==', - '== COMMON MISTAKES ==', - ]; - for (const heading of sections) { - const idx = prompt.indexOf(heading); - expect(idx).toBeGreaterThan(-1); - // Each section's body extends until the next == heading or the - // end of the string. Checking for the substring "heatmap" in - // that slice is enough to assert the heatmap entry exists. - const next = prompt.indexOf('\n== ', idx + heading.length); - const body = prompt.slice(idx, next === -1 ? prompt.length : next); - expect(body.toLowerCase()).toContain('heatmap'); - } - }); - - it('documents the dashboard filter and per-series numberFormat sections', () => { - const prompt = buildQueryGuidePrompt(); - // Sections must exist AND carry the substantive content. Heading- - // only assertions let a future contributor empty out a section - // and silently pass review. - const sliceSection = (heading: string) => { - const idx = prompt.indexOf(heading); - if (idx === -1) return ''; - const next = prompt.indexOf('\n== ', idx + heading.length); - return prompt.slice(idx, next === -1 ? prompt.length : next); - }; - const filters = sliceSection('== DASHBOARD FILTERS =='); - expect(filters).toContain('QUERY_EXPRESSION'); - expect(filters).toContain('expression'); - expect(filters).toContain('sourceId'); - const numberFormat = sliceSection('== NUMBER FORMAT =='); - expect(numberFormat).toContain('factor: 0.000000001'); - expect(numberFormat).toContain('duration'); - expect(numberFormat.toLowerCase()).toContain('per-series'); - }); - - it('declares the MCP metric-authoring gap rather than referencing fields that do not exist', () => { - // The MCP select-item schema does not carry metricName / metricType, - // so any prompt that tells the model to author a metric tile via - // the builder path is teaching it to ship JSON that gets silently - // stripped. Guard with an explicit assertion so a future diff that - // restores metric-tile guidance fails this test loudly. - const prompt = buildQueryGuidePrompt(); - expect(prompt).not.toMatch(/metricName \+ metricType/); - expect(prompt).not.toMatch(/exactly 1 select item with metricName/); - const constraintsIdx = prompt.indexOf('== PER-TILE TYPE CONSTRAINTS =='); - const constraintsBody = prompt.slice(constraintsIdx); - // The constraints section closes with an explicit note that builder - // tiles on a metric source are not reliable today, with a fallback - // recipe to raw SQL. Anchor on the phrase so a future diff that - // drops the gap-acknowledgement fails loudly. - expect(constraintsBody).toMatch( - /Authoring builder tiles on a metric source is not reliable/, - ); - expect(constraintsBody).toMatch(/MCP select-item shape does not carry/); - }); - - it('documents table-tile onClick linking features', () => { - // Lock down the documentation for row-click drill-downs so a - // future refactor can't quietly drop the section the LLM relies - // on to wire up onClick correctly. - const prompt = buildQueryGuidePrompt(); - const idx = prompt.indexOf('== TABLE TILE LINKING (config.onClick) =='); - expect(idx).toBeGreaterThan(-1); - const next = prompt.indexOf('\n== ', idx + 1); - const section = prompt.slice(idx, next === -1 ? prompt.length : next); - - // Both destination types are mentioned. - expect(section).toContain('type: "search"'); - expect(section).toContain('type: "dashboard"'); - // Both target modes are mentioned. - expect(section).toContain('mode: "id"'); - expect(section).toContain('mode: "template"'); - // Templating fields are mentioned. - expect(section).toContain('whereTemplate'); - expect(section).toContain('filters'); - expect(section).toContain('expressionTemplate'); - // The two server-side validation error messages are quoted so - // the LLM can recognize and recover from them. - expect(section).toContain('onClick search source IDs'); - expect(section).toContain('onClick dashboard IDs'); - }); - - it('documents onClick pitfalls under common mistakes', () => { - const prompt = buildQueryGuidePrompt(); - const idx = prompt.indexOf('== COMMON MISTAKES =='); - expect(idx).toBeGreaterThan(-1); - const section = prompt.slice(idx); - // Mention the four failure modes we hit during validation. - expect(section.toLowerCase()).toContain('onclick on a non-table tile'); - expect(section.toLowerCase()).toContain('non-log/trace source'); - expect(section.toLowerCase()).toContain('missing wherelanguage'); - expect(section.toLowerCase()).toContain("isn't in the table"); - }); - - it('documents the missing-alias mistake explicitly', () => { - // Claude built three number tiles without alias on the quantile() - // select item, even though rule 2 of the design checklist says - // ALIAS EVERY SELECT ITEM. Spelling out the mistake under COMMON - // MISTAKES gives the model a second touchpoint to catch it. - const prompt = buildQueryGuidePrompt(); - const idx = prompt.indexOf('== COMMON MISTAKES =='); - const section = prompt.slice(idx); - expect(section).toMatch(/Missing alias on a select item/); - // Must call out that number tiles need the alias too; this is the - // exact case Claude got wrong. - expect(section).toMatch(/number tiles too/); - }); - - it('documents the Lucene map-attribute gotcha across all operators including simple equality', () => { - // The original rule covered only comparison/wildcard. Claude's - // second pass showed simple equality (db.system:mongodb) also - // fails to translate to SpanAttributes['db.system']. Broaden the - // gotcha to "ALL Lucene operations on map-attribute paths". - const prompt = buildQueryGuidePrompt(); - const luceneIdx = prompt.indexOf('== LUCENE FILTER SYNTAX =='); - const luceneBody = prompt.slice(luceneIdx); - expect(luceneBody).toMatch(/NOT reliably translated to bracket access/); - expect(luceneBody).toMatch(/http\.status_code:>=500/); // operator - expect(luceneBody).toMatch(/http\.route:\*/); // wildcard - expect(luceneBody).toMatch(/db\.system:mongodb/); // simple equality - expect(luceneBody).toMatch(/use SQL with bracket access/); - - // Also a top-level entry in COMMON MISTAKES for discoverability. - const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); - const mistakesBody = prompt.slice(mistakesIdx); - expect(mistakesBody).toMatch( - /Lucene on a map-attribute path \(any operation\)/, - ); - }); - - it('documents the Lucene fuzzy-substring behavior on top-level columns', () => { - // Lucene field:value translates to ilike(field, '%value%'), not - // equality. Claude's second pass hit this on SpanKind:Server, - // which matched broader strings than intended. The note has to - // be in the Lucene section so a model reading top-down sees it - // before writing a SpanKind filter. - const prompt = buildQueryGuidePrompt(); - const luceneIdx = prompt.indexOf('== LUCENE FILTER SYNTAX =='); - const luceneBody = prompt.slice(luceneIdx); - expect(luceneBody).toMatch(/FUZZY-MATCH NOTE/); - expect(luceneBody).toMatch(/ilike\(field, '%value%'\)/); - // The canonical fix is SQL with =. - expect(luceneBody).toMatch(/SpanKind = 'Server'/); - - // And a COMMON MISTAKES entry covering enum-like columns. - const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); - const mistakesBody = prompt.slice(mistakesIdx); - expect(mistakesBody).toMatch( - /Lucene field:value on enum-like top-level columns/, - ); - }); - - it('documents the empty-string trap for map-attribute groupBy', () => { - // Map columns return '' (not NULL) when a key is unset on a row. - // A groupBy on such a key produces an empty-string bucket alongside - // the real values, which is visual noise on tables and pies. The - // standard fix is where: " != ''" alongside the groupBy. - const prompt = buildQueryGuidePrompt(); - const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); - const mistakesBody = prompt.slice(mistakesIdx); - expect(mistakesBody).toMatch( - /Forgetting that map-attribute values are often empty strings/, - ); - expect(mistakesBody).toMatch(/db\.collection\.name'\] != ''/); - }); - - it('documents the groupBy alias workaround for map-attribute drill-downs', () => { - // Builder tiles don't expose a groupBy alias today; map-attribute - // groupBys produce result columns named like the raw expression, - // which onClick template lookups can't reference. The workaround - // (author the tile as raw SQL with AS) needs to be findable. - const prompt = buildQueryGuidePrompt(); - const idx = prompt.indexOf( - '== GROUPBY ALIASES AND ROW-CLICK TEMPLATES ==', - ); - expect(idx).toBeGreaterThan(-1); - const section = prompt.slice(idx); - expect(section).toMatch(/do NOT currently expose a groupBy alias/); - expect(section).toMatch(/configType: "sql", displayType: "table"/); - expect(section).toMatch(/AS Route/); - }); - - it('points at ClickStack and ClickHouse docs in REFERENCES', () => { - // Without external doc links, the LLM has to guess at Lucene - // operators and ClickHouse function names. Anchor the canonical - // URLs in the prompt so it can cite them and so a future doc - // restructure can be caught here. - const prompt = buildQueryGuidePrompt(); - const idx = prompt.indexOf('== REFERENCES =='); - expect(idx).toBeGreaterThan(-1); - const section = prompt.slice(idx); - expect(section).toContain( - 'https://clickhouse.com/docs/use-cases/observability/clickstack/search', - ); - expect(section).toContain('https://clickhouse.com/docs/sql-reference'); - expect(section).toContain( - 'https://clickhouse.com/docs/use-cases/observability/clickstack/dashboards/sql-visualizations', - ); - }); - - it('strengthens the validate-every-tile rule under common mistakes', () => { - // Saving validates input shape, not query semantics. The prompt - // has to push for query_tile on every tile, not "at least one". - const prompt = buildQueryGuidePrompt(); - const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); - const section = prompt.slice(mistakesIdx); - expect(section).toMatch( - /on EVERY tile after hyperdx_save_dashboard, not just one/, - ); - }); - - it('flags the metric source builder gap', () => { - // Builder tiles on metric sources currently save but render with - // "Both table name and UUID are empty". Claude went 100% raw SQL - // across 21 metric tiles for this reason; the prompt has to make - // the workaround obvious so the model doesn't try the builder - // first and fail silently. - const prompt = buildQueryGuidePrompt(); - expect(prompt).toMatch(/Both table name and UUID are empty/); - expect(prompt).toMatch(/use a raw SQL tile/); - expect(prompt).toMatch(/otel_metrics_gauge/); - }); - - it('contains no em-dashes or en-dashes used as em-dashes', () => { - const prompt = buildQueryGuidePrompt(); - expect(prompt).not.toMatch(/—/); // [prose-lint: allow] - expect(prompt).not.toMatch(/ – /); // [prose-lint: allow] - }); - }); - - describe('buildSourceSummary', () => { - it('contains no em-dashes or en-dashes used as em-dashes', () => { - // buildSourceSummary is the source-list block that gets prepended - // to create_dashboard. The first iteration of this PR shipped with - // em-dashes here that the content.ts snapshot test missed because - // it calls the builders directly. Guard the helper output too. - const summary = buildSourceSummary( - [ - { - _id: '000000000000000000000001', - name: 'Traces', - kind: 'trace', - connection: '000000000000000000000002', - }, - { - _id: '000000000000000000000003', - name: 'Logs', - kind: 'log', - connection: '000000000000000000000002', - }, - ], - [{ _id: '000000000000000000000002', name: 'Default' }], - ); - expect(summary).not.toMatch(/—/); // [prose-lint: allow] - expect(summary).not.toMatch(/ – /); // [prose-lint: allow] - // Sanity: the helper still emits the source list, so the assertion - // above isn't trivially passing on an empty string. - expect(summary).toContain('Traces'); - expect(summary).toContain('AVAILABLE SOURCES'); - }); - }); - - describe('buildCreateDashboardPrompt', () => { - it('includes the design checklist and adapt-do-not-copy note', () => { - const prompt = buildCreateDashboardPrompt( - 'sources summary', - '000000000000000000000001', - '000000000000000000000002', - ); - expect(prompt).toContain('== DESIGN CHECKLIST =='); - // Each rule on the checklist exists at the same numbered position - // so a future contributor cannot silently drop one. Anchor on - // line start (with a trailing space) so digit-and-dot substrings - // like "0.000000001" elsewhere in the prompt cannot satisfy the - // assertion accidentally. - const checklistIdx = prompt.indexOf('== DESIGN CHECKLIST =='); - const adaptIdx = prompt.indexOf('== ADAPT, DO NOT COPY =='); - const checklistBody = prompt.slice(checklistIdx, adaptIdx); - // Thirteen rules: the original ten plus GROUP BY HAS NO ALIAS HOOK - // (rule 3), VALIDATE EVERY TILE AFTER SAVE (rule 12), and NO - // TITLE-RECAP MARKDOWN TILE (rule 13). Each came out of a live - // verification pass after watching Claude reliably ignore the soft - // "should" formulations or hit a schema gap the earlier checklist - // did not call out. - for (let i = 1; i <= 13; i++) { - expect(checklistBody).toMatch(new RegExp(`^${i}\\. `, 'm')); - } - expect(prompt).toContain('ADAPT, DO NOT COPY'); - // Rule 9 documents the replace-not-merge semantic on update; an - // agent that ignores this can silently drop tiles / filters / containers - // on a subsequent save call. - expect(checklistBody).toMatch(/UPDATE IS REPLACE, NOT MERGE/); - // Rule 2 must cover EVERY select item (including number tiles), not - // just aggregations on tables. The phrasing matters: Claude's first - // pass at this dropped aliases on number tiles because it read rule 2 - // as table-specific. The stronger phrasing pulls them in. - expect(checklistBody).toMatch(/ALIAS EVERY SELECT ITEM/); - // Rule 3 calls out the schema gap on groupBy: the chart config takes - // a single expression string with no alias field, so a table grouped - // by SpanAttributes['http.route'] renders arrayElement(...) as the - // column header. Naming the limit explicitly lets Claude pick a - // top-level column (SpanName, ServiceName) instead of grouping on - // the raw Map expression. - expect(checklistBody).toMatch(/GROUP BY HAS NO ALIAS HOOK/); - // Rule 10 must be a hard requirement at 5+ tiles, not a soft hint. - // Without the imperative, Claude built five dashboards averaging ten - // tiles each with zero containers. - expect(checklistBody).toMatch(/REQUIRED at five or more tiles/); - // Rule 11 + 12 close the post-save loop and the title-recap markdown - // habit Claude landed on every starter dashboard. - expect(checklistBody).toMatch(/VALIDATE EVERY TILE AFTER SAVE/); - expect(checklistBody).toMatch(/NO TITLE-RECAP MARKDOWN TILE/); - // Rule 2 now carries a concrete number-tile example. Claude's - // second pass still dropped aliases on every builder number tile, - // because the text rule was abstract; a copy-pasteable shape next - // to the rule is what makes it stick. - expect(checklistBody).toMatch( - /Number tile, correct:.*alias: "Server Requests"/, - ); - // Rule 10 now carries a concrete containers shape. Claude's - // second pass built five dashboards with 9-10 tiles each and zero - // containers despite the REQUIRED phrasing; a copy-pasteable shape - // inside the rule itself is the next-level push. - expect(checklistBody).toMatch(/Concrete shape \(copy this directly/); - expect(checklistBody).toMatch(/id: "kpis"/); - expect(checklistBody).toMatch(/id: "trends"/); - expect(checklistBody).toMatch(/id: "errors"/); - }); - - it('walks the workflow through six steps including read-existing and group-into-containers', () => { - // The workflow is the first thing a model anchors on. Claude - // skipped both "read existing dashboards before designing" and - // "group tiles into containers before saving" because neither was - // a numbered workflow step; both lived only in the checklist. The - // workflow now lists six steps with those two explicitly. - const prompt = buildCreateDashboardPrompt( - 'summary', - 'trace_src', - 'log_src', - ); - const wfIdx = prompt.indexOf('== WORKFLOW =='); - const wfEnd = prompt.indexOf('\n== ', wfIdx + 1); - const wf = prompt.slice(wfIdx, wfEnd); - for (let i = 1; i <= 6; i++) { - expect(wf).toMatch(new RegExp(`^${i}\\. `, 'm')); - } - // Step 2 must point at hyperdx_get_dashboard with no id. - expect(wf).toMatch(/hyperdx_get_dashboard \(no id\)/); - // Step 4 must call out grouping tiles into containers before save. - expect(wf).toMatch(/group them into 2-4 containers/); - // Step 6 must call out EVERY tile, not just one. - expect(wf).toMatch(/EVERY tile \(not just one\)/); - }); - - it('warns explicitly against title-recap markdown tiles in the tile type guide', () => { - // The TILE TYPE GUIDE entry for markdown is where a model reading - // top-down first decides whether to add an "About this dashboard" - // tile. The entry has to actively discourage it, not just describe - // the tile type, because every model trained on dashboard examples - // reaches for a title tile by default. - const prompt = buildCreateDashboardPrompt( - 'summary', - 'trace_src', - 'log_src', - ); - const guideIdx = prompt.indexOf('== TILE TYPE GUIDE =='); - const guideEnd = prompt.indexOf('\n== ', guideIdx + 1); - const guideBody = prompt.slice(guideIdx, guideEnd); - // The markdown line must say "skip" / "sparingly" / "do not" / - // "no headings", not just "use for notes". Look for the most - // load-bearing phrase. - expect(guideBody).toMatch(/Skip markdown tiles for starter dashboards/); - }); - - it('explains the 15-minute default time window UX trap', () => { - // Empty tiles on a fresh dashboard are usually a time-window problem, - // not a query problem. The prompt has to surface this so Claude - // tells the user (rather than silently padding individual tile time - // ranges to make a 24-hour view fit a 15-minute dashboard frame). - const prompt = buildCreateDashboardPrompt( - 'summary', - 'trace_src', - 'log_src', - ); - expect(prompt).toMatch(/DEFAULT TIME WINDOW/); - expect(prompt).toMatch(/15-minute default window/); - }); - - it('contains no em-dashes or en-dashes used as em-dashes', () => { - const prompt = buildCreateDashboardPrompt('sources summary', '', ''); - expect(prompt).not.toMatch(/—/); // [prose-lint: allow] - // En-dash flanked by spaces reads as an em-dash substitute and is - // disallowed by the voice rules. Numeric ranges (e.g. "1-20") are - // not in the checklist anyway, but guard against them just in case. - expect(prompt).not.toMatch(/ – /); // [prose-lint: allow] - }); - - it("mentions row-click linking in the table tile's description", () => { - // The create prompt is the LLM's primary entry point for new - // dashboards. Surface onClick on the TILE TYPE GUIDE so the - // model considers wiring it up on overview tables without the - // user having to ask. - const prompt = buildCreateDashboardPrompt( - 'summary', - 'trace_src', - 'log_src', - ); - const guideIdx = prompt.indexOf('== TILE TYPE GUIDE =='); - expect(guideIdx).toBeGreaterThan(-1); - const guideEnd = prompt.indexOf('\n== ', guideIdx + 1); - const guideBody = prompt.slice(guideIdx, guideEnd); - // The table line must hint at onClick and point at the - // consolidated TABLE TILE LINKING section in the query guide. - expect(guideBody).toContain('onClick'); - expect(guideBody).toContain('TABLE TILE LINKING'); - }); - }); - - describe('buildDashboardExamplesPrompt', () => { - it('exposes exactly the four verified trace/log examples plus infrastructure_sql', () => { - const all = buildDashboardExamplesPrompt( - '000000000000000000000001', - '000000000000000000000002', - '000000000000000000000003', - ); - // Order matters here only to keep the LLM\'s navigation stable - // across releases. If the order changes intentionally, update the - // snapshot. - const patternLineMatch = all.match(/Available patterns: ([^\n]+)/); - expect(patternLineMatch).toBeTruthy(); - const patternList = patternLineMatch?.[1].split(', '); - expect(patternList).toEqual([ - 'service_inventory', - 'service_detail', - 'log_analytics', - 'backend_dependencies', - 'drilldown_links', - 'infrastructure_sql', - ]); - }); - - it('renders each example with a leading "When to use" header', () => { - const all = buildDashboardExamplesPrompt( - '000000000000000000000001', - '000000000000000000000002', - '000000000000000000000003', - ); - for (const heading of [ - '== SERVICE INVENTORY ==', - '== SERVICE DETAIL ==', - '== LOG ANALYTICS ==', - '== BACKEND DEPENDENCIES ==', - '== INFRASTRUCTURE (Raw SQL) ==', - ]) { - expect(all).toContain(heading); - } - // Each non-SQL example should explain WHEN to reach for it. The - // SQL example does too, but the heading differs slightly. - const nonSqlSections = all - .split('== ') - .filter(s => - [ - 'SERVICE INVENTORY', - 'SERVICE DETAIL', - 'LOG ANALYTICS', - 'BACKEND DEPENDENCIES', - ].some(h => s.startsWith(h)), - ); - for (const section of nonSqlSections) { - expect(section.toLowerCase()).toContain('when to use'); - } - }); - - it('returns a single example when filtered by pattern', () => { - const single = buildDashboardExamplesPrompt( - '000000000000000000000001', - '000000000000000000000002', - '000000000000000000000003', - 'service_inventory', - ); - expect(single).toContain('== SERVICE INVENTORY =='); - expect(single).not.toContain('== SERVICE DETAIL =='); - expect(single).not.toContain('== LOG ANALYTICS =='); - }); - - it('wires service_inventory row-click into the service_detail dashboard', () => { - // The service_inventory pattern's main RED table should carry an - // onClick that drills into the partner "Service Detail" dashboard - // by name template, with a ServiceName filter rendered from the - // clicked row. Without this wiring, an agent following the canonical - // workflow would have to invent the drill-down each time and - // sometimes get the field shape wrong. - const single = buildDashboardExamplesPrompt( - '000000000000000000000001', - '000000000000000000000002', - '000000000000000000000003', - 'service_inventory', - ); - // The onClick target points at the partner pattern by name. - expect(single).toContain( - 'target: { mode: "template", template: "Service Detail" }', - ); - // The onClick filter expression matches service_detail's filter expression, - // so the destination dashboard's "Service" dropdown auto-populates. - expect(single).toContain('expression: "ServiceName"'); - // The template references the row's groupBy column (ServiceName). - expect(single).toContain('template: "{{ServiceName}}"'); - }); - - it('exposes a drilldown_links example pattern with onClick wiring', () => { - // Drew's drilldown_links example carries a self-contained - // onClick walkthrough (search drill-down + dashboard drill-down). - // Keep it discoverable so an agent asked to wire up drill-downs - // can opt into this example by pattern name. - const all = buildDashboardExamplesPrompt( - 'trace_src', - 'log_src', - 'conn_id', - ); - expect(all).toContain('drilldown_links'); - - const filtered = buildDashboardExamplesPrompt( - 'trace_src', - 'log_src', - 'conn_id', - 'drilldown_links', - ); - expect(filtered).toContain('ROW-CLICK DRILL-DOWN LINKS'); - expect(filtered).toContain('onClick'); - expect(filtered).toContain('type: "search"'); - expect(filtered).toContain('type: "dashboard"'); - expect(filtered).toContain('expressionTemplate'); - }); - - it('falls back to showing all examples when pattern is unknown', () => { - const fallback = buildDashboardExamplesPrompt( - '000000000000000000000001', - '000000000000000000000002', - '000000000000000000000003', - 'made_up_pattern', - ); - expect(fallback).toContain('No example found for pattern'); - // Listing every example name in the fallback gives the LLM a way - // to recover by re-requesting one of the known patterns. - expect(fallback).toContain('service_inventory'); - expect(fallback).toContain('service_detail'); - }); - - it('contains no em-dashes in any example body', () => { - const all = buildDashboardExamplesPrompt( - '000000000000000000000001', - '000000000000000000000002', - '000000000000000000000003', - ); - expect(all).not.toMatch(/—/); // [prose-lint: allow] - }); - }); -}); diff --git a/packages/api/src/mcp/__tests__/dashboards/deleteDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/deleteDashboard.test.ts new file mode 100644 index 0000000000..055887db15 --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/deleteDashboard.test.ts @@ -0,0 +1,38 @@ +import Dashboard from '@/models/dashboard'; + +import { callTool, getFirstText } from '../mcpTestUtils'; +import { setupDashboardTests } from './setup'; + +describe('MCP Dashboard Tools - clickstack_delete_dashboard', () => { + const ctx = setupDashboardTests(); + + it('should delete an existing dashboard', async () => { + const dashboard = await new Dashboard({ + name: 'To Delete', + tiles: [], + team: ctx.team._id, + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_delete_dashboard', { + id: dashboard._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.deleted).toBe(true); + expect(output.id).toBe(dashboard._id.toString()); + + // Verify deleted from database + const found = await Dashboard.findById(dashboard._id); + expect(found).toBeNull(); + }); + + it('should return error for non-existent dashboard', async () => { + const result = await callTool(ctx.client!, 'clickstack_delete_dashboard', { + id: '000000000000000000000000', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/getDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/getDashboard.test.ts new file mode 100644 index 0000000000..64b9f9cd04 --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/getDashboard.test.ts @@ -0,0 +1,65 @@ +import Dashboard from '@/models/dashboard'; + +import { callTool, getFirstText } from '../mcpTestUtils'; +import { setupDashboardTests } from './setup'; + +describe('MCP Dashboard Tools - clickstack_get_dashboard', () => { + const ctx = setupDashboardTests(); + + // Inside each test, use ctx.team, ctx.traceSource, ctx.connection, ctx.client + // Replace bare `team` with `ctx.team`, `client` with `ctx.client!`, etc. + + it('should list all dashboards when no id provided', async () => { + await new Dashboard({ + name: 'Dashboard 1', + tiles: [], + team: ctx.team._id, + tags: ['tag1'], + }).save(); + await new Dashboard({ + name: 'Dashboard 2', + tiles: [], + team: ctx.team._id, + tags: ['tag2'], + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_get_dashboard', {}); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(2); + expect(output[0]).toHaveProperty('id'); + expect(output[0]).toHaveProperty('name'); + expect(output[0]).toHaveProperty('tags'); + }); + + it('should get dashboard detail when id is provided', async () => { + const dashboard = await new Dashboard({ + name: 'My Dashboard', + tiles: [], + team: ctx.team._id, + tags: ['test'], + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_get_dashboard', { + id: dashboard._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.id).toBe(dashboard._id.toString()); + expect(output.name).toBe('My Dashboard'); + expect(output.tags).toEqual(['test']); + expect(output.tiles).toEqual([]); + }); + + it('should return error for non-existent dashboard id', async () => { + const fakeId = '000000000000000000000000'; + const result = await callTool(ctx.client!, 'clickstack_get_dashboard', { + id: fakeId, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/getDashboardTile.test.ts b/packages/api/src/mcp/__tests__/dashboards/getDashboardTile.test.ts new file mode 100644 index 0000000000..8cd5a655ec --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/getDashboardTile.test.ts @@ -0,0 +1,103 @@ +import Dashboard from '@/models/dashboard'; + +import { callTool, getFirstText } from '../mcpTestUtils'; +import { setupDashboardTests } from './setup'; + +describe('MCP Dashboard Tools - clickstack_get_dashboard_tile', () => { + const ctx = setupDashboardTests(); + + it('should return a single tile by tileId', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Tile Test Dashboard', + tiles: [ + { + name: 'Line Chart', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + { + name: 'Number Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const dashboardId = created.id; + const tileId = created.tiles[0].id; + + const result = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { + dashboardId, + tileId, + }, + ); + + expect(result.isError).toBeFalsy(); + const tile = JSON.parse(getFirstText(result)); + expect(tile.id).toBe(tileId); + expect(tile.name).toBe('Line Chart'); + expect(tile.config.displayType).toBe('line'); + }); + + it('should return error for non-existent tileId', async () => { + const dashboard = await new Dashboard({ + name: 'Empty Dashboard', + tiles: [], + team: ctx.team._id, + }).save(); + + const result = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { + dashboardId: dashboard._id.toString(), + tileId: 'nonexistent-tile', + }, + ); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Tile not found'); + }); + + it('should return error for non-existent dashboard', async () => { + const result = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { + dashboardId: '000000000000000000000000', + tileId: 'some-tile', + }, + ); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); + + it('should return error for invalid dashboard ID', async () => { + const result = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { + dashboardId: 'not-valid', + tileId: 'some-tile', + }, + ); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid dashboard ID'); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts new file mode 100644 index 0000000000..666f82edaf --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts @@ -0,0 +1,552 @@ +import Dashboard from '@/models/dashboard'; +import type { ExternalDashboardTileWithId } from '@/utils/zod'; + +import { callTool, getFirstText } from '../mcpTestUtils'; +import { setupDashboardTests } from './setup'; + +describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { + const ctx = setupDashboardTests(); + + it('should patch a tile config without affecting other tiles', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Patch Test Dashboard', + tiles: [ + { + name: 'Tile A', + x: 0, + y: 0, + w: 12, + h: 4, + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + { + name: 'Tile B', + x: 0, + y: 4, + w: 12, + h: 4, + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const dashboardId = created.id; + const tileAId = created.tiles[0].id; + + // Patch only tile A: change its name and displayType + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId, + tileId: tileAId, + tile: { + name: 'Patched Tile A', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + }, + ); + + expect(patchResult.isError).toBeFalsy(); + const patchOutput = JSON.parse(getFirstText(patchResult)); + expect(patchOutput.patchedTile.name).toBe('Patched Tile A'); + expect(patchOutput.patchedTile.config.displayType).toBe('table'); + + // Verify the full dashboard: tile B should be untouched + const getResult = await callTool(ctx.client!, 'clickstack_get_dashboard', { + id: dashboardId, + }); + const dashboard = JSON.parse(getFirstText(getResult)); + expect(dashboard.tiles).toHaveLength(2); + + const tileA = dashboard.tiles.find( + (t: ExternalDashboardTileWithId) => t.id === tileAId, + ); + expect(tileA.name).toBe('Patched Tile A'); + expect(tileA.config.displayType).toBe('table'); + + const tileB = dashboard.tiles.find( + (t: ExternalDashboardTileWithId) => t.id !== tileAId, + ); + expect(tileB.name).toBe('Tile B'); + expect(tileB.config.displayType).toBe('number'); + }); + + it('should preserve existing layout when not specified in patch', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Layout Test', + tiles: [ + { + name: 'Positioned Tile', + x: 6, + y: 10, + w: 8, + h: 5, + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const tileId = created.tiles[0].id; + + // Patch without specifying layout — layout should be preserved + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId: created.id, + tileId, + tile: { + name: 'Renamed Tile', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + }, + }, + }, + ); + + expect(patchResult.isError).toBeFalsy(); + const patched = JSON.parse(getFirstText(patchResult)); + expect(patched.patchedTile.x).toBe(6); + expect(patched.patchedTile.y).toBe(10); + expect(patched.patchedTile.w).toBe(8); + expect(patched.patchedTile.h).toBe(5); + expect(patched.patchedTile.name).toBe('Renamed Tile'); + }); + + it('should update dashboard name only (no tile patch)', async () => { + const dashboard = await new Dashboard({ + name: 'Original Name', + tiles: [], + team: ctx.team._id, + tags: ['tag1'], + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: dashboard._id.toString(), + name: 'New Name', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.name).toBe('New Name'); + expect(output.tags).toEqual(['tag1']); + expect(output.patchedTile).toBeUndefined(); + + // Verify in DB + const updated = await Dashboard.findById(dashboard._id); + expect(updated?.name).toBe('New Name'); + }); + + it('should update tags only', async () => { + const dashboard = await new Dashboard({ + name: 'Tag Test', + tiles: [], + team: ctx.team._id, + tags: ['old'], + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: dashboard._id.toString(), + tags: ['new1', 'new2'], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tags).toEqual(['new1', 'new2']); + expect(output.name).toBe('Tag Test'); + }); + + it('should update name and patch tile in one call', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Combo Test', + tiles: [ + { + name: 'A Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: created.id, + name: 'Combo Updated', + tileId: created.tiles[0].id, + tile: { + name: 'Updated Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + }, + }, + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.name).toBe('Combo Updated'); + expect(output.patchedTile.name).toBe('Updated Tile'); + }); + + it('should return error for non-existent tileId', async () => { + const dashboard = await new Dashboard({ + name: 'Tile Not Found', + tiles: [], + team: ctx.team._id, + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: dashboard._id.toString(), + tileId: 'nonexistent', + tile: { + name: 'Ghost', + config: { displayType: 'markdown', markdown: 'hello' }, + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Tile not found'); + }); + + it('should return error for non-existent dashboard', async () => { + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: '000000000000000000000000', + name: 'Ghost', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); + + it('should return error for missing source ID on patched tile', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Source Validation Test', + tiles: [ + { + name: 'Valid Tile', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: created.id, + tileId: created.tiles[0].id, + tile: { + name: 'Bad Source', + config: { + displayType: 'line', + sourceId: '000000000000000000000000', + select: [{ aggFn: 'count' }], + }, + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('source'); + }); + + it('should not modify other tiles in the database (positional update)', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Positional Update Test', + tiles: [ + { + name: 'Tile A', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + { + name: 'Tile B', + x: 6, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + { + name: 'Tile C', + x: 0, + y: 3, + w: 12, + h: 4, + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const dashboardId = created.id; + + // Snapshot the raw tiles from the DB before patching + const beforePatch = await Dashboard.findById(dashboardId).lean(); + const tilesBeforePatch = (beforePatch as any).tiles; + const tileBBefore = tilesBeforePatch[1]; + const tileCBefore = tilesBeforePatch[2]; + + // Patch only tile A (index 0) + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId, + tileId: created.tiles[0].id, + tile: { + name: 'Tile A Patched', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + }, + }, + }, + ); + expect(patchResult.isError).toBeFalsy(); + + // Read raw DB tiles again — tiles B and C should be byte-identical + const afterPatch = await Dashboard.findById(dashboardId).lean(); + const tilesAfterPatch = (afterPatch as any).tiles; + + expect(tilesAfterPatch).toHaveLength(3); + // Tile B (index 1) and Tile C (index 2) should be untouched + expect(tilesAfterPatch[1]).toEqual(tileBBefore); + expect(tilesAfterPatch[2]).toEqual(tileCBefore); + // Tile A (index 0) should have changed + expect(tilesAfterPatch[0].config.name).toBe('Tile A Patched'); + }); + + it('should preserve tile name when omitted from patch (config-only patch)', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Name Preservation Test', + tiles: [ + { + name: 'Original Title', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const tileId = created.tiles[0].id; + + // Patch config only — no name field at all + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId: created.id, + tileId, + tile: { + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + }, + }, + }, + ); + + expect(patchResult.isError).toBeFalsy(); + const patched = JSON.parse(getFirstText(patchResult)); + expect(patched.patchedTile.name).toBe('Original Title'); + expect(patched.patchedTile.config.displayType).toBe('table'); + + // Verify via get + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { dashboardId: created.id, tileId }, + ); + const tile = JSON.parse(getFirstText(getResult)); + expect(tile.name).toBe('Original Title'); + }); + + it('should return error when tile is removed between read and write', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Concurrent Delete Test', + tiles: [ + { + name: 'Tile A', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + { + name: 'Tile B', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const tileAId = created.tiles[0].id; + + // Simulate a concurrent save_dashboard that removes tile A by + // directly updating the DB to drop it from the array. + await Dashboard.findByIdAndUpdate(created.id, { + $pull: { tiles: { id: tileAId } }, + }); + + // Now try to patch tile A — it no longer exists in the array. + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId: created.id, + tileId: tileAId, + tile: { + name: 'Patched A', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + }, + ); + + expect(patchResult.isError).toBe(true); + // Could be "Tile not found" (caught at read) or "was not found at + // write time" (caught by positional $ miss). Either is acceptable. + const text = getFirstText(patchResult); + expect(text).toMatch(/not found|was not found/i); + }); + + it('should round-trip: patch then get_dashboard_tile', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Round Trip Test', + tiles: [ + { + name: 'Original', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const tileId = created.tiles[0].id; + + // Patch + await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: created.id, + tileId, + tile: { + name: 'Patched', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + }, + }, + }); + + // Get the tile back + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { + dashboardId: created.id, + tileId, + }, + ); + + expect(getResult.isError).toBeFalsy(); + const tile = JSON.parse(getFirstText(getResult)); + expect(tile.id).toBe(tileId); + expect(tile.name).toBe('Patched'); + expect(tile.config.displayType).toBe('number'); + expect(tile.config.select[0].aggFn).toBe('avg'); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/prompts.test.ts b/packages/api/src/mcp/__tests__/dashboards/prompts.test.ts new file mode 100644 index 0000000000..5bb16e6113 --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/prompts.test.ts @@ -0,0 +1,576 @@ +import { + buildCreateDashboardPrompt, + buildDashboardExamplesPrompt, + buildQueryGuidePrompt, +} from '../../prompts/dashboards/content'; +import { buildSourceSummary } from '../../prompts/dashboards/helpers'; + +describe('MCP Dashboard Prompts', () => { + describe('buildQueryGuidePrompt', () => { + it('documents heatmap in tile-type, constraints, mistakes, and aggFn sections', () => { + const prompt = buildQueryGuidePrompt(); + // Ensure each section the bot called out has a heatmap entry so + // the LLM cannot skip the constraints when producing a heatmap + // tile through clickstack_save_dashboard. + const sections = [ + '== AGGREGATION FUNCTIONS (aggFn) ==', + '== PER-TILE TYPE CONSTRAINTS ==', + '== COMMON MISTAKES ==', + ]; + for (const heading of sections) { + const idx = prompt.indexOf(heading); + expect(idx).toBeGreaterThan(-1); + // Each section's body extends until the next == heading or the + // end of the string. Checking for the substring "heatmap" in + // that slice is enough to assert the heatmap entry exists. + const next = prompt.indexOf('\n== ', idx + heading.length); + const body = prompt.slice(idx, next === -1 ? prompt.length : next); + expect(body.toLowerCase()).toContain('heatmap'); + } + }); + + it('documents the dashboard filter and per-series numberFormat sections', () => { + const prompt = buildQueryGuidePrompt(); + // Sections must exist AND carry the substantive content. Heading- + // only assertions let a future contributor empty out a section + // and silently pass review. + const sliceSection = (heading: string) => { + const idx = prompt.indexOf(heading); + if (idx === -1) return ''; + const next = prompt.indexOf('\n== ', idx + heading.length); + return prompt.slice(idx, next === -1 ? prompt.length : next); + }; + const filters = sliceSection('== DASHBOARD FILTERS =='); + expect(filters).toContain('QUERY_EXPRESSION'); + expect(filters).toContain('expression'); + expect(filters).toContain('sourceId'); + const numberFormat = sliceSection('== NUMBER FORMAT =='); + expect(numberFormat).toContain('factor: 0.000000001'); + expect(numberFormat).toContain('duration'); + expect(numberFormat.toLowerCase()).toContain('per-series'); + }); + + it('declares the MCP metric-authoring gap rather than referencing fields that do not exist', () => { + // The MCP select-item schema does not carry metricName / metricType, + // so any prompt that tells the model to author a metric tile via + // the builder path is teaching it to ship JSON that gets silently + // stripped. Guard with an explicit assertion so a future diff that + // restores metric-tile guidance fails this test loudly. + const prompt = buildQueryGuidePrompt(); + expect(prompt).not.toMatch(/metricName \+ metricType/); + expect(prompt).not.toMatch(/exactly 1 select item with metricName/); + const constraintsIdx = prompt.indexOf('== PER-TILE TYPE CONSTRAINTS =='); + const constraintsBody = prompt.slice(constraintsIdx); + // The constraints section closes with an explicit note that builder + // tiles on a metric source are not reliable today, with a fallback + // recipe to raw SQL. Anchor on the phrase so a future diff that + // drops the gap-acknowledgement fails loudly. + expect(constraintsBody).toMatch( + /Authoring builder tiles on a metric source is not reliable/, + ); + expect(constraintsBody).toMatch(/MCP select-item shape does not carry/); + }); + + it('documents table-tile onClick linking features', () => { + // Lock down the documentation for row-click drill-downs so a + // future refactor can't quietly drop the section the LLM relies + // on to wire up onClick correctly. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== TABLE TILE LINKING (config.onClick) =='); + expect(idx).toBeGreaterThan(-1); + const next = prompt.indexOf('\n== ', idx + 1); + const section = prompt.slice(idx, next === -1 ? prompt.length : next); + + // Both destination types are mentioned. + expect(section).toContain('type: "search"'); + expect(section).toContain('type: "dashboard"'); + // Both target modes are mentioned. + expect(section).toContain('mode: "id"'); + expect(section).toContain('mode: "template"'); + // Templating fields are mentioned. + expect(section).toContain('whereTemplate'); + expect(section).toContain('filters'); + expect(section).toContain('expressionTemplate'); + // The two server-side validation error messages are quoted so + // the LLM can recognize and recover from them. + expect(section).toContain('onClick search source IDs'); + expect(section).toContain('onClick dashboard IDs'); + }); + + it('documents onClick pitfalls under common mistakes', () => { + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== COMMON MISTAKES =='); + expect(idx).toBeGreaterThan(-1); + const section = prompt.slice(idx); + // Mention the four failure modes we hit during validation. + expect(section.toLowerCase()).toContain('onclick on a non-table tile'); + expect(section.toLowerCase()).toContain('non-log/trace source'); + expect(section.toLowerCase()).toContain('missing wherelanguage'); + expect(section.toLowerCase()).toContain("isn't in the table"); + }); + + it('documents the missing-alias mistake explicitly', () => { + // Claude built three number tiles without alias on the quantile() + // select item, even though rule 2 of the design checklist says + // ALIAS EVERY SELECT ITEM. Spelling out the mistake under COMMON + // MISTAKES gives the model a second touchpoint to catch it. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== COMMON MISTAKES =='); + const section = prompt.slice(idx); + expect(section).toMatch(/Missing alias on a select item/); + // Must call out that number tiles need the alias too; this is the + // exact case Claude got wrong. + expect(section).toMatch(/number tiles too/); + }); + + it('documents the Lucene map-attribute gotcha across all operators including simple equality', () => { + // The original rule covered only comparison/wildcard. Claude's + // second pass showed simple equality (db.system:mongodb) also + // fails to translate to SpanAttributes['db.system']. Broaden the + // gotcha to "ALL Lucene operations on map-attribute paths". + const prompt = buildQueryGuidePrompt(); + const luceneIdx = prompt.indexOf('== LUCENE FILTER SYNTAX =='); + const luceneBody = prompt.slice(luceneIdx); + expect(luceneBody).toMatch(/NOT reliably translated to bracket access/); + expect(luceneBody).toMatch(/http\.status_code:>=500/); // operator + expect(luceneBody).toMatch(/http\.route:\*/); // wildcard + expect(luceneBody).toMatch(/db\.system:mongodb/); // simple equality + expect(luceneBody).toMatch(/use SQL with bracket access/); + + // Also a top-level entry in COMMON MISTAKES for discoverability. + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const mistakesBody = prompt.slice(mistakesIdx); + expect(mistakesBody).toMatch( + /Lucene on a map-attribute path \(any operation\)/, + ); + }); + + it('documents the Lucene fuzzy-substring behavior on top-level columns', () => { + // Lucene field:value translates to ilike(field, '%value%'), not + // equality. Claude's second pass hit this on SpanKind:Server, + // which matched broader strings than intended. The note has to + // be in the Lucene section so a model reading top-down sees it + // before writing a SpanKind filter. + const prompt = buildQueryGuidePrompt(); + const luceneIdx = prompt.indexOf('== LUCENE FILTER SYNTAX =='); + const luceneBody = prompt.slice(luceneIdx); + expect(luceneBody).toMatch(/SUBSTRING MATCHING/); + expect(luceneBody).toMatch(/ilike\(field, '%value%'\)/); + // The canonical fix is SQL with =. + expect(luceneBody).toMatch(/SpanKind = 'Server'/); + // Wildcard limitation is documented alongside substring matching. + expect(luceneBody).toMatch(/prefix-within-substring/); + expect(luceneBody).toMatch(/LIKE 'api%'/); + + // And a COMMON MISTAKES entry covering enum-like columns and wildcards. + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const mistakesBody = prompt.slice(mistakesIdx); + expect(mistakesBody).toMatch(/Lucene field:value and field:value\*/); + }); + + it('documents the empty-string trap for map-attribute groupBy', () => { + // Map columns return '' (not NULL) when a key is unset on a row. + // A groupBy on such a key produces an empty-string bucket alongside + // the real values, which is visual noise on tables and pies. The + // standard fix is where: " != ''" alongside the groupBy. + const prompt = buildQueryGuidePrompt(); + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const mistakesBody = prompt.slice(mistakesIdx); + expect(mistakesBody).toMatch( + /Forgetting that map-attribute values are often empty strings/, + ); + expect(mistakesBody).toMatch(/db\.collection\.name'\] != ''/); + }); + + it('documents the groupBy alias workaround for map-attribute drill-downs', () => { + // Builder tiles don't expose a groupBy alias today; map-attribute + // groupBys produce result columns named like the raw expression, + // which onClick template lookups can't reference. The workaround + // (author the tile as raw SQL with AS) needs to be findable. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf( + '== GROUPBY ALIASES AND ROW-CLICK TEMPLATES ==', + ); + expect(idx).toBeGreaterThan(-1); + const section = prompt.slice(idx); + expect(section).toMatch(/do NOT currently expose a groupBy alias/); + expect(section).toMatch(/configType: "sql", displayType: "table"/); + expect(section).toMatch(/AS Route/); + }); + + it('points at ClickStack and ClickHouse docs in REFERENCES', () => { + // Without external doc links, the LLM has to guess at Lucene + // operators and ClickHouse function names. Anchor the canonical + // URLs in the prompt so it can cite them and so a future doc + // restructure can be caught here. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== REFERENCES =='); + expect(idx).toBeGreaterThan(-1); + const section = prompt.slice(idx); + expect(section).toContain( + 'https://clickhouse.com/docs/use-cases/observability/clickstack/search', + ); + expect(section).toContain('https://clickhouse.com/docs/sql-reference'); + expect(section).toContain( + 'https://clickhouse.com/docs/use-cases/observability/clickstack/dashboards/sql-visualizations', + ); + }); + + it('strengthens the validate-every-tile rule under common mistakes', () => { + // Saving validates input shape, not query semantics. The prompt + // has to push for query_tile on every tile, not "at least one". + const prompt = buildQueryGuidePrompt(); + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const section = prompt.slice(mistakesIdx); + expect(section).toMatch( + /on EVERY tile after clickstack_save_dashboard, not just one/, + ); + }); + + it('flags the metric source builder gap', () => { + // Builder tiles on metric sources currently save but render with + // "Both table name and UUID are empty". Claude went 100% raw SQL + // across 21 metric tiles for this reason; the prompt has to make + // the workaround obvious so the model doesn't try the builder + // first and fail silently. + const prompt = buildQueryGuidePrompt(); + expect(prompt).toMatch(/Both table name and UUID are empty/); + expect(prompt).toMatch(/use a raw SQL tile/); + expect(prompt).toMatch(/otel_metrics_gauge/); + }); + + it('contains no em-dashes or en-dashes used as em-dashes', () => { + const prompt = buildQueryGuidePrompt(); + expect(prompt).not.toMatch(/\u2014/); // [prose-lint: allow] + expect(prompt).not.toMatch(/ \u2013 /); // [prose-lint: allow] + }); + }); + + describe('buildSourceSummary', () => { + it('contains no em-dashes or en-dashes used as em-dashes', () => { + // buildSourceSummary is the source-list block that gets prepended + // to create_dashboard. The first iteration of this PR shipped with + // em-dashes here that the content.ts snapshot test missed because + // it calls the builders directly. Guard the helper output too. + const summary = buildSourceSummary( + [ + { + _id: '000000000000000000000001', + name: 'Traces', + kind: 'trace', + connection: '000000000000000000000002', + }, + { + _id: '000000000000000000000003', + name: 'Logs', + kind: 'log', + connection: '000000000000000000000002', + }, + ], + [{ _id: '000000000000000000000002', name: 'Default' }], + ); + expect(summary).not.toMatch(/\u2014/); // [prose-lint: allow] + expect(summary).not.toMatch(/ \u2013 /); // [prose-lint: allow] + // Sanity: the helper still emits the source list, so the assertion + // above isn't trivially passing on an empty string. + expect(summary).toContain('Traces'); + expect(summary).toContain('AVAILABLE SOURCES'); + }); + }); + + describe('buildCreateDashboardPrompt', () => { + it('includes the design checklist and adapt-do-not-copy note', () => { + const prompt = buildCreateDashboardPrompt( + 'sources summary', + '000000000000000000000001', + '000000000000000000000002', + ); + expect(prompt).toContain('== DESIGN CHECKLIST =='); + // Each rule on the checklist exists at the same numbered position + // so a future contributor cannot silently drop one. Anchor on + // line start (with a trailing space) so digit-and-dot substrings + // like "0.000000001" elsewhere in the prompt cannot satisfy the + // assertion accidentally. + const checklistIdx = prompt.indexOf('== DESIGN CHECKLIST =='); + const adaptIdx = prompt.indexOf('== ADAPT, DO NOT COPY =='); + const checklistBody = prompt.slice(checklistIdx, adaptIdx); + // Thirteen rules: the original ten plus GROUP BY HAS NO ALIAS HOOK + // (rule 3), VALIDATE EVERY TILE AFTER SAVE (rule 12), and NO + // TITLE-RECAP MARKDOWN TILE (rule 13). Each came out of a live + // verification pass after watching Claude reliably ignore the soft + // "should" formulations or hit a schema gap the earlier checklist + // did not call out. + for (let i = 1; i <= 13; i++) { + expect(checklistBody).toMatch(new RegExp(`^${i}\\. `, 'm')); + } + expect(prompt).toContain('ADAPT, DO NOT COPY'); + // Rule 9 documents the replace-not-merge semantic on update; an + // agent that ignores this can silently drop tiles / filters / containers + // on a subsequent save call. + expect(checklistBody).toMatch(/UPDATE IS REPLACE, NOT MERGE/); + // Rule 2 must cover EVERY select item (including number tiles), not + // just aggregations on tables. The phrasing matters: Claude's first + // pass at this dropped aliases on number tiles because it read rule 2 + // as table-specific. The stronger phrasing pulls them in. + expect(checklistBody).toMatch(/ALIAS EVERY SELECT ITEM/); + // Rule 3 calls out the schema gap on groupBy: the chart config takes + // a single expression string with no alias field, so a table grouped + // by SpanAttributes['http.route'] renders arrayElement(...) as the + // column header. Naming the limit explicitly lets Claude pick a + // top-level column (SpanName, ServiceName) instead of grouping on + // the raw Map expression. + expect(checklistBody).toMatch(/GROUP BY HAS NO ALIAS HOOK/); + // Rule 10 must be a hard requirement at 5+ tiles, not a soft hint. + // Without the imperative, Claude built five dashboards averaging ten + // tiles each with zero containers. + expect(checklistBody).toMatch(/REQUIRED at five or more tiles/); + // Rule 11 + 12 close the post-save loop and the title-recap markdown + // habit Claude landed on every starter dashboard. + expect(checklistBody).toMatch(/VALIDATE EVERY TILE AFTER SAVE/); + expect(checklistBody).toMatch(/NO TITLE-RECAP MARKDOWN TILE/); + // Rule 2 now carries a concrete number-tile example. Claude's + // second pass still dropped aliases on every builder number tile, + // because the text rule was abstract; a copy-pasteable shape next + // to the rule is what makes it stick. + expect(checklistBody).toMatch( + /Number tile, correct:.*alias: "Server Requests"/, + ); + // Rule 10 now carries a concrete containers shape. Claude's + // second pass built five dashboards with 9-10 tiles each and zero + // containers despite the REQUIRED phrasing; a copy-pasteable shape + // inside the rule itself is the next-level push. + expect(checklistBody).toMatch(/Concrete shape \(copy this directly/); + expect(checklistBody).toMatch(/id: "kpis"/); + expect(checklistBody).toMatch(/id: "trends"/); + expect(checklistBody).toMatch(/id: "errors"/); + }); + + it('walks the workflow through six steps including read-existing and group-into-containers', () => { + // The workflow is the first thing a model anchors on. Claude + // skipped both "read existing dashboards before designing" and + // "group tiles into containers before saving" because neither was + // a numbered workflow step; both lived only in the checklist. The + // workflow now lists six steps with those two explicitly. + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + const wfIdx = prompt.indexOf('== WORKFLOW =='); + const wfEnd = prompt.indexOf('\n== ', wfIdx + 1); + const wf = prompt.slice(wfIdx, wfEnd); + for (let i = 1; i <= 6; i++) { + expect(wf).toMatch(new RegExp(`^${i}\\. `, 'm')); + } + // Step 2 must point at clickstack_get_dashboard with no id. + expect(wf).toMatch(/clickstack_get_dashboard \(no id\)/); + // Step 4 must call out grouping tiles into containers before save. + expect(wf).toMatch(/group them into 2-4 containers/); + // Step 6 must call out EVERY tile, not just one. + expect(wf).toMatch(/EVERY tile \(not just one\)/); + }); + + it('warns explicitly against title-recap markdown tiles in the tile type guide', () => { + // The TILE TYPE GUIDE entry for markdown is where a model reading + // top-down first decides whether to add an "About this dashboard" + // tile. The entry has to actively discourage it, not just describe + // the tile type, because every model trained on dashboard examples + // reaches for a title tile by default. + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + const guideIdx = prompt.indexOf('== TILE TYPE GUIDE =='); + const guideEnd = prompt.indexOf('\n== ', guideIdx + 1); + const guideBody = prompt.slice(guideIdx, guideEnd); + // The markdown line must say "skip" / "sparingly" / "do not" / + // "no headings", not just "use for notes". Look for the most + // load-bearing phrase. + expect(guideBody).toMatch(/Skip markdown tiles for starter dashboards/); + }); + + it('explains the 15-minute default time window UX trap', () => { + // Empty tiles on a fresh dashboard are usually a time-window problem, + // not a query problem. The prompt has to surface this so Claude + // tells the user (rather than silently padding individual tile time + // ranges to make a 24-hour view fit a 15-minute dashboard frame). + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + expect(prompt).toMatch(/DEFAULT TIME WINDOW/); + expect(prompt).toMatch(/15-minute default window/); + }); + + it('contains no em-dashes or en-dashes used as em-dashes', () => { + const prompt = buildCreateDashboardPrompt('sources summary', '', ''); + expect(prompt).not.toMatch(/\u2014/); // [prose-lint: allow] + // En-dash flanked by spaces reads as an em-dash substitute and is + // disallowed by the voice rules. Numeric ranges (e.g. "1-20") are + // not in the checklist anyway, but guard against them just in case. + expect(prompt).not.toMatch(/ \u2013 /); // [prose-lint: allow] + }); + + it("mentions row-click linking in the table tile's description", () => { + // The create prompt is the LLM's primary entry point for new + // dashboards. Surface onClick on the TILE TYPE GUIDE so the + // model considers wiring it up on overview tables without the + // user having to ask. + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + const guideIdx = prompt.indexOf('== TILE TYPE GUIDE =='); + expect(guideIdx).toBeGreaterThan(-1); + const guideEnd = prompt.indexOf('\n== ', guideIdx + 1); + const guideBody = prompt.slice(guideIdx, guideEnd); + // The table line must hint at onClick and point at the + // consolidated TABLE TILE LINKING section in the query guide. + expect(guideBody).toContain('onClick'); + expect(guideBody).toContain('TABLE TILE LINKING'); + }); + }); + + describe('buildDashboardExamplesPrompt', () => { + it('exposes exactly the four verified trace/log examples plus infrastructure_sql', () => { + const all = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + ); + // Order matters here only to keep the LLM's navigation stable + // across releases. If the order changes intentionally, update the + // snapshot. + const patternLineMatch = all.match(/Available patterns: ([^\n]+)/); + expect(patternLineMatch).toBeTruthy(); + const patternList = patternLineMatch?.[1].split(', '); + expect(patternList).toEqual([ + 'service_inventory', + 'service_detail', + 'log_analytics', + 'backend_dependencies', + 'drilldown_links', + 'infrastructure_sql', + ]); + }); + + it('renders each example with a leading "When to use" header', () => { + const all = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + ); + for (const heading of [ + '== SERVICE INVENTORY ==', + '== SERVICE DETAIL ==', + '== LOG ANALYTICS ==', + '== BACKEND DEPENDENCIES ==', + '== INFRASTRUCTURE (Raw SQL) ==', + ]) { + expect(all).toContain(heading); + } + // Each non-SQL example should explain WHEN to reach for it. The + // SQL example does too, but the heading differs slightly. + const nonSqlSections = all + .split('== ') + .filter(s => + [ + 'SERVICE INVENTORY', + 'SERVICE DETAIL', + 'LOG ANALYTICS', + 'BACKEND DEPENDENCIES', + ].some(h => s.startsWith(h)), + ); + for (const section of nonSqlSections) { + expect(section.toLowerCase()).toContain('when to use'); + } + }); + + it('returns a single example when filtered by pattern', () => { + const single = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + 'service_inventory', + ); + expect(single).toContain('== SERVICE INVENTORY =='); + expect(single).not.toContain('== SERVICE DETAIL =='); + expect(single).not.toContain('== LOG ANALYTICS =='); + }); + + it('wires service_inventory row-click into the service_detail dashboard', () => { + // The service_inventory pattern's main RED table should carry an + // onClick that drills into the partner "Service Detail" dashboard + // by name template, with a ServiceName filter rendered from the + // clicked row. Without this wiring, an agent following the canonical + // workflow would have to invent the drill-down each time and + // sometimes get the field shape wrong. + const single = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + 'service_inventory', + ); + // The onClick target points at the partner pattern by name. + expect(single).toContain( + 'target: { mode: "template", template: "Service Detail" }', + ); + // The onClick filter expression matches service_detail's filter expression, + // so the destination dashboard's "Service" dropdown auto-populates. + expect(single).toContain('expression: "ServiceName"'); + // The template references the row's groupBy column (ServiceName). + expect(single).toContain('template: "{{ServiceName}}"'); + }); + + it('exposes a drilldown_links example pattern with onClick wiring', () => { + // Drew's drilldown_links example carries a self-contained + // onClick walkthrough (search drill-down + dashboard drill-down). + // Keep it discoverable so an agent asked to wire up drill-downs + // can opt into this example by pattern name. + const all = buildDashboardExamplesPrompt( + 'trace_src', + 'log_src', + 'conn_id', + ); + expect(all).toContain('drilldown_links'); + + const filtered = buildDashboardExamplesPrompt( + 'trace_src', + 'log_src', + 'conn_id', + 'drilldown_links', + ); + expect(filtered).toContain('ROW-CLICK DRILL-DOWN LINKS'); + expect(filtered).toContain('onClick'); + expect(filtered).toContain('type: "search"'); + expect(filtered).toContain('type: "dashboard"'); + expect(filtered).toContain('expressionTemplate'); + }); + + it('falls back to showing all examples when pattern is unknown', () => { + const fallback = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + 'made_up_pattern', + ); + expect(fallback).toContain('No example found for pattern'); + // Listing every example name in the fallback gives the LLM a way + // to recover by re-requesting one of the known patterns. + expect(fallback).toContain('service_inventory'); + expect(fallback).toContain('service_detail'); + }); + + it('contains no em-dashes in any example body', () => { + const all = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + ); + expect(all).not.toMatch(/\u2014/); // [prose-lint: allow] + }); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/queryTile.test.ts b/packages/api/src/mcp/__tests__/dashboards/queryTile.test.ts new file mode 100644 index 0000000000..3eb44a8676 --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/queryTile.test.ts @@ -0,0 +1,110 @@ +import { callTool, getFirstText } from '../mcpTestUtils'; +import { setupDashboardTests } from './setup'; + +describe('MCP Dashboard Tools - clickstack_query_tile', () => { + const ctx = setupDashboardTests(); + + it('should return error for non-existent dashboard', async () => { + const result = await callTool(ctx.client!, 'clickstack_query_tile', { + dashboardId: '000000000000000000000000', + tileId: 'some-tile-id', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); + + it('should return error for non-existent tile', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Tile Query Test', + tiles: [ + { + name: 'My Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const dashboard = JSON.parse(getFirstText(createResult)); + + const result = await callTool(ctx.client!, 'clickstack_query_tile', { + dashboardId: dashboard.id, + tileId: 'non-existent-tile-id', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Tile not found'); + }); + + it('should return error for invalid time range', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Time Range Test', + tiles: [ + { + name: 'Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const dashboard = JSON.parse(getFirstText(createResult)); + + const result = await callTool(ctx.client!, 'clickstack_query_tile', { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + startTime: 'not-a-date', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid'); + }); + + it('should execute query for a valid tile', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Query Tile Test', + tiles: [ + { + name: 'Count Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const dashboard = JSON.parse(getFirstText(createResult)); + + const result = await callTool(ctx.client!, 'clickstack_query_tile', { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + startTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + endTime: new Date().toISOString(), + }); + + // Should succeed (may have empty results since no data inserted) + expect(result.isError).toBeFalsy(); + expect(result.content).toHaveLength(1); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts new file mode 100644 index 0000000000..d0cfe53521 --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts @@ -0,0 +1,1806 @@ +import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; +import mongoose from 'mongoose'; + +import { DEFAULT_DATABASE } from '@/fixtures'; +import Dashboard from '@/models/dashboard'; +import { Source } from '@/models/source'; +import type { ExternalDashboardTileWithId } from '@/utils/zod'; + +import { callTool, getFirstText } from '../mcpTestUtils'; +import { setupDashboardTests } from './setup'; + +describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { + const ctx = setupDashboardTests(); + + describe('basic CRUD', () => { + it('should create a new dashboard with tiles', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'New MCP Dashboard', + tiles: [ + { + name: 'Line Chart', + x: 0, + y: 0, + w: 12, + h: 4, + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count', where: '' }], + }, + }, + ], + tags: ['mcp-test'], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.id).toBeDefined(); + expect(output.name).toBe('New MCP Dashboard'); + expect(output.tiles).toHaveLength(1); + expect(output.tiles[0].config.displayType).toBe('line'); + expect(output.tags).toEqual(['mcp-test']); + + // Verify in database + const dashboard = await Dashboard.findById(output.id); + expect(dashboard).not.toBeNull(); + expect(dashboard?.name).toBe('New MCP Dashboard'); + }); + + it('should create a dashboard with a markdown tile', async () => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Markdown Dashboard', + tiles: [ + { + name: 'Notes', + config: { + displayType: 'markdown', + markdown: '# Hello World', + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles).toHaveLength(1); + expect(output.tiles[0].config.displayType).toBe('markdown'); + }); + + it('should update an existing dashboard', async () => { + const sourceId = ctx.traceSource._id.toString(); + + // Create first + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Original Name', + tiles: [ + { + name: 'Tile 1', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + + // Update + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: created.id, + name: 'Updated Name', + tiles: [ + { + name: 'Updated Tile', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + tags: ['updated'], + }, + ); + + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.id).toBe(created.id); + expect(updated.name).toBe('Updated Name'); + expect(updated.tiles).toHaveLength(1); + expect(updated.tiles[0].name).toBe('Updated Tile'); + expect(updated.tiles[0].config.displayType).toBe('table'); + }); + + it('should return error for missing source ID', async () => { + const fakeSourceId = '000000000000000000000000'; + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad Dashboard', + tiles: [ + { + name: 'Bad Tile', + config: { + displayType: 'line', + sourceId: fakeSourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('source'); + }); + + it('should return error when updating non-existent dashboard', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id: '000000000000000000000000', + name: 'Ghost Dashboard', + tiles: [ + { + name: 'Tile', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); + + it('should create a dashboard with multiple tile types', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Multi-tile Dashboard', + tiles: [ + { + name: 'Line', + x: 0, + y: 0, + w: 12, + h: 4, + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + { + name: 'Table', + x: 0, + y: 4, + w: 12, + h: 4, + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + groupBy: 'SpanName', + groupByColumnsOnLeft: true, + }, + }, + { + name: 'Number', + x: 0, + y: 8, + w: 6, + h: 3, + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + { + name: 'Pie', + x: 6, + y: 8, + w: 6, + h: 3, + config: { + displayType: 'pie', + sourceId, + select: [{ aggFn: 'count' }], + groupBy: 'SpanName', + }, + }, + { + name: 'Notes', + x: 0, + y: 11, + w: 12, + h: 2, + config: { displayType: 'markdown', markdown: '# Dashboard Notes' }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles).toHaveLength(5); + + const tableTile = output.tiles.find( + (t: { name: string }) => t.name === 'Table', + ); + expect(tableTile).toBeDefined(); + expect(tableTile.config.groupByColumnsOnLeft).toBe(true); + }); + + it('should create a dashboard with a raw SQL tile', async () => { + const connectionId = ctx.connection._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'SQL Dashboard', + tiles: [ + { + name: 'Raw SQL', + config: { + configType: 'sql', + displayType: 'table', + connectionId, + sqlTemplate: 'SELECT 1 AS value LIMIT 1', + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles).toHaveLength(1); + }); + + it('should create a dashboard with a heatmap tile on a Trace source', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Heatmap Dashboard', + tiles: [ + { + name: 'Latency Heatmap', + x: 0, + y: 0, + w: 12, + h: 4, + config: { + displayType: 'heatmap', + sourceId, + select: [ + { + valueExpression: 'Duration', + }, + ], + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles).toHaveLength(1); + expect(output.tiles[0].config.displayType).toBe('heatmap'); + expect(output.tiles[0].config.select[0]).toMatchObject({ + valueExpression: 'Duration', + }); + }); + + it('should round-trip every MCP-specific heatmap field through save, get, update, and re-get', async () => { + const sourceId = ctx.traceSource._id.toString(); + + const createConfig = { + displayType: 'heatmap' as const, + sourceId, + select: [ + { + valueExpression: 'Duration', + countExpression: 'count()', + heatmapScaleType: 'log' as const, + }, + ], + where: 'level:error', + whereLanguage: 'lucene' as const, + numberFormat: { output: 'duration' as const, factor: 1e-9 }, + }; + // Mutate select + where on update; numberFormat, whereLanguage, + // and sourceId carry forward via the spread. Re-asserting against + // updatedConfig catches a regression where PUT silently drops any + // carried-forward field. + const updatedConfig = { + ...createConfig, + select: [ + { + valueExpression: "SpanAttributes['http.duration']", + heatmapScaleType: 'linear' as const, + }, + ], + where: 'level:error AND service:checkout', + }; + + const saveResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Heatmap Full Round-Trip', + tiles: [ + { + name: 'Latency Heatmap', + x: 0, + y: 0, + w: 12, + h: 4, + config: createConfig, + }, + ], + }, + ); + expect(saveResult.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(saveResult)); + expect(saved.tiles).toHaveLength(1); + expect(saved.tiles[0].config).toMatchObject(createConfig); + + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: saved.id, + }, + ); + expect(getResult.isError).toBeFalsy(); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.tiles[0].config).toMatchObject(createConfig); + + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: saved.id, + name: 'Heatmap Full Round-Trip', + tiles: [ + { + ...fetched.tiles[0], + config: { ...fetched.tiles[0].config, ...updatedConfig }, + }, + ], + }, + ); + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.tiles[0].config).toMatchObject(updatedConfig); + + const getAfterUpdate = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: saved.id, + }, + ); + expect(getAfterUpdate.isError).toBeFalsy(); + const refetched = JSON.parse(getFirstText(getAfterUpdate)); + expect(refetched.tiles[0].config).toMatchObject(updatedConfig); + }); + + it('should reject heatmap tile with empty valueExpression at the schema layer', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad Heatmap', + tiles: [ + { + name: 'Heatmap', + config: { + displayType: 'heatmap', + sourceId, + select: [{ valueExpression: '' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it.each([ + { output: 'duration', factor: 1e-9 }, + { output: 'data_rate' }, + { output: 'throughput' }, + ] as const)( + 'should round-trip numberFormat output "$output" through save and get', + async numberFormat => { + const sourceId = ctx.traceSource._id.toString(); + + const saveResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: `NumberFormat ${numberFormat.output}`, + tiles: [ + { + name: 'Number Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + numberFormat, + }, + }, + { + name: 'Line Tile', + config: { + displayType: 'line', + sourceId, + select: [ + { aggFn: 'count' }, + { + aggFn: 'avg', + valueExpression: 'Duration', + numberFormat, + }, + ], + }, + }, + ], + }, + ); + + expect(saveResult.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(saveResult)); + + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: saved.id, + }, + ); + expect(getResult.isError).toBeFalsy(); + const fetched = JSON.parse(getFirstText(getResult)); + + const numberTile = fetched.tiles.find( + (t: { name: string }) => t.name === 'Number Tile', + ); + expect(numberTile.config.numberFormat).toEqual(numberFormat); + + const lineTile = fetched.tiles.find( + (t: { name: string }) => t.name === 'Line Tile', + ); + expect(lineTile.config.select[1].numberFormat).toEqual(numberFormat); + }, + ); + + it('should reject numberFormat with an unknown output value', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad NumberFormat', + tiles: [ + { + name: 'Number Tile', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + numberFormat: { output: 'not_a_real_output' }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should reject heatmap tile on a non-Trace source', async () => { + // Create a Log source so the heatmap source-kind gate has + // something to reject. The schema accepts the tile shape (the + // sourceId is valid), so this exercises the runtime check that + // the REST POST path also runs. + const logSource = await Source.create({ + kind: SourceKind.Log, + team: ctx.team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: 'otel_logs', + }, + timestampValueExpression: 'Timestamp', + connection: ctx.connection._id, + name: 'Logs', + }); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Heatmap on Log Source', + tiles: [ + { + name: 'Heatmap', + config: { + displayType: 'heatmap', + sourceId: logSource._id.toString(), + select: [{ valueExpression: 'Duration' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + const text = getFirstText(result); + expect(text).toContain('Trace source'); + expect(text).toContain(logSource._id.toString()); + }); + + // Exercises the update-side source-kind gate via filterChangedHeatmapTiles + // (displayType changed to heatmap on an existing tile). + it('should reject update that changes a tile to heatmap on a non-Trace source', async () => { + const logSource = await Source.create({ + kind: SourceKind.Log, + team: ctx.team._id, + from: { databaseName: DEFAULT_DATABASE, tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + connection: ctx.connection._id, + name: 'Logs', + }); + + const created = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Line on Log Source', + tiles: [ + { + name: 'Line', + config: { + displayType: 'line', + sourceId: logSource._id.toString(), + select: [{ aggFn: 'count' }], + }, + }, + ], + }); + expect(created.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(created)); + + const update = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id: saved.id, + name: 'Line on Log Source', + tiles: [ + { + ...saved.tiles[0], + config: { + ...saved.tiles[0].config, + displayType: 'heatmap', + select: [{ valueExpression: 'Duration' }], + }, + }, + ], + }); + expect(update.isError).toBe(true); + const text = getFirstText(update); + expect(text).toContain('Trace source'); + expect(text).toContain(logSource._id.toString()); + }); + + // Exercises the update-side source-kind gate via filterChangedHeatmapTiles + // (sourceId changed on an existing heatmap tile). + it('should reject update that changes a heatmap tile source to a non-Trace source', async () => { + const logSource = await Source.create({ + kind: SourceKind.Log, + team: ctx.team._id, + from: { databaseName: DEFAULT_DATABASE, tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + connection: ctx.connection._id, + name: 'Logs', + }); + + const created = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Heatmap re-pointed at Log', + tiles: [ + { + name: 'Heatmap', + config: { + displayType: 'heatmap', + sourceId: ctx.traceSource._id.toString(), + select: [{ valueExpression: 'Duration' }], + }, + }, + ], + }); + expect(created.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(created)); + + const update = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id: saved.id, + name: 'Heatmap re-pointed at Log', + tiles: [ + { + ...saved.tiles[0], + config: { + ...saved.tiles[0].config, + sourceId: logSource._id.toString(), + }, + }, + ], + }); + expect(update.isError).toBe(true); + const text = getFirstText(update); + expect(text).toContain('Trace source'); + expect(text).toContain(logSource._id.toString()); + }); + + // Asserts each tile's config survives the serializer/deserializer cycle + // independently when mixed with other displayTypes on the same dashboard. + it('should round-trip a heatmap alongside line and number tiles in one dashboard', async () => { + const sourceId = ctx.traceSource._id.toString(); + + const heatmapConfig = { + displayType: 'heatmap' as const, + sourceId, + select: [ + { valueExpression: 'Duration', heatmapScaleType: 'log' as const }, + ], + }; + const lineConfig = { + displayType: 'line' as const, + sourceId, + select: [{ aggFn: 'count' as const }], + groupBy: "SpanAttributes['service.name']", + }; + const numberConfig = { + displayType: 'number' as const, + sourceId, + select: [{ aggFn: 'count' as const }], + numberFormat: { output: 'number' as const, average: true }, + }; + + const save = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Mixed Tile Round-Trip', + tiles: [ + { name: 'Heatmap Tile', config: heatmapConfig }, + { name: 'Line Tile', config: lineConfig }, + { name: 'Number Tile', config: numberConfig }, + ], + }); + expect(save.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(save)); + expect(saved.tiles).toHaveLength(3); + + const byName: Record = + Object.fromEntries( + saved.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), + ); + expect(byName['Heatmap Tile'].config).toMatchObject(heatmapConfig); + expect(byName['Line Tile'].config).toMatchObject(lineConfig); + expect(byName['Number Tile'].config).toMatchObject(numberConfig); + + const fetched = JSON.parse( + getFirstText( + await callTool(ctx.client!, 'clickstack_get_dashboard', { + id: saved.id, + }), + ), + ); + const fetchedByName: Record = + Object.fromEntries( + fetched.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), + ); + expect(fetchedByName['Heatmap Tile'].config).toMatchObject(heatmapConfig); + expect(fetchedByName['Line Tile'].config).toMatchObject(lineConfig); + expect(fetchedByName['Number Tile'].config).toMatchObject(numberConfig); + }); + }); + + describe('containers and tabs', () => { + // Mirrors the v2 external-API "Containers and tabs" describe block so + // the MCP path enforces the same 5 cross-field rules: container id + // unique, tab id unique within a container, tile.containerId + // resolves, tile.tabId resolves to a tab on that container, and + // tile.tabId requires tile.containerId. + const buildTile = ( + sourceId: string, + overrides: Record, + ) => ({ + name: 'Tile', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line' as const, + sourceId, + select: [{ aggFn: 'count' as const, where: '' }], + }, + ...overrides, + }); + + it('should round-trip containers, tabs, and tile containerId/tabId on create and update', async () => { + const sourceId = ctx.traceSource._id.toString(); + const containers = [ + { + id: 'service-health', + title: 'Service Health', + collapsed: false, + collapsible: true, + bordered: true, + tabs: [ + { id: 'errors', title: 'Errors' }, + { id: 'latency', title: 'Latency' }, + ], + }, + { + id: 'overview', + title: 'Overview', + collapsed: true, + }, + ]; + + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Containers MCP Round-Trip', + tiles: [ + buildTile(sourceId, { + name: 'In Group, Tab A', + containerId: 'service-health', + tabId: 'errors', + }), + buildTile(sourceId, { + name: 'In Group, Tab B', + containerId: 'service-health', + tabId: 'latency', + }), + // Tile inside a tabbed container without a tabId renders in + // the container shell rather than under a tab. Guards that the + // schema does not accidentally require tabId for every tile in + // a tabbed container. + buildTile(sourceId, { + name: 'In Tabbed Group, No Tab', + containerId: 'service-health', + }), + buildTile(sourceId, { + name: 'In Plain Group', + containerId: 'overview', + }), + buildTile(sourceId, { name: 'Ungrouped' }), + ], + tags: ['containers-mcp'], + containers, + }, + ); + + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + expect(created.containers).toEqual(containers); + + // Typed as ExternalDashboardTileWithId so a typo on .containerId + // would fail typecheck instead of silently asserting undefined. + const createdTilesByName: Record = + Object.fromEntries( + created.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), + ); + expect(createdTilesByName['In Group, Tab A']).toMatchObject({ + containerId: 'service-health', + tabId: 'errors', + }); + expect(createdTilesByName['In Group, Tab B']).toMatchObject({ + containerId: 'service-health', + tabId: 'latency', + }); + expect(createdTilesByName['In Tabbed Group, No Tab']).toMatchObject({ + containerId: 'service-health', + }); + expect( + createdTilesByName['In Tabbed Group, No Tab'].tabId, + ).toBeUndefined(); + expect(createdTilesByName['In Plain Group']).toMatchObject({ + containerId: 'overview', + }); + expect(createdTilesByName['In Plain Group'].tabId).toBeUndefined(); + expect(createdTilesByName.Ungrouped.containerId).toBeUndefined(); + expect(createdTilesByName.Ungrouped.tabId).toBeUndefined(); + + // Verify a fresh GET via the get tool also returns the structure. + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: created.id, + }, + ); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.containers).toEqual(containers); + + // Update: rename a tab, drop the second container, re-home tiles. + const updatedContainers = [ + { + id: 'service-health', + title: 'Service Health', + collapsed: true, + tabs: [ + { id: 'errors', title: 'Error Rate' }, + { id: 'latency', title: 'Latency' }, + ], + }, + ]; + const reHomedUngrouped = { + ...createdTilesByName.Ungrouped, + containerId: 'service-health', + tabId: 'errors', + }; + const droppedContainerTile = { + ...createdTilesByName['In Plain Group'], + containerId: undefined, + tabId: undefined, + }; + + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: created.id, + name: 'Containers MCP Round-Trip', + tiles: [ + createdTilesByName['In Group, Tab A'], + createdTilesByName['In Group, Tab B'], + createdTilesByName['In Tabbed Group, No Tab'], + droppedContainerTile, + reHomedUngrouped, + ], + tags: ['containers-mcp'], + containers: updatedContainers, + }, + ); + + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.containers).toEqual(updatedContainers); + const updatedTilesByName: Record = + Object.fromEntries( + updated.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]), + ); + expect(updatedTilesByName['In Plain Group'].containerId).toBeUndefined(); + expect(updatedTilesByName.Ungrouped).toMatchObject({ + containerId: 'service-health', + tabId: 'errors', + }); + expect(updatedTilesByName['In Tabbed Group, No Tab'].containerId).toBe( + 'service-health', + ); + expect( + updatedTilesByName['In Tabbed Group, No Tab'].tabId, + ).toBeUndefined(); + }); + + it('should reject duplicate container ids', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Duplicate Container Ids', + tiles: [buildTile(sourceId, {})], + containers: [ + { id: 'dupe', title: 'A', collapsed: false }, + { id: 'dupe', title: 'B', collapsed: false }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Container IDs must be unique'); + }); + + it('should reject duplicate tab ids within a container', async () => { + // Structural rules (duplicate container ids, duplicate tab ids) fire + // through the body schema, which is reported as a stringified issue + // array. Cross-tile-ref rules (covered below) fire through + // collectTileContainerRefIssues and produce plain prose. + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Duplicate Tab Ids', + tiles: [buildTile(sourceId, {})], + containers: [ + { + id: 'service-health', + title: 'Service Health', + collapsed: false, + tabs: [ + { id: 'errors', title: 'Errors' }, + { id: 'errors', title: 'Errors Two' }, + ], + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Duplicate tab id'); + expect(getFirstText(result)).toContain('errors'); + expect(getFirstText(result)).toContain('service-health'); + }); + + it('should reject a tile that supplies tabId without containerId', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Tab Without Container', + tiles: [buildTile(sourceId, { tabId: 'errors' })], + containers: [ + { + id: 'service-health', + title: 'Service Health', + collapsed: false, + tabs: [{ id: 'errors', title: 'Errors' }], + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain( + 'tabId requires containerId to be set', + ); + }); + + it('should reject a tile that references an unknown containerId', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Unknown Container', + tiles: [buildTile(sourceId, { containerId: 'does-not-exist' })], + containers: [{ id: 'real', title: 'Real', collapsed: false }], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain( + 'unknown containerId "does-not-exist"', + ); + }); + + it('should reject a tile that references an unknown tabId within a container', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Unknown Tab', + tiles: [ + buildTile(sourceId, { + containerId: 'service-health', + tabId: 'ghost', + }), + ], + containers: [ + { + id: 'service-health', + title: 'Service Health', + collapsed: false, + tabs: [{ id: 'errors', title: 'Errors' }], + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('unknown tabId "ghost"'); + }); + + it('should preserve existing containers on update when body omits the field', async () => { + // Exercises the `effectiveContainers = parsedContainers ?? existingDashboard.containers ?? []` + // fallback in the MCP update handler. Without it, a PUT that + // updates only `tiles` would reject because the tile's + // containerId reference would resolve against an empty array. + const sourceId = ctx.traceSource._id.toString(); + const containers = [ + { + id: 'service-health', + title: 'Service Health', + collapsed: false, + tabs: [{ id: 'errors', title: 'Errors' }], + }, + ]; + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'PUT-without-containers fallback', + tiles: [ + buildTile(sourceId, { + name: 'In Group', + containerId: 'service-health', + tabId: 'errors', + }), + ], + containers, + }, + ); + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: created.id, + name: 'PUT-without-containers fallback', + tiles: created.tiles, + // containers intentionally omitted + }, + ); + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.containers).toEqual(containers); + expect(updated.tiles[0]).toMatchObject({ + containerId: 'service-health', + tabId: 'errors', + }); + }); + + it('should wipe persisted containers when update body sets containers: []', async () => { + // Mirror the v2 behavior: an explicit empty array clears the + // persisted containers, and the response normalizes [] back to + // absent on read. + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Wipe containers', + tiles: [buildTile(sourceId, { name: 'Tile' })], + containers: [{ id: 'overview', title: 'Overview', collapsed: false }], + }, + ); + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + expect(created.containers).toHaveLength(1); + + const wipedTile = { + ...created.tiles[0], + containerId: undefined, + tabId: undefined, + }; + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: created.id, + name: 'Wipe containers', + tiles: [wipedTile], + containers: [], + }, + ); + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.containers).toBeUndefined(); + + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: created.id, + }, + ); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.containers).toBeUndefined(); + }); + + // Bounds mirror DASHBOARD_CONTAINER_ID_MAX (256) on the tile-level + // containerId / tabId. 256 chars must accept; 257 must reject. + // 257 trips the inputSchema's `.max(256)` and surfaces back as the + // MCP SDK's "Input validation error" envelope. + it('should accept a 256-char tile.containerId and reject 257', async () => { + const sourceId = ctx.traceSource._id.toString(); + const idAtMax = 'c'.repeat(256); + const okResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: '256-char containerId', + tiles: [buildTile(sourceId, { containerId: idAtMax })], + containers: [{ id: idAtMax, title: 'Max', collapsed: false }], + }, + ); + expect(okResult.isError).toBeFalsy(); + + const idTooLong = 'c'.repeat(257); + const tooLongResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: '257-char containerId', + tiles: [buildTile(sourceId, { containerId: idTooLong })], + containers: [{ id: idTooLong, title: 'Too long', collapsed: false }], + }, + ); + expect(tooLongResult.isError).toBe(true); + expect(getFirstText(tooLongResult)).toContain( + 'String must contain at most 256 character(s)', + ); + expect(getFirstText(tooLongResult)).toContain('containerId'); + }); + }); + + describe('dashboard filters', () => { + // The MCP input schema delegates to createDashboardBodySchema / + // updateDashboardBodySchema for the filter shape. These tests guard + // that the MCP path lights up the same filter round-trip the v2 REST + // path already covers: filters are saved verbatim, get back with an + // assigned id, survive an update, and reject obvious bad inputs. + + const traceTile = (sourceId: string) => ({ + name: 'Volume', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line' as const, + sourceId, + select: [{ aggFn: 'count' as const, where: '' }], + }, + }); + + it('should round-trip filters on create, get, and update', async () => { + const sourceId = ctx.traceSource._id.toString(); + + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Service Detail (MCP filter round-trip)', + tiles: [traceTile(sourceId)], + filters: [ + { + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }, + { + type: 'QUERY_EXPRESSION', + name: 'Environment', + expression: 'deployment.environment', + sourceId, + where: "deployment.environment = 'production'", + whereLanguage: 'sql', + }, + ], + }, + ); + + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + expect(Array.isArray(created.filters)).toBe(true); + expect(created.filters).toHaveLength(2); + // The body schema assigns an id to each filter on create. Capture + // them so the update payload can include the same ids and the + // filter array round-trips identically (instead of being treated + // as wholesale replacement with new ids). + const [serviceFilter, envFilter] = created.filters; + expect(serviceFilter).toMatchObject({ + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }); + expect(typeof serviceFilter.id).toBe('string'); + expect(serviceFilter.id.length).toBeGreaterThan(0); + expect(envFilter).toMatchObject({ + type: 'QUERY_EXPRESSION', + name: 'Environment', + expression: 'deployment.environment', + sourceId, + where: "deployment.environment = 'production'", + whereLanguage: 'sql', + }); + + // Fetch and assert the same shape. + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: created.id, + }, + ); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.filters).toEqual(created.filters); + + // Update: rename the first filter and drop the second. The first + // filter keeps its id, the second is dropped (not preserved). + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: created.id, + name: 'Service Detail (MCP filter round-trip)', + tiles: [traceTile(sourceId)], + filters: [ + { + id: serviceFilter.id, + type: 'QUERY_EXPRESSION', + name: 'Service (renamed)', + expression: 'ServiceName', + sourceId, + }, + ], + }, + ); + + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.filters).toHaveLength(1); + expect(updated.filters[0]).toMatchObject({ + id: serviceFilter.id, + type: 'QUERY_EXPRESSION', + name: 'Service (renamed)', + expression: 'ServiceName', + sourceId, + }); + }); + + it('should accept a create payload with stray filter ids (copy-paste round-trip)', async () => { + // An LLM that copies a filter out of clickstack_get_dashboard and + // back into clickstack_save_dashboard for a NEW dashboard ships an + // `id` on the filter. The body schema for create is + // `.strict()` and rejects `id`, so saveDashboard normalizes by + // stripping ids before validation. Without that normalization + // this payload would 4xx with a confusing strict-validation + // error. + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Create with stray filter id', + tiles: [traceTile(sourceId)], + filters: [ + { + id: '999999999999999999999999', // simulates a copied id + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }, + ], + }); + expect(result.isError).toBeFalsy(); + const dashboard = JSON.parse(getFirstText(result)); + expect(dashboard.filters).toHaveLength(1); + // The new dashboard gets a fresh id; the copied one is discarded. + expect(dashboard.filters[0].id).not.toBe('999999999999999999999999'); + expect(dashboard.filters[0]).toMatchObject({ + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }); + }); + + it('should assign ids to new filters added during update (no-id round-trip)', async () => { + // An LLM updating a dashboard to ADD a brand-new filter ships + // a filter without an `id`. The body schema for update requires + // id, so saveDashboard fills one in. Without that normalization + // this payload would 4xx complaining that id is missing. + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Update adds new filter', + tiles: [traceTile(sourceId)], + filters: [ + { + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + const existingFilterId = created.filters[0].id; + + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: created.id, + name: 'Update adds new filter', + tiles: [traceTile(sourceId)], + filters: [ + // Existing filter, preserved by id. + { + id: existingFilterId, + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }, + // Brand-new filter, no id. saveDashboard assigns one. + { + type: 'QUERY_EXPRESSION', + name: 'Environment', + expression: 'deployment.environment', + sourceId, + }, + ], + }, + ); + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.filters).toHaveLength(2); + const envFilter = updated.filters.find( + (f: { name: string }) => f.name === 'Environment', + ); + expect(envFilter).toBeDefined(); + expect(typeof envFilter.id).toBe('string'); + expect(envFilter.id.length).toBeGreaterThan(0); + expect(envFilter.id).not.toBe(existingFilterId); + }); + + it('should round-trip a table tile that uses a having clause', async () => { + // mcpTableTileSchema exposes `having` so the service_detail + // example's "Top Error Messages" pattern (groupBy StatusMessage + // with having: "StatusMessage != ''") survives MCP authoring. + // Without `having` on the schema, Zod would strip it and the + // saved tile would include empty-message rows instead of the + // filtered set the prompt promises. + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Top Error Messages', + tiles: [ + { + name: 'Top Error Messages', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count', alias: 'Count' }], + groupBy: 'StatusMessage', + having: "StatusMessage != ''", + orderBy: 'Count DESC', + groupByColumnsOnLeft: true, + }, + }, + ], + }); + expect(result.isError).toBeFalsy(); + const dashboard = JSON.parse(getFirstText(result)); + expect(dashboard.tiles[0].config.having).toBe("StatusMessage != ''"); + }); + }); + + describe('table onClick linking', () => { + // Mirrors the v2 external-API onClick tests so the MCP path + // enforces the same drill-down rules: row-click can target /search + // for a log/trace source or another dashboard, by concrete ID or + // by templated name, with optional whereTemplate/filters. + it('should round-trip a table tile with a search onClick by ID', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'OnClick search by id', + tiles: [ + { + name: 'Errors by Service', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + groupBy: "ResourceAttributes['service.name']", + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }, + }, + }, + ], + }, + ); + + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + expect(created.tiles[0].config.onClick).toEqual({ + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }); + + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: created.id, + }, + ); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.tiles[0].config.onClick).toEqual( + created.tiles[0].config.onClick, + ); + }); + + it('should round-trip a table tile with a dashboard onClick by ID', async () => { + const sourceId = ctx.traceSource._id.toString(); + // Create the target dashboard the onClick will link to so the + // server-side `getMissingOnClickDashboards` check resolves. + const targetDashboard = await new Dashboard({ + name: 'Service Detail', + tiles: [], + team: ctx.team._id, + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'OnClick dashboard by id', + tiles: [ + { + name: 'Top Services', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + groupBy: "ResourceAttributes['service.name']", + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { + mode: 'id', + id: targetDashboard._id.toString(), + }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toEqual({ + type: 'dashboard', + target: { mode: 'id', id: targetDashboard._id.toString() }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }); + }); + + it('should round-trip a templated dashboard onClick (mode=template)', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'OnClick dashboard by template', + tiles: [ + { + name: 'Service Picker', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + groupBy: 'SpanName', + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { + mode: 'template', + template: '{{TargetDashboard}}', + }, + whereLanguage: 'lucene', + whereTemplate: + "service.name:{{ResourceAttributes['service.name']}}", + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toEqual({ + type: 'dashboard', + target: { mode: 'template', template: '{{TargetDashboard}}' }, + whereLanguage: 'lucene', + whereTemplate: "service.name:{{ResourceAttributes['service.name']}}", + }); + }); + + it('should round-trip onClick on a raw SQL table tile', async () => { + const connectionId = ctx.connection._id.toString(); + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'SQL onClick', + tiles: [ + { + name: 'Raw SQL Table', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + configType: 'sql', + displayType: 'table', + connectionId, + sqlTemplate: + 'SELECT ServiceName, count() AS c FROM otel_traces GROUP BY ServiceName LIMIT 50', + onClick: { + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: '{{ServiceName}}', + }, + ], + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toEqual({ + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: '{{ServiceName}}', + }, + ], + }); + }); + + it('should reject a table tile onClick referencing a non-existent dashboard', async () => { + const sourceId = ctx.traceSource._id.toString(); + const ghostDashboardId = new mongoose.Types.ObjectId().toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'OnClick missing dashboard', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { mode: 'id', id: ghostDashboardId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + const text = getFirstText(result); + expect(text).toContain('onClick dashboard'); + expect(text).toContain(ghostDashboardId); + }); + + it('should reject a table tile onClick search target with a non-log/trace source', async () => { + // /search only renders log/trace sources; targeting a metric + // source must be rejected at save time, matching the REST POST + // path. Create the metric source inline so this test is + // self-contained. + const metricSource = await Source.create({ + kind: SourceKind.Metric, + team: ctx.team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: '', + }, + metricTables: { + [MetricsDataType.Gauge.toLowerCase()]: 'otel_metrics_gauge', + [MetricsDataType.Sum.toLowerCase()]: 'otel_metrics_sum', + [MetricsDataType.Histogram.toLowerCase()]: 'otel_metrics_histogram', + }, + timestampValueExpression: 'TimeUnix', + connection: ctx.connection._id, + name: 'Metrics', + }); + const sourceId = ctx.traceSource._id.toString(); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'OnClick metric source', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { + mode: 'id', + id: metricSource._id.toString(), + }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + const text = getFirstText(result); + expect(text).toContain('log or trace'); + expect(text).toContain(metricSource._id.toString()); + }); + + it('should reject a table tile onClick with an invalid target.id', async () => { + // target.id must be a valid ObjectId. Bad shape is caught by the + // input schema and surfaces as the SDK's "Input validation error" + // envelope rather than a custom 400 message. + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'OnClick bad object id', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { mode: 'id', id: 'not-a-valid-object-id' }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should accept onClick without whereLanguage (it is optional at the schema layer)', async () => { + // SearchConditionLanguageSchema is `.optional()` in common-utils, + // so omitting whereLanguage is allowed. Pin that behavior so a + // future tightening of the schema doesn't quietly change it + // without updating the docs that callers rely on. + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'OnClick no whereLanguage', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { mode: 'id', id: sourceId }, + // whereLanguage intentionally omitted + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toMatchObject({ + type: 'search', + target: { mode: 'id', id: sourceId }, + }); + }); + + it('should reject an unknown onClick.type', async () => { + // The schema is a discriminated union on `type`; a bogus type + // must be rejected up front. + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'OnClick bad type', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'navigate-to-mars', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should validate onClick on update too', async () => { + // Round-trip a tile with no onClick, then update it to add an + // onClick pointing at a non-existent dashboard. The PUT path + // mirrors POST: missing dashboards are rejected with the same + // message. + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'OnClick update validation', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }, + ); + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + + const ghostDashboardId = new mongoose.Types.ObjectId().toString(); + const updateResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + id: created.id, + name: 'OnClick update validation', + tiles: [ + { + ...created.tiles[0], + config: { + ...created.tiles[0].config, + onClick: { + type: 'dashboard', + target: { mode: 'id', id: ghostDashboardId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }, + ); + + expect(updateResult.isError).toBe(true); + const text = getFirstText(updateResult); + expect(text).toContain('onClick dashboard'); + expect(text).toContain(ghostDashboardId); + }); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/searchDashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards/searchDashboards.test.ts new file mode 100644 index 0000000000..72a44d4d3f --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/searchDashboards.test.ts @@ -0,0 +1,261 @@ +import mongoose from 'mongoose'; + +import Dashboard from '@/models/dashboard'; + +import { callTool, getFirstText } from '../mcpTestUtils'; +import { setupDashboardTests } from './setup'; + +describe('MCP Dashboard Tools - clickstack_search_dashboards', () => { + const ctx = setupDashboardTests(); + + it('should find dashboards by name (case-insensitive)', async () => { + await new Dashboard({ + name: 'Service Overview', + tiles: [], + team: ctx.team._id, + tags: [], + }).save(); + await new Dashboard({ + name: 'Error Dashboard', + tiles: [], + team: ctx.team._id, + tags: [], + }).save(); + await new Dashboard({ + name: 'service metrics', + tiles: [], + team: ctx.team._id, + tags: [], + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: 'service', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(2); + expect(output.map((d: { name: string }) => d.name).sort()).toEqual([ + 'Service Overview', + 'service metrics', + ]); + }); + + it('should find dashboards by tags', async () => { + await new Dashboard({ + name: 'Dashboard A', + tiles: [], + team: ctx.team._id, + tags: ['production', 'backend'], + }).save(); + await new Dashboard({ + name: 'Dashboard B', + tiles: [], + team: ctx.team._id, + tags: ['production', 'frontend'], + }).save(); + await new Dashboard({ + name: 'Dashboard C', + tiles: [], + team: ctx.team._id, + tags: ['staging'], + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + tags: ['production'], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(2); + + // Search with multiple tags (AND) + const result2 = await callTool( + ctx.client!, + 'clickstack_search_dashboards', + { + tags: ['production', 'backend'], + }, + ); + + const output2 = JSON.parse(getFirstText(result2)); + expect(output2).toHaveLength(1); + expect(output2[0].name).toBe('Dashboard A'); + }); + + it('should combine name and tags filters', async () => { + await new Dashboard({ + name: 'API Service', + tiles: [], + team: ctx.team._id, + tags: ['production'], + }).save(); + await new Dashboard({ + name: 'API Errors', + tiles: [], + team: ctx.team._id, + tags: ['staging'], + }).save(); + await new Dashboard({ + name: 'Web Service', + tiles: [], + team: ctx.team._id, + tags: ['production'], + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: 'API', + tags: ['production'], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(1); + expect(output[0].name).toBe('API Service'); + }); + + it('should return empty array when no dashboards match', async () => { + await new Dashboard({ + name: 'Unrelated', + tiles: [], + team: ctx.team._id, + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: 'nonexistent-dashboard-name', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(0); + }); + + it('should treat regex metacharacters as literal substrings', async () => { + await new Dashboard({ + name: 'API (v2) Service', + tiles: [], + team: ctx.team._id, + }).save(); + await new Dashboard({ + name: 'API v2 Service', + tiles: [], + team: ctx.team._id, + }).save(); + + // Parentheses are regex metacharacters — without escaping, "(v2)" + // would be treated as a capture group and match "v2" anywhere. + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: '(v2)', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + // Only the dashboard with literal "(v2)" should match + expect(output).toHaveLength(1); + expect(output[0].name).toBe('API (v2) Service'); + }); + + it('should not throw on regex-invalid characters like [', async () => { + await new Dashboard({ + name: 'Test Dashboard', + tiles: [], + team: ctx.team._id, + }).save(); + + // An unescaped "[" would throw a MongoDB BadValue error + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: '[invalid', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(0); + }); + + it('should match literal dots, not arbitrary characters', async () => { + await new Dashboard({ + name: 'api.v2.service', + tiles: [], + team: ctx.team._id, + }).save(); + await new Dashboard({ + name: 'apiXv2Xservice', + tiles: [], + team: ctx.team._id, + }).save(); + + // Unescaped "." in regex matches any character, so ".v2." would + // match "Xv2X". With escaping, only the literal dot matches. + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: '.v2.', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(1); + expect(output[0].name).toBe('api.v2.service'); + }); + + it('should reject empty query string', async () => { + await new Dashboard({ + name: 'Should Not Appear', + tiles: [], + team: ctx.team._id, + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: '', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('at least one'); + }); + + it('should reject empty tags array', async () => { + await new Dashboard({ + name: 'Should Not Appear', + tiles: [], + team: ctx.team._id, + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + tags: [], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('at least one'); + }); + + it('should reject empty query with empty tags', async () => { + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: '', + tags: [], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('at least one'); + }); + + it('should only return dashboards for the current team', async () => { + await new Dashboard({ + name: 'My Dashboard', + tiles: [], + team: ctx.team._id, + }).save(); + // Create a dashboard for a different team + const otherTeamId = new mongoose.Types.ObjectId(); + await new Dashboard({ + name: 'My Dashboard', + tiles: [], + team: otherTeamId, + }).save(); + + const result = await callTool(ctx.client!, 'clickstack_search_dashboards', { + query: 'My Dashboard', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(1); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards/setup.ts b/packages/api/src/mcp/__tests__/dashboards/setup.ts new file mode 100644 index 0000000000..46f1b3600a --- /dev/null +++ b/packages/api/src/mcp/__tests__/dashboards/setup.ts @@ -0,0 +1,81 @@ +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +import * as config from '@/config'; +import { + DEFAULT_DATABASE, + DEFAULT_TRACES_TABLE, + getLoggedInAgent, + getServer, +} from '@/fixtures'; +import Connection from '@/models/connection'; +import { Source } from '@/models/source'; + +import { McpContext } from '../../tools/types'; +import { createTestClient } from '../mcpTestUtils'; + +/** + * Shared setup/teardown for all dashboard MCP tool tests. + * + * Usage: + * const ctx = setupDashboardTests(); + * // Then access ctx.team, ctx.traceSource, ctx.connection, ctx.client + */ +export function setupDashboardTests() { + const server = getServer(); + const ctx = { + server, + team: null as any, + user: null as any, + traceSource: null as any, + connection: null as any, + client: null as Client | null, + }; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + ctx.team = result.team; + ctx.user = result.user; + + ctx.connection = await Connection.create({ + team: ctx.team._id, + name: 'Default', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + + ctx.traceSource = await Source.create({ + kind: SourceKind.Trace, + team: ctx.team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: DEFAULT_TRACES_TABLE, + }, + timestampValueExpression: 'Timestamp', + connection: ctx.connection._id, + name: 'Traces', + }); + + const mcpContext: McpContext = { + teamId: ctx.team._id.toString(), + userId: ctx.user._id.toString(), + }; + ctx.client = await createTestClient(mcpContext); + }); + + afterEach(async () => { + await ctx.client?.close(); + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + return ctx; +} diff --git a/packages/api/src/mcp/__tests__/deltasTool.test.ts b/packages/api/src/mcp/__tests__/deltasTool.test.ts index 30c6868608..1d9beb5d87 100644 --- a/packages/api/src/mcp/__tests__/deltasTool.test.ts +++ b/packages/api/src/mcp/__tests__/deltasTool.test.ts @@ -71,9 +71,9 @@ describe('MCP Event Deltas Tool', () => { }); describe('schema serialization', () => { - it('exposes hyperdx_event_deltas via tools/list with target + baseline groups', async () => { + it('exposes clickstack_event_deltas via tools/list with target + baseline groups', async () => { const { tools } = await client.listTools(); - const t = tools.find(t => t.name === 'hyperdx_event_deltas'); + const t = tools.find(t => t.name === 'clickstack_event_deltas'); expect(t).toBeDefined(); const schema = t!.inputSchema; const props = Object.keys(schema.properties ?? {}); @@ -90,7 +90,7 @@ describe('MCP Event Deltas Tool', () => { describe('validation', () => { it('rejects target where endTime <= startTime', async () => { - const result = await callTool(client, 'hyperdx_event_deltas', { + const result = await callTool(client, 'clickstack_event_deltas', { sourceId: logSource._id.toString(), target: { startTime: '2026-05-10T01:00:00Z', @@ -147,7 +147,7 @@ describe('MCP Event Deltas Tool', () => { }); it('ranks SeverityText and ServiceName as top differentiating properties', async () => { - const result = await callTool(client, 'hyperdx_event_deltas', { + const result = await callTool(client, 'clickstack_event_deltas', { sourceId: logSource._id.toString(), target: { startTime: new Date(now - 5 * 60_000).toISOString(), @@ -208,7 +208,7 @@ describe('MCP Event Deltas Tool', () => { }); it('returns includeHidden:true with a hidden array containing high-cardinality / id fields', async () => { - const result = await callTool(client, 'hyperdx_event_deltas', { + const result = await callTool(client, 'clickstack_event_deltas', { sourceId: logSource._id.toString(), target: { startTime: new Date(now - 5 * 60_000).toISOString(), diff --git a/packages/api/src/mcp/__tests__/queryTool.test.ts b/packages/api/src/mcp/__tests__/queryTool.test.ts index d51a6f92a0..b5264580b5 100644 --- a/packages/api/src/mcp/__tests__/queryTool.test.ts +++ b/packages/api/src/mcp/__tests__/queryTool.test.ts @@ -87,9 +87,9 @@ describe('MCP Query Tools', () => { // ─── Schema serialization ──────────────────────────────────────────────────── describe('schema serialization', () => { - it('should expose hyperdx_timeseries with expected properties', async () => { + it('should expose clickstack_timeseries with expected properties', async () => { const { tools } = await client.listTools(); - const tool = tools.find(t => t.name === 'hyperdx_timeseries'); + const tool = tools.find(t => t.name === 'clickstack_timeseries'); expect(tool).toBeDefined(); const props = Object.keys(tool!.inputSchema.properties ?? {}); @@ -102,9 +102,9 @@ describe('MCP Query Tools', () => { expect(tool!.inputSchema.required).toContain('select'); }); - it('should expose hyperdx_table with expected properties', async () => { + it('should expose clickstack_table with expected properties', async () => { const { tools } = await client.listTools(); - const tool = tools.find(t => t.name === 'hyperdx_table'); + const tool = tools.find(t => t.name === 'clickstack_table'); expect(tool).toBeDefined(); const props = Object.keys(tool!.inputSchema.properties ?? {}); @@ -116,9 +116,9 @@ describe('MCP Query Tools', () => { expect(tool!.inputSchema.required).toContain('select'); }); - it('should expose hyperdx_search with expected properties', async () => { + it('should expose clickstack_search with expected properties', async () => { const { tools } = await client.listTools(); - const tool = tools.find(t => t.name === 'hyperdx_search'); + const tool = tools.find(t => t.name === 'clickstack_search'); expect(tool).toBeDefined(); const props = Object.keys(tool!.inputSchema.properties ?? {}); @@ -129,9 +129,9 @@ describe('MCP Query Tools', () => { expect(tool!.inputSchema.required).toContain('sourceId'); }); - it('should expose hyperdx_event_patterns with expected properties', async () => { + it('should expose clickstack_event_patterns with expected properties', async () => { const { tools } = await client.listTools(); - const tool = tools.find(t => t.name === 'hyperdx_event_patterns'); + const tool = tools.find(t => t.name === 'clickstack_event_patterns'); expect(tool).toBeDefined(); const props = Object.keys(tool!.inputSchema.properties ?? {}); @@ -143,9 +143,9 @@ describe('MCP Query Tools', () => { expect(tool!.inputSchema.required).toContain('sourceId'); }); - it('should expose hyperdx_sql with expected properties', async () => { + it('should expose clickstack_sql with expected properties', async () => { const { tools } = await client.listTools(); - const tool = tools.find(t => t.name === 'hyperdx_sql'); + const tool = tools.find(t => t.name === 'clickstack_sql'); expect(tool).toBeDefined(); const props = Object.keys(tool!.inputSchema.properties ?? {}); @@ -158,11 +158,11 @@ describe('MCP Query Tools', () => { }); }); - // ─── hyperdx_timeseries ───────────────────────────────────────────────────── + // ─── clickstack_timeseries ───────────────────────────────────────────────────── - describe('hyperdx_timeseries', () => { + describe('clickstack_timeseries', () => { it('should execute a line chart query', async () => { - const result = await callTool(client, 'hyperdx_timeseries', { + const result = await callTool(client, 'clickstack_timeseries', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -176,7 +176,7 @@ describe('MCP Query Tools', () => { }); it('should execute a stacked bar chart query', async () => { - const result = await callTool(client, 'hyperdx_timeseries', { + const result = await callTool(client, 'clickstack_timeseries', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], shape: 'stacked_bar', @@ -189,7 +189,7 @@ describe('MCP Query Tools', () => { }); it('should default to line shape when shape is omitted', async () => { - const result = await callTool(client, 'hyperdx_timeseries', { + const result = await callTool(client, 'clickstack_timeseries', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], }); @@ -199,7 +199,7 @@ describe('MCP Query Tools', () => { }); it('should accept granularity in correct format', async () => { - const result = await callTool(client, 'hyperdx_timeseries', { + const result = await callTool(client, 'clickstack_timeseries', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], granularity: '1 minute', @@ -212,7 +212,7 @@ describe('MCP Query Tools', () => { }); it('should return error for invalid time range', async () => { - const result = await callTool(client, 'hyperdx_timeseries', { + const result = await callTool(client, 'clickstack_timeseries', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], startTime: 'invalid-date', @@ -223,11 +223,11 @@ describe('MCP Query Tools', () => { }); }); - // ─── hyperdx_table ────────────────────────────────────────────────────────── + // ─── clickstack_table ────────────────────────────────────────────────────────── - describe('hyperdx_table', () => { + describe('clickstack_table', () => { it('should execute a table query', async () => { - const result = await callTool(client, 'hyperdx_table', { + const result = await callTool(client, 'clickstack_table', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], groupBy: 'SpanName', @@ -242,7 +242,7 @@ describe('MCP Query Tools', () => { }); it('should execute a number query', async () => { - const result = await callTool(client, 'hyperdx_table', { + const result = await callTool(client, 'clickstack_table', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], shape: 'number', @@ -255,7 +255,7 @@ describe('MCP Query Tools', () => { }); it('should execute a pie query', async () => { - const result = await callTool(client, 'hyperdx_table', { + const result = await callTool(client, 'clickstack_table', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], shape: 'pie', @@ -270,7 +270,7 @@ describe('MCP Query Tools', () => { it('should auto-upgrade shape:"number" to "table" when select has multiple items', async () => { // This should NOT error — it should silently upgrade to table - const result = await callTool(client, 'hyperdx_table', { + const result = await callTool(client, 'clickstack_table', { sourceId: traceSource._id.toString(), select: [ { aggFn: 'count' }, @@ -286,7 +286,7 @@ describe('MCP Query Tools', () => { }); it('should auto-upgrade shape:"pie" to "table" when select has multiple items', async () => { - const result = await callTool(client, 'hyperdx_table', { + const result = await callTool(client, 'clickstack_table', { sourceId: traceSource._id.toString(), select: [ { aggFn: 'count' }, @@ -303,7 +303,7 @@ describe('MCP Query Tools', () => { }); it('should use default time range when not provided', async () => { - const result = await callTool(client, 'hyperdx_table', { + const result = await callTool(client, 'clickstack_table', { sourceId: traceSource._id.toString(), select: [{ aggFn: 'count' }], }); @@ -313,11 +313,11 @@ describe('MCP Query Tools', () => { }); }); - // ─── hyperdx_search ───────────────────────────────────────────────────────── + // ─── clickstack_search ───────────────────────────────────────────────────────── - describe('hyperdx_search', () => { + describe('clickstack_search', () => { it('should execute a search query', async () => { - const result = await callTool(client, 'hyperdx_search', { + const result = await callTool(client, 'clickstack_search', { sourceId: traceSource._id.toString(), where: '', startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -329,7 +329,7 @@ describe('MCP Query Tools', () => { }); it('should respect maxResults parameter', async () => { - const result = await callTool(client, 'hyperdx_search', { + const result = await callTool(client, 'clickstack_search', { sourceId: traceSource._id.toString(), maxResults: 10, startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -340,7 +340,7 @@ describe('MCP Query Tools', () => { }); it('should use default time range when not provided', async () => { - const result = await callTool(client, 'hyperdx_search', { + const result = await callTool(client, 'clickstack_search', { sourceId: traceSource._id.toString(), }); @@ -349,7 +349,7 @@ describe('MCP Query Tools', () => { }); it('should reject calls missing sourceId', async () => { - const result = await callTool(client, 'hyperdx_search', { + const result = await callTool(client, 'clickstack_search', { where: 'level:error', }); @@ -358,11 +358,11 @@ describe('MCP Query Tools', () => { }); }); - // ─── hyperdx_event_patterns ───────────────────────────────────────────────── + // ─── clickstack_event_patterns ───────────────────────────────────────────────── - describe('hyperdx_event_patterns', () => { + describe('clickstack_event_patterns', () => { it('should execute an event_patterns query on a log source', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { sourceId: logSource._id.toString(), startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), endTime: new Date().toISOString(), @@ -382,7 +382,7 @@ describe('MCP Query Tools', () => { }); it('should execute with explicit bodyExpression on trace source', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { sourceId: traceSource._id.toString(), bodyExpression: 'SpanName', startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -398,7 +398,7 @@ describe('MCP Query Tools', () => { }); it('should accept custom bodyExpression on log source', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { sourceId: logSource._id.toString(), bodyExpression: 'SeverityText', startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -412,7 +412,7 @@ describe('MCP Query Tools', () => { }); it('should respect sampleSize parameter', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { sourceId: logSource._id.toString(), sampleSize: 100, startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -424,7 +424,7 @@ describe('MCP Query Tools', () => { }); it('should respect where filter', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { sourceId: logSource._id.toString(), where: "SeverityText = 'ERROR'", whereLanguage: 'sql', @@ -437,7 +437,7 @@ describe('MCP Query Tools', () => { }); it('should reject calls missing sourceId', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), endTime: new Date().toISOString(), }); @@ -447,7 +447,7 @@ describe('MCP Query Tools', () => { }); it('should return error for non-existent source', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { sourceId: '000000000000000000000000', startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), endTime: new Date().toISOString(), @@ -496,7 +496,7 @@ describe('MCP Query Tools', () => { }); it('should mine patterns from seeded data and return non-empty results', async () => { - const result = await callTool(client, 'hyperdx_event_patterns', { + const result = await callTool(client, 'clickstack_event_patterns', { sourceId: logSource._id.toString(), startTime: new Date(now.getTime() - 10 * 60 * 1000).toISOString(), endTime: new Date(now.getTime() + 60 * 1000).toISOString(), @@ -535,11 +535,11 @@ describe('MCP Query Tools', () => { }); }); - // ─── hyperdx_sql ──────────────────────────────────────────────────────────── + // ─── clickstack_sql ──────────────────────────────────────────────────────────── - describe('hyperdx_sql', () => { + describe('clickstack_sql', () => { it('should execute a raw SQL query', async () => { - const result = await callTool(client, 'hyperdx_sql', { + const result = await callTool(client, 'clickstack_sql', { connectionId: connection._id.toString(), sql: 'SELECT 1 AS value', startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -551,7 +551,7 @@ describe('MCP Query Tools', () => { }); it('should execute SQL with time macros', async () => { - const result = await callTool(client, 'hyperdx_sql', { + const result = await callTool(client, 'clickstack_sql', { connectionId: connection._id.toString(), sql: `SELECT count() AS cnt FROM ${DEFAULT_DATABASE}.${DEFAULT_TRACES_TABLE} WHERE $__timeFilter(Timestamp) LIMIT 10`, startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), @@ -563,7 +563,7 @@ describe('MCP Query Tools', () => { }); it('should use default time range when not provided', async () => { - const result = await callTool(client, 'hyperdx_sql', { + const result = await callTool(client, 'clickstack_sql', { connectionId: connection._id.toString(), sql: 'SELECT 1 AS value', }); @@ -573,7 +573,7 @@ describe('MCP Query Tools', () => { }); it('should return error for invalid time range', async () => { - const result = await callTool(client, 'hyperdx_sql', { + const result = await callTool(client, 'clickstack_sql', { connectionId: connection._id.toString(), sql: 'SELECT 1', startTime: 'not-a-date', diff --git a/packages/api/src/mcp/__tests__/savedSearches.test.ts b/packages/api/src/mcp/__tests__/savedSearches.test.ts index b63b752d95..81756fb156 100644 --- a/packages/api/src/mcp/__tests__/savedSearches.test.ts +++ b/packages/api/src/mcp/__tests__/savedSearches.test.ts @@ -87,15 +87,19 @@ describe('MCP Saved Search Tools', () => { }); } - // ─── hyperdx_get_saved_search ───────────────────────────────────────────── + // ─── clickstack_get_saved_search ───────────────────────────────────────────── - describe('hyperdx_get_saved_search', () => { + describe('clickstack_get_saved_search', () => { describe('list (no id)', () => { it('should list all saved searches with slim summary fields', async () => { await createTestSavedSearch({ name: 'Search 1' }); await createTestSavedSearch({ name: 'Search 2' }); - const result = await callTool(client, 'hyperdx_get_saved_search', {}); + const result = await callTool( + client, + 'clickstack_get_saved_search', + {}, + ); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -115,7 +119,11 @@ describe('MCP Saved Search Tools', () => { }); it('should return empty array when no saved searches exist', async () => { - const result = await callTool(client, 'hyperdx_get_saved_search', {}); + const result = await callTool( + client, + 'clickstack_get_saved_search', + {}, + ); expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); @@ -133,7 +141,7 @@ describe('MCP Saved Search Tools', () => { const listResult = await callTool( client2, - 'hyperdx_get_saved_search', + 'clickstack_get_saved_search', {}, ); const output = JSON.parse(getFirstText(listResult)); @@ -150,7 +158,7 @@ describe('MCP Saved Search Tools', () => { where: 'level:error', }); - const result = await callTool(client, 'hyperdx_get_saved_search', { + const result = await callTool(client, 'clickstack_get_saved_search', { id: savedSearch._id.toString(), }); @@ -170,7 +178,7 @@ describe('MCP Saved Search Tools', () => { }); it('should return error for invalid ObjectId format', async () => { - const result = await callTool(client, 'hyperdx_get_saved_search', { + const result = await callTool(client, 'clickstack_get_saved_search', { id: 'not-a-valid-id', }); @@ -180,7 +188,7 @@ describe('MCP Saved Search Tools', () => { it('should return error for non-existent saved search id', async () => { const fakeId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_get_saved_search', { + const result = await callTool(client, 'clickstack_get_saved_search', { id: fakeId, }); @@ -190,12 +198,12 @@ describe('MCP Saved Search Tools', () => { }); }); - // ─── hyperdx_save_saved_search ──────────────────────────────────────────── + // ─── clickstack_save_saved_search ──────────────────────────────────────────── - describe('hyperdx_save_saved_search', () => { + describe('clickstack_save_saved_search', () => { describe('create', () => { it('should create a new saved search', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { name: 'Error Traces', sourceId: traceSource._id.toString(), where: 'StatusCode:Error', @@ -220,7 +228,7 @@ describe('MCP Saved Search Tools', () => { }); it('should create a saved search with minimal fields', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { name: 'Minimal Search', sourceId: traceSource._id.toString(), }); @@ -234,7 +242,7 @@ describe('MCP Saved Search Tools', () => { }); it('should create a saved search with SQL where language', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { name: 'SQL Search', sourceId: traceSource._id.toString(), where: "StatusCode = 'Error'", @@ -248,7 +256,7 @@ describe('MCP Saved Search Tools', () => { }); it('should create a saved search with select and orderBy', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { name: 'Full Search', sourceId: traceSource._id.toString(), select: 'body,service.name,duration', @@ -263,7 +271,7 @@ describe('MCP Saved Search Tools', () => { }); it('should create a saved search with filters', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { name: 'Filtered Search', sourceId: traceSource._id.toString(), filters: [ @@ -285,7 +293,7 @@ describe('MCP Saved Search Tools', () => { }); it('should reject invalid sourceId', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { name: 'Bad Source', sourceId: 'not-a-valid-id', }); @@ -295,7 +303,7 @@ describe('MCP Saved Search Tools', () => { }); it('should include url in response when FRONTEND_URL is set', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { name: 'URL Test', sourceId: traceSource._id.toString(), }); @@ -314,7 +322,7 @@ describe('MCP Saved Search Tools', () => { name: 'Original Name', }); - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { id: savedSearch._id.toString(), name: 'Updated Name', sourceId: traceSource._id.toString(), @@ -338,7 +346,7 @@ describe('MCP Saved Search Tools', () => { it('should return error for non-existent saved search on update', async () => { const fakeId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { id: fakeId, name: 'Ghost Search', sourceId: traceSource._id.toString(), @@ -349,7 +357,7 @@ describe('MCP Saved Search Tools', () => { }); it('should return error for invalid ObjectId format on update', async () => { - const result = await callTool(client, 'hyperdx_save_saved_search', { + const result = await callTool(client, 'clickstack_save_saved_search', { id: '!!!', name: 'Bad ID', sourceId: traceSource._id.toString(), diff --git a/packages/api/src/mcp/__tests__/sources.test.ts b/packages/api/src/mcp/__tests__/sources.test.ts index aea5ead6d7..008e4fe852 100644 --- a/packages/api/src/mcp/__tests__/sources.test.ts +++ b/packages/api/src/mcp/__tests__/sources.test.ts @@ -96,11 +96,11 @@ describe('MCP Source Tools', () => { await server.stop(); }); - // ── hyperdx_list_sources ────────────────────────────────────────────────── + // ── clickstack_list_sources ────────────────────────────────────────────────── - describe('hyperdx_list_sources', () => { + describe('clickstack_list_sources', () => { it('should list sources and connections as a lightweight catalog', async () => { - const result = await callTool(client, 'hyperdx_list_sources'); + const result = await callTool(client, 'clickstack_list_sources'); expect(result.isError).toBeFalsy(); expect(result.content).toHaveLength(1); @@ -129,11 +129,11 @@ describe('MCP Source Tools', () => { // Should include nextStep guidance pointing to describe_source expect(output.nextStep).toBeDefined(); - expect(output.nextStep).toContain('hyperdx_describe_source'); + expect(output.nextStep).toContain('clickstack_describe_source'); }); it('should include keyColumns from source config without ClickHouse queries', async () => { - const result = await callTool(client, 'hyperdx_list_sources'); + const result = await callTool(client, 'clickstack_list_sources'); const output = JSON.parse(getFirstText(result)); const trace = output.sources.find( @@ -159,7 +159,7 @@ describe('MCP Source Tools', () => { }; const client2 = await createTestClient(context2); - const result = await callTool(client2, 'hyperdx_list_sources'); + const result = await callTool(client2, 'clickstack_list_sources'); const output = JSON.parse(getFirstText(result)); expect(output.sources).toHaveLength(0); @@ -169,11 +169,11 @@ describe('MCP Source Tools', () => { }); }); - // ── hyperdx_describe_source ─────────────────────────────────────────────── + // ── clickstack_describe_source ─────────────────────────────────────────────── - describe('hyperdx_describe_source', () => { + describe('clickstack_describe_source', () => { it('should return full column schema for a trace source', async () => { - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: traceSource._id.toString(), }); @@ -197,7 +197,7 @@ describe('MCP Source Tools', () => { }); it('should return full column schema for a log source', async () => { - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: logSource._id.toString(), }); @@ -223,7 +223,7 @@ describe('MCP Source Tools', () => { }); it('should include map attribute keys', async () => { - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: traceSource._id.toString(), }); const output = JSON.parse(getFirstText(result)); @@ -237,7 +237,7 @@ describe('MCP Source Tools', () => { }); it('should include keyColumns for trace source', async () => { - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: traceSource._id.toString(), }); const output = JSON.parse(getFirstText(result)); @@ -267,7 +267,7 @@ describe('MCP Source Tools', () => { name: 'Metrics', }); - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: metricSource._id.toString(), }); @@ -290,7 +290,7 @@ describe('MCP Source Tools', () => { }); it('should include usage guidance and nextSteps', async () => { - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: traceSource._id.toString(), }); const output = JSON.parse(getFirstText(result)); @@ -301,7 +301,7 @@ describe('MCP Source Tools', () => { }); it('should return error for non-existent source', async () => { - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: '000000000000000000000000', }); @@ -330,7 +330,7 @@ describe('MCP Source Tools', () => { name: 'Other Traces', }); - const result = await callTool(client, 'hyperdx_describe_source', { + const result = await callTool(client, 'clickstack_describe_source', { sourceId: otherSource._id.toString(), }); diff --git a/packages/api/src/mcp/__tests__/trace.test.ts b/packages/api/src/mcp/__tests__/trace.test.ts index 691b55a590..adc5dd30f5 100644 --- a/packages/api/src/mcp/__tests__/trace.test.ts +++ b/packages/api/src/mcp/__tests__/trace.test.ts @@ -147,9 +147,9 @@ describe('MCP Trace Tools', () => { // ─── Schema serialization ────────────────────────────────────────────────── describe('schema serialization', () => { - it('should expose hyperdx_trace_waterfall with expected properties', async () => { + it('should expose clickstack_trace_waterfall with expected properties', async () => { const { tools } = await client.listTools(); - const tool = tools.find(t => t.name === 'hyperdx_trace_waterfall'); + const tool = tools.find(t => t.name === 'clickstack_trace_waterfall'); expect(tool).toBeDefined(); const props = Object.keys(tool!.inputSchema.properties ?? {}); @@ -162,10 +162,10 @@ describe('MCP Trace Tools', () => { expect(tool!.inputSchema.required).toContain('sourceId'); }); - it('should expose hyperdx_trace_top_time_consuming_operations with expected properties', async () => { + it('should expose clickstack_trace_top_time_consuming_operations with expected properties', async () => { const { tools } = await client.listTools(); const tool = tools.find( - t => t.name === 'hyperdx_trace_top_time_consuming_operations', + t => t.name === 'clickstack_trace_top_time_consuming_operations', ); expect(tool).toBeDefined(); @@ -183,11 +183,11 @@ describe('MCP Trace Tools', () => { }); }); - // ─── hyperdx_trace_waterfall ─────────────────────────────────────────────── + // ─── clickstack_trace_waterfall ─────────────────────────────────────────────── - describe('hyperdx_trace_waterfall', () => { + describe('clickstack_trace_waterfall', () => { it('should return error for non-existent source', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: '000000000000000000000000', }); expect(result.isError).toBe(true); @@ -195,7 +195,7 @@ describe('MCP Trace Tools', () => { }); it('should return error for non-trace source', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: logSource._id.toString(), }); expect(result.isError).toBe(true); @@ -203,7 +203,7 @@ describe('MCP Trace Tools', () => { }); it('should return error for invalid time range', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), startTime: 'not-a-date', }); @@ -212,7 +212,7 @@ describe('MCP Trace Tools', () => { }); it('should return no-match hint when no traces exist', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), endTime: new Date().toISOString(), @@ -276,7 +276,7 @@ describe('MCP Trace Tools', () => { }); it('should fetch a trace by traceId and return a waterfall tree', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), traceId: TRACE_ID, startTime: new Date(now.getTime() - 10 * 60 * 1000).toISOString(), @@ -306,7 +306,7 @@ describe('MCP Trace Tools', () => { }); it('should auto-pick the slowest trace when traceId is omitted', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), pickFilter: `ServiceName:${WF_SVC}`, pickBy: 'slowest', @@ -321,7 +321,7 @@ describe('MCP Trace Tools', () => { }); it('should auto-pick the most recent trace', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), pickFilter: `ServiceName:${WF_SVC}`, pickBy: 'most_recent', @@ -335,7 +335,7 @@ describe('MCP Trace Tools', () => { }); it('should respect maxSpans and note truncation', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), traceId: TRACE_ID, maxSpans: 2, @@ -371,7 +371,7 @@ describe('MCP Trace Tools', () => { }, ]); - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), traceId: TRACE_ID, includeLogs: true, @@ -388,7 +388,7 @@ describe('MCP Trace Tools', () => { }); it('should omit logs when includeLogs is false', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), traceId: TRACE_ID, includeLogs: false, @@ -402,7 +402,7 @@ describe('MCP Trace Tools', () => { }); it('should apply pickFilter to narrow which trace is picked', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), pickFilter: `ServiceName:${WF_SVC}`, pickBy: 'slowest', @@ -416,7 +416,7 @@ describe('MCP Trace Tools', () => { }); it('should apply pickFilter with sql language', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), pickFilter: `ServiceName = '${WF_SVC}'`, pickFilterLanguage: 'sql', @@ -455,7 +455,7 @@ describe('MCP Trace Tools', () => { }); it('should auto-pick trace with an error span', async () => { - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: traceSource._id.toString(), pickFilter: 'ServiceName:wf-test-err-svc', pickBy: 'first_error', @@ -498,7 +498,7 @@ describe('MCP Trace Tools', () => { }); const TRACE_ID = 'aaaabbbbccccddddeeeeffffgggghhhh'; - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: noLogTraceSource._id.toString(), traceId: TRACE_ID, includeLogs: true, @@ -538,7 +538,7 @@ describe('MCP Trace Tools', () => { }); const TRACE_ID = 'aaaabbbbccccddddeeeeffffgggghhhh'; - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: badLogTraceSource._id.toString(), traceId: TRACE_ID, includeLogs: true, @@ -579,7 +579,7 @@ describe('MCP Trace Tools', () => { }); const TRACE_ID = 'aaaabbbbccccddddeeeeffffgggghhhh'; - const result = await callTool(client, 'hyperdx_trace_waterfall', { + const result = await callTool(client, 'clickstack_trace_waterfall', { sourceId: wrongKindTraceSource._id.toString(), traceId: TRACE_ID, includeLogs: true, @@ -595,13 +595,13 @@ describe('MCP Trace Tools', () => { }); }); - // ─── hyperdx_trace_top_time_consuming_operations ─────────────────────────── + // ─── clickstack_trace_top_time_consuming_operations ─────────────────────────── - describe('hyperdx_trace_top_time_consuming_operations', () => { + describe('clickstack_trace_top_time_consuming_operations', () => { it('should return error for non-existent source', async () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: '000000000000000000000000', parentFilter: "ServiceName = 'api-gateway'", @@ -616,7 +616,7 @@ describe('MCP Trace Tools', () => { it('should return error for non-trace source', async () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: logSource._id.toString(), parentFilter: "ServiceName = 'api-gateway'", @@ -631,7 +631,7 @@ describe('MCP Trace Tools', () => { it('should return error for invalid time range', async () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: traceSource._id.toString(), parentFilter: "ServiceName = 'api-gateway'", @@ -646,7 +646,7 @@ describe('MCP Trace Tools', () => { it('should return empty operations when no traces match', async () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: traceSource._id.toString(), parentFilter: "ServiceName = 'nonexistent-service'", @@ -753,7 +753,7 @@ describe('MCP Trace Tools', () => { it('should break down child operations ranked by total time', async () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: traceSource._id.toString(), parentFilter: `ServiceName = '${PARENT_SVC}' AND SpanName = '${PARENT_OP}'`, @@ -790,7 +790,7 @@ describe('MCP Trace Tools', () => { // Only trace 2 has a parent >= 850ms (900ms) const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: traceSource._id.toString(), parentFilter: `ServiceName = '${PARENT_SVC}' AND SpanName = '${PARENT_OP}'`, @@ -815,7 +815,7 @@ describe('MCP Trace Tools', () => { it('should respect topN parameter', async () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: traceSource._id.toString(), parentFilter: `ServiceName = '${PARENT_SVC}' AND SpanName = '${PARENT_OP}'`, @@ -840,7 +840,7 @@ describe('MCP Trace Tools', () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: traceSource._id.toString(), parentFilter: `ServiceName = '${PARENT_SVC}' AND SpanName = '${PARENT_OP}'`, @@ -860,7 +860,7 @@ describe('MCP Trace Tools', () => { it('should return error for invalid parentFilter SQL', async () => { const result = await callTool( client, - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { sourceId: traceSource._id.toString(), parentFilter: 'INVALID SQL @@@ SYNTAX', diff --git a/packages/api/src/mcp/mcpServer.ts b/packages/api/src/mcp/mcpServer.ts index 0635db892b..885cc245e2 100644 --- a/packages/api/src/mcp/mcpServer.ts +++ b/packages/api/src/mcp/mcpServer.ts @@ -13,7 +13,7 @@ import { McpContext } from './tools/types'; export function createServer(context: McpContext) { const server = new McpServer({ - name: 'hyperdx', + name: 'clickstack', version: `${CODE_VERSION}-beta`, }); diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 6ba6f2c437..9b1cc2f730 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -17,25 +17,25 @@ export function buildCreateDashboardPrompt( ${userContext} ${sourceSummary} -IMPORTANT: Call hyperdx_list_sources first to get source IDs, then hyperdx_describe_source for each source you plan to query. This gives you the full column schema, attribute keys, and sampled values (e.g. SeverityText, StatusCode). The source IDs above are correct, but you need the schema details to write accurate queries. +IMPORTANT: Call clickstack_list_sources first to get source IDs, then clickstack_describe_source for each source you plan to query. This gives you the full column schema, attribute keys, and sampled values (e.g. SeverityText, StatusCode). The source IDs above are correct, but you need the schema details to write accurate queries. == WORKFLOW == -1. Call hyperdx_list_sources to discover source IDs and connection IDs. -2. Call hyperdx_describe_source for each source you plan to query to get column schema, attribute keys, and sampled low-cardinality values (SeverityText, StatusCode, ServiceName, etc.). -3. Call hyperdx_get_dashboard (no id) to list existing dashboards. If any exist, fetch one or two with their id and skim them to pick up local idioms (naming style, time ranges, common groupings) before adding new ones. Skip when the workspace is empty. +1. Call clickstack_list_sources to discover source IDs and connection IDs. +2. Call clickstack_describe_source for each source you plan to query to get column schema, attribute keys, and sampled low-cardinality values (SeverityText, StatusCode, ServiceName, etc.). +3. Call clickstack_get_dashboard (no id) to list existing dashboards. If any exist, fetch one or two with their id and skim them to pick up local idioms (naming style, time ranges, common groupings) before adding new ones. Skip when the workspace is empty. 4. Pick the right source kind for the question. Trace data for request volume / latency / errors. Log data for severity / messages. Metric data for system telemetry. 5. Sketch tiles, THEN group them into 2-4 containers (Overview / Trends / Errors is a sane default for a service-health pattern) before assembling the save payload. Ungrouped tiles render as a flat sprawl that fails the readability test at five or more tiles. -6. Call hyperdx_save_dashboard with the tiles, containers, and dashboard-level filters. -7. Call hyperdx_query_tile on EVERY tile (not just one) to confirm queries return data and the dashboard is not silently degraded. +6. Call clickstack_save_dashboard with the tiles, containers, and dashboard-level filters. +7. Call clickstack_query_tile on EVERY tile (not just one) to confirm queries return data and the dashboard is not silently degraded. == UPDATING AN EXISTING DASHBOARD == -When updating a dashboard (passing the top-level \`id\` to hyperdx_save_dashboard), +When updating a dashboard (passing the top-level \`id\` to clickstack_save_dashboard), the \`filters\` array must be fully self-describing: every filter needs an \`id\`: - Existing filter you are KEEPING: copy its \`id\` verbatim from the - hyperdx_get_dashboard response. The same applies if you are renaming + clickstack_get_dashboard response. The same applies if you are renaming or tweaking it. This preserves any savedFilterValues bound to that id. - New filter you are ADDING in this update: generate a fresh random 24-character hex string (a Mongo-style ObjectId) and set it as \`id\`. @@ -46,11 +46,11 @@ the \`filters\` array must be fully self-describing: every filter needs an \`id\ Recommended pattern: - 1. \`hyperdx_get_dashboard({ id })\` to capture the current \`filters[]\` + 1. \`clickstack_get_dashboard({ id })\` to capture the current \`filters[]\` and their ids. 2. Build the new array: spread the kept filters as-is, append new filters with freshly-minted ids, omit removed ones. - 3. \`hyperdx_save_dashboard({ id, ..., filters: })\`. + 3. \`clickstack_save_dashboard({ id, ..., filters: })\`. == TILE TYPE GUIDE == @@ -72,15 +72,15 @@ Use RAW SQL tiles (with connectionId) only for queries the builder cannot expres - Top-level columns use PascalCase by default: Duration, StatusCode, SpanName, Body, SeverityText, ServiceName, SpanKind. NOTE: These are defaults for the standard HyperDX schema. Custom sources may use different names. - Always call hyperdx_describe_source to get the real column names, keyColumns, and sampled values for each source. + Always call clickstack_describe_source to get the real column names, keyColumns, and sampled values for each source. - Map-type columns use bracket syntax: SpanAttributes['http.method'], ResourceAttributes['service.name']. NEVER use dot notation for Map columns. Always use brackets. - JSON-type columns use dot notation: JsonColumn.key.subkey. - Check the jsType returned by hyperdx_describe_source to determine whether a column is Map or JSON. + Check the jsType returned by clickstack_describe_source to determine whether a column is Map or JSON. == DESIGN CHECKLIST == -Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the renderer; ignoring one is a runtime error you will then need to fix. +Apply these before calling clickstack_save_dashboard. Each rule is enforced by the renderer; ignoring one is a runtime error you will then need to fix. 1. ONE QUESTION PER DASHBOARD. A dashboard answers a single observability question well. Split if the request mixes traces, logs, and metrics into unrelated views. @@ -104,7 +104,7 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the 9. FOR FOCUSED PER-DIMENSION DASHBOARDS, DECLARE A DASHBOARD-LEVEL FILTER. Pass filters: [{ type: "QUERY_EXPRESSION", name, expression, sourceId }] at the top level. The user gets a dropdown in the dashboard header; by default every tile is re-scoped when a value is picked. On mixed-source dashboards add appliesToSourceIds: ["", ...] to restrict the filter to only the tiles whose source carries that column. Omit the field to keep the broadcast-to-all-tiles default. Do NOT hardcode the dimension into each tile's where clause. -10. UPDATE IS REPLACE, NOT MERGE. hyperdx_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call hyperdx_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. +10. UPDATE IS REPLACE, NOT MERGE. clickstack_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call clickstack_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. 11. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not. @@ -121,17 +121,17 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the ] Use tabs when one container needs to show different views of the same data (Throughput / Latency / Errors over time, for instance); the tab bar appears when a container has two or more tabs declared. -12. VALIDATE EVERY TILE AFTER SAVE. After hyperdx_save_dashboard, call hyperdx_query_tile on EVERY tile (not just one). Save validates input shape; it does NOT validate query semantics. Some queries pass save and fail at render time (known gaps: Lucene comparison/wildcard on map attributes, metric tiles with multiple metricTables, malformed having clauses). If query_tile returns an error, fix the tile and re-save before declaring the dashboard ready. +12. VALIDATE EVERY TILE AFTER SAVE. After clickstack_save_dashboard, call clickstack_query_tile on EVERY tile (not just one). Save validates input shape; it does NOT validate query semantics. Some queries pass save and fail at render time (known gaps: Lucene comparison/wildcard on map attributes, metric tiles with multiple metricTables, malformed having clauses). If query_tile returns an error, fix the tile and re-save before declaring the dashboard ready. 13. NO TITLE-RECAP MARKDOWN TILE. The dashboard's name shows in the title bar. Adding a markdown tile with the dashboard name (or a "About this dashboard" header) doubles the title and eats a row of vertical space because markdown heading styles render at title-bar scale. Skip the markdown tile entirely on starter dashboards. == ADAPT, DO NOT COPY == -The dashboard_examples prompt returns concrete example shapes. Read them as patterns to adapt to the user's actual request and schema, not as literal templates to substitute names into. Specifically: the literal "ServiceName", "StatusCode:STATUS_CODE_ERROR", "Duration" come from the standard HyperDX schema. Real source schemas may use different column names and status values; always verify with hyperdx_list_sources before reusing literals. +The dashboard_examples prompt returns concrete example shapes. Read them as patterns to adapt to the user's actual request and schema, not as literal templates to substitute names into. Specifically: the literal "ServiceName", "StatusCode:STATUS_CODE_ERROR", "Duration" come from the standard HyperDX schema. Real source schemas may use different column names and status values; always verify with clickstack_list_sources before reusing literals. IMPORTANT: The exact values for StatusCode and SeverityText vary by deployment. Do NOT assume values like "STATUS_CODE_ERROR", "Ok", "error", or "fatal". -Always call hyperdx_describe_source first and inspect the lowCardinalityValues +Always call clickstack_describe_source first and inspect the lowCardinalityValues returned for each source to discover the real values used in your data. == DEFAULT TIME WINDOW == @@ -143,12 +143,12 @@ Dashboards open with a 15-minute default window. There is no dashboard-level fie - Using valueExpression with aggFn "count" (count takes no valueExpression). - Forgetting valueExpression for non-count aggFns (avg, sum, min, max, quantile all require it). - Using dot notation for Map-type attributes (always use SpanAttributes['key'] bracket syntax). -- Skipping hyperdx_list_sources + hyperdx_describe_source (you need real source IDs, column names, and values). -- Skipping hyperdx_query_tile after save (tiles can silently fail on syntax or attribute mismatches). +- Skipping clickstack_list_sources + clickstack_describe_source (you need real source IDs, column names, and values). +- Skipping clickstack_query_tile after save (tiles can silently fail on syntax or attribute mismatches). - Setting chart-level numberFormat on a table that mixes counts and durations (counts render as 0:00:00). - Multiple select items on number / pie / heatmap tiles (each takes exactly one). - Missing level on aggFn "quantile" (must specify 0.5, 0.9, 0.95, or 0.99). -- Assuming StatusCode or SeverityText values (always inspect lowCardinalityValues from hyperdx_describe_source). +- Assuming StatusCode or SeverityText values (always inspect lowCardinalityValues from clickstack_describe_source). - Heatmap on a non-Trace source (heatmap is Trace-only today). - Hardcoding a focus dimension into every tile's where clause (use a dashboard-level filter instead).`; } @@ -614,7 +614,7 @@ or a more detailed dashboard. Useful when the dashboard answers inside that service?". Replace with the ID of an existing service-detail -dashboard. Use hyperdx_get_dashboard (no id) to list dashboards and +dashboard. Use clickstack_get_dashboard (no id) to list dashboards and copy the right ID and discover which filters are available. { @@ -741,8 +741,8 @@ SQL TEMPLATE REFERENCE: 'Concrete dashboard examples for common observability patterns. ' + 'These are PATTERNS to adapt, not literal templates. Adapt the structure ' + "(table shape, container layout, filter expression) to the user's request; " + - 'always verify column names and status literals via hyperdx_list_sources before reusing them.\n' + - 'Replace ${sourceId} / ${connectionId} placeholders with real IDs from hyperdx_list_sources.\n\n'; + 'always verify column names and status literals via clickstack_list_sources before reusing them.\n' + + 'Replace ${sourceId} / ${connectionId} placeholders with real IDs from clickstack_list_sources.\n\n'; if (pattern) { const key = pattern.toLowerCase().replace(/[\s-]+/g, '_'); @@ -765,7 +765,7 @@ SQL TEMPLATE REFERENCE: } export function buildQueryGuidePrompt(): string { - return `Reference guide for writing queries with HyperDX MCP tools (hyperdx_timeseries, hyperdx_table, hyperdx_search, hyperdx_event_patterns, hyperdx_sql, and hyperdx_save_dashboard). + return `Reference guide for writing queries with ClickStack MCP tools (clickstack_timeseries, clickstack_table, clickstack_search, clickstack_event_patterns, clickstack_sql, and clickstack_save_dashboard). == AGGREGATION FUNCTIONS (aggFn) == @@ -794,7 +794,7 @@ Top-level columns (PascalCase defaults, use directly in valueExpression and grou Duration, StatusCode, SpanName, ServiceName, SpanKind, Body, SeverityText, Timestamp, TraceId, SpanId, ParentSpanId NOTE: These are defaults for the standard HyperDX schema. Custom sources may - use different column names. Always verify with hyperdx_describe_source, which returns + use different column names. Always verify with clickstack_describe_source, which returns the real column names and keyColumns expressions for each source. Map-type columns (bracket syntax, access keys via ['key']): @@ -810,7 +810,7 @@ IMPORTANT: Always use bracket syntax for Map-type columns. Never use dot notatio JSON-type columns (dot notation, access nested keys via dot path): JsonColumn.key.subkey - NOTE: Check the jsType field returned by hyperdx_describe_source to determine + NOTE: Check the jsType field returned by clickstack_describe_source to determine whether a column is Map (use brackets) or JSON (use dots). == LUCENE FILTER SYNTAX == @@ -821,7 +821,7 @@ Used in the "where" field of select items and search tiles. AND: service.name:api AND http.status_code:>=500 OR: level:error OR level:fatal NOT: NOT level:debug - Wildcard: service.name:front* + Wildcard: ServiceName:front* Phrase: Body:"connection refused" Exists: _exists_:http.route Range (numeric): Duration:>1000000000 @@ -831,16 +831,39 @@ Used in the "where" field of select items and search tiles. NOTE: In Lucene filters, use dot notation for attribute keys (service.name, http.method). This is different from valueExpression and groupBy which require bracket syntax (SpanAttributes['http.method']). -GOTCHA: Lucene queries that reference map-attribute keys via dotted paths (http.status_code, db.system, deployment.environment, anything under SpanAttributes / ResourceAttributes / LogAttributes) are NOT reliably translated to bracket access. Operators (>=, >, <=, <), wildcards (*), AND simple equality all hit translator gaps depending on the column. Symptoms: tile saves fine; hyperdx_query_tile returns an error or returns zero rows when there should be matches; the dashboard renders "Error loading chart" on the tile. Do NOT trust dotted-path Lucene; use SQL with bracket access for ALL map-attribute filtering. +*** SUBSTRING MATCHING (field:value and field:value*) *** + +Lucene field:value does NOT mean exact equality. It translates to ilike(field, '%value%') -- a case-insensitive SUBSTRING match. This has two major consequences: + + 1. Plain value is a substring search: + SpanKind:Server matches "Server", "ServerStreaming", "InternalServer", etc. + SeverityText:error matches "error", "error_timeout", "critical_error", etc. + + 2. Trailing wildcard (field:val*) is prefix-within-substring, NOT a true prefix match: + ServiceName:api* does NOT mean "starts with api". It means "contains a substring starting with api". + In practice this often works for prefix matching, but it is NOT equivalent to SQL LIKE 'api%'. + + For exact matching or true prefix/suffix/contains patterns, use SQL instead: + Exact: whereLanguage: "sql", where: "SpanKind = 'Server'" + Prefix: whereLanguage: "sql", where: "ServiceName LIKE 'api%'" + Contains: whereLanguage: "sql", where: "Body LIKE '%timeout%'" + Regex: whereLanguage: "sql", where: "match(ServiceName, '^api-v[0-9]+')" + + When Lucene substring matching is FINE: free-text columns like Body, StatusMessage where substring is the intent. + When Lucene substring matching is WRONG: enum-like columns (SpanKind, SeverityText, StatusCode, ServiceName) where you want exact or true prefix match. + +*** MAP-ATTRIBUTE GOTCHA *** + +Lucene queries that reference map-attribute keys via dotted paths (http.status_code, db.system, deployment.environment, anything under SpanAttributes / ResourceAttributes / LogAttributes) are NOT reliably translated to bracket access. Operators (>=, >, <=, <), wildcards (*), AND simple equality all hit translator gaps depending on the column. Symptoms: tile saves fine; clickstack_query_tile returns an error or returns zero rows when there should be matches; the dashboard renders "Error loading chart" on the tile. Do NOT trust dotted-path Lucene; use SQL with bracket access for ALL map-attribute filtering. Unreliable in Lucene (any operation, any operator): http.status_code:>=500 (operator) - http.route:* (wildcard) + http.route:* (wildcard -- also hits the substring matching trap above) db.system:mongodb (simple equality; the translator does NOT map this to SpanAttributes['db.system']) Reliable in Lucene (top-level columns only): Duration:>1000000000 (top-level numeric range) - ServiceName:checkout (top-level string, see fuzzy-match note below for what this actually means) + ServiceName:checkout (top-level string -- but remember this is a substring match, not exact!) Workaround: switch to SQL with bracket access. Map values are stored as strings, so quote the comparison value. @@ -853,11 +876,6 @@ Workaround: switch to SQL with bracket access. Map values are stored as strings, whereLanguage: "sql" where: "SpanAttributes['http.route'] != ''" (filters out empty keys; map values are often empty) -FUZZY-MATCH NOTE: Lucene field:value on a top-level string column translates to ilike(field, '%value%'), not exact equality. That is fine for free-text columns (Body, StatusMessage) but surprising for enum-like columns (SpanKind, SeverityText, StatusCode). SpanKind:Server matches "Server" AND any other string containing "Server" as a substring. For exact-match semantics on top-level columns, use SQL: - - whereLanguage: "sql" - where: "SpanKind = 'Server'" - == SQL FILTER SYNTAX == Alternative to Lucene. Set whereLanguage: "sql" when using SQL syntax. @@ -927,7 +945,7 @@ For configType: "sql" tiles, write ClickHouse SQL with template macros: search No select items (select is a column list string). where is the filter. markdown No select items. Set markdown field with content. -NOTE: Authoring builder tiles on a metric source is not reliable today. The MCP select-item shape does not carry the metricName / metricType fields the metric query path needs, and a save with a metric sourceId may render in the UI as "Both table name and UUID are empty" even though the save itself succeeded. For metrics, use a raw SQL tile (configType: "sql") with explicit table reference. The standard tables that back a metric source are otel_metrics_gauge, otel_metrics_sum, and otel_metrics_histogram; hyperdx_list_sources returns the metric source's metricTables map so you know which table holds which metric kind. Discovery: metric source schemas today do NOT publish mapAttributeKeys for ResourceAttributes / Attributes the way log and trace sources do, so attribute keys must be discovered by sampling (SELECT DISTINCT mapKeys(Attributes) FROM ...). +NOTE: Authoring builder tiles on a metric source is not reliable today. The MCP select-item shape does not carry the metricName / metricType fields the metric query path needs, and a save with a metric sourceId may render in the UI as "Both table name and UUID are empty" even though the save itself succeeded. For metrics, use a raw SQL tile (configType: "sql") with explicit table reference. The standard tables that back a metric source are otel_metrics_gauge, otel_metrics_sum, and otel_metrics_histogram; clickstack_list_sources returns the metric source's metricTables map so you know which table holds which metric kind. Discovery: metric source schemas today do NOT publish mapAttributeKeys for ResourceAttributes / Attributes the way log and trace sources do, so attribute keys must be discovered by sampling (SELECT DISTINCT mapKeys(Attributes) FROM ...). == NUMBER FORMAT == @@ -997,11 +1015,11 @@ Destination types: { type: "search", target, whereLanguage, whereTemplate?, filters? } Opens the /search page for a log or trace source. Metric and session sources are rejected by the server (the /search page does not render those kinds). { type: "dashboard", target, whereLanguage, whereTemplate?, filters? } - Opens another HyperDX dashboard owned by the same team. + Opens another ClickStack dashboard owned by the same team. Target shape, how the destination is identified: target: { mode: "id", id: "" } - Pins a specific source (for type=search) or dashboard (for type=dashboard) by its ID. Prefer this when the target is known. Find source IDs with hyperdx_list_sources; find dashboard IDs with hyperdx_get_dashboard (no id arg returns the list of all dashboards). + Pins a specific source (for type=search) or dashboard (for type=dashboard) by its ID. Prefer this when the target is known. Find source IDs with clickstack_list_sources; find dashboard IDs with clickstack_get_dashboard (no id arg returns the list of all dashboards). target: { mode: "template", template: "" } Renders the template against the clicked row's columns, then resolves the result by NAME on the destination team. Example template "{{Service}}" reads the row's Service value and looks up a source/dashboard with that name. Use this when the destination depends on which row was clicked. A constant template like "Service Detail" (no Handlebars vars) simply resolves to the dashboard or source with that exact name. @@ -1088,7 +1106,7 @@ For onClick drill-down on a plain top-level column (ServiceName), no workaround == CONTAINERS AND TABS == -Optional dashboard organization layer. Group related tiles visually with optional tab bars. Pass on hyperdx_save_dashboard as a top-level "containers" array; reference containers from tiles via "containerId" (and "tabId" when the container has tabs). +Optional dashboard organization layer. Group related tiles visually with optional tab bars. Pass on clickstack_save_dashboard as a top-level "containers" array; reference containers from tiles via "containerId" (and "tabId" when the container has tabs). Container shape: { id, title, collapsed, collapsible?, bordered?, tabs? } @@ -1112,7 +1130,7 @@ Rules enforced by the API: - A tile's tabId requires containerId to be set. Example: - hyperdx_save_dashboard({ + clickstack_save_dashboard({ name: "Service Overview", containers: [ { @@ -1149,7 +1167,7 @@ containers[i].tabs[j].id). Read the path to know which input to fix. == EVENT PATTERN MINING == -Use hyperdx_event_patterns when asked to find common log messages, recurring +Use clickstack_event_patterns when asked to find common log messages, recurring patterns, noisy services, or top event types. This is the right choice whenever the question is about "most common logs", "top patterns", "what is generating the most log volume", or similar pattern-discovery questions. @@ -1161,7 +1179,7 @@ It samples random events and clusters them with the Drain algorithm, returning: - a whereSnippet for each pattern to drill into matching raw events Required parameters: - - sourceId (call hyperdx_list_sources first) + - sourceId (call clickstack_list_sources first) - startTime / endTime for the time window Optional parameters: @@ -1170,7 +1188,7 @@ Optional parameters: - bodyExpression: column to mine (auto-detected: Body for logs, SpanName for traces) Example: find top patterns for production services over the last 4 hours: - hyperdx_event_patterns({ + clickstack_event_patterns({ sourceId: "", startTime: "<4 hours ago ISO>", endTime: "", @@ -1190,12 +1208,12 @@ Example: find top patterns for production services over the last 4 hours: 3. Using dot notation for Map-type attributes in valueExpression or groupBy Wrong: groupBy: "SpanAttributes.http.method" Correct: groupBy: "SpanAttributes['http.method']" - NOTE: JSON-type columns DO use dot notation. Check jsType from hyperdx_describe_source. + NOTE: JSON-type columns DO use dot notation. Check jsType from clickstack_describe_source. 4. Multiple select items on number / pie / heatmap tiles Wrong: displayType: "number", select: [{ aggFn: "count" }, { aggFn: "avg", ... }] Correct: displayType: "number", select: [{ aggFn: "count" }] - Note: hyperdx_table (the query tool) auto-upgrades shape:"number" to "table" when select has >1 item; dashboard tiles do not. + Note: clickstack_table (the query tool) auto-upgrades shape:"number" to "table" when select has >1 item; dashboard tiles do not. 5. Missing level for quantile Wrong: { aggFn: "quantile", valueExpression: "Duration" } @@ -1212,7 +1230,7 @@ Example: find top patterns for production services over the last 4 hours: The dashboard filter applies globally; tiles do not need the literal. On mixed-source dashboards where only some tiles carry the column, add appliesToSourceIds: ["", ...] to scope the filter to just those sources instead of breaking the unrelated tiles. 8. Forgetting to validate tiles after saving - Always call hyperdx_query_tile on EVERY tile after hyperdx_save_dashboard, not just one. Save validates input shape but not query semantics. Several known gaps (Lucene comparison/wildcard on map attributes, builder tiles on metric sources, malformed having) pass save and fail at render time. A dashboard with one bad tile renders the whole page in a degraded state; the user sees "Error loading chart" with no way to know which tile broke unless you validated. If query_tile returns an error, fix the where / SQL and re-save before declaring the dashboard ready. + Always call clickstack_query_tile on EVERY tile after clickstack_save_dashboard, not just one. Save validates input shape but not query semantics. Several known gaps (Lucene comparison/wildcard on map attributes, builder tiles on metric sources, malformed having) pass save and fail at render time. A dashboard with one bad tile renders the whole page in a degraded state; the user sees "Error loading chart" with no way to know which tile broke unless you validated. If query_tile returns an error, fix the where / SQL and re-save before declaring the dashboard ready. 9. Using sourceId with SQL tiles or connectionId with builder tiles Builder tiles (line, table, etc.) use sourceId. @@ -1220,13 +1238,13 @@ Example: find top patterns for production services over the last 4 hours: 10. Assuming StatusCode or SeverityText values Values like STATUS_CODE_ERROR, Ok, error, fatal vary by deployment. - Always call hyperdx_describe_source and inspect the lowCardinalityValues + Always call clickstack_describe_source and inspect the lowCardinalityValues before writing filters that depend on these columns. 11. Heatmap tile on a non-Trace source Wrong: { displayType: "heatmap", sourceId: "", select: [...] } Correct: { displayType: "heatmap", sourceId: "", select: [...] } - Heatmap is currently restricted to Trace sources. Pick a source whose kind is "trace" from hyperdx_list_sources. + Heatmap is currently restricted to Trace sources. Pick a source whose kind is "trace" from clickstack_list_sources. 12. Heatmap tile with empty or missing valueExpression Wrong: select: [{}] @@ -1247,10 +1265,12 @@ Example: find top patterns for production services over the last 4 hours: Correct: where: "SpanAttributes['db.system'] = 'mongodb'" (whereLanguage: "sql") Lucene parses dotted-path queries happily at save time but the translator does not reliably map them to bracket access. Behavior varies by column and operator. Treat any Lucene filter on a map-attribute key as a save-time bug and use SQL with bracket access for ALL map-attribute filtering. Map values are stored as strings; quote the comparison value. -15. Lucene field:value on enum-like top-level columns (fuzzy substring match, not equality) - Wrong: where: "SpanKind:Server" (whereLanguage: "lucene"; translates to ilike(SpanKind, '%Server%') and matches "Server_Client", "Internal_Server_Error", etc.) - Correct: where: "SpanKind = 'Server'" (whereLanguage: "sql") - Lucene field:value is a substring match (ilike with surrounding wildcards), not exact equality. Fine for Body and StatusMessage; wrong for enum-like columns where exact match matters (SpanKind, SeverityText, StatusCode). When the column has discrete known values, use SQL with =. +15. Lucene field:value and field:value* (substring matching, not exact or true prefix) + Wrong: where: "SpanKind:Server" (whereLanguage: "lucene"; translates to ilike(SpanKind, '%Server%') and matches "ServerStreaming", "InternalServer", etc.) + Wrong: where: "ServiceName:api*" (whereLanguage: "lucene"; NOT a true prefix match -- it is prefix-within-substring) + Correct: where: "SpanKind = 'Server'" (whereLanguage: "sql"; exact match) + Correct: where: "ServiceName LIKE 'api%'" (whereLanguage: "sql"; true prefix match) + Lucene field:value is a substring match (ilike with surrounding wildcards), not exact equality. Lucene field:value* is prefix-within-substring, not SQL LIKE 'value%'. Fine for free-text columns (Body, StatusMessage); wrong for enum-like columns where exact match matters (SpanKind, SeverityText, StatusCode) and wrong when true prefix matching is needed. Use SQL for exact match (=), prefix (LIKE 'x%'), contains (LIKE '%x%'), or regex (match(col, 'pattern')). 16. Forgetting that map-attribute values are often empty strings Wrong: groupBy on SpanAttributes['db.collection.name'] without filtering empty values; the result has an empty-string bucket alongside the real ones, which renders as a blank row in tables and an unlabelled slice in pies. diff --git a/packages/api/src/mcp/prompts/dashboards/helpers.ts b/packages/api/src/mcp/prompts/dashboards/helpers.ts index 364f2a9407..f0c4e3d470 100644 --- a/packages/api/src/mcp/prompts/dashboards/helpers.ts +++ b/packages/api/src/mcp/prompts/dashboards/helpers.ts @@ -5,7 +5,7 @@ export function buildSourceSummary( connections: { _id: unknown; name: string }[], ): string { if (sources.length === 0 && connections.length === 0) { - return 'No sources or connections found. Call hyperdx_list_sources to discover available data.'; + return 'No sources or connections found. Call clickstack_list_sources to discover available data.'; } const lines: string[] = []; diff --git a/packages/api/src/mcp/prompts/dashboards/index.ts b/packages/api/src/mcp/prompts/dashboards/index.ts index e98e9f312d..a8735ee33e 100644 --- a/packages/api/src/mcp/prompts/dashboards/index.ts +++ b/packages/api/src/mcp/prompts/dashboards/index.ts @@ -26,7 +26,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { { title: 'Create a Dashboard', description: - 'Create a HyperDX dashboard with the MCP tools. ' + + 'Create a ClickStack dashboard with the MCP tools. ' + 'Follow the recommended workflow, pick tile types, write queries, ' + 'and validate results against your real data sources.', argsSchema: { @@ -72,7 +72,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { 'Failed to fetch sources for create_dashboard prompt', ); sourceSummary = - 'Could not fetch sources. Call hyperdx_list_sources to discover available data.'; + 'Could not fetch sources. Call clickstack_list_sources to discover available data.'; traceSourceId = ''; logSourceId = ''; } @@ -106,7 +106,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { 'Concrete dashboard example shapes for common observability patterns: ' + 'service_inventory, service_detail, log_analytics, backend_dependencies, drilldown_links, infrastructure_sql. ' + 'Each example carries a "when to use" header. Adapt the structure to the user\'s request; ' + - 'do not copy literal column names or status values without verifying via hyperdx_list_sources first.', + 'do not copy literal column names or status values without verifying via clickstack_list_sources first.', argsSchema: { pattern: z .string() diff --git a/packages/api/src/mcp/tools/alerts/getAlert.ts b/packages/api/src/mcp/tools/alerts/getAlert.ts index b6059ebd7b..6b39655d93 100644 --- a/packages/api/src/mcp/tools/alerts/getAlert.ts +++ b/packages/api/src/mcp/tools/alerts/getAlert.ts @@ -50,7 +50,7 @@ export function registerGetAlert(server: McpServer, context: McpContext): void { const frontendUrl = config.FRONTEND_URL; server.registerTool( - 'hyperdx_get_alert', + 'clickstack_get_alert', { title: 'Get Alert(s)', description: @@ -75,7 +75,7 @@ export function registerGetAlert(server: McpServer, context: McpContext): void { ), }), }, - withToolTracing('hyperdx_get_alert', context, async ({ id, state }) => { + withToolTracing('clickstack_get_alert', context, async ({ id, state }) => { // ── List all alerts (slim summary) ── if (!id) { const query: Record = { diff --git a/packages/api/src/mcp/tools/alerts/getWebhook.ts b/packages/api/src/mcp/tools/alerts/getWebhook.ts index e87db90fb7..0707b0c1ab 100644 --- a/packages/api/src/mcp/tools/alerts/getWebhook.ts +++ b/packages/api/src/mcp/tools/alerts/getWebhook.ts @@ -13,16 +13,16 @@ export function registerGetWebhook( const { teamId } = context; server.registerTool( - 'hyperdx_get_webhook', + 'clickstack_get_webhook', { title: 'List Webhooks', description: 'List available webhook destinations (id, name, service type). ' + 'Use the returned id as the webhookId when creating alerts with ' + - 'hyperdx_save_alert.', + 'clickstack_save_alert.', inputSchema: z.object({}), }, - withToolTracing('hyperdx_get_webhook', context, async () => { + withToolTracing('clickstack_get_webhook', context, async () => { const webhooks = await Webhook.find({ team: teamId }); const output = webhooks.map(wh => ({ diff --git a/packages/api/src/mcp/tools/alerts/saveAlert.ts b/packages/api/src/mcp/tools/alerts/saveAlert.ts index c693858b8e..88e9b7635c 100644 --- a/packages/api/src/mcp/tools/alerts/saveAlert.ts +++ b/packages/api/src/mcp/tools/alerts/saveAlert.ts @@ -40,7 +40,7 @@ export function registerSaveAlert( const frontendUrl = config.FRONTEND_URL; server.registerTool( - 'hyperdx_save_alert', + 'clickstack_save_alert', { title: 'Create or Update Alert', description: @@ -49,7 +49,7 @@ export function registerSaveAlert( 'metric crosses a threshold. A webhook notification channel is required.', inputSchema: mcpSaveAlertSchema, }, - withToolTracing('hyperdx_save_alert', context, async input => { + withToolTracing('clickstack_save_alert', context, async input => { // ── Runtime cross-field validation ── const validationError = validateSaveAlertInput(input); if (validationError) { diff --git a/packages/api/src/mcp/tools/alerts/schemas.ts b/packages/api/src/mcp/tools/alerts/schemas.ts index d5878e9847..c678863430 100644 --- a/packages/api/src/mcp/tools/alerts/schemas.ts +++ b/packages/api/src/mcp/tools/alerts/schemas.ts @@ -6,7 +6,7 @@ import { import { z } from 'zod'; // --------------------------------------------------------------------------- -// MCP-compatible flat Zod schema for hyperdx_save_alert. +// MCP-compatible flat Zod schema for clickstack_save_alert. // // The MCP SDK's normalizeObjectSchema() cannot serialize ZodEffects // (superRefine) or discriminatedUnion. We keep the inputSchema as a plain diff --git a/packages/api/src/mcp/tools/dashboards/deleteDashboard.ts b/packages/api/src/mcp/tools/dashboards/deleteDashboard.ts index 5d9fb48b78..0b00f6a4b1 100644 --- a/packages/api/src/mcp/tools/dashboards/deleteDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/deleteDashboard.ts @@ -15,18 +15,18 @@ export function registerDeleteDashboard( const { teamId } = context; server.registerTool( - 'hyperdx_delete_dashboard', + 'clickstack_delete_dashboard', { title: 'Delete Dashboard', description: 'Permanently delete a dashboard by ID. Also removes any alerts attached to its tiles. ' + - 'Use hyperdx_get_dashboard (without an ID) to list available dashboard IDs.', + 'Use clickstack_get_dashboard (without an ID) to list available dashboard IDs.', inputSchema: z.object({ id: z.string().describe('Dashboard ID to delete.'), }), }, withToolTracing( - 'hyperdx_delete_dashboard', + 'clickstack_delete_dashboard', context, async ({ id: dashboardId }) => { if (!mongoose.Types.ObjectId.isValid(dashboardId)) { diff --git a/packages/api/src/mcp/tools/dashboards/getDashboard.ts b/packages/api/src/mcp/tools/dashboards/getDashboard.ts index ee9b2d73a8..412b9e17b3 100644 --- a/packages/api/src/mcp/tools/dashboards/getDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/getDashboard.ts @@ -18,7 +18,7 @@ export function registerGetDashboard( const frontendUrl = config.FRONTEND_URL; server.registerTool( - 'hyperdx_get_dashboard', + 'clickstack_get_dashboard', { title: 'Get Dashboard(s)', description: @@ -33,7 +33,7 @@ export function registerGetDashboard( ), }), }, - withToolTracing('hyperdx_get_dashboard', context, async ({ id }) => { + withToolTracing('clickstack_get_dashboard', context, async ({ id }) => { if (!id) { const dashboards = await getDashboards( new mongoose.Types.ObjectId(teamId), diff --git a/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts b/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts new file mode 100644 index 0000000000..03f834809c --- /dev/null +++ b/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts @@ -0,0 +1,83 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import mongoose from 'mongoose'; +import { z } from 'zod'; + +import Dashboard from '@/models/dashboard'; +import { convertToExternalDashboard } from '@/routers/external-api/v2/utils/dashboards'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; + +export function registerGetDashboardTile( + server: McpServer, + context: McpContext, +): void { + const { teamId } = context; + + server.registerTool( + 'clickstack_get_dashboard_tile', + { + title: 'Get a Single Dashboard Tile', + description: + 'Retrieve a single tile from a dashboard by tileId. ' + + 'Useful for inspecting one tile without loading the full dashboard. ' + + 'Use clickstack_get_dashboard (without an ID) to list dashboards, ' + + 'then clickstack_get_dashboard (with an ID) to see all tile IDs.', + inputSchema: z.object({ + dashboardId: z.string().describe('Dashboard ID.'), + tileId: z + .string() + .describe( + 'Tile ID within the dashboard. ' + + 'Obtain from clickstack_get_dashboard.', + ), + }), + }, + withToolTracing( + 'clickstack_get_dashboard_tile', + context, + async ({ dashboardId, tileId }) => { + if (!mongoose.Types.ObjectId.isValid(dashboardId)) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Invalid dashboard ID' }], + }; + } + + const dashboard = await Dashboard.findOne({ + _id: dashboardId, + team: teamId, + }); + if (!dashboard) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Dashboard not found' }], + }; + } + + const externalDashboard = convertToExternalDashboard(dashboard); + const tile = externalDashboard.tiles.find(t => t.id === tileId); + if (!tile) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Tile not found: ${tileId}. Available tile IDs: ${externalDashboard.tiles.map(t => `${t.id} (${t.name})`).join(', ')}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(tile, null, 2), + }, + ], + }; + }, + ), + ); +} diff --git a/packages/api/src/mcp/tools/dashboards/index.ts b/packages/api/src/mcp/tools/dashboards/index.ts index a1e511e995..f479ae541b 100644 --- a/packages/api/src/mcp/tools/dashboards/index.ts +++ b/packages/api/src/mcp/tools/dashboards/index.ts @@ -3,8 +3,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpContext, ToolDefinition } from '../types'; import { registerDeleteDashboard } from './deleteDashboard'; import { registerGetDashboard } from './getDashboard'; +import { registerGetDashboardTile } from './getDashboardTile'; +import { registerPatchDashboard } from './patchDashboard'; import { registerQueryTile } from './queryTile'; import { registerSaveDashboard } from './saveDashboard'; +import { registerSearchDashboards } from './searchDashboards'; export * from './schemas'; @@ -13,8 +16,11 @@ const dashboardsTools: ToolDefinition = ( context: McpContext, ) => { registerGetDashboard(server, context); + registerGetDashboardTile(server, context); registerSaveDashboard(server, context); + registerPatchDashboard(server, context); registerDeleteDashboard(server, context); + registerSearchDashboards(server, context); registerQueryTile(server, context); }; diff --git a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts new file mode 100644 index 0000000000..0ee13f8cf9 --- /dev/null +++ b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts @@ -0,0 +1,286 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { uniq } from 'lodash'; +import mongoose from 'mongoose'; + +import * as config from '@/config'; +import Dashboard from '@/models/dashboard'; +import { + cleanupDashboardAlerts, + convertToExternalDashboard, + convertToInternalTileConfig, + isConfigTile, + validateDashboardTiles, +} from '@/routers/external-api/v2/utils/dashboards'; +import type { ExternalDashboardTileWithId } from '@/utils/zod'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; +import { mcpPatchDashboardSchema } from './schemas'; + +export function registerPatchDashboard( + server: McpServer, + context: McpContext, +): void { + const { teamId } = context; + const frontendUrl = config.FRONTEND_URL; + + server.registerTool( + 'clickstack_patch_dashboard', + { + title: 'Patch Dashboard', + description: + 'Make targeted updates to a dashboard without resubmitting the full object. ' + + 'You can update dashboard-level fields (name, tags) and/or replace a single ' + + 'tile by tileId \u2014 all in one call. Unmentioned tiles and fields are preserved. ' + + 'Use clickstack_get_dashboard_tile to inspect a tile before patching it. ' + + 'IMPORTANT: After patching a tile, run clickstack_query_tile to confirm the query still works.', + inputSchema: mcpPatchDashboardSchema, + }, + withToolTracing( + 'clickstack_patch_dashboard', + context, + async ({ dashboardId, name, tags, tileId, tile: inputTile }) => { + // Cross-field validation (kept in handler so the inputSchema + // stays a plain z.object and its properties are visible in the + // JSON Schema that the MCP SDK exposes to LLMs). + if ( + name === undefined && + tags === undefined && + (tileId === undefined || inputTile === undefined) + ) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Provide at least one of: name, tags, or tileId+tile to patch.', + }, + ], + }; + } + if ((tileId === undefined) !== (inputTile === undefined)) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'tileId and tile must both be provided or both omitted.', + }, + ], + }; + } + + if (!mongoose.Types.ObjectId.isValid(dashboardId)) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Invalid dashboard ID' }], + }; + } + + const existingDashboard = await Dashboard.findOne({ + _id: dashboardId, + team: teamId, + }); + if (!existingDashboard) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Dashboard not found' }], + }; + } + + // Build the $set payload and the query filter. Metadata fields + // are simple top-level $set entries; the tile patch uses the + // positional $ operator matched by 'tiles.id' in the filter. + const setPayload: Record = {}; + const queryFilter: Record = { + _id: dashboardId, + team: teamId, + }; + + if (name !== undefined) { + setPayload.name = name; + } + if (tags !== undefined) { + setPayload.tags = uniq(tags); + } + + let patchedTile: ExternalDashboardTileWithId | undefined; + + // ── Tile-level patch ───────────────────────────────────────── + if (tileId !== undefined && inputTile !== undefined) { + // Work directly with the persisted internal tiles array so + // untouched tiles are never round-tripped through the external + // converter (which would strip orphaned container refs from + // unrelated tiles). + const internalTiles = + (existingDashboard.tiles as { id: string }[]) ?? []; + const existingIdx = internalTiles.findIndex(t => t.id === tileId); + if (existingIdx === -1) { + // Build a human-readable list of available tile IDs. + // Convert only for the error message (read-only, no write-back). + const externalDashboard = + convertToExternalDashboard(existingDashboard); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Tile not found: ${tileId}. Available tile IDs: ${externalDashboard.tiles.map(t => `${t.id} (${t.name})`).join(', ')}`, + }, + ], + }; + } + + // Read the existing tile's layout and container refs from the + // persisted internal format so fallback values are accurate + // regardless of the external converter's self-healing logic. + const existingInternalTile = internalTiles[existingIdx] as { + id: string; + x?: number; + y?: number; + w?: number; + h?: number; + containerId?: string; + tabId?: string; + config?: { name?: string }; + }; + + // Merge: the incoming tile definition replaces config/name, + // but layout and container refs fall back to the existing tile + // when omitted so the LLM doesn't have to re-specify them. + // Coerce legacy empty-string containerId/tabId to undefined so + // they don't trip the container-ref validator (mirrors the + // self-heal in convertTileToExternalChart). + const incoming = inputTile as Partial; + const existingContainerId = + existingInternalTile.containerId || undefined; + const existingTabId = existingInternalTile.tabId || undefined; + const mergedTile: ExternalDashboardTileWithId = { + id: tileId, + name: incoming.name ?? existingInternalTile.config?.name ?? '', + x: incoming.x ?? existingInternalTile.x ?? 0, + y: incoming.y ?? existingInternalTile.y ?? 0, + w: incoming.w ?? existingInternalTile.w ?? 12, + h: incoming.h ?? existingInternalTile.h ?? 4, + containerId: + 'containerId' in incoming + ? incoming.containerId + : existingContainerId, + tabId: 'tabId' in incoming ? incoming.tabId : existingTabId, + // The config comes from the incoming tile (validated by Zod). + config: incoming.config, + } as ExternalDashboardTileWithId; + + // Validate the patched tile using the shared validation helper. + const validationError = await validateDashboardTiles({ + teamId, + tiles: [mergedTile], + existingTiles: existingDashboard.tiles ?? [], + containers: existingDashboard.containers ?? [], + }); + if (validationError) { + return { + isError: true, + content: [{ type: 'text' as const, text: validationError }], + }; + } + + // Convert only the patched tile to internal format. + if (!isConfigTile(mergedTile)) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Tile must have a config block.', + }, + ], + }; + } + const internalTile = convertToInternalTileConfig(mergedTile); + + // Use the positional $ operator matched by 'tiles.id' in the + // query filter. This targets the tile by its id field rather + // than a captured numeric index, so a concurrent save_dashboard + // that replaces the whole tiles array can't cause us to + // overwrite an unrelated tile at a stale index. + queryFilter['tiles.id'] = tileId; + setPayload['tiles.$'] = internalTile; + patchedTile = mergedTile; + } + + const updatedDashboard = await Dashboard.findOneAndUpdate( + queryFilter, + { $set: setPayload }, + { new: true }, + ); + + if (!updatedDashboard) { + // When a tile patch is in flight, a null result means the tile + // was removed or the dashboard was deleted between our read + // and this write. + if (tileId !== undefined) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: + `Tile ${tileId} was not found at write time (it may have been removed by a concurrent update). ` + + 'The entire update was rejected — name/tags changes (if any) were not applied. Resubmit.', + }, + ], + }; + } + return { + isError: true, + content: [{ type: 'text' as const, text: 'Dashboard not found' }], + }; + } + + // Reconcile alerts: if the tile's displayType changed to one + // that doesn't support alerts (e.g. raw SQL line/pie), clean up + // stale alert documents. Scope to just the patched tile — pass + // it as both the "new" tiles and "existing" ids so the helper + // checks whether the updated config still supports alerts. + if (tileId !== undefined) { + const existingTileIds = new Set([tileId]); + const patchedTileInDb = updatedDashboard.tiles.filter( + t => t.id === tileId, + ); + await cleanupDashboardAlerts({ + dashboardId, + teamId, + internalTiles: patchedTileInDb, + existingTileIds, + }); + } + + // Return a lightweight response: the patched tile (if any) plus + // updated dashboard metadata, without the full tile array. + const output: Record = { + id: updatedDashboard._id.toString(), + name: updatedDashboard.name, + tags: updatedDashboard.tags, + ...(frontendUrl + ? { url: `${frontendUrl}/dashboards/${updatedDashboard._id}` } + : {}), + }; + if (patchedTile) { + output.patchedTile = patchedTile; + output.hint = + 'Use clickstack_query_tile to test the patched tile query.'; + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(output, null, 2), + }, + ], + }; + }, + ), + ); +} diff --git a/packages/api/src/mcp/tools/dashboards/queryTile.ts b/packages/api/src/mcp/tools/dashboards/queryTile.ts index 5604bee8df..77b12df45b 100644 --- a/packages/api/src/mcp/tools/dashboards/queryTile.ts +++ b/packages/api/src/mcp/tools/dashboards/queryTile.ts @@ -16,21 +16,21 @@ export function registerQueryTile( const { teamId } = context; server.registerTool( - 'hyperdx_query_tile', + 'clickstack_query_tile', { title: 'Query a Dashboard Tile', description: 'Execute the query for a specific tile on an existing dashboard. ' + 'Useful for validating that a tile returns data or for spot-checking results ' + 'without rebuilding the query from scratch. ' + - 'Use hyperdx_get_dashboard with an ID to find tile IDs.', + 'Use clickstack_get_dashboard with an ID to find tile IDs.', inputSchema: z.object({ dashboardId: z.string().describe('Dashboard ID.'), tileId: z .string() .describe( 'Tile ID within the dashboard. ' + - 'Obtain from hyperdx_get_dashboard.', + 'Obtain from clickstack_get_dashboard.', ), startTime: z .string() @@ -46,7 +46,7 @@ export function registerQueryTile( }), }, withToolTracing( - 'hyperdx_query_tile', + 'clickstack_query_tile', context, async ({ dashboardId, tileId, startTime, endTime }) => { const timeRange = parseTimeRange(startTime, endTime); diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index d6bee8eb5c..84f19db437 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -8,20 +8,13 @@ import * as config from '@/config'; import Dashboard, { IDashboard } from '@/models/dashboard'; import { cleanupDashboardAlerts, - collectTileContainerRefIssues, convertExternalFiltersToInternal, convertExternalTilesToInternal, convertToExternalDashboard, createDashboardBodySchema, - fetchSourcesForValidation, - filterChangedHeatmapTiles, - getHeatmapTilesWithIncompatibleSources, - getInvalidOnClickSearchSources, - getMissingConnections, - getMissingOnClickDashboards, - getMissingSources, resolveSavedQueryLanguage, updateDashboardBodySchema, + validateDashboardTiles, } from '@/routers/external-api/v2/utils/dashboards'; import type { ExternalDashboardFilter, @@ -41,15 +34,16 @@ export function registerSaveDashboard( const frontendUrl = config.FRONTEND_URL; server.registerTool( - 'hyperdx_save_dashboard', + 'clickstack_save_dashboard', { title: 'Create or Update Dashboard', description: 'Create a new dashboard (omit id) or update an existing one (provide id). ' + - 'Call hyperdx_list_sources first to obtain sourceId and connectionId values. ' + - 'IMPORTANT: After saving a dashboard, always run hyperdx_query_tile on each tile ' + + 'Call clickstack_list_sources first to obtain sourceId and connectionId values. ' + + 'IMPORTANT: After saving a dashboard, always run clickstack_query_tile on each tile ' + 'to confirm the queries work and return expected data. Tiles can silently fail ' + - 'due to incorrect filter syntax, missing attributes, or wrong column names.', + 'due to incorrect filter syntax, missing attributes, or wrong column names. ' + + 'TIP: To update a single tile without resubmitting all tiles, use clickstack_patch_dashboard instead.', inputSchema: z.object({ id: z .string() @@ -65,7 +59,7 @@ export function registerSaveDashboard( }), }, withToolTracing( - 'hyperdx_save_dashboard', + 'clickstack_save_dashboard', context, async ({ id: dashboardId, @@ -180,105 +174,16 @@ async function createDashboard({ const { tiles, filters, containers: parsedContainers } = parsed.data; const tilesWithId = tiles as ExternalDashboardTileWithId[]; - // Mirror the v2 router POST handler: structural container checks ran - // through the body schema; per-tile containerId/tabId resolution runs - // against the request body's containers (no existing dashboard to fall - // back to on create). - const tileRefIssues = collectTileContainerRefIssues( - parsedContainers ?? [], - tilesWithId, - ); - if (tileRefIssues.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Validation error: ${tileRefIssues.join('; ')}`, - }, - ], - }; - } - - // Hoist the source fetch so missing-source and heatmap-source-kind - // checks share a single DB round-trip, mirroring the REST POST path. - const [ - sources, - missingConnections, - missingOnClickDashboards, - invalidOnClickSearchSources, - ] = await Promise.all([ - fetchSourcesForValidation(teamId), - getMissingConnections(teamId, tilesWithId), - getMissingOnClickDashboards(teamId, tilesWithId), - getInvalidOnClickSearchSources(teamId, tilesWithId), - ]); - - const missingSources = getMissingSources(sources, tilesWithId, filters); - if (missingSources.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Could not find source IDs: ${missingSources.join(', ')}`, - }, - ], - }; - } - if (missingConnections.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Could not find connection IDs: ${missingConnections.join(', ')}`, - }, - ], - }; - } - - // Source-kind gate for heatmap tiles. Mirrors the REST POST path so - // an MCP-issued create cannot persist a heatmap that the REST PUT - // would reject with 400 on the next round-trip. - const heatmapNonTraceSources = getHeatmapTilesWithIncompatibleSources( - sources, - tilesWithId, - ); - if (heatmapNonTraceSources.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`, - }, - ], - }; - } - - // Validate that a table tile's row-click will not land on a - // missing dashboard or a non-log/trace source. - if (missingOnClickDashboards.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`, - }, - ], - }; - } - if (invalidOnClickSearchSources.length > 0) { + const validationError = await validateDashboardTiles({ + teamId, + tiles: tilesWithId, + filters, + containers: parsedContainers ?? [], + }); + if (validationError) { return { isError: true, - content: [ - { - type: 'text' as const, - text: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join(', ')}`, - }, - ], + content: [{ type: 'text' as const, text: validationError }], }; } @@ -311,7 +216,7 @@ async function createDashboard({ ...(frontendUrl ? { url: `${frontendUrl}/dashboards/${newDashboard._id}` } : {}), - hint: 'Use hyperdx_query_tile to test individual tile queries before viewing the dashboard.', + hint: 'Use clickstack_query_tile to test individual tile queries before viewing the dashboard.', }, null, 2, @@ -373,53 +278,10 @@ async function updateDashboard({ const { tiles, filters, containers: parsedContainers } = parsed.data; const tilesWithId = tiles as ExternalDashboardTileWithId[]; - // Hoist sources, connections, and existing-dashboard fetches into a - // single Promise.all so the source list is shared across helpers and - // the heatmap source-kind check can scope itself to tiles whose - // sourceId/displayType actually changed in this request, matching - // the REST PUT path. `containers` is in the projection so the - // container/tab ref check can fall back to the persisted containers - // when the payload omits them. - const [ - sources, - missingConnections, - missingOnClickDashboards, - invalidOnClickSearchSources, - existingDashboard, - ] = await Promise.all([ - fetchSourcesForValidation(teamId), - getMissingConnections(teamId, tilesWithId), - getMissingOnClickDashboards(teamId, tilesWithId), - getInvalidOnClickSearchSources(teamId, tilesWithId), - Dashboard.findOne( - { _id: dashboardId, team: teamId }, - { tiles: 1, filters: 1, containers: 1 }, - ).lean(), - ]); - - const missingSources = getMissingSources(sources, tilesWithId, filters); - if (missingSources.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Could not find source IDs: ${missingSources.join(', ')}`, - }, - ], - }; - } - if (missingConnections.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Could not find connection IDs: ${missingConnections.join(', ')}`, - }, - ], - }; - } + const existingDashboard = await Dashboard.findOne( + { _id: dashboardId, team: teamId }, + { tiles: 1, filters: 1, containers: 1 }, + ).lean(); if (!existingDashboard) { return { @@ -428,70 +290,19 @@ async function updateDashboard({ }; } - // Mirror the v2 router PUT handler: resolve tile container/tab refs - // against an effective container set so a payload that omits the - // `containers` field falls back to the persisted dashboard rather - // than an empty fallback. Otherwise a tile pointing at a preserved - // container would be rejected with "Tile references unknown - // containerId". const effectiveContainers = parsedContainers ?? existingDashboard.containers ?? []; - const tileRefIssues = collectTileContainerRefIssues( - effectiveContainers, - tilesWithId, - ); - if (tileRefIssues.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Validation error: ${tileRefIssues.join('; ')}`, - }, - ], - }; - } - - // Source-kind gate, scoped to heatmap tiles whose source or - // displayType changed in this request. Mirrors the REST PUT path. - const heatmapNonTraceSources = getHeatmapTilesWithIncompatibleSources( - sources, - filterChangedHeatmapTiles(tilesWithId, existingDashboard.tiles ?? []), - ); - if (heatmapNonTraceSources.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`, - }, - ], - }; - } - - // Validate that a table tile's row-click will not land on a - // missing dashboard or a non-log/trace source. - if (missingOnClickDashboards.length > 0) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`, - }, - ], - }; - } - if (invalidOnClickSearchSources.length > 0) { + const validationError = await validateDashboardTiles({ + teamId, + tiles: tilesWithId, + filters, + existingTiles: existingDashboard.tiles ?? [], + containers: effectiveContainers, + }); + if (validationError) { return { isError: true, - content: [ - { - type: 'text' as const, - text: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join(', ')}`, - }, - ], + content: [{ type: 'text' as const, text: validationError }], }; } @@ -570,7 +381,7 @@ async function updateDashboard({ ...(frontendUrl ? { url: `${frontendUrl}/dashboards/${updatedDashboard._id}` } : {}), - hint: 'Use hyperdx_query_tile to test individual tile queries before viewing the dashboard.', + hint: 'Use clickstack_query_tile to test individual tile queries before viewing the dashboard.', }, null, 2, diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 8a0fc5e8d1..e8c25b8997 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -1,6 +1,6 @@ // prose-lint: allow-file // MCP tool descriptions intentionally use the established en-dash -// separator (e.g. "Source ID – call hyperdx_list_sources") for LLM +// separator (e.g. "Source ID – call clickstack_list_sources") for LLM // readability. Reformatting all separators is out of scope here. import { AggregateFunctionSchema, @@ -174,8 +174,8 @@ const mcpOnClickTargetSchema = z .min(1) .describe( 'Concrete source ID (for type=search) or dashboard ID (for type=dashboard). ' + - 'Get source IDs from hyperdx_list_sources; get dashboard IDs from ' + - 'hyperdx_get_dashboard (no id arg returns the list). ' + + 'Get source IDs from clickstack_list_sources; get dashboard IDs from ' + + 'clickstack_get_dashboard (no id arg returns the list). ' + 'For type=search the source kind must be "log" or "trace"; the /search ' + 'page does not render metric/session sources.', ), @@ -245,7 +245,9 @@ const mcpOnClickSearchSchema = z const mcpOnClickDashboardSchema = z .object({ - type: z.literal('dashboard').describe('Link to another HyperDX dashboard.'), + type: z + .literal('dashboard') + .describe('Link to another ClickStack dashboard.'), target: mcpOnClickTargetSchema, whereTemplate: z .string() @@ -309,7 +311,7 @@ const mcpOnClickSchema = z ); const mcpTileLayoutSchema = z.object({ - name: z.string().describe('Tile title shown on the dashboard'), + name: z.string().min(1).describe('Tile title shown on the dashboard'), x: z .number() .min(0) @@ -366,7 +368,7 @@ const mcpTileLayoutSchema = z.object({ const mcpLineTileSchema = mcpTileLayoutSchema.extend({ config: z.object({ displayType: z.literal('line').describe('Line chart over time'), - sourceId: z.string().describe('Source ID – call hyperdx_list_sources'), + sourceId: z.string().describe('Source ID – call clickstack_list_sources'), select: z .array(mcpTileSelectItemSchema) .min(1) @@ -397,7 +399,7 @@ const mcpBarTileSchema = mcpTileLayoutSchema.extend({ displayType: z .literal('stacked_bar') .describe('Stacked bar chart over time'), - sourceId: z.string().describe('Source ID – call hyperdx_list_sources'), + sourceId: z.string().describe('Source ID – call clickstack_list_sources'), select: z.array(mcpTileSelectItemSchema).min(1).max(20), groupBy: z.string().optional(), fillNulls: z.boolean().optional().default(true), @@ -409,7 +411,7 @@ const mcpBarTileSchema = mcpTileLayoutSchema.extend({ const mcpTableTileSchema = mcpTileLayoutSchema.extend({ config: z.object({ displayType: z.literal('table').describe('Tabular aggregated data'), - sourceId: z.string().describe('Source ID – call hyperdx_list_sources'), + sourceId: z.string().describe('Source ID – call clickstack_list_sources'), select: z.array(mcpTileSelectItemSchema).min(1).max(20), groupBy: z .string() @@ -444,7 +446,7 @@ const mcpTableTileSchema = mcpTileLayoutSchema.extend({ const mcpNumberTileSchema = mcpTileLayoutSchema.extend({ config: z.object({ displayType: z.literal('number').describe('Single aggregate scalar value'), - sourceId: z.string().describe('Source ID – call hyperdx_list_sources'), + sourceId: z.string().describe('Source ID – call clickstack_list_sources'), select: z .array(mcpTileSelectItemSchema) .length(1) @@ -461,7 +463,7 @@ const mcpNumberTileSchema = mcpTileLayoutSchema.extend({ const mcpPieTileSchema = mcpTileLayoutSchema.extend({ config: z.object({ displayType: z.literal('pie').describe('Pie chart'), - sourceId: z.string().describe('Source ID – call hyperdx_list_sources'), + sourceId: z.string().describe('Source ID – call clickstack_list_sources'), select: z.array(mcpTileSelectItemSchema).length(1), groupBy: z .string() @@ -512,7 +514,7 @@ const mcpHeatmapTileSchema = mcpTileLayoutSchema.extend({ sourceId: z .string() .describe( - 'Source ID. Must be a Trace source today; use hyperdx_list_sources and ' + + 'Source ID. Must be a Trace source today; use clickstack_list_sources and ' + 'pick one whose kind is "trace".', ), select: z @@ -539,7 +541,7 @@ const mcpHeatmapTileSchema = mcpTileLayoutSchema.extend({ const mcpSearchTileSchema = mcpTileLayoutSchema.extend({ config: z.object({ displayType: z.literal('search').describe('Log/event search results list'), - sourceId: z.string().describe('Source ID – call hyperdx_list_sources'), + sourceId: z.string().describe('Source ID – call clickstack_list_sources'), where: z .string() .optional() @@ -577,7 +579,7 @@ const mcpSqlTileSchema = mcpTileLayoutSchema.extend({ connectionId: z .string() .describe( - 'Connection ID (not sourceId) – call hyperdx_list_sources to find available connections', + 'Connection ID (not sourceId) – call clickstack_list_sources to find available connections', ), sqlTemplate: z .string() @@ -610,11 +612,51 @@ const mcpTileSchema = z.union([ mcpSqlTileSchema, ]); +// Layout schema without defaults for the patch tool: layout fields stay +// truly `undefined` when omitted so the merge logic can fall back to the +// existing tile's values instead of Zod filling in 0/12/4. +const mcpPatchTileLayoutSchema = z.object({ + name: z + .string() + .min(1) + .optional() + .describe('Tile title. Omit to keep the existing title.'), + x: z.number().min(0).max(23).optional(), + y: z.number().min(0).optional(), + w: z.number().min(1).max(24).optional(), + h: z.number().min(1).optional(), + containerId: z.string().min(1).max(DASHBOARD_CONTAINER_ID_MAX).optional(), + tabId: z.string().min(1).max(DASHBOARD_CONTAINER_ID_MAX).optional(), +}); + +// Build the patch tile union by extending the default-free layout with +// each tile type's config shape. We only need the `config` field from +// each tile schema; the layout wrapper is replaced. +const mcpPatchTileSchema = z.union([ + mcpPatchTileLayoutSchema.extend({ config: mcpLineTileSchema.shape.config }), + mcpPatchTileLayoutSchema.extend({ config: mcpBarTileSchema.shape.config }), + mcpPatchTileLayoutSchema.extend({ config: mcpTableTileSchema.shape.config }), + mcpPatchTileLayoutSchema.extend({ + config: mcpNumberTileSchema.shape.config, + }), + mcpPatchTileLayoutSchema.extend({ config: mcpPieTileSchema.shape.config }), + mcpPatchTileLayoutSchema.extend({ + config: mcpHeatmapTileSchema.shape.config, + }), + mcpPatchTileLayoutSchema.extend({ + config: mcpSearchTileSchema.shape.config, + }), + mcpPatchTileLayoutSchema.extend({ + config: mcpMarkdownTileSchema.shape.config, + }), + mcpPatchTileLayoutSchema.extend({ config: mcpSqlTileSchema.shape.config }), +]); + export const mcpTilesParam = z .array(mcpTileSchema) .describe( 'Array of dashboard tiles. Each tile needs a name, optional layout (x/y/w/h), and a config block. ' + - 'The config block varies by displayType – use hyperdx_list_sources for sourceId and connectionId values.\n\n' + + 'The config block varies by displayType – use clickstack_list_sources for sourceId and connectionId values.\n\n' + 'Example tiles:\n' + '1. Line chart: { "name": "Error Rate", "config": { "displayType": "line", "sourceId": "", ' + '"groupBy": "ResourceAttributes[\'service.name\']", "select": [{ "aggFn": "count", "where": "StatusCode:STATUS_CODE_ERROR" }] } }\n' + @@ -640,7 +682,7 @@ const mcpDashboardFilterSchema = z .describe( 'Filter identity. ' + 'On UPDATE of an existing dashboard, every filter in the array MUST carry ' + - 'an id: pass the exact id returned by hyperdx_get_dashboard for any filter ' + + 'an id: pass the exact id returned by clickstack_get_dashboard for any filter ' + 'you are keeping (so saved values bound to it stay attached), and generate ' + 'a fresh random hex/ObjectId string for any filter you are adding in this ' + 'update. Omitting `id` on an existing filter would orphan its saved values; ' + @@ -670,7 +712,7 @@ const mcpDashboardFilterSchema = z ), sourceId: objectIdSchema.describe( 'Source the filter values are pulled from (for the dropdown). ' + - 'Get IDs from hyperdx_list_sources.', + 'Get IDs from clickstack_list_sources.', ), sourceMetricType: z .nativeEnum(MetricsDataType) @@ -733,6 +775,49 @@ export const mcpFiltersParam = z ']', ); +export const mcpPatchDashboardSchema = z.object({ + dashboardId: z.string().describe('Dashboard ID.'), + name: z + .string() + .min(1) + .optional() + .describe('New dashboard name. Omit to keep the current name.'), + tags: z + .array(z.string()) + .optional() + .describe( + 'New tags array (replaces all existing tags). Omit to keep the current tags.', + ), + tileId: z + .string() + .optional() + .describe( + 'ID of the tile to replace. Must be paired with `tile`. ' + + 'Obtain tile IDs from clickstack_get_dashboard.', + ), + tile: mcpPatchTileSchema + .optional() + .describe( + 'The full replacement tile definition. Replaces the tile matched by tileId. ' + + 'Layout fields (x, y, w, h), name, and containerId/tabId default to the ' + + "existing tile's values when omitted, so you only need to specify what changed.", + ), +}); + +export const mcpSearchDashboardsSchema = z.object({ + query: z + .string() + .max(200) + .optional() + .describe( + 'Search term to match against dashboard names (case-insensitive substring match).', + ), + tags: z + .array(z.string().min(1)) + .optional() + .describe('Filter to dashboards that have ALL of these tags.'), +}); + export const mcpContainersParam = z .array(DashboardContainerSchema) .max(DASHBOARD_MAX_CONTAINERS) diff --git a/packages/api/src/mcp/tools/dashboards/searchDashboards.ts b/packages/api/src/mcp/tools/dashboards/searchDashboards.ts new file mode 100644 index 0000000000..fa77915fd3 --- /dev/null +++ b/packages/api/src/mcp/tools/dashboards/searchDashboards.ts @@ -0,0 +1,115 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { escapeRegExp } from 'lodash'; + +import * as config from '@/config'; +import Dashboard from '@/models/dashboard'; +import logger from '@/utils/logger'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; +import { mcpSearchDashboardsSchema } from './schemas'; + +const SEARCH_RESULTS_LIMIT = 100; + +export function registerSearchDashboards( + server: McpServer, + context: McpContext, +): void { + const { teamId } = context; + const frontendUrl = config.FRONTEND_URL; + + server.registerTool( + 'clickstack_search_dashboards', + { + title: 'Search Dashboards', + description: + 'Search dashboards by name and/or tags. Returns matching dashboards with ' + + 'their IDs, names, and tags. More targeted than clickstack_get_dashboard (which ' + + 'lists all dashboards). At least one of query or tags must be provided.', + inputSchema: mcpSearchDashboardsSchema, + }, + withToolTracing( + 'clickstack_search_dashboards', + context, + async ({ query, tags }) => { + const hasQuery = typeof query === 'string' && query.length > 0; + const hasTags = Array.isArray(tags) && tags.length > 0; + + if (!hasQuery && !hasTags) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Provide at least one of: query (non-empty string) or tags (non-empty array).', + }, + ], + }; + } + + const filter: Record = { team: teamId }; + + if (hasQuery) { + filter.name = { $regex: escapeRegExp(query), $options: 'i' }; + } + if (hasTags) { + filter.tags = { $all: tags }; + } + + try { + const dashboards = await Dashboard.find(filter) + .select({ name: 1, tags: 1 }) + .limit(SEARCH_RESULTS_LIMIT + 1) + .lean(); + + const truncated = dashboards.length > SEARCH_RESULTS_LIMIT; + const results = truncated + ? dashboards.slice(0, SEARCH_RESULTS_LIMIT) + : dashboards; + + const output = results.map(d => ({ + id: d._id.toString(), + name: d.name, + tags: d.tags, + ...(frontendUrl + ? { url: `${frontendUrl}/dashboards/${d._id}` } + : {}), + })); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + truncated + ? { + results: output, + truncated: true, + hint: `Returned first ${SEARCH_RESULTS_LIMIT} results. Narrow your query or tags to see more.`, + } + : output, + null, + 2, + ), + }, + ], + }; + } catch (err) { + logger.error( + { err, teamId, query, tags }, + 'clickstack_search_dashboards: query failed', + ); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Search failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + }; + } + }, + ), + ); +} diff --git a/packages/api/src/mcp/tools/query/eventDeltas.ts b/packages/api/src/mcp/tools/query/eventDeltas.ts index f5ded14674..7ffdca1570 100644 --- a/packages/api/src/mcp/tools/query/eventDeltas.ts +++ b/packages/api/src/mcp/tools/query/eventDeltas.ts @@ -47,7 +47,7 @@ const deltasSchema = z.object({ .string() .describe( 'Source ID. Works for trace and log sources. ' + - 'Call hyperdx_list_sources for available sources.', + 'Call clickstack_list_sources for available sources.', ), target: groupSchema.describe( 'Target ("outlier") group — the rows you suspect are different (e.g. the ' + @@ -140,7 +140,7 @@ export function registerEventDeltas(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_event_deltas', + 'clickstack_event_deltas', { title: 'Compare Events: Target vs Baseline', description: @@ -177,9 +177,9 @@ export function registerEventDeltas(server: McpServer, context: McpContext) { ' Any pair of row sets over the same source works — the tool just ' + ' asks "what is statistically different about target vs baseline".\n\n' + 'WHEN NOT TO USE: when the question is already known to be about a ' + - 'specific attribute (use hyperdx_table groupBy), when you want raw ' + - 'rows (use hyperdx_search), or when you need a time-series shape ' + - '(use hyperdx_timeseries).\n\n' + + 'specific attribute (use clickstack_table groupBy), when you want raw ' + + 'rows (use clickstack_search), or when you need a time-series shape ' + + '(use clickstack_timeseries).\n\n' + 'OUTPUT SHAPE: an array of properties, each with rank, key, score, ' + 'semanticBoost (true for well-known OTel attrs like service.name / ' + 'http.method / error.type / status), targetCount and baselineCount ' + @@ -197,7 +197,7 @@ export function registerEventDeltas(server: McpServer, context: McpContext) { inputSchema: deltasSchema, }, withToolTracing( - 'hyperdx_event_deltas', + 'clickstack_event_deltas', context, async (input: DeltasInput) => { const targetRange = parseTimeRange( @@ -272,7 +272,7 @@ export function registerEventDeltas(server: McpServer, context: McpContext) { content: [ { type: 'text' as const, - text: `Source not found: ${input.sourceId}. Call hyperdx_list_sources to find available source IDs.`, + text: `Source not found: ${input.sourceId}. Call clickstack_list_sources to find available source IDs.`, }, ], }; @@ -286,7 +286,7 @@ export function registerEventDeltas(server: McpServer, context: McpContext) { content: [ { type: 'text' as const, - text: `Source ${input.sourceId} is kind="${source.kind}". hyperdx_event_deltas requires a trace or log source.`, + text: `Source ${input.sourceId} is kind="${source.kind}". clickstack_event_deltas requires a trace or log source.`, }, ], }; @@ -511,7 +511,7 @@ export function registerEventDeltas(server: McpServer, context: McpContext) { ? { warning: 'One or both groups returned 0 rows — verify the time ' + - 'window and where filter via hyperdx_search.', + 'window and where filter via clickstack_search.', } : {}), }, diff --git a/packages/api/src/mcp/tools/query/eventPatterns.ts b/packages/api/src/mcp/tools/query/eventPatterns.ts index c3da47cca7..a2a85f3d0f 100644 --- a/packages/api/src/mcp/tools/query/eventPatterns.ts +++ b/packages/api/src/mcp/tools/query/eventPatterns.ts @@ -66,7 +66,7 @@ export function registerEventPatterns(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_event_patterns', + 'clickstack_event_patterns', { title: 'Event Pattern Mining', description: @@ -76,15 +76,15 @@ export function registerEventPatterns(server: McpServer, context: McpContext) { 'Use this when asked about "top patterns", "common logs", "noisy services", ' + '"recurring messages", or log noise analysis.\n\n' + 'Each pattern includes a "whereSnippet" — use it as the "where" parameter in ' + - 'a follow-up hyperdx_search call to browse matching raw events.\n\n' + - 'Requires sourceId — call hyperdx_list_sources then hyperdx_describe_source first.\n\n' + + 'a follow-up clickstack_search call to browse matching raw events.\n\n' + + 'Requires sourceId — call clickstack_list_sources then clickstack_describe_source first.\n\n' + 'When to use which tool:\n' + - ' - hyperdx_event_patterns: clustering / recurring shapes / noise analysis\n' + - ' - hyperdx_search: raw individual rows\n' + - ' - hyperdx_table: aggregated metrics / counts / top-N', + ' - clickstack_event_patterns: clustering / recurring shapes / noise analysis\n' + + ' - clickstack_search: raw individual rows\n' + + ' - clickstack_table: aggregated metrics / counts / top-N', inputSchema: eventPatternsSchema, }, - withToolTracing('hyperdx_event_patterns', context, async input => { + withToolTracing('clickstack_event_patterns', context, async input => { const timeRange = parseTimeRange(input.startTime, input.endTime); if ('error' in timeRange) { return { diff --git a/packages/api/src/mcp/tools/query/helpers.ts b/packages/api/src/mcp/tools/query/helpers.ts index 51f83f2d80..7fbd3d200a 100644 --- a/packages/api/src/mcp/tools/query/helpers.ts +++ b/packages/api/src/mcp/tools/query/helpers.ts @@ -216,7 +216,7 @@ export async function runConfigTile( content: [ { type: 'text' as const, - text: `Source not found: ${builderConfig.source}. Call hyperdx_list_sources to discover available source IDs.`, + text: `Source not found: ${builderConfig.source}. Call clickstack_list_sources to discover available source IDs.`, }, ], }; @@ -339,7 +339,7 @@ export async function runConfigTile( content: [ { type: 'text' as const, - text: `Connection not found: ${savedConfig.connection}. Call hyperdx_list_sources to discover available connection IDs.`, + text: `Connection not found: ${savedConfig.connection}. Call clickstack_list_sources to discover available connection IDs.`, }, ], }; diff --git a/packages/api/src/mcp/tools/query/runEventPatterns.ts b/packages/api/src/mcp/tools/query/runEventPatterns.ts index 1e11df8a37..47f757cea6 100644 --- a/packages/api/src/mcp/tools/query/runEventPatterns.ts +++ b/packages/api/src/mcp/tools/query/runEventPatterns.ts @@ -71,7 +71,7 @@ export async function runEventPatterns( content: [ { type: 'text' as const, - text: `Source not found: ${sourceId}. Call hyperdx_list_sources to discover available source IDs.`, + text: `Source not found: ${sourceId}. Call clickstack_list_sources to discover available source IDs.`, }, ], }; @@ -88,7 +88,7 @@ export async function runEventPatterns( content: [ { type: 'text' as const, - text: `Connection not found for source: ${sourceId}. Call hyperdx_list_sources to discover available source IDs.`, + text: `Connection not found for source: ${sourceId}. Call clickstack_list_sources to discover available source IDs.`, }, ], }; @@ -286,7 +286,7 @@ export async function runEventPatterns( // ── Format response ── // Convert trend timestamps to ISO strings, extract sample body texts, // and build a whereSnippet per pattern so the agent can drill into - // matching events via a follow-up hyperdx_search query. + // matching events via a follow-up clickstack_search query. const sampledCount = sampleRows.length; const slicedPatterns = rawPatterns.slice(0, topN); @@ -349,7 +349,7 @@ export async function runEventPatterns( (trendBuckets > 0 ? 'trend.count is similarly extrapolated from sample bucket counts. ' : '') + - 'Use whereSnippet as the "where" parameter in a hyperdx_search call to browse matching raw events.', + 'Use whereSnippet as the "where" parameter in a clickstack_search call to browse matching raw events.', }; const { data: trimmedOutput, isTrimmed } = trimToolResponse(output); diff --git a/packages/api/src/mcp/tools/query/schemas.ts b/packages/api/src/mcp/tools/query/schemas.ts index 217a84c30d..19abd9c71c 100644 --- a/packages/api/src/mcp/tools/query/schemas.ts +++ b/packages/api/src/mcp/tools/query/schemas.ts @@ -13,7 +13,10 @@ const WHERE_DESCRIPTION = ' Lucene uses DOT notation: SpanAttributes.http.method:GET\n' + " SQL uses BRACKET notation: SpanAttributes['http.method'] = 'GET'\n\n" + "WRONG: SpanAttributes['key']:value (Lucene cannot parse bracket syntax)\n" + - 'WRONG: level = "error" (SQL syntax with whereLanguage:"lucene")'; + 'WRONG: level = "error" (SQL syntax with whereLanguage:"lucene")\n\n' + + 'SUBSTRING TRAP: Lucene field:value matches ANY row containing "value" as a substring, not exact equality.\n' + + ' SpanKind:Server matches "Server", "ServerStreaming", "InternalServer", etc.\n' + + " For exact match, use SQL: SpanKind = 'Server'"; const WHERE_LANGUAGE_DESCRIPTION = 'Query language for the "where" filter. Default: lucene.\n' + @@ -21,8 +24,10 @@ const WHERE_LANGUAGE_DESCRIPTION = ' Lucene (default): Column:value, Column.mapKey:value, Column:>100\n' + " SQL: Column = 'value', SpanAttributes['key'] = 'value'\n\n" + 'Lucene supports comparisons (>= > < <=), wildcards (field:val*), ranges ([1 TO 5]), ' + - 'and map attributes via dot notation. ' + - 'Use "sql" for IN(...) lists, complex expressions, or function calls.\n\n' + + 'and map attributes via dot notation. Use "sql" for IN(...) lists, complex expressions, or function calls.\n' + + 'IMPORTANT: Lucene field:value is a SUBSTRING match (ilike), not exact equality. ' + + 'field:val* is prefix-within-substring, not a true prefix match. ' + + "For exact matching or reliable wildcards, use SQL: WHERE field = 'value' or WHERE field LIKE 'val%'.\n\n" + 'Common mistake: writing Column:value (Lucene) but setting whereLanguage to "sql". ' + 'If your filter uses colon syntax, leave whereLanguage as "lucene" (the default).'; @@ -125,7 +130,7 @@ export const endTimeSchema = z export const sourceIdSchema = z .string() .describe( - 'Source ID (required). Call hyperdx_list_sources to find available sources.', + 'Source ID (required). Call clickstack_list_sources to find available sources.', ); export const whereSchema = z diff --git a/packages/api/src/mcp/tools/query/search.ts b/packages/api/src/mcp/tools/query/search.ts index 272a757a54..624639ffd7 100644 --- a/packages/api/src/mcp/tools/query/search.ts +++ b/packages/api/src/mcp/tools/query/search.ts @@ -46,21 +46,21 @@ export function registerSearch(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_search', + 'clickstack_search', { title: 'Search Events', description: 'Browse individual log/event/trace rows. ' + 'Use this when you need to see raw events, investigate specific log lines, ' + 'or drill into individual records matching a filter.\n\n' + - 'Requires sourceId — call hyperdx_list_sources then hyperdx_describe_source first.\n\n' + - 'For aggregated metrics, use hyperdx_table instead. ' + - 'For pattern discovery, use hyperdx_event_patterns instead.\n\n' + + 'Requires sourceId — call clickstack_list_sources then clickstack_describe_source first.\n\n' + + 'For aggregated metrics, use clickstack_table instead. ' + + 'For pattern discovery, use clickstack_event_patterns instead.\n\n' + 'Column naming: top-level columns are PascalCase (Duration, StatusCode). ' + "Map attributes use bracket syntax: SpanAttributes['http.method'].", inputSchema: searchSchema, }, - withToolTracing('hyperdx_search', context, async input => { + withToolTracing('clickstack_search', context, async input => { const timeRange = parseTimeRange(input.startTime, input.endTime); if ('error' in timeRange) { return { diff --git a/packages/api/src/mcp/tools/query/sql.ts b/packages/api/src/mcp/tools/query/sql.ts index c6a096675c..4e7fede713 100644 --- a/packages/api/src/mcp/tools/query/sql.ts +++ b/packages/api/src/mcp/tools/query/sql.ts @@ -13,7 +13,7 @@ const sqlSchema = z.object({ .string() .describe( 'Connection ID (required). This is the only split tool that needs a connectionId ' + - 'instead of sourceId. Call hyperdx_list_sources to find available connections.', + 'instead of sourceId. Call clickstack_list_sources to find available connections.', ), sql: z .string() @@ -56,23 +56,23 @@ export function registerSql(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_sql', + 'clickstack_sql', { title: 'Raw SQL Query', description: 'Execute raw ClickHouse SQL. ' + 'ADVANCED: only use this when you need capabilities the builder tools cannot express — ' + 'JOINs, sub-queries, CTEs, or querying tables not registered as sources.\n\n' + - 'Requires connectionId (not sourceId) — call hyperdx_list_sources to find connections. ' + - 'Call hyperdx_describe_source to discover column names before writing SQL.\n\n' + + 'Requires connectionId (not sourceId) — call clickstack_list_sources to find connections. ' + + 'Call clickstack_describe_source to discover column names before writing SQL.\n\n' + 'Results are always returned as table rows — for time-series semantics, ' + 'include a time column and ORDER BY it in your SQL.\n\n' + - 'For standard aggregations use hyperdx_table. ' + - 'For time-series charts use hyperdx_timeseries. ' + - 'For browsing rows use hyperdx_search.', + 'For standard aggregations use clickstack_table. ' + + 'For time-series charts use clickstack_timeseries. ' + + 'For browsing rows use clickstack_search.', inputSchema: sqlSchema, }, - withToolTracing('hyperdx_sql', context, async input => { + withToolTracing('clickstack_sql', context, async input => { const timeRange = parseTimeRange(input.startTime, input.endTime); if ('error' in timeRange) { return { diff --git a/packages/api/src/mcp/tools/query/table.ts b/packages/api/src/mcp/tools/query/table.ts index cf1e15cb03..41b7e44f0b 100644 --- a/packages/api/src/mcp/tools/query/table.ts +++ b/packages/api/src/mcp/tools/query/table.ts @@ -154,14 +154,14 @@ export function registerTable(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_table', + 'clickstack_table', { title: 'Aggregation Table', description: 'Compute aggregated metrics as a table, single number, or pie chart. ' + 'Use this for grouped aggregations, top-N queries, single-value KPIs, ' + 'or proportional breakdowns.\n\n' + - 'Requires sourceId — call hyperdx_list_sources then hyperdx_describe_source first.\n\n' + + 'Requires sourceId — call clickstack_list_sources then clickstack_describe_source first.\n\n' + 'Use the top-level "where" to scope the entire query (e.g. filter by service). ' + 'Each select item can also have its own "where" for per-metric cohort ' + 'comparisons (compiles to If(...)). Both can be used together.\n\n' + @@ -173,7 +173,7 @@ export function registerTable(server: McpServer, context: McpContext) { 'it is transparently upgraded to "table".', inputSchema: tableSchema, }, - withToolTracing('hyperdx_table', context, async input => { + withToolTracing('clickstack_table', context, async input => { const timeRange = parseTimeRange(input.startTime, input.endTime); if ('error' in timeRange) { return { diff --git a/packages/api/src/mcp/tools/query/timeseries.ts b/packages/api/src/mcp/tools/query/timeseries.ts index 190ae2f58c..283effb48a 100644 --- a/packages/api/src/mcp/tools/query/timeseries.ts +++ b/packages/api/src/mcp/tools/query/timeseries.ts @@ -69,20 +69,20 @@ export function registerTimeseries(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_timeseries', + 'clickstack_timeseries', { title: 'Time-Series Chart', description: 'Plot metrics over time as a line or stacked bar chart. ' + 'Use this when you need to visualize trends, compare time-series, ' + 'or monitor metric changes over a time window.\n\n' + - 'Requires sourceId — call hyperdx_list_sources then hyperdx_describe_source first. ' + + 'Requires sourceId — call clickstack_list_sources then clickstack_describe_source first. ' + 'Each select item defines one plotted series.\n\n' + 'Column naming: top-level columns are PascalCase (Duration, StatusCode). ' + "Map attributes use bracket syntax: SpanAttributes['http.method'].", inputSchema: timeseriesSchema, }, - withToolTracing('hyperdx_timeseries', context, async input => { + withToolTracing('clickstack_timeseries', context, async input => { const timeRange = parseTimeRange(input.startTime, input.endTime); if ('error' in timeRange) { return { diff --git a/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts b/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts index 90d33bb18b..663566c691 100644 --- a/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts +++ b/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts @@ -17,7 +17,7 @@ export function registerGetSavedSearch( const frontendUrl = config.FRONTEND_URL; server.registerTool( - 'hyperdx_get_saved_search', + 'clickstack_get_saved_search', { title: 'Get Saved Search(es)', description: @@ -34,7 +34,7 @@ export function registerGetSavedSearch( ), }), }, - withToolTracing('hyperdx_get_saved_search', context, async ({ id }) => { + withToolTracing('clickstack_get_saved_search', context, async ({ id }) => { // ── List all saved searches (slim query — only fetch the fields we need) ── if (!id) { const savedSearches = await SavedSearch.find( diff --git a/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts index e348eb21ff..b8f81020c2 100644 --- a/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts +++ b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts @@ -21,16 +21,16 @@ export function registerSaveSavedSearch( const frontendUrl = config.FRONTEND_URL; server.registerTool( - 'hyperdx_save_saved_search', + 'clickstack_save_saved_search', { title: 'Create or Update Saved Search', description: 'Create a new saved search (omit id) or update an existing one (provide id). ' + 'A saved search stores a reusable query against a data source. ' + - 'Use hyperdx_list_sources to find the sourceId.', + 'Use clickstack_list_sources to find the sourceId.', inputSchema: mcpSaveSavedSearchSchema, }, - withToolTracing('hyperdx_save_saved_search', context, async input => { + withToolTracing('clickstack_save_saved_search', context, async input => { const isUpdate = !!input.id; // ── Validate ID for updates ── diff --git a/packages/api/src/mcp/tools/savedSearches/schemas.ts b/packages/api/src/mcp/tools/savedSearches/schemas.ts index d3348be3d9..bd5bbccbb3 100644 --- a/packages/api/src/mcp/tools/savedSearches/schemas.ts +++ b/packages/api/src/mcp/tools/savedSearches/schemas.ts @@ -60,7 +60,7 @@ export const mcpSaveSavedSearchSchema = z.object({ sourceId: z .string() .describe( - 'Source ID — call hyperdx_list_sources to find available sources.', + 'Source ID — call clickstack_list_sources to find available sources.', ), tags: z .array(z.string()) diff --git a/packages/api/src/mcp/tools/sources/describeSource.ts b/packages/api/src/mcp/tools/sources/describeSource.ts index 8f2fefd5ee..a8249ae329 100644 --- a/packages/api/src/mcp/tools/sources/describeSource.ts +++ b/packages/api/src/mcp/tools/sources/describeSource.ts @@ -44,7 +44,7 @@ async function describeSourceSchema( content: [ { type: 'text' as const, - text: `Source "${sourceId}" not found. Call hyperdx_list_sources to see available source IDs.`, + text: `Source "${sourceId}" not found. Call clickstack_list_sources to see available source IDs.`, }, ], }; @@ -103,7 +103,7 @@ async function describeSourceSchema( { source: meta, nextSteps: { - query: `Use hyperdx_timeseries, hyperdx_table, or hyperdx_search with sourceId "${sourceId}" and the metric tables above.`, + query: `Use clickstack_timeseries, clickstack_table, or clickstack_search with sourceId "${sourceId}" and the metric tables above.`, }, }, null, @@ -291,7 +291,7 @@ async function describeSourceSchema( const lcValuesHint = skippedStages.length > 0 ? 'Value sampling was partially skipped due to timeout. ' + - 'The values shown may be incomplete — verify with a hyperdx_search query if needed.' + 'The values shown may be incomplete — verify with a clickstack_search query if needed.' : 'These are the REAL values in your data — use them in filters instead of guessing. ' + 'Example: where: "SeverityText:error" (if \'error\' appears in the sampled values above).'; @@ -305,7 +305,7 @@ async function describeSourceSchema( lowCardinalityValues: lcValuesHint, }, nextSteps: { - query: `Use hyperdx_timeseries, hyperdx_table, or hyperdx_search with sourceId "${sourceId}" and the columns/attributes above.`, + query: `Use clickstack_timeseries, clickstack_table, or clickstack_search with sourceId "${sourceId}" and the columns/attributes above.`, mapAttributeAccess: "Use bracket syntax for map columns: ResourceAttributes['service.name'], SpanAttributes['http.method']", }, @@ -335,14 +335,14 @@ export function registerDescribeSource( const { teamId } = context; server.registerTool( - 'hyperdx_describe_source', + 'clickstack_describe_source', { title: 'Describe Source Schema', description: 'CALL THIS BEFORE WRITING QUERIES — prevents unknown-column errors.\n\n' + 'Returns the full column schema, map-attribute keys, and sampled low-cardinality ' + 'values (e.g. SeverityText, StatusCode, ServiceName) for a single data source.\n\n' + - 'Workflow: call hyperdx_list_sources first to get source IDs, then call this tool ' + + 'Workflow: call clickstack_list_sources first to get source IDs, then call this tool ' + 'for each source you plan to query.\n\n' + 'Returns:\n' + '- columns[]: column name, ClickHouse type, and JS type\n' + @@ -356,12 +356,12 @@ export function registerDescribeSource( sourceId: z .string() .describe( - 'The source ID to describe. Get this from hyperdx_list_sources.', + 'The source ID to describe. Get this from clickstack_list_sources.', ), }), }, withToolTracing( - 'hyperdx_describe_source', + 'clickstack_describe_source', context, async ({ sourceId }) => { const controller = new AbortController(); @@ -388,7 +388,7 @@ export function registerDescribeSource( if (e instanceof Error && e.message === 'DESCRIBE_TIMEOUT') { logger.warn( { teamId, sourceId }, - 'hyperdx_describe_source timed out', + 'clickstack_describe_source timed out', ); return { isError: true, @@ -397,7 +397,7 @@ export function registerDescribeSource( type: 'text' as const, text: 'Schema discovery timed out. The ClickHouse server may be under load. ' + - 'Try again, or use hyperdx_list_sources for basic source info without schema details.', + 'Try again, or use clickstack_list_sources for basic source info without schema details.', }, ], }; diff --git a/packages/api/src/mcp/tools/sources/listSources.ts b/packages/api/src/mcp/tools/sources/listSources.ts index 7efe719d06..0c1e614689 100644 --- a/packages/api/src/mcp/tools/sources/listSources.ts +++ b/packages/api/src/mcp/tools/sources/listSources.ts @@ -15,21 +15,21 @@ export function registerListSources( const { teamId } = context; server.registerTool( - 'hyperdx_list_sources', + 'clickstack_list_sources', { title: 'List Sources & Connections', description: 'List all data sources (logs, metrics, traces) and database connections available to this team. ' + 'Returns source IDs, names, kinds, and connection IDs as a lightweight catalog.\n\n' + - 'NEXT STEP: After identifying the source(s) you need, call hyperdx_describe_source with the ' + + 'NEXT STEP: After identifying the source(s) you need, call clickstack_describe_source with the ' + 'sourceId to get the full column schema, attribute keys, and sampled values. ' + 'This two-step approach avoids fetching expensive schema details for sources you do not need.\n\n' + - 'NOTE: For most queries, use source IDs with hyperdx_timeseries, hyperdx_table, ' + - 'hyperdx_search, or hyperdx_event_patterns. ' + - 'Connection IDs are only needed for hyperdx_sql (raw ClickHouse SQL).', + 'NOTE: For most queries, use source IDs with clickstack_timeseries, clickstack_table, ' + + 'clickstack_search, or clickstack_event_patterns. ' + + 'Connection IDs are only needed for clickstack_sql (raw ClickHouse SQL).', inputSchema: z.object({}), }, - withToolTracing('hyperdx_list_sources', context, async () => { + withToolTracing('clickstack_list_sources', context, async () => { const [sources, connections] = await Promise.all([ getSources(teamId.toString()), getConnectionsByTeam(teamId.toString()), @@ -85,9 +85,9 @@ export function registerListSources( name: c.name, })), nextStep: - 'Call hyperdx_describe_source with a sourceId above to get the full column schema, ' + + 'Call clickstack_describe_source with a sourceId above to get the full column schema, ' + 'attribute keys, and sampled low-cardinality values before writing queries. ' + - 'connectionId is only needed for hyperdx_sql.', + 'connectionId is only needed for clickstack_sql.', }; return { content: [ diff --git a/packages/api/src/mcp/tools/trace/breakdown.ts b/packages/api/src/mcp/tools/trace/breakdown.ts index 7d4887ff01..0fd1a2f0d2 100644 --- a/packages/api/src/mcp/tools/trace/breakdown.ts +++ b/packages/api/src/mcp/tools/trace/breakdown.ts @@ -1,7 +1,7 @@ /** - * hyperdx_trace_top_time_consuming_operations + * clickstack_trace_top_time_consuming_operations * - * Aggregate counterpart to hyperdx_trace_waterfall. Given a parent-span + * Aggregate counterpart to clickstack_trace_waterfall. Given a parent-span * filter (a service + operation, optionally constrained to slow durations), * return the child operations consuming the most cumulative time across all * traces matching the parent filter — ranked by total_time_ms DESC. @@ -34,7 +34,7 @@ const traceBreakdownSchema = z.object({ .string() .describe( 'Trace source ID. Must be a source of kind="trace". ' + - 'Call hyperdx_list_sources to find available sources.', + 'Call clickstack_list_sources to find available sources.', ), parentFilter: z .string() @@ -102,7 +102,7 @@ export function registerTraceBreakdown(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', { title: 'Top Time-Consuming Operations Across Matching Traces', description: @@ -138,7 +138,7 @@ export function registerTraceBreakdown(server: McpServer, context: McpContext) { 'contained at least one such span), `p50_ms`, `p99_ms`. Plus a ' + '`summary` block with the matched-parent count.\n\n' + 'NEXT STEP after this tool: once a dominant slow child operation is ' + - 'identified, the canonical follow-up is hyperdx_event_deltas with ' + + 'identified, the canonical follow-up is clickstack_event_deltas with ' + 'slow-vs-fast spans of THAT child operation as target/baseline ' + "(target = {where: SpanName='' AND Duration > X}, " + "baseline = {where: SpanName='' AND Duration <= Y}). " + @@ -147,13 +147,13 @@ export function registerTraceBreakdown(server: McpServer, context: McpContext) { 'CROSS-SERVICE BREAKDOWN: this tool does NOT scope children to the ' + "parent's service. Slow cross-service calls (database, cache, " + 'upstream HTTP) surface naturally — useful for triage.\n\n' + - 'PAIR TOOL: hyperdx_trace_waterfall returns ONE concrete trace as a ' + + 'PAIR TOOL: clickstack_trace_waterfall returns ONE concrete trace as a ' + "parent/child tree. Use it for an example after this tool's " + 'aggregate breakdown has pointed you at the slow downstream operation.', inputSchema: traceBreakdownSchema, }, withToolTracing( - 'hyperdx_trace_top_time_consuming_operations', + 'clickstack_trace_top_time_consuming_operations', context, async (rawInput: TraceBreakdownInput) => { const input = traceBreakdownSchema.parse(rawInput); @@ -174,7 +174,7 @@ export function registerTraceBreakdown(server: McpServer, context: McpContext) { content: [ { type: 'text' as const, - text: `Source not found: ${input.sourceId}. Call hyperdx_list_sources to find available source IDs.`, + text: `Source not found: ${input.sourceId}. Call clickstack_list_sources to find available source IDs.`, }, ], }; @@ -185,7 +185,7 @@ export function registerTraceBreakdown(server: McpServer, context: McpContext) { content: [ { type: 'text' as const, - text: `Source ${input.sourceId} is kind="${source.kind}". hyperdx_trace_top_time_consuming_operations requires a source of kind="trace".`, + text: `Source ${input.sourceId} is kind="${source.kind}". clickstack_trace_top_time_consuming_operations requires a source of kind="trace".`, }, ], }; diff --git a/packages/api/src/mcp/tools/trace/waterfall.ts b/packages/api/src/mcp/tools/trace/waterfall.ts index b37354cac6..ba7293e2bc 100644 --- a/packages/api/src/mcp/tools/trace/waterfall.ts +++ b/packages/api/src/mcp/tools/trace/waterfall.ts @@ -23,7 +23,7 @@ const traceSchema = z.object({ .string() .describe( 'Trace source ID. Must be a source of kind "trace". ' + - 'Call hyperdx_list_sources to find available sources.', + 'Call clickstack_list_sources to find available sources.', ), traceId: z .string() @@ -169,7 +169,7 @@ export function registerTraceWaterfall(server: McpServer, context: McpContext) { const { teamId } = context; server.registerTool( - 'hyperdx_trace_waterfall', + 'clickstack_trace_waterfall', { title: 'Trace Waterfall (single trace)', description: @@ -179,7 +179,7 @@ export function registerTraceWaterfall(server: McpServer, context: McpContext) { 'the cascade for you instead of forcing the model to write self-JOINs in raw SQL.\n\n' + 'NOT THE RIGHT TOOL when the question is "where does the time go across MANY ' + 'slow traces of operation X" — that is the aggregate question, and the answer ' + - 'is hyperdx_trace_top_time_consuming_operations (called per affected ' + + 'is clickstack_trace_top_time_consuming_operations (called per affected ' + '(service, operation) with `minParentDurationMs`). Use this tool only when you ' + 'want a single concrete example to inspect, or after the aggregate breakdown ' + 'has already identified a suspicious operation.\n\n' + @@ -199,7 +199,7 @@ export function registerTraceWaterfall(server: McpServer, context: McpContext) { "Prefer this over running raw SQL with JOINs on TraceId — it uses the source's " + 'configured traceIdExpression / parentSpanIdExpression / spanIdExpression so ' + 'attribute extraction stays consistent with the rest of the platform.\n\n' + - 'PAIR TOOL: hyperdx_trace_top_time_consuming_operations is the aggregate ' + + 'PAIR TOOL: clickstack_trace_top_time_consuming_operations is the aggregate ' + 'counterpart — given a parent-span filter, it ranks child operations by total ' + 'time across MANY matching traces. Use that when the question is "where does ' + 'time go in slow X" (aggregate), and use THIS tool when the question is "show ' + @@ -207,7 +207,7 @@ export function registerTraceWaterfall(server: McpServer, context: McpContext) { inputSchema: traceSchema, }, withToolTracing( - 'hyperdx_trace_waterfall', + 'clickstack_trace_waterfall', context, async (rawInput: TraceInput) => { const input = traceSchema.parse(rawInput); @@ -227,7 +227,7 @@ export function registerTraceWaterfall(server: McpServer, context: McpContext) { content: [ { type: 'text' as const, - text: `Source not found: ${input.sourceId}. Call hyperdx_list_sources to find available source IDs.`, + text: `Source not found: ${input.sourceId}. Call clickstack_list_sources to find available source IDs.`, }, ], }; @@ -238,7 +238,7 @@ export function registerTraceWaterfall(server: McpServer, context: McpContext) { content: [ { type: 'text' as const, - text: `Source ${input.sourceId} is kind="${source.kind}". hyperdx_trace_waterfall requires a source of kind="trace".`, + text: `Source ${input.sourceId} is kind="${source.kind}". clickstack_trace_waterfall requires a source of kind="trace".`, }, ], }; diff --git a/packages/api/src/mcp/utils/tracing.ts b/packages/api/src/mcp/utils/tracing.ts index 38809bcd81..37f12870ec 100644 --- a/packages/api/src/mcp/utils/tracing.ts +++ b/packages/api/src/mcp/utils/tracing.ts @@ -5,7 +5,7 @@ import logger from '@/utils/logger'; import type { McpContext } from '../tools/types'; -const mcpTracer = opentelemetry.trace.getTracer('hyperdx-mcp', CODE_VERSION); +const mcpTracer = opentelemetry.trace.getTracer('clickstack-mcp', CODE_VERSION); type ToolResult = { content: { type: 'text'; text: string }[]; diff --git a/packages/api/src/models/user.ts b/packages/api/src/models/user.ts index b7067b20c3..8f60fadeb5 100644 --- a/packages/api/src/models/user.ts +++ b/packages/api/src/models/user.ts @@ -47,5 +47,6 @@ UserSchema.plugin(passportLocalMongoose, { }); UserSchema.index({ email: 1 }, { unique: true }); +UserSchema.index({ accessKey: 1 }, { unique: true }); export default mongoose.model('User', UserSchema); diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts index 4d8cbb7d44..3f9c075b23 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -2573,6 +2573,66 @@ describe('External API v2 Dashboards - new format', () => { ); }); + it('persists where as empty string when omitted from heatmap tile', async () => { + const heatmapNoWhere = { + name: 'Heatmap Without Where', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'heatmap', + sourceId: traceSource._id.toString(), + select: [{ valueExpression: 'Duration' }], + }, + }; + + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard Heatmap No Where', + tiles: [heatmapNoWhere], + tags: [], + }) + .expect(200); + + const dashboardInDb = await Dashboard.findById( + response.body.data.id, + ).lean(); + + expect((dashboardInDb!.tiles[0].config as any).where).toBe(''); + }); + + it('persists select-item where as empty string when omitted', async () => { + const lineChartNoWhere = { + name: 'Line Chart Without Where', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + }, + }; + + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard Line No Where', + tiles: [lineChartNoWhere], + tags: [], + }) + .expect(200); + + const dashboardInDb = await Dashboard.findById( + response.body.data.id, + ).lean(); + + expect( + (dashboardInDb!.tiles[0].config as any).select[0].aggCondition, + ).toBe(''); + }); + it('does not silently downgrade a corrupted heatmap to line on GET', async () => { // Seed a Dashboard directly via Mongo with a heatmap tile whose // select[0] lacks a non-empty valueExpression. The current API diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index cb3735ae82..9dd32fbead 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -4,28 +4,18 @@ import { z } from 'zod'; import { deleteDashboard } from '@/controllers/dashboard'; import Dashboard, { IDashboard } from '@/models/dashboard'; -import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; +import { processRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; import { ExternalDashboardTileWithId, objectIdSchema } from '@/utils/zod'; import { cleanupDashboardAlerts, - collectTileContainerRefIssues, convertExternalFiltersToInternal, convertExternalTilesToInternal, convertToExternalDashboard, createDashboardBodySchema, - fetchSourcesForValidation, - filterChangedHeatmapTiles, - getHeatmapTilesWithIncompatibleSources, - getInvalidOnClickSearchSources, - getMissingConnections, - getMissingOnClickDashboards, - getMissingSources, - isConfigTile, - isRawSqlExternalTileConfig, resolveSavedQueryLanguage, - SourceForValidation, updateDashboardBodySchema, + validateDashboardTiles, } from './utils/dashboards'; /** @@ -45,29 +35,6 @@ const EXTERNAL_DASHBOARD_PROJECTION = { containers: 1, } as const; -function getSourceConnectionMismatches( - sources: SourceForValidation[], - tiles: ExternalDashboardTileWithId[], -): string[] { - const sourceById = new Map(sources.map(s => [s._id.toString(), s])); - - const sourcesWithInvalidConnections: string[] = []; - for (const tile of tiles) { - if ( - isConfigTile(tile) && - isRawSqlExternalTileConfig(tile.config) && - tile.config.sourceId - ) { - const source = sourceById.get(tile.config.sourceId); - if (source && source.connection.toString() !== tile.config.connectionId) { - sourcesWithInvalidConnections.push(tile.config.sourceId); - } - } - } - - return sourcesWithInvalidConnections; -} - /** * @openapi * components: @@ -1922,90 +1889,14 @@ router.post( containers, } = req.body; - // Tile-level container/tab ref resolution: container structure - // (duplicate ids, tab uniqueness) was checked by the body schema; - // tile-resolution runs here against the request body's containers - // (POST has no existing dashboard to fall back to). - const tileRefIssues = collectTileContainerRefIssues( - containers ?? [], - tiles, - ); - if (tileRefIssues.length > 0) { - return res.status(400).json({ - message: tileRefIssues.join('; '), - }); - } - - // Hoist the source fetch so the source-derived validators - // (missing sources, source/connection mismatch, heatmap source - // kind) reuse one query result instead of each re-running - // `getSources(team)`. onClick helpers stay separate because - // they own their own dashboard lookup as well as a sources - // lookup; firing them inside the same Promise.all keeps the - // request latency unchanged. - const [ - sources, - missingConnections, - missingOnClickDashboards, - invalidOnClickSearchSources, - ] = await Promise.all([ - fetchSourcesForValidation(teamId), - getMissingConnections(teamId, tiles), - getMissingOnClickDashboards(teamId, tiles), - getInvalidOnClickSearchSources(teamId, tiles), - ]); - - const missingSources = getMissingSources(sources, tiles, filters); - const sourceConnectionMismatches = getSourceConnectionMismatches( - sources, + const validationError = await validateDashboardTiles({ + teamId: teamId.toString(), tiles, - ); - const heatmapNonTraceSources = getHeatmapTilesWithIncompatibleSources( - sources, - tiles, - ); - - if (missingSources.length > 0) { - return res.status(400).json({ - message: `Could not find the following source IDs: ${missingSources.join( - ', ', - )}`, - }); - } - if (missingConnections.length > 0) { - return res.status(400).json({ - message: `Could not find the following connection IDs: ${missingConnections.join( - ', ', - )}`, - }); - } - if (sourceConnectionMismatches.length > 0) { - return res.status(400).json({ - message: `The following source IDs do not match the specified connections: ${sourceConnectionMismatches.join( - ', ', - )}`, - }); - } - if (heatmapNonTraceSources.length > 0) { - return res.status(400).json({ - message: `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join( - ', ', - )}`, - }); - } - if (missingOnClickDashboards.length > 0) { - return res.status(400).json({ - message: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join( - ', ', - )}`, - }); - } - if (invalidOnClickSearchSources.length > 0) { - return res.status(400).json({ - message: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join( - ', ', - )}`, - }); + filters, + containers: containers ?? [], + }); + if (validationError) { + return res.status(400).json({ message: validationError }); } const internalTiles = convertExternalTilesToInternal(tiles); @@ -2212,122 +2103,35 @@ router.put( containers, } = req.body ?? {}; - // Hoist the source fetch and the existing-dashboard read so the - // source-derived validators reuse one query result and the - // heatmap source-kind check can scope itself to tiles whose - // sourceId/displayType actually changed in this request (a - // source whose `kind` was changed after the dashboard was - // originally accepted shouldn't wedge unrelated edits). onClick - // helpers stay separate because they own their own dashboard - // lookup as well as a sources lookup; firing them inside the - // same Promise.all keeps the request latency unchanged. - const [ - sources, - missingConnections, - missingOnClickDashboards, - invalidOnClickSearchSources, - existingDashboard, - ] = await Promise.all([ - fetchSourcesForValidation(teamId), - getMissingConnections(teamId, tiles), - getMissingOnClickDashboards(teamId, tiles), - getInvalidOnClickSearchSources(teamId, tiles), - Dashboard.findOne( - { _id: dashboardId, team: teamId }, - { tiles: 1, filters: 1, containers: 1 }, - ).lean(), - ]); - - const missingSources = getMissingSources(sources, tiles, filters); - const sourceConnectionMismatches = getSourceConnectionMismatches( - sources, - tiles, - ); - const heatmapNonTraceSources = getHeatmapTilesWithIncompatibleSources( - sources, - filterChangedHeatmapTiles(tiles, existingDashboard?.tiles ?? []), - ); + const existingDashboard = await Dashboard.findOne( + { _id: dashboardId, team: teamId }, + { tiles: 1, filters: 1, containers: 1 }, + ).lean(); - if (missingSources.length > 0) { - return res.status(400).json({ - message: `Could not find the following source IDs: ${missingSources.join( - ', ', - )}`, - }); - } - if (missingConnections.length > 0) { - return res.status(400).json({ - message: `Could not find the following connection IDs: ${missingConnections.join( - ', ', - )}`, - }); - } - if (sourceConnectionMismatches.length > 0) { - return res.status(400).json({ - message: `The following source IDs do not match the specified connections: ${sourceConnectionMismatches.join( - ', ', - )}`, - }); - } - if (heatmapNonTraceSources.length > 0) { - return res.status(400).json({ - message: `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join( - ', ', - )}`, - }); - } - if (missingOnClickDashboards.length > 0) { - return res.status(400).json({ - message: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join( - ', ', - )}`, - }); + if (existingDashboard == null) { + return res.sendStatus(404); } - if (invalidOnClickSearchSources.length > 0) { - return res.status(400).json({ - message: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join( - ', ', - )}`, - }); + + const effectiveContainers = + containers ?? existingDashboard.containers ?? []; + const validationError = await validateDashboardTiles({ + teamId: teamId.toString(), + tiles, + filters, + existingTiles: existingDashboard.tiles ?? [], + containers: effectiveContainers, + }); + if (validationError) { + return res.status(400).json({ message: validationError }); } - // PUT performs a read-modify-write on the dashboard doc with no - // optimistic-concurrency check (no `If-Match` / `updatedAt` - // guard). Concurrent PUTs may silently overwrite each other, - // which can leave orphan tile->container refs on layout-shape - // edits. The endpoint contract is at-most-one-writer; the - // OpenAPI description above documents this. Adding ETag-style - // concurrency would be a breaking change to the request shape - // and is tracked separately. `existingDashboard` is fetched in - // the hoisted Promise.all above and reused here for tile-ref - // resolution against the effective container set. const existingTileIds = new Set( - (existingDashboard?.tiles ?? []).map((t: { id: string }) => t.id), + (existingDashboard.tiles ?? []).map((t: { id: string }) => t.id), ); const existingFilterIds = new Set( - (existingDashboard?.filters ?? []).map((f: { id: string }) => f.id), + (existingDashboard.filters ?? []).map((f: { id: string }) => f.id), ); - // Tile-level container/tab ref resolution: container structure - // was checked by the body schema; tile-resolution runs here - // against the effective container set so an omitted `containers` - // body field (which preserves the existing array on write) is - // resolved against the doc's existing containers rather than an - // empty fallback. Without this, a PUT that updates only `tiles` - // and leaves a tile pointing at a real preserved container - // would be rejected with "Tile references unknown containerId". - const effectiveContainers = - containers ?? existingDashboard?.containers ?? []; - const tileRefIssues = collectTileContainerRefIssues( - effectiveContainers, - tiles, - ); - if (tileRefIssues.length > 0) { - return res.status(400).json({ - message: tileRefIssues.join('; '), - }); - } - const internalTiles = convertExternalTilesToInternal( tiles, existingTileIds, diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 25c59b35f2..844a92e194 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -1,9 +1,13 @@ -import { displayTypeSupportsRawSqlAlerts } from '@hyperdx/common-utils/dist/core/utils'; +import { + displayTypeSupportsBuilderAlerts, + displayTypeSupportsRawSqlAlerts, +} from '@hyperdx/common-utils/dist/core/utils'; import { validateDashboardContainersStructure, validateDashboardTileContainerRefs, } from '@hyperdx/common-utils/dist/dashboardValidation'; import { + isBuilderSavedChartConfig, isHeatmapCompatibleSource, isPromqlSavedChartConfig, isRawSqlSavedChartConfig, @@ -70,7 +74,7 @@ export type ConfigTile = ExternalDashboardTileWithId & { config: Exclude; }; -export function isRawSqlExternalTileConfig( +function isRawSqlExternalTileConfig( config: ExternalDashboardTileConfig, ): config is ExternalDashboardRawSqlTileConfig { return 'configType' in config && config.configType === 'sql'; @@ -767,15 +771,13 @@ export function convertToInternalTileConfig( * router can pass a single fetched array into multiple helpers without * pulling in `controllers/sources` for the type alone. */ -export type SourceForValidation = Awaited< - ReturnType ->[number]; +type SourceForValidation = Awaited>[number]; /** Fetches sources for a team. Re-exports the controller call so callers * outside `controllers/sources` don't need a second import for the * validation flow. The return type is the awaited shape of `getSources` * (an array of Source documents) so callers can `await` it directly. */ -export async function fetchSourcesForValidation( +async function fetchSourcesForValidation( team: string | mongoose.Types.ObjectId, ): Promise { return getSources(team.toString()); @@ -792,7 +794,7 @@ function getTileOnClick(tile: ExternalDashboardTileWithId) { } /** Returns source IDs referenced in tiles/filters that do not exist for the team */ -export function getMissingSources( +function getMissingSources( sources: SourceForValidation[], tiles: ExternalDashboardTileWithId[], filters?: (ExternalDashboardFilter | ExternalDashboardFilterWithId)[], @@ -840,7 +842,7 @@ export function getMissingSources( * `packages/common-utils/src/guards.ts` and `ChartEditorControls.tsx`), so * UI and API gates move together. */ -export function getHeatmapTilesWithIncompatibleSources( +function getHeatmapTilesWithIncompatibleSources( sources: SourceForValidation[], tiles: ExternalDashboardTileWithId[], ): string[] { @@ -874,7 +876,7 @@ export function getHeatmapTilesWithIncompatibleSources( * tiles, tiles whose displayType just changed to heatmap, and tiles * whose `sourceId` changed all flow through the check. */ -export function filterChangedHeatmapTiles( +function filterChangedHeatmapTiles( requestTiles: ExternalDashboardTileWithId[], existingTiles: DashboardDocument['tiles'], ): ExternalDashboardTileWithId[] { @@ -918,7 +920,7 @@ export function filterChangedHeatmapTiles( * Sources that don't exist are ignored here, getMissingSources handles that * case separately with a clearer error message. */ -export async function getInvalidOnClickSearchSources( +async function getInvalidOnClickSearchSources( team: string | mongoose.Types.ObjectId, tiles: ExternalDashboardTileWithId[], ): Promise { @@ -943,7 +945,7 @@ export async function getInvalidOnClickSearchSources( * Returns dashboard IDs referenced by tile OnClick link-outs (mode=id, * type=dashboard) that do not exist for the team. */ -export async function getMissingOnClickDashboards( +async function getMissingOnClickDashboards( team: string | mongoose.Types.ObjectId, tiles: ExternalDashboardTileWithId[], ): Promise { @@ -969,7 +971,7 @@ export async function getMissingOnClickDashboards( } /** Returns connection IDs referenced in tiles that do not belong to the team */ -export async function getMissingConnections( +async function getMissingConnections( team: string | mongoose.Types.ObjectId, tiles: ExternalDashboardTileWithId[], ): Promise { @@ -1074,8 +1076,122 @@ export function convertExternalFiltersToInternal( } /** - * Delete alerts for tiles that were removed or converted to raw SQL - * (which doesn't support alerts). + * Returns source IDs on raw SQL tiles whose connection doesn't match + * the source's persisted connection. Catches copy-paste errors where + * the LLM mixes up sourceId and connectionId from different sources. + */ +function getSourceConnectionMismatches( + sources: SourceForValidation[], + tiles: ExternalDashboardTileWithId[], +): string[] { + const sourceById = new Map(sources.map(s => [s._id.toString(), s])); + + const mismatched: string[] = []; + for (const tile of tiles) { + if ( + isConfigTile(tile) && + isRawSqlExternalTileConfig(tile.config) && + tile.config.sourceId + ) { + const source = sourceById.get(tile.config.sourceId); + if (source && source.connection.toString() !== tile.config.connectionId) { + mismatched.push(tile.config.sourceId); + } + } + } + + return mismatched; +} + +// ── Shared tile validation ─────────────────────────────────────────────── + +export type TileValidationContext = { + teamId: string; + tiles: ExternalDashboardTileWithId[]; + /** Filters to check for missing source IDs (create/full-update paths). */ + filters?: (ExternalDashboardFilter | ExternalDashboardFilterWithId)[]; + /** Existing internal tiles for scoping heatmap change detection (update paths). */ + existingTiles?: DashboardDocument['tiles']; + /** Container set to validate tile containerId/tabId refs against. */ + containers: DashboardContainer[]; +}; + +/** + * Run the full suite of tile validation checks (sources, connections, + * heatmap source-kind, onClick targets, container/tab refs). Returns + * `null` when all checks pass, or an error message string on failure. + * + * Consolidates the ~95-line validation block that was previously + * duplicated across REST v2 POST/PUT, MCP save (create/update), and + * MCP patch handlers. + */ +export async function validateDashboardTiles( + ctx: TileValidationContext, +): Promise { + const { teamId, tiles, filters, existingTiles, containers } = ctx; + + // Container/tab ref resolution. + const tileRefIssues = collectTileContainerRefIssues(containers, tiles); + if (tileRefIssues.length > 0) { + return tileRefIssues.join('; '); + } + + // Fetch sources/connections/onClick targets in parallel. + const [ + sources, + missingConnections, + missingOnClickDashboards, + invalidOnClickSearchSources, + ] = await Promise.all([ + fetchSourcesForValidation(teamId), + getMissingConnections(teamId, tiles), + getMissingOnClickDashboards(teamId, tiles), + getInvalidOnClickSearchSources(teamId, tiles), + ]); + + const missingSources = getMissingSources(sources, tiles, filters); + if (missingSources.length > 0) { + return `Could not find the following source IDs: ${missingSources.join(', ')}`; + } + if (missingConnections.length > 0) { + return `Could not find the following connection IDs: ${missingConnections.join(', ')}`; + } + + const sourceConnectionMismatches = getSourceConnectionMismatches( + sources, + tiles, + ); + if (sourceConnectionMismatches.length > 0) { + return `The following source IDs do not match the specified connections: ${sourceConnectionMismatches.join(', ')}`; + } + + // Heatmap source-kind gate. On create (no existingTiles), validate all + // tiles. On update, scope to tiles whose sourceId/displayType changed. + const heatmapTilesToCheck = existingTiles + ? filterChangedHeatmapTiles(tiles, existingTiles) + : tiles; + const heatmapNonTraceSources = getHeatmapTilesWithIncompatibleSources( + sources, + heatmapTilesToCheck, + ); + if (heatmapNonTraceSources.length > 0) { + return `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`; + } + + if (missingOnClickDashboards.length > 0) { + return `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`; + } + if (invalidOnClickSearchSources.length > 0) { + return `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join(', ')}`; + } + + return null; +} + +/** + * Delete alerts for tiles that were removed or whose config no longer + * supports alerts (raw SQL with incompatible displayType, or builder + * tiles with incompatible displayType like Pie/Table/Heatmap/etc.). */ export async function cleanupDashboardAlerts({ dashboardId, @@ -1090,13 +1206,19 @@ export async function cleanupDashboardAlerts({ }) { const newTileIdSet = new Set(internalTiles.map(t => t.id)); const tileIdsToDeleteAlerts = [ + // Tiles whose config no longer supports alerts (raw SQL or builder). ...internalTiles - .filter( - tile => - isRawSqlSavedChartConfig(tile.config) && - !displayTypeSupportsRawSqlAlerts(tile.config.displayType), - ) + .filter(tile => { + if (isRawSqlSavedChartConfig(tile.config)) { + return !displayTypeSupportsRawSqlAlerts(tile.config.displayType); + } + if (isBuilderSavedChartConfig(tile.config)) { + return !displayTypeSupportsBuilderAlerts(tile.config.displayType); + } + return false; + }) .map(tile => tile.id), + // Tiles that were completely removed. ...[...existingTileIds].filter(id => !newTileIdSet.has(id)), ]; if (tileIdsToDeleteAlerts.length > 0) { diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index bb6906e674..b2d3e53ebb 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -146,7 +146,7 @@ export type ExternalDashboardFilter = z.infer< >; export const externalDashboardSavedFilterValueSchema = z.object({ - type: z.enum(['sql', 'lucene']).optional().default('sql'), + type: z.literal('sql').optional().default('sql'), condition: z.string().max(10000), }); diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index a2a9a05c4b..544c6762d5 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -17,10 +17,6 @@ import { getAlignedDateRange, Granularity, } from '@hyperdx/common-utils/dist/core/utils'; -import { - type FilterState, - filtersToQuery, -} from '@hyperdx/common-utils/dist/filters'; import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards'; import { AggregateFunction as AggFnV2, @@ -927,19 +923,13 @@ export function buildEventsSearchUrl({ // Add group-by column filters if (groupFilters && groupFilters.length > 0) { - const filterState: FilterState = {}; - for (const { column, value } of groupFilters) { + groupFilters.forEach(({ column, value }) => { if (column && value != null) { - if (!filterState[column]) { - filterState[column] = { - included: new Set(), - excluded: new Set(), - }; - } - filterState[column].included.add(String(value)); + // Can't use SQLString.escape here because the search endpoint relies on exist match for UI + const condition = `${column} IN (${SqlString.escape(value)})`; + additionalFilters.push({ type: 'sql', condition }); } - } - additionalFilters.push(...filtersToQuery(filterState)); + }); } // Add Y-axis value range filter (±threshold) for charts diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index 9ce75353a5..2db7a2b328 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -15,6 +15,12 @@ import { Controller, useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { convertToDashboardDocument } from '@hyperdx/common-utils/dist/core/utils'; +import { + type DashboardFilterQueryIssue, + type SavedFilterValueIssue, + validateDashboardFilterQueries, + validateSavedFilterValues, +} from '@hyperdx/common-utils/dist/filters'; import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; import { type DashboardTemplate, @@ -80,6 +86,18 @@ function FileSelection({ details?: ReactNode; } | null>(null); const [errorDetails, { toggle: toggleErrorDetails }] = useDisclosure(false); + const [filterWarnings, setFilterWarnings] = useState( + [], + ); + const [filterQueryWarnings, setFilterQueryWarnings] = useState< + DashboardFilterQueryIssue[] + >([]); + const [warningDetails, { toggle: toggleWarningDetails }] = + useDisclosure(false); + const [ + filterQueryWarningDetails, + { toggle: toggleFilterQueryWarningDetails }, + ] = useDisclosure(false); const { control, handleSubmit } = useForm({ resolver: zodResolver(FormSchema), @@ -87,6 +105,8 @@ function FileSelection({ const onSubmit = async ({ file }: FormValues) => { setError(null); + setFilterWarnings([]); + setFilterQueryWarnings([]); if (!file) return; let data: unknown; @@ -120,6 +140,22 @@ function FileSelection({ return; } + // The schema only guarantees saved filter values are well-shaped, not that + // their condition strings parse. Surface a non-blocking warning so users + // who hand-edited or generated the file know a value won't actually filter, + // while still letting them proceed with the import. + setFilterWarnings( + validateSavedFilterValues(result.data.savedFilterValues ?? []), + ); + + // Import never runs the filters' values queries, so a filter whose `where` + // clause is malformed would otherwise only surface as a failed query after + // opening the dashboard. Statically validate the `where` clauses here so the + // problem is visible up front (non-blocking). + setFilterQueryWarnings( + validateDashboardFilterQueries(result.data.filters ?? []), + ); + onComplete(result.data); }; @@ -212,6 +248,80 @@ function FileSelection({ )} )} + + {filterWarnings.length > 0 && ( +
+ + {filterWarnings.length === 1 + ? '1 saved filter value has an invalid condition and will not be applied.' + : `${filterWarnings.length} saved filter values have invalid conditions and will not be applied.`} + + + + + {filterWarnings.map(warning => ( + + Invalid {warning.language} condition: {warning.condition} + + ))} + + +
+ )} + + {filterQueryWarnings.length > 0 && ( +
+ + {filterQueryWarnings.length === 1 + ? "1 filter has an invalid query and won't load values." + : `${filterQueryWarnings.length} filters have invalid queries and won't load values.`} + + + + + {filterQueryWarnings.map(warning => ( + + {warning.filterName}: invalid {warning.language} query:{' '} + {warning.where} + + ))} + + +
+ )} ); diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 1fc157053b..c6f0aa058c 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -116,6 +116,8 @@ import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import { FavoriteButton } from '@/components/FavoriteButton'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; +import { PageHeader } from '@/components/PageHeader'; +import { PageLayout } from '@/components/PageLayout'; import { TimePicker } from '@/components/TimePicker'; import { parseTimeRangeInput } from '@/components/TimePicker/utils'; import { @@ -2218,8 +2220,309 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { dashboard?.savedQuery || dashboard?.savedFilterValues?.length, ); - return ( - + const dashboardMeta = + !isLocalDashboard && dashboard ? ( + + {dashboard.createdBy && ( + + Created by {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} + + )} + {dashboard.updatedAt && ( + + + {dashboard.updatedBy + ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` + : ''} + + } + > + {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} + + )} + + ) : null; + + const pageBreadcrumbs = ( + + + + Dashboards + + + {isLocalDashboard + ? 'Temporary Dashboard' + : (dashboard?.name ?? 'Untitled')} + + + {dashboardMeta} + + ); + + const dashboardName = ( + { + if (dashboard != null) { + setDashboard({ + ...dashboard, + name: editedName, + }); + } + }} + /> + ); + + const dashboardActions = !isLocalDashboard ? ( + + {dashboard?.id && ( + + )} + {dashboard?.id && ( + + + + )} + {/* local dashboards cant be "deleted" */} + + + + + + + + + {containers.length > 0 && ( + <> + View + + ) : ( + + ) + } + onClick={() => setTocVisible(v => !v)} + data-testid="toggle-toc-menu-item" + > + {tocVisible + ? 'Hide table of contents' + : 'Show table of contents'} + + } + onClick={handleCollapseAll} + disabled={collapsibleContainers.length === 0} + data-testid="collapse-all-sections-menu-item" + > + Collapse all sections + + } + onClick={handleExpandAll} + disabled={collapsibleContainers.length === 0} + data-testid="expand-all-sections-menu-item" + > + Expand all sections + + + + )} + {hasTiles && ( + } + data-testid="export-dashboard-menu-item" + onClick={() => { + if (!sources || !dashboard) { + notifications.show({ + color: 'red', + message: 'Export Failed', + }); + return; + } + downloadObjectAsJson( + convertToDashboardTemplate( + dashboard, + sources, + connections, + allDashboards ?? [], + ), + dashboard?.name, + ); + }} + > + Export Dashboard + + )} + } + onClick={() => { + if (dashboard && !dashboard.tiles.length) { + router.push(`/dashboards/import?dashboardId=${dashboard.id}`); + } else { + router.push('/dashboards/import'); + } + }} + > + {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + + + } + onClick={handleSaveQuery} + > + {hasSavedQueryAndFilterDefaults + ? 'Update Default Query & Filters' + : 'Save Query & Filters as Default'} + + {hasSavedQueryAndFilterDefaults && ( + } + color="red" + onClick={handleRemoveSavedQuery} + > + Remove Default Query & Filters + + )} + + } + color="red" + onClick={() => + deleteDashboard.mutate(dashboard?.id ?? '', { + onSuccess: () => { + router.push('/dashboards'); + }, + }) + } + > + Delete Dashboard + + + + + ) : null; + + const titleRow = ( + + {dashboardName} + {dashboardActions} + + ); + + const queryToolbar = ( + { + e.preventDefault(); + onSubmit(); + }} + > + + setValue('whereLanguage', lang) + } + label="WHERE" + enableHotkey + allowMultiline + minWidth={300} + data-testid="search-input" + /> + { + onSearch(range); + }} + /> + + + + + + + + + + + setShowFiltersModal(true)} + data-testid="edit-filters-button" + size="input-sm" + > + + + + + + ); + + // Extracted for the same reason as `KubernetesDashboardPage`: keeps the + // `` return shallow and prevents the tile-grid tree below + // from being wrapped in an extra indentation level. + const dashboardBody = ( + <> {dashboard?.name ? `${dashboard.name}` : 'Dashboard'} – {brandName} @@ -2261,321 +2564,23 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }} /> - - {isLocalDashboard ? ( - <> - <Breadcrumbs mb="xs" mt="xs" fz="sm"> - <Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed"> - Dashboards - </Anchor> - <Text fz="sm" c="dimmed"> - Temporary Dashboard - </Text> - </Breadcrumbs> - <Paper my="lg" p="md" data-testid="temporary-dashboard-banner"> - <Flex justify="space-between" align="center"> - <Text size="sm"> - This is a temporary dashboard and can not be saved. - </Text> - <Button - variant="primary" - fw={400} - onClick={onCreateDashboard} - data-testid="create-dashboard-button" - > - Create New Saved Dashboard - </Button> - </Flex> - </Paper> - </> - ) : ( - <Group align="flex-start" mb="xs" mt="xs" justify="space-between"> - <Breadcrumbs fz="sm"> - <Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed"> - Dashboards - </Anchor> - <Text fz="sm" c="dimmed" maw={500} truncate="end" lh={1}> - {dashboard?.name ?? 'Untitled'} - </Text> - </Breadcrumbs> - {!isLocalDashboard && dashboard && ( - <Text size="xs" c="dimmed"> - {dashboard.createdBy && ( - <span> - Created by{' '} - {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} - </span> - )} - {dashboard.updatedAt && ( - <Tooltip - label={ - <> - <FormatTime value={dashboard.updatedAt} format="short" /> - {dashboard.updatedBy - ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` - : ''} - </> - } - > - <span>{`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`}</span> - </Tooltip> - )} + {isLocalDashboard && ( + <Paper mt="xs" mb="md" p="md" data-testid="temporary-dashboard-banner"> + <Flex justify="space-between" align="center"> + <Text size="sm"> + This is a temporary dashboard and can not be saved. </Text> - )} - </Group> - )} - <Flex mt="xs" mb="md" justify="space-between" align="flex-start"> - <EditablePageName - key={`${dashboardHash}`} - name={dashboard?.name ?? ''} - onSave={editedName => { - if (dashboard != null) { - setDashboard({ - ...dashboard, - name: editedName, - }); - } - }} - /> - <Group gap="xs"> - {!isLocalDashboard && dashboard?.id && ( - <FavoriteButton - resourceType="dashboard" - resourceId={dashboard.id} - /> - )} - {!isLocalDashboard && dashboard?.id && ( - <Tags - allowCreate - values={dashboard?.tags || []} - onChange={handleUpdateTags} + <Button + variant="primary" + fw={400} + onClick={onCreateDashboard} + data-testid="create-dashboard-button" > - <Button - variant="secondary" - px="xs" - size="xs" - style={{ flexShrink: 0 }} - > - <IconTags size={14} className="me-2" /> - {dashboard?.tags?.length || 0}{' '} - {dashboard?.tags?.length === 1 ? 'Tag' : 'Tags'} - </Button> - </Tags> - )} - {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( - <Menu width={250}> - <Menu.Target> - <ActionIcon - variant="secondary" - size="input-xs" - data-testid="dashboard-menu-button" - > - <IconDotsVertical size={14} /> - </ActionIcon> - </Menu.Target> - - <Menu.Dropdown> - {containers.length > 0 && ( - <> - <Menu.Label>View</Menu.Label> - <Menu.Item - leftSection={ - tocVisible ? ( - <IconLayoutSidebarRightCollapse size={16} /> - ) : ( - <IconLayoutSidebarRightExpand size={16} /> - ) - } - onClick={() => setTocVisible(v => !v)} - data-testid="toggle-toc-menu-item" - > - {tocVisible - ? 'Hide table of contents' - : 'Show table of contents'} - </Menu.Item> - <Menu.Item - leftSection={<IconChevronsUp size={16} />} - onClick={handleCollapseAll} - disabled={collapsibleContainers.length === 0} - data-testid="collapse-all-sections-menu-item" - > - Collapse all sections - </Menu.Item> - <Menu.Item - leftSection={<IconChevronsDown size={16} />} - onClick={handleExpandAll} - disabled={collapsibleContainers.length === 0} - data-testid="expand-all-sections-menu-item" - > - Expand all sections - </Menu.Item> - <Menu.Divider /> - </> - )} - {hasTiles && ( - <Menu.Item - leftSection={<IconDownload size={16} />} - onClick={() => { - if (!sources || !dashboard) { - notifications.show({ - color: 'red', - message: 'Export Failed', - }); - return; - } - downloadObjectAsJson( - convertToDashboardTemplate( - dashboard, - sources, - connections, - allDashboards ?? [], - ), - dashboard?.name, - ); - }} - > - Export Dashboard - </Menu.Item> - )} - <Menu.Item - leftSection={<IconUpload size={16} />} - onClick={() => { - if (dashboard && !dashboard.tiles.length) { - router.push( - `/dashboards/import?dashboardId=${dashboard.id}`, - ); - } else { - router.push('/dashboards/import'); - } - }} - > - {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} - </Menu.Item> - <Menu.Divider /> - <Menu.Item - data-testid="save-default-query-filters-menu-item" - leftSection={<IconDeviceFloppy size={16} />} - onClick={handleSaveQuery} - > - {hasSavedQueryAndFilterDefaults - ? 'Update Default Query & Filters' - : 'Save Query & Filters as Default'} - </Menu.Item> - {hasSavedQueryAndFilterDefaults && ( - <Menu.Item - data-testid="remove-default-query-filters-menu-item" - leftSection={<IconX size={16} />} - color="red" - onClick={handleRemoveSavedQuery} - > - Remove Default Query & Filters - </Menu.Item> - )} - <Menu.Divider /> - <Menu.Item - leftSection={<IconTrash size={16} />} - color="red" - onClick={() => - deleteDashboard.mutate(dashboard?.id ?? '', { - onSuccess: () => { - router.push('/dashboards'); - }, - }) - } - > - Delete Dashboard - </Menu.Item> - </Menu.Dropdown> - </Menu> - )} - </Group> - {/* <Button variant="outline" size="sm"> - Save - </Button> */} - </Flex> - <Flex - gap="sm" - mt="sm" - wrap="wrap" - component="form" - onSubmit={e => { - e.preventDefault(); - onSubmit(); - }} - > - <SearchWhereInput - tableConnections={tableConnections} - control={control} - name="where" - onSubmit={onSubmit} - onLanguageChange={(lang: 'sql' | 'lucene') => - setValue('whereLanguage', lang) - } - label="WHERE" - enableHotkey - allowMultiline - minWidth={300} - data-testid="search-input" - /> - <TimePicker - inputValue={displayedTimeInputValue} - setInputValue={setDisplayedTimeInputValue} - onSearch={range => { - onSearch(range); - }} - /> - <GranularityPickerControlled control={control} name="granularity" /> - <Tooltip - withArrow - label={ - isRefreshEnabled - ? `Auto-refreshing with ${granularityOverride} interval` - : 'Enable auto-refresh' - } - fz="xs" - color="gray" - > - <Button - onClick={() => setIsLive(prev => !prev)} - size="sm" - variant={isLive ? 'primary' : 'secondary'} - title={isLive ? 'Disable auto-refresh' : 'Enable auto-refresh'} - > - Live - </Button> - </Tooltip> - <Tooltip withArrow label="Refresh dashboard" fz="xs" color="gray"> - <ActionIcon - onClick={refresh} - loading={manualRefreshCooloff} - disabled={manualRefreshCooloff} - variant="secondary" - title="Refresh dashboard" - size="input-sm" - > - <IconRefresh size={18} /> - </ActionIcon> - </Tooltip> - <Tooltip withArrow label="Edit Filters" fz="xs" color="gray"> - <ActionIcon - variant="secondary" - onClick={() => setShowFiltersModal(true)} - data-testid="edit-filters-button" - size="input-sm" - > - <IconFilterEdit size={18} /> - </ActionIcon> - </Tooltip> - <Button - data-testid="search-submit-button" - variant="primary" - type="submit" - leftSection={<IconPlayerPlay size={16} />} - style={{ flexShrink: 0 }} - > - Run - </Button> - </Flex> + Create New Saved Dashboard + </Button> + </Flex> + </Paper> + )} {shouldShowIgnoredFiltersBanner && ignoredFilterExpressions.length > 0 && ( <Alert @@ -2768,7 +2773,20 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onRemoveFilter={handleRemoveFilter} isLoading={isSavingDashboard || isFetchingDashboard} /> - </Box> + </> + ); + + return ( + <PageLayout + data-testid="dashboard-page" + header={ + <PageHeader breadcrumbs={pageBreadcrumbs} stickyRow={queryToolbar}> + {titleRow} + </PageHeader> + } + padded + content={dashboardBody} + /> ); } diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 92fe91474c..2862d3b857 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -127,7 +127,9 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; -import SourceSchemaPreview from './components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './components/SourceSchemaPreview'; import { getRelativeTimeOptionLabel, LIVE_TAIL_DURATION_MS, @@ -277,6 +279,7 @@ function SearchSubmitButton({ type="submit" leftSection={<IconPlayerPlay size={16} />} style={{ flexShrink: 0 }} + size="xs" > Run </Button> @@ -1664,10 +1667,8 @@ export function DBSearchPage() { [inputSourceObj], ); - const sourceSchemaPreview = useMemo( - () => <SourceSchemaPreview source={inputSourceObj} variant="text" />, - [inputSourceObj], - ); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); const onTimePickerSearch = useCallback( (range: string) => { @@ -1742,13 +1743,38 @@ export function DBSearchPage() { setModelFormExpanded(false); }, [setModelFormExpanded]); - const onEditSources = useCallback(() => { + // `Edit source` (singular): operate on the currently selected source. + // Local mode opens the inline edit modal seeded with `inputSource`; + // non-local uses a hard navigation so the page's `useQueryStates` + // (source/where/select/whereLanguage/filters/orderBy) can't merge + // stale /search state into the destination URL, and so + // `router.basePath` is correctly prepended for the /clickstack build. + const onEditCurrentSource = useCallback(() => { if (IS_LOCAL_MODE) { - setModelFormExpanded(v => !v); + setModelFormExpanded(true); + return; + } + if (inputSource) { + window.location.assign(`${router.basePath}/team?source=${inputSource}`); } else { - router.push('/team'); + window.location.assign(`${router.basePath}/team`); } - }, [setModelFormExpanded]); + }, [inputSource, setModelFormExpanded]); + + // `Manage sources`: open the all-sources list view. Only wired in + // non-local mode; local has no list-view surface so the menu item + // hides itself when this prop is undefined. We use `window.location` + // for a hard navigation instead of `router.push` so the page's + // `useQueryStates` (source/where/select/whereLanguage/filters/orderBy) + // can't restore its state into the new URL during the client-side + // transition, and so `router.basePath` is correctly prepended for + // the /clickstack build. + const onManageSources = useMemo(() => { + if (IS_LOCAL_MODE) return undefined; + return () => { + window.location.assign(`${router.basePath}/team`); + }; + }, []); const setNewSourceModalClosed = useCallback( () => setNewSourceModalOpened(false), @@ -1885,12 +1911,22 @@ export function DBSearchPage() { control={control} name="source" onCreate={openNewSourceModal} - onEdit={onEditSources} + onEdit={onEditCurrentSource} + onManageSources={onManageSources} + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled( + inputSourceObj, + )} allowedSourceKinds={ALLOWED_SOURCE_KINDS} data-testid="source-selector" - sourceSchemaPreview={sourceSchemaPreview} style={{ minWidth: 150 }} /> + <SourceSchemaPreview + source={inputSourceObj} + controlled + open={isSourceSchemaPreviewOpen} + onClose={() => setIsSourceSchemaPreviewOpen(false)} + /> <Box style={{ flex: '1 1 0%', minWidth: 100 }}> <SQLInlineEditorControlled tableConnection={inputSourceTableConnection} @@ -1982,6 +2018,7 @@ export function DBSearchPage() { minWidth="min(600px, 100%)" dateRange={searchedTimeRange} sourceId={inputSource} + size="xs" /> <Flex gap="sm" @@ -2001,12 +2038,13 @@ export function DBSearchPage() { isLive && interval !== LIVE_TAIL_DURATION_MS } width="100%" + size="xs" /> {isLive && ( <Tooltip label="Live tail refresh interval"> <Box style={{ width: 80, minWidth: 80, flexShrink: 0 }}> <Select - size="sm" + size="xs" w="100%" data={LIVE_TAIL_REFRESH_FREQUENCY_OPTIONS} value={String(refreshFrequency)} diff --git a/packages/app/src/DBServiceMapPage.tsx b/packages/app/src/DBServiceMapPage.tsx index 211a376652..31e4d5ea0b 100644 --- a/packages/app/src/DBServiceMapPage.tsx +++ b/packages/app/src/DBServiceMapPage.tsx @@ -15,7 +15,9 @@ import { withAppNav } from '@/layout'; import OnboardingModal from './components/OnboardingModal'; import ServiceMap from './components/ServiceMap/ServiceMap'; import { TableSourceForm } from './components/Sources/SourceForm'; -import SourceSchemaPreview from './components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './components/SourceSchemaPreview'; import { SourceSelectControlled } from './components/SourceSelect'; import { TimePicker } from './components/TimePicker'; import { useBrandDisplayName } from './theme/ThemeProvider'; @@ -86,6 +88,8 @@ function DBServiceMapPage() { }); const watchedSource = useWatch({ control, name: 'source' }); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); useEffect(() => { if (watchedSource !== sourceId) { @@ -116,15 +120,22 @@ function DBServiceMapPage() { ); const sourceSelect = source ? ( - <SourceSelectControlled - control={control} - name="source" - size="xs" - allowedSourceKinds={[SourceKind.Trace]} - sourceSchemaPreview={ - <SourceSchemaPreview source={source} variant="text" /> - } - /> + <> + <SourceSelectControlled + control={control} + name="source" + size="xs" + allowedSourceKinds={[SourceKind.Trace]} + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)} + /> + <SourceSchemaPreview + source={source} + controlled + open={isSourceSchemaPreviewOpen} + onClose={() => setIsSourceSchemaPreviewOpen(false)} + /> + </> ) : null; const headerActions = ( diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 141362670b..f92ac131c8 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -1,7 +1,7 @@ import { FilterState } from '@hyperdx/common-utils/dist/filters'; import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; import { Group, Stack, Text, Tooltip } from '@mantine/core'; -import { IconHelp, IconRefresh } from '@tabler/icons-react'; +import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react'; import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect'; import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; @@ -12,6 +12,7 @@ interface DashboardFilterSelectProps { value: string[]; values?: string[]; isLoading?: boolean; + isError?: boolean; } const getAppliesToTooltip = (filter: DashboardFilter) => { @@ -26,6 +27,7 @@ const DashboardFilterSelect = ({ value, values, isLoading, + isError, }: DashboardFilterSelectProps) => { const sortedValues = values?.toSorted() || []; const tooltipText = getAppliesToTooltip(filter); @@ -43,12 +45,27 @@ const DashboardFilterSelect = ({ data-testid={`dashboard-filter-help-${filter.name}`} /> </Tooltip> + {isError && ( + <Tooltip + label="Filter values query failed. The filter's query may be invalid." + withinPortal + > + <IconAlertTriangle + size={12} + color="var(--color-text-danger)" + data-testid={`dashboard-filter-error-${filter.name}`} + /> + </Tooltip> + )} </Group> <div style={{ width: 250 }}> <VirtualMultiSelect placeholder={value.length === 0 ? filter.name : undefined} values={value} data={sortedValues} + // Disable only while values are genuinely loading. A completed query + // that returned no rows (or failed) must stay interactive so the user + // can still clear/adjust the selection instead of being stuck. disabled={isLoading} onChange={onChange} data-testid={`dashboard-filter-select-${filter.name}`} @@ -71,24 +88,35 @@ const DashboardFilters = ({ filterValues, onSetFilterValue, }: DashboardFilterProps) => { - const { data: filterValuesById, isFetching } = useDashboardFilterValues({ + const { + data: filterValuesById, + erroredFilterIds, + isFetching, + } = useDashboardFilterValues({ filters, dateRange, }); return ( - <Group mt="sm" align="start"> + <Group align="start"> {Object.values(filters).map(filter => { const queriedFilterValues = filterValuesById?.get(filter.id); const included = filterValues[filter.expression]?.included; const selectedValues = included ? Array.from(included).map(v => v.toString()) : []; + // Fall back to the hook-level fetching state only until this filter's + // query has produced an entry; once it has (even with empty values), + // honor its own loading flag so a finished query never stays disabled. + const isLoadingValues = queriedFilterValues + ? queriedFilterValues.isLoading + : isFetching; return ( <DashboardFilterSelect key={filter.id} filter={filter} - isLoading={!queriedFilterValues} + isLoading={isLoadingValues} + isError={erroredFilterIds?.has(filter.id) ?? false} onChange={values => onSetFilterValue(filter.expression, values)} values={queriedFilterValues?.values} value={selectedValues} diff --git a/packages/app/src/DashboardFiltersModal.tsx b/packages/app/src/DashboardFiltersModal.tsx index 94b5fcb77a..c773922d12 100644 --- a/packages/app/src/DashboardFiltersModal.tsx +++ b/packages/app/src/DashboardFiltersModal.tsx @@ -37,7 +37,9 @@ import SearchWhereInput, { import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import { SourceMultiSelectControlled } from './components/SourceMultiSelect'; -import SourceSchemaPreview from './components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './components/SourceSchemaPreview'; import { SourceSelectControlled } from './components/SourceSelect'; import { useSource, useSources } from './source'; import { getMetricTableName } from './utils'; @@ -139,6 +141,8 @@ const DashboardFilterEditForm = ({ const [modalContentRef, setModalContentRef] = useState<HTMLElement | null>( null, ); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); return ( <Modal @@ -183,11 +187,16 @@ const DashboardFilterEditForm = ({ data-testid="source-selector" rules={{ required: true }} comboboxProps={{ withinPortal: true }} - sourceSchemaPreview={ - <SourceSchemaPreview source={source} variant="text" /> - } + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)} disabled={!!presetSource} /> + <SourceSchemaPreview + source={source} + controlled + open={isSourceSchemaPreviewOpen} + onClose={() => setIsSourceSchemaPreviewOpen(false)} + /> </CustomInputWrapper> {!presetSource && ( <CustomInputWrapper diff --git a/packages/app/src/KubernetesDashboardPage.tsx b/packages/app/src/KubernetesDashboardPage.tsx index 1d2b8a7cfa..cdeaab0bb7 100644 --- a/packages/app/src/KubernetesDashboardPage.tsx +++ b/packages/app/src/KubernetesDashboardPage.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import Head from 'next/head'; import Link from 'next/link'; @@ -50,7 +50,9 @@ import { DBTimeChart } from './components/DBTimeChart'; import { FormatPodStatus } from './components/KubeComponents'; import { KubernetesFilters } from './components/KubernetesFilters'; import OnboardingModal from './components/OnboardingModal'; -import SourceSchemaPreview from './components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './components/SourceSchemaPreview'; import { SourceSelectControlled } from './components/SourceSelect'; import { useQueriedChartConfig } from './hooks/useChartConfig'; import { useDashboardRefresh } from './hooks/useDashboardRefresh'; @@ -1187,6 +1189,10 @@ function KubernetesDashboardPage() { defaultValue: 'pods', }); + const [isLogSchemaPreviewOpen, setIsLogSchemaPreviewOpen] = useState(false); + const [isMetricSchemaPreviewOpen, setIsMetricSchemaPreviewOpen] = + useState(false); + const [searchQuery, setSearchQuery] = useQueryState('q', { defaultValue: '', }); @@ -1257,9 +1263,14 @@ function KubernetesDashboardPage() { allowedSourceKinds={[SourceKind.Log]} size="xs" allowDeselect={false} - sourceSchemaPreview={ - <SourceSchemaPreview source={logSource} variant="text" /> - } + onSchemaPreview={() => setIsLogSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(logSource)} + /> + <SourceSchemaPreview + source={logSource} + controlled + open={isLogSchemaPreviewOpen} + onClose={() => setIsLogSchemaPreviewOpen(false)} /> <SourceSelectControlled name="metricSourceId" @@ -1267,9 +1278,14 @@ function KubernetesDashboardPage() { allowedSourceKinds={[SourceKind.Metric]} size="xs" allowDeselect={false} - sourceSchemaPreview={ - <SourceSchemaPreview source={metricSource} variant="text" /> - } + onSchemaPreview={() => setIsMetricSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(metricSource)} + /> + <SourceSchemaPreview + source={metricSource} + controlled + open={isMetricSchemaPreviewOpen} + onClose={() => setIsMetricSchemaPreviewOpen(false)} /> </Group> ); diff --git a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx index ac3b7e5c63..cb5327aa5c 100644 --- a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx +++ b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx @@ -208,7 +208,12 @@ jest.mock('../components/PatternTable', () => () => <div />); jest.mock('../components/Search/DBSearchHeatmapChart', () => ({ DBSearchHeatmapChart: () => <div />, })); -jest.mock('../components/SourceSchemaPreview', () => () => <div />); +jest.mock('../components/SourceSchemaPreview', () => ({ + __esModule: true, + default: () => <div />, + isSourceSchemaPreviewEnabled: () => false, + getSourceSchemaTables: () => [], +})); jest.mock('../components/Error/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: { children: React.ReactNode }) => ( <>{children}</> diff --git a/packages/app/src/__tests__/searchFilters.test.ts b/packages/app/src/__tests__/searchFilters.test.ts index 1d30fc7314..ec91631c03 100644 --- a/packages/app/src/__tests__/searchFilters.test.ts +++ b/packages/app/src/__tests__/searchFilters.test.ts @@ -1,11 +1,5 @@ import { enableMapSet } from 'immer'; -import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; -import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; import { filtersToQuery } from '@hyperdx/common-utils/dist/filters'; -import { - CustomSchemaSQLSerializerV2, - SearchQueryBuilder, -} from '@hyperdx/common-utils/dist/queryParser'; import { act, renderHook } from '@testing-library/react'; import { @@ -636,222 +630,6 @@ describe('searchFilters', () => { }); }); - describe('round-trip: Lucene filters', () => { - it('round-trips Map filter with single included value', () => { - const filters = { - "LogAttributes['service.name']": { - included: new Set<string | boolean>(['my-app']), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - expect(query).toEqual([ - { - type: 'lucene', - condition: 'LogAttributes.service.name:"my-app"', - }, - ]); - // After round-trip, bracket-notation key becomes dot notation - const parsed = parseQuery(query); - expect(parsed.filters).toEqual({ - 'LogAttributes.service.name': { - included: new Set(['my-app']), - excluded: new Set(), - }, - }); - }); - - it('round-trips Map filter with multiple included values', () => { - const filters = { - "ResourceAttributes['env']": { - included: new Set<string | boolean>(['prod', 'staging']), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters).toEqual({ - 'ResourceAttributes.env': { - included: new Set(['prod', 'staging']), - excluded: new Set(), - }, - }); - }); - - it('round-trips Map filter with excluded values', () => { - const filters = { - "LogAttributes['level']": { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(['debug', 'trace']), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters).toEqual({ - 'LogAttributes.level': { - included: new Set(), - excluded: new Set(['debug', 'trace']), - }, - }); - }); - - it('round-trips values containing double quotes', () => { - const filters = { - "LogAttributes['msg']": { - included: new Set<string | boolean>(['say "hello"']), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters).toEqual({ - 'LogAttributes.msg': { - included: new Set(['say "hello"']), - excluded: new Set(), - }, - }); - }); - - it('round-trips values containing backslashes', () => { - const filters = { - "LogAttributes['path']": { - included: new Set<string | boolean>(['C:\\path\\to\\file']), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters).toEqual({ - 'LogAttributes.path': { - included: new Set(['C:\\path\\to\\file']), - excluded: new Set(), - }, - }); - }); - - it('round-trips plain key filters', () => { - const filters = { - service: { - included: new Set<string | boolean>(['app']), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters).toEqual({ - service: { - included: new Set(['app']), - excluded: new Set(), - }, - }); - }); - - it('round-trips boolean filter values', () => { - const filters = { - isRootSpan: { - included: new Set<string | boolean>([true]), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters).toEqual({ - isRootSpan: { - included: new Set([true]), - excluded: new Set(), - }, - }); - // Verify .has(true) works (boolean, not string) - expect(parsed.filters.isRootSpan.included.has(true)).toBe(true); - expect(parsed.filters.isRootSpan.included.has('true')).toBe(false); - }); - - it('round-trips empty string values', () => { - const filters = { - tag: { - included: new Set<string | boolean>(['']), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters.tag.included).toEqual(new Set([''])); - }); - - it('round-trips values with Lucene reserved characters', () => { - const filters = { - query: { - included: new Set<string | boolean>(['(foo) AND [bar]']), - excluded: new Set<string | boolean>(), - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters.query.included).toEqual( - new Set(['(foo) AND [bar]']), - ); - }); - - it('round-trips range filters through Lucene', () => { - const filters = { - duration: { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(), - range: { min: 10, max: 500 }, - }, - }; - const query = filtersToQuery(filters); - const parsed = parseQuery(query); - expect(parsed.filters.duration.range).toEqual({ min: 10, max: 500 }); - }); - - it('merges mixed sql and lucene filters for the same key', () => { - const parsed = parseQuery([ - { type: 'sql', condition: `service IN ('app')` }, - { type: 'lucene', condition: 'service:"web"' }, - ]); - expect(parsed.filters.service.included).toEqual(new Set(['app', 'web'])); - }); - - it('parses existing lucene filter from URL/API', () => { - const parsed = parseQuery([ - { - type: 'lucene', - condition: - 'LogAttributes.service:"app-1" OR LogAttributes.service:"app-2"', - }, - ]); - expect(parsed.filters).toEqual({ - 'LogAttributes.service': { - included: new Set(['app-1', 'app-2']), - excluded: new Set(), - }, - }); - }); - - it('preserves unparseable Lucene filters as passthrough', () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const parsed = parseQuery([ - { type: 'lucene', condition: '((((' }, - { type: 'lucene', condition: 'service:"app"' }, - ]); - // Valid filter is parsed - expect(parsed.filters.service.included).toEqual(new Set(['app'])); - // Invalid filter is preserved in passthroughFilters - expect(parsed.passthroughFilters).toEqual([ - { type: 'lucene', condition: '((((' }, - ]); - warnSpy.mockRestore(); - }); - - it('returns empty array for valid but non-quoted Lucene (no passthrough)', () => { - const parsed = parseQuery([{ type: 'lucene', condition: 'service:foo' }]); - // Unquoted term — valid Lucene but not a filter, nothing extracted - expect(Object.keys(parsed.filters)).toHaveLength(0); - expect(parsed.passthroughFilters).toHaveLength(0); - }); - }); - describe('useSearchPageFilterState', () => { const onFilterChange = jest.fn(); @@ -869,15 +647,20 @@ describe('searchFilters', () => { }); expect(onFilterChange).toHaveBeenLastCalledWith([ - { type: 'lucene', condition: 'service:"app"' }, - { type: 'lucene', condition: 'level:"error"' }, + { + type: 'sql', + condition: "service IN ('app')", + }, + { + type: 'sql', + condition: "level IN ('error')", + }, ]); }); - it('updating filter query from legacy sql input', () => { + it('updating filter query', () => { const { result } = renderHook(() => useSearchPageFilterState({ - // Legacy sql filters are still parsed correctly searchQuery: [ { type: 'sql', condition: `service IN ('hdx-oss-dev-app')` }, { type: 'sql', condition: `hyperdx_event_type IN ('span')` }, @@ -892,14 +675,10 @@ describe('searchFilters', () => { result.current.setFilterValue('another_facet', 'some_value'); }); - // Output is now lucene after round-trip expect(onFilterChange).toHaveBeenLastCalledWith([ - { type: 'lucene', condition: 'hyperdx_event_type:"span"' }, - { - type: 'lucene', - condition: '(level:"info" OR level:"ok")', - }, - { type: 'lucene', condition: 'another_facet:"some_value"' }, + { type: 'sql', condition: `hyperdx_event_type IN ('span')` }, + { type: 'sql', condition: `level IN ('info', 'ok')` }, + { type: 'sql', condition: `another_facet IN ('some_value')` }, ]); }); @@ -920,12 +699,12 @@ describe('searchFilters', () => { }); expect(onFilterChange).toHaveBeenLastCalledWith([ - { type: 'lucene', condition: 'service:"hdx-oss-dev-app"' }, - { type: 'lucene', condition: 'hyperdx_event_type:"span"' }, + { type: 'sql', condition: `service IN ('hdx-oss-dev-app')` }, + { type: 'sql', condition: `hyperdx_event_type IN ('span')` }, ]); }); - it('correctly hydrates filter state from sql query', () => { + it('correctly hydrates filter state from query', () => { const { result } = renderHook(() => useSearchPageFilterState({ searchQuery: [ @@ -966,43 +745,11 @@ describe('searchFilters', () => { }); expect(onFilterChange).toHaveBeenCalledWith([ - { type: 'lucene', condition: 'service:"app"' }, - { type: 'lucene', condition: 'level:"info"' }, - ]); - }); - - it('migrates legacy SQL filters to Lucene on load', () => { - const onFilterChangeMigrate = jest.fn(); - renderHook(() => - useSearchPageFilterState({ - searchQuery: [ - { type: 'sql', condition: `service IN ('app')` }, - { type: 'sql', condition: `level IN ('info', 'ok')` }, - ], - onFilterChange: onFilterChangeMigrate, - }), - ); - - // Migration fires on mount, converting SQL to Lucene - expect(onFilterChangeMigrate).toHaveBeenCalledWith([ - { type: 'lucene', condition: 'service:"app"' }, - { type: 'lucene', condition: '(level:"info" OR level:"ok")' }, + { type: 'sql', condition: `service IN ('app')` }, + { type: 'sql', condition: `level IN ('info')` }, // Should only have the included value, no excluded values ]); }); - it('does not migrate when filters are already Lucene', () => { - const onFilterChangeNoMigrate = jest.fn(); - renderHook(() => - useSearchPageFilterState({ - searchQuery: [{ type: 'lucene', condition: 'service:"app"' }], - onFilterChange: onFilterChangeNoMigrate, - }), - ); - - // No migration needed — onFilterChange should not be called - expect(onFilterChangeNoMigrate).not.toHaveBeenCalled(); - }); - describe('retainFiltersByColumns', () => { it('returns [] and does not touch URL when filter state is empty', () => { const onFilterChangeLocal = jest.fn(); @@ -1078,8 +825,8 @@ describe('searchFilters', () => { const { result } = renderHook(() => useSearchPageFilterState({ searchQuery: [ - { type: 'lucene', condition: 'OldColumn:"x"' }, - { type: 'lucene', condition: 'AnotherGone:"y"' }, + { type: 'sql', condition: `OldColumn IN ('x')` }, + { type: 'sql', condition: `AnotherGone IN ('y')` }, ], onFilterChange: onFilterChangeLocal, }), @@ -1101,8 +848,8 @@ describe('searchFilters', () => { const { result } = renderHook(() => useSearchPageFilterState({ searchQuery: [ - { type: 'lucene', condition: 'ServiceName:"app"' }, - { type: 'lucene', condition: 'Body:"oops"' }, + { type: 'sql', condition: `ServiceName IN ('app')` }, + { type: 'sql', condition: `Body IN ('oops')` }, ], onFilterChange: onFilterChangeLocal, }), @@ -1117,157 +864,9 @@ describe('searchFilters', () => { expect(dropped).toEqual(['Body']); expect(onFilterChangeLocal).toHaveBeenLastCalledWith([ - { type: 'lucene', condition: 'ServiceName:"app"' }, - ]); - }); - - it('preserves passthrough filters when reconciling', () => { - const onFilterChangeLocal = jest.fn(); - const warnSpy = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - // Unparseable Lucene (mismatched parens) is preserved by parseQuery - // as a passthrough filter. - const { result } = renderHook(() => - useSearchPageFilterState({ - searchQuery: [ - { type: 'lucene', condition: 'ServiceName:"app"' }, - { type: 'lucene', condition: 'Body:"oops"' }, - { type: 'lucene', condition: '((((' }, - ], - onFilterChange: onFilterChangeLocal, - }), - ); - - let dropped: string[] = []; - act(() => { - dropped = result.current.retainFiltersByColumns( - new Set(['ServiceName']), - ); - }); - - expect(dropped).toEqual(['Body']); - // Passthrough filter rides along after the kept filters. - expect(onFilterChangeLocal).toHaveBeenLastCalledWith([ - { type: 'lucene', condition: 'ServiceName:"app"' }, - { type: 'lucene', condition: '((((' }, + { type: 'sql', condition: `ServiceName IN ('app')` }, ]); - warnSpy.mockRestore(); - }); - }); - }); - - describe('filters use direct_read optimization', () => { - const metadata = getMetadata( - new ClickhouseClient({ host: 'http://localhost:8123' }), - ); - - metadata.getColumn = jest.fn().mockImplementation(async ({ column }) => { - if (column === 'LogAttributes') { - return { name: 'LogAttributes', type: 'Map(String, String)' }; - } - return undefined; - }); - metadata.getMaterializedColumnsLookupTable = jest - .fn() - .mockResolvedValue(new Map()); - metadata.getColumns = jest.fn().mockResolvedValue([ - { - name: 'LogAttributes', - type: 'Map(String, String)', - default_type: '', - default_expression: '', - }, - { - name: 'LogAttributeItems', - type: 'Array(String)', - default_type: 'ALIAS', - default_expression: - "arrayMap((arr) -> concat(arr.1, '=', arr.2), LogAttributes::Array(Tuple(String, String)))", - }, - ]); - metadata.getSkipIndices = jest.fn().mockResolvedValue([ - { - name: 'idx_log_attr_items', - type: 'text', - typeFull: 'text(tokenizer=array)', - expression: 'LogAttributeItems', - granularity: 1, - }, - ]); - metadata.getSetting = jest.fn().mockResolvedValue('0'); - metadata.getServerVersion = jest - .fn() - .mockResolvedValue([26, 5, 0, 0] as const); - - const serializer = new CustomSchemaSQLSerializerV2({ - metadata, - databaseName: 'default', - tableName: 'otel_logs', - connectionId: 'test', - }); - - it('single included filter value uses has() direct_read', async () => { - const filters = filtersToQuery({ - "LogAttributes['service.name']": { - included: new Set<string | boolean>(['my-app']), - excluded: new Set<string | boolean>(), - }, - }); - - const condition = filters[0]; - expect(condition.type).toBe('lucene'); - const builder = new SearchQueryBuilder( - (condition as { condition: string }).condition, - serializer, - ); - const sql = await builder.build(); - expect(sql).toContain( - "has(`LogAttributeItems`, concat('service.name', '=', 'my-app'))", - ); - }); - - it('multiple included filter values each use has() direct_read', async () => { - const filters = filtersToQuery({ - "LogAttributes['env']": { - included: new Set<string | boolean>(['prod', 'staging']), - excluded: new Set<string | boolean>(), - }, }); - - const condition = filters[0]; - expect(condition.type).toBe('lucene'); - const builder = new SearchQueryBuilder( - (condition as { condition: string }).condition, - serializer, - ); - const sql = await builder.build(); - expect(sql).toContain( - "has(`LogAttributeItems`, concat('env', '=', 'prod'))", - ); - expect(sql).toContain( - "has(`LogAttributeItems`, concat('env', '=', 'staging'))", - ); - }); - - it('excluded filter value uses NOT has() direct_read', async () => { - const filters = filtersToQuery({ - "LogAttributes['level']": { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(['debug']), - }, - }); - - const condition = filters[0]; - expect(condition.type).toBe('lucene'); - const builder = new SearchQueryBuilder( - (condition as { condition: string }).condition, - serializer, - ); - const sql = await builder.build(); - expect(sql).toContain( - "NOT has(`LogAttributeItems`, concat('level', '=', 'debug'))", - ); }); }); }); diff --git a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx index 04b9a2a746..fb3a441601 100644 --- a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx +++ b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Control, UseFormSetValue, useWatch } from 'react-hook-form'; import { TableConnection, @@ -31,7 +31,9 @@ import { DEFAULT_TILE_ALERT } from '@/utils/alerts'; import { ConnectionSelectControlled } from '../ConnectionSelect'; import { OnClickFormButton } from '../DBEditTimeChartForm/OnClickForm/OnClickFormButton'; -import SourceSchemaPreview from '../SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from '../SourceSchemaPreview'; import { SourceSelectControlled } from '../SourceSelect'; import { SQL_PLACEHOLDERS } from './constants'; @@ -137,9 +139,8 @@ export default function RawSqlChartEditor({ return [...paramCompletions, ...macroCompletions]; }, [displayType]); - const sourceSchemaPreview = useMemo(() => { - return <SourceSchemaPreview source={sourceObject} variant="text" />; - }, [sourceObject]); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); const tableConnections: TableConnection[] = useMemo(() => { if (!sources) return []; @@ -206,7 +207,14 @@ export default function RawSqlChartEditor({ size="xs" clearable placeholder="None" - sourceSchemaPreview={sourceSchemaPreview} + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(sourceObject)} + /> + <SourceSchemaPreview + source={sourceObject} + controlled + open={isSourceSchemaPreviewOpen} + onClose={() => setIsSourceSchemaPreviewOpen(false)} /> </Group> <Group gap="xs"> diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index fdaa884e8c..62948f7109 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Control, FieldArrayWithId, @@ -25,7 +26,9 @@ import { } from '@/components/ChartEditor/types'; import MVOptimizationIndicator from '@/components/MaterializedViews/MVOptimizationIndicator'; import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; -import SourceSchemaPreview from '@/components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from '@/components/SourceSchemaPreview'; import { SourceSelectControlled } from '@/components/SourceSelect'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import { IS_LOCAL_MODE } from '@/config'; @@ -91,6 +94,9 @@ export function ChartEditorControls({ openDisplaySettings, openHeatmapSettings, }: ChartEditorControlsProps) { + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); + return ( <> <Flex mb="md" align="center" justify="space-between"> @@ -108,9 +114,14 @@ export function ChartEditorControls({ ? [...HEATMAP_ALLOWED_SOURCE_KINDS] : undefined } - sourceSchemaPreview={ - <SourceSchemaPreview source={tableSource} variant="text" /> - } + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(tableSource)} + /> + <SourceSchemaPreview + source={tableSource} + controlled + open={isSourceSchemaPreviewOpen} + onClose={() => setIsSourceSchemaPreviewOpen(false)} /> </Group> <Group> diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx index e889a6df69..161a5abfe6 100644 --- a/packages/app/src/components/DBRowJsonViewer.test.tsx +++ b/packages/app/src/components/DBRowJsonViewer.test.tsx @@ -53,10 +53,10 @@ describe('DBRowJsonViewer', () => { }); // Helper to render component - const renderComponent = (data: any, jsonColumns?: string[]) => { + const renderComponent = (data: any) => { return renderWithMantine( <RowSidePanelContext.Provider value={defaultContext}> - <DBRowJsonViewer data={data} jsonColumns={jsonColumns} /> + <DBRowJsonViewer data={data} /> </RowSidePanelContext.Provider>, ); }; @@ -133,21 +133,6 @@ describe('DBRowJsonViewer', () => { ); }); - it('adds filters on JSON-typed column as plain dot notation (no toString, no backticks)', () => { - const jsonData = { - Metadata: { - endpoint: 'grpc://thingy:8888/Verify', - }, - }; - renderComponent(jsonData, ['Metadata']); - clickLineButton('endpoint', 'Add to Filters'); - - expect(mockOnPropertyAddClick).toHaveBeenCalledWith( - 'Metadata.endpoint', - 'grpc://thingy:8888/Verify', - ); - }); - it('toggles columns with correct path formatting', () => { renderComponent(logData); clickLineButton('field1', 'Column'); diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index e6aea39c5f..65abd56115 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -27,7 +27,6 @@ import { IconTextWrap, } from '@tabler/icons-react'; -import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils'; import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson'; import { mergePath } from '@/utils'; import { @@ -371,6 +370,8 @@ export function DBRowJsonViewer({ ({ keyPath, value, isInParsedJson, parsedJsonRootPath }) => { const actions: LineAction[] = []; const fieldPath = mergePath(keyPath, jsonColumns); + const isJsonColumn = + keyPath.length > 0 && jsonColumns?.includes(keyPath[0]); // Add to Filters action (strings only) // FIXME: TOTAL HACK To disallow adding timestamp to filters @@ -393,6 +394,7 @@ export function DBRowJsonViewer({ onClick: () => { let filterFieldPath = fieldPath; + // Handle parsed JSON from string columns using JSONExtractString if (isInParsedJson && parsedJsonRootPath) { const jsonQuery = buildJSONExtractQuery( keyPath, @@ -401,14 +403,23 @@ export function DBRowJsonViewer({ ); if (jsonQuery) { filterFieldPath = jsonQuery; + } else { + // We're at the root of the parsed JSON, treat as string + filterFieldPath = isJsonColumn + ? `toString(${fieldPath})` + : fieldPath; } + } else { + // Regular JSON column or non-JSON field + filterFieldPath = isJsonColumn + ? `toString(${fieldPath})` + : fieldPath; } - const cleanedPath = cleanClickHouseExpression(filterFieldPath); - onPropertyAddClick(cleanedPath, value); + onPropertyAddClick(filterFieldPath, value); notifications.show({ color: 'green', - message: `Added "${cleanedPath} = ${value}" to filters`, + message: `Added "${fieldPath} = ${value}" to filters`, }); }, }); diff --git a/packages/app/src/components/DBTableSelect.tsx b/packages/app/src/components/DBTableSelect.tsx index e76740536d..0228db08f7 100644 --- a/packages/app/src/components/DBTableSelect.tsx +++ b/packages/app/src/components/DBTableSelect.tsx @@ -1,11 +1,14 @@ +import { useState } from 'react'; import { useController, UseControllerProps } from 'react-hook-form'; -import { Flex, Select } from '@mantine/core'; +import { ComboboxChevron, Flex, Select } from '@mantine/core'; import { IconTable } from '@tabler/icons-react'; import { useTablesDirect } from '@/clickhouse'; -import SourceSchemaPreview from './SourceSchemaPreview'; -import { SourceSelectRightSection } from './SourceSelect'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './SourceSchemaPreview'; +import { SourceManagementMenu } from './SourceSelect'; function DBTableSelect({ database, @@ -38,25 +41,22 @@ function DBTableSelect({ label: db.name, })); - const rightSectionProps = SourceSelectRightSection({ - sourceSchemaPreview: - connectionId && database && table ? ( - <SourceSchemaPreview - source={{ - connection: connectionId, - from: { databaseName: database, tableName: table }, - }} - variant="text" - /> - ) : undefined, - }); + const [isSchemaPreviewOpen, setIsSchemaPreviewOpen] = useState(false); + const previewSource = + connectionId && database && table + ? { + connection: connectionId, + from: { databaseName: database, tableName: table }, + } + : undefined; return ( - <Flex align="center" gap={8}> + <Flex align="center" gap={4}> <Select searchable placeholder="Table" leftSection={<IconTable size={16} />} + rightSection={<ComboboxChevron />} maxDropdownHeight={280} data={data} disabled={isTablesLoading} @@ -68,7 +68,17 @@ function DBTableSelect({ ref={inputRef} size={size} className="flex-grow-1" - {...rightSectionProps} + /> + <SourceManagementMenu + hasSelection={!!table} + onSchemaPreview={() => setIsSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(previewSource)} + /> + <SourceSchemaPreview + source={previewSource} + controlled + open={isSchemaPreviewOpen} + onClose={() => setIsSchemaPreviewOpen(false)} /> </Flex> ); diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index f1ffcb00e2..b6deae7923 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; import { useQueryState } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; @@ -28,7 +28,9 @@ import { parseAsJsonEncoded } from '@/utils/queryParsers'; import { RowDataPanel } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; -import SourceSchemaPreview from './SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './SourceSchemaPreview'; import { SourceSelectControlled } from './SourceSelect'; const eventRowWhereParser = parseAsJsonEncoded<{ @@ -141,9 +143,8 @@ export default function DBTracePanel({ }; }, [traceId, setEventRowWhere]); - const sourceSchemaPreview = useMemo(() => { - return <SourceSchemaPreview source={childSourceData} variant="text" />; - }, [childSourceData]); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); const [displayedTab, setDisplayedTab] = useState<Tab>(Tab.Overview); return ( @@ -177,7 +178,16 @@ export default function DBTracePanel({ control={control} name="source" size="xs" - sourceSchemaPreview={sourceSchemaPreview} + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled( + childSourceData, + )} + /> + <SourceSchemaPreview + source={childSourceData} + controlled + open={isSourceSchemaPreviewOpen} + onClose={() => setIsSourceSchemaPreviewOpen(false)} /> </Group> </Flex> diff --git a/packages/app/src/components/EventTag.tsx b/packages/app/src/components/EventTag.tsx index d2ae465604..9cb6177fb0 100644 --- a/packages/app/src/components/EventTag.tsx +++ b/packages/app/src/components/EventTag.tsx @@ -5,7 +5,6 @@ import { SearchConditionLanguage } from '@hyperdx/common-utils/dist/types'; import { Button, Group, Popover, Stack, Text, Tooltip } from '@mantine/core'; import { IconCirclePlus, IconLink, IconSearch } from '@tabler/icons-react'; -import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils'; import { isLinkableUrl } from '@/utils/highlightedAttributes'; export default function EventTag({ @@ -106,10 +105,7 @@ export default function EventTag({ size="xs" rightSection={<IconCirclePlus size={14} />} onClick={() => { - onPropertyAddClick( - cleanClickHouseExpression(sqlExpression), - value, - ); + onPropertyAddClick(sqlExpression, value); setOpened(false); }} > diff --git a/packages/app/src/components/FullscreenPanelModal.tsx b/packages/app/src/components/FullscreenPanelModal.tsx index e7f9c61c4a..1b3df9d793 100644 --- a/packages/app/src/components/FullscreenPanelModal.tsx +++ b/packages/app/src/components/FullscreenPanelModal.tsx @@ -1,6 +1,8 @@ import { Box, Modal } from '@mantine/core'; import { useHotkeys } from '@mantine/hooks'; +import { useZIndex, ZIndexContext } from '@/zIndex'; + export default function FullscreenPanelModal({ opened, onClose, @@ -15,6 +17,13 @@ export default function FullscreenPanelModal({ // YouTube-style 'f' key to toggle fullscreen useHotkeys([['f', () => opened && onClose()]]); + // Stack on top of any ancestor drawer/modal (`contextZIndex + 10`) and + // expose this value via `ZIndexContext` so any drawer/popover opened from + // inside the fullscreen view (e.g. clicking a row in a search tile) lands + // above the modal instead of being hidden behind it. + const contextZIndex = useZIndex(); + const modalZIndex = contextZIndex + 10; + return ( <Modal opened={opened} @@ -22,6 +31,7 @@ export default function FullscreenPanelModal({ title={title} fullScreen transitionProps={{ transition: 'fade', duration: 200 }} + zIndex={modalZIndex} styles={{ body: { height: 'calc(100vh - 80px)', @@ -42,17 +52,19 @@ export default function FullscreenPanelModal({ trapFocus={false} lockScroll > - <Box - h="100%" - w="100%" - p="md" - style={{ - overflow: 'auto', - position: 'relative', - }} - > - {children} - </Box> + <ZIndexContext.Provider value={modalZIndex}> + <Box + h="100%" + w="100%" + p="md" + style={{ + overflow: 'auto', + position: 'relative', + }} + > + {children} + </Box> + </ZIndexContext.Provider> </Modal> ); } diff --git a/packages/app/src/components/PageHeader.module.scss b/packages/app/src/components/PageHeader.module.scss index 47964b9227..1bde82d825 100644 --- a/packages/app/src/components/PageHeader.module.scss +++ b/packages/app/src/components/PageHeader.module.scss @@ -14,7 +14,51 @@ padding: 0 var(--mantine-spacing-sm); position: sticky; top: 0; - z-index: 100; + + /* Top-level drawers in this app are rendered at z-index 10 + (`contextZIndex + 10` from `ZIndexContext`, see e.g. `DBRowSidePanel`). + Sit 8 below that so the drawer overlay always covers the page chrome, + while the header still floats above normal scrolling content. */ + z-index: 2; +} + +/* Applied automatically when a `stickyRow` is provided: the chrome scrolls + away with the page and the `stickyRow` below becomes the only pinned bar. + The chrome's bottom border and bottom padding are also dropped — the + sticky row owns both the separator and the breathing room between page + chrome and scrolling content, so doubling up creates a visible gap. */ +.notSticky { + position: static; + top: auto; + z-index: auto; + border-bottom: none; + padding-block-end: 0; +} + +/* Sibling of `.header`. Pinned to the top of the scroll container; styled + to match the header (body background + bottom border) so chart/list + content scrolls cleanly underneath. Vertical padding adapts to the + "stuck" state via `.stickyRowAttached` — see below. */ +.stickyRow { + background-color: var(--color-bg-body); + border-bottom: 1px solid var(--color-border); + padding: var(--mantine-spacing-sm); + position: sticky; + top: 0; + + /* Match `.header` — sit 8 below the top-level drawer z-index (10) so + drawer overlays always cover this row. */ + z-index: 2; +} + +/* Applied when the sticky row sits directly under chrome AND is not + currently stuck. In that state we drop the row's `padding-top` so the + chrome and sticky row read as one continuous block (the chrome's own + bottom edge already provides spacing). As soon as the row sticks at + `top: 0`, this class is removed and the row's normal `sm` top padding + takes over, giving the toolbar breathing room from the viewport edge. */ +.stickyRowAttached { + padding-top: 0; } /* Breadcrumbs row + toolbar row inside one sticky header */ diff --git a/packages/app/src/components/PageHeader.tsx b/packages/app/src/components/PageHeader.tsx index 3ce58dcbaf..6945556553 100644 --- a/packages/app/src/components/PageHeader.tsx +++ b/packages/app/src/components/PageHeader.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import styles from './PageHeader.module.scss'; @@ -45,6 +46,21 @@ export type PageHeaderProps = { children?: React.ReactNode; className?: string; 'data-testid'?: string; + /** + * Single-row content that should be the only sticky element on the + * page. When provided, the rest of the header (`breadcrumbs`, + * `title` / `leading` / `actions`, `children`) becomes non-sticky + * chrome that scrolls away, and this row is pinned to the top of the + * scroll container instead. + * + * Use this for pages that have a tall header (e.g. dashboards with + * breadcrumbs + an editable name + a query toolbar) where only a + * specific row — typically the controls users keep reaching for while + * scrolling, like the query toolbar — should remain visible. + * + * When omitted, the header behaves as a single sticky block. + */ + stickyRow?: React.ReactNode; }; export function PageHeader({ @@ -55,20 +71,36 @@ export function PageHeader({ children, className, 'data-testid': testId, + stickyRow, }: PageHeaderProps) { const hasToolbar = title != null || leading != null || actions != null; const hasBreadcrumbs = breadcrumbs != null; + const hasStickyRow = stickyRow != null; - if (!hasToolbar && !hasBreadcrumbs) { - return ( - <header - className={classNames(styles.header, className)} - data-testid={testId} - > - {children} - </header> + // Detect when the sticky row reaches `top: 0` of the scroll container. + // `IntersectionObserver` with `rootMargin: -1px` at the top fires the + // moment the row's top edge is clipped by the viewport (i.e. it has + // become stuck). We use this to swap the row's `padding-top` between + // "attached to chrome" (no top padding, tight at rest) and "stuck" + // (standard top padding so the toolbar isn't flush with the edge). + const stickyRowRef = useRef<HTMLDivElement>(null); + const [isStuck, setIsStuck] = useState(false); + useEffect(() => { + const node = stickyRowRef.current; + if (!node || !hasStickyRow) return; + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(entry.intersectionRatio < 1), + { threshold: [1], rootMargin: '-1px 0px 0px 0px' }, ); - } + observer.observe(node); + return () => observer.disconnect(); + }, [hasStickyRow]); + // When a `stickyRow` is provided, the chrome above it scrolls with the + // page. The chrome and the sticky row render as siblings (Fragment) so + // the sticky row's containing block is the page layout root rather + // than the chrome `<header>`, which lets it stay pinned for the full + // length of the page rather than only the height of the header. + const chromeStickyClass = hasStickyRow ? styles.notSticky : undefined; const toolbarInner = ( <> @@ -80,36 +112,75 @@ export function PageHeader({ </> ); - if (hasBreadcrumbs && hasToolbar) { - return ( + const chrome = + !hasToolbar && !hasBreadcrumbs ? ( + // Only render the chrome `<header>` if there's something to put in + // it. A page with only a `stickyRow` (no breadcrumbs, no title) + // skips the empty chrome entirely. + children != null ? ( + <header + className={classNames(styles.header, chromeStickyClass, className)} + data-testid={testId} + > + {children} + </header> + ) : null + ) : hasBreadcrumbs && hasToolbar ? ( <header - className={classNames(styles.header, styles.headerStacked, className)} + className={classNames( + styles.header, + styles.headerStacked, + chromeStickyClass, + className, + )} data-testid={testId} > <div className={styles.breadcrumbsRow}>{breadcrumbs}</div> <div className={styles.toolbarRow}>{toolbarInner}</div> </header> - ); - } - - if (hasBreadcrumbs && !hasToolbar) { - return ( + ) : hasBreadcrumbs && !hasToolbar ? ( <header - className={classNames(styles.header, styles.headerStacked, className)} + className={classNames( + styles.header, + styles.headerStacked, + chromeStickyClass, + className, + )} data-testid={testId} > <div className={styles.breadcrumbsRow}>{breadcrumbs}</div> {children} </header> + ) : ( + <header + className={classNames(styles.header, chromeStickyClass, className)} + data-testid={testId} + > + {toolbarInner} + </header> ); - } + + if (!hasStickyRow) return chrome; return ( - <header - className={classNames(styles.header, className)} - data-testid={testId} - > - {toolbarInner} - </header> + <> + {chrome} + <div + ref={stickyRowRef} + className={classNames( + styles.stickyRow, + // While the row is in its at-rest position directly under + // chrome, drop its `padding-top` so the chrome's bottom edge + // and the toolbar read as one continuous block. Once stuck, + // the standard padding kicks back in so the toolbar isn't + // flush against the viewport edge. + chrome != null && !isStuck && styles.stickyRowAttached, + )} + data-stuck={isStuck ? 'true' : undefined} + data-testid="page-header-sticky-row" + > + {stickyRow} + </div> + </> ); } diff --git a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx index 8212e3c9c1..8d7829e3e1 100644 --- a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx +++ b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx @@ -305,7 +305,7 @@ export default function SQLInlineEditor({ // Only apply expanded styling when multiline is enabled and focused const isExpanded = allowMultiline && isFocused; - const baseHeight = size === 'xs' ? 32 : 36; + const baseHeight = size === 'xs' ? 30 : 36; return ( <div diff --git a/packages/app/src/components/SearchInput/AutocompleteInput.tsx b/packages/app/src/components/SearchInput/AutocompleteInput.tsx index 861c019bfc..0669f30a11 100644 --- a/packages/app/src/components/SearchInput/AutocompleteInput.tsx +++ b/packages/app/src/components/SearchInput/AutocompleteInput.tsx @@ -154,7 +154,7 @@ export default function AutocompleteInput({ }, [language, onLanguageChange, inputRef]); // Height including the 2px border from .textarea (1px top + 1px bottom) - const baseHeight = size === 'xs' ? 32 : size === 'lg' ? 44 : 38; + const baseHeight = size === 'xs' ? 30 : size === 'lg' ? 44 : 38; return ( <div diff --git a/packages/app/src/components/SearchInput/SearchWhereInput.module.scss b/packages/app/src/components/SearchInput/SearchWhereInput.module.scss index a2d3d2d849..21674397ad 100644 --- a/packages/app/src/components/SearchInput/SearchWhereInput.module.scss +++ b/packages/app/src/components/SearchInput/SearchWhereInput.module.scss @@ -56,10 +56,13 @@ } } - /* !important overrides the inline min-height set by react-textarea-autosize */ + /* !important overrides the inline min-height set by react-textarea-autosize. + Inner textarea is 2px shorter than the wrapper because the wrapper adds + a 1px border top/bottom, so 28px + 2px border = 30px outer (matches the + SQL inline editor's xs height). */ /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ &.sizeXs :global(.mantine-InputWrapper-root) textarea { - min-height: 30px !important; + min-height: 28px !important; } /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ @@ -79,7 +82,7 @@ background-color: var(--color-bg-field-addon); &.sizeXs { - height: 32px; + height: 30px; } &.sizeSm { diff --git a/packages/app/src/components/SelectControlled.tsx b/packages/app/src/components/SelectControlled.tsx index b4653198a1..439a263f10 100644 --- a/packages/app/src/components/SelectControlled.tsx +++ b/packages/app/src/components/SelectControlled.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useController, UseControllerProps } from 'react-hook-form'; import { Select, SelectProps } from '@mantine/core'; -export enum SelectControlledSpecialValues { +enum SelectControlledSpecialValues { CreateNewValue = '_create_new_value', EditValue = '_edit_value', } diff --git a/packages/app/src/components/SourceSchemaPreview.tsx b/packages/app/src/components/SourceSchemaPreview.tsx index 3dc10b5ef5..0b62799182 100644 --- a/packages/app/src/components/SourceSchemaPreview.tsx +++ b/packages/app/src/components/SourceSchemaPreview.tsx @@ -44,7 +44,7 @@ const SourceSchemaInfoIcon = ({ <Text fw={500} size="xs" - className="text-sucess-hover" + className="text-success-hover" style={{ cursor: isEnabled ? 'pointer' : 'default', ...iconStyles }} > Schema @@ -128,6 +128,15 @@ interface SourceSchemaPreviewProps { source?: SourceSchemaPreviewSource; iconStyles?: React.CSSProperties; variant?: 'icon' | 'text'; + /** + * When true, the trigger element (icon or text button) is NOT rendered; + * the parent owns open state and drives the modal via `open` / `onClose`. + * Useful when the trigger lives outside this component (e.g. a kebab menu + * adjacent to a source picker). + */ + controlled?: boolean; + open?: boolean; + onClose?: () => void; } const METRIC_TYPE_NAMES: Record<MetricsDataType, string> = { @@ -138,16 +147,21 @@ const METRIC_TYPE_NAMES: Record<MetricsDataType, string> = { [MetricsDataType.ExponentialHistogram]: 'Exponential Histogram', }; -const SourceSchemaPreview = ({ - source, - iconStyles, - variant = 'icon', -}: SourceSchemaPreviewProps) => { - const [isModalOpen, setIsModalOpen] = useState(false); - - const isMetricSource = source?.kind === 'metric'; +/** + * Build the list of tables (and their titles) to show in the schema preview + * modal for a given source. Internal helper that powers both + * `<SourceSchemaPreview>` rendering and `isSourceSchemaPreviewEnabled`, + * so callers can decide whether the preview has anything to show before + * rendering a trigger. + */ +function getSourceSchemaTables( + source?: SourceSchemaPreviewSource, +): (TableSchemaPreviewProps & { title: string })[] { const tables: (TableSchemaPreviewProps & { title: string })[] = []; - if (source && isMetricSource) { + if (!source) return tables; + + const isMetricSource = source.kind === 'metric'; + if (isMetricSource) { tables.push( ...Object.values(MetricsDataType) .map(metricType => ({ @@ -162,7 +176,7 @@ const SourceSchemaPreview = ({ title: METRIC_TYPE_NAMES[metricType], })), ); - } else if (source && source.from.tableName) { + } else if (source.from.tableName) { tables.push({ databaseName: source.from.databaseName, tableName: source.from.tableName, @@ -171,31 +185,61 @@ const SourceSchemaPreview = ({ }); } - const mvConfigs = source?.materializedViews ?? []; + const mvConfigs = source.materializedViews ?? []; tables.push( ...mvConfigs.map(({ tableName, databaseName }) => ({ databaseName, tableName, - connectionId: source!.connection, + connectionId: source.connection, title: `${tableName} (MV)`, })), ); - const isEnabled = !!source && tables.length > 0; + return tables; +} + +export function isSourceSchemaPreviewEnabled( + source?: SourceSchemaPreviewSource, +): boolean { + return !!source && getSourceSchemaTables(source).length > 0; +} + +const SourceSchemaPreview = ({ + source, + iconStyles, + variant = 'icon', + controlled = false, + open, + onClose, +}: SourceSchemaPreviewProps) => { + const [internalOpen, setInternalOpen] = useState(false); + const isModalOpen = controlled ? !!open : internalOpen; + const handleClose = () => { + if (controlled) { + onClose?.(); + } else { + setInternalOpen(false); + } + }; + + const tables = getSourceSchemaTables(source); + const isEnabled = isSourceSchemaPreviewEnabled(source); return ( <> - <SourceSchemaInfoIcon - isEnabled={isEnabled} - onClick={() => setIsModalOpen(true)} - iconStyles={iconStyles} - tableCount={tables.length} - variant={variant} - /> + {!controlled && ( + <SourceSchemaInfoIcon + isEnabled={isEnabled} + onClick={() => setInternalOpen(true)} + iconStyles={iconStyles} + tableCount={tables.length} + variant={variant} + /> + )} {isEnabled && ( <Modal opened={isModalOpen} - onClose={() => setIsModalOpen(false)} + onClose={handleClose} size="auto" title={tables.length > 1 ? `Table Schemas` : `Table Schema`} > diff --git a/packages/app/src/components/SourceSelect.tsx b/packages/app/src/components/SourceSelect.tsx index ef015b9597..d00e23e656 100644 --- a/packages/app/src/components/SourceSelect.tsx +++ b/packages/app/src/components/SourceSelect.tsx @@ -2,17 +2,24 @@ import { memo, useCallback, useMemo } from 'react'; import { UseControllerProps, useWatch } from 'react-hook-form'; import { SourceKind } from '@hyperdx/common-utils/dist/types'; import { + ActionIcon, ComboboxChevron, ComboboxItem, Group, + Menu, SelectProps, - UnstyledButton, + Tooltip, } from '@mantine/core'; -import { IconPlus, IconSettings, IconStack } from '@tabler/icons-react'; - -import SelectControlled, { - SelectControlledSpecialValues, -} from '@/components/SelectControlled'; +import { + IconCode, + IconDotsVertical, + IconPencil, + IconPlus, + IconSettings, + IconStack, +} from '@tabler/icons-react'; + +import SelectControlled from '@/components/SelectControlled'; import { SOURCE_KIND_ICONS, useFilteredSortedSourceItems, @@ -22,59 +29,144 @@ import { useSources } from '@/source'; import styles from '../../styles/SourceSelectControlled.module.scss'; -interface SourceSelectRightSectionProps { - sourceSchemaPreview?: React.ReactNode; +interface SourceManagementMenuProps { + hasSelection: boolean; + onSchemaPreview?: () => void; + isSchemaPreviewEnabled?: boolean; + /** + * Open the edit form for the currently selected source. Should act + * on `hasSelection ? currentSourceId : null`. Hidden from the menu + * when no handler is passed. + */ + onEdit?: () => void; + /** + * Open the list view of all sources (e.g. `/team#sources`). Hidden + * from the menu when no handler is passed; typically the case in + * local mode where no list-view surface exists. + */ + onManageSources?: () => void; + onCreate?: () => void; } -export const SourceSelectRightSection = ({ - sourceSchemaPreview, -}: SourceSelectRightSectionProps) => { - if (!sourceSchemaPreview) { - return { - rightSection: <ComboboxChevron />, - }; +/** + * Adjacent kebab menu that consolidates source-management actions: + * View schema, Edit source, Manage sources, Create new source. + * + * `Edit source` operates on the current selection; `Manage sources` + * opens the all-sources list view. Each item hides if its handler + * isn't wired, so callers can pick the right combination for their + * surface (local mode, for example, has no list view and should leave + * `onManageSources` unset). + * + * Exposed so non-`SourceSelectControlled` callers (e.g. `DBTableSelect`) + * can attach the same surface if needed. + */ +export const SourceManagementMenu = ({ + hasSelection, + onSchemaPreview, + isSchemaPreviewEnabled = true, + onEdit, + onManageSources, + onCreate, +}: SourceManagementMenuProps) => { + const items: React.ReactNode[] = []; + + if (onSchemaPreview) { + items.push( + <Menu.Item + key="view-schema" + leftSection={<IconCode size={14} />} + onClick={onSchemaPreview} + disabled={!hasSelection || !isSchemaPreviewEnabled} + > + View schema + </Menu.Item>, + ); + } + + if (onEdit) { + items.push( + <Menu.Item + key="edit-source" + leftSection={<IconPencil size={14} />} + onClick={onEdit} + disabled={!hasSelection} + > + Edit source + </Menu.Item>, + ); } - return { - rightSection: ( - <> - <UnstyledButton - onClick={(e: React.MouseEvent<HTMLButtonElement>) => { - e.stopPropagation(); - e.preventDefault(); - }} - className={styles.sourceSchemaPreviewButton} - > - {sourceSchemaPreview} - </UnstyledButton> - <ComboboxChevron /> - </> - ), - rightSectionWidth: 70, - }; -}; + if (onManageSources) { + items.push( + <Menu.Item + key="manage-sources" + leftSection={<IconSettings size={14} />} + onClick={onManageSources} + > + Manage sources + </Menu.Item>, + ); + } -const OPTION_ICONS: Record<string, React.ReactNode> = { - [SelectControlledSpecialValues.CreateNewValue]: <IconPlus size={14} />, - [SelectControlledSpecialValues.EditValue]: <IconSettings size={14} />, + if (onCreate) { + if (items.length > 0) { + items.push(<Menu.Divider key="divider-create" />); + } + items.push( + <Menu.Item + key="create-new-source" + leftSection={<IconPlus size={14} />} + onClick={onCreate} + > + Create new source + </Menu.Item>, + ); + } + + if (items.length === 0) return null; + + return ( + <Menu width={220} withinPortal position="bottom-end"> + <Menu.Target> + <Tooltip label="Source actions" position="top" withArrow> + <ActionIcon + variant="subtle" + color="gray" + size="input-xs" + className={styles.sourceMenuButton} + data-testid="source-actions-menu" + aria-label="Source actions" + > + <IconDotsVertical size={14} /> + </ActionIcon> + </Tooltip> + </Menu.Target> + <Menu.Dropdown>{items}</Menu.Dropdown> + </Menu> + ); }; function SourceSelectControlledComponent({ size, onCreate, onEdit, + onManageSources, + onSchemaPreview, + isSchemaPreviewEnabled, allowedSourceKinds, connectionId, comboboxProps, - sourceSchemaPreview, ...props }: { size?: string; onCreate?: () => void; onEdit?: () => void; + onManageSources?: () => void; + onSchemaPreview?: () => void; + isSchemaPreviewEnabled?: boolean; allowedSourceKinds?: SourceKind[]; connectionId?: string; - sourceSchemaPreview?: React.ReactNode; } & UseControllerProps<any> & SelectProps) { const { data } = useSources(); @@ -96,9 +188,7 @@ function SourceSelectControlledComponent({ const renderOption = useCallback( ({ option }: { option: ComboboxItem }) => { - const icon = - OPTION_ICONS[option.value] ?? - SOURCE_KIND_ICONS[sourceKindMap.get(option.value) ?? '']; + const icon = SOURCE_KIND_ICONS[sourceKindMap.get(option.value) ?? '']; if (!icon) return option.label; return ( <Group gap="xs" wrap="nowrap"> @@ -110,54 +200,50 @@ function SourceSelectControlledComponent({ [sourceKindMap], ); - const hasActions = !!onCreate || !!onEdit; - const sourceItems = useFilteredSortedSourceItems({ sources: data, allowedSourceKinds, connectionId, }); - const values = useMemo(() => { - if (!hasActions) { - return sourceItems; - } - - const actionItems: { value: string; label: string }[] = []; - if (onCreate) { - actionItems.push({ - value: SelectControlledSpecialValues.CreateNewValue, - label: 'Create New Source', - }); - } - if (onEdit) { - actionItems.push({ - value: SelectControlledSpecialValues.EditValue, - label: 'Edit Sources', - }); - } - - return [...sourceItems, { group: 'Actions', items: actionItems }]; - }, [sourceItems, onCreate, onEdit, hasActions]); - - const rightSectionProps = SourceSelectRightSection({ sourceSchemaPreview }); + const hasSelection = !!selectedSourceId; + const hasMenu = + !!onCreate || !!onEdit || !!onManageSources || !!onSchemaPreview; return ( - <SelectControlled - {...props} - data={values} - comboboxProps={{ withinPortal: false, ...comboboxProps }} - classNames={{ groupLabel: styles.groupLabel }} - renderOption={renderOption} - searchable - placeholder="Data Source" - leftSection={leftIcon} - maxDropdownHeight={280} - size={size} - onCreate={onCreate} - onEdit={onEdit} - {...rightSectionProps} - /> + <Group + gap={0} + wrap="nowrap" + className={styles.sourceSelectGroup} + data-with-menu={hasMenu || undefined} + > + <SelectControlled + {...props} + data={sourceItems} + comboboxProps={{ withinPortal: false, ...comboboxProps }} + classNames={{ + input: styles.sourceSelectInput, + groupLabel: styles.groupLabel, + }} + renderOption={renderOption} + searchable + placeholder="Data Source" + leftSection={leftIcon} + maxDropdownHeight={280} + size={size} + rightSection={<ComboboxChevron />} + /> + {hasMenu && ( + <SourceManagementMenu + hasSelection={hasSelection} + onSchemaPreview={onSchemaPreview} + isSchemaPreviewEnabled={isSchemaPreviewEnabled} + onEdit={onEdit} + onManageSources={onManageSources} + onCreate={onCreate} + /> + )} + </Group> ); } diff --git a/packages/app/src/components/Sources/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm.tsx index 7544b14bc1..a73ed1ca40 100644 --- a/packages/app/src/components/Sources/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm.tsx @@ -1521,6 +1521,59 @@ function TraceTableModelForm(props: TableModelProps) { ]} value={value} onChange={onChange} + // Mantine 9's Slider styles use the pattern + // `:where([data-orientation="vertical"]) .<part>`, + // which matches when ANY ancestor has + // `data-orientation="vertical"`. Mantine Card sets + // `data-orientation="vertical"` by default, and the + // SourceForm renders inside a Card, so the slider's + // trackContainer/track/bar/thumb/markWrapper/ + // markLabel all pick up the vertical-orientation + // styling: the track collapses to 8px wide and the + // four marks stack on top of each other. Override + // every affected part back to its horizontal + // default so the slider renders correctly inside + // the Card. + styles={{ + trackContainer: { + width: '100%', + flexDirection: 'row', + height: 'calc(var(--slider-size) * 2)', + }, + track: { + width: '100%', + height: 'var(--slider-size)', + }, + bar: { + top: 0, + bottom: 0, + height: '100%', + insetInlineStart: 'var(--slider-bar-offset)', + width: 'var(--slider-bar-width)', + }, + thumb: { + left: 'var(--slider-thumb-offset)', + top: '50%', + right: 'auto', + bottom: 'auto', + transform: 'translate(-50%, -50%)', + }, + markWrapper: { + insetInlineStart: + 'calc(var(--mark-offset) - var(--slider-size) / 2)', + top: 0, + bottom: 'auto', + width: 'auto', + }, + markLabel: { + transform: + 'translate(calc(-50% + var(--slider-size) / 2), calc(var(--mantine-spacing-xs) / 2))', + }, + label: { + top: '-36px', + insetInlineStart: 'auto', + }, + }} /> </div> )} @@ -1913,7 +1966,7 @@ function PromqlTableModelForm({ }, [setValue]); // PromQL sources use the standard database + table fields from BaseSourceSchema. - // No additional fields needed — the table should point to the TimeSeries engine table. + // No additional fields needed; the table should point to the TimeSeries engine table. return null; } diff --git a/packages/app/src/components/Sources/SourcesList.tsx b/packages/app/src/components/Sources/SourcesList.tsx index 54961ca122..56732610c9 100644 --- a/packages/app/src/components/Sources/SourcesList.tsx +++ b/packages/app/src/components/Sources/SourcesList.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; import { SourceKind } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, @@ -73,6 +74,29 @@ export function SourcesList({ const [editedSourceId, setEditedSourceId] = useState<string | null>(null); const [isCreatingSource, setIsCreatingSource] = useState(false); + // Honor `?source=<id>` from the URL so kebab "Edit source" deep-links + // expand the right entry. We auto-expand once per param value, then + // strip it from the URL so subsequent in-page interactions don't + // re-fight the user. `expandedFromQueryRef` prevents the effect from + // toggling back on every keystroke that ticks `router.query`. + const router = useRouter(); + const expandedFromQueryRef = useRef<string | null>(null); + useEffect(() => { + if (!router.isReady) return; + const sourceParam = router.query.source; + const sourceId = Array.isArray(sourceParam) ? sourceParam[0] : sourceParam; + if (!sourceId || expandedFromQueryRef.current === sourceId) return; + if (!sources?.some(s => s.id === sourceId)) return; + expandedFromQueryRef.current = sourceId; + setEditedSourceId(sourceId); + const { source: _omit, ...rest } = router.query; + void router.replace( + { pathname: router.pathname, query: rest, hash: 'sources' }, + undefined, + { shallow: true }, + ); + }, [router, sources]); + const isLoading = isLoadingConnections || isLoadingSources; const error = connectionsError || sourcesError; diff --git a/packages/app/src/components/TimePicker/TimePicker.tsx b/packages/app/src/components/TimePicker/TimePicker.tsx index d61862088f..6e5002495e 100644 --- a/packages/app/src/components/TimePicker/TimePicker.tsx +++ b/packages/app/src/components/TimePicker/TimePicker.tsx @@ -106,6 +106,7 @@ const TimePickerComponent = ({ isLiveMode = false, defaultRelativeTimeMode = false, width = 350, + size = 'sm', }: { inputValue: string; setInputValue: (str: string) => any; @@ -116,6 +117,7 @@ const TimePickerComponent = ({ isLiveMode?: boolean; defaultRelativeTimeMode?: boolean; width?: number | string; + size?: 'xs' | 'sm'; }) => { const { userPreferences: { timeFormat }, @@ -278,7 +280,7 @@ const TimePickerComponent = ({ onChange={event => onChange(event.currentTarget.value)} onClick={toggle} placeholder="Time Range" - size="sm" + size={size} w={width} onKeyDown={e => { if (e.key === 'Enter' && e.target instanceof HTMLInputElement) { diff --git a/packages/app/src/components/__tests__/DBTracePanel.test.tsx b/packages/app/src/components/__tests__/DBTracePanel.test.tsx index 8594db58da..ca95483eb4 100644 --- a/packages/app/src/components/__tests__/DBTracePanel.test.tsx +++ b/packages/app/src/components/__tests__/DBTracePanel.test.tsx @@ -39,6 +39,8 @@ jest.mock('../SourceSelect', () => ({ jest.mock('../SourceSchemaPreview', () => ({ __esModule: true, default: () => <div />, + isSourceSchemaPreviewEnabled: () => false, + getSourceSchemaTables: () => [], })); describe('DBTracePanel', () => { diff --git a/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx new file mode 100644 index 0000000000..d033a5a3fb --- /dev/null +++ b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import SourceSchemaPreview from '../SourceSchemaPreview'; + +jest.mock('@/hooks/useMetadata', () => ({ + useTableMetadata: () => ({ data: undefined, isLoading: false }), +})); + +jest.mock('../ChartSQLPreview', () => ({ + SQLPreview: () => <div data-testid="sql-preview">SQL Preview</div>, +})); + +const mockLogSource = { + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'logs' }, +}; + +describe('SourceSchemaPreview — controlled mode', () => { + it('does not render the uncontrolled trigger when controlled is true', () => { + renderWithMantine( + <SourceSchemaPreview + source={mockLogSource} + controlled + open={false} + onClose={jest.fn()} + />, + ); + // The non-controlled mode would render a clickable icon/text trigger; + // in controlled mode nothing should render when the modal is closed. + expect(screen.queryByText('Schema')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('opens the modal when open is true', () => { + renderWithMantine( + <SourceSchemaPreview + source={mockLogSource} + controlled + open={true} + onClose={jest.fn()} + />, + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not show the modal when open is false', () => { + renderWithMantine( + <SourceSchemaPreview + source={mockLogSource} + controlled + open={false} + onClose={jest.fn()} + />, + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('calls onClose when the modal close button is clicked', async () => { + const onClose = jest.fn(); + renderWithMantine( + <SourceSchemaPreview + source={mockLogSource} + controlled + open={true} + onClose={onClose} + />, + ); + // Mantine 9 CloseButton renders without aria-label; find by its class. + const closeBtn = document.querySelector( + '.mantine-Modal-close', + ) as HTMLElement; + expect(closeBtn).toBeTruthy(); + await userEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose on Escape key press', async () => { + const onClose = jest.fn(); + renderWithMantine( + <SourceSchemaPreview + source={mockLogSource} + controlled + open={true} + onClose={onClose} + />, + ); + await userEvent.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/components/__tests__/SourceSelect.test.tsx b/packages/app/src/components/__tests__/SourceSelect.test.tsx new file mode 100644 index 0000000000..08b1e23b37 --- /dev/null +++ b/packages/app/src/components/__tests__/SourceSelect.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SourceManagementMenu } from '../SourceSelect'; + +describe('SourceManagementMenu', () => { + it('does not render the kebab trigger when no actions are wired', () => { + renderWithMantine(<SourceManagementMenu hasSelection={false} />); + expect(screen.queryByTestId('source-actions-menu')).not.toBeInTheDocument(); + }); + + it('renders the kebab trigger when onSchemaPreview is wired', () => { + renderWithMantine( + <SourceManagementMenu hasSelection={false} onSchemaPreview={jest.fn()} />, + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + + it('renders the kebab trigger when only onEdit is wired', () => { + renderWithMantine( + <SourceManagementMenu hasSelection={false} onEdit={jest.fn()} />, + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + + it('renders the kebab trigger when only onManageSources is wired', () => { + renderWithMantine( + <SourceManagementMenu hasSelection={false} onManageSources={jest.fn()} />, + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + + it('renders the kebab trigger when only onCreate is wired', () => { + renderWithMantine( + <SourceManagementMenu hasSelection={false} onCreate={jest.fn()} />, + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + + describe('View schema item', () => { + it('is disabled when hasSelection is false', async () => { + renderWithMantine( + <SourceManagementMenu + hasSelection={false} + onSchemaPreview={jest.fn()} + isSchemaPreviewEnabled + />, + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + const viewSchemaBtn = await screen.findByText('View schema'); + expect(viewSchemaBtn.closest('button')).toHaveAttribute( + 'data-disabled', + 'true', + ); + }); + + it('is disabled when isSchemaPreviewEnabled is false', async () => { + renderWithMantine( + <SourceManagementMenu + hasSelection + onSchemaPreview={jest.fn()} + isSchemaPreviewEnabled={false} + />, + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + const viewSchemaBtn = await screen.findByText('View schema'); + expect(viewSchemaBtn.closest('button')).toHaveAttribute( + 'data-disabled', + 'true', + ); + }); + + it('is enabled when hasSelection and isSchemaPreviewEnabled are both true', async () => { + renderWithMantine( + <SourceManagementMenu + hasSelection + onSchemaPreview={jest.fn()} + isSchemaPreviewEnabled + />, + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + const viewSchemaBtn = await screen.findByText('View schema'); + expect(viewSchemaBtn.closest('button')).not.toHaveAttribute( + 'data-disabled', + ); + }); + + it('calls onSchemaPreview when clicked', async () => { + const onSchemaPreview = jest.fn(); + renderWithMantine( + <SourceManagementMenu + hasSelection + onSchemaPreview={onSchemaPreview} + isSchemaPreviewEnabled + />, + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(await screen.findByText('View schema')); + expect(onSchemaPreview).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onEdit when Edit source is clicked', async () => { + const onEdit = jest.fn(); + renderWithMantine( + // hasSelection required: Edit source is disabled without a selection. + <SourceManagementMenu hasSelection onEdit={onEdit} />, + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(await screen.findByText('Edit source')); + expect(onEdit).toHaveBeenCalledTimes(1); + }); + + it('calls onManageSources when Manage sources is clicked', async () => { + const onManageSources = jest.fn(); + renderWithMantine( + <SourceManagementMenu + hasSelection={false} + onManageSources={onManageSources} + />, + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(await screen.findByText('Manage sources')); + expect(onManageSources).toHaveBeenCalledTimes(1); + }); + + it('calls onCreate when Create new source is clicked', async () => { + const onCreate = jest.fn(); + renderWithMantine( + <SourceManagementMenu hasSelection={false} onCreate={onCreate} />, + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(await screen.findByText('Create new source')); + expect(onCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx index 98837b38d8..6384fb7540 100644 --- a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx +++ b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx @@ -881,8 +881,15 @@ describe('useDashboardFilterValues', () => { isLoading: false, }); - // filter2 (service.name) should not be in the map because the query failed - expect(result.current.data?.has('filter2')).toBe(false); + // filter2 (service.name) is still present with empty values (so the UI can + // keep the control interactive) and is flagged as errored so callers can + // surface a warning. + expect(result.current.data?.get('filter2')).toEqual({ + values: [], + isLoading: false, + }); + expect(result.current.erroredFilterIds.has('filter1')).toBe(false); + expect(result.current.erroredFilterIds.has('filter2')).toBe(true); // Overall error state should be true expect(result.current.isError).toBe(true); @@ -891,6 +898,44 @@ describe('useDashboardFilterValues', () => { expect(mockMetadata.getKeyValues).toHaveBeenCalledTimes(2); }); + it('keeps a filter that returned no values present and interactive', async () => { + // Arrange + jest.spyOn(sourceModule, 'useSources').mockReturnValue({ + data: mockSources, + isLoading: false, + } as any); + + jest + .mocked(optimizeGetKeyValuesCalls) + .mockImplementationOnce(async ({ chartConfig, keys }) => [ + { chartConfig, keys }, + ]); + + // Query succeeds but returns no rows for the requested key. + mockMetadata.getKeyValues.mockResolvedValueOnce([]); + + // Act + const { result } = renderHook( + () => + useDashboardFilterValues({ + filters: [mockFilters[0]], + dateRange: mockDateRange, + }), + { wrapper }, + ); + + // Assert + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Entry exists with empty values and is not loading → control stays usable. + expect(result.current.data?.get('filter1')).toEqual({ + values: [], + isLoading: false, + }); + expect(result.current.erroredFilterIds.has('filter1')).toBe(false); + expect(result.current.isError).toBe(false); + }); + it('should keep previous data while fetching new data (placeholderData behavior)', async () => { // Arrange jest.spyOn(sourceModule, 'useSources').mockReturnValue({ diff --git a/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx b/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx index 2aa490a135..baa8904b3b 100644 --- a/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx +++ b/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx @@ -90,7 +90,7 @@ describe('useDashboardFilters', () => { ); }); - it('should generate lucene condition for multi-select values', () => { + it('should generate IN clause for multi-select values', () => { const { result } = renderHook(() => useDashboardFilters(mockFilters)); act(() => { @@ -105,7 +105,7 @@ describe('useDashboardFilters', () => { const query = result2.current.filterQueries[0]; const condition = 'condition' in query ? query.condition : ''; expect(condition).toEqual( - '(environment:"production" OR environment:"staging")', + "toString(environment) IN ('production', 'staging')", ); }); @@ -272,81 +272,4 @@ describe('useDashboardFilters', () => { expect(result.current.ignoredFilterExpressions).toEqual([]); }); }); - - it('should match bracket-notation expressions after Lucene round-trip', () => { - const bracketFilters: DashboardFilter[] = [ - { - id: 'filter-bracket', - type: 'QUERY_EXPRESSION', - name: 'Pod', - expression: "SpanAttributes['k8s.pod.name']", - source: 'traces', - }, - ]; - - const { result } = renderHook(() => useDashboardFilters(bracketFilters)); - - act(() => { - result.current.setFilterValue("SpanAttributes['k8s.pod.name']", [ - 'pod-1', - ]); - }); - - const { result: result2 } = renderHook(() => - useDashboardFilters(bracketFilters), - ); - - // The bracket-notation expression should still match after the Lucene - // round-trip converts the key to dot notation internally. - expect( - result2.current.filterValues["SpanAttributes['k8s.pod.name']"]?.included, - ).toEqual(new Set(['pod-1'])); - expect(result2.current.ignoredFilterExpressions).toEqual([]); - }); - - it('should match dot-notation URL key to bracket-notation expression', () => { - const bracketFilters: DashboardFilter[] = [ - { - id: 'filter-bracket', - type: 'QUERY_EXPRESSION', - name: 'Pod', - expression: "SpanAttributes['k8s.pod.name']", - source: 'traces', - }, - ]; - - // Pre-seed URL state with a dot-notation Lucene filter (as would be - // stored after a round-trip through filtersToQuery → parseQuery). - mockState = [ - { - type: 'lucene', - condition: 'SpanAttributes.k8s.pod.name:"pod-1"', - }, - ]; - - const { result } = renderHook(() => useDashboardFilters(bracketFilters)); - - expect( - result.current.filterValues["SpanAttributes['k8s.pod.name']"]?.included, - ).toEqual(new Set(['pod-1'])); - expect(result.current.ignoredFilterExpressions).toEqual([]); - }); - - it('should migrate legacy SQL filters to Lucene on load', () => { - // Pre-seed URL with old-format SQL filters - mockState = [{ type: 'sql', condition: "environment IN ('production')" }]; - - renderHook(() => useDashboardFilters(mockFilters)); - - // Migration should have called setFilterQueries with Lucene format - expect(mockSetState).toHaveBeenCalled(); - const lastCall = - mockSetState.mock.calls[mockSetState.mock.calls.length - 1]; - // setFilterQueries receives an updater or a value; resolve it - const result = - typeof lastCall[0] === 'function' ? lastCall[0](mockState) : lastCall[0]; - expect(result).toEqual([ - { type: 'lucene', condition: 'environment:"production"' }, - ]); - }); }); diff --git a/packages/app/src/hooks/useDashboardFilterValues.tsx b/packages/app/src/hooks/useDashboardFilterValues.tsx index 8e36a05668..710eecb930 100644 --- a/packages/app/src/hooks/useDashboardFilterValues.tsx +++ b/packages/app/src/hooks/useDashboardFilterValues.tsx @@ -217,29 +217,42 @@ export function useDashboardFilterValues({ // Map results by filter ID instead of expression so that two filters with // the same expression but different sources/WHERE clauses get distinct values. - const flattenedData = useMemo( - () => - new Map( - results.flatMap(({ isLoading, data = [] }, resultIndex) => { - const call = calls[resultIndex]; - return data.flatMap(({ key: expression, value }) => { - const keyIndex = call.keys.indexOf(expression); - const filterIds = call.filterIds?.[keyIndex] ?? []; - const entry = { - values: value.map(v => v.toString()), - isLoading, - }; - return filterIds.map( - filterId => [filterId, entry] as [string, typeof entry], - ); - }); - }), - ), - [results, calls], - ); + // + // We iterate over the *requested* keys (not just the returned rows) so that a + // filter whose query returned no rows — or failed outright — still gets an + // entry (with empty `values`). Without this, such filters would be missing + // from the map entirely, and any consumer that derives "is still loading" + // from the entry's absence would leave the control stuck disabled forever. + // Failed filter IDs are tracked separately so callers can surface a warning + // while keeping the control interactive. + const { data: flattenedData, erroredFilterIds } = useMemo(() => { + const map = new Map<string, { values: string[]; isLoading: boolean }>(); + const errored = new Set<string>(); + results.forEach((result, resultIndex) => { + const call = calls[resultIndex]; + if (!call) return; + const { isLoading, isError, data = [] } = result; + const valuesByExpression = new Map<string, string[]>( + data.map(({ key, value }) => [key, value.map(v => v.toString())]), + ); + call.keys.forEach((expression, keyIndex) => { + const filterIds = call.filterIds?.[keyIndex] ?? []; + const values = valuesByExpression.get(expression) ?? []; + for (const filterId of filterIds) { + map.set(filterId, { values, isLoading }); + if (isError) { + errored.add(filterId); + } + } + }); + }); + return { data: map, erroredFilterIds: errored }; + }, [results, calls]); return { data: flattenedData, + /** Filter IDs whose key-values query failed. */ + erroredFilterIds, isLoading: isLoadingOptimizedCalls || results.every(r => r.isLoading), isFetching: isFetchingOptimizedCalls || results.some(r => r.isFetching), isError: results.some(r => r.isError), diff --git a/packages/app/src/hooks/useDashboardFilters.tsx b/packages/app/src/hooks/useDashboardFilters.tsx index 093cf84254..53211b0000 100644 --- a/packages/app/src/hooks/useDashboardFilters.tsx +++ b/packages/app/src/hooks/useDashboardFilters.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useMemo } from 'react'; import { useQueryState } from 'nuqs'; -import { parseKeyPath } from '@hyperdx/common-utils/dist/core/metadata'; import { FilterState, filtersToQuery, @@ -22,19 +21,19 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { (expression: string, values: string[]) => { setFilterQueries(prev => { const { filters: filterValues } = parseQuery(prev ?? []); - // Normalize the expression to dot notation so it matches the keys - // returned by parseQuery (which converts bracket notation to dots). - const key = parseKeyPath(expression).join('.'); if (values.length === 0) { - delete filterValues[key]; + delete filterValues[expression]; } else { - filterValues[key] = { + filterValues[expression] = { included: new Set(values), excluded: new Set(), }; } - return filtersToQuery(filterValues); + return filtersToQuery( + filterValues, + { stringifyKeys: false }, // Don't wrap keys with toString(), to preserve exact key names in URL query parameters + ); }); }, [setFilterQueries], @@ -48,28 +47,16 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { } = useMemo(() => { const { filters: parsedFilters } = parseQuery(filterQueries ?? []); const valuesForExistingFilters: FilterState = {}; + const knownExpressions = new Set(filters.map(f => f.expression)); const ignored: string[] = []; - // Build a normalized lookup so bracket-notation expressions - // (e.g. SpanAttributes['k8s.pod.name']) match the dot-notation keys - // returned by parseLuceneFilter (e.g. SpanAttributes.k8s.pod.name). - const normalizeKey = (k: string) => parseKeyPath(k).join('.'); - const normalizedParsed = new Map( - Object.entries(parsedFilters).map(([k, v]) => [normalizeKey(k), v]), - ); - const knownNormalized = new Set( - filters.map(f => normalizeKey(f.expression)), - ); - for (const { expression } of filters) { - const norm = normalizeKey(expression); - const match = normalizedParsed.get(norm); - if (match) { - valuesForExistingFilters[expression] = match; + if (expression in parsedFilters) { + valuesForExistingFilters[expression] = parsedFilters[expression]; } } for (const key of Object.keys(parsedFilters)) { - if (!knownNormalized.has(normalizeKey(key))) { + if (!knownExpressions.has(key)) { ignored.push(key); } } @@ -88,7 +75,12 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { return { valuesForExistingFilters, - queriesForExistingFilters: filtersToQuery(valuesForExistingFilters), + queriesForExistingFilters: filtersToQuery( + valuesForExistingFilters, + // Wrap keys in `toString()` to support JSON/Dynamic-type columns. + // All keys can be stringified, since filter select values are stringified as well. + { stringifyKeys: true }, + ), ignoredExpressions: ignored, filtersByExpression, }; @@ -123,20 +115,6 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { [valuesForExistingFilters, filtersByExpression], ); - // Migrate legacy SQL filters in the URL to Lucene on load - const hasMigratedRef = useRef(false); - useEffect(() => { - if (hasMigratedRef.current || !filterQueries) return; - const hasSqlFilters = filterQueries.some( - f => 'condition' in f && f.type === 'sql', - ); - if (hasSqlFilters) { - hasMigratedRef.current = true; - const { filters: parsed, passthroughFilters } = parseQuery(filterQueries); - setFilterQueries([...filtersToQuery(parsed), ...passthroughFilters]); - } - }, [filterQueries, setFilterQueries]); - return { filterValues: valuesForExistingFilters, filterQueries: queriesForExistingFilters, diff --git a/packages/app/src/searchFilters.tsx b/packages/app/src/searchFilters.tsx index 23ec18ba42..28d8fa50ae 100644 --- a/packages/app/src/searchFilters.tsx +++ b/packages/app/src/searchFilters.tsx @@ -1,11 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import produce from 'immer'; -import { parseKeyPath } from '@hyperdx/common-utils/dist/core/metadata'; import { - coerceBooleanValue, type FilterState, filtersToQuery, - parseLuceneFilter, } from '@hyperdx/common-utils/dist/filters'; import type { Filter } from '@hyperdx/common-utils/dist/types'; @@ -300,8 +297,6 @@ export const parseQuery = ( q: Filter[], ): { filters: FilterState; - /** Filters that could not be parsed into FilterState (preserved for re-emission) */ - passthroughFilters: Filter[]; } => { const state = new Map< string, @@ -311,29 +306,7 @@ export const parseQuery = ( range?: { min: number; max: number }; } >(); - const passthroughFilters: Filter[] = []; for (const filter of q) { - if (filter.type === 'lucene') { - const parsedFields = parseLuceneFilter(filter.condition); - if (parsedFields === undefined) { - // Parse failure — preserve so it isn't silently lost - if (filter.condition.trim()) { - passthroughFilters.push(filter); - } - } else { - for (const { key, included, excluded, range } of parsedFields) { - if (!state.has(key)) { - state.set(key, { included: new Set(), excluded: new Set() }); - } - const sets = state.get(key)!; - for (const v of included) sets.included.add(v); - for (const v of excluded) sets.excluded.add(v); - if (range) sets.range = range; - } - } - continue; - } - if (filter.type !== 'sql') continue; // Check for BETWEEN condition (only when BETWEEN appears outside quotes) @@ -379,7 +352,7 @@ export const parseQuery = ( }); } } - return { filters: Object.fromEntries(state), passthroughFilters }; + return { filters: Object.fromEntries(state) }; }; export const useSearchPageFilterState = ({ @@ -394,7 +367,7 @@ export const useSearchPageFilterState = ({ return parseQuery(searchQuery); } catch (e) { console.error(e); - return { filters: {}, passthroughFilters: [] }; + return { filters: {} }; } }, [searchQuery]); @@ -408,32 +381,11 @@ export const useSearchPageFilterState = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [parsedQuery.filters]); - // Migrate legacy SQL filters to Lucene on load so URLs become canonical - const hasMigratedRef = useRef(false); - useEffect(() => { - if (hasMigratedRef.current) return; - const hasSqlFilters = searchQuery?.some( - f => 'condition' in f && f.type === 'sql', - ); - if (hasSqlFilters && Object.keys(parsedQuery.filters).length > 0) { - hasMigratedRef.current = true; - onFilterChange([ - ...filtersToQuery(parsedQuery.filters), - ...parsedQuery.passthroughFilters, - ]); - } - }, [searchQuery, parsedQuery, onFilterChange]); - const updateFilterQuery = useCallback( (newFilters: FilterState) => { - // Preserve unparseable filters so they aren't silently lost when - // the user toggles a sidebar facet. - onFilterChange([ - ...filtersToQuery(newFilters), - ...parsedQuery.passthroughFilters, - ]); + onFilterChange(filtersToQuery(newFilters)); }, - [onFilterChange, parsedQuery.passthroughFilters], + [onFilterChange], ); const setFilterValue = useCallback( @@ -442,21 +394,15 @@ export const useSearchPageFilterState = ({ value: string | boolean, action?: 'only' | 'exclude' | 'include', ) => { - // Normalize bracket notation to dot notation so the key matches - // what parseQuery returns after a Lucene round-trip. - const key = parseKeyPath(property).join('.'); - // Normalize "true"/"false" strings to booleans so the value matches - // what parseLuceneFilter returns after a Lucene round-trip. - const normalizedValue = coerceBooleanValue(value); setFilters(prevFilters => { const newFilters = produce(prevFilters, draft => { - if (!draft[key]) { - draft[key] = { included: new Set(), excluded: new Set() }; + if (!draft[property]) { + draft[property] = { included: new Set(), excluded: new Set() }; } if (action === 'only') { - draft[key] = { - included: new Set([normalizedValue]), + draft[property] = { + included: new Set([value]), excluded: new Set(), }; return; @@ -464,22 +410,22 @@ export const useSearchPageFilterState = ({ if (action === 'exclude') { // Remove from included if it was there - draft[key].included.delete(normalizedValue); + draft[property].included.delete(value); // Toggle in excluded - if (draft[key].excluded.has(normalizedValue)) { - draft[key].excluded.delete(normalizedValue); + if (draft[property].excluded.has(value)) { + draft[property].excluded.delete(value); } else { - draft[key].excluded.add(normalizedValue); + draft[property].excluded.add(value); } return; } // Regular toggle (include) - draft[key].excluded.delete(normalizedValue); - if (draft[key].included.has(normalizedValue)) { - draft[key].included.delete(normalizedValue); + draft[property].excluded.delete(value); + if (draft[property].included.has(value)) { + draft[property].included.delete(value); } else { - draft[key].included.add(normalizedValue); + draft[property].included.add(value); } }); updateFilterQuery(newFilters); @@ -491,13 +437,12 @@ export const useSearchPageFilterState = ({ const setFilterRange = useCallback( (property: string, range: { min: number; max: number }) => { - const key = parseKeyPath(property).join('.'); setFilters(prevFilters => { const newFilters = produce(prevFilters, draft => { - if (!draft[key]) { - draft[key] = { included: new Set(), excluded: new Set() }; + if (!draft[property]) { + draft[property] = { included: new Set(), excluded: new Set() }; } - draft[key].range = range; + draft[property].range = range; }); updateFilterQuery(newFilters); return newFilters; @@ -508,10 +453,9 @@ export const useSearchPageFilterState = ({ const clearFilter = useCallback( (property: string) => { - const key = parseKeyPath(property).join('.'); setFilters(prevFilters => { const newFilters = produce(prevFilters, draft => { - delete draft[key]; + delete draft[property]; }); updateFilterQuery(newFilters); return newFilters; diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 0c77f5498c..0651f8ef9d 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -200,6 +200,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--palette-brand-300); + --color-viz-range-handle-grip: var(--color-text-inverted); + --color-viz-range-border: var(--palette-brand-700); + --color-viz-range-overlay: rgb(0 0 0 / 20%); + --color-viz-range-brush: rgb(250 255 105 / 20%); + --color-viz-range-brush-border: rgb(250 255 105 / 50%); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 25%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body) !important; --mantine-color-text: var(--color-text) !important; @@ -401,6 +412,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--mantine-color-indigo-8); + --color-viz-range-handle-grip: var(--mantine-color-indigo-1); + --color-viz-range-border: var(--mantine-color-indigo-8); + --color-viz-range-overlay: rgb(0 0 0 / 4%); + --color-viz-range-brush: rgb(67 126 239 / 20%); + --color-viz-range-brush-border: var(--mantine-color-indigo-8); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 4%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body) !important; --mantine-color-text: var(--color-text) !important; diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index 1503bbc4ae..88f8e6ff8b 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -116,6 +116,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--mantine-color-dark-2); + --color-viz-range-handle-grip: rgb(255 255 255 / 45%); + --color-viz-range-border: var(--mantine-color-dark-3); + --color-viz-range-overlay: rgb(0 0 0 / 20%); + --color-viz-range-brush: rgb(100 149 237 / 25%); + --color-viz-range-brush-border: rgb(100 149 237 / 60%); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 25%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body) !important; --mantine-color-text: var(--color-text); @@ -228,6 +239,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--mantine-color-gray-5); + --color-viz-range-handle-grip: rgb(255 255 255 / 80%); + --color-viz-range-border: var(--mantine-color-gray-4); + --color-viz-range-overlay: rgb(0 0 0 / 10%); + --color-viz-range-brush: rgb(66 105 208 / 20%); + --color-viz-range-brush-border: rgb(66 105 208 / 50%); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 4%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body); --mantine-color-text: var(--color-text); diff --git a/packages/app/styles/SourceSelectControlled.module.scss b/packages/app/styles/SourceSelectControlled.module.scss index f586b5d2a7..6f648df5be 100644 --- a/packages/app/styles/SourceSelectControlled.module.scss +++ b/packages/app/styles/SourceSelectControlled.module.scss @@ -1,3 +1,6 @@ +/* Legacy class. Still used by DBTableSelect for its inline schema-preview + * text button. New code uses SourceManagementMenu (kebab) instead. + */ .sourceSchemaPreviewButton { pointer-events: all; font-size: var(--mantine-font-size-xxs); @@ -16,3 +19,74 @@ border-color: var(--color-border); } } + +/* Chip + kebab wrapper. Holds the Mantine Select and the SourceManagementMenu + * ActionIcon as a single inline-flex unit. When the kebab is present + * (`[data-with-menu]`), the two surfaces are "glued" into a split-button chip: + * the adjoining corners are squared, the kebab overlaps the input's right + * border by 1px so they share a single vertical seam, and the kebab stretches + * to match the input's height so the chip reads as one unit. + * + * Override Mantine via its own CSS variables (`--input-radius`, `--ai-radius`) + * rather than fighting class-level `border-radius`: Mantine v7 reads these + * vars to compute the rounded corners, so setting them is the canonical + * override path. + */ +.sourceSelectGroup { + display: inline-flex; + align-items: stretch; + gap: 0; + + &[data-with-menu] { + /* Square only the right edge of the input. */ + .sourceSelectInput { + --input-radius: 0; + + border-top-left-radius: var(--mantine-radius-default); + border-bottom-left-radius: var(--mantine-radius-default); + } + + /* Square only the left edge of the kebab. Fixed 30px height matches the + * input chip; -1px margin overlaps the input's right edge so the shared + * border is one pixel wide, not two. */ + .sourceMenuButton { + --ai-radius: 0; + + width: 30px; + height: 30px; + margin-left: -1px; + background-color: var(--color-bg-field-addon); + border: 1px solid var(--input-bd, var(--color-border)); + border-top-right-radius: var(--mantine-radius-default); + border-bottom-right-radius: var(--mantine-radius-default); + } + } +} + +/* Chip hover state. The Mantine Select input is the "chip" surface; + * we tint its background and force a pointer cursor on hover so the + * chevron + label feel like a single clickable affordance even though + * the input itself is searchable. + */ +.sourceSelectInput { + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + + &:hover:not(:focus-within) { + background-color: var(--mantine-color-default-hover); + } + + /* Once the user actually starts typing, keep text cursor semantics. */ + &:focus-within { + cursor: text; + } +} + +/* Kebab button. Mantine's subtle variant gives the hover tint; layout shared + * with the input is set up in the `[data-with-menu]` block above. + */ +.sourceMenuButton { + flex-shrink: 0; +} diff --git a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts index d4f127b2aa..88c4d43d0e 100644 --- a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts @@ -205,10 +205,10 @@ test.describe( const url = new URL(page.url()); expect(url.searchParams.get('source')).toBe(logsSourceId); expect(url.searchParams.get('isLive')).toBe('false'); - // Default row click encodes the group-by filter as a Lucene condition - // in the JSON-encoded `filters` param. + // Default row click encodes the group-by filter as a SQL IN clause in + // the JSON-encoded `filters` param. const filters = url.searchParams.get('filters') ?? '[]'; - expect(filters).toContain(`ServiceName:\\"${serviceName}\\"`); + expect(filters).toContain(`ServiceName IN ('${serviceName}')`); const from = Number(url.searchParams.get('from')); const to = Number(url.searchParams.get('to')); expect(from).toBeGreaterThan(0); @@ -563,8 +563,8 @@ test.describe( const filters = JSON.parse(decodeURIComponent(filtersRaw!)); expect(filters).toEqual([ { - type: 'lucene', - condition: `ServiceName:"${serviceName}"`, + type: 'sql', + condition: `ServiceName IN ('${serviceName}')`, }, ]); }); @@ -649,8 +649,8 @@ test.describe( const filters = JSON.parse(decodeURIComponent(filtersRaw!)); expect(filters).toEqual([ { - type: 'lucene', - condition: `ServiceName:"${serviceName}"`, + type: 'sql', + condition: `ServiceName IN ('${serviceName}')`, }, ]); }); diff --git a/packages/app/tests/e2e/features/dashboard-template-import.spec.ts b/packages/app/tests/e2e/features/dashboard-template-import.spec.ts index f8099abaa8..1fd44c5ad9 100644 --- a/packages/app/tests/e2e/features/dashboard-template-import.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-template-import.spec.ts @@ -599,6 +599,88 @@ test.describe('Dashboard Template Import', { tag: ['@dashboard'] }, () => { }, ); + test( + 'should round-trip a dashboard through export and re-import', + { tag: '@full-stack' }, + async ({ page }) => { + const ts = Date.now(); + const dashboardName = `E2E Export Import ${ts}`; + const tileName = `Logs Count ${ts}`; + const filterName = `Service ${ts}`; + + await test.step('Create a dashboard with a tile and a filter', async () => { + await dashboardPage.goto(); + await dashboardPage.createNewDashboard(); + await dashboardPage.editDashboardName(dashboardName); + await dashboardPage.addTileWithSource( + tileName, + DEFAULT_LOGS_SOURCE_NAME, + ); + + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addFilterToDashboard( + filterName, + DEFAULT_LOGS_SOURCE_NAME, + 'ServiceName', + ); + await expect( + dashboardPage.getFilterItemByName(filterName), + ).toBeVisible(); + await dashboardPage.closeFiltersModal(); + // Export reads the in-memory dashboard state (not the persisted doc), + // so the tile + filter we just added are already present — no need to + // wait for the fire-and-forget PATCH to land. + }); + + let exported: Record<string, any> = {}; + await test.step('Export the dashboard and verify the downloaded template', async () => { + exported = await dashboardPage.exportDashboard(); + expect(exported.name).toBe(dashboardName); + expect(exported.tiles).toHaveLength(1); + expect(exported.tiles[0].config.source).toBe(DEFAULT_LOGS_SOURCE_NAME); + expect(exported.filters).toHaveLength(1); + expect(exported.filters[0]).toMatchObject({ + name: filterName, + expression: 'ServiceName', + source: DEFAULT_LOGS_SOURCE_NAME, + }); + }); + + await test.step('Re-import the exported template and finish (auto-resolved mappings)', async () => { + await dashboardImportPage.gotoImport(); + await dashboardImportPage.uploadDashboardFile(JSON.stringify(exported)); + await expect(dashboardImportPage.mappingStepHeading).toBeVisible(); + await expect(dashboardImportPage.dashboardNameInput).toHaveValue( + dashboardName, + ); + await dashboardImportPage.finishImportButton.click(); + await expect( + dashboardImportPage.getImportSuccessNotification(), + ).toBeVisible(); + await page.waitForURL(/\/dashboards\/[a-f0-9]{24}/); + }); + + await test.step('Verify the re-imported dashboard matches the original', async () => { + const logSources = await getSources(page, 'log'); + const logsSourceId = logSources.find( + (s: { name: string }) => s.name === DEFAULT_LOGS_SOURCE_NAME, + ).id; + + const importedId = dashboardPage.getCurrentDashboardId(); + const imported = await fetchDashboardById(page, importedId); + expect(imported.name).toBe(dashboardName); + expect(imported.tiles).toHaveLength(1); + expect(imported.tiles[0].config.source).toBe(logsSourceId); + expect(imported.filters).toHaveLength(1); + expect(imported.filters[0]).toMatchObject({ + name: filterName, + expression: 'ServiceName', + source: logsSourceId, + }); + }); + }, + ); + test( 'should drop an unmapped onClick from the imported tile', { tag: '@full-stack' }, diff --git a/packages/app/tests/e2e/features/sources.spec.ts b/packages/app/tests/e2e/features/sources.spec.ts index b56bad6fbf..3490a016d3 100644 --- a/packages/app/tests/e2e/features/sources.spec.ts +++ b/packages/app/tests/e2e/features/sources.spec.ts @@ -102,13 +102,20 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => { await searchPage.goto(); }); - test('should show source actions in dropdown', async () => { - // Open source selector dropdown - await searchPage.sourceDropdown.click(); + test('should show source actions in kebab menu', async () => { + // Open the source-actions kebab menu next to the source picker. + await searchPage.sourceActionsMenu.click(); - // Verify action items are visible in the dropdown + // Per-source and create actions are always wired. + await expect(searchPage.editSourceItem).toBeVisible(); await expect(searchPage.createNewSourceItem).toBeVisible(); - await expect(searchPage.editSourcesItem).toBeVisible(); + + // `Manage sources` is wired only in non-local (full-stack) mode. + if (process.env.E2E_FULLSTACK === 'true') { + await expect(searchPage.manageSourcesItem).toBeVisible(); + } else { + await expect(searchPage.manageSourcesItem).toHaveCount(0); + } }); test( @@ -142,7 +149,7 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => { ); test('should show proper fields when creating a new source', async () => { - await searchPage.sourceDropdown.click(); + await searchPage.sourceActionsMenu.click(); await searchPage.createNewSourceItem.click(); // for each source type (log, trace, session, metric), verify the correct fields are shown for (const sourceData of allSourcesData) { diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 93c14f1e6a..1d2e9cb6c4 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -86,6 +86,7 @@ export class DashboardPage { private readonly dashboardMenuButton: Locator; private readonly saveDefaultQueryAndFiltersMenuItem: Locator; private readonly removeDefaultQueryAndFiltersMenuItem: Locator; + private readonly exportDashboardMenuItem: Locator; constructor(page: Page) { this.page = page; @@ -137,6 +138,9 @@ export class DashboardPage { this.removeDefaultQueryAndFiltersMenuItem = page.getByTestId( 'remove-default-query-filters-menu-item', ); + this.exportDashboardMenuItem = page.getByTestId( + 'export-dashboard-menu-item', + ); } /** @@ -536,6 +540,22 @@ export class DashboardPage { await this.saveTile(); } + /** + * Add a tile bound to an explicit source. Unlike addTileWithConfig (which + * relies on the editor's default source), this selects a known source so the + * exported tile carries a deterministic source name that auto-maps on import. + */ + async addTileWithSource(chartName: string, sourceName: string) { + await this.addTile(); + await expect(this.chartEditor.nameInput).toBeVisible(); + await this.chartEditor.waitForDataToLoad(); + await this.chartEditor.setChartName(chartName); + await this.chartEditor.selectSource(sourceName); + await this.chartEditor.runQuery(false); + await this.chartEditor.save(); + await expect(this.getTiles()).toHaveCount(1, { timeout: 10000 }); + } + /** * Get all dashboard tiles */ @@ -616,6 +636,25 @@ export class DashboardPage { await this.removeDefaultQueryAndFiltersMenuItem.click(); } + /** + * Export the current dashboard via the dashboard menu and return the parsed + * JSON that was downloaded. The export triggers a client-side anchor download + * (see `downloadObjectAsJson` in DBDashboardPage), so we capture the + * Playwright download event and read its stream. + */ + async exportDashboard(): Promise<Record<string, any>> { + await this.dashboardMenuButton.click(); + const downloadPromise = this.page.waitForEvent('download'); + await this.exportDashboardMenuItem.click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString('utf-8')); + } + /** * Toggle live mode */ diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts index 6734ab72b9..9682c977f1 100644 --- a/packages/app/tests/e2e/page-objects/SearchPage.ts +++ b/packages/app/tests/e2e/page-objects/SearchPage.ts @@ -73,12 +73,24 @@ export class SearchPage { this.sourceSelector = page.getByTestId('source-selector'); } + get sourceActionsMenu() { + return this.page.getByTestId('source-actions-menu'); + } + get createNewSourceItem() { - return this.page.getByRole('option', { name: 'Create New Source' }); + return this.page.getByRole('menuitem', { name: 'Create new source' }); + } + + get editSourceItem() { + return this.page.getByRole('menuitem', { name: 'Edit source' }); } - get editSourcesItem() { - return this.page.getByRole('option', { name: 'Edit Sources' }); + get manageSourcesItem() { + return this.page.getByRole('menuitem', { name: 'Manage sources' }); + } + + get viewSchemaItem() { + return this.page.getByRole('menuitem', { name: 'View schema' }); } /** @@ -98,8 +110,8 @@ export class SearchPage { } async openEditSourceModal() { - await this.sourceSelector.click(); - await this.editSourcesItem.click(); + await this.sourceActionsMenu.click(); + await this.editSourceItem.click(); } async sourceModalShowOptionalFields() { diff --git a/packages/common-utils/src/__tests__/colon-in-mapkey.test.ts b/packages/common-utils/src/__tests__/colon-in-mapkey.test.ts deleted file mode 100644 index 59cc207949..0000000000 --- a/packages/common-utils/src/__tests__/colon-in-mapkey.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { filtersToQuery, parseLuceneFilter } from '@/filters'; -import { parse } from '@/queryParser'; - -describe('Colon-in-MAP-KEY scenario (LogAttributes with key containing colon)', () => { - it('parses Lucene when the colon in a field name is properly escaped', () => { - expect(() => - parse(String.raw`LogAttributes.foo\:bar:"value"`), - ).not.toThrow(); - }); - - it('filtersToQuery emits parseable Lucene for bracket-form Map key containing colon', () => { - const filters = { - "LogAttributes['foo:bar']": { - included: new Set<string | boolean>(['value1']), - excluded: new Set<string | boolean>(), - }, - }; - const result = filtersToQuery(filters); - const condition = (result[0] as { condition: string }).condition; - expect(condition).toBe(String.raw`LogAttributes.foo\:bar:"value1"`); - expect(() => parse(condition)).not.toThrow(); - }); - - it('filtersToQuery emits parseable Lucene for dot-form Map key containing colon', () => { - const filters = { - 'LogAttributes.foo:bar': { - included: new Set<string | boolean>(['value1']), - excluded: new Set<string | boolean>(), - }, - }; - const result = filtersToQuery(filters); - const condition = (result[0] as { condition: string }).condition; - expect(condition).toBe(String.raw`LogAttributes.foo\:bar:"value1"`); - expect(() => parse(condition)).not.toThrow(); - }); - - it('round-trips Map keys containing colons through filtersToQuery + parseLuceneFilter', () => { - const filters = { - "LogAttributes['foo:bar']": { - included: new Set<string | boolean>(['value1']), - excluded: new Set<string | boolean>(), - }, - }; - const [emitted] = filtersToQuery(filters); - const cond = (emitted as { condition: string }).condition; - const parsed = parseLuceneFilter(cond); - expect(parsed).toEqual([ - { - key: 'LogAttributes.foo:bar', - included: ['value1'], - excluded: [], - }, - ]); - }); - - it('round-trips Map keys with colons and excluded values', () => { - const filters = { - 'LogAttributes.foo:bar': { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(['unwanted:value']), - }, - }; - const [emitted] = filtersToQuery(filters); - const cond = (emitted as { condition: string }).condition; - expect(cond).toBe(String.raw`-LogAttributes.foo\:bar:"unwanted:value"`); - expect(parseLuceneFilter(cond)).toEqual([ - { - key: 'LogAttributes.foo:bar', - included: [], - excluded: ['unwanted:value'], - }, - ]); - }); - - it('round-trips Map keys with multiple colons in the key segment', () => { - const filters = { - "LogAttributes['k8s.io:annotation:foo']": { - included: new Set<string | boolean>(['v1']), - excluded: new Set<string | boolean>(), - }, - }; - const [emitted] = filtersToQuery(filters); - const cond = (emitted as { condition: string }).condition; - expect(() => parse(cond)).not.toThrow(); - expect(parseLuceneFilter(cond)).toEqual([ - { - key: 'LogAttributes.k8s.io:annotation:foo', - included: ['v1'], - excluded: [], - }, - ]); - }); - - it('round-trips Map key colon-in-key + colon-in-value simultaneously', () => { - const filters = { - "LogAttributes['foo:bar']": { - included: new Set<string | boolean>(['https://example.com:8080']), - excluded: new Set<string | boolean>(), - }, - }; - const [emitted] = filtersToQuery(filters); - const cond = (emitted as { condition: string }).condition; - expect(() => parse(cond)).not.toThrow(); - expect(parseLuceneFilter(cond)).toEqual([ - { - key: 'LogAttributes.foo:bar', - included: ['https://example.com:8080'], - excluded: [], - }, - ]); - }); -}); diff --git a/packages/common-utils/src/__tests__/filters.test.ts b/packages/common-utils/src/__tests__/filters.test.ts index 9ba03e7c30..1b998e2fda 100644 --- a/packages/common-utils/src/__tests__/filters.test.ts +++ b/packages/common-utils/src/__tests__/filters.test.ts @@ -1,38 +1,41 @@ -import { type FilterState, filtersToQuery, parseLuceneFilter } from '@/filters'; -import type { Filter } from '@/types'; - -/** Extract condition string from a Filter (filtersToQuery never emits sql_ast) */ -const getCondition = (f: Filter): string => - 'condition' in f ? f.condition : ''; +import { + filtersToQuery, + validateDashboardFilterQueries, + validateSavedFilterValues, +} from '@/filters'; +import type { DashboardFilter, Filter } from '@/types'; describe('filters', () => { describe('filtersToQuery', () => { - it('should return empty array when no filters', () => { - expect(filtersToQuery({})).toEqual([]); + it('should return empty string when no filters', () => { + const filters = {}; + expect(filtersToQuery(filters)).toEqual([]); }); - it('should emit lucene for a single included value', () => { + it('should return query for one filter', () => { const filters = { a: { included: new Set<string>(['b']), excluded: new Set<string>() }, }; expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'a:"b"' }, + { type: 'sql', condition: "a IN ('b')" }, ]); }); - it('should emit lucene with parens for multiple included values', () => { + it('should return query for multiple filters', () => { const filters = { + a: { included: new Set<string>(['b']), excluded: new Set<string>() }, c: { included: new Set<string>(['d', 'x']), excluded: new Set<string>(), }, }; expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: '(c:"d" OR c:"x")' }, + { type: 'sql', condition: "a IN ('b')" }, + { type: 'sql', condition: "c IN ('d', 'x')" }, ]); }); - it('should handle excluded values without parens', () => { + it('should handle excluded values', () => { const filters = { a: { included: new Set<string>(['b']), @@ -40,458 +43,337 @@ describe('filters', () => { }, }; expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'a:"b"' }, - { type: 'lucene', condition: '-a:"c"' }, + { type: 'sql', condition: "a IN ('b')" }, + { type: 'sql', condition: "a NOT IN ('c')" }, ]); }); - it('should handle multiple excluded values without parens', () => { + it('should wrap keys with toString() when specified', () => { const filters = { - a: { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>([true, false]), + 'json.key': { + included: new Set<string>(['value']), + excluded: new Set<string>(['other value']), }, }; - expect(filtersToQuery(filters)).toEqual([ - { - type: 'lucene', - condition: '-a:"true" AND -a:"false"', - }, + expect(filtersToQuery(filters, { stringifyKeys: true })).toEqual([ + { type: 'sql', condition: "toString(json.key) IN ('value')" }, + { type: 'sql', condition: "toString(json.key) NOT IN ('other value')" }, ]); }); - it('should handle boolean filter values', () => { + it('should should handle boolean filter values', () => { const filters = { isRootSpan: { included: new Set<string | boolean>([true]), excluded: new Set<string | boolean>([]), }, - }; - expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'isRootSpan:"true"' }, - ]); - }); - - it('should escape double quotes in values', () => { - const filters = { - message: { - included: new Set<string | boolean>(['say "hello"']), - excluded: new Set<string | boolean>(), + another_column: { + included: new Set<string | boolean>([]), + excluded: new Set<string | boolean>([true, false]), }, }; expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'message:"say \\"hello\\""' }, + { type: 'sql', condition: 'isRootSpan IN (true)' }, + { type: 'sql', condition: 'another_column NOT IN (true, false)' }, ]); }); - it('should pass through single quotes in values', () => { + it('should escape single quotes in filter values', () => { const filters = { message: { - included: new Set<string | boolean>(["it's a test"]), + included: new Set<string | boolean>(["my 'filter' key"]), excluded: new Set<string | boolean>(), }, }; expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'message:"it\'s a test"' }, + { + type: 'sql', + condition: "message IN ('my ''filter'' key')", + }, ]); }); - it('should escape backslashes in values', () => { + it('should escape single quotes in excluded filter values', () => { const filters = { - FilePath: { - included: new Set<string | boolean>(['C:\\path\\to\\file']), - excluded: new Set<string | boolean>(), + message: { + included: new Set<string | boolean>(), + excluded: new Set<string | boolean>(["it's a test"]), }, }; expect(filtersToQuery(filters)).toEqual([ { - type: 'lucene', - condition: 'FilePath:"C:\\\\path\\\\to\\\\file"', + type: 'sql', + condition: "message NOT IN ('it''s a test')", }, ]); }); - it('should convert Map bracket-notation keys to dot notation', () => { + it('should escape single quotes with stringifyKeys', () => { const filters = { - "LogAttributes['service.name']": { - included: new Set<string | boolean>(['my-app']), + 'json.key': { + included: new Set<string | boolean>(["value with 'quotes'"]), excluded: new Set<string | boolean>(), }, }; - expect(filtersToQuery(filters)).toEqual([ + expect(filtersToQuery(filters, { stringifyKeys: true })).toEqual([ { - type: 'lucene', - condition: 'LogAttributes.service.name:"my-app"', + type: 'sql', + condition: "toString(json.key) IN ('value with ''quotes''')", }, ]); }); - it('should handle Map bracket-notation with multiple values', () => { + it('should escape backslashes in filter values', () => { const filters = { - "LogAttributes['env']": { - included: new Set<string | boolean>(['prod', 'staging']), + FilePath: { + included: new Set<string | boolean>(['C:\\path\\to\\file']), excluded: new Set<string | boolean>(), }, }; expect(filtersToQuery(filters)).toEqual([ { - type: 'lucene', - condition: - '(LogAttributes.env:"prod" OR LogAttributes.env:"staging")', + type: 'sql', + condition: "FilePath IN ('C:\\\\path\\\\to\\\\file')", }, ]); }); - it('should handle Map bracket-notation excluded values', () => { + it('should escape backslashes in excluded filter values', () => { const filters = { - "LogAttributes['level']": { + FilePath: { included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(['debug']), + excluded: new Set<string | boolean>(['C:\\path\\to\\file']), }, }; expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: '-LogAttributes.level:"debug"' }, - ]); - }); - - it('should handle Map bracket-notation with both included and excluded', () => { - const filters = { - "ResourceAttributes['host']": { - included: new Set<string | boolean>(['web-1']), - excluded: new Set<string | boolean>(['web-2']), + { + type: 'sql', + condition: "FilePath NOT IN ('C:\\\\path\\\\to\\\\file')", }, - }; - expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'ResourceAttributes.host:"web-1"' }, - { type: 'lucene', condition: '-ResourceAttributes.host:"web-2"' }, ]); }); - it('should emit lucene range syntax', () => { + it('should escape backslashes before single quotes so quotes stay escaped', () => { const filters = { - duration: { - included: new Set<string | boolean>(), + message: { + included: new Set<string | boolean>(["a\\'b"]), excluded: new Set<string | boolean>(), - range: { min: 10, max: 500 }, }, }; expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'duration:[10 TO 500]' }, - ]); - }); - - it('should emit lucene range with dot notation for Map bracket-notation keys', () => { - const filters = { - "LogAttributes['latency']": { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(), - range: { min: 0, max: 100 }, + { + type: 'sql', + condition: "message IN ('a\\\\''b')", }, - }; - expect(filtersToQuery(filters)).toEqual([ - { type: 'lucene', condition: 'LogAttributes.latency:[0 TO 100]' }, ]); }); }); - describe('parseLuceneFilter', () => { - it('should return empty array for empty string', () => { - expect(parseLuceneFilter('')).toEqual([]); - }); - - it('should return empty array for unquoted terms', () => { - expect(parseLuceneFilter('service:foo')).toEqual([]); - }); - - it('should parse a single included term', () => { - expect(parseLuceneFilter('service:"app"')).toEqual([ - { key: 'service', included: ['app'], excluded: [] }, - ]); - }); - - it('should parse a single excluded term', () => { - expect(parseLuceneFilter('-service:"app"')).toEqual([ - { key: 'service', included: [], excluded: ['app'] }, - ]); - }); - - it('should parse multiple included values with OR', () => { - expect(parseLuceneFilter('(service:"app" OR service:"web")')).toEqual([ - { key: 'service', included: ['app', 'web'], excluded: [] }, - ]); - }); - - it('should parse multiple excluded values with AND', () => { - expect( - parseLuceneFilter('-service:"debug" AND -service:"trace"'), - ).toEqual([ - { key: 'service', included: [], excluded: ['debug', 'trace'] }, - ]); - }); - - it('should parse mixed included and excluded for the same field', () => { - expect( - parseLuceneFilter('-service:"bingo" (service:"foo" OR service:"bar")'), - ).toEqual([ - { key: 'service', included: ['foo', 'bar'], excluded: ['bingo'] }, - ]); - }); - - it('should parse multiple distinct fields', () => { - expect(parseLuceneFilter('service:"app" level:"info"')).toEqual([ - { key: 'service', included: ['app'], excluded: [] }, - { key: 'level', included: ['info'], excluded: [] }, - ]); - }); - - it('should parse multiple fields with mixed negation', () => { - expect( - parseLuceneFilter('service:"app" -level:"debug" level:"info"'), - ).toEqual([ - { key: 'service', included: ['app'], excluded: [] }, - { key: 'level', included: ['info'], excluded: ['debug'] }, - ]); + describe('validateSavedFilterValues', () => { + it('returns no issues for an empty array', () => { + expect(validateSavedFilterValues([])).toEqual([]); }); - it('should parse dot-notation fields (Map columns)', () => { - expect(parseLuceneFilter('LogAttributes.env:"prod"')).toEqual([ - { key: 'LogAttributes.env', included: ['prod'], excluded: [] }, - ]); + it('accepts a valid single-value lucene condition', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: 'ServiceName:"hdx-oss-dev-api"' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); }); - it('should handle values with escaped double quotes', () => { - expect(parseLuceneFilter('message:"say \\"hello\\""')).toEqual([ - { key: 'message', included: ['say "hello"'], excluded: [] }, - ]); + it('accepts a valid multi-value (OR) lucene condition', () => { + const filters: Filter[] = [ + { + type: 'lucene', + condition: + '(ServiceName:"hdx-oss-dev-api" OR ServiceName:"hdx-oss-dev-app")', + }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); }); - it('should handle values with backslashes', () => { - expect(parseLuceneFilter('path:"C:\\\\dir\\\\file"')).toEqual([ - { key: 'path', included: ['C:\\dir\\file'], excluded: [] }, - ]); + it('accepts lucene conditions over map / bracket-notation keys', () => { + const filters: Filter[] = [ + { + type: 'lucene', + condition: 'ResourceAttributes.k8s\\.pod\\.name:"checkout-0"', + }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); }); - it('should coerce "true"/"false" back to booleans', () => { - expect(parseLuceneFilter('isRootSpan:"true"')).toEqual([ - { key: 'isRootSpan', included: [true], excluded: [] }, - ]); - expect(parseLuceneFilter('-col:"true" AND -col:"false"')).toEqual([ - { key: 'col', included: [], excluded: [true, false] }, - ]); + it('accepts a valid sql condition', () => { + const filters: Filter[] = [ + { type: 'sql', condition: "ServiceName = 'hdx-oss-dev-api'" }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); }); - it('should parse range terms', () => { - expect(parseLuceneFilter('duration:[10 TO 500]')).toEqual([ + it('accepts a valid sql condition over a map access column', () => { + const filters: Filter[] = [ { - key: 'duration', - included: [], - excluded: [], - range: { min: 10, max: 500 }, + type: 'sql', + condition: "ResourceAttributes['service.name'] = 'checkout'", }, - ]); + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); }); - it('should parse range with dot-notation field', () => { - expect(parseLuceneFilter('LogAttributes.latency:[0 TO 100]')).toEqual([ + it('flags a malformed lucene condition', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: 'ServiceName:((("broken' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([ { - key: 'LogAttributes.latency', - included: [], - excluded: [], - range: { min: 0, max: 100 }, + index: 0, + language: 'lucene', + condition: 'ServiceName:((("broken', }, ]); }); - it('should return undefined for invalid lucene syntax', () => { - expect(parseLuceneFilter('((((')).toBeUndefined(); + it('flags a malformed sql condition', () => { + const filters: Filter[] = [ + { type: 'sql', condition: 'ServiceName = = ' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([ + { index: 0, language: 'sql', condition: 'ServiceName = = ' }, + ]); }); - }); - describe('filtersToQuery -> parseLuceneFilter round-trip', () => { - it('round-trips a single included value', () => { - const state: FilterState = { - service: { - included: new Set<string | boolean>(['app']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed).toEqual([ - { key: 'service', included: ['app'], excluded: [] }, + it('treats empty / whitespace-only conditions as valid (no-ops)', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: '' }, + { type: 'sql', condition: ' ' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('treats structurally-valid sql_ast filters as valid', () => { + const filters: Filter[] = [ + { type: 'sql_ast', operator: '=', left: 'ServiceName', right: 'api' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('reports the correct index for each invalid value in a mixed list', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: 'ServiceName:"good"' }, + { type: 'lucene', condition: 'Bad:((("' }, + { type: 'sql', condition: "Level = 'error'" }, + { type: 'sql', condition: 'broken = = =' }, + ]; + const issues = validateSavedFilterValues(filters); + expect(issues).toEqual([ + { index: 1, language: 'lucene', condition: 'Bad:((("' }, + { index: 3, language: 'sql', condition: 'broken = = =' }, ]); }); + }); - it('round-trips multiple included values', () => { - const state: FilterState = { - env: { - included: new Set<string | boolean>(['prod', 'staging']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed?.[0].key).toBe('env'); - expect(parsed?.[0].included).toEqual( - expect.arrayContaining(['prod', 'staging']), - ); + describe('validateDashboardFilterQueries', () => { + const filter = (overrides: Partial<DashboardFilter>): DashboardFilter => ({ + id: 'f1', + type: 'QUERY_EXPRESSION', + name: 'ServiceName', + expression: 'ServiceName', + source: 'logs', + ...overrides, }); - it('round-trips excluded values', () => { - const state: FilterState = { - level: { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(['debug', 'trace']), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed?.[0].key).toBe('level'); - expect(parsed?.[0].excluded).toEqual( - expect.arrayContaining(['debug', 'trace']), - ); + it('returns no issues for an empty array', () => { + expect(validateDashboardFilterQueries([])).toEqual([]); }); - it('round-trips boolean values preserving type', () => { - const state: FilterState = { - isRootSpan: { - included: new Set<string | boolean>([true]), - excluded: new Set<string | boolean>([false]), - }, - }; - const filters = filtersToQuery(state); - const includedParsed = parseLuceneFilter(getCondition(filters[0])); - const excludedParsed = parseLuceneFilter(getCondition(filters[1])); - expect(includedParsed?.[0].included).toEqual([true]); - expect(excludedParsed?.[0].excluded).toEqual([false]); - }); - - it('round-trips the literal string "true" without boolean coercion', () => { - // The string "true" is indistinguishable from boolean true after - // Lucene round-trip due to coerceBooleanValue. This test documents - // that known limitation. - const state: FilterState = { - status: { - included: new Set<string | boolean>(['true']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - // Coerced to boolean — this is the expected (lossy) behavior - expect(parsed?.[0].included).toEqual([true]); - }); - - it('round-trips values with double quotes', () => { - const state: FilterState = { - msg: { - included: new Set<string | boolean>(['say "hello"']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed?.[0].included).toEqual(['say "hello"']); + it('treats a filter with no where clause as valid', () => { + expect( + validateDashboardFilterQueries([filter({ whereLanguage: 'lucene' })]), + ).toEqual([]); }); - it('round-trips values with backslashes', () => { - const state: FilterState = { - path: { - included: new Set<string | boolean>(['C:\\dir\\file']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed?.[0].included).toEqual(['C:\\dir\\file']); + it('treats a whitespace-only where clause as valid', () => { + expect( + validateDashboardFilterQueries([ + filter({ where: ' ', whereLanguage: 'lucene' }), + ]), + ).toEqual([]); }); - it('round-trips empty string values', () => { - const state: FilterState = { - tag: { - included: new Set<string | boolean>(['']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed?.[0].included).toEqual(['']); + it('accepts a valid lucene where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ where: 'ServiceName:*', whereLanguage: 'lucene' }), + ]), + ).toEqual([]); }); - it('round-trips values containing Lucene reserved characters', () => { - const state: FilterState = { - query: { - included: new Set<string | boolean>(['(foo) AND [bar]']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed?.[0].included).toEqual(['(foo) AND [bar]']); + it('accepts a valid sql where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ where: "ServiceName != ''", whereLanguage: 'sql' }), + ]), + ).toEqual([]); }); - it('round-trips range filters', () => { - const state: FilterState = { - duration: { - included: new Set<string | boolean>(), - excluded: new Set<string | boolean>(), - range: { min: 10, max: 500 }, + it('flags a malformed lucene where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ + id: 'svc', + name: 'Service', + where: 'ServiceName:((("', + whereLanguage: 'lucene', + }), + ]), + ).toEqual([ + { + filterId: 'svc', + filterName: 'Service', + language: 'lucene', + where: 'ServiceName:((("', }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - expect(parsed?.[0].range).toEqual({ min: 10, max: 500 }); + ]); }); - it('round-trips Map bracket-notation key', () => { - const state: FilterState = { - "LogAttributes['service.name']": { - included: new Set<string | boolean>(['my-app']), - excluded: new Set<string | boolean>(), + it('flags a malformed sql where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ + id: 'svc', + name: 'Service', + where: 'ServiceName = =', + whereLanguage: 'sql', + }), + ]), + ).toEqual([ + { + filterId: 'svc', + filterName: 'Service', + language: 'sql', + where: 'ServiceName = =', }, - }; - const filters = filtersToQuery(state); - const parsed = parseLuceneFilter(getCondition(filters[0])); - // Bracket notation normalizes to dot notation - expect(parsed?.[0].key).toBe('LogAttributes.service.name'); - expect(parsed?.[0].included).toEqual(['my-app']); + ]); }); - it('round-trips a JSON column dot-path with colons and slashes in value', () => { - const state: FilterState = { - 'Metadata.endpoint': { - included: new Set<string | boolean>(['grpc://thingy:8888/Verify']), - excluded: new Set<string | boolean>(), - }, - }; - const filters = filtersToQuery(state); - const condition = getCondition(filters[0]); - expect(condition).not.toMatch(/^toString\(/); - expect(condition).not.toMatch(/`/); - const parsed = parseLuceneFilter(condition); - expect(parsed?.[0].key).toBe('Metadata.endpoint'); - expect(parsed?.[0].included).toEqual(['grpc://thingy:8888/Verify']); - }); - - it('round-trips mixed included and excluded for same field', () => { - const state: FilterState = { - env: { - included: new Set<string | boolean>(['prod', 'staging']), - excluded: new Set<string | boolean>(['dev']), + it('only reports the invalid filters in a mixed list', () => { + const issues = validateDashboardFilterQueries([ + filter({ id: 'a', where: 'ServiceName:*', whereLanguage: 'lucene' }), + filter({ + id: 'b', + name: 'Bad', + where: 'Bad:((("', + whereLanguage: 'lucene', + }), + filter({ id: 'c', where: "Level = 'error'", whereLanguage: 'sql' }), + ]); + expect(issues).toEqual([ + { + filterId: 'b', + filterName: 'Bad', + language: 'lucene', + where: 'Bad:((("', }, - }; - const filters = filtersToQuery(state); - // filtersToQuery emits separate Filter entries for included/excluded - expect(filters).toHaveLength(2); - // Parse both and merge - const allParsed = filters.flatMap( - f => parseLuceneFilter(getCondition(f)) ?? [], - ); - const envEntries = allParsed.filter(p => p.key === 'env'); - const included = envEntries.flatMap(e => e.included); - const excluded = envEntries.flatMap(e => e.excluded); - expect(included).toEqual(expect.arrayContaining(['prod', 'staging'])); - expect(excluded).toEqual(['dev']); + ]); }); }); }); diff --git a/packages/common-utils/src/__tests__/queryParser.test.ts b/packages/common-utils/src/__tests__/queryParser.test.ts index 343b9b9f2e..a2afc2118f 100644 --- a/packages/common-utils/src/__tests__/queryParser.test.ts +++ b/packages/common-utils/src/__tests__/queryParser.test.ts @@ -494,19 +494,6 @@ describe('CustomSchemaSQLSerializerV2 - json', () => { expect(actualSql).toBe(expectedSql); }); - it('"Add to Filters" repro: JSON column path + value with colons and slashes emits valid SQL', async () => { - const lucene = - 'ResourceAttributesJSON.endpoint:"grpc://auth-svc:50051/Verify"'; - const builder = new SearchQueryBuilder(lucene, serializer); - const actualSql = await builder.build(); - expect(actualSql).toContain( - 'toString(`ResourceAttributesJSON`.`endpoint`)', - ); - expect(actualSql).toContain('grpc://auth-svc:50051/Verify'); - expect(actualSql).not.toContain('http_COLON_'); - expect(actualSql).not.toContain('HDX_COLON'); - }); - describe('CustomSchemaSQLSerializerV2: implicit column falls back to bodyExpression', () => { // Symmetric counterpart to the one-way fallback in // `getEventBody` (packages/app/src/source.ts): a log source admin who diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index 077a9ae2f9..d70aa40882 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -13,6 +13,7 @@ import { import { aliasMapToWithClauses, + convertToDashboardDocument, convertToDashboardTemplate, extractSettingsClauseFromEnd, findJsonExpressions, @@ -605,6 +606,227 @@ describe('utils', () => { }); }); + it('should include saved default query and filter values in the template', () => { + const dashboard: z.infer<typeof DashboardSchema> = { + id: 'dashboard1', + name: 'Dashboard With Defaults', + tags: [], + tiles: [ + { + id: 'tile1', + config: { + name: 'Log Tile', + source: 'source1', + select: '', + where: '', + }, + x: 0, + y: 0, + w: 6, + h: 6, + }, + ], + savedQuery: 'level:error', + savedQueryLanguage: 'lucene', + savedFilterValues: [ + { + type: 'lucene', + condition: 'ServiceName:"accounting"', + }, + ], + }; + + const sources: TSource[] = [ + { + id: 'source1', + name: 'Logs', + connection: 'connection1', + kind: SourceKind.Log, + from: { + databaseName: 'db1', + tableName: 'logs_table', + }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + }, + ]; + + const template = convertToDashboardTemplate(dashboard, sources); + + expect(template.savedQuery).toBe('level:error'); + expect(template.savedQueryLanguage).toBe('lucene'); + expect(template.savedFilterValues).toEqual([ + { type: 'lucene', condition: 'ServiceName:"accounting"' }, + ]); + + const document = convertToDashboardDocument(template); + expect(document.savedQuery).toBe('level:error'); + expect(document.savedQueryLanguage).toBe('lucene'); + expect(document.savedFilterValues).toEqual([ + { type: 'lucene', condition: 'ServiceName:"accounting"' }, + ]); + }); + + describe('savedFilterValues export/import round trip', () => { + const baseSources: TSource[] = [ + { + id: 'source1', + name: 'Logs', + connection: 'connection1', + kind: SourceKind.Log, + from: { databaseName: 'db1', tableName: 'logs_table' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + }, + ]; + + const makeDashboard = ( + savedFilterValues?: z.infer< + typeof DashboardSchema + >['savedFilterValues'], + ): z.infer<typeof DashboardSchema> => ({ + id: 'dashboard1', + name: 'Service Filter Dashboard', + tags: [], + tiles: [ + { + id: 'tile1', + config: { + name: 'Log Tile', + source: 'source1', + select: '', + where: '', + }, + x: 0, + y: 0, + w: 6, + h: 6, + }, + ], + filters: [ + { + id: 'filter1', + type: 'QUERY_EXPRESSION', + name: 'ServiceName Filter', + expression: 'ServiceName', + source: 'source1', + }, + ], + ...(savedFilterValues !== undefined ? { savedFilterValues } : {}), + }); + + it('omits savedFilterValues when no services are selected (undefined)', () => { + const dashboard = makeDashboard(undefined); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in template).toBe(false); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in document).toBe(false); + }); + + it('omits savedFilterValues when the selection is an empty array', () => { + const dashboard = makeDashboard([]); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in template).toBe(false); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in document).toBe(false); + }); + + it('carries a single selected service through the round trip', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-oss-dev-api"', + }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toEqual(savedFilterValues); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toEqual(savedFilterValues); + }); + + it('carries multiple selected services through the round trip', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: + '(ServiceName:"hdx-oss-dev-api" OR ServiceName:"hdx-oss-dev-app")', + }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toEqual(savedFilterValues); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toEqual(savedFilterValues); + }); + + it('carries selected values across multiple filter expressions', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: + '(ServiceName:"hdx-oss-dev-api" OR ServiceName:"hdx-oss-dev-app")', + }, + { type: 'lucene' as const, condition: 'SeverityText:"error"' }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toEqual(savedFilterValues); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toEqual(savedFilterValues); + }); + + it('deep-clones savedFilterValues so the template does not alias the input', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-oss-dev-api"', + }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).not.toBe( + dashboard.savedFilterValues, + ); + expect(template.savedFilterValues?.[0]).not.toBe(savedFilterValues[0]); + + // Mutating the source after export must not leak into the template. + savedFilterValues[0].condition = 'ServiceName:"mutated"'; + const exported = template.savedFilterValues?.[0]; + expect(exported).toEqual({ + type: 'lucene', + condition: 'ServiceName:"hdx-oss-dev-api"', + }); + }); + + it('produces a template that validates against DashboardTemplateSchema', () => { + const dashboard = makeDashboard([ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-oss-dev-api"', + }, + ]); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(() => DashboardTemplateSchema.parse(template)).not.toThrow(); + }); + }); + it('should remap filter.appliesToSourceIds from IDs to names', () => { const sources: TSource[] = [ { diff --git a/packages/common-utils/src/core/__tests__/linkUrlBuilder.test.ts b/packages/common-utils/src/core/__tests__/linkUrlBuilder.test.ts index ee7b64b7f5..067e3dbbce 100644 --- a/packages/common-utils/src/core/__tests__/linkUrlBuilder.test.ts +++ b/packages/common-utils/src/core/__tests__/linkUrlBuilder.test.ts @@ -244,7 +244,7 @@ describe('renderOnClickSearch', () => { const params = new URLSearchParams(result.url.split('?')[1]); expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), - ).toEqual([{ type: 'lucene', condition: 'ServiceName:"MyService"' }]); + ).toEqual([{ type: 'sql', condition: "ServiceName IN ('MyService')" }]); } }); @@ -278,9 +278,7 @@ describe('renderOnClickSearch', () => { const params = new URLSearchParams(result.url.split('?')[1]); expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), - ).toEqual([ - { type: 'lucene', condition: '(ServiceName:"A" OR ServiceName:"B")' }, - ]); + ).toEqual([{ type: 'sql', condition: "ServiceName IN ('A', 'B')" }]); } }); @@ -315,8 +313,8 @@ describe('renderOnClickSearch', () => { expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), ).toEqual([ - { type: 'lucene', condition: 'ServiceName:"MyService"' }, - { type: 'lucene', condition: 'SeverityText:"error"' }, + { type: 'sql', condition: "ServiceName IN ('MyService')" }, + { type: 'sql', condition: "SeverityText IN ('error')" }, ]); } }); @@ -346,7 +344,7 @@ describe('renderOnClickSearch', () => { const params = new URLSearchParams(result.url.split('?')[1]); expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), - ).toEqual([{ type: 'lucene', condition: 'ServiceName:"O\'Malley"' }]); + ).toEqual([{ type: 'sql', condition: "ServiceName IN ('O''Malley')" }]); } }); @@ -376,7 +374,7 @@ describe('renderOnClickSearch', () => { expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), ).toEqual([ - { type: 'lucene', condition: 'FilePath:"C:\\\\path\\\\to\\\\file"' }, + { type: 'sql', condition: "FilePath IN ('C:\\\\path\\\\to\\\\file')" }, ]); } }); @@ -408,8 +406,8 @@ describe('renderOnClickSearch', () => { JSON.parse(decodeURIComponent(params.get('filters') ?? '')), ).toEqual([ { - type: 'lucene', - condition: 'SpanAttributes.url:"/users%2F42"', + type: 'sql', + condition: "SpanAttributes['url'] IN ('/users%2F42')", }, ]); } @@ -678,7 +676,7 @@ describe('renderOnClickDashboard', () => { const params = new URLSearchParams(result.url.split('?')[1]); expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), - ).toEqual([{ type: 'lucene', condition: 'ServiceName:"MyService"' }]); + ).toEqual([{ type: 'sql', condition: "ServiceName IN ('MyService')" }]); } }); @@ -712,9 +710,7 @@ describe('renderOnClickDashboard', () => { const params = new URLSearchParams(result.url.split('?')[1]); expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), - ).toEqual([ - { type: 'lucene', condition: '(ServiceName:"A" OR ServiceName:"B")' }, - ]); + ).toEqual([{ type: 'sql', condition: "ServiceName IN ('A', 'B')" }]); } }); @@ -749,8 +745,8 @@ describe('renderOnClickDashboard', () => { expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), ).toEqual([ - { type: 'lucene', condition: 'ServiceName:"MyService"' }, - { type: 'lucene', condition: 'SeverityText:"error"' }, + { type: 'sql', condition: "ServiceName IN ('MyService')" }, + { type: 'sql', condition: "SeverityText IN ('error')" }, ]); } }); @@ -780,7 +776,7 @@ describe('renderOnClickDashboard', () => { const params = new URLSearchParams(result.url.split('?')[1]); expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), - ).toEqual([{ type: 'lucene', condition: 'ServiceName:"O\'Malley"' }]); + ).toEqual([{ type: 'sql', condition: "ServiceName IN ('O''Malley')" }]); } }); @@ -810,7 +806,7 @@ describe('renderOnClickDashboard', () => { expect( JSON.parse(decodeURIComponent(params.get('filters') ?? '')), ).toEqual([ - { type: 'lucene', condition: 'FilePath:"C:\\\\path\\\\to\\\\file"' }, + { type: 'sql', condition: "FilePath IN ('C:\\\\path\\\\to\\\\file')" }, ]); } }); @@ -842,8 +838,8 @@ describe('renderOnClickDashboard', () => { JSON.parse(decodeURIComponent(params.get('filters') ?? '')), ).toEqual([ { - type: 'lucene', - condition: 'SpanAttributes.url:"/users%2F42"', + type: 'sql', + condition: "SpanAttributes['url'] IN ('/users%2F42')", }, ]); } diff --git a/packages/common-utils/src/core/eventDeltas.ts b/packages/common-utils/src/core/eventDeltas.ts index 060b40d3bc..e969e4be57 100644 --- a/packages/common-utils/src/core/eventDeltas.ts +++ b/packages/common-utils/src/core/eventDeltas.ts @@ -1,6 +1,6 @@ /** * Event-deltas algorithm — pure functions used by both the HyperDX UI - * (DBDeltaChart) and the MCP server (hyperdx_event_deltas tool). + * (DBDeltaChart) and the MCP server (clickstack_event_deltas tool). * * Given two row sets — a target group and a baseline group — the algorithm * ranks each property by how differently its values are distributed between @@ -22,7 +22,7 @@ */ export function flattenData(data: Record<string, any>): Record<string, any> { const result: Record<string, any> = {}; - // eslint-disable-next-line security/detect-object-injection -- prop is built from known object keys via recursion, not user input + function recurse(cur: Record<string, any>, prop: string) { if (Object(cur) !== cur) { result[prop] = cur; // eslint-disable-line security/detect-object-injection diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index 5f95e6cb0b..2095771aca 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -672,6 +672,15 @@ export function convertToDashboardTemplate( output.containers = structuredClone(input.containers); } + if (input.savedQuery != null) { + output.savedQuery = input.savedQuery; + output.savedQueryLanguage = input.savedQueryLanguage ?? null; + } + + if (input.savedFilterValues && input.savedFilterValues.length > 0) { + output.savedFilterValues = structuredClone(input.savedFilterValues); + } + return output; } @@ -711,6 +720,15 @@ export function convertToDashboardDocument( output.containers = structuredClone(input.containers); } + if (input.savedQuery != null) { + output.savedQuery = input.savedQuery; + output.savedQueryLanguage = input.savedQueryLanguage ?? null; + } + + if (input.savedFilterValues && input.savedFilterValues.length > 0) { + output.savedFilterValues = structuredClone(input.savedFilterValues); + } + return output; } diff --git a/packages/common-utils/src/filters.ts b/packages/common-utils/src/filters.ts index e5ded218a4..194311a511 100644 --- a/packages/common-utils/src/filters.ts +++ b/packages/common-utils/src/filters.ts @@ -1,15 +1,8 @@ -import lucene from '@hyperdx/lucene'; +import * as SQLParser from 'node-sql-parser'; -import { parseKeyPath } from '@/core/metadata'; -import { - decodeSpecialTokens, - isBinaryAST, - isLeftOnlyAST, - isNodeRangedTerm, - isNodeTerm, - parse, -} from '@/queryParser'; -import { Filter } from '@/types'; +import { replaceJsonExpressions } from '@/core/utils'; +import { parse } from '@/queryParser'; +import { DashboardFilter, Filter } from '@/types'; export type FilterState = { [key: string]: { @@ -19,25 +12,14 @@ export type FilterState = { }; }; -/** Escape a value for use inside a Lucene quoted term ("...") */ -const escapeLuceneQuotedTerm = (s: string) => { - return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +const escapeString = (s: string) => { + return s.replace(/\\/g, '\\\\').replace(/'/g, "''"); }; -/** - * Escape backslashes and colons so the field name survives Lucene parsing. - * Map sub-keys can legitimately contain `:` (e.g. `LogAttributes['foo:bar']` - * normalizes to `LogAttributes.foo:bar` via parseKeyPath().join('.')), and - * `:` is the Lucene field/value separator. Backslashes are escaped first so - * the inserted `\:` survives `encodeSpecialTokens`' `\\` → backslash-literal - * substitution; the encoder's matching `\:` → HDX_COLON rule then makes the - * colon opaque to the parser, and `decodeSpecialTokens` restores the - * original key on the consumer side. - */ -const escapeLuceneFieldName = (key: string): string => - key.replace(/\\/g, '\\\\').replace(/:/g, '\\:'); - -export const filtersToQuery = (filters: FilterState): Filter[] => { +export const filtersToQuery = ( + filters: FilterState, + { stringifyKeys = false }: { stringifyKeys?: boolean } = {}, +): Filter[] => { return Object.entries(filters) .filter( ([_, values]) => @@ -47,137 +29,156 @@ export const filtersToQuery = (filters: FilterState): Filter[] => { ) .flatMap(([key, values]) => { const conditions: Filter[] = []; - const luceneField = escapeLuceneFieldName(parseKeyPath(key).join('.')); + const actualKey = stringifyKeys ? `toString(${key})` : key; if (values.included.size > 0) { - const terms = Array.from(values.included).map( - v => `${luceneField}:"${escapeLuceneQuotedTerm(String(v))}"`, - ); conditions.push({ - type: 'lucene' as const, - condition: terms.length > 1 ? `(${terms.join(' OR ')})` : terms[0], + type: 'sql' as const, + condition: `${actualKey} IN (${Array.from(values.included) + .map(v => (typeof v === 'string' ? `'${escapeString(v)}'` : v)) + .join(', ')})`, }); } if (values.excluded.size > 0) { conditions.push({ - type: 'lucene' as const, - condition: Array.from(values.excluded) - .map(v => `-${luceneField}:"${escapeLuceneQuotedTerm(String(v))}"`) - .join(' AND '), + type: 'sql' as const, + condition: `${actualKey} NOT IN (${Array.from(values.excluded) + .map(v => (typeof v === 'string' ? `'${escapeString(v)}'` : v)) + .join(', ')})`, }); } - if (values.range != null) { - // Lucene range syntax: field:[min TO max] conditions.push({ - type: 'lucene' as const, - condition: `${luceneField}:[${values.range.min} TO ${values.range.max}]`, + type: 'sql' as const, + condition: `${actualKey} BETWEEN ${values.range.min} AND ${values.range.max}`, }); } return conditions; }); }; -type CollectedTerm = { field: string; value: string; negated: boolean }; -type CollectedRange = { field: string; min: number; max: number }; +export type SavedFilterValueIssue = { + /** Index of the offending value within the input array */ + index: number; + /** Query language the condition claims to be written in */ + language: 'lucene' | 'sql'; + /** The raw condition string that failed to parse */ + condition: string; +}; -/** - * Collect quoted terms and range terms from a Lucene AST. - */ -function collectFromAst( - ast: lucene.AST | lucene.Node, - terms: CollectedTerm[], - ranges: CollectedRange[], -): void { - if (isNodeTerm(ast)) { - if (!ast.quoted) return; - const negated = ast.field.startsWith('-'); - const field = decodeSpecialTokens(negated ? ast.field.slice(1) : ast.field); - terms.push({ field, value: decodeSpecialTokens(ast.term), negated }); - } else if (isNodeRangedTerm(ast)) { - const field = decodeSpecialTokens(ast.field); - const min = parseFloat(ast.term_min); - const max = parseFloat(ast.term_max); - if (!isNaN(min) && !isNaN(max)) { - ranges.push({ field, min, max }); - } - } else if (isBinaryAST(ast)) { - collectFromAst(ast.left, terms, ranges); - collectFromAst(ast.right, terms, ranges); - } else if (isLeftOnlyAST(ast)) { - collectFromAst(ast.left, terms, ranges); +const isParseableLucene = (condition: string): boolean => { + try { + parse(condition); + return true; + } catch { + return false; } -} +}; -/** Coerce "true"/"false" strings back to booleans, pass through otherwise */ -export function coerceBooleanValue(v: string | boolean): string | boolean { - if (typeof v === 'boolean') return v; - if (v === 'true') return true; - if (v === 'false') return false; - return v; -} +// node-sql-parser can't handle ClickHouse map / array access (e.g. +// `LogAttributes['k']` or `arr[1]`), so swap those out for harmless literals +// before parsing — we only care whether the predicate is structurally valid. +const MAP_OR_ARRAY_ACCESS_REGEX = /\b[a-zA-Z0-9_]+\[([0-9]+|'[^']*')\]/g; -export type ParsedLuceneFilter = { - key: string; - included: (string | boolean)[]; - excluded: (string | boolean)[]; - range?: { min: number; max: number }; +const isParseableSql = (condition: string): boolean => { + try { + const { sqlWithReplacements } = replaceJsonExpressions(condition); + const sanitized = sqlWithReplacements.replace( + MAP_OR_ARRAY_ACCESS_REGEX, + "''", + ); + new SQLParser.Parser().astify(`SELECT 1 FROM t WHERE ${sanitized}`, { + database: 'Postgresql', + }); + return true; + } catch { + return false; + } }; /** - * Parse a Lucene filter condition back into per-field included/excluded values - * and optional ranges. - * - * Returns an array with one entry per distinct field (empty array if the input - * is valid Lucene but contains no quoted terms or ranges), or undefined if - * parsing fails entirely. + * Whether a condition string parses as the given query language. Empty / + * whitespace-only conditions are considered valid (they're no-ops, not errors). + * `promql` is not statically validated here and is treated as valid. */ -export function parseLuceneFilter( +export function isValidFilterCondition( condition: string, -): ParsedLuceneFilter[] | undefined { - try { - const ast = parse(condition); - const terms: CollectedTerm[] = []; - const ranges: CollectedRange[] = []; - collectFromAst(ast, terms, ranges); - - // Group by field, coercing "true"/"false" back to booleans - const byField = new Map< - string, - { - included: (string | boolean)[]; - excluded: (string | boolean)[]; - range?: { min: number; max: number }; - } - >(); - - const getEntry = (field: string) => { - if (!byField.has(field)) { - byField.set(field, { included: [], excluded: [] }); - } - return byField.get(field)!; - }; + language: 'lucene' | 'sql' | 'promql', +): boolean { + if (!condition.trim()) return true; + if (language === 'lucene') return isParseableLucene(condition); + if (language === 'sql') return isParseableSql(condition); + return true; +} - for (const t of terms) { - const entry = getEntry(t.field); - const value = coerceBooleanValue(t.value); - if (t.negated) { - entry.excluded.push(value); - } else { - entry.included.push(value); - } +/** + * Validate the condition strings carried in a dashboard's saved filter values. + * + * Schema validation only guarantees each value has a `{ type, condition }` + * shape — the condition text itself is a free-form string and may be broken in + * a hand-edited or machine-generated export. This returns one issue per value + * whose condition fails to parse as the language it claims to be, so callers + * can warn the user (e.g. on import) without hard-blocking the operation. + * + * Empty / whitespace-only conditions are treated as valid (they're no-ops at + * query time, not errors), as are structurally-validated `sql_ast` filters. + */ +export function validateSavedFilterValues( + filters: Filter[], +): SavedFilterValueIssue[] { + const issues: SavedFilterValueIssue[] = []; + filters.forEach((filter, index) => { + if (filter.type !== 'lucene' && filter.type !== 'sql') return; + const condition = filter.condition; + if (!condition.trim()) return; + if (!isValidFilterCondition(condition, filter.type)) { + issues.push({ index, language: filter.type, condition }); } + }); + return issues; +} - for (const r of ranges) { - const entry = getEntry(r.field); - entry.range = { min: r.min, max: r.max }; - } +export type DashboardFilterQueryIssue = { + /** ID of the offending dashboard filter */ + filterId: string; + /** Display name of the offending dashboard filter */ + filterName: string; + /** Query language of the filter's `where` clause */ + language: 'lucene' | 'sql'; + /** The raw `where` clause that failed to parse */ + where: string; +}; - return Array.from(byField.entries()).map(([key, vals]) => ({ - key, - ...vals, - })); - } catch { - return undefined; +/** + * Validate the `where` clause of each dashboard filter *definition* (the query + * that scopes which values populate the filter's dropdown). + * + * Useful at import time, where no values query is actually run: a filter whose + * `where` clause is malformed Lucene/SQL would otherwise only reveal itself as + * a failed query once the dashboard is opened. Returns one issue per filter + * whose `where` fails to parse as its declared language. + * + * Note: this only checks that the `where` clause *parses*. It cannot catch a + * `where`/`expression` that references a non-existent column — that only fails + * when the query runs against ClickHouse. + */ +export function validateDashboardFilterQueries( + filters: DashboardFilter[], +): DashboardFilterQueryIssue[] { + const issues: DashboardFilterQueryIssue[] = []; + for (const filter of filters) { + const where = filter.where ?? ''; + if (!where.trim()) continue; + const language = filter.whereLanguage ?? 'sql'; + if (language !== 'lucene' && language !== 'sql') continue; + if (!isValidFilterCondition(where, language)) { + issues.push({ + filterId: filter.id, + filterName: filter.name, + language, + where, + }); + } } + return issues; } diff --git a/packages/common-utils/src/queryParser.ts b/packages/common-utils/src/queryParser.ts index fb195d016b..f96ffb635b 100644 --- a/packages/common-utils/src/queryParser.ts +++ b/packages/common-utils/src/queryParser.ts @@ -33,7 +33,7 @@ function encodeSpecialTokens(query: string): string { .replace(/localhost:(\d{1,5})/, 'localhost_COLON_$1') .replace(/\\:/g, 'HDX_COLON'); } -export function decodeSpecialTokens(query: string): string { +function decodeSpecialTokens(query: string): string { return query .replace(/\\"/g, '"') .replace(/HDX_BACKSLASH_LITERAL/g, '\\') @@ -61,21 +61,17 @@ function normalizeChExpression(expr: string): string { const IMPLICIT_FIELD = '<implicit>'; // Type guards for lucene AST types -export function isNodeTerm( - node: lucene.Node | lucene.AST, -): node is lucene.NodeTerm { +function isNodeTerm(node: lucene.Node | lucene.AST): node is lucene.NodeTerm { return 'term' in node && node.term != null; } -export function isNodeRangedTerm( +function isNodeRangedTerm( node: lucene.Node | lucene.AST, ): node is lucene.NodeRangedTerm { return 'inclusive' in node && node.inclusive != null; } -export function isBinaryAST( - ast: lucene.AST | lucene.Node, -): ast is lucene.BinaryAST { +function isBinaryAST(ast: lucene.AST | lucene.Node): ast is lucene.BinaryAST { return 'right' in ast && ast.right != null; } @@ -85,7 +81,7 @@ function hasStart( return 'start' in ast && !!ast.start; } -export function isLeftOnlyAST( +function isLeftOnlyAST( ast: lucene.AST | lucene.Node, ): ast is lucene.LeftOnlyAST { return ( @@ -1766,14 +1762,9 @@ async function nodeTerm( serializer: Serializer, context: SerializerContext, ): Promise<string> { - const isImplicitField = node.field === IMPLICIT_FIELD; - const rawField = node.field[0] === '-' ? node.field.slice(1) : node.field; - // Decode special-token placeholders the emitter inserted in the field name - // (e.g. HDX_COLON for an escaped `:` in a Map sub-key). Leave the implicit - // field sentinel untouched so downstream comparisons against IMPLICIT_FIELD - // still match. - const field = isImplicitField ? rawField : decodeSpecialTokens(rawField); + const field = node.field[0] === '-' ? node.field.slice(1) : node.field; let isNegatedField = node.field[0] === '-'; + const isImplicitField = node.field === IMPLICIT_FIELD; // NodeTerm if (isNodeTerm(node)) { @@ -1875,7 +1866,7 @@ function createSerializerContext( return { ...currentContext, - implicitColumnExpression: decodeSpecialTokens(fieldWithoutNegation), + implicitColumnExpression: fieldWithoutNegation, ...(isNegatedAndParenthesized(ast) ? { isNegatedAndParenthesized: true } : {}),