mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
wip: Dashboard Linking PoC
This commit is contained in:
parent
f086842f3c
commit
34defe5a06
23 changed files with 2479 additions and 203 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}}" }] } } }',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export function convertFormStateToSavedChartConfig(
|
|||
'fillNulls',
|
||||
'alignDateRangeToGranularity',
|
||||
'alert',
|
||||
'onClick',
|
||||
]),
|
||||
sqlTemplate: form.sqlTemplate ?? '',
|
||||
connection: form.connection ?? '',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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's
|
||||
group-by column values and the current dashboard'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ describe('usePresetDashboardFilters', () => {
|
|||
setFilterValue: mockSetFilterValue,
|
||||
filterQueries: mockFilterQueries,
|
||||
setFilterQueries: jest.fn(),
|
||||
ignoredFilterExpressions: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
101
packages/app/src/hooks/useTableOnClickResolver.ts
Normal file
101
packages/app/src/hooks/useTableOnClickResolver.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
125
packages/common-utils/src/core/__tests__/linkTemplate.test.ts
Normal file
125
packages/common-utils/src/core/__tests__/linkTemplate.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
421
packages/common-utils/src/core/__tests__/linkUrlBuilder.test.ts
Normal file
421
packages/common-utils/src/core/__tests__/linkUrlBuilder.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
105
packages/common-utils/src/core/linkTemplate.ts
Normal file
105
packages/common-utils/src/core/linkTemplate.ts
Normal 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();
|
||||
}
|
||||
300
packages/common-utils/src/core/linkUrlBuilder.ts
Normal file
300
packages/common-utils/src/core/linkUrlBuilder.ts
Normal 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()}`;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue