mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
type simplification
This commit is contained in:
parent
34defe5a06
commit
404e2cd813
6 changed files with 254 additions and 230 deletions
|
|
@ -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: "<ObjectId>" }` — precise, use when you have ' +
|
||||
' the returned id from step 1 (dashboard) or from ' +
|
||||
' hyperdx_list_sources (source).\n' +
|
||||
' - `{ mode: "template", template: "<Handlebars>" }` — 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: "<column/expression>", ' +
|
||||
' template: "{{ColumnName}}" }`. Values are SQL-escaped automatically.\n' +
|
||||
|
|
|
|||
|
|
@ -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": "<log source>" }, ' +
|
||||
' { "type": "search", "target": { "mode": "id", "id": "<log source 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": "<from list_sources>", ' +
|
||||
'"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": "<log source>", ' +
|
||||
'"groupBy": "TraceId", "select": [{ "aggFn": "count", "where": "SeverityText:ERROR" }], ' +
|
||||
'"onClick": { "type": "search", "source": { "mode": "id", "sourceId": "<log source>" }, ' +
|
||||
'"onClick": { "type": "search", "target": { "mode": "id", "id": "<log source>" }, ' +
|
||||
'"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] } } }',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<TableOnClick, { type: 'dashboard' }>,
|
||||
'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 <DashboardOnClickFields onClick={onClick} setValue={setValue} />;
|
||||
return (
|
||||
<DashboardOnClickFields
|
||||
onClick={onClick}
|
||||
setValue={setValue}
|
||||
control={control}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick.type === 'search') {
|
||||
return <SearchOnClickFields onClick={onClick} setValue={setValue} />;
|
||||
return (
|
||||
<SearchOnClickFields
|
||||
onClick={onClick}
|
||||
setValue={setValue}
|
||||
control={control}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default (type: 'none')
|
||||
|
|
@ -190,9 +232,11 @@ function ModeFields({
|
|||
function DashboardOnClickFields({
|
||||
onClick,
|
||||
setValue,
|
||||
control,
|
||||
}: {
|
||||
onClick: Extract<TableOnClick, { type: 'dashboard' }>;
|
||||
setValue: (name: 'onClick', value: TableOnClick) => void;
|
||||
control: Control<DrawerFormValues>;
|
||||
}) {
|
||||
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<string | undefined>(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 (
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'By Dashboard', value: 'id' },
|
||||
{ label: 'By Name (templated)', value: 'name-template' },
|
||||
{ label: 'By Name (templated)', value: 'template' },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={next => {
|
||||
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' && (
|
||||
<TextInput
|
||||
label="Target dashboard name (Handlebars)"
|
||||
description="Rendered per row. Target name must match exactly one dashboard on your team."
|
||||
placeholder="{{ServiceName}} Errors"
|
||||
value={
|
||||
onClick.target.mode === 'name-template'
|
||||
? onClick.target.nameTemplate
|
||||
: ''
|
||||
onClick.target.mode === 'template' ? onClick.target.template : ''
|
||||
}
|
||||
onChange={e => {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
target: {
|
||||
mode: 'name-template',
|
||||
nameTemplate: e.currentTarget.value,
|
||||
mode: 'template',
|
||||
template: e.currentTarget.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="Global WHERE template (optional)"
|
||||
placeholder="ServiceName = '{{ServiceName}}'"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={onClick.whereTemplate ?? ''}
|
||||
onChange={e => {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
whereTemplate: e.currentTarget.value || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'SQL', value: 'sql' },
|
||||
{ label: 'Lucene', value: 'lucene' },
|
||||
]}
|
||||
value={onClick.whereLanguage ?? 'sql'}
|
||||
onChange={next => {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
whereLanguage: next as 'sql' | 'lucene',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Global WHERE template (optional)
|
||||
</Text>
|
||||
<SearchWhereInput
|
||||
control={control}
|
||||
name="onClick.whereTemplate"
|
||||
languageName="onClick.whereLanguage"
|
||||
showLabel={false}
|
||||
allowMultiline
|
||||
sqlPlaceholder="ServiceName = '{{ServiceName}}'"
|
||||
lucenePlaceholder="ServiceName:{{ServiceName}}"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<FilterExpressionList
|
||||
entries={onClick.filterValueTemplates ?? []}
|
||||
|
|
@ -394,9 +452,11 @@ function FilterExpressionList({
|
|||
function SearchOnClickFields({
|
||||
onClick,
|
||||
setValue,
|
||||
control,
|
||||
}: {
|
||||
onClick: Extract<TableOnClick, { type: 'search' }>;
|
||||
setValue: (name: 'onClick', value: TableOnClick) => void;
|
||||
control: Control<DrawerFormValues>;
|
||||
}) {
|
||||
const { data: sources } = useSources();
|
||||
const sourceOptions = useMemo(
|
||||
|
|
@ -404,7 +464,7 @@ function SearchOnClickFields({
|
|||
[sources],
|
||||
);
|
||||
|
||||
const mode = onClick.source.mode;
|
||||
const mode = onClick.target.mode;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
|
|
@ -418,12 +478,12 @@ function SearchOnClickFields({
|
|||
if (next === 'id') {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
source: { mode: 'id', sourceId: '' },
|
||||
target: { mode: 'id', id: '' },
|
||||
});
|
||||
} else {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
source: { mode: 'template', sourceTemplate: '' },
|
||||
target: { mode: 'template', template: '' },
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
@ -435,11 +495,11 @@ function SearchOnClickFields({
|
|||
placeholder="Select a source"
|
||||
searchable
|
||||
data={sourceOptions}
|
||||
value={onClick.source.mode === 'id' ? onClick.source.sourceId : ''}
|
||||
value={onClick.target.mode === 'id' ? onClick.target.id : ''}
|
||||
onChange={next => {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
source: { mode: 'id', sourceId: next ?? '' },
|
||||
target: { mode: 'id', id: next ?? '' },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
@ -448,52 +508,37 @@ function SearchOnClickFields({
|
|||
{mode === 'template' && (
|
||||
<TextInput
|
||||
label="Source template"
|
||||
description="Resolves to a source name (case-insensitive)"
|
||||
placeholder="Logs-{{SourceName}}"
|
||||
description="Resolves to a source id or case-insensitive source name."
|
||||
placeholder="{{SourceName}}"
|
||||
value={
|
||||
onClick.source.mode === 'template'
|
||||
? onClick.source.sourceTemplate
|
||||
: ''
|
||||
onClick.target.mode === 'template' ? onClick.target.template : ''
|
||||
}
|
||||
onChange={e => {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
source: {
|
||||
target: {
|
||||
mode: 'template',
|
||||
sourceTemplate: e.currentTarget.value,
|
||||
template: e.currentTarget.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="WHERE template"
|
||||
placeholder="ServiceName = '{{ServiceName}}'"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={onClick.whereTemplate ?? ''}
|
||||
onChange={e => {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
whereTemplate: e.currentTarget.value || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'SQL', value: 'sql' },
|
||||
{ label: 'Lucene', value: 'lucene' },
|
||||
]}
|
||||
value={onClick.whereLanguage ?? 'sql'}
|
||||
onChange={next => {
|
||||
setValue('onClick', {
|
||||
...onClick,
|
||||
whereLanguage: next as 'sql' | 'lucene',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
WHERE template
|
||||
</Text>
|
||||
<SearchWhereInput
|
||||
control={control}
|
||||
name="onClick.whereTemplate"
|
||||
languageName="onClick.whereLanguage"
|
||||
showLabel={false}
|
||||
allowMultiline
|
||||
sqlPlaceholder="ServiceName = '{{ServiceName}}'"
|
||||
lucenePlaceholder="ServiceName:{{ServiceName}}"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<FilterExpressionList
|
||||
entries={onClick.filterValueTemplates ?? []}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
it('uses a concrete id target', () => {
|
||||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: { mode: 'id', dashboardId: 'dash_1' },
|
||||
target: { mode: 'id', id: 'dash_1' },
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
const result = buildDashboardLinkUrl({
|
||||
|
|
@ -46,8 +46,8 @@ describe('buildDashboardLinkUrl', () => {
|
|||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: {
|
||||
mode: 'name-template',
|
||||
nameTemplate: '{{ServiceName}} Errors',
|
||||
mode: 'template',
|
||||
template: '{{ServiceName}} Errors',
|
||||
},
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
|
|
@ -64,7 +64,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
it('resolves case-insensitively', () => {
|
||||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: { mode: 'name-template', nameTemplate: 'API ERRORS' },
|
||||
target: { mode: 'template', template: 'API ERRORS' },
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
const result = buildDashboardLinkUrl({
|
||||
|
|
@ -81,8 +81,8 @@ describe('buildDashboardLinkUrl', () => {
|
|||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: {
|
||||
mode: 'name-template',
|
||||
nameTemplate: '{{ServiceName}} Errors',
|
||||
mode: 'template',
|
||||
template: '{{ServiceName}} Errors',
|
||||
},
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
|
|
@ -101,7 +101,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
it('errors when the rendered name matches more than one dashboard', () => {
|
||||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: { mode: 'name-template', nameTemplate: 'Errors' },
|
||||
target: { mode: 'template', template: 'Errors' },
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
const result = buildDashboardLinkUrl({
|
||||
|
|
@ -126,7 +126,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
// In strict mode, referencing a missing key throws — so a template that
|
||||
// legitimately resolves to empty needs the key to exist with a falsy
|
||||
// value and a helper default.
|
||||
target: { mode: 'name-template', nameTemplate: '{{default v ""}}' },
|
||||
target: { mode: 'template', template: '{{default v ""}}' },
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
const result = buildDashboardLinkUrl({
|
||||
|
|
@ -143,8 +143,8 @@ describe('buildDashboardLinkUrl', () => {
|
|||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: {
|
||||
mode: 'name-template',
|
||||
nameTemplate: '{{ServiceName}} Errors',
|
||||
mode: 'template',
|
||||
template: '{{ServiceName}} Errors',
|
||||
},
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
|
|
@ -163,7 +163,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
it('renders whereTemplate into the url', () => {
|
||||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: { mode: 'id', dashboardId: 'd1' },
|
||||
target: { mode: 'id', id: 'd1' },
|
||||
whereTemplate: "ServiceName = '{{ServiceName}}'",
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
|
|
@ -184,7 +184,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
it('renders filterValueTemplates into SQL IN conditions using the raw expression', () => {
|
||||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: { mode: 'id', dashboardId: 'd1' },
|
||||
target: { mode: 'id', id: 'd1' },
|
||||
filterValueTemplates: [
|
||||
{ filter: 'ServiceName', template: '{{ServiceName}}' },
|
||||
{ filter: 'Env', template: 'prod' },
|
||||
|
|
@ -211,7 +211,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
it('skips filterValueTemplates rows with empty filter or template', () => {
|
||||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: { mode: 'id', dashboardId: 'd1' },
|
||||
target: { mode: 'id', id: 'd1' },
|
||||
filterValueTemplates: [
|
||||
{ filter: '', template: '{{ServiceName}}' },
|
||||
{ filter: 'Env', template: '' },
|
||||
|
|
@ -236,7 +236,7 @@ describe('buildDashboardLinkUrl', () => {
|
|||
it('merges repeated filter expressions into a single IN clause', () => {
|
||||
const onClick: TableOnClickDashboard = {
|
||||
type: 'dashboard',
|
||||
target: { mode: 'id', dashboardId: 'd1' },
|
||||
target: { mode: 'id', id: 'd1' },
|
||||
filterValueTemplates: [
|
||||
{ filter: 'ServiceName', template: 'api' },
|
||||
{ filter: 'Env', template: 'prod' },
|
||||
|
|
@ -274,7 +274,7 @@ describe('renderSearchLinkPieces', () => {
|
|||
it('resolves source by id', () => {
|
||||
const onClick: TableOnClickSearch = {
|
||||
type: 'search',
|
||||
source: { mode: 'id', sourceId: 'src_1' },
|
||||
target: { mode: 'id', id: 'src_1' },
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
const result = renderSearchLinkPieces({
|
||||
|
|
@ -290,7 +290,7 @@ describe('renderSearchLinkPieces', () => {
|
|||
it('resolves source by templated name', () => {
|
||||
const onClick: TableOnClickSearch = {
|
||||
type: 'search',
|
||||
source: { mode: 'template', sourceTemplate: '{{Src}}' },
|
||||
target: { mode: 'template', template: '{{Src}}' },
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
const result = renderSearchLinkPieces({
|
||||
|
|
@ -309,7 +309,7 @@ describe('renderSearchLinkPieces', () => {
|
|||
it('errors when source template does not match any source', () => {
|
||||
const onClick: TableOnClickSearch = {
|
||||
type: 'search',
|
||||
source: { mode: 'template', sourceTemplate: '{{Src}}' },
|
||||
target: { mode: 'template', template: '{{Src}}' },
|
||||
whereLanguage: 'sql',
|
||||
};
|
||||
const result = renderSearchLinkPieces({
|
||||
|
|
@ -325,7 +325,7 @@ describe('renderSearchLinkPieces', () => {
|
|||
it('renders filterValueTemplates into SQL IN conditions', () => {
|
||||
const onClick: TableOnClickSearch = {
|
||||
type: 'search',
|
||||
source: { mode: 'id', sourceId: 'src_1' },
|
||||
target: { mode: 'id', id: 'src_1' },
|
||||
whereLanguage: 'sql',
|
||||
filterValueTemplates: [
|
||||
{ filter: 'ServiceName', template: '{{ServiceName}}' },
|
||||
|
|
@ -350,7 +350,7 @@ describe('renderSearchLinkPieces', () => {
|
|||
it('skips filterValueTemplates rows with empty filter or template', () => {
|
||||
const onClick: TableOnClickSearch = {
|
||||
type: 'search',
|
||||
source: { mode: 'id', sourceId: 'src_1' },
|
||||
target: { mode: 'id', id: 'src_1' },
|
||||
whereLanguage: 'sql',
|
||||
filterValueTemplates: [
|
||||
{ filter: '', template: '{{ServiceName}}' },
|
||||
|
|
@ -375,7 +375,7 @@ describe('renderSearchLinkPieces', () => {
|
|||
it('merges repeated filter expressions into a single IN clause', () => {
|
||||
const onClick: TableOnClickSearch = {
|
||||
type: 'search',
|
||||
source: { mode: 'id', sourceId: 'src_1' },
|
||||
target: { mode: 'id', id: 'src_1' },
|
||||
whereLanguage: 'sql',
|
||||
filterValueTemplates: [
|
||||
{ filter: 'ServiceName', template: 'api' },
|
||||
|
|
|
|||
|
|
@ -87,11 +87,11 @@ function renderFilterTemplates(
|
|||
|
||||
/**
|
||||
* Build a URL to navigate from a table row click to another dashboard.
|
||||
* For `mode: 'name-template'`, resolves the rendered dashboard name against
|
||||
* the supplied lookup (case-insensitive). Since dashboard names are not
|
||||
* unique per team, ambiguous resolutions surface an error rather than
|
||||
* silently picking one. Renders handlebars templates with the row context
|
||||
* and appends the current dashboard's time range so it is preserved across
|
||||
* For `mode: 'template'`, resolves the rendered dashboard name against the
|
||||
* supplied lookup (case-insensitive). Since dashboard names are not unique
|
||||
* per team, ambiguous resolutions surface an error rather than silently
|
||||
* picking one. Renders handlebars templates with the row context and
|
||||
* appends the current dashboard's time range so it is preserved across
|
||||
* navigation.
|
||||
*/
|
||||
export function buildDashboardLinkUrl({
|
||||
|
|
@ -107,16 +107,16 @@ export function buildDashboardLinkUrl({
|
|||
}): LinkBuildResult {
|
||||
let dashboardId: string;
|
||||
if (onClick.target.mode === 'id') {
|
||||
if (!onClick.target.dashboardId) {
|
||||
if (!onClick.target.id) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Dashboard link: no target dashboard selected',
|
||||
};
|
||||
}
|
||||
dashboardId = onClick.target.dashboardId;
|
||||
dashboardId = onClick.target.id;
|
||||
} else {
|
||||
const nameResult = renderOrError(
|
||||
onClick.target.nameTemplate,
|
||||
onClick.target.template,
|
||||
row,
|
||||
'Dashboard link',
|
||||
);
|
||||
|
|
@ -203,8 +203,8 @@ export function renderSearchLinkPieces({
|
|||
}): { ok: true; value: RenderedSearchLink } | { ok: false; error: string } {
|
||||
let sourceId: string;
|
||||
let sourceResolvedFrom: RenderedSearchLink['sourceResolvedFrom'];
|
||||
if (onClick.source.mode === 'id') {
|
||||
sourceId = onClick.source.sourceId;
|
||||
if (onClick.target.mode === 'id') {
|
||||
sourceId = onClick.target.id;
|
||||
if (!sourceId) {
|
||||
return { ok: false, error: 'Search link: no target source selected' };
|
||||
}
|
||||
|
|
@ -216,11 +216,7 @@ export function renderSearchLinkPieces({
|
|||
}
|
||||
sourceResolvedFrom = 'id';
|
||||
} else {
|
||||
const rendered = renderOrError(
|
||||
onClick.source.sourceTemplate,
|
||||
row,
|
||||
'Search link',
|
||||
);
|
||||
const rendered = renderOrError(onClick.target.template, row, 'Search link');
|
||||
if (!rendered.ok) return rendered;
|
||||
const value = rendered.value.trim();
|
||||
if (value === '') {
|
||||
|
|
|
|||
|
|
@ -688,21 +688,23 @@ export type TableOnClickFilterTemplate = z.infer<
|
|||
typeof TableOnClickFilterTemplateSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Shared target shape for both dashboard and search onClicks:
|
||||
* - `{ mode: 'id', id }` references a specific destination by its ObjectId.
|
||||
* - `{ mode: 'template', template }` renders a Handlebars template to either
|
||||
* a name (for dashboards) or a name / id (for sources) at click time.
|
||||
* Empty strings are permitted so the drawer can be committed mid-edit; the
|
||||
* URL builder surfaces empty values as explicit errors at click time.
|
||||
*/
|
||||
export const TableOnClickTargetSchema = z.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('id'), id: z.string() }),
|
||||
z.object({ mode: z.literal('template'), template: z.string() }),
|
||||
]);
|
||||
export type TableOnClickTarget = z.infer<typeof TableOnClickTargetSchema>;
|
||||
|
||||
export const TableOnClickDashboardSchema = z.object({
|
||||
type: z.literal('dashboard'),
|
||||
// Empty strings are permitted so the drawer can be committed mid-edit (e.g.,
|
||||
// user switched to dashboard mode but hasn't picked a target yet). Empty
|
||||
// values are surfaced as errors at click time by the URL builder.
|
||||
target: z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
mode: z.literal('id'),
|
||||
dashboardId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal('name-template'),
|
||||
nameTemplate: z.string(),
|
||||
}),
|
||||
]),
|
||||
target: TableOnClickTargetSchema,
|
||||
whereTemplate: z.string().optional(),
|
||||
whereLanguage: SearchConditionLanguageSchema,
|
||||
filterValueTemplates: z.array(TableOnClickFilterTemplateSchema).optional(),
|
||||
|
|
@ -711,18 +713,7 @@ export type TableOnClickDashboard = z.infer<typeof TableOnClickDashboardSchema>;
|
|||
|
||||
export const TableOnClickSearchSchema = z.object({
|
||||
type: z.literal('search'),
|
||||
// Same as dashboard above: allow empty strings so Apply can commit
|
||||
// mid-edit; the URL builder validates at click time.
|
||||
source: z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
mode: z.literal('id'),
|
||||
sourceId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal('template'),
|
||||
sourceTemplate: z.string(),
|
||||
}),
|
||||
]),
|
||||
target: TableOnClickTargetSchema,
|
||||
whereTemplate: z.string().optional(),
|
||||
whereLanguage: SearchConditionLanguageSchema,
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue