From 404e2cd81346c712986ae5c6c69d97aff2877594 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 20 Apr 2026 16:11:30 -0400 Subject: [PATCH] type simplification --- .../src/mcp/tools/dashboards/saveDashboard.ts | 16 +- .../api/src/mcp/tools/dashboards/schemas.ts | 140 ++++++----- .../TableOnClickDrawer.tsx | 221 +++++++++++------- .../src/core/__tests__/linkUrlBuilder.test.ts | 40 ++-- .../common-utils/src/core/linkUrlBuilder.ts | 26 +-- packages/common-utils/src/types.ts | 41 ++-- 6 files changed, 254 insertions(+), 230 deletions(-) diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index 6eeb785b..b8318284 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -50,13 +50,15 @@ export function registerSaveDashboard( ' `filters` they expect to receive (e.g. a `ServiceName` filter), since ' + " the parent's onClick will drive those filter expressions.\n" + ' 2. Create the PARENT (overview) dashboard with table tiles whose ' + - ' `onClick` points at the leaf dashboards. Two ways to reference the ' + - ' target:\n' + - ' - mode="id" + the ObjectId returned from step 1 (most precise).\n' + - ' - mode="name-template" + a Handlebars template that resolves to the ' + - ' EXACT name of the target dashboard. Dashboard names must be unique ' + - ' on the team for this to resolve. Use this when the target is ' + - ' dynamic (e.g. "{{ServiceName}} Details").\n' + + ' `onClick` points at the leaf dashboards. The target shape is the ' + + ' same for both dashboard and search onClicks:\n' + + ' - `{ mode: "id", id: "" }` — precise, use when you have ' + + ' the returned id from step 1 (dashboard) or from ' + + ' hyperdx_list_sources (source).\n' + + ' - `{ mode: "template", template: "" }` — rendered ' + + ' per row; for dashboard onClicks the rendered value must match ' + + ' the EXACT name of one dashboard on the team, and for search ' + + ' onClicks it resolves to a source id or case-insensitive name.\n' + ' 3. Populate `filterValueTemplates` with one entry per filter value to ' + ' forward. Each entry is `{ filter: "", ' + ' template: "{{ColumnName}}" }`. Values are SQL-escaped automatically.\n' + diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index e0b335eb..526ee517 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -26,6 +26,60 @@ const mcpOnClickFilterEntrySchema = z.object({ ), }); +// Both dashboard and search onClicks share the same target shape. `mode:'id'` +// means "send me to this specific dashboard/source by ObjectId", and +// `mode:'template'` means "render this Handlebars template per row and +// resolve it to a target at click time" — a dashboard name for dashboard +// onClicks, or a source id / name for search onClicks. +const mcpOnClickTargetSchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('id'), + id: z + .string() + .describe( + 'Destination ObjectId. For type="dashboard" this is the target ' + + 'dashboard id (usually the id returned from a prior ' + + 'hyperdx_save_dashboard call in the same session). For ' + + 'type="search" this is a source id from hyperdx_list_sources.', + ), + }), + z.object({ + mode: z.literal('template'), + template: z + .string() + .describe( + 'Handlebars template rendered per row. For type="dashboard" it ' + + 'must resolve to the EXACT name of one dashboard on the team ' + + '(case-insensitive) — non-unique names abort with a toast. For ' + + 'type="search" it resolves to either a source id or a case-' + + 'insensitive source name. Example: "{{ServiceName}} Details".', + ), + }), +]); + +const mcpOnClickSharedFields = { + target: mcpOnClickTargetSchema, + whereTemplate: z + .string() + .optional() + .describe( + "Optional Handlebars template rendered into the destination's " + + 'global WHERE input. Example: "ServiceName = \'{{ServiceName}}\'". ' + + 'Row values are NOT auto-escaped here — use filterValueTemplates ' + + 'for values coming from the row unless you need raw SQL.', + ), + whereLanguage: SearchConditionLanguageSchema.optional().describe( + 'Language of whereTemplate: "sql" or "lucene". Default "sql".', + ), + filterValueTemplates: z + .array(mcpOnClickFilterEntrySchema) + .optional() + .describe( + 'Adds per-column filters to the destination URL as ' + + '`expression IN (value)`. Values are SQL-escaped automatically.', + ), +} as const; + const mcpOnClickSchema = z .discriminatedUnion('type', [ z @@ -33,84 +87,20 @@ const mcpOnClickSchema = z .describe('Default: row click opens search pre-filtered by group-by.'), z.object({ type: z.literal('dashboard'), - target: z.discriminatedUnion('mode', [ - z.object({ - mode: z.literal('id'), - dashboardId: z - .string() - .describe( - 'Target dashboard ObjectId. Use this when the target is a ' + - 'specific, known dashboard (you usually have its id from a ' + - 'prior hyperdx_save_dashboard call in the same session).', - ), - }), - z.object({ - mode: z.literal('name-template'), - nameTemplate: z - .string() - .describe( - 'Handlebars template that must resolve to the exact name of ' + - 'a dashboard on the same team (case-insensitive). The rendered ' + - 'name must match EXACTLY ONE dashboard or the click surfaces ' + - 'a toast error. Example: "{{ServiceName}} Details".', - ), - }), - ]), - whereTemplate: z - .string() - .optional() - .describe( - 'Optional Handlebars template rendered into the destination ' + - "dashboard's global WHERE input. Example: \"ServiceName = '{{ServiceName}}'\". " + - 'Row values are NOT auto-escaped here — use filterValueTemplates ' + - 'for values coming from the row unless you need raw SQL.', - ), - whereLanguage: SearchConditionLanguageSchema.optional().describe( - 'Language of whereTemplate: "sql" or "lucene". Default "sql".', - ), - filterValueTemplates: z - .array(mcpOnClickFilterEntrySchema) - .optional() - .describe( - 'Adds per-column filters to the destination URL as ' + - '`expression IN (value)`. Values are SQL-escaped automatically.', - ), + ...mcpOnClickSharedFields, }), z.object({ type: z.literal('search'), - source: z.discriminatedUnion('mode', [ - z.object({ - mode: z.literal('id'), - sourceId: z - .string() - .describe('Target source id from hyperdx_list_sources.'), - }), - z.object({ - mode: z.literal('template'), - sourceTemplate: z - .string() - .describe( - 'Handlebars template rendered to a source id or case-insensitive ' + - 'source name. Example: "{{SourceName}}".', - ), - }), - ]), - whereTemplate: z.string().optional(), - whereLanguage: SearchConditionLanguageSchema.optional(), - filterValueTemplates: z - .array(mcpOnClickFilterEntrySchema) - .optional() - .describe( - 'Adds per-column filters on the destination search page as ' + - '`expression IN (value)`.', - ), + ...mcpOnClickSharedFields, }), ]) .describe( 'Row-click drill-down action. Only applies to table tiles. On click, the ' + "row's column values are threaded through Handlebars templates so the " + "destination reflects the clicked row. The current dashboard's time " + - 'range is always propagated.\n\n' + + 'range is always propagated. Dashboard and Search onClicks share the ' + + 'same shape — only the `type` (and where the target resolves to) ' + + 'differs.\n\n' + 'Available Handlebars helpers:\n' + ' • {{int v}} round a number / numeric string to an integer\n' + ' • {{default v "fb"}} fallback when v is null/empty\n' + @@ -123,11 +113,11 @@ const mcpOnClickSchema = z 'toast error.\n\n' + 'Typical patterns:\n' + '1. Drill from a services table into a per-service dashboard:\n' + - ' { "type": "dashboard", "target": { "mode": "name-template", ' + - '"nameTemplate": "{{ServiceName}} Details" }, ' + + ' { "type": "dashboard", "target": { "mode": "template", ' + + '"template": "{{ServiceName}} Details" }, ' + '"filterValueTemplates": [{ "filter": "ServiceName", "template": "{{ServiceName}}" }] }\n' + '2. Drill into search for a specific error:\n' + - ' { "type": "search", "source": { "mode": "id", "sourceId": "" }, ' + + ' { "type": "search", "target": { "mode": "id", "id": "" }, ' + '"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] }', ); @@ -460,13 +450,13 @@ export const mcpTilesParam = z '5. Linked table (drill into a per-service dashboard by name): ' + '{ "name": "Services", "config": { "displayType": "table", "sourceId": "", ' + '"groupBy": "ResourceAttributes[\'service.name\']", "select": [{ "aggFn": "count" }], ' + - '"onClick": { "type": "dashboard", "target": { "mode": "name-template", ' + - '"nameTemplate": "{{`ResourceAttributes[\'service.name\']`}} Details" }, ' + + '"onClick": { "type": "dashboard", "target": { "mode": "template", ' + + '"template": "{{`ResourceAttributes[\'service.name\']`}} Details" }, ' + '"filterValueTemplates": [{ "filter": "ResourceAttributes[\'service.name\']", ' + '"template": "{{`ResourceAttributes[\'service.name\']`}}" }] } } }\n' + '6. Linked table (drill into search by trace id): ' + '{ "name": "Recent Errors", "config": { "displayType": "table", "sourceId": "", ' + '"groupBy": "TraceId", "select": [{ "aggFn": "count", "where": "SeverityText:ERROR" }], ' + - '"onClick": { "type": "search", "source": { "mode": "id", "sourceId": "" }, ' + + '"onClick": { "type": "search", "target": { "mode": "id", "id": "" }, ' + '"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] } } }', ); diff --git a/packages/app/src/components/DBEditTimeChartForm/TableOnClickDrawer.tsx b/packages/app/src/components/DBEditTimeChartForm/TableOnClickDrawer.tsx index 48827d0a..e3977dab 100644 --- a/packages/app/src/components/DBEditTimeChartForm/TableOnClickDrawer.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/TableOnClickDrawer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { Control, Controller, useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -18,11 +18,11 @@ import { Select, Stack, Text, - Textarea, TextInput, } from '@mantine/core'; import { IconPlus, IconTrash } from '@tabler/icons-react'; +import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; import { useDashboards } from '@/dashboard'; import { useSources } from '@/source'; @@ -40,7 +40,7 @@ const DrawerSchema = z.object({ onClick: TableOnClickSchema }); function emptyDashboardOnClick(): TableOnClick { return { type: 'dashboard', - target: { mode: 'id', dashboardId: '' }, + target: { mode: 'id', id: '' }, whereLanguage: 'sql', }; } @@ -48,7 +48,7 @@ function emptyDashboardOnClick(): TableOnClick { function emptySearchOnClick(): TableOnClick { return { type: 'search', - source: { mode: 'id', sourceId: '' }, + target: { mode: 'id', id: '' }, whereLanguage: 'sql', }; } @@ -57,6 +57,29 @@ function noneOnClick(): TableOnClick { return { type: 'none' }; } +/** + * Fields shared by the `dashboard` and `search` onClick variants. Preserved + * across mode toggles so users don't lose a half-written WHERE template or + * their filter rows when experimenting with destinations. + */ +type SharedTemplateFields = Pick< + Extract, + 'whereTemplate' | 'whereLanguage' | 'filterValueTemplates' +>; + +function carryAcrossModes( + from: TableOnClick | undefined, +): SharedTemplateFields { + if (!from || from.type === 'none') return {}; + const out: SharedTemplateFields = {}; + if (from.whereTemplate !== undefined) out.whereTemplate = from.whereTemplate; + if (from.whereLanguage !== undefined) out.whereLanguage = from.whereLanguage; + if (from.filterValueTemplates !== undefined) { + out.filterValueTemplates = from.filterValueTemplates; + } + return out; +} + type TableOnClickDrawerProps = { opened: boolean; value: TableOnClick | undefined; @@ -128,11 +151,18 @@ export default function TableOnClickDrawer({ ]} value={onClickValue?.type ?? 'none'} onChange={next => { - if (next === 'none') setValue('onClick', noneOnClick()); - else if (next === 'dashboard') - setValue('onClick', emptyDashboardOnClick()); - else if (next === 'search') - setValue('onClick', emptySearchOnClick()); + if (next === 'none') { + setValue('onClick', noneOnClick()); + return; + } + const base = + next === 'dashboard' + ? emptyDashboardOnClick() + : emptySearchOnClick(); + setValue('onClick', { + ...base, + ...carryAcrossModes(onClickValue), + }); }} fullWidth /> @@ -171,11 +201,23 @@ function ModeFields({ if (!onClick) return null; if (onClick.type === 'dashboard') { - return ; + return ( + + ); } if (onClick.type === 'search') { - return ; + return ( + + ); } // Default (type: 'none') @@ -190,9 +232,11 @@ function ModeFields({ function DashboardOnClickFields({ onClick, setValue, + control, }: { onClick: Extract; setValue: (name: 'onClick', value: TableOnClick) => void; + control: Control; }) { const { data: dashboards } = useDashboards(); @@ -203,24 +247,53 @@ function DashboardOnClickFields({ const mode = onClick.target.mode; + // Seed the filter list with the selected dashboard's declared filter + // expressions (value templates left blank) the first time a user picks + // a specific dashboard while the current list is "blank" — i.e. empty, or + // every entry has no template value filled in yet. The ref tracks the + // last dashboard we've handled so clearing rows or switching away and + // back doesn't silently re-populate on top of user intent. + const seededForDashboardRef = useRef(undefined); + useEffect(() => { + const dashboardId = + onClick.target.mode === 'id' ? onClick.target.id : undefined; + if (!dashboardId) return; + if (seededForDashboardRef.current === dashboardId) return; + const target = (dashboards ?? []).find(d => d.id === dashboardId); + if (!target) return; + seededForDashboardRef.current = dashboardId; + const targetFilters = target.filters ?? []; + if (targetFilters.length === 0) return; + const currentEntries = onClick.filterValueTemplates ?? []; + const allTemplatesBlank = currentEntries.every(e => !e.template); + if (!allTemplatesBlank) return; // preserve anything the user typed + setValue('onClick', { + ...onClick, + filterValueTemplates: targetFilters.map(f => ({ + filter: f.expression, + template: '', + })), + }); + }, [onClick, dashboards, setValue]); + return ( { if (next === 'id') { setValue('onClick', { ...onClick, - target: { mode: 'id', dashboardId: '' }, + target: { mode: 'id', id: '' }, }); } else { setValue('onClick', { ...onClick, - target: { mode: 'name-template', nameTemplate: '' }, + target: { mode: 'template', template: '' }, }); } }} @@ -232,65 +305,50 @@ function DashboardOnClickFields({ placeholder="Select a dashboard" searchable data={dashboardOptions} - value={onClick.target.mode === 'id' ? onClick.target.dashboardId : ''} + value={onClick.target.mode === 'id' ? onClick.target.id : ''} onChange={next => { setValue('onClick', { ...onClick, - target: { mode: 'id', dashboardId: next ?? '' }, + target: { mode: 'id', id: next ?? '' }, }); }} /> )} - {mode === 'name-template' && ( + {mode === 'template' && ( { setValue('onClick', { ...onClick, target: { - mode: 'name-template', - nameTemplate: e.currentTarget.value, + mode: 'template', + template: e.currentTarget.value, }, }); }} /> )} -