hyperdx/packages/app/src/components/SourceForm.tsx
Aaron Knudtson b99236d774
Fix autocomplete for (#713)
Added an override to provide fields as an input to SearchInputV2 (for lucene) and SqlInlineEditorControlled (for sql), instead of fetching in those respective components. This fixes autocomplete for fields on DBDashboardPage, which was not working previously. I have a fix for field values coming in a future PR.

![image](https://github.com/user-attachments/assets/93de4345-7e8d-4157-aaa1-d223ec49fc72)

![image](https://github.com/user-attachments/assets/c73eb00d-da0e-4fc3-adb4-3f9d2c600a70)

Ref: HDX-1471
2025-03-27 19:17:29 +00:00

1093 lines
30 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import {
Control,
Controller,
useForm,
UseFormSetValue,
UseFormWatch,
} from 'react-hook-form';
import {
MetricsDataType,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Box,
Button,
Divider,
Flex,
Group,
Menu,
Radio,
SegmentedControl,
Select,
Slider,
Stack,
Switch,
Text,
Tooltip,
} from '@mantine/core';
import { useDebouncedCallback } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { IS_METRICS_ENABLED, IS_SESSIONS_ENABLED } from '@/config';
import { useConnections } from '@/connection';
import {
inferTableSourceConfig,
isValidMetricTable,
useCreateSource,
useDeleteSource,
useSource,
useUpdateSource,
} from '@/source';
import ConfirmDeleteMenu from './ConfirmDeleteMenu';
import { ConnectionSelectControlled } from './ConnectionSelect';
import { DatabaseSelectControlled } from './DatabaseSelect';
import { DBTableSelectControlled } from './DBTableSelect';
import { InputControlled } from './InputControlled';
import { SQLInlineEditorControlled } from './SQLInlineEditor';
const DEFAULT_DATABASE = 'default';
// TODO: maybe otel clickhouse export migrate the schema?
const OTEL_CLICKHOUSE_EXPRESSIONS = {
timestampValueExpression: 'TimeUnix',
resourceAttributesExpression: 'ResourceAttributes',
};
function FormRow({
label,
children,
helpText,
}: {
label: React.ReactNode;
children: React.ReactNode;
helpText?: string;
}) {
return (
// <Group grow preventGrowOverflow={false}>
<Flex align="center">
<Stack
justify="center"
style={{
maxWidth: 220,
minWidth: 220,
height: '36px',
}}
>
{typeof label === 'string' ? (
<Text c="gray.6" size="sm">
{label}
</Text>
) : (
label
)}
</Stack>
<Text
c="gray.4"
me="sm"
style={{
...(!helpText ? { opacity: 0, pointerEvents: 'none' } : {}),
}}
>
<Tooltip label={helpText} color="dark" c="white">
<i className="bi bi-question-circle cursor-pointer" />
</Tooltip>
</Text>
<Box
w="100%"
style={{
minWidth: 0,
}}
>
{children}
</Box>
</Flex>
);
}
// traceModel= ...
// logModel=....
// traceModel.logModel = 'custom'
// will pop open the custom trace model form as well
// need to make sure we don't recursively render them :joy:
// OR traceModel.logModel = 'log_id_blah'
// custom always points towards the url param
export function LogTableModelForm({
control,
watch,
setValue,
}: {
control: Control<TSource>;
watch: UseFormWatch<TSource>;
setValue: UseFormSetValue<TSource>;
}) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
const [showOptionalFields, setShowOptionalFields] = useState(false);
return (
<>
<Stack gap="sm">
<FormRow label={'Server Connection'}>
<ConnectionSelectControlled control={control} name={`connection`} />
</FormRow>
<FormRow label={'Database'}>
<DatabaseSelectControlled
control={control}
name={`from.databaseName`}
connectionId={connectionId}
/>
</FormRow>
<FormRow label={'Table'}>
<DBTableSelectControlled
database={databaseName}
control={control}
name={`from.tableName`}
connectionId={connectionId}
rules={{ required: 'Table is required' }}
/>
</FormRow>
<FormRow
label={'Timestamp Column'}
helpText="DateTime column or expression that is part of your table's primary key."
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="timestampValueExpression"
disableKeywordAutocomplete
/>
</FormRow>
<FormRow
label={'Default Select'}
helpText="Default columns selected in search results (this can be customized per search later)"
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="defaultTableSelectExpression"
placeholder="Timestamp, Body"
/>
</FormRow>
<Box>
{!showOptionalFields && (
<Anchor
underline="always"
onClick={() => setShowOptionalFields(true)}
size="xs"
c="gray.4"
>
<Text me="sm" span>
<i className="bi bi-gear" />
</Text>
Configure Optional Fields
</Anchor>
)}
{showOptionalFields && (
<Button
onClick={() => setShowOptionalFields(false)}
size="xs"
variant="subtle"
color="gray.4"
>
Hide Optional Fields
</Button>
)}
</Box>
</Stack>
<Stack
gap="sm"
style={{
display: showOptionalFields ? 'flex' : 'none',
}}
>
<Divider />
<FormRow label={'Service Name Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="serviceNameExpression"
placeholder="ServiceName"
/>
</FormRow>
<FormRow label={'Log Level Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="severityTextExpression"
placeholder="SeverityText"
/>
</FormRow>
<FormRow label={'Body Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="bodyExpression"
placeholder="Body"
/>
</FormRow>
<FormRow label={'Log Attributes Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="eventAttributesExpression"
placeholder="LogAttributes"
/>
</FormRow>
<FormRow label={'Resource Attributes Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="resourceAttributesExpression"
placeholder="ResourceAttributes"
/>
</FormRow>
<FormRow
label={'Displayed Timestamp Column'}
helpText="This DateTime column is used to display search results."
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="displayedTimestampValueExpression"
disableKeywordAutocomplete
/>
</FormRow>
<Divider />
<FormRow
label={'Correlated Metric Source'}
helpText="HyperDX Source for metrics associated with logs. Optional"
>
<SourceSelectControlled control={control} name="metricSourceId" />
</FormRow>
<FormRow
label={'Correlated Trace Source'}
helpText="HyperDX Source for traces associated with logs. Optional"
>
<SourceSelectControlled control={control} name="traceSourceId" />
</FormRow>
<FormRow label={'Trace Id Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="traceIdExpression"
placeholder="TraceId"
/>
</FormRow>
<FormRow label={'Span Id Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanIdExpression"
placeholder="SpanId"
/>
</FormRow>
<Divider />
{/* <FormRow
label={'Unique Row ID Expression'}
helpText="Unique identifier for a given row, will be primary key if not specified. Used for showing full row details in search results."
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="uniqueRowIdExpression"
placeholder="Timestamp, ServiceName, Body"
/>
</FormRow> */}
{/* <FormRow label={'Table Filter Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="tableFilterExpression"
placeholder="ServiceName = 'only_this_service'"
/>
</FormRow> */}
<FormRow
label={'Implicit Column Expression'}
helpText="Column used for full text search if no property is specified in a Lucene-based search. Typically the message body of a log."
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="implicitColumnExpression"
placeholder="Body"
/>
</FormRow>
</Stack>
</>
);
}
export function TraceTableModelForm({
control,
watch,
setValue,
}: {
control: Control<TSource>;
watch: UseFormWatch<TSource>;
setValue: UseFormSetValue<TSource>;
}) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
return (
<Stack gap="sm">
<FormRow label={'Server Connection'}>
<ConnectionSelectControlled control={control} name={`connection`} />
</FormRow>
<FormRow label={'Database'}>
<DatabaseSelectControlled
connectionId={connectionId}
control={control}
name={`from.databaseName`}
/>
</FormRow>
<FormRow label={'Table'}>
<DBTableSelectControlled
connectionId={connectionId}
database={databaseName}
control={control}
name={`from.tableName`}
rules={{ required: 'Table is required' }}
/>
</FormRow>
<FormRow label={'Timestamp Column'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="timestampValueExpression"
placeholder="Timestamp"
disableKeywordAutocomplete
/>
</FormRow>
<FormRow
label={'Default Select'}
helpText="Default columns selected in search results (this can be customized per search later)"
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="defaultTableSelectExpression"
placeholder="Timestamp, ServiceName, StatusCode, Duration, SpanName"
/>
</FormRow>
<Divider />
<FormRow label={'Duration Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="durationExpression"
placeholder="Duration Column"
/>
</FormRow>
<FormRow label={'Duration Precision'}>
<Box mx="xl">
<Controller
control={control}
name="durationPrecision"
render={({ field: { onChange, onBlur, value, ref } }) => (
<div style={{ width: '90%', marginBottom: 8 }}>
<Slider
color="green"
defaultValue={0}
min={0}
max={9}
marks={[
{ value: 0, label: 'Seconds' },
{ value: 3, label: 'Millisecond' },
{ value: 6, label: 'Microsecond' },
{ value: 9, label: 'Nanosecond' },
]}
value={value}
onChange={onChange}
/>
</div>
)}
/>
</Box>
</FormRow>
<FormRow label={'Trace Id Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="traceIdExpression"
placeholder="TraceId"
/>
</FormRow>
<FormRow label={'Span Id Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanIdExpression"
placeholder="SpanId"
/>
</FormRow>
<FormRow label={'Parent Span Id Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="parentSpanIdExpression"
placeholder="ParentSpanId"
/>
</FormRow>
<FormRow label={'Span Name Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanNameExpression"
placeholder="SpanName"
/>
</FormRow>
<FormRow label={'Span Kind Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanKindExpression"
placeholder="SpanKind"
/>
</FormRow>
<Divider />
<FormRow
label={'Correlated Log Source'}
helpText="HyperDX Source for logs associated with traces. Optional"
>
<SourceSelectControlled control={control} name="logSourceId" />
</FormRow>
<FormRow
label={'Correlated Session Source'}
helpText="HyperDX Source for sessions associated with traces. Optional"
>
<SourceSelectControlled control={control} name="sessionSourceId" />
</FormRow>
<FormRow label={'Status Code Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="statusCodeExpression"
placeholder="StatusCode"
/>
</FormRow>
<FormRow label={'Status Message Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="statusMessageExpression"
placeholder="StatusMessage"
/>
</FormRow>
<FormRow label={'Service Name Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="serviceNameExpression"
placeholder="ServiceName"
/>
</FormRow>
<FormRow label={'Resource Attributes Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="resourceAttributesExpression"
placeholder="ResourceAttributes"
/>
</FormRow>
<FormRow label={'Event Attributes Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="eventAttributesExpression"
placeholder="SpanAttributes"
/>
</FormRow>
<FormRow
label={'Implicit Column Expression'}
helpText="Column used for full text search if no property is specified in a Lucene-based search. Typically the message body of a log."
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="implicitColumnExpression"
placeholder="SpanName"
/>
</FormRow>
</Stack>
);
}
export function SessionTableModelForm({
control,
watch,
setValue,
}: {
control: Control<TSource>;
watch: UseFormWatch<TSource>;
setValue: UseFormSetValue<TSource>;
}) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
const [showOptionalFields, setShowOptionalFields] = useState(false);
return (
<>
<Stack gap="sm">
<FormRow label={'Server Connection'}>
<ConnectionSelectControlled control={control} name={`connection`} />
</FormRow>
<FormRow label={'Database'}>
<DatabaseSelectControlled
control={control}
name={`from.databaseName`}
connectionId={connectionId}
/>
</FormRow>
<FormRow label={'Table'}>
<DBTableSelectControlled
database={databaseName}
control={control}
name={`from.tableName`}
connectionId={connectionId}
rules={{ required: 'Table is required' }}
/>
</FormRow>
<FormRow
label={'Timestamp Column'}
helpText="DateTime column or expression that is part of your table's primary key."
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="timestampValueExpression"
disableKeywordAutocomplete
/>
</FormRow>
<FormRow label={'Log Attributes Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="eventAttributesExpression"
placeholder="LogAttributes"
/>
</FormRow>
<FormRow label={'Resource Attributes Expression'}>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="resourceAttributesExpression"
placeholder="ResourceAttributes"
/>
</FormRow>
<FormRow
label={'Correlated Trace Source'}
helpText="HyperDX Source for traces associated with sessions. Required"
>
<SourceSelectControlled control={control} name="traceSourceId" />
</FormRow>
<FormRow
label={'Implicit Column Expression'}
helpText="Column used for full text search if no property is specified in a Lucene-based search. Typically the message body of a log."
>
<SQLInlineEditorControlled
tableConnections={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="implicitColumnExpression"
placeholder="Body"
/>
</FormRow>
</Stack>
</>
);
}
export function MetricTableModelForm({
control,
watch,
setValue,
}: {
control: Control<TSource>;
watch: UseFormWatch<TSource>;
setValue: UseFormSetValue<TSource>;
}) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const connectionId = watch(`connection`);
useEffect(() => {
for (const [_key, _value] of Object.entries(OTEL_CLICKHOUSE_EXPRESSIONS)) {
setValue(_key as any, _value);
}
const { unsubscribe } = watch(async (value, { name, type }) => {
try {
if (name && type === 'change') {
const [prefix, suffix] = name.split('.');
if (prefix === 'metricTables') {
const tableName =
value?.metricTables?.[suffix as keyof typeof value.metricTables];
const metricType = suffix as MetricsDataType;
const isValid = await isValidMetricTable({
databaseName,
tableName,
connectionId,
metricType,
});
if (!isValid) {
notifications.show({
color: 'red',
message: `${tableName} is not a valid OTEL ${metricType} schema.`,
});
}
}
}
} catch (e) {
console.error(e);
notifications.show({
color: 'red',
message: e.message,
});
}
});
return () => unsubscribe();
}, [setValue, watch, databaseName, connectionId]);
return (
<>
<Stack gap="sm">
<FormRow label={'Server Connection'}>
<ConnectionSelectControlled control={control} name={`connection`} />
</FormRow>
<FormRow label={'Database'}>
<DatabaseSelectControlled
connectionId={connectionId}
control={control}
name={`from.databaseName`}
/>
</FormRow>
{Object.keys(MetricsDataType).map(metricType => (
<FormRow
key={metricType.toLowerCase()}
label={`${metricType} Table`}
helpText={`Table containing ${metricType.toLowerCase()} metrics data`}
>
<DBTableSelectControlled
connectionId={connectionId}
database={databaseName}
control={control}
name={`metricTables.${metricType.toLowerCase()}`}
/>
</FormRow>
))}
<FormRow
label={'Correlated Log Source'}
helpText="HyperDX Source for logs associated with metrics. Optional"
>
<SourceSelectControlled control={control} name="logSourceId" />
</FormRow>
</Stack>
</>
);
}
function TableModelForm({
control,
watch,
setValue,
kind,
}: {
control: Control<TSource>;
watch: UseFormWatch<TSource>;
setValue: UseFormSetValue<TSource>;
kind: SourceKind;
}) {
switch (kind) {
case SourceKind.Log:
return (
<LogTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
case SourceKind.Trace:
return (
<TraceTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
case SourceKind.Session:
return (
<SessionTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
case SourceKind.Metric:
return (
<MetricTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
}
}
export function TableSourceForm({
sourceId,
onSave,
onCreate,
isNew = false,
defaultName,
onCancel,
}: {
sourceId?: string;
onSave?: () => void;
onCreate?: (source: TSource) => void;
onCancel?: () => void;
isNew?: boolean;
defaultName?: string;
}) {
const { data: source } = useSource({ id: sourceId });
const { data: connections } = useConnections();
const { watch, control, setValue, handleSubmit, resetField, formState } =
useForm<TSource>({
defaultValues: {
kind: SourceKind.Log,
name: defaultName,
connection: connections?.[0]?.id,
from: {
databaseName: 'default',
tableName: '',
},
},
values: source,
resetOptions: {
keepDirtyValues: true,
keepErrors: true,
},
});
useEffect(() => {
const { unsubscribe } = watch(async (value, { name, type }) => {
try {
if (
value.connection != null &&
value.from?.databaseName != null &&
value.from.tableName != null &&
name === 'from.tableName' &&
type === 'change'
) {
const config = await inferTableSourceConfig({
databaseName: value.from.databaseName,
tableName: value.from.tableName,
connectionId: value.connection,
});
if (Object.keys(config).length > 0) {
notifications.show({
color: 'green',
message:
'Automatically inferred source configuration from table schema.',
});
}
Object.entries(config).forEach(([key, value]) => {
resetField(key as any, {
keepDirty: true,
defaultValue: value,
});
});
}
} catch (e) {
console.error(e);
}
});
return () => unsubscribe();
}, [watch, resetField]);
// Sets the default connection field to the first connection after the
// connections have been loaded
useEffect(() => {
resetField('connection', { defaultValue: connections?.[0]?.id });
}, [connections, resetField]);
const kind: SourceKind = watch('kind');
const createSource = useCreateSource();
const updateSource = useUpdateSource();
const deleteSource = useDeleteSource();
const _onCreate = useCallback(() => {
handleSubmit(data => {
createSource.mutate(
{ source: data },
{
onSuccess: data => {
onCreate?.(data);
notifications.show({
color: 'green',
message: 'Source created',
});
},
onError: () => {
notifications.show({
color: 'red',
message: 'Failed to create source',
});
},
},
);
})();
}, [handleSubmit, createSource, onCreate]);
const _onSave = useCallback(() => {
handleSubmit(data => {
updateSource.mutate(
{ source: data },
{
onSuccess: () => {
onSave?.();
notifications.show({
color: 'green',
message: 'Source updated',
});
},
onError: () => {
notifications.show({
color: 'red',
message: 'Failed to update source',
});
},
},
);
})();
}, [handleSubmit, updateSource, onSave]);
return (
<div
style={
{
// maxWidth: 700
}
}
>
<Stack gap="md" mb="md">
<Flex justify="space-between" align="center" mb="lg">
<Text c="gray.4">Source Settings</Text>
<Group>
{onCancel && (
<Button
variant="outline"
color="gray.4"
onClick={onCancel}
size="xs"
>
Cancel
</Button>
)}
{isNew ? (
<Button
variant="outline"
color="green"
onClick={_onCreate}
size="xs"
loading={createSource.isPending}
>
Save New Source
</Button>
) : (
<>
<ConfirmDeleteMenu
onDelete={() => deleteSource.mutate({ id: sourceId ?? '' })}
/>
<Button
variant="outline"
color="green"
onClick={_onSave}
size="xs"
loading={createSource.isPending}
>
Save Source
</Button>
</>
)}
</Group>
</Flex>
<FormRow label={'Name'}>
<InputControlled
control={control}
name="name"
rules={{ required: 'Name is required' }}
/>
</FormRow>
<FormRow label={'Source Data Type'}>
<Controller
control={control}
name="kind"
render={({ field: { onChange, value } }) => (
<Radio.Group
value={value}
onChange={v => onChange(v)}
withAsterisk
>
<Group>
<Radio value={SourceKind.Log} label="Log" />
<Radio value={SourceKind.Trace} label="Trace" />
{IS_METRICS_ENABLED && (
<Radio value={SourceKind.Metric} label="OTEL Metrics" />
)}
{IS_SESSIONS_ENABLED && (
<Radio value={SourceKind.Session} label="Session" />
)}
</Group>
</Radio.Group>
)}
/>
</FormRow>
</Stack>
<TableModelForm
control={control}
watch={watch}
setValue={setValue}
kind={kind}
/>
</div>
);
}