-
-
+ }
+ labelPosition="right"
+ mb={8}
+ mt="sm"
+ />
+
+
+ {tableSource?.kind === SourceKind.Metric && metricType && (
+
+ {
+ setValue(`${namePrefix}metricName`, value);
+ setValue(`${namePrefix}valueExpression`, 'Value');
+ }}
+ setMetricType={value =>
+ setValue(`${namePrefix}metricType`, value)
+ }
+ metricSource={tableSource}
+ data-testid="metric-name-selector"
+ error={errors?.metricName?.message}
+ onFocus={() => clearErrors(`${namePrefix}metricName`)}
+ />
+ {metricType === 'gauge' && (
+
+
+
+ )}
+
+ )}
+ {tableSource?.kind !== SourceKind.Metric && aggFn !== 'count' && (
+
+
+
+ )}
+ {(showWhere || showGroupBy || showHaving) && (
+
+ {showWhere && (
+ <>
+
Where
+
+
+
+ >
+ )}
+ {showGroupBy && (
+ <>
+
+ Group By
+
+
+
+
+ {showHaving && (
+ <>
+
+ Having
+
+
+
+
+ >
+ )}
+ >
+ )}
+
+ )}
+
+ {tableSource?.kind === SourceKind.Metric && metricName && metricType && (
+
+ )}
+ >
+ );
+}
diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx
new file mode 100644
index 00000000..67736b90
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx
@@ -0,0 +1,675 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
+import {
+ displayTypeSupportsBuilderAlerts,
+ displayTypeSupportsRawSqlAlerts,
+} from '@hyperdx/common-utils/dist/core/utils';
+import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
+import {
+ ChartConfigWithDateRange,
+ DisplayType,
+ SavedChartConfig,
+ SourceKind,
+ TSource,
+} from '@hyperdx/common-utils/dist/types';
+import {
+ Box,
+ Divider,
+ Flex,
+ SegmentedControl,
+ Tabs,
+ Text,
+ Textarea,
+} from '@mantine/core';
+import { useDisclosure, usePrevious } from '@mantine/hooks';
+import { notifications } from '@mantine/notifications';
+import {
+ IconChartLine,
+ IconChartPie,
+ IconList,
+ IconMarkdown,
+ IconNumbers,
+ IconTable,
+} from '@tabler/icons-react';
+
+import { getPreviousDateRange } from '@/ChartUtils';
+import ChartDisplaySettingsDrawer, {
+ ChartConfigDisplaySettings,
+} from '@/components/ChartDisplaySettingsDrawer';
+import RawSqlChartEditor from '@/components/ChartEditor/RawSqlChartEditor';
+import {
+ ChartEditorFormState,
+ SavedChartConfigWithSelectArray,
+} from '@/components/ChartEditor/types';
+import {
+ convertFormStateToChartConfig,
+ convertFormStateToSavedChartConfig,
+ convertSavedChartConfigToFormState,
+ isRawSqlDisplayType,
+ validateChartForm,
+} from '@/components/ChartEditor/utils';
+import { ErrorBoundary } from '@/components/Error/ErrorBoundary';
+import { InputControlled } from '@/components/InputControlled';
+import SaveToDashboardModal from '@/components/SaveToDashboardModal';
+import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput';
+import HDXMarkdownChart from '@/HDXMarkdownChart';
+import { getTraceDurationNumberFormat, useSource } from '@/source';
+import { normalizeNoOpAlertScheduleFields } from '@/utils/alerts';
+
+import { ChartActionBar } from './ChartActionBar';
+import { ChartEditorControls } from './ChartEditorControls';
+import { ChartPreviewPanel } from './ChartPreviewPanel';
+import { ErrorNotificationMessage } from './ErrorNotificationMessage';
+import {
+ buildChartConfigForExplanations,
+ computeDbTimeChartConfig,
+ displayTypeToActiveTab,
+ TABS_WITH_GENERATED_SQL,
+ zSavedChartConfig,
+} from './utils';
+
+type EditTimeChartFormProps = {
+ dashboardId?: string;
+ chartConfig: SavedChartConfig;
+ displayedTimeInputValue?: string;
+ dateRange: [Date, Date];
+ isSaving?: boolean;
+ onTimeRangeSearch?: (value: string) => void;
+ setChartConfig?: (chartConfig: SavedChartConfig) => void;
+ setDisplayedTimeInputValue?: (value: string) => void;
+ onSave?: (chart: SavedChartConfig) => void;
+ onClose?: () => void;
+ onDirtyChange?: (isDirty: boolean) => void;
+ onTimeRangeSelect?: (start: Date, end: Date) => void;
+ 'data-testid'?: string;
+ submitRef?: React.MutableRefObject<(() => void) | undefined>;
+ isDashboardForm?: boolean;
+ autoRun?: boolean;
+};
+
+export default function EditTimeChartForm({
+ dashboardId,
+ chartConfig,
+ displayedTimeInputValue,
+ dateRange,
+ isSaving,
+ onTimeRangeSearch,
+ setChartConfig,
+ setDisplayedTimeInputValue,
+ onSave,
+ onTimeRangeSelect,
+ onClose,
+ onDirtyChange,
+ 'data-testid': dataTestId,
+ submitRef,
+ isDashboardForm = false,
+ autoRun = false,
+}: EditTimeChartFormProps) {
+ const formValue: ChartEditorFormState = useMemo(
+ () => convertSavedChartConfigToFormState(chartConfig),
+ [chartConfig],
+ );
+
+ const {
+ control,
+ setValue,
+ handleSubmit,
+ register,
+ setError,
+ clearErrors,
+ formState: { errors, isDirty, dirtyFields },
+ } = useForm
({
+ defaultValues: formValue,
+ values: formValue,
+ resolver: zodResolver(zSavedChartConfig),
+ });
+
+ const {
+ fields,
+ append,
+ remove: removeSeries,
+ swap: swapSeries,
+ } = useFieldArray({
+ control,
+ name: 'series',
+ });
+
+ useEffect(() => {
+ onDirtyChange?.(isDirty);
+ }, [isDirty, onDirtyChange]);
+
+ const select = useWatch({ control, name: 'select' });
+ const sourceId = useWatch({ control, name: 'source' });
+ const alert = useWatch({ control, name: 'alert' });
+ const seriesReturnType = useWatch({ control, name: 'seriesReturnType' });
+ const groupBy = useWatch({ control, name: 'groupBy' });
+ const displayType =
+ useWatch({ control, name: 'displayType' }) ?? DisplayType.Line;
+ const markdown = useWatch({ control, name: 'markdown' });
+ const granularity = useWatch({ control, name: 'granularity' });
+ const configType = useWatch({ control, name: 'configType' });
+
+ const chartConfigAlert = chartConfig.alert;
+ const isRawSqlInput =
+ configType === 'sql' && isRawSqlDisplayType(displayType);
+
+ const { data: tableSource } = useSource({ id: sourceId });
+ const databaseName = tableSource?.from.databaseName;
+ const tableName = tableSource?.from.tableName;
+
+ const activeTab = displayTypeToActiveTab(displayType);
+
+ // When switching display types, remove the alert if the new display type doesn't support alerts
+ const previousDisplayType = usePrevious(displayType);
+ useEffect(() => {
+ if (displayType === previousDisplayType) return;
+ const displayTypeSupportsAlerts =
+ configType === 'sql'
+ ? displayTypeSupportsRawSqlAlerts(displayType)
+ : displayTypeSupportsBuilderAlerts(displayType);
+ if (!displayTypeSupportsAlerts) {
+ setValue('alert', undefined);
+ }
+ }, [configType, displayType, previousDisplayType, setValue]);
+
+ const showGeneratedSql = TABS_WITH_GENERATED_SQL.has(activeTab);
+
+ const showSampleEvents =
+ tableSource?.kind !== SourceKind.Metric && !isRawSqlInput;
+
+ const [
+ alignDateRangeToGranularity,
+ fillNulls,
+ compareToPreviousPeriod,
+ numberFormat,
+ ] = useWatch({
+ control,
+ name: [
+ 'alignDateRangeToGranularity',
+ 'fillNulls',
+ 'compareToPreviousPeriod',
+ 'numberFormat',
+ ],
+ });
+
+ const autoDetectedNumberFormat = useMemo(
+ () =>
+ getTraceDurationNumberFormat(
+ tableSource,
+ Array.isArray(select) ? select : undefined,
+ ),
+ [tableSource, select],
+ );
+
+ const displaySettings: ChartConfigDisplaySettings = useMemo(
+ () => ({
+ alignDateRangeToGranularity,
+ fillNulls,
+ compareToPreviousPeriod,
+ numberFormat,
+ }),
+ [
+ alignDateRangeToGranularity,
+ fillNulls,
+ compareToPreviousPeriod,
+ numberFormat,
+ ],
+ );
+
+ const [
+ displaySettingsOpened,
+ { open: openDisplaySettings, close: closeDisplaySettings },
+ ] = useDisclosure(false);
+
+ // Only update this on submit, otherwise we'll have issues
+ // with using the source value from the last submit
+ // (ex. ignoring local custom source updates)
+ const [queriedConfig, setQueriedConfig] = useState<
+ ChartConfigWithDateRange | undefined
+ >(undefined);
+ const [queriedSource, setQueriedSource] = useState(
+ undefined,
+ );
+
+ const setQueriedConfigAndSource = useCallback(
+ (config: ChartConfigWithDateRange, source: TSource | undefined) => {
+ setQueriedConfig(config);
+ setQueriedSource(source);
+ },
+ [],
+ );
+
+ const dbTimeChartConfig = useMemo(
+ () => computeDbTimeChartConfig(queriedConfig, alert),
+ [queriedConfig, alert],
+ );
+
+ const [saveToDashboardModalOpen, setSaveToDashboardModalOpen] =
+ useState(false);
+
+ const validateAndNormalize = useCallback(
+ (form: ChartEditorFormState) => {
+ const errors = validateChartForm(form, tableSource, setError);
+ if (errors.length > 0) return { errors, config: null };
+
+ const savedConfig = convertFormStateToSavedChartConfig(form, tableSource);
+ if (!savedConfig) return { errors: [], config: null };
+
+ const config = isRawSqlSavedChartConfig(savedConfig)
+ ? savedConfig
+ : {
+ ...savedConfig,
+ alert: normalizeNoOpAlertScheduleFields(
+ savedConfig.alert,
+ chartConfigAlert,
+ {
+ preserveExplicitScheduleOffsetMinutes:
+ dirtyFields.alert?.scheduleOffsetMinutes === true,
+ preserveExplicitScheduleStartAt:
+ dirtyFields.alert?.scheduleStartAt === true,
+ },
+ ),
+ };
+
+ return { errors: [], config };
+ },
+ [
+ tableSource,
+ setError,
+ chartConfigAlert,
+ dirtyFields.alert?.scheduleOffsetMinutes,
+ dirtyFields.alert?.scheduleStartAt,
+ ],
+ );
+
+ const onSubmit = useCallback(
+ (suppressErrorNotification: boolean = false) => {
+ handleSubmit(form => {
+ const { errors, config } = validateAndNormalize(form);
+ if (errors.length > 0) {
+ if (!suppressErrorNotification) {
+ notifications.show({
+ id: 'chart-error',
+ title: 'Invalid Chart',
+ message: ,
+ color: 'red',
+ });
+ }
+ return;
+ }
+
+ const queriedConfig = convertFormStateToChartConfig(
+ form,
+ dateRange,
+ tableSource,
+ );
+
+ if (config && queriedConfig) {
+ const isRawSqlChart =
+ form.configType === 'sql' && isRawSqlDisplayType(form.displayType);
+ setChartConfig?.(config);
+ setQueriedConfigAndSource(
+ queriedConfig,
+ isRawSqlChart ? undefined : tableSource,
+ );
+ }
+ })();
+ },
+ [
+ validateAndNormalize,
+ handleSubmit,
+ setChartConfig,
+ setQueriedConfigAndSource,
+ tableSource,
+ dateRange,
+ ],
+ );
+
+ useEffect(() => {
+ if (submitRef) {
+ submitRef.current = onSubmit;
+ }
+ }, [onSubmit, submitRef]);
+
+ const autoRunFired = useRef(false);
+ useEffect(() => {
+ if (autoRun && !autoRunFired.current && tableSource) {
+ autoRunFired.current = true;
+ onSubmit(true);
+ }
+ }, [autoRun, tableSource, onSubmit]);
+
+ const handleSave = useCallback(
+ (form: ChartEditorFormState) => {
+ const { errors, config } = validateAndNormalize(form);
+ if (errors.length > 0) {
+ notifications.show({
+ id: 'chart-error',
+ title: 'Invalid Chart',
+ message: ,
+ color: 'red',
+ });
+ return;
+ }
+
+ if (config) {
+ onSave?.(config);
+ }
+ },
+ [validateAndNormalize, onSave],
+ );
+
+ // Track previous values for detecting changes
+ const prevGranularityRef = useRef(granularity);
+ const prevDisplayTypeRef = useRef(displayType);
+ const prevConfigTypeRef = useRef(configType);
+
+ useEffect(() => {
+ // Emulate the granularity picker auto-searching similar to dashboards
+ if (granularity !== prevGranularityRef.current) {
+ prevGranularityRef.current = granularity;
+ onSubmit();
+ }
+ }, [granularity, onSubmit]);
+
+ useEffect(() => {
+ const displayTypeChanged = displayType !== prevDisplayTypeRef.current;
+ const configTypeChanged = configType !== prevConfigTypeRef.current;
+
+ if (displayTypeChanged || configTypeChanged) {
+ prevDisplayTypeRef.current = displayType;
+ prevConfigTypeRef.current = configType;
+
+ if (displayType === DisplayType.Search && typeof select !== 'string') {
+ setValue('select', '');
+ setValue('series', []);
+ }
+
+ if (displayType !== DisplayType.Search && !Array.isArray(select)) {
+ const defaultSeries: SavedChartConfigWithSelectArray['select'] = [
+ {
+ aggFn: 'count',
+ aggCondition: '',
+ aggConditionLanguage: getStoredLanguage() ?? 'lucene',
+ valueExpression: '',
+ },
+ ];
+ setValue('where', '');
+ setValue('select', defaultSeries);
+ setValue('series', defaultSeries);
+ }
+
+ // Don't auto-submit when config type changes, to avoid clearing form state (like source)
+ if (displayTypeChanged) {
+ // true = Suppress error notification (because we're auto-submitting)
+ onSubmit(true);
+ }
+ }
+ }, [displayType, select, setValue, onSubmit, configType]);
+
+ // Emulate the date range picker auto-searching similar to dashboards
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
+ if (config == null) {
+ return config;
+ }
+
+ return {
+ ...config,
+ dateRange,
+ };
+ });
+ }, [dateRange]);
+
+ const chartConfigForExplanations = useMemo(
+ () =>
+ buildChartConfigForExplanations({
+ queriedConfig,
+ queriedSourceId: queriedSource?.id,
+ tableSource,
+ chartConfig,
+ dateRange,
+ activeTab,
+ dbTimeChartConfig,
+ }),
+ [
+ queriedConfig,
+ queriedSource?.id,
+ tableSource,
+ chartConfig,
+ dateRange,
+ activeTab,
+ dbTimeChartConfig,
+ ],
+ );
+
+ const previousDateRange = getPreviousDateRange(dateRange);
+
+ // Need to force a rerender on change as the modal will not be mounted when initially rendered
+ const [parentRef, setParentRef] = useState(null);
+
+ const handleUpdateDisplaySettings = useCallback(
+ ({
+ numberFormat,
+ alignDateRangeToGranularity,
+ fillNulls,
+ compareToPreviousPeriod,
+ }: ChartConfigDisplaySettings) => {
+ setValue('numberFormat', numberFormat);
+ setValue('alignDateRangeToGranularity', alignDateRangeToGranularity);
+ setValue('fillNulls', fillNulls);
+ setValue('compareToPreviousPeriod', compareToPreviousPeriod);
+ onSubmit();
+ },
+ [setValue, onSubmit],
+ );
+
+ const tableConnection = useMemo(
+ () => tcFromSource(tableSource),
+ [tableSource],
+ );
+
+ return (
+
+
+ (
+
+
+ }
+ data-testid="chart-type-line"
+ >
+ Line/Bar
+
+ }
+ data-testid="chart-type-table"
+ >
+ Table
+
+ }
+ data-testid="chart-type-number"
+ >
+ Number
+
+ }
+ data-testid="chart-type-pie"
+ >
+ Pie
+
+ }
+ data-testid="chart-type-search"
+ >
+ Search
+
+ }
+ data-testid="chart-type-markdown"
+ >
+ Markdown
+
+
+
+ )}
+ />
+
+
+ Chart Name
+
+
+ {isRawSqlDisplayType(displayType) && (
+ (
+
+ )}
+ />
+ )}
+
+
+ {activeTab === 'markdown' ? (
+
+
+
+
+
+
+ ) : isRawSqlInput ? (
+
+ ) : (
+
+ )}
+
+
+
setValue(name, value)}
+ onSubmit={onSubmit}
+ />
+ setSaveToDashboardModalOpen(false)}
+ />
+
+
+ );
+}
diff --git a/packages/app/src/components/DBEditTimeChartForm/ErrorNotificationMessage.tsx b/packages/app/src/components/DBEditTimeChartForm/ErrorNotificationMessage.tsx
new file mode 100644
index 00000000..f0df9975
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/ErrorNotificationMessage.tsx
@@ -0,0 +1,24 @@
+import { Path } from 'react-hook-form';
+import { List } from '@mantine/core';
+import { IconX } from '@tabler/icons-react';
+
+import { ChartEditorFormState } from '@/components/ChartEditor/types';
+
+type ErrorNotificationMessageProps = {
+ errors: { path: Path; message: string }[];
+};
+
+export const ErrorNotificationMessage = ({
+ errors,
+}: ErrorNotificationMessageProps) => {
+ return (
+
}
+ >
+ {errors.map(({ message }, index) => (
+ {message}
+ ))}
+
+ );
+};
diff --git a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx
new file mode 100644
index 00000000..6c32621a
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx
@@ -0,0 +1,266 @@
+import {
+ Control,
+ Controller,
+ UseFormSetValue,
+ useWatch,
+} from 'react-hook-form';
+import {
+ AlertThresholdType,
+ isRangeThresholdType,
+} from '@hyperdx/common-utils/dist/types';
+import {
+ ActionIcon,
+ Alert,
+ Badge,
+ Box,
+ Collapse,
+ Group,
+ NativeSelect,
+ NumberInput,
+ Paper,
+ Text,
+ Tooltip,
+ UnstyledButton,
+} from '@mantine/core';
+import { useDisclosure } from '@mantine/hooks';
+import {
+ IconChevronDown,
+ IconHelpCircle,
+ IconInfoCircleFilled,
+ IconTrash,
+} from '@tabler/icons-react';
+
+import api from '@/api';
+import { AlertChannelForm } from '@/components/Alerts';
+import { AckAlert } from '@/components/alerts/AckAlert';
+import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
+import { AlertScheduleFields } from '@/components/AlertScheduleFields';
+import { ChartEditorFormState } from '@/components/ChartEditor/types';
+import { optionsToSelectData } from '@/utils';
+import {
+ ALERT_CHANNEL_OPTIONS,
+ intervalToMinutes,
+ TILE_ALERT_INTERVAL_OPTIONS,
+ TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
+} from '@/utils/alerts';
+
+export function TileAlertEditor({
+ control,
+ setValue,
+ alert,
+ onRemove,
+ error,
+ warning,
+ tooltip,
+}: {
+ control: Control;
+ setValue: UseFormSetValue;
+ alert: NonNullable;
+ onRemove: () => void;
+ error?: string;
+ warning?: string;
+ tooltip?: string;
+}) {
+ const [opened, { toggle }] = useDisclosure(true);
+
+ const alertChannelType = useWatch({ control, name: 'alert.channel.type' });
+ const alertThresholdType = useWatch({ control, name: 'alert.thresholdType' });
+ const alertThreshold = useWatch({ control, name: 'alert.threshold' });
+ const alertThresholdMax = useWatch({ control, name: 'alert.thresholdMax' });
+ const alertScheduleOffsetMinutes = useWatch({
+ control,
+ name: 'alert.scheduleOffsetMinutes',
+ });
+ const maxAlertScheduleOffsetMinutes = alert?.interval
+ ? Math.max(intervalToMinutes(alert.interval) - 1, 0)
+ : 0;
+ const alertIntervalLabel = alert?.interval
+ ? TILE_ALERT_INTERVAL_OPTIONS[alert.interval]
+ : undefined;
+
+ const { data: alertData } = api.useAlert(alert.id);
+ const alertItem = alertData?.data;
+
+ return (
+
+
+
+
+
+
+
+ Alert
+
+ {tooltip && (
+
+
+
+ )}
+ {error && (
+
+
+ Invalid Query
+
+
+ )}
+ {warning && (
+
+
+ Warning
+
+
+ )}
+
+
+
+
+ {alertItem && alertItem.history.length > 0 && (
+
+ )}
+ {alertItem && }
+
+
+
+
+
+
+
+
+
+
+
+ Trigger when the value
+
+ (
+ {
+ field.onChange(e);
+ if (
+ isRangeThresholdType(e.currentTarget.value) &&
+ alertThresholdMax == null
+ ) {
+ setValue('alert.thresholdMax', (alertThreshold ?? 0) + 1);
+ }
+ }}
+ />
+ )}
+ />
+ (
+
+ )}
+ />
+ {isRangeThresholdType(alertThresholdType as AlertThresholdType) && (
+ <>
+
+ and
+
+ (
+
+ )}
+ />
+ >
+ )}
+ over
+ (
+
+ )}
+ />
+
+ window via
+
+ (
+
+ )}
+ />
+
+ {alert?.createdBy && (
+
+ Created by {alert.createdBy.name || alert.createdBy.email}
+
+ )}
+
+
+ Send to
+
+
+ {(alertThresholdType === AlertThresholdType.EQUAL ||
+ alertThresholdType === AlertThresholdType.NOT_EQUAL) && (
+ }
+ color="gray"
+ py="xs"
+ mt="md"
+ >
+ Note: Floating-point query results are not rounded during equality
+ comparison.
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartActionBar.test.tsx b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartActionBar.test.tsx
new file mode 100644
index 00000000..9d272e15
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartActionBar.test.tsx
@@ -0,0 +1,224 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { DisplayType } from '@hyperdx/common-utils/dist/types';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ChartEditorFormState } from '@/components/ChartEditor/types';
+
+import { ChartActionBar } from '../ChartActionBar';
+
+jest.mock('@/components/SQLEditor/SQLInlineEditor', () => ({
+ SQLInlineEditorControlled: (props: any) => (
+ {props.label}
+ ),
+}));
+
+jest.mock('@/components/TimePicker', () => ({
+ TimePicker: () => TimePicker
,
+}));
+
+jest.mock('@/GranularityPicker', () => ({
+ GranularityPickerControlled: () => (
+ Granularity
+ ),
+}));
+
+const defaultTableConnection = {
+ databaseName: 'default',
+ tableName: 'logs',
+ connectionId: 'default',
+};
+
+type WrapperProps = {
+ children: (props: { control: any; handleSubmit: any }) => React.ReactNode;
+ defaultValues?: Partial;
+};
+
+function FormWrapper({ children, defaultValues }: WrapperProps) {
+ const { control, handleSubmit } = useForm({
+ defaultValues: {
+ displayType: DisplayType.Line,
+ name: 'Test Chart',
+ select: [
+ {
+ aggFn: 'count',
+ aggCondition: '',
+ aggConditionLanguage: 'lucene',
+ valueExpression: '',
+ },
+ ],
+ where: '',
+ whereLanguage: 'lucene',
+ granularity: 'auto',
+ ...defaultValues,
+ },
+ });
+
+ return <>{children({ control, handleSubmit })}>;
+}
+
+const renderActionBar = (
+ overrides: Partial> = {},
+) => {
+ const onSubmit = jest.fn();
+ const handleSave = jest.fn();
+ const onSave = jest.fn();
+ const onClose = jest.fn();
+ const setSaveToDashboardModalOpen = jest.fn();
+
+ const result = renderWithMantine(
+
+ {({ control, handleSubmit }) => (
+
+ )}
+ ,
+ );
+
+ return {
+ ...result,
+ onSubmit,
+ handleSave,
+ onSave,
+ onClose,
+ setSaveToDashboardModalOpen,
+ };
+};
+
+describe('ChartActionBar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render Save button when onSave is provided', () => {
+ renderActionBar();
+
+ expect(screen.getByTestId('chart-save-button')).toBeInTheDocument();
+ expect(screen.getByTestId('chart-save-button')).toHaveTextContent('Save');
+ });
+
+ it('should not render Save button when onSave is undefined', () => {
+ renderActionBar({ onSave: undefined });
+
+ expect(screen.queryByTestId('chart-save-button')).not.toBeInTheDocument();
+ });
+
+ it('should render Cancel button when onClose is provided', () => {
+ renderActionBar();
+
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ });
+
+ it('should not render Cancel button when onClose is undefined', () => {
+ renderActionBar({ onClose: undefined });
+
+ expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
+ });
+
+ it('should call onClose when Cancel is clicked', async () => {
+ const { onClose } = renderActionBar();
+
+ await userEvent.click(screen.getByText('Cancel'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render Run button for non-markdown tabs', () => {
+ renderActionBar({ activeTab: 'time' });
+
+ expect(screen.getByTestId('chart-run-query-button')).toBeInTheDocument();
+ expect(screen.getByTestId('chart-run-query-button')).toHaveTextContent(
+ 'Run',
+ );
+ });
+
+ it('should not render Run button for markdown tab', () => {
+ renderActionBar({ activeTab: 'markdown' });
+
+ expect(
+ screen.queryByTestId('chart-run-query-button'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should call onSubmit when Run is clicked', async () => {
+ const { onSubmit } = renderActionBar();
+
+ await userEvent.click(screen.getByTestId('chart-run-query-button'));
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render granularity picker for time tab', () => {
+ renderActionBar({ activeTab: 'time' });
+
+ expect(screen.getByTestId('granularity-picker')).toBeInTheDocument();
+ });
+
+ it('should not render granularity picker for non-time tabs', () => {
+ renderActionBar({ activeTab: 'table' });
+
+ expect(screen.queryByTestId('granularity-picker')).not.toBeInTheDocument();
+ });
+
+ it('should render ORDER BY editor for table tab when not raw SQL', () => {
+ renderActionBar({ activeTab: 'table', isRawSqlInput: false });
+
+ expect(screen.getByTestId('sql-editor-order-by')).toBeInTheDocument();
+ });
+
+ it('should not render ORDER BY editor for table tab when raw SQL', () => {
+ renderActionBar({ activeTab: 'table', isRawSqlInput: true });
+
+ expect(screen.queryByTestId('sql-editor-order-by')).not.toBeInTheDocument();
+ });
+
+ it('should render TimePicker when time range props are provided', () => {
+ renderActionBar({
+ displayedTimeInputValue: 'Last 24h',
+ setDisplayedTimeInputValue: jest.fn(),
+ onTimeRangeSearch: jest.fn(),
+ });
+
+ expect(screen.getByTestId('time-picker')).toBeInTheDocument();
+ });
+
+ it('should not render TimePicker when time range props are missing', () => {
+ renderActionBar({
+ displayedTimeInputValue: undefined,
+ setDisplayedTimeInputValue: undefined,
+ onTimeRangeSearch: undefined,
+ });
+
+ expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
+ });
+
+ it('should disable Cancel button when isSaving is true', () => {
+ renderActionBar({ isSaving: true });
+
+ expect(screen.getByText('Cancel').closest('button')).toBeDisabled();
+ });
+
+ it('should render action bar controls for raw SQL input mode', () => {
+ renderActionBar({ isRawSqlInput: true, activeTab: 'time' });
+
+ // The key regression test: action bar renders even for raw SQL
+ expect(screen.getByTestId('chart-save-button')).toBeInTheDocument();
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ expect(screen.getByTestId('chart-run-query-button')).toBeInTheDocument();
+ expect(screen.getByTestId('granularity-picker')).toBeInTheDocument();
+ });
+});
diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx
new file mode 100644
index 00000000..3463d867
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx
@@ -0,0 +1,200 @@
+import React from 'react';
+import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
+import { screen } from '@testing-library/react';
+
+import { ChartPreviewPanel } from '../ChartPreviewPanel';
+
+jest.mock('@/components/ChartSQLPreview', () => ({
+ __esModule: true,
+ default: () => Chart SQL Preview
,
+}));
+
+jest.mock('@/components/DBTimeChart', () => ({
+ DBTimeChart: () => Time Chart
,
+}));
+
+jest.mock('@/components/DBTableChart', () => ({
+ __esModule: true,
+ default: () => Table Chart
,
+}));
+
+jest.mock('@/components/DBNumberChart', () => ({
+ __esModule: true,
+ default: () => Number Chart
,
+}));
+
+jest.mock('@/components/DBPieChart', () => ({
+ DBPieChart: () => Pie Chart
,
+}));
+
+jest.mock('@/components/DBSqlRowTableWithSidebar', () => ({
+ __esModule: true,
+ default: () => SQL Row Table
,
+}));
+
+jest.mock('@/source', () => ({
+ getFirstTimestampValueExpression: jest.fn().mockReturnValue('Timestamp'),
+}));
+
+const dateRange: [Date, Date] = [
+ new Date('2024-01-01'),
+ new Date('2024-01-02'),
+];
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const mockTableSource = {
+ id: 'test-source',
+ kind: SourceKind.Log,
+ name: 'Test Source',
+ from: {
+ databaseName: 'default',
+ tableName: 'logs',
+ },
+ connection: 'default',
+ timestampValueExpression: 'Timestamp',
+} as TSource;
+
+const baseBuilderConfig = {
+ timestampValueExpression: 'Timestamp',
+ connection: 'default',
+ from: { databaseName: 'default', tableName: 'logs' },
+ select: [{ aggFn: 'count' as const, valueExpression: '' }],
+ where: '',
+ granularity: 'auto' as const,
+ dateRange,
+};
+
+const renderPanel = (
+ overrides: Partial> = {},
+) => {
+ return renderWithMantine(
+ ,
+ );
+};
+
+describe('ChartPreviewPanel', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when no query has been run', () => {
+ it('should show placeholder message', () => {
+ renderPanel({ queriedConfig: undefined });
+
+ expect(screen.getByText(/please start by defining/i)).toBeInTheDocument();
+ });
+
+ it('should not show placeholder for markdown tab', () => {
+ renderPanel({ queriedConfig: undefined, activeTab: 'markdown' });
+
+ expect(
+ screen.queryByText(/please start by defining/i),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when query is ready', () => {
+ it('should render time chart for time tab', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ dbTimeChartConfig: baseBuilderConfig,
+ activeTab: 'time',
+ });
+
+ expect(screen.getByTestId('db-time-chart')).toBeInTheDocument();
+ });
+
+ it('should render table chart for table tab', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ tableSource: mockTableSource,
+ activeTab: 'table',
+ });
+
+ expect(screen.getByTestId('db-table-chart')).toBeInTheDocument();
+ });
+
+ it('should render number chart for number tab', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ activeTab: 'number',
+ });
+
+ expect(screen.getByTestId('db-number-chart')).toBeInTheDocument();
+ });
+
+ it('should render pie chart for pie tab', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ activeTab: 'pie',
+ });
+
+ expect(screen.getByTestId('db-pie-chart')).toBeInTheDocument();
+ });
+
+ it('should not render time chart when dbTimeChartConfig is missing', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ dbTimeChartConfig: undefined,
+ activeTab: 'time',
+ });
+
+ expect(screen.queryByTestId('db-time-chart')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('generated SQL section', () => {
+ it('should show Generated SQL accordion when showGeneratedSql is true', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ showGeneratedSql: true,
+ activeTab: 'time',
+ });
+
+ expect(screen.getByText('Generated SQL')).toBeInTheDocument();
+ });
+
+ it('should not show Generated SQL when showGeneratedSql is false', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ showGeneratedSql: false,
+ activeTab: 'time',
+ });
+
+ expect(screen.queryByText('Generated SQL')).not.toBeInTheDocument();
+ });
+
+ it('should show Sample Matched Events when showSampleEvents is true', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ showGeneratedSql: true,
+ showSampleEvents: true,
+ tableSource: mockTableSource,
+ activeTab: 'time',
+ });
+
+ expect(screen.getByText('Sample Matched Events')).toBeInTheDocument();
+ });
+
+ it('should not show Sample Matched Events when showSampleEvents is false', () => {
+ renderPanel({
+ queriedConfig: baseBuilderConfig,
+ showGeneratedSql: true,
+ showSampleEvents: false,
+ activeTab: 'time',
+ });
+
+ expect(
+ screen.queryByText('Sample Matched Events'),
+ ).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/app/src/components/__tests__/DBEditTimeChartForm.test.tsx b/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx
similarity index 85%
rename from packages/app/src/components/__tests__/DBEditTimeChartForm.test.tsx
rename to packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx
index cbe9ded3..5021139e 100644
--- a/packages/app/src/components/__tests__/DBEditTimeChartForm.test.tsx
+++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx
@@ -11,7 +11,7 @@ import userEvent from '@testing-library/user-event';
import { useSource } from '@/source';
-import DBEditTimeChartForm from '../DBEditTimeChartForm';
+import DBEditTimeChartForm from '..';
// Mock the hooks that fetch data
jest.mock('@/hooks/useFetchMetricResourceAttrs', () => ({
@@ -68,9 +68,10 @@ jest.mock('@/source', () => ({
return { data: undefined };
}),
getFirstTimestampValueExpression: jest.fn().mockReturnValue('Timestamp'),
+ getTraceDurationNumberFormat: jest.fn().mockReturnValue(undefined),
}));
-jest.mock('../MetricNameSelect', () => ({
+jest.mock('../../MetricNameSelect', () => ({
MetricNameSelect: (props: any) => {
const { error, onFocus, setMetricName, metricName } = props;
const testId = props['data-testid'];
@@ -93,7 +94,7 @@ jest.mock('../MetricNameSelect', () => ({
},
}));
-jest.mock('../SourceSelect', () => ({
+jest.mock('../../SourceSelect', () => ({
SourceSelectControlled: () => (
@@ -101,21 +102,21 @@ jest.mock('../SourceSelect', () => ({
),
}));
-jest.mock('../ChartSQLPreview', () => ({
+jest.mock('../../ChartSQLPreview', () => ({
__esModule: true,
default: () => Chart SQL Preview
,
}));
-jest.mock('../DBTimeChart', () => ({
+jest.mock('../../DBTimeChart', () => ({
DBTimeChart: () => Time Chart
,
}));
-jest.mock('../DBTableChart', () => ({
+jest.mock('../../DBTableChart', () => ({
__esModule: true,
default: () => Table Chart
,
}));
-jest.mock('../DBNumberChart', () => ({
+jest.mock('../../DBNumberChart', () => ({
__esModule: true,
default: () => Number Chart
,
}));
@@ -125,12 +126,12 @@ jest.mock('@/components/SearchInput/SearchInputV2', () => ({
default: () => Search Input
,
}));
-jest.mock('../MaterializedViews/MVOptimizationIndicator', () => ({
+jest.mock('../../MaterializedViews/MVOptimizationIndicator', () => ({
__esModule: true,
default: () => MV Indicator
,
}));
-jest.mock('../SQLEditor/SQLInlineEditor', () => ({
+jest.mock('../../SQLEditor/SQLInlineEditor', () => ({
SQLInlineEditorControlled: () => SQL Editor
,
}));
@@ -167,19 +168,21 @@ const defaultChartConfig: SavedChartConfig = {
alignDateRangeToGranularity: true,
};
-describe('DBEditTimeChartForm - Metric Name Validation', () => {
- const renderComponent = (props = {}) => {
- return renderWithMantine(
-
-
- ,
- );
- };
+const renderComponent = (
+ props: Partial> = {},
+) => {
+ return renderWithMantine(
+
+
+ ,
+ );
+};
+describe('DBEditTimeChartForm - Metric Name Validation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -251,7 +254,7 @@ describe('DBEditTimeChartForm - Metric Name Validation', () => {
aggCondition: '',
aggConditionLanguage: 'lucene' as const,
valueExpression: '',
- metricType: 'gauge' as const,
+ metricType: MetricsDataType.Gauge,
metricName: 'test.metric.gauge',
},
{
@@ -259,7 +262,7 @@ describe('DBEditTimeChartForm - Metric Name Validation', () => {
aggCondition: '',
aggConditionLanguage: 'lucene' as const,
valueExpression: '',
- metricType: 'gauge' as const,
+ metricType: MetricsDataType.Gauge,
metricName: '', // Empty metric name - should trigger validation
},
],
@@ -355,7 +358,7 @@ describe('DBEditTimeChartForm - Metric Name Validation', () => {
aggCondition: '',
aggConditionLanguage: 'lucene' as const,
valueExpression: '',
- metricType: 'gauge' as const,
+ metricType: MetricsDataType.Gauge,
metricName: '', // Empty metricName with metricType set - should trigger validation
},
],
@@ -377,18 +380,6 @@ describe('DBEditTimeChartForm - Metric Name Validation', () => {
});
describe('DBEditTimeChartForm - Save Button Metric Name Validation', () => {
- const renderComponent = (props = {}) => {
- return renderWithMantine(
-
-
- ,
- );
- };
-
beforeEach(() => {
jest.clearAllMocks();
});
@@ -418,28 +409,21 @@ describe('DBEditTimeChartForm - Save Button Metric Name Validation', () => {
});
describe('DBEditTimeChartForm - Add/delete alerts for display type Number', () => {
- const renderComponent = (props = {}) => {
- return renderWithMantine(
-
-
- ,
- );
- };
+ const renderAlertComponent = (
+ props: Partial> = {},
+ ) =>
+ renderComponent({
+ chartConfig: { ...defaultChartConfig, displayType: DisplayType.Number },
+ dashboardId: 'test-dashboard-id',
+ ...props,
+ });
beforeEach(() => {
jest.clearAllMocks();
});
it('should add an alert when clicking the add alert button', async () => {
- renderComponent();
+ renderAlertComponent();
// Find and click the add alert button
const alertButton = screen.getByTestId('alert-button');
@@ -453,19 +437,20 @@ describe('DBEditTimeChartForm - Add/delete alerts for display type Number', () =
it('should remove an alert when clicking the remove alert button', async () => {
const onSave = jest.fn();
- renderComponent({ onSave });
+ renderAlertComponent({ onSave });
// Find and click the add alert button
- const alertButton = screen.getByTestId('alert-button');
- await userEvent.click(alertButton);
+ const addAlertButton = screen.getByTestId('alert-button');
+ await userEvent.click(addAlertButton);
// Verify that the alert is added
const alert = screen.getByTestId('alert-details');
expect(alert).toBeInTheDocument();
- // The add and remove alert button are the same element
- expect(alertButton).toHaveTextContent('Remove Alert');
- await userEvent.click(alertButton);
+ expect(addAlertButton).not.toBeVisible();
+
+ const removeAlertButton = screen.getByTestId('remove-alert-button');
+ await userEvent.click(removeAlertButton);
// Verify that the alert is deleted
expect(alert).not.toBeInTheDocument();
@@ -475,17 +460,21 @@ describe('DBEditTimeChartForm - Add/delete alerts for display type Number', () =
});
it('shows alert scheduling fields inside advanced settings', async () => {
- renderComponent();
+ renderAlertComponent();
await userEvent.click(screen.getByTestId('alert-button'));
- expect(
- screen.getByTestId('alert-advanced-settings-panel'),
- ).not.toBeVisible();
+ // Mantine v9 Collapse sets aria-hidden on its wrapper element
+ const collapseWrapper = screen
+ .getByTestId('alert-advanced-settings-panel')
+ .closest('[aria-hidden]');
+ expect(collapseWrapper).toHaveAttribute('aria-hidden', 'true');
await userEvent.click(screen.getByTestId('alert-advanced-settings-toggle'));
- expect(screen.getByTestId('alert-advanced-settings-panel')).toBeVisible();
+ await waitFor(() => {
+ expect(collapseWrapper).toHaveAttribute('aria-hidden', 'false');
+ });
expect(screen.getByText('Anchor start time')).toBeInTheDocument();
expect(
screen.getByTestId('alert-advanced-settings-toggle'),
diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts b/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts
new file mode 100644
index 00000000..79cbd892
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts
@@ -0,0 +1,470 @@
+import {
+ ChartConfigWithDateRange,
+ DisplayType,
+ SavedChartConfig,
+ SourceKind,
+ TSource,
+} from '@hyperdx/common-utils/dist/types';
+
+import { ChartEditorFormState } from '@/components/ChartEditor/types';
+
+import {
+ buildChartConfigForExplanations,
+ buildSampleEventsConfig,
+ computeDbTimeChartConfig,
+ displayTypeToActiveTab,
+ isQueryReady,
+ seriesToFilters,
+ TABS_WITH_GENERATED_SQL,
+} from '../utils';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const dateRange: [Date, Date] = [
+ new Date('2024-01-01'),
+ new Date('2024-01-02'),
+];
+
+const builderConfig: ChartConfigWithDateRange = {
+ select: [
+ {
+ aggFn: 'count',
+ aggCondition: '',
+ valueExpression: '',
+ },
+ ],
+ from: { databaseName: 'default', tableName: 'logs' },
+ where: '',
+ whereLanguage: 'sql',
+ timestampValueExpression: 'Timestamp',
+ connection: 'clickhouse',
+ dateRange,
+ granularity: 'auto',
+} as ChartConfigWithDateRange;
+
+const rawSqlConfig = {
+ configType: 'sql' as const,
+ sqlTemplate: 'SELECT count() FROM logs',
+ connection: 'clickhouse',
+ dateRange,
+} as ChartConfigWithDateRange;
+
+const logSource = {
+ kind: SourceKind.Log,
+ id: 'log-source',
+ name: 'Logs',
+ from: { databaseName: 'default', tableName: 'logs' },
+ connection: 'clickhouse',
+ timestampValueExpression: 'Timestamp',
+ defaultTableSelectExpression: '*',
+} as TSource;
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const metricSource = {
+ kind: SourceKind.Metric,
+ id: 'metric-source',
+ name: 'Metrics',
+ from: { databaseName: 'default', tableName: '' },
+ connection: 'clickhouse',
+ timestampValueExpression: 'Timestamp',
+ metricTables: {
+ gauge: 'metrics.gauge',
+ sum: 'metrics.sum',
+ histogram: 'metrics.histogram',
+ },
+} as TSource;
+
+const savedChartConfig: SavedChartConfig = {
+ name: 'Test Chart',
+ source: 'log-source',
+ displayType: DisplayType.Line,
+ select: [
+ {
+ aggFn: 'count',
+ aggCondition: '',
+ valueExpression: '',
+ },
+ ],
+ where: '',
+ whereLanguage: 'sql',
+ granularity: 'auto',
+} as SavedChartConfig;
+
+const rawSqlSavedChartConfig: SavedChartConfig = {
+ configType: 'sql' as const,
+ name: 'SQL Chart',
+ sqlTemplate: 'SELECT count() FROM logs',
+ connection: 'clickhouse',
+} as SavedChartConfig;
+
+// ---------------------------------------------------------------------------
+// isQueryReady
+// ---------------------------------------------------------------------------
+
+describe('isQueryReady', () => {
+ it('returns false for undefined', () => {
+ expect(isQueryReady(undefined)).toBe(false);
+ });
+
+ it('returns truthy for a valid builder config', () => {
+ expect(isQueryReady(builderConfig)).toBeTruthy();
+ });
+
+ it('returns falsy when select is empty array', () => {
+ expect(isQueryReady({ ...builderConfig, select: [] })).toBeFalsy();
+ });
+
+ it('returns truthy when select is a string', () => {
+ expect(
+ isQueryReady({ ...builderConfig, select: 'col1, col2' }),
+ ).toBeTruthy();
+ });
+
+ it('returns falsy when databaseName is missing', () => {
+ expect(
+ isQueryReady({
+ ...builderConfig,
+ from: { databaseName: '', tableName: 'logs' },
+ }),
+ ).toBeFalsy();
+ });
+
+ it('returns truthy for metric sources with metricTables but empty tableName', () => {
+ expect(
+ isQueryReady({
+ ...builderConfig,
+ from: { databaseName: 'default', tableName: '' },
+ metricTables: { gauge: 'metrics.gauge' },
+ } as ChartConfigWithDateRange),
+ ).toBeTruthy();
+ });
+
+ it('returns truthy for raw SQL config with sqlTemplate and connection', () => {
+ expect(isQueryReady(rawSqlConfig)).toBeTruthy();
+ });
+
+ it('returns false for raw SQL config without sqlTemplate', () => {
+ expect(
+ isQueryReady({
+ ...rawSqlConfig,
+ sqlTemplate: '',
+ } as ChartConfigWithDateRange),
+ ).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// seriesToFilters
+// ---------------------------------------------------------------------------
+
+describe('seriesToFilters', () => {
+ it('returns empty array for string select', () => {
+ expect(seriesToFilters('col1, col2')).toEqual([]);
+ });
+
+ it('converts series with conditions to filters', () => {
+ const select = [
+ {
+ aggFn: 'count',
+ aggCondition: 'status > 400',
+ aggConditionLanguage: 'sql' as const,
+ valueExpression: '',
+ },
+ {
+ aggFn: 'avg',
+ aggCondition: 'level:error',
+ aggConditionLanguage: 'lucene' as const,
+ valueExpression: 'duration',
+ },
+ ];
+
+ expect(seriesToFilters(select)).toEqual([
+ { type: 'sql', condition: 'status > 400' },
+ { type: 'lucene', condition: 'level:error' },
+ ]);
+ });
+
+ it('skips series with null condition or language', () => {
+ const select = [
+ {
+ aggFn: 'count',
+ aggCondition: '',
+ aggConditionLanguage: 'lucene' as const,
+ valueExpression: '',
+ },
+ {
+ aggFn: 'avg',
+ valueExpression: 'duration',
+ },
+ ];
+
+ // First has empty string condition (still not null), second has no language
+ expect(seriesToFilters(select)).toEqual([
+ { type: 'lucene', condition: '' },
+ ]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// displayTypeToActiveTab
+// ---------------------------------------------------------------------------
+
+describe('displayTypeToActiveTab', () => {
+ it.each([
+ [DisplayType.Search, 'search'],
+ [DisplayType.Markdown, 'markdown'],
+ [DisplayType.Table, 'table'],
+ [DisplayType.Pie, 'pie'],
+ [DisplayType.Number, 'number'],
+ [DisplayType.Line, 'time'],
+ ])('maps %s to %s', (displayType, expected) => {
+ expect(displayTypeToActiveTab(displayType)).toBe(expected);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TABS_WITH_GENERATED_SQL
+// ---------------------------------------------------------------------------
+
+describe('TABS_WITH_GENERATED_SQL', () => {
+ it('includes table, time, number, pie', () => {
+ expect(TABS_WITH_GENERATED_SQL.has('table')).toBe(true);
+ expect(TABS_WITH_GENERATED_SQL.has('time')).toBe(true);
+ expect(TABS_WITH_GENERATED_SQL.has('number')).toBe(true);
+ expect(TABS_WITH_GENERATED_SQL.has('pie')).toBe(true);
+ });
+
+ it('excludes search, markdown', () => {
+ expect(TABS_WITH_GENERATED_SQL.has('search')).toBe(false);
+ expect(TABS_WITH_GENERATED_SQL.has('markdown')).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// computeDbTimeChartConfig
+// ---------------------------------------------------------------------------
+
+describe('computeDbTimeChartConfig', () => {
+ it('returns undefined when queriedConfig is undefined', () => {
+ expect(computeDbTimeChartConfig(undefined, undefined)).toBeUndefined();
+ });
+
+ it('returns config unchanged when there is no alert', () => {
+ const result = computeDbTimeChartConfig(builderConfig, undefined);
+ expect(result).toEqual(builderConfig);
+ });
+
+ it('overrides granularity and dateRange when alert is present', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const alert = {
+ interval: '1h' as const,
+ threshold: 100,
+ thresholdType: 'above' as const,
+ channel: { type: 'webhook' as const },
+ } as unknown as ChartEditorFormState['alert'];
+
+ const result = computeDbTimeChartConfig(builderConfig, alert);
+
+ expect(result).toBeDefined();
+ // granularity should be changed from the alert interval
+ expect(result!.granularity).not.toBe(builderConfig.granularity);
+ // dateRange should be extended
+ expect(result!.dateRange).toBeDefined();
+ });
+
+ it('preserves other config fields', () => {
+ const result = computeDbTimeChartConfig(builderConfig, undefined);
+ expect(result!.connection).toBe(builderConfig.connection);
+ expect(result!.from).toBe(builderConfig.from);
+ // @ts-expect-error union types..
+ expect(result!.select).toBe(builderConfig.select);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildSampleEventsConfig
+// ---------------------------------------------------------------------------
+
+describe('buildSampleEventsConfig', () => {
+ it('returns null when tableSource is undefined', () => {
+ expect(
+ buildSampleEventsConfig(builderConfig, undefined, dateRange, true),
+ ).toBeNull();
+ });
+
+ it('returns null when queriedConfig is undefined', () => {
+ expect(
+ buildSampleEventsConfig(undefined, logSource, dateRange, true),
+ ).toBeNull();
+ });
+
+ it('returns null when queryReady is false', () => {
+ expect(
+ buildSampleEventsConfig(builderConfig, logSource, dateRange, false),
+ ).toBeNull();
+ });
+
+ it('returns null for raw SQL config', () => {
+ expect(
+ buildSampleEventsConfig(rawSqlConfig, logSource, dateRange, true),
+ ).toBeNull();
+ });
+
+ it('builds config for a valid builder config with log source', () => {
+ const result = buildSampleEventsConfig(
+ builderConfig,
+ logSource,
+ dateRange,
+ true,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.dateRange).toBe(dateRange);
+ expect(result!.connection).toBe(logSource.connection);
+ expect(result!.from).toBe(logSource.from);
+ expect(result!.limit).toEqual({ limit: 200 });
+ // @ts-expect-error union types..
+ expect(result!.select).toBe(logSource.defaultTableSelectExpression);
+ expect(result!.orderBy).toEqual([
+ { ordering: 'DESC', valueExpression: 'Timestamp' },
+ ]);
+ expect(result!.filtersLogicalOperator).toBe('OR');
+ expect(result!.groupBy).toBeUndefined();
+ expect(result!.granularity).toBeUndefined();
+ expect(result!.having).toBeUndefined();
+ });
+
+ it('uses empty string for select when source has no defaultTableSelectExpression', () => {
+ const result = buildSampleEventsConfig(
+ builderConfig,
+ metricSource,
+ dateRange,
+ true,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.select).toBe('');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildChartConfigForExplanations
+// ---------------------------------------------------------------------------
+
+describe('buildChartConfigForExplanations', () => {
+ const baseParams = {
+ chartConfig: savedChartConfig,
+ dateRange,
+ activeTab: 'time',
+ };
+
+ it('returns raw SQL queriedConfig with updated dateRange', () => {
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ queriedConfig: rawSqlConfig,
+ });
+
+ expect(result).toBeDefined();
+ expect(result!.dateRange).toBe(dateRange);
+ // @ts-expect-error union types..
+ expect(result!.sqlTemplate).toBe(rawSqlConfig.sqlTemplate);
+ });
+
+ it('returns raw SQL savedChartConfig with updated dateRange', () => {
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ chartConfig: rawSqlSavedChartConfig,
+ });
+
+ expect(result).toBeDefined();
+ expect(result!.dateRange).toBe(dateRange);
+ });
+
+ it('returns undefined when no configs match', () => {
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ queriedConfig: undefined,
+ tableSource: undefined,
+ });
+
+ expect(result).toBeUndefined();
+ });
+
+ it('uses dbTimeChartConfig when activeTab is time and source matches', () => {
+ const dbTimeConfig = {
+ ...builderConfig,
+ granularity: '1 hour',
+ } as ChartConfigWithDateRange;
+
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ queriedConfig: builderConfig,
+ queriedSourceId: logSource.id,
+ tableSource: logSource,
+ activeTab: 'time',
+ dbTimeChartConfig: dbTimeConfig,
+ });
+
+ expect(result).toBeDefined();
+ });
+
+ it.each(['table', 'number', 'pie'] as const)(
+ 'uses queriedConfig for activeTab=%s and applies tab transform',
+ activeTab => {
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ queriedConfig: builderConfig,
+ queriedSourceId: logSource.id,
+ tableSource: logSource,
+ activeTab,
+ });
+
+ expect(result).toBeDefined();
+ },
+ );
+
+ it('falls back to chartConfig when queriedSource does not match', () => {
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ queriedConfig: builderConfig,
+ queriedSourceId: 'other-source',
+ tableSource: logSource,
+ chartConfig: { ...savedChartConfig, source: logSource.id },
+ });
+
+ expect(result).toBeDefined();
+ // Should use tableSource fields since chartConfig.source matches
+ expect(result!.connection).toBe(logSource.connection);
+ });
+
+ it('returns config as-is for unrecognized activeTab', () => {
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ queriedConfig: builderConfig,
+ queriedSourceId: logSource.id,
+ tableSource: logSource,
+ activeTab: 'search',
+ });
+
+ expect(result).toBeDefined();
+ // @ts-expect-error union types..
+ expect(result!.select).toEqual(builderConfig.select);
+ });
+
+ it('returns undefined when config is raw SQL after resolution', () => {
+ // queriedConfig is builder but after source mismatch, falls back to
+ // chartConfig which is raw SQL
+ const result = buildChartConfigForExplanations({
+ ...baseParams,
+ queriedConfig: undefined,
+ tableSource: logSource,
+ chartConfig: rawSqlSavedChartConfig,
+ });
+
+ // Raw SQL saved config is handled early, returns with dateRange
+ expect(result).toBeDefined();
+ expect(result!.dateRange).toBe(dateRange);
+ });
+});
diff --git a/packages/app/src/components/DBEditTimeChartForm/index.ts b/packages/app/src/components/DBEditTimeChartForm/index.ts
new file mode 100644
index 00000000..6e070af1
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/index.ts
@@ -0,0 +1 @@
+export { default } from './EditTimeChartForm';
diff --git a/packages/app/src/components/DBEditTimeChartForm/utils.ts b/packages/app/src/components/DBEditTimeChartForm/utils.ts
new file mode 100644
index 00000000..40cf487a
--- /dev/null
+++ b/packages/app/src/components/DBEditTimeChartForm/utils.ts
@@ -0,0 +1,234 @@
+import z from 'zod';
+import {
+ isBuilderChartConfig,
+ isRawSqlChartConfig,
+ isRawSqlSavedChartConfig,
+} from '@hyperdx/common-utils/dist/guards';
+import {
+ ChartAlertBaseSchema,
+ ChartConfigWithDateRange,
+ ChartConfigWithOptTimestamp,
+ DisplayType,
+ Filter,
+ SavedChartConfig,
+ SelectList,
+ SourceKind,
+ TSource,
+ validateAlertScheduleOffsetMinutes,
+} from '@hyperdx/common-utils/dist/types';
+
+import {
+ convertToNumberChartConfig,
+ convertToPieChartConfig,
+ convertToTableChartConfig,
+ convertToTimeChartConfig,
+} from '@/ChartUtils';
+import { ChartEditorFormState } from '@/components/ChartEditor/types';
+import { getFirstTimestampValueExpression } from '@/source';
+import {
+ extendDateRangeToInterval,
+ intervalToGranularity,
+} from '@/utils/alerts';
+
+export const isQueryReady = (
+ queriedConfig: ChartConfigWithDateRange | undefined,
+) => {
+ if (!queriedConfig) return false;
+ if (isRawSqlChartConfig(queriedConfig)) {
+ return !!(queriedConfig.sqlTemplate && queriedConfig.connection);
+ }
+ return (
+ ((queriedConfig.select?.length ?? 0) > 0 ||
+ typeof queriedConfig.select === 'string') &&
+ queriedConfig.from?.databaseName &&
+ // tableName is empty for metric sources
+ (queriedConfig.from?.tableName || queriedConfig.metricTables) &&
+ queriedConfig.timestampValueExpression
+ );
+};
+
+export const zSavedChartConfig = z
+ .object({
+ // TODO: Chart
+ alert: ChartAlertBaseSchema.superRefine(
+ validateAlertScheduleOffsetMinutes,
+ ).optional(),
+ })
+ .passthrough();
+
+// similar to seriesToSearchQuery from v1
+export function seriesToFilters(select: SelectList): Filter[] {
+ if (typeof select === 'string') {
+ return [];
+ }
+
+ const filters: Filter[] = select
+ .map(({ aggCondition, aggConditionLanguage }) => {
+ if (aggConditionLanguage != null && aggCondition != null) {
+ return {
+ type: aggConditionLanguage,
+ condition: aggCondition,
+ };
+ } else {
+ return null;
+ }
+ })
+ .filter(f => f != null);
+
+ return filters;
+}
+
+export function displayTypeToActiveTab(displayType: DisplayType): string {
+ switch (displayType) {
+ case DisplayType.Search:
+ return 'search';
+ case DisplayType.Markdown:
+ return 'markdown';
+ case DisplayType.Table:
+ return 'table';
+ case DisplayType.Pie:
+ return 'pie';
+ case DisplayType.Number:
+ return 'number';
+ default:
+ return 'time';
+ }
+}
+
+export const TABS_WITH_GENERATED_SQL = new Set([
+ 'table',
+ 'time',
+ 'number',
+ 'pie',
+]);
+
+export function computeDbTimeChartConfig(
+ queriedConfig: ChartConfigWithDateRange | undefined,
+ alert: ChartEditorFormState['alert'],
+): ChartConfigWithDateRange | undefined {
+ if (!queriedConfig) {
+ return undefined;
+ }
+
+ return {
+ ...queriedConfig,
+ granularity: alert
+ ? intervalToGranularity(alert.interval)
+ : queriedConfig.granularity,
+ dateRange: alert
+ ? extendDateRangeToInterval(queriedConfig.dateRange, alert.interval)
+ : queriedConfig.dateRange,
+ };
+}
+
+export function buildSampleEventsConfig(
+ queriedConfig: ChartConfigWithDateRange | undefined,
+ tableSource: TSource | undefined,
+ dateRange: [Date, Date],
+ queryReady: boolean,
+) {
+ if (
+ tableSource == null ||
+ queriedConfig == null ||
+ !isBuilderChartConfig(queriedConfig) ||
+ !queryReady
+ ) {
+ return null;
+ }
+
+ return {
+ ...queriedConfig,
+ orderBy: [
+ {
+ ordering: 'DESC' as const,
+ valueExpression: getFirstTimestampValueExpression(
+ tableSource.timestampValueExpression,
+ ),
+ },
+ ],
+ dateRange,
+ timestampValueExpression: tableSource.timestampValueExpression,
+ connection: tableSource.connection,
+ from: tableSource.from,
+ limit: { limit: 200 },
+ select:
+ ((tableSource.kind === SourceKind.Log ||
+ tableSource.kind === SourceKind.Trace) &&
+ tableSource.defaultTableSelectExpression) ||
+ '',
+ filters: seriesToFilters(queriedConfig.select),
+ filtersLogicalOperator: 'OR' as const,
+ groupBy: undefined,
+ granularity: undefined,
+ having: undefined,
+ };
+}
+
+type BuildChartConfigForExplanationsParams = {
+ queriedConfig?: ChartConfigWithDateRange;
+ queriedSourceId?: string;
+ tableSource?: TSource;
+ chartConfig: SavedChartConfig;
+ dateRange: [Date, Date];
+ activeTab: string;
+ dbTimeChartConfig?: ChartConfigWithDateRange;
+};
+
+export function buildChartConfigForExplanations({
+ queriedConfig,
+ queriedSourceId,
+ tableSource,
+ chartConfig,
+ dateRange,
+ activeTab,
+ dbTimeChartConfig,
+}: BuildChartConfigForExplanationsParams):
+ | ChartConfigWithOptTimestamp
+ | undefined {
+ if (queriedConfig && isRawSqlChartConfig(queriedConfig))
+ return { ...queriedConfig, dateRange };
+
+ if (chartConfig && isRawSqlSavedChartConfig(chartConfig))
+ return { ...chartConfig, dateRange };
+
+ const userHasSubmittedQuery = !!queriedConfig;
+ const queriedSourceMatchesSelectedSource =
+ queriedSourceId === tableSource?.id;
+ const urlParamsSourceMatchesSelectedSource =
+ chartConfig.source === tableSource?.id;
+
+ const effectiveQueriedConfig =
+ activeTab === 'time' ? dbTimeChartConfig : queriedConfig;
+
+ const config =
+ userHasSubmittedQuery && queriedSourceMatchesSelectedSource
+ ? effectiveQueriedConfig
+ : chartConfig && urlParamsSourceMatchesSelectedSource && tableSource
+ ? {
+ ...chartConfig,
+ dateRange,
+ timestampValueExpression: tableSource.timestampValueExpression,
+ from: tableSource.from,
+ connection: tableSource.connection,
+ }
+ : undefined;
+
+ if (!config || isRawSqlChartConfig(config)) {
+ return undefined;
+ }
+
+ // Apply the transformations that child components will apply,
+ // so that the MV optimization explanation and generated SQL preview
+ // are accurate.
+ if (activeTab === 'time') {
+ return convertToTimeChartConfig(config);
+ } else if (activeTab === 'number') {
+ return convertToNumberChartConfig(config);
+ } else if (activeTab === 'table') {
+ return convertToTableChartConfig(config);
+ } else if (activeTab === 'pie') {
+ return convertToPieChartConfig(config);
+ }
+
+ return config;
+}
diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx
index f548a3b7..a5c0986d 100644
--- a/packages/app/src/components/DBHeatmapChart.tsx
+++ b/packages/app/src/components/DBHeatmapChart.tsx
@@ -28,7 +28,7 @@ import { isAggregateFunction, timeBucketByGranularity } from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { NumberFormat } from '@/types';
import { FormatTime } from '@/useFormatTime';
-import { formatNumber } from '@/utils';
+import { formatDurationMs, formatNumber } from '@/utils';
import ChartContainer from './charts/ChartContainer';
import { SQLPreview } from './ChartSQLPreview';
@@ -843,24 +843,12 @@ function Heatmap({
// to the actual value before formatting.
const actualValue = scaleType === 'log' ? Math.exp(value) : value;
- if (numberFormat?.unit === 'ms') {
- // Auto-scale duration: ms → s → min, picking the most compact unit
- const abs = Math.abs(actualValue);
- if (abs >= 60_000) {
- const v = actualValue / 60_000;
- return `${Number.isInteger(v) ? v : v.toFixed(1)}m`;
- }
- if (abs >= 1_000) {
- const v = actualValue / 1_000;
- return `${Number.isInteger(v) ? v : v.toFixed(1)}s`;
- }
- if (abs >= 1) {
- return `${Math.round(actualValue)}ms`;
- }
- if (abs >= 0.001) {
- return `${+(actualValue * 1_000).toPrecision(2)}µs`;
- }
- return `${actualValue.toPrecision(2)}ms`;
+ if (numberFormat?.unit === 'ms' || numberFormat?.output === 'duration') {
+ const msValue =
+ numberFormat?.output === 'duration'
+ ? actualValue * (numberFormat?.factor ?? 1) * 1000
+ : actualValue;
+ return formatDurationMs(msValue);
}
return numberFormat
diff --git a/packages/app/src/components/DBListBarChart.tsx b/packages/app/src/components/DBListBarChart.tsx
index 6c477d71..d541ef40 100644
--- a/packages/app/src/components/DBListBarChart.tsx
+++ b/packages/app/src/components/DBListBarChart.tsx
@@ -8,7 +8,7 @@ import { Box, Code, Flex, HoverCard, Text } from '@mantine/core';
import { buildMVDateRangeIndicator } from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
-import { useSource } from '@/source';
+import { useResolvedNumberFormat, useSource } from '@/source';
import type { NumberFormat } from '@/types';
import { omit } from '@/utils';
import { formatNumber, semanticKeyedColor } from '@/utils';
@@ -213,6 +213,8 @@ export default function DBListBarChart({
const { data: source } = useSource({ id: config.source });
+ const resolvedNumberFormat = useResolvedNumberFormat(config);
+
const columns = useMemo(() => {
const rows = data?.data ?? [];
if (rows.length === 0) {
@@ -224,9 +226,9 @@ export default function DBListBarChart({
.map(key => ({
dataKey: key,
displayName: key,
- numberFormat: config.numberFormat,
+ numberFormat: resolvedNumberFormat,
}));
- }, [config.numberFormat, data, hiddenSeries]);
+ }, [resolvedNumberFormat, data, hiddenSeries]);
const toolbarItemsMemo = useMemo(() => {
const allToolbarItems = [];
diff --git a/packages/app/src/components/DBNumberChart.tsx b/packages/app/src/components/DBNumberChart.tsx
index 5d46aed1..4e3f6f42 100644
--- a/packages/app/src/components/DBNumberChart.tsx
+++ b/packages/app/src/components/DBNumberChart.tsx
@@ -19,7 +19,7 @@ import {
} from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
-import { useSource } from '@/source';
+import { useResolvedNumberFormat, useSource } from '@/source';
import { formatNumber } from '@/utils';
import ChartContainer from './charts/ChartContainer';
@@ -81,10 +81,12 @@ export default function DBNumberChart({
)
: error;
+ const resolvedNumberFormat = useResolvedNumberFormat(config);
+
const value = valueColumn
? data?.data?.[0]?.[valueColumn.name]
: (Object.values(data?.data?.[0] ?? {})?.[0] ?? Number.NaN);
- const formattedValue = formatNumber(value as number, config.numberFormat);
+ const formattedValue = formatNumber(value as number, resolvedNumberFormat);
const { data: source } = useSource({
id: config.source,
diff --git a/packages/app/src/components/DBPieChart.tsx b/packages/app/src/components/DBPieChart.tsx
index 54e0be42..e2821cfe 100644
--- a/packages/app/src/components/DBPieChart.tsx
+++ b/packages/app/src/components/DBPieChart.tsx
@@ -14,7 +14,7 @@ import {
} from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
-import { useSource } from '@/source';
+import { useResolvedNumberFormat, useSource } from '@/source';
import type { NumberFormat } from '@/types';
import { getColorProps } from '@/utils';
@@ -74,6 +74,8 @@ export const DBPieChart = ({
id: config.source,
});
+ const resolvedNumberFormat = useResolvedNumberFormat(config);
+
const queriedConfig = useMemo(() => {
return isBuilderChartConfig(config)
? convertToPieChartConfig(config)
@@ -188,7 +190,9 @@ export const DBPieChart = ({
))}
}
+ content={
+
+ }
/>
diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx
index adf027db..6119936c 100644
--- a/packages/app/src/components/DBRowSidePanel.tsx
+++ b/packages/app/src/components/DBRowSidePanel.tsx
@@ -506,9 +506,11 @@ const DBRowSidePanel = ({
)}
>
-