diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index c7ce7ef5..6eeb785b 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -38,7 +38,34 @@ export function registerSaveDashboard( 'Call hyperdx_list_sources first to obtain sourceId and connectionId values. ' + 'IMPORTANT: After saving a dashboard, always run hyperdx_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.\n\n' + + 'LINKED DASHBOARDS (drill-downs):\n' + + 'Table tiles can declare an `onClick` config that navigates the user from a ' + + "clicked row to another dashboard or the search page, with the row's column " + + 'values threaded through Handlebars templates (e.g. `{{ServiceName}}`). Use ' + + 'this to build multi-level flows — an overview dashboard that drills into ' + + 'per-entity detail dashboards.\n\n' + + 'Recipe for creating a set of linked dashboards in one session:\n' + + ' 1. Create the LEAF (detail) dashboards first. These should declare the ' + + ' `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' + + ' 3. Populate `filterValueTemplates` with one entry per filter value to ' + + ' forward. Each entry is `{ filter: "", ' + + ' template: "{{ColumnName}}" }`. Values are SQL-escaped automatically.\n' + + ' 4. Alternatively use `whereTemplate` for free-form SQL/Lucene conditions ' + + ' (not auto-escaped — prefer filterValueTemplates for row values).\n\n' + + 'Supported Handlebars helpers: `int` (round to integer), `default`, `eq` ' + + '(block), `json`, `encodeURIComponent`. Built-in helpers (#if, #each, #with, ' + + 'lookup, etc.) are disabled. Strict mode is on: referencing a column the row ' + + 'does not have aborts navigation with a toast error.', inputSchema: z.object({ id: z .string() diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index b01f8c94..e0b335eb 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -6,6 +6,131 @@ import { z } from 'zod'; import { externalQuantileLevelSchema } from '@/utils/zod'; +// ─── Row-click (onClick) schemas for drill-down linking ────────────────────── +// Kept parallel to TableOnClickSchema in common-utils/types.ts so each field +// carries rich Zod `.describe()` annotations for the LLM. + +const mcpOnClickFilterEntrySchema = z.object({ + filter: z + .string() + .describe( + 'SQL expression to filter on at the destination (usually a column name). ' + + 'Example: "ServiceName" or "SpanAttributes[\'http.route\']". ' + + 'Multiple entries with the same `filter` are merged into one `IN (...)` clause.', + ), + template: z + .string() + .describe( + "Handlebars template rendered per-row for this filter's value. " + + 'Example: "{{ServiceName}}". Value is SQL-escaped automatically.', + ), +}); + +const mcpOnClickSchema = z + .discriminatedUnion('type', [ + z + .object({ type: z.literal('none') }) + .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.', + ), + }), + 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)`.', + ), + }), + ]) + .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' + + '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' + + ' • {{#eq a b}}..{{/eq}} block helper: renders body when a === b\n' + + ' • {{json v}} JSON.stringify(v)\n' + + ' • {{encodeURIComponent v}}\n' + + 'Built-in Handlebars helpers (#if, #each, #with, lookup, etc.) are ' + + 'DISABLED for security — stick to the helpers above. Strict mode is on, ' + + 'so referencing a column the row does not have aborts navigation with a ' + + '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" }, ' + + '"filterValueTemplates": [{ "filter": "ServiceName", "template": "{{ServiceName}}" }] }\n' + + '2. Drill into search for a specific error:\n' + + ' { "type": "search", "source": { "mode": "id", "sourceId": "" }, ' + + '"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] }', + ); + // ─── Shared tile schemas for MCP dashboard tools ───────────────────────────── const mcpTileSelectItemSchema = z .object({ @@ -146,6 +271,14 @@ const mcpTableTileSchema = mcpTileLayoutSchema.extend({ ), orderBy: z.string().optional().describe('Sort results by this column'), asRatio: z.boolean().optional(), + onClick: mcpOnClickSchema + .optional() + .describe( + 'Optional row-click action. Use to build drill-down dashboards that ' + + 'navigate to another dashboard or the search page with the row ' + + 'values threaded through as filters. See the discriminated union ' + + 'above for the shape.', + ), }), }); @@ -288,6 +421,13 @@ const mcpSqlTileSchema = mcpTileLayoutSchema.extend({ ), fillNulls: z.boolean().optional(), alignDateRangeToGranularity: z.boolean().optional(), + onClick: mcpOnClickSchema + .optional() + .describe( + 'Optional row-click action. Only meaningful when displayType is ' + + '"table". Rendered column keys in Handlebars templates come from ' + + 'the SQL query result (aliased column names / SELECT expressions).', + ), }), }); @@ -316,5 +456,17 @@ export const mcpTilesParam = z '"select": [{ "aggFn": "count" }], "numberFormat": { "output": "number", "average": true } } }\n' + '4. Number (duration): { "name": "P95 Latency", "config": { "displayType": "number", "sourceId": "", ' + '"select": [{ "aggFn": "quantile", "level": 0.95, "valueExpression": "Duration" }], ' + - '"numberFormat": { "output": "time", "factor": 0.000000001 } } }', + '"numberFormat": { "output": "time", "factor": 0.000000001 } } }\n' + + '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" }, ' + + '"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": "" }, ' + + '"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] } } }', ); diff --git a/packages/api/src/models/dashboard.ts b/packages/api/src/models/dashboard.ts index fa24833c..8c80ecad 100644 --- a/packages/api/src/models/dashboard.ts +++ b/packages/api/src/models/dashboard.ts @@ -15,39 +15,38 @@ export interface IDashboard extends z.infer { export type DashboardDocument = mongoose.HydratedDocument; -export default mongoose.model( - 'Dashboard', - new Schema( - { - name: { - type: String, - required: true, - }, - tiles: { type: mongoose.Schema.Types.Mixed, required: true }, - team: { type: mongoose.Schema.Types.ObjectId, ref: 'Team' }, - tags: { - type: [String], - default: [], - }, - filters: { type: mongoose.Schema.Types.Array, default: [] }, - savedQuery: { type: String, required: false }, - savedQueryLanguage: { type: String, required: false }, - savedFilterValues: { type: mongoose.Schema.Types.Array, required: false }, - containers: { type: mongoose.Schema.Types.Array, required: false }, - createdBy: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: false, - }, - updatedBy: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: false, - }, +const dashboardSchema = new Schema( + { + name: { + type: String, + required: true, }, - { - timestamps: true, - toJSON: { getters: true }, + tiles: { type: mongoose.Schema.Types.Mixed, required: true }, + team: { type: mongoose.Schema.Types.ObjectId, ref: 'Team' }, + tags: { + type: [String], + default: [], }, - ), + filters: { type: mongoose.Schema.Types.Array, default: [] }, + savedQuery: { type: String, required: false }, + savedQueryLanguage: { type: String, required: false }, + savedFilterValues: { type: mongoose.Schema.Types.Array, required: false }, + containers: { type: mongoose.Schema.Types.Array, required: false }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + updatedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + }, + { + timestamps: true, + toJSON: { getters: true }, + }, ); + +export default mongoose.model('Dashboard', dashboardSchema); 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 f51dda6f..65eac1f6 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -144,6 +144,7 @@ const convertToExternalTileChartConfig = ( sqlTemplate: config.sqlTemplate, sourceId: config.source, numberFormat: config.numberFormat, + onClick: config.onClick, }; case DisplayType.Number: return { @@ -241,7 +242,7 @@ const convertToExternalTileChartConfig = ( }; case DisplayType.Table: return { - ...pick(config, ['having', 'numberFormat']), + ...pick(config, ['having', 'numberFormat', 'onClick']), displayType: config.displayType, sourceId, asRatio: @@ -383,6 +384,10 @@ export function convertToInternalTileConfig( sqlTemplate: externalConfig.sqlTemplate, source: externalConfig.sourceId, numberFormat: externalConfig.numberFormat, + onClick: + externalConfig.displayType === 'table' + ? externalConfig.onClick + : undefined, } satisfies RawSqlSavedChartConfig; break; default: @@ -423,6 +428,7 @@ export function convertToInternalTileConfig( 'numberFormat', 'having', 'orderBy', + 'onClick', ]), displayType: DisplayType.Table, select: externalConfig.select.map(convertToInternalSelectItem), diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index bd9c1813..08de2955 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -6,6 +6,7 @@ import { NumberFormatSchema, scheduleStartAtSchema, SearchConditionLanguageSchema as whereLanguageSchema, + TableOnClickSchema, validateAlertScheduleOffsetMinutes, validateAlertThresholdMax, WebhookService, @@ -262,11 +263,13 @@ const externalDashboardTableChartConfigSchema = z.object({ orderBy: z.string().max(10000).optional(), asRatio: z.boolean().optional(), numberFormat: NumberFormatSchema.optional(), + onClick: TableOnClickSchema.optional(), }); const externalDashboardTableRawSqlChartConfigSchema = externalDashboardRawSqlChartConfigBaseSchema.extend({ displayType: z.literal('table'), + onClick: TableOnClickSchema.optional(), }); const externalDashboardNumberRawSqlChartConfigSchema = diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index 0080ba29..aa316b82 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -47,141 +47,124 @@ import { getDashboardTemplate } from './dashboardTemplates'; import { withAppNav } from './layout'; import { useSources } from './source'; +export type ImportedFile = { + fileName: string; + template: DashboardTemplate; +}; + function FileSelection({ onComplete, }: { - onComplete: (input: DashboardTemplate | null) => void; + onComplete: (inputs: ImportedFile[] | null) => void; }) { - // The schema for the form data we expect to receive - const FormSchema = z.object({ file: z.instanceof(File).nullable() }); - - type FormValues = z.infer; - const [error, setError] = useState<{ message: string; details?: string; } | null>(null); const [errorDetails, { toggle: toggleErrorDetails }] = useDisclosure(false); - const { - control, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(FormSchema), - }); - - const onSubmit = async ({ file }: FormValues) => { + const handleDrop = async (files: File[]) => { setError(null); - if (!file) return; + if (files.length === 0) return; - try { - const text = await file.text(); - const data = JSON.parse(text); - const parsed = DashboardTemplateSchema.parse(data); // throws if invalid - onComplete(parsed); - } catch (e: any) { + const imported: ImportedFile[] = []; + const errors: string[] = []; + for (const file of files) { + try { + const text = await file.text(); + const data = JSON.parse(text); + const parsed = DashboardTemplateSchema.parse(data); + imported.push({ fileName: file.name, template: parsed }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push(`${file.name}: ${msg}`); + } + } + + if (errors.length > 0) { onComplete(null); setError({ - message: 'Failed to Import Dashboard', - details: e?.message ?? 'Failed to parse/validate JSON', + message: + files.length === 1 + ? 'Failed to Import Dashboard' + : `Failed to import ${errors.length} of ${files.length} files`, + details: errors.join('\n\n'), }); + return; } + onComplete(imported); }; return ( -
- - ( - { - field.onChange(files[0]); - handleSubmit(onSubmit)(); - }} - onReject={() => - setError({ message: 'Invalid File Type or Size' }) - } - maxSize={5 * 1024 ** 2} - maxFiles={1} - accept={['application/json']} - > - - - - - - - - - - + + setError({ message: 'Invalid File Type or Size' })} + maxSize={5 * 1024 ** 2} + accept={['application/json']} + > + + + + + + + + + + -
- - Import Dashboard - - - Drag and drop a JSON file here, or click to select from your - computer. - -
-
-
- )} - /> - - {error && (
- {error.message} - {error.details && ( - <> - - - {error.details} - - - )} + + Import Dashboard + + + Drag and drop one or more JSON files here, or click to select from + your computer. Linked dashboards imported together have their + cross-references rewritten automatically. +
- )} -
- +
+
+ + {error && ( +
+ {error.message} + {error.details && ( + <> + + + + {error.details} + + + + )} +
+ )} +
); } @@ -519,6 +502,263 @@ function Mapping({ input }: { input: DashboardTemplate }) { ); } +const BundleMappingForm = z.object({ + dashboards: z.array( + z.object({ + dashboardName: z.string().min(1), + tags: z.array(z.string()), + sourceMappings: z.array(z.string()), + connectionMappings: z.array(z.string()), + filterSourceMappings: z.array(z.string()).optional(), + }), + ), +}); + +type BundleMappingFormValues = z.infer; + +function BundleMapping({ inputs }: { inputs: ImportedFile[] }) { + const router = useRouter(); + const { data: sources } = useSources(); + const { data: connections } = useConnections(); + const { data: existingTags } = api.useTags(); + + const createDashboard = useCreateDashboard(); + + const { control, handleSubmit, setValue } = useForm({ + resolver: zodResolver(BundleMappingForm), + defaultValues: { + dashboards: inputs.map(i => ({ + dashboardName: i.template.name, + tags: i.template.tags ?? [], + sourceMappings: i.template.tiles.map(() => ''), + connectionMappings: i.template.tiles.map(() => ''), + filterSourceMappings: i.template.filters?.map(() => ''), + })), + }, + }); + + // Auto-populate per-file source/connection mappings the same way Mapping does. + useEffect(() => { + if (!sources || !connections) return; + inputs.forEach((input, dashIdx) => { + const sourceMappings = input.template.tiles.map(tile => { + const config = tile.config as SavedChartConfig; + if (!config.source) return ''; + const match = sources.find( + s => s.name.toLowerCase() === config.source!.toLowerCase(), + ); + return match?.id ?? ''; + }); + const connectionMappings = input.template.tiles.map(tile => { + const config = tile.config as SavedChartConfig; + if (!isRawSqlSavedChartConfig(config)) return ''; + const match = connections.find( + c => c.name.toLowerCase() === config.connection.toLowerCase(), + ); + return match?.id ?? ''; + }); + const filterSourceMappings = input.template.filters?.map(filter => { + const match = sources.find( + s => s.name.toLowerCase() === filter.source.toLowerCase(), + ); + return match?.id ?? ''; + }); + setValue(`dashboards.${dashIdx}.sourceMappings`, sourceMappings); + setValue(`dashboards.${dashIdx}.connectionMappings`, connectionMappings); + if (filterSourceMappings) { + setValue( + `dashboards.${dashIdx}.filterSourceMappings`, + filterSourceMappings, + ); + } + }); + }, [inputs, sources, connections, setValue]); + + const onSubmit = async (data: BundleMappingFormValues) => { + try { + // Build the resolved dashboard documents. Templates do not carry their + // original id (stripped on export), so cross-bundle id-mode onClick + // links cannot be rewritten here — users should use name-template + // mode for stable cross-dashboard linking. + const newIdByIndex: string[] = []; + for (let dashIdx = 0; dashIdx < inputs.length; dashIdx++) { + const input = inputs[dashIdx]; + const perDash = data.dashboards[dashIdx]; + const zippedTiles = input.template.tiles.map((tile, idx) => { + const source = sources?.find( + s => s.id === perDash.sourceMappings[idx], + ); + if (isRawSqlSavedChartConfig(tile.config)) { + const connection = connections?.find( + c => c.id === perDash.connectionMappings[idx], + ); + return { + ...tile, + config: { + ...tile.config, + connection: connection!.id, + ...(source ? { source: source.id } : {}), + }, + }; + } + return { + ...tile, + config: { + ...tile.config, + source: source!.id, + }, + }; + }); + const zippedFilters = input.template.filters?.map((filter, idx) => { + const source = sources?.find( + s => s.id === perDash.filterSourceMappings?.[idx], + ); + return { ...filter, source: source!.id }; + }); + const doc = convertToDashboardDocument({ + ...input.template, + tiles: zippedTiles, + filters: zippedFilters, + name: perDash.dashboardName, + tags: perDash.tags, + }); + const created = await createDashboard.mutateAsync(doc); + newIdByIndex.push(created.id); + } + + notifications.show({ + color: 'green', + message: `Imported ${inputs.length} dashboards`, + }); + router.push(`/dashboards/${newIdByIndex[0]}`); + } catch (err) { + notifications.show({ + color: 'red', + message: + err instanceof Error + ? err.message + : 'Something went wrong. Please try again.', + }); + } + }; + + return ( +
+ + + Step 2: Map Data for {inputs.length} dashboards + + {inputs.map((input, dashIdx) => ( + + {input.fileName} + ( + + )} + /> + ( + + )} + /> + + + + Name + Input Source + Mapped Source + Input Connection + Mapped Connection + + + + {input.template.tiles.map((tile, i) => { + const config = tile.config; + const isRawSql = isRawSqlSavedChartConfig(config); + return ( + + {tile.config.name} + {config.source ?? ''} + + {config.source != null && ( + ({ + value: source.id, + label: source.name, + }))} + placeholder="Select a source" + /> + )} + + {isRawSql ? config.connection : ''} + + {isRawSql ? ( + ({ + value: conn.id, + label: conn.name, + }))} + placeholder="Select a connection" + /> + ) : null} + + + ); + })} + {input.template.filters?.map((filter, i) => ( + + {filter.name} (filter) + {filter.source} + + ({ + value: source.id, + label: source.name, + }))} + placeholder="Select a source" + /> + + + + + ))} + +
+
+ ))} + {createDashboard.isError && ( + {createDashboard.error.toString()} + )} + +
+
+ ); +} + function DBDashboardImportPage() { const brandName = useBrandDisplayName(); const router = useRouter(); @@ -533,8 +773,11 @@ function DBDashboardImportPage() { [templateName], ); - const [fileInput, setFileInput] = useState(null); - const input = templateInput ?? fileInput; + const [fileInputs, setFileInputs] = useState(null); + const templateInputs: ImportedFile[] | null = templateInput + ? [{ fileName: `${templateName}.json`, template: templateInput }] + : null; + const inputs = templateInputs ?? fileInputs; const isTemplateNotFound = isTemplate && !isLoadingRoute && !templateInput; return ( @@ -579,11 +822,14 @@ function DBDashboardImportPage() { ) : !isTemplate ? ( { - setFileInput(i); + setFileInputs(i); }} /> ) : null} - {input && } + {inputs && inputs.length === 1 && ( + + )} + {inputs && inputs.length > 1 && } diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index fe71f241..632d618a 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -47,6 +47,7 @@ import { } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, + Alert, Anchor, Box, Breadcrumbs, @@ -64,6 +65,7 @@ import { import { useHotkeys } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { + IconAlertTriangle, IconArrowsMaximize, IconBell, IconChartBar, @@ -110,6 +112,7 @@ import SearchWhereInput, { import { Tags } from './components/Tags'; import useDashboardFilters from './hooks/useDashboardFilters'; import { useDashboardRefresh } from './hooks/useDashboardRefresh'; +import { useTableOnClickResolver } from './hooks/useTableOnClickResolver'; import { useBrandDisplayName } from './theme/ThemeProvider'; import { parseAsJsonEncoded, parseAsStringEncoded } from './utils/queryParsers'; import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils'; @@ -227,6 +230,26 @@ const Tile = forwardRef( id: chart.config.source, }); + const onClickResolver = useTableOnClickResolver({ + onClick: chart.config.onClick, + dateRange, + }); + + const router = useRouter(); + const onRowLinkClick = useCallback( + (row: Record, e: React.MouseEvent) => { + if (!onClickResolver) return; + const url = onClickResolver(row); + if (!url) return; // resolver surfaced the error via toast already + if (e.metaKey || e.ctrlKey || e.button === 1) { + window.open(url, '_blank'); + } else { + router.push(url); + } + }, + [onClickResolver, router], + ); + const isSourceMissing = !!chart.config.source && isSourceFetched && source == null; const isSourceUnset = @@ -580,6 +603,9 @@ const Tile = forwardRef( }) : undefined } + onRowLinkClick={ + onClickResolver ? onRowLinkClick : undefined + } /> )} @@ -665,6 +691,8 @@ const Tile = forwardRef( filterWarning, isSourceMissing, isSourceUnset, + onClickResolver, + onRowLinkClick, ], ); @@ -895,8 +923,25 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const [showFiltersModal, setShowFiltersModal] = useState(false); const filters = dashboard?.filters ?? []; - const { filterValues, setFilterValue, filterQueries, setFilterQueries } = - useDashboardFilters(filters); + const { + filterValues, + setFilterValue, + filterQueries, + setFilterQueries, + ignoredFilterExpressions, + } = useDashboardFilters(filters); + + // Warn when the URL has filter values that don't correspond to any declared + // dashboard filter — they'd otherwise be silently dropped, and users who + // arrive via a shared link, bookmark, or onClick action might not notice. + // Only consider URL filters ignored once the dashboard has finished loading + // so we don't flash the banner before `dashboard.filters` is available. + const dashboardReady = + !!dashboard?.id && + router.isReady && + (isLocalDashboard || !isFetchingDashboard); + const shouldShowIgnoredFiltersBanner = + dashboardReady && ignoredFilterExpressions.length > 0; const handleSaveFilter = (filter: DashboardFilter) => { if (!dashboard) return; @@ -996,19 +1041,25 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { // Query defaults: URL query overrides saved defaults. If switching to a // dashboard without defaults, clear query. On first load/reload, keep current state. - if (!hasWhereInUrl) { - if (dashboard.savedQuery) { - setValue('where', dashboard.savedQuery); - setWhere(dashboard.savedQuery); - const savedLanguage = dashboard.savedQueryLanguage ?? 'lucene'; - setValue('whereLanguage', savedLanguage); - setWhereLanguage(savedLanguage); - } else if (isSwitchingDashboards) { - setValue('where', ''); - setWhere(''); - setValue('whereLanguage', 'lucene'); - setWhereLanguage('lucene'); + if (hasWhereInUrl) { + // Form defaults were evaluated before nuqs finished hydrating from the + // URL, so sync them now that the URL value is available (e.g., landing + // here from an onClick link with a pre-populated WHERE template). + setValue('where', where); + if (whereLanguage) { + setValue('whereLanguage', whereLanguage as SearchConditionLanguage); } + } else if (dashboard.savedQuery) { + setValue('where', dashboard.savedQuery); + setWhere(dashboard.savedQuery); + const savedLanguage = dashboard.savedQueryLanguage ?? 'lucene'; + setValue('whereLanguage', savedLanguage); + setWhereLanguage(savedLanguage); + } else if (isSwitchingDashboards) { + setValue('where', ''); + setWhere(''); + setValue('whereLanguage', 'lucene'); + setWhereLanguage('lucene'); } // Filter defaults: URL filters override saved defaults. If switching to a @@ -1031,6 +1082,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { isFetchingDashboard, router.isReady, router.query, + where, + whereLanguage, setValue, setWhere, setWhereLanguage, @@ -1629,18 +1682,20 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { )} - { - if (dashboard != null) { - setDashboard({ - ...dashboard, - name: editedName, - }); - } - }} - /> + + { + if (dashboard != null) { + setDashboard({ + ...dashboard, + name: editedName, + }); + } + }} + /> + {!isLocalDashboard && dashboard?.id && ( + {shouldShowIgnoredFiltersBanner && ( + } + title="Some filters could not be applied" + data-testid="ignored-url-filters-banner" + > + No dashboard filter(s) found for{' '} + {ignoredFilterExpressions.length === 1 ? 'expression' : 'expressions'} + : {ignoredFilterExpressions.join(', ')}. Add a filter with a matching + expression to apply these filters. + + )} string | null; + /** + * Row-level click handler for the configurable per-tile onClick action. + * When provided, takes precedence over `getRowSearchLink` and each cell + * becomes clickable without a pre-computed href — the handler resolves + * and navigates on demand so large tables don't pay for every row up + * front. Pass the MouseEvent through for meta/ctrl-click handling. + */ + onRowLinkClick?: (row: any, e: React.MouseEvent) => void; tableBottom?: React.ReactNode; enableClientSideSorting?: boolean; sorting: SortingState; @@ -142,6 +151,51 @@ export const Table = ({ formattedValue = formatNumber(value, numberFormat); } + if (onRowLinkClick != null) { + return ( +
onRowLinkClick(row.original, e)} + // Middle-click (button === 1) fires onAuxClick but NOT + // onClick on non-anchor elements, so route it explicitly. + // Right-click (button === 2) is left alone for browser + // context menu defaults. + onAuxClick={e => { + if (e.button === 1) { + e.preventDefault(); + onRowLinkClick(row.original, e); + } + }} + // Suppress the browser's middle-click autoscroll cursor + // on non-anchor elements. + onMouseDown={e => { + if (e.button === 1) e.preventDefault(); + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + // Synthesize a minimal MouseEvent-like object for the + // handler; keyboard activations never open a new tab. + onRowLinkClick( + row.original, + e as unknown as React.MouseEvent, + ); + } + }} + > + {formattedValue} +
+ ); + } + if (getRowSearchLink == null) { return formattedValue; } diff --git a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx index f1c1fecc..49b3a906 100644 --- a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx +++ b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx @@ -18,8 +18,10 @@ import { isTraceSource, } from '@hyperdx/common-utils/dist/types'; import { Box, Button, Group, Stack, Text, Tooltip } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { IconBell, IconHelpCircle } from '@tabler/icons-react'; +import TableOnClickDrawer from '@/components/DBEditTimeChartForm/TableOnClickDrawer'; import { TileAlertEditor } from '@/components/DBEditTimeChartForm/TileAlertEditor'; import { SQLEditorControlled } from '@/components/SQLEditor/SQLEditor'; import { type SQLCompletion } from '@/components/SQLEditor/utils'; @@ -64,6 +66,18 @@ export default function RawSqlChartEditor({ const sqlTemplate = useWatch({ control, name: 'sqlTemplate' }); const sourceObject = sources?.find(s => s.id === source); + const [ + onClickDrawerOpened, + { open: openOnClickDrawer, close: closeOnClickDrawer }, + ] = useDisclosure(false); + const onClickValue = useWatch({ control, name: 'onClick' }); + const onClickTypeLabel = + onClickValue?.type === 'dashboard' + ? 'Navigate to Dashboard' + : onClickValue?.type === 'search' + ? 'Search' + : 'Default'; + const rawSqlConfig = useMemo( () => ({ @@ -222,6 +236,15 @@ export default function RawSqlChartEditor({ Add Alert )} + {displayType === DisplayType.Table && ( + + )} )} - + + {displayType === DisplayType.Table && ( + + )} + + ) : ( @@ -311,6 +337,15 @@ export function ChartEditorControls({ /> )} + { + setValue('onClick', next, { shouldDirty: true }); + onSubmit(); + }} + onClose={closeOnClickDrawer} + /> ); } diff --git a/packages/app/src/components/DBEditTimeChartForm/TableOnClickDrawer.tsx b/packages/app/src/components/DBEditTimeChartForm/TableOnClickDrawer.tsx new file mode 100644 index 00000000..48827d0a --- /dev/null +++ b/packages/app/src/components/DBEditTimeChartForm/TableOnClickDrawer.tsx @@ -0,0 +1,509 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { Control, Controller, useForm, useWatch } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + TableOnClick, + TableOnClickFilterTemplate, + TableOnClickSchema, +} from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Box, + Button, + Divider, + Drawer, + Group, + SegmentedControl, + Select, + Stack, + Text, + Textarea, + TextInput, +} from '@mantine/core'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; + +import { useDashboards } from '@/dashboard'; +import { useSources } from '@/source'; + +const HELP_TEXT = + 'Use Handlebars syntax to reference the column values of the clicked row. Example: {{ServiceName}}. Helpers: {{int Duration}}.'; + +type DrawerFormValues = { onClick: TableOnClick }; + +// Wrap the shared schema so the drawer validates the whole form object, +// not just the nested onClick union. Keeping it local also lets us tighten +// validation later (e.g., required filter templates) without affecting the +// persisted saved-chart schema. +const DrawerSchema = z.object({ onClick: TableOnClickSchema }); + +function emptyDashboardOnClick(): TableOnClick { + return { + type: 'dashboard', + target: { mode: 'id', dashboardId: '' }, + whereLanguage: 'sql', + }; +} + +function emptySearchOnClick(): TableOnClick { + return { + type: 'search', + source: { mode: 'id', sourceId: '' }, + whereLanguage: 'sql', + }; +} + +function noneOnClick(): TableOnClick { + return { type: 'none' }; +} + +type TableOnClickDrawerProps = { + opened: boolean; + value: TableOnClick | undefined; + onChange: (next: TableOnClick) => void; + onClose: () => void; +}; + +export default function TableOnClickDrawer({ + opened, + value, + onChange, + onClose, +}: TableOnClickDrawerProps) { + const appliedDefaults: DrawerFormValues = useMemo( + () => ({ onClick: value ?? noneOnClick() }), + [value], + ); + + const { control, handleSubmit, reset, setValue } = useForm({ + defaultValues: appliedDefaults, + resolver: zodResolver(DrawerSchema), + }); + + // Whenever the drawer is (re)opened with a fresh value from the parent, + // sync the local form to that value. Reopening after a cancel should not + // resurrect abandoned edits. + useEffect(() => { + if (opened) reset(appliedDefaults); + }, [opened, appliedDefaults, reset]); + + const applyChanges = useCallback(() => { + handleSubmit(values => { + onChange(values.onClick); + onClose(); + })(); + }, [handleSubmit, onChange, onClose]); + + const handleClose = useCallback(() => { + reset(appliedDefaults); + onClose(); + }, [reset, appliedDefaults, onClose]); + + const resetToDefaults = useCallback(() => { + reset({ onClick: noneOnClick() }); + }, [reset]); + + return ( + + + + {HELP_TEXT} + + + ( + { + if (next === 'none') setValue('onClick', noneOnClick()); + else if (next === 'dashboard') + setValue('onClick', emptyDashboardOnClick()); + else if (next === 'search') + setValue('onClick', emptySearchOnClick()); + }} + fullWidth + /> + )} + /> + + + + + + + + + + + + + + ); +} + +function ModeFields({ + control, + setValue, +}: { + control: Control; + setValue: (name: 'onClick', value: TableOnClick) => void; +}) { + const onClick = useWatch({ control, name: 'onClick' }); + if (!onClick) return null; + + if (onClick.type === 'dashboard') { + return ; + } + + if (onClick.type === 'search') { + return ; + } + + // Default (type: 'none') + return ( + + Clicking a row opens the search page, pre-filtered by the row's + group-by column values and the current dashboard's time range. + + ); +} + +function DashboardOnClickFields({ + onClick, + setValue, +}: { + onClick: Extract; + setValue: (name: 'onClick', value: TableOnClick) => void; +}) { + const { data: dashboards } = useDashboards(); + + const dashboardOptions = useMemo( + () => (dashboards ?? []).map(d => ({ value: d.id, label: d.name })), + [dashboards], + ); + + const mode = onClick.target.mode; + + return ( + + { + if (next === 'id') { + setValue('onClick', { + ...onClick, + target: { mode: 'id', dashboardId: '' }, + }); + } else { + setValue('onClick', { + ...onClick, + target: { mode: 'name-template', nameTemplate: '' }, + }); + } + }} + /> + + {mode === 'id' && ( +