wip: Dashboard Linking PoC

This commit is contained in:
Drew Davis 2026-04-20 12:42:03 -04:00
parent f086842f3c
commit 34defe5a06
23 changed files with 2479 additions and 203 deletions

View file

@ -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: "<column/expression>", ' +
' 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()

View file

@ -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": "<log source>" }, ' +
'"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": "<from list_sources>", ' +
'"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": "<from list_sources>", ' +
'"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": "<log source>", ' +
'"groupBy": "TraceId", "select": [{ "aggFn": "count", "where": "SeverityText:ERROR" }], ' +
'"onClick": { "type": "search", "source": { "mode": "id", "sourceId": "<log source>" }, ' +
'"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] } } }',
);

View file

@ -15,39 +15,38 @@ export interface IDashboard extends z.infer<typeof DashboardSchema> {
export type DashboardDocument = mongoose.HydratedDocument<IDashboard>;
export default mongoose.model<IDashboard>(
'Dashboard',
new Schema<IDashboard>(
{
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<IDashboard>(
{
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<IDashboard>('Dashboard', dashboardSchema);

View file

@ -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),

View file

@ -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 =

View file

@ -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<typeof FormSchema>;
const [error, setError] = useState<{
message: string;
details?: string;
} | null>(null);
const [errorDetails, { toggle: toggleErrorDetails }] = useDisclosure(false);
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
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 (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<Controller
name="file"
control={control}
render={({ field }) => (
<Dropzone
onDrop={files => {
field.onChange(files[0]);
handleSubmit(onSubmit)();
}}
onReject={() =>
setError({ message: 'Invalid File Type or Size' })
}
maxSize={5 * 1024 ** 2}
maxFiles={1}
accept={['application/json']}
>
<Group
justify="center"
gap="xl"
mih={150}
style={{ pointerEvents: 'none' }}
>
<Dropzone.Accept>
<IconUpload
size={52}
color="var(--color-text-brand)"
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX
size={52}
color="var(--color-text-danger)"
stroke={1.5}
/>
</Dropzone.Reject>
<Dropzone.Idle>
<IconFile
size={52}
color="var(--color-text-muted)"
stroke={1.5}
/>
</Dropzone.Idle>
<Stack gap="sm">
<Dropzone
onDrop={handleDrop}
onReject={() => setError({ message: 'Invalid File Type or Size' })}
maxSize={5 * 1024 ** 2}
accept={['application/json']}
>
<Group
justify="center"
gap="xl"
mih={150}
style={{ pointerEvents: 'none' }}
>
<Dropzone.Accept>
<IconUpload
size={52}
color="var(--color-text-brand)"
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={52} color="var(--color-text-danger)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFile size={52} color="var(--color-text-muted)" stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Import Dashboard
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Drag and drop a JSON file here, or click to select from your
computer.
</Text>
</div>
</Group>
</Dropzone>
)}
/>
{error && (
<div>
<Text c="red">{error.message}</Text>
{error.details && (
<>
<Button
variant="transparent"
onClick={toggleErrorDetails}
px={0}
>
<Group c="red" gap={0} align="center">
<IconChevronRight
size="16px"
style={{
transition: 'transform 0.2s ease-in-out',
transform: errorDetails
? 'rotate(90deg)'
: 'rotate(0deg)',
}}
/>
{errorDetails ? 'Hide Details' : 'Show Details'}
</Group>
</Button>
<Collapse expanded={errorDetails}>
<Text c="red">{error.details}</Text>
</Collapse>
</>
)}
<Text size="xl" inline>
Import Dashboard
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
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.
</Text>
</div>
)}
</Stack>
</form>
</Group>
</Dropzone>
{error && (
<div>
<Text c="red">{error.message}</Text>
{error.details && (
<>
<Button variant="transparent" onClick={toggleErrorDetails} px={0}>
<Group c="red" gap={0} align="center">
<IconChevronRight
size="16px"
style={{
transition: 'transform 0.2s ease-in-out',
transform: errorDetails
? 'rotate(90deg)'
: 'rotate(0deg)',
}}
/>
{errorDetails ? 'Hide Details' : 'Show Details'}
</Group>
</Button>
<Collapse expanded={errorDetails}>
<Text c="red" style={{ whiteSpace: 'pre-wrap' }}>
{error.details}
</Text>
</Collapse>
</>
)}
</div>
)}
</Stack>
);
}
@ -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<typeof BundleMappingForm>;
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<BundleMappingFormValues>({
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 (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="lg">
<Text fw={500} size="sm">
Step 2: Map Data for {inputs.length} dashboards
</Text>
{inputs.map((input, dashIdx) => (
<Stack key={input.fileName} gap="sm">
<Text fw={500}>{input.fileName}</Text>
<Controller
name={`dashboards.${dashIdx}.dashboardName`}
control={control}
render={({ field, formState }) => (
<TextInput
label="Dashboard Name"
{...field}
error={
formState.errors.dashboards?.[dashIdx]?.dashboardName
?.message
}
/>
)}
/>
<Controller
name={`dashboards.${dashIdx}.tags`}
control={control}
render={({ field }) => (
<TagsInput
label="Tags"
placeholder="Add tags"
data={existingTags?.data ?? []}
{...field}
/>
)}
/>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Input Source</Table.Th>
<Table.Th>Mapped Source</Table.Th>
<Table.Th>Input Connection</Table.Th>
<Table.Th>Mapped Connection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{input.template.tiles.map((tile, i) => {
const config = tile.config;
const isRawSql = isRawSqlSavedChartConfig(config);
return (
<Table.Tr key={tile.id}>
<Table.Td>{tile.config.name}</Table.Td>
<Table.Td>{config.source ?? ''}</Table.Td>
<Table.Td>
{config.source != null && (
<SelectControlled
control={control}
name={`dashboards.${dashIdx}.sourceMappings.${i}`}
data={sources?.map(source => ({
value: source.id,
label: source.name,
}))}
placeholder="Select a source"
/>
)}
</Table.Td>
<Table.Td>{isRawSql ? config.connection : ''}</Table.Td>
<Table.Td>
{isRawSql ? (
<SelectControlled
control={control}
name={`dashboards.${dashIdx}.connectionMappings.${i}`}
data={connections?.map(conn => ({
value: conn.id,
label: conn.name,
}))}
placeholder="Select a connection"
/>
) : null}
</Table.Td>
</Table.Tr>
);
})}
{input.template.filters?.map((filter, i) => (
<Table.Tr key={filter.id}>
<Table.Td>{filter.name} (filter)</Table.Td>
<Table.Td>{filter.source}</Table.Td>
<Table.Td>
<SelectControlled
control={control}
name={`dashboards.${dashIdx}.filterSourceMappings.${i}`}
data={sources?.map(source => ({
value: source.id,
label: source.name,
}))}
placeholder="Select a source"
/>
</Table.Td>
<Table.Td />
<Table.Td />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
))}
{createDashboard.isError && (
<Text c="red">{createDashboard.error.toString()}</Text>
)}
<Button type="submit" loading={createDashboard.isPending} mb="md">
Finish Import
</Button>
</Stack>
</form>
);
}
function DBDashboardImportPage() {
const brandName = useBrandDisplayName();
const router = useRouter();
@ -533,8 +773,11 @@ function DBDashboardImportPage() {
[templateName],
);
const [fileInput, setFileInput] = useState<DashboardTemplate | null>(null);
const input = templateInput ?? fileInput;
const [fileInputs, setFileInputs] = useState<ImportedFile[] | null>(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 ? (
<FileSelection
onComplete={i => {
setFileInput(i);
setFileInputs(i);
}}
/>
) : null}
{input && <Mapping input={input} />}
{inputs && inputs.length === 1 && (
<Mapping input={inputs[0].template} />
)}
{inputs && inputs.length > 1 && <BundleMapping inputs={inputs} />}
</Stack>
</Container>
</div>

View file

@ -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<string, unknown>, 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
}
/>
</Box>
)}
@ -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 }) {
</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,
});
}
}}
/>
<Box>
<EditablePageName
key={`${dashboardHash}`}
name={dashboard?.name ?? ''}
onSave={editedName => {
if (dashboard != null) {
setDashboard({
...dashboard,
name: editedName,
});
}
}}
/>
</Box>
<Group gap="xs">
{!isLocalDashboard && dashboard?.id && (
<FavoriteButton
@ -1843,6 +1898,20 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
Run
</Button>
</Flex>
{shouldShowIgnoredFiltersBanner && (
<Alert
mt="sm"
color="yellow"
icon={<IconAlertTriangle size={16} />}
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.
</Alert>
)}
<DashboardFilters
filters={filters}
filterValues={filterValues}

View file

@ -36,6 +36,7 @@ export const Table = ({
groupColumnName,
columns,
getRowSearchLink,
onRowLinkClick,
tableBottom,
enableClientSideSorting = false,
sorting,
@ -55,6 +56,14 @@ export const Table = ({
}[];
groupColumnName?: string;
getRowSearchLink?: (row: any) => 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 (
<div
role="link"
tabIndex={0}
className={cx('align-top overflow-hidden py-1 pe-3', {
'text-break': wrapLinesEnabled,
'text-truncate': !wrapLinesEnabled,
})}
style={{ cursor: 'pointer' }}
// Left-click: fires onClick with button === 0. The parent
// handler detects meta/ctrl for cmd/ctrl-click → new tab.
onClick={e => 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}
</div>
);
}
if (getRowSearchLink == null) {
return formattedValue;
}

View file

@ -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
</Button>
)}
{displayType === DisplayType.Table && (
<Button
onClick={openOnClickDrawer}
size="compact-sm"
variant="secondary"
>
Row Click Action: {onClickTypeLabel}
</Button>
)}
<Button
onClick={onOpenDisplaySettings}
size="compact-sm"
@ -255,6 +278,12 @@ export default function RawSqlChartEditor({
tooltip={alertTooltip}
/>
)}
<TableOnClickDrawer
opened={onClickDrawerOpened}
value={onClickValue}
onChange={next => setValue('onClick', next, { shouldDirty: true })}
onClose={closeOnClickDrawer}
/>
</Stack>
);
}

