hyperdx/packages/app/src/components/Sources/SourceForm.tsx

1929 lines
56 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Control,
Controller,
useFieldArray,
useForm,
UseFormSetValue,
useWatch,
} from 'react-hook-form';
import { z } from 'zod';
import {
MetricsDataType,
SourceKind,
sourceSchemaWithout,
TSource,
TSourceUnion,
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
Center,
Divider,
Flex,
Grid,
Group,
Radio,
Select,
Slider,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import {
IconCirclePlus,
IconHelpCircle,
IconSettings,
IconTrash,
} from '@tabler/icons-react';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { IS_METRICS_ENABLED, IS_SESSIONS_ENABLED } from '@/config';
import { useConnections } from '@/connection';
import { useMetadataWithSettings } from '@/hooks/useMetadata';
import {
inferTableSourceConfig,
isValidMetricTable,
isValidSessionsTable,
useCreateSource,
useDeleteSource,
useSource,
useSources,
useUpdateSource,
} from '@/source';
import {
inferMaterializedViewConfig,
MV_AGGREGATE_FUNCTIONS,
MV_GRANULARITY_OPTIONS,
} from '@/utils/materializedViews';
import ConfirmDeleteMenu from '../ConfirmDeleteMenu';
import { ConnectionSelectControlled } from '../ConnectionSelect';
import { DatabaseSelectControlled } from '../DatabaseSelect';
import { DBTableSelectControlled } from '../DBTableSelect';
import { InputControlled } from '../InputControlled';
import SelectControlled from '../SelectControlled';
import { SQLInlineEditorControlled } from '../SQLInlineEditor';
const DEFAULT_DATABASE = 'default';
const MV_AGGREGATE_FUNCTION_OPTIONS = MV_AGGREGATE_FUNCTIONS.map(fn => ({
value: fn,
label: fn,
}));
// TODO: maybe otel clickhouse export migrate the schema?
const OTEL_CLICKHOUSE_EXPRESSIONS = {
timestampValueExpression: 'TimeUnix',
resourceAttributesExpression: 'ResourceAttributes',
};
const CORRELATION_FIELD_MAP: Record<
SourceKind,
Record<string, { targetKind: SourceKind; targetField: keyof TSource }[]>
> = {
[SourceKind.Log]: {
metricSourceId: [
{ targetKind: SourceKind.Metric, targetField: 'logSourceId' },
],
traceSourceId: [
{ targetKind: SourceKind.Trace, targetField: 'logSourceId' },
],
},
[SourceKind.Trace]: {
logSourceId: [{ targetKind: SourceKind.Log, targetField: 'traceSourceId' }],
sessionSourceId: [
{ targetKind: SourceKind.Session, targetField: 'traceSourceId' },
],
metricSourceId: [
{ targetKind: SourceKind.Metric, targetField: 'logSourceId' },
],
},
[SourceKind.Session]: {
traceSourceId: [
{ targetKind: SourceKind.Trace, targetField: 'sessionSourceId' },
],
},
[SourceKind.Metric]: {
logSourceId: [
{ targetKind: SourceKind.Log, targetField: 'metricSourceId' },
],
},
};
function FormRow({
label,
children,
helpText,
}: {
label: React.ReactNode;
children: React.ReactNode;
helpText?: string;
}) {
return (
// <Group grow preventGrowOverflow={false}>
<Flex align="flex-start">
<Flex align="center">
<Stack
justify="center"
style={{
maxWidth: 220,
minWidth: 220,
height: '36px',
}}
>
{typeof label === 'string' ? (
<Text tt="capitalize" size="sm">
{label}
</Text>
) : (
label
)}
</Stack>
<Center
me="sm"
ms="sm"
style={{
...(!helpText ? { opacity: 0, pointerEvents: 'none' } : {}),
}}
>
<Tooltip label={helpText} color="dark" c="white" multiline maw={600}>
<IconHelpCircle size={20} className="cursor-pointer" />
</Tooltip>
</Center>
</Flex>
<Box
w="100%"
style={{
minWidth: 0,
}}
>
{children}
</Box>
</Flex>
);
}
function HighlightedAttributeExpressionsFormRow({
control,
name,
label,
helpText,
}: Omit<TableModelProps, 'setValue'> & {
name:
| 'highlightedTraceAttributeExpressions'
| 'highlightedRowAttributeExpressions';
label: string;
helpText?: string;
}) {
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const tableName = useWatch({ control, name: 'from.tableName' });
const connectionId = useWatch({ control, name: 'connection' });
const {
fields: highlightedAttributes,
append: appendHighlightedAttribute,
remove: removeHighlightedAttribute,
} = useFieldArray({
control,
name,
});
return (
<FormRow label={label} helpText={helpText}>
<Grid columns={5}>
{highlightedAttributes.map((field, index) => (
<React.Fragment key={field.id}>
<Grid.Col span={3} pe={0}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name={`${name}.${index}.sqlExpression`}
disableKeywordAutocomplete
placeholder="ResourceAttributes['http.host']"
/>
</Grid.Col>
<Grid.Col span={2} ps="xs">
<Flex align="center" gap="sm">
<Text c="gray">AS</Text>
<SQLInlineEditorControlled
control={control}
name={`${name}.${index}.alias`}
placeholder="Optional Alias"
disableKeywordAutocomplete
/>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => removeHighlightedAttribute(index)}
>
<IconTrash size={16} />
</ActionIcon>
</Flex>
</Grid.Col>
<Grid.Col span={3} pe={0}>
<InputControlled
control={control}
name={`${name}.${index}.luceneExpression`}
placeholder="ResourceAttributes.http.host (Optional) "
/>
</Grid.Col>
<Grid.Col span={1} pe={0}>
<Text me="sm" mt={6}>
<Tooltip
label={
'An optional, Lucene version of the above expression. If provided, it is used when searching for this attribute value.'
}
color="dark"
c="white"
multiline
maw={600}
>
<IconHelpCircle size={14} className="cursor-pointer" />
</Tooltip>
</Text>
</Grid.Col>
</React.Fragment>
))}
</Grid>
<Button
variant="default"
size="sm"
color="gray"
className="align-self-start"
mt={highlightedAttributes.length ? 'sm' : 'md'}
onClick={() => {
appendHighlightedAttribute({
sqlExpression: '',
luceneExpression: '',
alias: '',
});
}}
>
<IconCirclePlus size={14} className="me-2" />
Add expression
</Button>
</FormRow>
);
}
/** Component for configuring one or more materialized views */
function MaterializedViewsFormSection({ control, setValue }: TableModelProps) {
const databaseName = useWatch({
control,
name: `from.databaseName`,
defaultValue: DEFAULT_DATABASE,
});
const {
fields: materializedViews,
append: appendMaterializedView,
remove: removeMaterializedView,
} = useFieldArray({
control,
name: 'materializedViews',
});
return (
<Stack gap="md">
<FormRow
label={
<Group>
Materialized Views
<Badge size="sm" radius="sm" color="gray">
Beta
</Badge>
</Group>
}
helpText="Configure materialized views for query optimization. These pre-aggregated views can significantly improve query performance on aggregation queries."
>
<Stack gap="md">
{materializedViews.map((field, index) => (
<MaterializedViewFormSection
key={field.id}
control={control}
mvIndex={index}
setValue={setValue}
onRemove={() => removeMaterializedView(index)}
/>
))}
<Button
variant="default"
onClick={() => {
appendMaterializedView({
databaseName: databaseName,
tableName: '',
dimensionColumns: '',
minGranularity: '',
timestampColumn: '',
aggregatedColumns: [],
});
}}
>
<Group>
<IconCirclePlus size={16} />
Add Materialized View
</Group>
</Button>
</Stack>
</FormRow>
</Stack>
);
}
/** Component for configuring a single materialized view */
function MaterializedViewFormSection({
control,
mvIndex,
onRemove,
setValue,
}: { mvIndex: number; onRemove: () => void } & TableModelProps) {
const connection = useWatch({ control, name: `connection` });
const sourceDatabaseName = useWatch({
control,
name: `from.databaseName`,
defaultValue: DEFAULT_DATABASE,
});
const mvDatabaseName = useWatch({
control,
name: `materializedViews.${mvIndex}.databaseName`,
defaultValue: sourceDatabaseName,
});
const mvTableName = useWatch({
control,
name: `materializedViews.${mvIndex}.tableName`,
defaultValue: '',
});
return (
<Stack gap="sm">
<Grid columns={2} flex={1}>
<Grid.Col span={1}>
<DatabaseSelectControlled
control={control}
name={`materializedViews.${mvIndex}.databaseName`}
connectionId={connection}
/>
</Grid.Col>
<Grid.Col span={1}>
<Group>
<Box flex={1}>
<DBTableSelectControlled
database={mvDatabaseName}
control={control}
name={`materializedViews.${mvIndex}.tableName`}
connectionId={connection}
/>
</Box>
<ActionIcon size="sm" onClick={onRemove}>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Grid.Col>
<Grid.Col span={2}>
<Text size="xs" fw={500} mb={4}>
Timestamp Column
</Text>
<SQLInlineEditorControlled
tableConnection={{
databaseName: mvDatabaseName,
tableName: mvTableName,
connectionId: connection,
}}
control={control}
placeholder="Timestamp"
name={`materializedViews.${mvIndex}.timestampColumn`}
disableKeywordAutocomplete
/>
</Grid.Col>
<Grid.Col span={1}>
<Text size="xs" fw={500} mb={4}>
Granularity
<Tooltip
label={'The granularity of the timestamp column'}
color="dark"
c="white"
multiline
maw={600}
>
<IconHelpCircle size={14} className="cursor-pointer ms-1" />
</Tooltip>
</Text>
<Controller
control={control}
name={`materializedViews.${mvIndex}.minGranularity`}
render={({ field }) => (
<Select
{...field}
data={MV_GRANULARITY_OPTIONS}
placeholder="Granularity"
size="sm"
/>
)}
/>
</Grid.Col>
<Grid.Col span={1}>
<Text size="xs" fw={500} mb={4}>
Minimum Date
<Tooltip
label="(Optional) The earliest date and time (in the local timezone) for which the materialized view contains data. If not provided, then HyperDX will assume that the materialized view contains data for all dates for which the source table contains data."
color="dark"
c="white"
multiline
maw={600}
>
<IconHelpCircle size={14} className="cursor-pointer ms-1" />
</Tooltip>
</Text>
<Controller
control={control}
name={`materializedViews.${mvIndex}.minDate`}
render={({ field }) => (
<DateInput
{...field}
value={field.value ? new Date(field.value) : undefined}
onChange={dateStr =>
field.onChange(dateStr ? dateStr.toISOString() : null)
}
clearable
highlightToday
placeholder="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
/>
)}
/>
</Grid.Col>
</Grid>
<Box>
<Text size="xs" fw={500} mb={4}>
Dimension Columns (comma-separated)
<Tooltip
label={
'Columns which are not pre-aggregated in the materialized view and can be used for filtering and grouping.'
}
color="dark"
c="white"
multiline
maw={600}
>
<IconHelpCircle size={14} className="cursor-pointer ms-1" />
</Tooltip>
</Text>
<SQLInlineEditorControlled
tableConnection={{
databaseName: mvDatabaseName,
tableName: mvTableName,
connectionId: connection,
}}
control={control}
name={`materializedViews.${mvIndex}.dimensionColumns`}
placeholder="ServiceName, StatusCode"
disableKeywordAutocomplete
/>
</Box>
<AggregatedColumnsFormSection
control={control}
mvIndex={mvIndex}
setValue={setValue}
/>
<Divider />
</Stack>
);
}
/** Component for configuring the Aggregated Columns list for a single materialized view */
function AggregatedColumnsFormSection({
control,
setValue,
mvIndex,
}: TableModelProps & { mvIndex: number }) {
const {
fields: aggregates,
append: appendAggregate,
remove: removeAggregate,
replace: replaceAggregates,
} = useFieldArray({
control,
name: `materializedViews.${mvIndex}.aggregatedColumns`,
});
const addAggregate = useCallback(() => {
appendAggregate({ sourceColumn: '', aggFn: 'avg', mvColumn: '' });
}, [appendAggregate]);
const kind = useWatch({ control, name: 'kind' });
const connection = useWatch({ control, name: 'connection' });
const mvTableName = useWatch({
control,
name: `materializedViews.${mvIndex}.tableName`,
});
const mvDatabaseName = useWatch({
control,
name: `materializedViews.${mvIndex}.databaseName`,
});
const fromDatabaseName = useWatch({ control, name: 'from.databaseName' });
const fromTableName = useWatch({ control, name: 'from.tableName' });
const prevMvTableNameRef = useRef(mvTableName);
const metadata = useMetadataWithSettings();
useEffect(() => {
(async () => {
try {
if (mvTableName !== prevMvTableNameRef.current) {
prevMvTableNameRef.current = mvTableName;
if (
(kind === SourceKind.Log || kind === SourceKind.Trace) &&
connection &&
mvDatabaseName &&
mvTableName &&
fromDatabaseName &&
fromTableName
) {
const config = await inferMaterializedViewConfig(
{
databaseName: mvDatabaseName,
tableName: mvTableName,
connectionId: connection,
},
{
databaseName: fromDatabaseName,
tableName: fromTableName,
connectionId: connection,
},
metadata,
);
if (config) {
setValue(`materializedViews.${mvIndex}`, config);
replaceAggregates(config.aggregatedColumns ?? []);
notifications.show({
color: 'green',
id: 'mv-infer-success',
message:
'Partially inferred materialized view configuration from view schema.',
});
} else {
notifications.show({
color: 'yellow',
id: 'mv-infer-failure',
message: 'Unable to infer materialized view configuration.',
});
}
}
}
} catch (e) {
console.error(e);
}
})();
}, [
mvTableName,
kind,
connection,
mvDatabaseName,
fromDatabaseName,
fromTableName,
mvIndex,
replaceAggregates,
setValue,
metadata,
]);
return (
<Box>
<Text size="xs" mb={4}>
Pre-aggregated Columns
<Tooltip
label={'Columns which are pre-aggregated by the materialized view'}
color="dark"
c="white"
multiline
maw={600}
>
<IconHelpCircle size={14} className="cursor-pointer ms-1" />
</Tooltip>
</Text>
<Grid columns={10}>
{aggregates.map((field, colIndex) => (
<AggregatedColumnRow
key={field.id}
setValue={setValue}
control={control}
mvIndex={mvIndex}
colIndex={colIndex}
onRemove={() => removeAggregate(colIndex)}
/>
))}
</Grid>
<Button size="sm" variant="default" onClick={addAggregate} mt="lg">
<Group>
<IconCirclePlus size={16} />
Add Column
</Group>
</Button>
</Box>
);
}
/** Component to render one row in the MV Aggregated Columns section */
function AggregatedColumnRow({
control,
mvIndex,
colIndex,
onRemove,
}: TableModelProps & {
mvIndex: number;
colIndex: number;
onRemove: () => void;
}) {
const connectionId = useWatch({ control, name: `connection` });
const sourceDatabaseName = useWatch({
control,
name: `from.databaseName`,
defaultValue: DEFAULT_DATABASE,
});
const sourceTableName = useWatch({ control, name: `from.tableName` });
const mvDatabaseName = useWatch({
control,
name: `materializedViews.${mvIndex}.databaseName`,
defaultValue: sourceDatabaseName,
});
const mvTableName = useWatch({
control,
name: `materializedViews.${mvIndex}.tableName`,
});
const isCount =
useWatch({
control,
name: `materializedViews.${mvIndex}.aggregatedColumns.${colIndex}.aggFn`,
}) === 'count';
return (
<>
<Grid.Col span={2}>
<SelectControlled
control={control}
name={`materializedViews.${mvIndex}.aggregatedColumns.${colIndex}.aggFn`}
data={MV_AGGREGATE_FUNCTION_OPTIONS}
size="sm"
/>
</Grid.Col>
{!isCount && (
<Grid.Col span={4}>
<SQLInlineEditorControlled
tableConnection={{
databaseName: sourceDatabaseName,
tableName: sourceTableName,
connectionId,
}}
control={control}
name={`materializedViews.${mvIndex}.aggregatedColumns.${colIndex}.sourceColumn`}
placeholder="Source Column"
disableKeywordAutocomplete
/>
</Grid.Col>
)}
<Grid.Col span={!isCount ? 4 : 8}>
<Group wrap="nowrap">
<Box flex={1}>
<SQLInlineEditorControlled
tableConnection={{
databaseName: mvDatabaseName,
tableName: mvTableName,
connectionId,
}}
control={control}
name={`materializedViews.${mvIndex}.aggregatedColumns.${colIndex}.mvColumn`}
placeholder="View Column"
disableKeywordAutocomplete
/>
</Box>
<ActionIcon size="sm" onClick={onRemove}>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Grid.Col>
</>
);
}
// 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(props: TableModelProps) {
const { control } = props;
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const tableName = useWatch({ control, name: 'from.tableName' });
const connectionId = useWatch({ control, name: 'connection' });
const [showOptionalFields, setShowOptionalFields] = useState(false);
return (
<>
<Stack gap="sm">
<FormRow
label={'Timestamp Column'}
helpText="DateTime column or expression that is part of your table's primary key."
>
<SQLInlineEditorControlled
tableConnection={{
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
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="defaultTableSelectExpression"
placeholder="Timestamp, Body"
/>
</FormRow>
<Box>
{!showOptionalFields && (
<Anchor
underline="always"
onClick={() => setShowOptionalFields(true)}
size="xs"
>
<Group gap="xs">
<IconSettings size={14} />
Configure Optional Fields
</Group>
</Anchor>
)}
{showOptionalFields && (
<Button
onClick={() => setShowOptionalFields(false)}
size="xs"
variant="subtle"
>
Hide Optional Fields
</Button>
)}
</Box>
</Stack>
<Stack
gap="sm"
style={{
display: showOptionalFields ? 'flex' : 'none',
}}
>
<Divider />
<FormRow label={'Service Name Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="serviceNameExpression"
placeholder="ServiceName"
/>
</FormRow>
<FormRow label={'Log Level Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="severityTextExpression"
placeholder="SeverityText"
/>
</FormRow>
<FormRow label={'Body Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="bodyExpression"
placeholder="Body"
/>
</FormRow>
<FormRow label={'Log Attributes Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="eventAttributesExpression"
placeholder="LogAttributes"
/>
</FormRow>
<FormRow label={'Resource Attributes Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="resourceAttributesExpression"
placeholder="ResourceAttributes"
/>
</FormRow>
<FormRow
label={'Displayed Timestamp Column'}
helpText="This DateTime column is used to display and order search results."
>
<SQLInlineEditorControlled
tableConnection={{
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
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="traceIdExpression"
placeholder="TraceId"
/>
</FormRow>
<FormRow label={'Span Id Expression'}>
<SQLInlineEditorControlled
tableConnection={{
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
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="uniqueRowIdExpression"
placeholder="Timestamp, ServiceName, Body"
/>
</FormRow> */}
{/* <FormRow label={'Table Filter Expression'}>
<SQLInlineEditorControlled
tableConnection={{
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
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="implicitColumnExpression"
placeholder="Body"
/>
</FormRow>
<Divider />
<HighlightedAttributeExpressionsFormRow
{...props}
name="highlightedRowAttributeExpressions"
label="Highlighted Attributes"
helpText="Expressions defining row-level attributes which are displayed in the row side panel for the selected row."
/>
<HighlightedAttributeExpressionsFormRow
{...props}
name="highlightedTraceAttributeExpressions"
label="Highlighted Trace Attributes"
helpText="Expressions defining trace-level attributes which are displayed in the trace view for the selected trace."
/>
<Divider />
<MaterializedViewsFormSection {...props} />
</Stack>
</>
);
}
export function TraceTableModelForm(props: TableModelProps) {
const { control } = props;
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const tableName = useWatch({ control, name: 'from.tableName' });
const connectionId = useWatch({ control, name: 'connection' });
return (
<Stack gap="sm">
<FormRow
label={'Timestamp Column'}
helpText="DateTime column or expression defines the start of the span"
>
<SQLInlineEditorControlled
tableConnection={{
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
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="defaultTableSelectExpression"
placeholder="Timestamp, ServiceName, StatusCode, Duration, SpanName"
/>
</FormRow>
<Divider />
<FormRow label={'Duration Expression'}>
<SQLInlineEditorControlled
tableConnection={{
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, value } }) => (
<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
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="traceIdExpression"
placeholder="TraceId"
/>
</FormRow>
<FormRow label={'Span Id Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanIdExpression"
placeholder="SpanId"
/>
</FormRow>
<FormRow label={'Parent Span Id Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="parentSpanIdExpression"
placeholder="ParentSpanId"
/>
</FormRow>
<FormRow label={'Span Name Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanNameExpression"
placeholder="SpanName"
/>
</FormRow>
<FormRow label={'Span Kind Expression'}>
<SQLInlineEditorControlled
tableConnection={{
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={'Correlated Metric Source'}
helpText="HyperDX Source for metrics associated with traces. Optional"
>
<SourceSelectControlled control={control} name="metricSourceId" />
</FormRow>
<FormRow label={'Status Code Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="statusCodeExpression"
placeholder="StatusCode"
/>
</FormRow>
<FormRow label={'Status Message Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="statusMessageExpression"
placeholder="StatusMessage"
/>
</FormRow>
<FormRow label={'Service Name Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="serviceNameExpression"
placeholder="ServiceName"
/>
</FormRow>
<FormRow label={'Resource Attributes Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="resourceAttributesExpression"
placeholder="ResourceAttributes"
/>
</FormRow>
<FormRow label={'Event Attributes Expression'}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="eventAttributesExpression"
placeholder="SpanAttributes"
/>
</FormRow>
<FormRow
label={'Span Events Expression'}
helpText="Expression to extract span events. Used to capture events associated with spans. Expected to be Nested ( Timestamp DateTime64(9), Name LowCardinality(String), Attributes Map(LowCardinality(String), String)"
>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanEventsValueExpression"
placeholder="Events"
/>
</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
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="implicitColumnExpression"
placeholder="SpanName"
/>
</FormRow>
<FormRow
label={'Displayed Timestamp Column'}
helpText="This DateTime column is used to display and order search results."
>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="displayedTimestampValueExpression"
disableKeywordAutocomplete
/>
</FormRow>
<Divider />
<HighlightedAttributeExpressionsFormRow
{...props}
name="highlightedRowAttributeExpressions"
label="Highlighted Attributes"
helpText="Expressions defining row-level attributes which are displayed in the row side panel for the selected row"
/>
<HighlightedAttributeExpressionsFormRow
{...props}
name="highlightedTraceAttributeExpressions"
label="Highlighted Trace Attributes"
helpText="Expressions defining trace-level attributes which are displayed in the trace view for the selected trace."
/>
<Divider />
<MaterializedViewsFormSection {...props} />
</Stack>
);
}
export function SessionTableModelForm({ control }: TableModelProps) {
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const connectionId = useWatch({ control, name: 'connection' });
const tableName = useWatch({ control, name: 'from.tableName' });
const prevTableNameRef = useRef(tableName);
const metadata = useMetadataWithSettings();
useEffect(() => {
(async () => {
try {
if (tableName && tableName !== prevTableNameRef.current) {
prevTableNameRef.current = tableName;
const isValid = await isValidSessionsTable({
databaseName,
tableName,
connectionId,
metadata,
});
if (!isValid) {
notifications.show({
color: 'red',
message: `${tableName} is not a valid Sessions schema.`,
});
}
}
} catch (e) {
console.error(e);
notifications.show({
color: 'red',
message: e.message,
});
}
})();
}, [tableName, databaseName, connectionId, metadata]);
return (
<>
<Stack gap="sm">
<FormRow
label={'Correlated Trace Source'}
helpText="HyperDX Source for traces associated with sessions. Required"
>
<SourceSelectControlled control={control} name="traceSourceId" />
</FormRow>
</Stack>
</>
);
}
interface TableModelProps {
control: Control<TSourceUnion>;
setValue: UseFormSetValue<TSourceUnion>;
}
export function MetricTableModelForm({ control, setValue }: TableModelProps) {
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const connectionId = useWatch({ control, name: 'connection' });
const metricTables = useWatch({ control, name: 'metricTables' });
const prevMetricTablesRef = useRef(metricTables);
const metadata = useMetadataWithSettings();
useEffect(() => {
for (const [_key, _value] of Object.entries(OTEL_CLICKHOUSE_EXPRESSIONS)) {
setValue(_key as any, _value);
}
}, [setValue]);
useEffect(() => {
(async () => {
try {
if (metricTables && prevMetricTablesRef.current) {
// Check which metric table changed
for (const metricType of Object.values(MetricsDataType)) {
const newValue =
metricTables[metricType as keyof typeof metricTables];
const prevValue =
prevMetricTablesRef.current[
metricType as keyof typeof prevMetricTablesRef.current
];
if (newValue !== prevValue) {
const isValid = await isValidMetricTable({
databaseName,
tableName: newValue as string,
connectionId,
metricType: metricType as MetricsDataType,
metadata,
});
if (!isValid) {
notifications.show({
color: 'red',
message: `${newValue} is not a valid OTEL ${metricType} schema.`,
});
}
}
}
}
prevMetricTablesRef.current = metricTables;
} catch (e) {
console.error(e);
notifications.show({
color: 'red',
message: e.message,
});
}
})();
}, [metricTables, databaseName, connectionId, metadata]);
return (
<>
<Stack gap="sm">
{Object.values(MetricsDataType).map(metricType => (
<FormRow
key={metricType.toLowerCase()}
label={`${metricType} Table`}
helpText={
metricType === MetricsDataType.ExponentialHistogram ||
metricType === MetricsDataType.Summary
? `Table containing ${metricType.toLowerCase()} metrics data. Note: not yet fully supported by HyperDX`
: `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,
setValue,
kind,
}: {
control: Control<TSourceUnion>;
setValue: UseFormSetValue<TSourceUnion>;
kind: SourceKind;
}) {
switch (kind) {
case SourceKind.Log:
return <LogTableModelForm control={control} setValue={setValue} />;
case SourceKind.Trace:
return <TraceTableModelForm control={control} setValue={setValue} />;
case SourceKind.Session:
return <SessionTableModelForm control={control} setValue={setValue} />;
case SourceKind.Metric:
return <MetricTableModelForm control={control} 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 { control, setValue, handleSubmit, resetField, setError, clearErrors } =
useForm<TSourceUnion>({
defaultValues: {
kind: SourceKind.Log,
name: defaultName,
connection: connections?.[0]?.id,
from: {
databaseName: 'default',
tableName: '',
},
},
// TODO: HDX-1768 remove type assertion
values: source as TSourceUnion,
resetOptions: {
keepDirtyValues: true,
keepErrors: true,
},
});
const watchedConnection = useWatch({
control,
name: 'connection',
defaultValue: source?.connection,
});
const watchedDatabaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: source?.from?.databaseName || DEFAULT_DATABASE,
});
const watchedTableName = useWatch({
control,
name: 'from.tableName',
defaultValue: source?.from?.tableName,
});
const watchedKind = useWatch({
control,
name: 'kind',
defaultValue: source?.kind || SourceKind.Log,
});
const prevTableNameRef = useRef(watchedTableName);
const metadata = useMetadataWithSettings();
useEffect(() => {
(async () => {
try {
if (watchedTableName !== prevTableNameRef.current) {
prevTableNameRef.current = watchedTableName;
if (
watchedConnection != null &&
watchedDatabaseName != null &&
(watchedKind === SourceKind.Metric || watchedTableName != null)
) {
const config = await inferTableSourceConfig({
databaseName: watchedDatabaseName,
tableName:
watchedKind !== SourceKind.Metric ? watchedTableName : '',
connectionId: watchedConnection,
metadata,
});
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);
}
})();
}, [
watchedTableName,
watchedConnection,
watchedDatabaseName,
watchedKind,
resetField,
metadata,
]);
// 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 = useWatch({
control,
name: 'kind',
defaultValue: source?.kind || SourceKind.Log,
});
const createSource = useCreateSource();
const updateSource = useUpdateSource();
const deleteSource = useDeleteSource();
// Bidirectional source linking
const { data: sources } = useSources();
const currentSourceId = useWatch({ control, name: 'id' });
// Watch all potential correlation fields
const logSourceId = useWatch({ control, name: 'logSourceId' });
const traceSourceId = useWatch({ control, name: 'traceSourceId' });
const metricSourceId = useWatch({ control, name: 'metricSourceId' });
const sessionTraceSourceId = useWatch({ control, name: 'traceSourceId' }); // For sessions
const prevLogSourceIdRef = useRef(logSourceId);
const prevTraceSourceIdRef = useRef(traceSourceId);
const prevMetricSourceIdRef = useRef(metricSourceId);
const prevSessionTraceSourceIdRef = useRef(sessionTraceSourceId);
useEffect(() => {
(async () => {
if (!currentSourceId || !sources || !kind) return;
const correlationFields = CORRELATION_FIELD_MAP[kind];
if (!correlationFields) return;
// Check each field for changes
const changedFields: Array<{
name: keyof TSourceUnion;
value: string | undefined;
}> = [];
if (logSourceId !== prevLogSourceIdRef.current) {
prevLogSourceIdRef.current = logSourceId;
changedFields.push({
name: 'logSourceId' as keyof TSourceUnion,
value: logSourceId ?? undefined,
});
}
if (traceSourceId !== prevTraceSourceIdRef.current) {
prevTraceSourceIdRef.current = traceSourceId;
changedFields.push({
name: 'traceSourceId' as keyof TSourceUnion,
value: traceSourceId ?? undefined,
});
}
if (metricSourceId !== prevMetricSourceIdRef.current) {
prevMetricSourceIdRef.current = metricSourceId;
changedFields.push({
name: 'metricSourceId' as keyof TSourceUnion,
value: metricSourceId ?? undefined,
});
}
if (
sessionTraceSourceId !== prevSessionTraceSourceIdRef.current &&
kind === SourceKind.Session
) {
prevSessionTraceSourceIdRef.current = sessionTraceSourceId;
changedFields.push({
name: 'traceSourceId' as keyof TSourceUnion,
value: sessionTraceSourceId ?? undefined,
});
}
for (const {
name: fieldName,
value: newTargetSourceId,
} of changedFields) {
if (!(fieldName in correlationFields)) continue;
const targetConfigs = correlationFields[fieldName];
for (const { targetKind, targetField } of targetConfigs) {
// Find the previously linked source if any
const previouslyLinkedSource = sources.find(
s => s.kind === targetKind && s[targetField] === currentSourceId,
);
// If there was a previously linked source and it's different from the new one, unlink it
if (
previouslyLinkedSource &&
previouslyLinkedSource.id !== newTargetSourceId
) {
await updateSource.mutateAsync({
source: {
...previouslyLinkedSource,
[targetField]: undefined,
} as TSource,
});
}
// If a new source is selected, link it back
if (newTargetSourceId) {
const targetSource = sources.find(s => s.id === newTargetSourceId);
if (targetSource && targetSource.kind === targetKind) {
// Only update if the target field is empty to avoid overwriting existing correlations
if (!targetSource[targetField]) {
await updateSource.mutateAsync({
source: {
...targetSource,
[targetField]: currentSourceId,
} as TSource,
});
}
}
}
}
}
})();
}, [
logSourceId,
traceSourceId,
metricSourceId,
sessionTraceSourceId,
kind,
currentSourceId,
sources,
updateSource,
]);
const sourceFormSchema = sourceSchemaWithout({ id: true });
const handleError = useCallback(
(error: z.ZodError<TSourceUnion>) => {
const errors = error.errors;
for (const err of errors) {
const errorPath: string = err.path.join('.');
// TODO: HDX-1768 get rid of this type assertion if possible
setError(errorPath as any, { ...err });
}
notifications.show({
color: 'red',
message: (
<Stack>
<Text size="sm">
<b>Failed to create source</b>
</Text>
{errors.map((err, i) => (
<Text key={i} size="sm">
{err.message}
</Text>
))}
</Stack>
),
});
},
[setError],
);
const _onCreate = useCallback(() => {
clearErrors();
handleSubmit(async data => {
const parseResult = sourceFormSchema.safeParse(data);
if (parseResult.error) {
handleError(parseResult.error);
return;
}
createSource.mutate(
// TODO: HDX-1768 get rid of this type assertion
{ source: data as TSource },
{
onSuccess: async newSource => {
// Handle bidirectional linking for new sources
const correlationFields = CORRELATION_FIELD_MAP[newSource.kind];
if (correlationFields && sources) {
for (const [fieldName, targetConfigs] of Object.entries(
correlationFields,
)) {
const targetSourceId = (newSource as any)[fieldName];
if (targetSourceId) {
for (const { targetKind, targetField } of targetConfigs) {
const targetSource = sources.find(
s => s.id === targetSourceId,
);
if (targetSource && targetSource.kind === targetKind) {
// Only update if the target field is empty to avoid overwriting existing correlations
if (!targetSource[targetField]) {
await updateSource.mutateAsync({
source: {
...targetSource,
[targetField]: newSource.id,
} as TSource,
});
}
}
}
}
}
}
onCreate?.(newSource);
notifications.show({
color: 'green',
message: 'Source created',
});
},
onError: error => {
notifications.show({
color: 'red',
message: `Failed to create source - ${error.message}`,
});
},
},
);
})();
}, [
clearErrors,
handleError,
sourceFormSchema,
handleSubmit,
createSource,
onCreate,
sources,
updateSource,
]);
const _onSave = useCallback(() => {
clearErrors();
handleSubmit(data => {
const parseResult = sourceFormSchema.safeParse(data);
if (parseResult.error) {
handleError(parseResult.error);
return;
}
updateSource.mutate(
// TODO: HDX-1768 get rid of this type assertion
{ source: data as TSource },
{
onSuccess: () => {
onSave?.();
notifications.show({
color: 'green',
message: 'Source updated',
});
},
onError: () => {
notifications.show({
color: 'red',
message: 'Failed to update source',
});
},
},
);
})();
}, [
handleSubmit,
updateSource,
onSave,
clearErrors,
handleError,
sourceFormSchema,
]);
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: source?.from?.databaseName || DEFAULT_DATABASE,
});
const connectionId = useWatch({
control,
name: 'connection',
defaultValue: source?.connection,
});
return (
<div
style={
{
// maxWidth: 700
}
}
>
<Stack gap="md" mb="md">
<Text mb="lg">Source Settings</Text>
<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>
<FormRow label={'Server Connection'}>
<ConnectionSelectControlled control={control} name={`connection`} />
</FormRow>
<FormRow label={'Database'}>
<DatabaseSelectControlled
control={control}
name={`from.databaseName`}
connectionId={connectionId}
/>
</FormRow>
{kind !== SourceKind.Metric && (
<FormRow label={'Table'}>
<DBTableSelectControlled
database={databaseName}
control={control}
name={`from.tableName`}
connectionId={connectionId}
rules={{ required: 'Table is required' }}
/>
</FormRow>
)}
</Stack>
<TableModelForm control={control} setValue={setValue} kind={kind} />
<Group justify="flex-end" mt="lg">
{onCancel && (
<Button variant="secondary" onClick={onCancel} size="xs">
Cancel
</Button>
)}
{isNew ? (
<Button
variant="primary"
onClick={_onCreate}
size="xs"
loading={createSource.isPending}
>
Save New Source
</Button>
) : (
<>
<ConfirmDeleteMenu
onDelete={() => deleteSource.mutate({ id: sourceId ?? '' })}
/>
<Button
variant="primary"
onClick={_onSave}
size="xs"
loading={createSource.isPending}
>
Save Source
</Button>
</>
)}
</Group>
</div>
);
}