View file

@ -78,6 +78,7 @@ export function convertFormStateToSavedChartConfig(
'fillNulls',
'alignDateRangeToGranularity',
'alert',
'onClick',
]),
sqlTemplate: form.sqlTemplate ?? '',
connection: form.connection ?? '',

View file

@ -4,6 +4,7 @@ import {
FieldErrors,
UseFormClearErrors,
UseFormSetValue,
useWatch,
} from 'react-hook-form';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards';
@ -14,6 +15,7 @@ import {
TSource,
} from '@hyperdx/common-utils/dist/types';
import { Box, Button, Divider, Flex, Group, Switch, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconBell, IconCirclePlus } from '@tabler/icons-react';
import {
@ -29,6 +31,7 @@ import { IS_LOCAL_MODE } from '@/config';
import { DEFAULT_TILE_ALERT } from '@/utils/alerts';
import { ChartSeriesEditor } from './ChartSeriesEditor';
import TableOnClickDrawer from './TableOnClickDrawer';
import { TileAlertEditor } from './TileAlertEditor';
type ChartEditorControlsProps = {
@ -84,6 +87,18 @@ export function ChartEditorControls({
onSubmit,
openDisplaySettings,
}: ChartEditorControlsProps) {
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';
return (
<>
<Flex mb="md" align="center" justify="space-between">
@ -259,13 +274,24 @@ export function ChartEditorControls({
</Button>
)}
</Group>
<Button
onClick={openDisplaySettings}
size="compact-sm"
variant="secondary"
>
Display Settings
</Button>
<Group>
{displayType === DisplayType.Table && (
<Button
onClick={openOnClickDrawer}
size="compact-sm"
variant="secondary"
>
Row Click Action: {onClickTypeLabel}
</Button>
)}
<Button
onClick={openDisplaySettings}
size="compact-sm"
variant="secondary"
>
Display Settings
</Button>
</Group>
</Flex>
</>
) : (
@ -311,6 +337,15 @@ export function ChartEditorControls({
/>
</Box>
)}
<TableOnClickDrawer
opened={onClickDrawerOpened}
value={onClickValue}
onChange={next => {
setValue('onClick', next, { shouldDirty: true });
onSubmit();
}}
onClose={closeOnClickDrawer}
/>
</>
);
}

View file

@ -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<DrawerFormValues>({
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 (
<Drawer
title="On Row Click"
opened={opened}
onClose={handleClose}
position="right"
size="lg"
>
<Stack>
<Text size="xs" c="dimmed">
{HELP_TEXT}
</Text>
<Controller
control={control}
name="onClick"
render={({ field: { value: onClickValue } }) => (
<SegmentedControl
data={[
{ label: 'Default', value: 'none' },
{ label: 'Dashboard', value: 'dashboard' },
{ label: 'Search', value: 'search' },
]}
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());
}}
fullWidth
/>
)}
/>
<ModeFields control={control} setValue={setValue} />
<Divider />
<Group justify="space-between">
<Button variant="secondary" onClick={resetToDefaults}>
Reset
</Button>
<Group>
<Button variant="subtle" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" onClick={applyChanges}>
Apply
</Button>
</Group>
</Group>
</Stack>
</Drawer>
);
}
function ModeFields({
control,
setValue,
}: {
control: Control<DrawerFormValues>;
setValue: (name: 'onClick', value: TableOnClick) => void;
}) {
const onClick = useWatch({ control, name: 'onClick' });
if (!onClick) return null;
if (onClick.type === 'dashboard') {
return <DashboardOnClickFields onClick={onClick} setValue={setValue} />;
}
if (onClick.type === 'search') {
return <SearchOnClickFields onClick={onClick} setValue={setValue} />;
}
// Default (type: 'none')
return (
<Text size="sm" c="dimmed">
Clicking a row opens the search page, pre-filtered by the row&apos;s
group-by column values and the current dashboard&apos;s time range.
</Text>
);
}
function DashboardOnClickFields({
onClick,
setValue,
}: {
onClick: Extract<TableOnClick, { type: 'dashboard' }>;
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 (
<Stack gap="xs">
<SegmentedControl
data={[
{ label: 'By Dashboard', value: 'id' },
{ label: 'By Name (templated)', value: 'name-template' },
]}
value={mode}
onChange={next => {
if (next === 'id') {
setValue('onClick', {
...onClick,
target: { mode: 'id', dashboardId: '' },
});
} else {
setValue('onClick', {
...onClick,
target: { mode: 'name-template', nameTemplate: '' },
});
}
}}
/>
{mode === 'id' && (
<Select
label="Target dashboard"
placeholder="Select a dashboard"
searchable
data={dashboardOptions}
value={onClick.target.mode === 'id' ? onClick.target.dashboardId : ''}
onChange={next => {
setValue('onClick', {
...onClick,
target: { mode: 'id', dashboardId: next ?? '' },
});
}}
/>
)}
{mode === 'name-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
: ''
}
onChange={e => {
setValue('onClick', {
...onClick,
target: {
mode: 'name-template',
nameTemplate: 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',
});
}}
/>
<FilterExpressionList
entries={onClick.filterValueTemplates ?? []}
onChange={entries =>
setValue('onClick', {
...onClick,
filterValueTemplates: entries.length === 0 ? undefined : entries,
})
}
/>
</Stack>
);
}
function upsertFilterEntries(
entries: TableOnClickFilterTemplate[],
index: number,
next: Partial<TableOnClickFilterTemplate>,
): TableOnClickFilterTemplate[] {
const copy = [...entries];
const current = copy[index] ?? { filter: '', template: '' };
copy[index] = { ...current, ...next };
return copy;
}
/**
* User-managed list of `{expression, value-template}` rows. Expression is
* treated as raw SQL on the destination, so the URL builder can emit
* `expression IN (value)` without consulting the target dashboard's filter
* metadata. Used by both dashboard and search onClick modes.
*/
function FilterExpressionList({
entries,
onChange,
}: {
entries: TableOnClickFilterTemplate[];
onChange: (next: TableOnClickFilterTemplate[]) => void;
}) {
return (
<Box>
<Text size="sm" fw={500} mb={4}>
Filters
</Text>
<Text size="xs" c="dimmed" mb="xs">
Enter an expression (e.g. a column name) and a Handlebars template for
its value.
</Text>
<Stack gap="xs">
{entries.map((entry, i) => (
<Group key={i} gap="xs" align="flex-end" wrap="nowrap">
<TextInput
label={i === 0 ? 'Expression' : undefined}
placeholder="ServiceName"
value={entry.filter}
onChange={e =>
onChange(
upsertFilterEntries(entries, i, {
filter: e.currentTarget.value,
}),
)
}
style={{ flex: 1 }}
/>
<TextInput
label={i === 0 ? 'Value template' : undefined}
placeholder="{{ServiceName}}"
value={entry.template}
onChange={e =>
onChange(
upsertFilterEntries(entries, i, {
template: e.currentTarget.value,
}),
)
}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Remove filter row"
onClick={() => onChange(entries.filter((_, j) => j !== i))}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<Button
variant="subtle"
size="compact-sm"
leftSection={<IconPlus size={14} />}
onClick={() => onChange([...entries, { filter: '', template: '' }])}
style={{ alignSelf: 'flex-start' }}
>
Add filter
</Button>
</Stack>
</Box>
);
}
function SearchOnClickFields({
onClick,
setValue,
}: {
onClick: Extract<TableOnClick, { type: 'search' }>;
setValue: (name: 'onClick', value: TableOnClick) => void;
}) {
const { data: sources } = useSources();
const sourceOptions = useMemo(
() => (sources ?? []).map(s => ({ value: s.id, label: s.name })),
[sources],
);
const mode = onClick.source.mode;
return (
<Stack gap="xs">
<SegmentedControl
data={[
{ label: 'By Source', value: 'id' },
{ label: 'By Name (templated)', value: 'template' },
]}
value={mode}
onChange={next => {
if (next === 'id') {
setValue('onClick', {
...onClick,
source: { mode: 'id', sourceId: '' },
});
} else {
setValue('onClick', {
...onClick,
source: { mode: 'template', sourceTemplate: '' },
});
}
}}
/>
{mode === 'id' && (
<Select
label="Target source"
placeholder="Select a source"
searchable
data={sourceOptions}
value={onClick.source.mode === 'id' ? onClick.source.sourceId : ''}
onChange={next => {
setValue('onClick', {
...onClick,
source: { mode: 'id', sourceId: next ?? '' },
});
}}
/>
)}
{mode === 'template' && (
<TextInput
label="Source template"
description="Resolves to a source name (case-insensitive)"
placeholder="Logs-{{SourceName}}"
value={
onClick.source.mode === 'template'
? onClick.source.sourceTemplate
: ''
}
onChange={e => {
setValue('onClick', {
...onClick,
source: {
mode: 'template',
sourceTemplate: 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',
});
}}
/>
<FilterExpressionList
entries={onClick.filterValueTemplates ?? []}
onChange={entries =>
setValue('onClick', {
...onClick,
filterValueTemplates: entries.length === 0 ? undefined : entries,
})
}
/>
</Stack>
);
}

View file

@ -27,6 +27,7 @@ import { SQLPreview } from './ChartSQLPreview';
export default function DBTableChart({
config,
getRowSearchLink,
onRowLinkClick,
enabled = true,
queryKeyPrefix,
onSortingChange,
@ -40,6 +41,7 @@ export default function DBTableChart({
}: {
config: ChartConfigWithOptTimestamp;
getRowSearchLink?: (row: any) => string | null;
onRowLinkClick?: (row: any, e: React.MouseEvent) => void;
queryKeyPrefix?: string;
enabled?: boolean;
onSortingChange?: (sort: SortingState) => void;
@ -237,6 +239,7 @@ export default function DBTableChart({
data={data?.data ?? []}
columns={columns}
getRowSearchLink={getRowSearchLink}
onRowLinkClick={onRowLinkClick}
sorting={effectiveSort}
enableClientSideSorting={isRawSqlChartConfig(config)}
onSortingChange={handleSortingChange}

View file

@ -109,6 +109,7 @@ describe('usePresetDashboardFilters', () => {
setFilterValue: mockSetFilterValue,
filterQueries: mockFilterQueries,
setFilterQueries: jest.fn(),
ignoredFilterExpressions: [],
});
});

View file

@ -35,33 +35,50 @@ const useDashboardFilters = (filters: DashboardFilter[]) => {
[setFilterQueries],
);
const { valuesForExistingFilters, queriesForExistingFilters } =
useMemo(() => {
const { filters: parsedFilters } = parseQuery(filterQueries ?? []);
const valuesForExistingFilters: FilterState = {};
const {
valuesForExistingFilters,
queriesForExistingFilters,
ignoredExpressions,
} = useMemo(() => {
const { filters: parsedFilters } = parseQuery(filterQueries ?? []);
const valuesForExistingFilters: FilterState = {};
const knownExpressions = new Set(filters.map(f => f.expression));
const ignored: string[] = [];
for (const { expression } of filters) {
if (expression in parsedFilters) {
valuesForExistingFilters[expression] = parsedFilters[expression];
}
for (const { expression } of filters) {
if (expression in parsedFilters) {
valuesForExistingFilters[expression] = parsedFilters[expression];
}
}
for (const key of Object.keys(parsedFilters)) {
if (!knownExpressions.has(key)) {
ignored.push(key);
}
}
return {
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 },
),
};
}, [filterQueries, filters]);
// 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,
};
}, [filterQueries, filters]);
return {
filterValues: valuesForExistingFilters,
filterQueries: queriesForExistingFilters,
setFilterValue,
setFilterQueries,
/**
* Expressions parsed from the URL `filters=` param that don't correspond
* to any of this dashboard's declared filters i.e., values that would
* be silently dropped. Callers can surface a warning.
*/
ignoredFilterExpressions: ignoredExpressions,
};
};

View file

@ -0,0 +1,101 @@
import { useMemo, useRef } from 'react';
import {
buildDashboardLinkUrl,
buildSearchLinkUrlFromPieces,
DashboardLookup,
renderSearchLinkPieces,
} from '@hyperdx/common-utils/dist/core/linkUrlBuilder';
import { TableOnClick } from '@hyperdx/common-utils/dist/types';
import { notifications } from '@mantine/notifications';
import { useDashboards } from '@/dashboard';
import { useSources } from '@/source';
type ResolverArgs = {
onClick: TableOnClick | undefined;
dateRange: [Date, Date];
};
/**
* Returns a function that, given a table row, produces a URL for the
* configured onClick action. Errors (unresolved names, malformed templates,
* unknown sources) surface as Mantine toast notifications; the function
* returns null in those cases so the row renders as non-clickable text.
*/
export function useTableOnClickResolver({
onClick,
dateRange,
}: ResolverArgs): ((row: Record<string, unknown>) => string | null) | null {
const { data: dashboards } = useDashboards();
const { data: sources } = useSources();
// Avoid spamming toasts for the same error repeatedly as the user scrolls
// through identical rows — dedupe by error message within a resolver.
const shownErrorsRef = useRef<Set<string>>(new Set());
const lookup: DashboardLookup = useMemo(() => {
// Names aren't unique per team, so key by case-insensitive name → list of
// matching ids. The URL builder surfaces an error when a rendered name
// resolves to 0 or more than 1 match.
const nameToIds = new Map<string, string[]>();
for (const d of dashboards ?? []) {
const key = d.name.trim().toLowerCase();
if (!key) continue;
const list = nameToIds.get(key) ?? [];
list.push(d.id);
nameToIds.set(key, list);
}
return { nameToIds };
}, [dashboards]);
const sourcesById = useMemo(() => {
const map = new Map<string, { id: string; name: string }>();
for (const s of sources ?? []) map.set(s.id, { id: s.id, name: s.name });
return map;
}, [sources]);
const sourcesByName = useMemo(() => {
const map = new Map<string, { id: string; name: string }>();
for (const s of sources ?? [])
map.set(s.name.toLowerCase(), { id: s.id, name: s.name });
return map;
}, [sources]);
return useMemo(() => {
if (!onClick || onClick.type === 'none') return null;
return (row: Record<string, unknown>) => {
const showError = (message: string) => {
if (shownErrorsRef.current.has(message)) return;
shownErrorsRef.current.add(message);
notifications.show({ color: 'red', title: 'Link error', message });
};
if (onClick.type === 'dashboard') {
const result = buildDashboardLinkUrl({
onClick,
row,
dateRange,
dashboards: lookup,
});
if (!result.ok) {
showError(result.error);
return null;
}
return result.url;
}
// search mode
const pieces = renderSearchLinkPieces({
onClick,
row,
sourcesById,
sourcesByName,
});
if (!pieces.ok) {
showError(pieces.error);
return null;
}
return buildSearchLinkUrlFromPieces({ pieces: pieces.value, dateRange });
};
}, [onClick, lookup, sourcesById, sourcesByName, dateRange]);
}

View file

@ -17,6 +17,7 @@
"@hyperdx/lucene": "^3.1.1",
"date-fns": "^2.28.0",
"date-fns-tz": "^2.0.0",
"handlebars": "^4.7.9",
"lodash": "^4.17.23",
"node-sql-parser": "^5.3.5",
"object-hash": "^3.0.0",

View file

@ -0,0 +1,125 @@
import {
clearLinkTemplateCache,
LinkTemplateError,
MissingTemplateVariableError,
renderLinkTemplate,
} from '../linkTemplate';
describe('renderLinkTemplate', () => {
beforeEach(() => clearLinkTemplateCache());
it('substitutes row column values', () => {
expect(
renderLinkTemplate('svc={{ServiceName}}', { ServiceName: 'api' }),
).toBe('svc=api');
});
it('throws MissingTemplateVariableError on missing context keys (strict mode)', () => {
try {
renderLinkTemplate('x={{ServiceName}}', {});
throw new Error('expected to throw');
} catch (err) {
expect(err).toBeInstanceOf(MissingTemplateVariableError);
if (err instanceof MissingTemplateVariableError) {
expect(err.variable).toBe('ServiceName');
}
}
});
it('supports the encodeURIComponent helper', () => {
expect(
renderLinkTemplate('x={{encodeURIComponent v}}', { v: 'a b&c' }),
).toBe('x=a%20b%26c');
});
it('supports the json helper', () => {
expect(renderLinkTemplate('x={{json v}}', { v: { a: 1 } })).toBe(
'x={"a":1}',
);
});
it('supports the default helper', () => {
expect(renderLinkTemplate('x={{default missing "fallback"}}', {})).toBe(
'x=fallback',
);
expect(
renderLinkTemplate('x={{default v "fallback"}}', { v: 'present' }),
).toBe('x=present');
});
it('supports the eq helper', () => {
expect(
renderLinkTemplate('{{#eq v "a"}}yes{{else}}no{{/eq}}', { v: 'a' }),
).toBe('yes');
expect(
renderLinkTemplate('{{#eq v "a"}}yes{{else}}no{{/eq}}', { v: 'b' }),
).toBe('no');
});
it('throws LinkTemplateError on malformed template', () => {
expect(() => renderLinkTemplate('{{#if', { v: 1 })).toThrow(
LinkTemplateError,
);
});
it('caches compiled templates', () => {
// Second render of the same string exercises the cache path — just verify
// the behavior is stable.
expect(renderLinkTemplate('{{v}}', { v: '1' })).toBe('1');
expect(renderLinkTemplate('{{v}}', { v: '2' })).toBe('2');
});
it('does not HTML-escape output', () => {
expect(renderLinkTemplate('{{v}}', { v: '<&>' })).toBe('<&>');
});
describe('int helper', () => {
it('rounds numbers to the nearest integer', () => {
expect(renderLinkTemplate('{{int v}}', { v: 3.4 })).toBe('3');
expect(renderLinkTemplate('{{int v}}', { v: 3.6 })).toBe('4');
expect(renderLinkTemplate('{{int v}}', { v: -2.5 })).toBe('-2');
expect(renderLinkTemplate('{{int v}}', { v: 42 })).toBe('42');
});
it('parses numeric strings', () => {
expect(renderLinkTemplate('{{int v}}', { v: '3.4' })).toBe('3');
expect(renderLinkTemplate('{{int v}}', { v: ' 99 ' })).toBe('99');
});
it('returns empty string for non-numeric or nullish values', () => {
expect(renderLinkTemplate('{{int v}}', { v: null })).toBe('');
expect(renderLinkTemplate('{{int v}}', { v: 'abc' })).toBe('');
expect(renderLinkTemplate('{{int v}}', { v: '' })).toBe('');
expect(renderLinkTemplate('{{int v}}', { v: Number.NaN })).toBe('');
expect(renderLinkTemplate('{{int v}}', { v: Infinity })).toBe('');
});
});
describe('built-in helpers are disabled', () => {
it('does not expose Handlebars #if', () => {
// Without #if registered, the block falls back to blockHelperMissing —
// which we also removed, so strict mode surfaces it as unknown.
expect(() =>
renderLinkTemplate('{{#if v}}yes{{/if}}', { v: true }),
).toThrow();
});
it('does not expose Handlebars #each', () => {
expect(() =>
renderLinkTemplate('{{#each xs}}{{this}}{{/each}}', { xs: [1, 2, 3] }),
).toThrow();
});
it('does not expose Handlebars #with', () => {
expect(() =>
renderLinkTemplate('{{#with v}}{{a}}{{/with}}', { v: { a: 1 } }),
).toThrow();
});
it('does not expose Handlebars lookup', () => {
expect(() =>
renderLinkTemplate('{{lookup v "a"}}', { v: { a: 1 } }),
).toThrow();
});
});
});

View file

@ -0,0 +1,421 @@
import type { TableOnClickDashboard, TableOnClickSearch } from '../../types';
import {
buildDashboardLinkUrl,
buildSearchLinkUrlFromPieces,
renderSearchLinkPieces,
} from '../linkUrlBuilder';
const dateRange: [Date, Date] = [
new Date('2026-01-01T00:00:00Z'),
new Date('2026-01-01T01:00:00Z'),
];
function lookup(entries: [string, string][] = []) {
const nameToIds = new Map<string, string[]>();
for (const [name, id] of entries) {
const key = name.toLowerCase();
const list = nameToIds.get(key) ?? [];
list.push(id);
nameToIds.set(key, list);
}
return { nameToIds };
}
describe('buildDashboardLinkUrl', () => {
it('uses a concrete id target', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'dash_1' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: {},
dateRange,
dashboards: lookup(),
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.url).toContain('/dashboards/dash_1');
expect(result.url).toContain('from=1767225600000');
expect(result.url).toContain('to=1767229200000');
}
});
it('resolves a name template against the lookup', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: {
mode: 'name-template',
nameTemplate: '{{ServiceName}} Errors',
},
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: { ServiceName: 'api' },
dateRange,
dashboards: lookup([['api Errors', 'dash_abc']]),
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.url).toContain('/dashboards/dash_abc');
});
it('resolves case-insensitively', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'name-template', nameTemplate: 'API ERRORS' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: {},
dateRange,
dashboards: lookup([['api errors', 'dash_abc']]),
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.url).toContain('/dashboards/dash_abc');
});
it('errors when no dashboard matches the rendered name', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: {
mode: 'name-template',
nameTemplate: '{{ServiceName}} Errors',
},
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: { ServiceName: 'api' },
dateRange,
dashboards: lookup(),
});
expect(result).toEqual({
ok: false,
error: "Dashboard link: no dashboard named 'api Errors' was found",
});
});
it('errors when the rendered name matches more than one dashboard', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'name-template', nameTemplate: 'Errors' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: {},
dateRange,
dashboards: lookup([
['Errors', 'dash_1'],
['errors', 'dash_2'],
]),
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/matches 2 dashboards/);
expect(result.error).toMatch(/names must be unique/);
}
});
it('errors when the name template renders empty', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
// 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 ""}}' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: { v: null },
dateRange,
dashboards: lookup(),
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/rendered empty/);
});
it('errors when a template references a column the row does not have', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: {
mode: 'name-template',
nameTemplate: '{{ServiceName}} Errors',
},
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: {},
dateRange,
dashboards: lookup(),
});
expect(result).toEqual({
ok: false,
error: "Dashboard link: row has no column 'ServiceName'",
});
});
it('renders whereTemplate into the url', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
whereTemplate: "ServiceName = '{{ServiceName}}'",
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: { ServiceName: 'api' },
dateRange,
dashboards: lookup(),
});
expect(result.ok).toBe(true);
if (result.ok) {
const url = new URL(`https://x${result.url}`);
expect(url.searchParams.get('where')).toBe("ServiceName = 'api'");
expect(url.searchParams.get('whereLanguage')).toBe('sql');
}
});
it('renders filterValueTemplates into SQL IN conditions using the raw expression', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
filterValueTemplates: [
{ filter: 'ServiceName', template: '{{ServiceName}}' },
{ filter: 'Env', template: 'prod' },
],
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: { ServiceName: 'api' },
dateRange,
dashboards: lookup(),
});
expect(result.ok).toBe(true);
if (result.ok) {
const url = new URL(`https://x${result.url}`);
const f = JSON.parse(url.searchParams.get('filters') ?? '[]');
expect(f).toEqual([
{ type: 'sql', condition: "ServiceName IN ('api')" },
{ type: 'sql', condition: "Env IN ('prod')" },
]);
}
});
it('skips filterValueTemplates rows with empty filter or template', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
filterValueTemplates: [
{ filter: '', template: '{{ServiceName}}' },
{ filter: 'Env', template: '' },
{ filter: 'ServiceName', template: '{{ServiceName}}' },
],
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: { ServiceName: 'api' },
dateRange,
dashboards: lookup(),
});
expect(result.ok).toBe(true);
if (result.ok) {
const url = new URL(`https://x${result.url}`);
const f = JSON.parse(url.searchParams.get('filters') ?? '[]');
expect(f).toEqual([{ type: 'sql', condition: "ServiceName IN ('api')" }]);
}
});
it('merges repeated filter expressions into a single IN clause', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
filterValueTemplates: [
{ filter: 'ServiceName', template: 'api' },
{ filter: 'Env', template: 'prod' },
{ filter: 'ServiceName', template: 'web' },
],
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
onClick,
row: {},
dateRange,
dashboards: lookup(),
});
expect(result.ok).toBe(true);
if (result.ok) {
const url = new URL(`https://x${result.url}`);
const f = JSON.parse(url.searchParams.get('filters') ?? '[]');
// Expressions appear in order of first occurrence.
expect(f).toEqual([
{ type: 'sql', condition: "ServiceName IN ('api', 'web')" },
{ type: 'sql', condition: "Env IN ('prod')" },
]);
}
});
});
describe('renderSearchLinkPieces', () => {
const sourcesById = new Map<string, { id: string; name: string }>([
['src_1', { id: 'src_1', name: 'Logs' }],
]);
const sourcesByName = new Map<string, { id: string; name: string }>([
['logs', { id: 'src_1', name: 'Logs' }],
]);
it('resolves source by id', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
whereLanguage: 'sql',
};
const result = renderSearchLinkPieces({
onClick,
row: {},
sourcesById,
sourcesByName,
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.sourceId).toBe('src_1');
});
it('resolves source by templated name', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'template', sourceTemplate: '{{Src}}' },
whereLanguage: 'sql',
};
const result = renderSearchLinkPieces({
onClick,
row: { Src: 'Logs' },
sourcesById,
sourcesByName,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.sourceId).toBe('src_1');
expect(result.value.sourceResolvedFrom).toBe('template-name');
}
});
it('errors when source template does not match any source', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'template', sourceTemplate: '{{Src}}' },
whereLanguage: 'sql',
};
const result = renderSearchLinkPieces({
onClick,
row: { Src: 'NoSuchSource' },
sourcesById,
sourcesByName,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/could not resolve source/);
});
it('renders filterValueTemplates into SQL IN conditions', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
whereLanguage: 'sql',
filterValueTemplates: [
{ filter: 'ServiceName', template: '{{ServiceName}}' },
{ filter: 'Env', template: 'prod' },
],
};
const result = renderSearchLinkPieces({
onClick,
row: { ServiceName: 'api' },
sourcesById,
sourcesByName,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.filters).toEqual([
{ type: 'sql', condition: "ServiceName IN ('api')" },
{ type: 'sql', condition: "Env IN ('prod')" },
]);
}
});
it('skips filterValueTemplates rows with empty filter or template', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
whereLanguage: 'sql',
filterValueTemplates: [
{ filter: '', template: '{{ServiceName}}' },
{ filter: 'Env', template: '' },
{ filter: 'ServiceName', template: '{{ServiceName}}' },
],
};
const result = renderSearchLinkPieces({
onClick,
row: { ServiceName: 'api' },
sourcesById,
sourcesByName,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.filters).toEqual([
{ type: 'sql', condition: "ServiceName IN ('api')" },
]);
}
});
it('merges repeated filter expressions into a single IN clause', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
whereLanguage: 'sql',
filterValueTemplates: [
{ filter: 'ServiceName', template: 'api' },
{ filter: 'Env', template: 'prod' },
{ filter: 'ServiceName', template: 'web' },
],
};
const result = renderSearchLinkPieces({
onClick,
row: {},
sourcesById,
sourcesByName,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.filters).toEqual([
{ type: 'sql', condition: "ServiceName IN ('api', 'web')" },
{ type: 'sql', condition: "Env IN ('prod')" },
]);
}
});
});
describe('buildSearchLinkUrlFromPieces', () => {
it('appends from/to and base params', () => {
const url = buildSearchLinkUrlFromPieces({
pieces: {
sourceId: 'src_1',
sourceResolvedFrom: 'id',
where: 'x = 1',
whereLanguage: 'sql',
filters: [],
},
dateRange,
});
const parsed = new URL(`https://x${url}`);
expect(parsed.pathname).toBe('/search');
expect(parsed.searchParams.get('source')).toBe('src_1');
expect(parsed.searchParams.get('where')).toBe('x = 1');
expect(parsed.searchParams.get('from')).toBe('1767225600000');
expect(parsed.searchParams.get('to')).toBe('1767229200000');
});
});

View file

@ -0,0 +1,105 @@
import Handlebars from 'handlebars';
const compileCache = new Map<string, HandlebarsTemplateDelegate>();
const hb = Handlebars.create();
// Strip every built-in helper (`if`, `each`, `with`, `unless`, `lookup`,
// `log`, `helperMissing`, `blockHelperMissing`) so templates only have access
// to the vetted custom helpers registered below. Strict mode already throws
// on unknown references, so removing `helperMissing` / `blockHelperMissing`
// doesn't regress behavior.
for (const name of Object.keys(hb.helpers)) {
hb.unregisterHelper(name);
}
hb.registerHelper('encodeURIComponent', (value: unknown) => {
if (value == null) return '';
return encodeURIComponent(String(value));
});
hb.registerHelper('json', (value: unknown) => JSON.stringify(value ?? null));
hb.registerHelper('default', (value: unknown, fallback: unknown) => {
if (value == null || value === '') return fallback ?? '';
return value;
});
hb.registerHelper(
'eq',
function (
this: unknown,
a: unknown,
b: unknown,
options: Handlebars.HelperOptions,
) {
if (a === b) return options.fn(this);
return options.inverse(this);
},
);
/**
* Rounds a number or numeric string to the nearest integer. Returns an
* empty string when the input is null, undefined, or not parseable as a
* finite number consistent with the `default` helper's fallback shape.
*/
hb.registerHelper('int', (value: unknown): string => {
if (value == null || value === '') return '';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (!Number.isFinite(num)) return '';
return String(Math.round(num));
});
export class LinkTemplateError extends Error {
constructor(message: string) {
super(message);
this.name = 'LinkTemplateError';
}
}
/**
* Thrown when a template references a context variable that isn't in the
* row data. Surfaced distinctly so callers can show a friendlier warning
* than a generic "template error".
*/
export class MissingTemplateVariableError extends LinkTemplateError {
constructor(public variable: string) {
super(`Template references unknown variable: ${variable}`);
this.name = 'MissingTemplateVariableError';
}
}
// Handlebars strict-mode message: `"varname" not defined in { ... } - <loc>`
const STRICT_MISSING_RE = /^"([^"]+)" not defined/;
export function renderLinkTemplate(
template: string,
ctx: Record<string, unknown>,
): string {
let compiled = compileCache.get(template);
if (!compiled) {
try {
// Strict mode throws when a template references a context key that
// isn't set. We lean on that rather than an upfront AST walk so the
// check respects runtime branching (e.g. `{{#if x}}{{y}}{{/if}}`).
compiled = hb.compile(template, { noEscape: true, strict: true });
} catch (err) {
throw new LinkTemplateError(
err instanceof Error ? err.message : String(err),
);
}
compileCache.set(template, compiled);
}
try {
return compiled(ctx);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const match = STRICT_MISSING_RE.exec(msg);
if (match) throw new MissingTemplateVariableError(match[1]);
throw new LinkTemplateError(msg);
}
}
export function clearLinkTemplateCache(): void {
compileCache.clear();
}

View file

@ -0,0 +1,300 @@
import SqlString from 'sqlstring';
import type {
Filter,
TableOnClickDashboard,
TableOnClickFilterTemplate,
} from '../types';
import {
LinkTemplateError,
MissingTemplateVariableError,
renderLinkTemplate,
} from './linkTemplate';
export type LinkBuildResult =
| { ok: true; url: string }
| { ok: false; error: string };
export type DashboardLookup = {
/**
* Map from case-insensitive dashboard name to all dashboard ids that share
* that name. Names are not unique per team, so the list may contain more
* than one entry the caller surfaces an error for ambiguous resolutions.
*/
nameToIds: Map<string, string[]>;
};
type ErrorPrefix = 'Dashboard link' | 'Search link';
function renderOrError(
template: string,
ctx: Record<string, unknown>,
errorPrefix: ErrorPrefix,
): { ok: true; value: string } | { ok: false; error: string } {
try {
return { ok: true as const, value: renderLinkTemplate(template, ctx) };
} catch (err) {
if (err instanceof MissingTemplateVariableError) {
return {
ok: false as const,
error: `${errorPrefix}: row has no column '${err.variable}'`,
};
}
const msg = err instanceof LinkTemplateError ? err.message : String(err);
return {
ok: false as const,
error: `${errorPrefix}: template error: ${msg}`,
};
}
}
function escapeSqlValue(value: string): string {
return SqlString.escape(value);
}
/**
* Render the filter entries into `{expression} IN (v1, v2, ...)` SQL
* conditions. Entries that share the same `filter` expression are merged
* into a single IN clause so the destination sees all requested values.
* Expressions appear in the URL in order of first occurrence; values within
* a group retain their input order.
*/
function renderFilterTemplates(
entries: TableOnClickFilterTemplate[] | undefined,
row: Record<string, unknown>,
errorPrefix: ErrorPrefix,
): { ok: true; filters: Filter[] } | { ok: false; error: string } {
if (!entries || entries.length === 0) return { ok: true, filters: [] };
// Map preserves insertion order, keyed by expression.
const grouped = new Map<string, string[]>();
for (const entry of entries) {
if (!entry.template || !entry.filter) continue;
const rendered = renderOrError(entry.template, row, errorPrefix);
if (!rendered.ok) return rendered;
const existing = grouped.get(entry.filter);
if (existing) existing.push(rendered.value);
else grouped.set(entry.filter, [rendered.value]);
}
const filters: Filter[] = [];
for (const [expression, values] of grouped) {
const escaped = values.map(escapeSqlValue).join(', ');
filters.push({ type: 'sql', condition: `${expression} IN (${escaped})` });
}
return { ok: true, filters };
}
/**
* 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
* navigation.
*/
export function buildDashboardLinkUrl({
onClick,
row,
dateRange,
dashboards,
}: {
onClick: TableOnClickDashboard;
row: Record<string, unknown>;
dateRange: [Date, Date];
dashboards: DashboardLookup;
}): LinkBuildResult {
let dashboardId: string;
if (onClick.target.mode === 'id') {
if (!onClick.target.dashboardId) {
return {
ok: false,
error: 'Dashboard link: no target dashboard selected',
};
}
dashboardId = onClick.target.dashboardId;
} else {
const nameResult = renderOrError(
onClick.target.nameTemplate,
row,
'Dashboard link',
);
if (!nameResult.ok) return nameResult;
const name = nameResult.value.trim();
if (name === '') {
return {
ok: false,
error: 'Dashboard link: name template rendered empty',
};
}
const mappedIds = dashboards.nameToIds.get(name.toLowerCase()) ?? [];
if (mappedIds.length === 0) {
return {
ok: false,
error: `Dashboard link: no dashboard named '${name}' was found`,
};
}
if (mappedIds.length > 1) {
return {
ok: false,
error: `Dashboard link: dashboard name '${name}' matches ${mappedIds.length} dashboards — names must be unique to be used as a link target`,
};
}
dashboardId = mappedIds[0];
}
const params = new URLSearchParams();
params.set('from', String(dateRange[0].getTime()));
params.set('to', String(dateRange[1].getTime()));
if (onClick.whereTemplate) {
const whereResult = renderOrError(
onClick.whereTemplate,
row,
'Dashboard link',
);
if (!whereResult.ok) return whereResult;
params.set('where', whereResult.value);
if (onClick.whereLanguage) {
params.set('whereLanguage', onClick.whereLanguage);
}
}
const rendered = renderFilterTemplates(
onClick.filterValueTemplates,
row,
'Dashboard link',
);
if (!rendered.ok) return rendered;
if (rendered.filters.length > 0) {
params.set('filters', JSON.stringify(rendered.filters));
}
return { ok: true, url: `/dashboards/${dashboardId}?${params.toString()}` };
}
/**
* Build a URL to navigate from a table row click to the search page.
*
* Returns the rendered pieces; callers in app/ should assemble the final URL
* with {@link buildSearchLinkRequest} and their existing search URL builder
* (e.g. ChartUtils.buildEventsSearchUrl) since that needs frontend-only
* pieces (metric source resolution, etc.).
*/
export type RenderedSearchLink = {
sourceId?: string;
sourceResolvedFrom: 'id' | 'template-id' | 'template-name';
where: string;
whereLanguage: string | undefined;
filters: Filter[];
};
export function renderSearchLinkPieces({
onClick,
row,
sourcesById,
sourcesByName,
}: {
onClick: import('../types').TableOnClickSearch;
row: Record<string, unknown>;
sourcesById: Map<string, { id: string; name: string }>;
sourcesByName: Map<string, { id: string; name: string }>;
}): { 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 (!sourceId) {
return { ok: false, error: 'Search link: no target source selected' };
}
if (!sourcesById.has(sourceId)) {
return {
ok: false,
error: `Search link: source id '${sourceId}' not found`,
};
}
sourceResolvedFrom = 'id';
} else {
const rendered = renderOrError(
onClick.source.sourceTemplate,
row,
'Search link',
);
if (!rendered.ok) return rendered;
const value = rendered.value.trim();
if (value === '') {
return {
ok: false,
error: 'Search link: source template rendered empty',
};
}
if (sourcesById.has(value)) {
sourceId = value;
sourceResolvedFrom = 'template-id';
} else {
const byName = sourcesByName.get(value.toLowerCase());
if (!byName) {
return {
ok: false,
error: `Search link: could not resolve source '${value}'`,
};
}
sourceId = byName.id;
sourceResolvedFrom = 'template-name';
}
}
let where = '';
if (onClick.whereTemplate) {
const whereResult = renderOrError(
onClick.whereTemplate,
row,
'Search link',
);
if (!whereResult.ok) return whereResult;
where = whereResult.value;
}
const rendered = renderFilterTemplates(
onClick.filterValueTemplates,
row,
'Search link',
);
if (!rendered.ok) return rendered;
return {
ok: true,
value: {
sourceId,
sourceResolvedFrom,
where,
whereLanguage: onClick.whereLanguage,
filters: rendered.filters,
},
};
}
/**
* Convenience: assemble a full /search URL from the rendered pieces. Used
* when the caller does not need the fancier behavior of ChartUtils
* buildEventsSearchUrl (no metric-source remapping, no groupFilters).
*/
export function buildSearchLinkUrlFromPieces({
pieces,
dateRange,
}: {
pieces: RenderedSearchLink;
dateRange: [Date, Date];
}): string {
const params = new URLSearchParams({
source: pieces.sourceId ?? '',
where: pieces.where,
whereLanguage: pieces.whereLanguage ?? 'lucene',
filters: JSON.stringify(pieces.filters),
isLive: 'false',
from: String(dateRange[0].getTime()),
to: String(dateRange[1].getTime()),
});
return `/search?${params.toString()}`;
}

View file

@ -670,6 +670,76 @@ export type NumberFormat = z.infer<typeof NumberFormatSchema>;
// When making changes here, consider if they need to be made to the external API
// schema as well (packages/api/src/utils/zod.ts).
/**
* Each entry targets a DashboardFilter on the destination dashboard. `filter`
* matches the target filter's `id` first, then falls back to a
* case-insensitive `name` match. Array (rather than a record keyed by id) so
* name-template links where the target is unknown at edit time can still
* supply arbitrary filter-name keys.
*/
export const TableOnClickFilterTemplateSchema = z.object({
// Allow empty strings so the drawer can render blank rows while the user is
// editing. The URL builder skips entries with no filter/template at click
// time, so empty rows are harmless on persist.
filter: z.string(),
template: z.string(),
});
export type TableOnClickFilterTemplate = z.infer<
typeof TableOnClickFilterTemplateSchema
>;
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(),
}),
]),
whereTemplate: z.string().optional(),
whereLanguage: SearchConditionLanguageSchema,
filterValueTemplates: z.array(TableOnClickFilterTemplateSchema).optional(),
});
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(),
}),
]),
whereTemplate: z.string().optional(),
whereLanguage: SearchConditionLanguageSchema,
/**
* Per-filter templates rendered into SQL `IN` conditions. `filter` is used
* as the raw expression (column / SQL snippet).
*/
filterValueTemplates: z.array(TableOnClickFilterTemplateSchema).optional(),
});
export type TableOnClickSearch = z.infer<typeof TableOnClickSearchSchema>;
export const TableOnClickSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('none') }),
TableOnClickDashboardSchema,
TableOnClickSearchSchema,
]);
export type TableOnClick = z.infer<typeof TableOnClickSchema>;
/**
* Schema describing display settings which are shared between Raw SQL
* chart configs and Structured ChartBuilder chart configs
@ -681,6 +751,7 @@ const SharedChartDisplaySettingsSchema = z.object({
compareToPreviousPeriod: z.boolean().optional(),
fillNulls: z.union([z.number(), z.literal(false)]).optional(),
alignDateRangeToGranularity: z.boolean().optional(),
onClick: TableOnClickSchema.optional(),
});
export const _ChartConfigSchema = SharedChartDisplaySettingsSchema.extend({

View file

@ -4533,6 +4533,7 @@ __metadata:
date-fns: "npm:^2.28.0"
date-fns-tz: "npm:^2.0.0"
dotenv: "npm:^17.2.3"
handlebars: "npm:^4.7.9"
jest: "npm:^30.2.0"
lodash: "npm:^4.17.23"
node-sql-parser: "npm:^5.3.5"