diff --git a/cspell.config.json b/cspell.config.json index e75694ee6..33483451f 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -78,8 +78,9 @@ "timerange", "timeseries", "timespan", - "TLSCA", "toggleTip", + "TLSCA", + "TLSSSL", "Toggletip", "totime", "traceid", diff --git a/src/components/configEditor/AliasTableConfig.tsx b/src/components/configEditor/AliasTableConfig.tsx index 0d64c179d..cb4d7442c 100644 --- a/src/components/configEditor/AliasTableConfig.tsx +++ b/src/components/configEditor/AliasTableConfig.tsx @@ -1,10 +1,11 @@ import React, { ChangeEvent, useState } from 'react'; import { ConfigSection } from 'components/experimental/ConfigSection'; -import { Input, Field, HorizontalGroup, Button } from '@grafana/ui'; +import { Input, Field, Stack, Button } from '@grafana/ui'; import { AliasTableEntry } from 'types/config'; import allLabels from 'labels'; import { styles } from 'styles'; import { selectors as allSelectors } from 'selectors'; +import { trackClickhouseConfigV2ColumnAliasTableAdded } from 'views/config-v2/tracking'; interface AliasTablesConfigProps { aliasTables?: AliasTableEntry[]; @@ -90,7 +91,10 @@ export const AliasTableConfig = (props: AliasTablesConfigProps) => { icon="plus-circle" variant="secondary" size="sm" - onClick={addEntry} + onClick={() => { + addEntry(); + trackClickhouseConfigV2ColumnAliasTableAdded(); + }} className={styles.Common.smallBtn} > {labels.addTableLabel} @@ -123,7 +127,7 @@ const AliasTableEditor = (props: AliasTableEditorProps) => { return (
- + { onClick={onRemove} /> )} - +
); }; diff --git a/src/components/configEditor/HttpHeadersConfig.tsx b/src/components/configEditor/HttpHeadersConfig.tsx index 37c831bae..165c83215 100644 --- a/src/components/configEditor/HttpHeadersConfig.tsx +++ b/src/components/configEditor/HttpHeadersConfig.tsx @@ -1,6 +1,6 @@ import React, { ChangeEvent, useMemo, useState } from 'react'; import { ConfigSection } from 'components/experimental/ConfigSection'; -import { Input, Field, HorizontalGroup, Switch, SecretInput, Button } from '@grafana/ui'; +import { Input, Field, Stack, Switch, SecretInput, Button } from '@grafana/ui'; import { CHHttpHeader } from 'types/config'; import allLabels from 'labels'; import { styles } from 'styles'; @@ -113,8 +113,6 @@ const HttpHeaderEditor = (props: HttpHeaderEditorProps) => { { const headerValueLabel = secure ? labels.secureHeaderValueLabel : labels.insecureHeaderValueLabel; return (
- + { onClick={onRemove} /> )} - +
); }; diff --git a/src/views/CHConfigEditor.tsx b/src/views/CHConfigEditor.tsx index a2dd344df..015743caf 100644 --- a/src/views/CHConfigEditor.tsx +++ b/src/views/CHConfigEditor.tsx @@ -25,7 +25,7 @@ import { QuerySettingsConfig } from 'components/configEditor/QuerySettingsConfig import { LogsConfig } from 'components/configEditor/LogsConfig'; import { TracesConfig } from 'components/configEditor/TracesConfig'; import { HttpHeadersConfig } from 'components/configEditor/HttpHeadersConfig'; -import allLabels from 'labels'; +import allLabels from '../labels'; import { onHttpHeadersChange, useConfigDefaults } from './CHConfigEditorHooks'; import { AliasTableConfig } from '../components/configEditor/AliasTableConfig'; import * as trackingV1 from './trackingV1'; diff --git a/src/views/config-v2/AdditionalSettingsSection.tsx b/src/views/config-v2/AdditionalSettingsSection.tsx new file mode 100644 index 000000000..3a36b98e0 --- /dev/null +++ b/src/views/config-v2/AdditionalSettingsSection.tsx @@ -0,0 +1,290 @@ +import { ConfigSubSection } from 'components/experimental/ConfigSection'; +import allLabels from './labelsV2'; +import React, { ChangeEvent, useState } from 'react'; +import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOption } from '@grafana/data'; +import { AliasTableEntry, CHConfig, CHCustomSetting, CHLogsConfig, CHSecureConfig, CHTracesConfig } from 'types/config'; +import { AliasTableConfig } from 'components/configEditor/AliasTableConfig'; +import { DefaultDatabaseTableConfig } from 'components/configEditor/DefaultDatabaseTableConfig'; +import { LogsConfig } from 'components/configEditor/LogsConfig'; +import { QuerySettingsConfig } from 'components/configEditor/QuerySettingsConfig'; +import { TracesConfig } from 'components/configEditor/TracesConfig'; +import { config } from '@grafana/runtime'; +import { TimeUnit } from 'types/queryBuilder'; +import { useConfigDefaults } from 'views/CHConfigEditorHooks'; +import { gte as versionGte } from 'semver'; +import { + Field, + Divider, + Stack, + Input, + Button, + Switch, + Box, + CollapsableSection, + Text, + Badge, + useStyles2, +} from '@grafana/ui'; +import { CONFIG_SECTION_HEADERS, CONTAINER_MIN_WIDTH } from './constants'; +import { + trackClickhouseConfigV2CustomSettingClicked, + trackClickhouseConfigV2DefaultDbInput, + trackClickhouseConfigV2DefaultTableInput, + trackClickhouseConfigV2EnableRowLimitToggle, + trackClickhouseConfigV2LogsConfig, + trackClickhouseConfigV2QuerySettings, + trackClickhouseConfigV2TracesConfig, +} from './tracking'; +import { css } from '@emotion/css'; + +export interface Props extends DataSourcePluginOptionsEditorProps {} + +export const AdditionalSettingsSection = (props: Props) => { + const { options, onOptionsChange } = props; + const { jsonData } = options; + const labels = allLabels.components.Config.ConfigEditor; + const styles = useStyles2(getStyles); + + useConfigDefaults(options, onOptionsChange); + + const [customSettings, setCustomSettings] = useState(jsonData.customSettings || []); + + const onLogsConfigChange = (key: keyof CHLogsConfig, value: string | boolean | string[]) => { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + logs: { + ...options.jsonData.logs, + [key]: value, + }, + }, + }); + }; + + const onUpdateLogsConfig = (key: keyof CHLogsConfig, value: string | boolean | string[]) => { + trackClickhouseConfigV2LogsConfig({ [key]: value }); + onLogsConfigChange(key, value); + }; + + const onTracesConfigChange = (key: keyof CHTracesConfig, value: string | boolean) => { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + traces: { + ...options.jsonData.traces, + durationUnit: options.jsonData.traces?.durationUnit || TimeUnit.Nanoseconds, + [key]: value, + }, + }, + }); + }; + + const onUpdateTracesConfig = (key: keyof CHTracesConfig, value: string | boolean) => { + trackClickhouseConfigV2TracesConfig({ [key]: value }); + onTracesConfigChange(key, value); + }; + + const onAliasTableConfigChange = (aliasTables: AliasTableEntry[]) => { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + aliasTables, + }, + }); + }; + + const onCustomSettingsChange = (customSettings: CHCustomSetting[]) => { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + customSettings: customSettings.filter((s) => !!s.setting && !!s.value), + }, + }); + }; + + return ( + + + 4. {CONFIG_SECTION_HEADERS[3].label} + + + } + isOpen={!!CONFIG_SECTION_HEADERS[3].isOpen} + > + { + trackClickhouseConfigV2DefaultDbInput(); + onUpdateDatasourceJsonDataOption(props, 'defaultDatabase')(e); + }} + onDefaultTableChange={(e) => { + trackClickhouseConfigV2DefaultTableInput(); + onUpdateDatasourceJsonDataOption(props, 'defaultTable')(e); + }} + /> + + { + trackClickhouseConfigV2QuerySettings({ dialTimeout: Number(e.currentTarget.value) }); + onUpdateDatasourceJsonDataOption(props, 'dialTimeout')(e); + }} + onQueryTimeoutChange={(e) => { + trackClickhouseConfigV2QuerySettings({ queryTimeout: Number(e.currentTarget.value) }); + onUpdateDatasourceJsonDataOption(props, 'queryTimeout')(e); + }} + onConnMaxLifetimeChange={(e) => { + trackClickhouseConfigV2QuerySettings({ connMaxLifetime: Number(e.currentTarget.value) }); + onUpdateDatasourceJsonDataOption(props, 'connMaxLifetime')(e); + }} + onConnMaxIdleConnsChange={(e) => { + trackClickhouseConfigV2QuerySettings({ maxIdleConns: Number(e.currentTarget.value) }); + onUpdateDatasourceJsonDataOption(props, 'maxIdleConns')(e); + }} + onConnMaxOpenConnsChange={(e) => { + trackClickhouseConfigV2QuerySettings({ maxOpenConns: Number(e.currentTarget.value) }); + onUpdateDatasourceJsonDataOption(props, 'maxOpenConns')(e); + }} + onValidateSqlChange={(e) => { + trackClickhouseConfigV2QuerySettings({ validateSql: e.currentTarget.checked }); + onUpdateDatasourceJsonDataOption(props, 'validateSql')(e); + }} + /> + + onUpdateLogsConfig('defaultDatabase', db)} + onDefaultTableChange={(table) => onUpdateLogsConfig('defaultTable', table)} + onOtelEnabledChange={(v) => onUpdateLogsConfig('otelEnabled', v)} + onOtelVersionChange={(v) => onUpdateLogsConfig('otelVersion', v)} + onTimeColumnChange={(c) => onUpdateLogsConfig('timeColumn', c)} + onLevelColumnChange={(c) => onUpdateLogsConfig('levelColumn', c)} + onMessageColumnChange={(c) => onUpdateLogsConfig('messageColumn', c)} + onSelectContextColumnsChange={(c) => onUpdateLogsConfig('selectContextColumns', c)} + onContextColumnsChange={(c) => onUpdateLogsConfig('contextColumns', c)} + /> + + + onUpdateTracesConfig('defaultDatabase', db)} + onDefaultTableChange={(table) => onUpdateTracesConfig('defaultTable', table)} + onOtelEnabledChange={(v) => onUpdateTracesConfig('otelEnabled', v)} + onOtelVersionChange={(v) => onUpdateTracesConfig('otelVersion', v)} + onTraceIdColumnChange={(c) => onUpdateTracesConfig('traceIdColumn', c)} + onSpanIdColumnChange={(c) => onUpdateTracesConfig('spanIdColumn', c)} + onOperationNameColumnChange={(c) => onUpdateTracesConfig('operationNameColumn', c)} + onParentSpanIdColumnChange={(c) => onUpdateTracesConfig('parentSpanIdColumn', c)} + onServiceNameColumnChange={(c) => onUpdateTracesConfig('serviceNameColumn', c)} + onDurationColumnChange={(c) => onUpdateTracesConfig('durationColumn', c)} + onDurationUnitChange={(c) => onUpdateTracesConfig('durationUnit', c)} + onStartTimeColumnChange={(c) => onUpdateTracesConfig('startTimeColumn', c)} + onTagsColumnChange={(c) => onUpdateTracesConfig('tagsColumn', c)} + onServiceTagsColumnChange={(c) => onUpdateTracesConfig('serviceTagsColumn', c)} + onKindColumnChange={(c) => onUpdateTracesConfig('kindColumn', c)} + onStatusCodeColumnChange={(c) => onUpdateTracesConfig('statusCodeColumn', c)} + onStatusMessageColumnChange={(c) => onUpdateTracesConfig('statusMessageColumn', c)} + onStateColumnChange={(c) => onUpdateTracesConfig('stateColumn', c)} + onInstrumentationLibraryNameColumnChange={(c) => onUpdateTracesConfig('instrumentationLibraryNameColumn', c)} + onInstrumentationLibraryVersionColumnChange={(c) => + onUpdateTracesConfig('instrumentationLibraryVersionColumn', c) + } + onFlattenNestedChange={(c) => onUpdateTracesConfig('flattenNested', c)} + onEventsColumnPrefixChange={(c) => onUpdateTracesConfig('traceEventsColumnPrefix', c)} + onLinksColumnPrefixChange={(c) => onUpdateTracesConfig('traceLinksColumnPrefix', c)} + /> + + + + + { + trackClickhouseConfigV2EnableRowLimitToggle({ rowLimitEnabled: e.currentTarget.checked }); + onUpdateDatasourceJsonDataOption(props, 'enableRowLimit')(e); + }} + /> + + {config.secureSocksDSProxyEnabled && versionGte(config.buildInfo.version, '10.0.0') && ( + + onUpdateDatasourceJsonDataOption(props, 'enableSecureSocksProxy')(e)} + /> + + )} + + {customSettings.map(({ setting, value }, i) => { + return ( + + + ) => { + let newSettings = customSettings.concat(); + newSettings[i] = { setting: changeEvent.target.value, value }; + setCustomSettings(newSettings); + }} + onBlur={() => onCustomSettingsChange(customSettings)} + > + + + ) => { + let newSettings = customSettings.concat(); + newSettings[i] = { setting, value: changeEvent.target.value }; + setCustomSettings(newSettings); + }} + onBlur={() => { + onCustomSettingsChange(customSettings); + }} + > + + + ); + })} + + + + + ); +}; + +const getStyles = () => ({ + badge: css({ + marginLeft: 'auto', + }), +}); diff --git a/src/views/config-v2/AliasTableConfigV2.test.tsx b/src/views/config-v2/AliasTableConfigV2.test.tsx new file mode 100644 index 000000000..e7df173cb --- /dev/null +++ b/src/views/config-v2/AliasTableConfigV2.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { AliasTableConfigV2 } from './AliasTableConfigV2'; +import { selectors as allSelectors } from 'selectors'; +import { AliasTableEntry } from 'types/config'; + +describe('AliasTableConfig', () => { + const selectors = allSelectors.components.Config.AliasTableConfig; + + it('should render', () => { + const result = render( {}} />); + expect(result.container.firstChild).not.toBeNull(); + }); + + it('should not call onAliasTablesChange when entry is added', () => { + const onAliasTablesChange = jest.fn(); + const result = render(); + expect(result.container.firstChild).not.toBeNull(); + + const addEntryButton = result.getByTestId(selectors.addEntryButton); + expect(addEntryButton).toBeInTheDocument(); + fireEvent.click(addEntryButton); + + expect(onAliasTablesChange).toHaveBeenCalledTimes(0); + }); + + it('should call onAliasTablesChange when entry is updated', () => { + const onAliasTablesChange = jest.fn(); + const result = render(); + expect(result.container.firstChild).not.toBeNull(); + + const addEntryButton = result.getByTestId(selectors.addEntryButton); + expect(addEntryButton).toBeInTheDocument(); + fireEvent.click(addEntryButton); + + const aliasEditor = result.getByTestId(selectors.aliasEditor); + expect(aliasEditor).toBeInTheDocument(); + + const targetDatabaseInput = result.getByTestId(selectors.targetDatabaseInput); + expect(targetDatabaseInput).toBeInTheDocument(); + fireEvent.change(targetDatabaseInput, { target: { value: 'default ' } }); // with space in name + fireEvent.blur(targetDatabaseInput); + expect(targetDatabaseInput).toHaveValue('default '); + expect(onAliasTablesChange).toHaveBeenCalledTimes(1); + + const targetTableInput = result.getByTestId(selectors.targetTableInput); + expect(targetTableInput).toBeInTheDocument(); + fireEvent.change(targetTableInput, { target: { value: 'query_log' } }); + fireEvent.blur(targetTableInput); + expect(targetTableInput).toHaveValue('query_log'); + expect(onAliasTablesChange).toHaveBeenCalledTimes(2); + + const aliasDatabaseInput = result.getByTestId(selectors.aliasDatabaseInput); + expect(aliasDatabaseInput).toBeInTheDocument(); + fireEvent.change(aliasDatabaseInput, { target: { value: 'default_aliases ' } }); // with space in name + fireEvent.blur(aliasDatabaseInput); + expect(aliasDatabaseInput).toHaveValue('default_aliases '); + expect(onAliasTablesChange).toHaveBeenCalledTimes(3); + + const aliasTableInput = result.getByTestId(selectors.aliasTableInput); + expect(aliasTableInput).toBeInTheDocument(); + fireEvent.change(aliasTableInput, { target: { value: 'query_log_aliases' } }); + fireEvent.blur(aliasTableInput); + expect(aliasTableInput).toHaveValue('query_log_aliases'); + expect(onAliasTablesChange).toHaveBeenCalledTimes(4); + + const expected: AliasTableEntry[] = [ + { + targetDatabase: 'default', // without space in name + targetTable: 'query_log', + aliasDatabase: 'default_aliases', // without space in name + aliasTable: 'query_log_aliases', + }, + ]; + expect(onAliasTablesChange).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it('should call onAliasTablesChange when entry is removed', () => { + const onAliasTablesChange = jest.fn(); + const result = render( + + ); + expect(result.container.firstChild).not.toBeNull(); + + const removeEntryButton = result.getAllByTestId(selectors.removeEntryButton)[0]; // Get 1st + expect(removeEntryButton).toBeInTheDocument(); + fireEvent.click(removeEntryButton); + + const expected: AliasTableEntry[] = [ + { + targetDatabase: '', + targetTable: 'query_log2', + aliasDatabase: '', + aliasTable: 'query_log2_aliases', + }, + ]; + expect(onAliasTablesChange).toHaveBeenCalledTimes(1); + expect(onAliasTablesChange).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); +}); diff --git a/src/views/config-v2/AliasTableConfigV2.tsx b/src/views/config-v2/AliasTableConfigV2.tsx new file mode 100644 index 000000000..eecd965d2 --- /dev/null +++ b/src/views/config-v2/AliasTableConfigV2.tsx @@ -0,0 +1,180 @@ +import React, { ChangeEvent, useState } from 'react'; +import { ConfigSection } from 'components/experimental/ConfigSection'; +import { Input, Field, Stack, Button } from '@grafana/ui'; +import { AliasTableEntry } from 'types/config'; +import allLabels from './labelsV2'; +import { styles } from 'styles'; +import { selectors as allSelectors } from 'selectors'; +import { trackClickhouseConfigV2ColumnAliasTableAdded } from 'views/config-v2/tracking'; + +interface AliasTablesConfigProps { + aliasTables?: AliasTableEntry[]; + onAliasTablesChange: (v: AliasTableEntry[]) => void; +} + +export const AliasTableConfigV2 = (props: AliasTablesConfigProps) => { + const { onAliasTablesChange } = props; + const [entries, setEntries] = useState(props.aliasTables || []); + const labels = allLabels.components.Config.AliasTableConfig; + const selectors = allSelectors.components.Config.AliasTableConfig; + + const entryToUniqueKey = (entry: AliasTableEntry) => + `"${entry.targetDatabase}"."${entry.targetTable}":"${entry.aliasDatabase}"."${entry.aliasTable}"`; + const removeDuplicateEntries = (entries: AliasTableEntry[]): AliasTableEntry[] => { + const duplicateKeys = new Set(); + return entries.filter((entry) => { + const key = entryToUniqueKey(entry); + if (duplicateKeys.has(key)) { + return false; + } + + duplicateKeys.add(key); + return true; + }); + }; + + const addEntry = () => { + setEntries( + removeDuplicateEntries([ + ...entries, + { + targetDatabase: '', + targetTable: '', + aliasDatabase: '', + aliasTable: '', + }, + ]) + ); + }; + const removeEntry = (index: number) => { + let nextEntries: AliasTableEntry[] = entries.slice(); + nextEntries.splice(index, 1); + nextEntries = removeDuplicateEntries(nextEntries); + setEntries(nextEntries); + onAliasTablesChange(nextEntries); + }; + const updateEntry = (index: number, entry: AliasTableEntry) => { + let nextEntries: AliasTableEntry[] = entries.slice(); + entry.targetDatabase = entry.targetDatabase.trim(); + entry.targetTable = entry.targetTable.trim(); + entry.aliasDatabase = entry.aliasDatabase.trim(); + entry.aliasTable = entry.aliasTable.trim(); + nextEntries[index] = entry; + + nextEntries = removeDuplicateEntries(nextEntries); + setEntries(nextEntries); + onAliasTablesChange(nextEntries); + }; + + return ( + +
+ {labels.descriptionParts[0]} + {labels.descriptionParts[1]} + {labels.descriptionParts[2]} +
+
+ + {entries.map((entry, index) => ( + updateEntry(index, e)} + onRemove={() => removeEntry(index)} + /> + ))} + +
+ ); +}; + +interface AliasTableEditorProps { + targetDatabase: string; + targetTable: string; + aliasDatabase: string; + aliasTable: string; + onEntryChange: (v: AliasTableEntry) => void; + onRemove?: () => void; +} + +const AliasTableEditor = (props: AliasTableEditorProps) => { + const { onEntryChange, onRemove } = props; + const [targetDatabase, setTargetDatabase] = useState(props.targetDatabase); + const [targetTable, setTargetTable] = useState(props.targetTable); + const [aliasDatabase, setAliasDatabase] = useState(props.aliasDatabase); + const [aliasTable, setAliasTable] = useState(props.aliasTable); + const labels = allLabels.components.Config.AliasTableConfig; + const selectors = allSelectors.components.Config.AliasTableConfig; + + const onUpdate = () => { + onEntryChange({ targetDatabase, targetTable, aliasDatabase, aliasTable }); + }; + + return ( +
+ + + ) => setTargetDatabase(e.target.value)} + onBlur={() => onUpdate()} + /> + + + ) => setTargetTable(e.target.value)} + onBlur={() => onUpdate()} + /> + + + ) => setAliasDatabase(e.target.value)} + onBlur={() => onUpdate()} + /> + + + ) => setAliasTable(e.target.value)} + onBlur={() => onUpdate()} + /> + + {onRemove && ( +
+ ); +}; diff --git a/src/views/config-v2/CHConfigEditor.tsx b/src/views/config-v2/CHConfigEditor.tsx index dab3b4d5e..3a3ebfd37 100644 --- a/src/views/config-v2/CHConfigEditor.tsx +++ b/src/views/config-v2/CHConfigEditor.tsx @@ -7,7 +7,9 @@ import { css } from '@emotion/css'; import { LeftSidebar } from './LeftSidebar'; import { CONTAINER_MIN_WIDTH } from './constants'; import { trackInfluxDBConfigV2FeedbackButtonClicked } from './tracking'; -import { RemainingConfigCode } from './RemainingConfigCode'; +import { AdditionalSettingsSection } from './AdditionalSettingsSection'; +import { DatabaseCredentialsSection } from './DatabaseCredentialsSection'; +import { TLSSSLSettingsSection } from './TLSSSLSettingsSection'; export interface ConfigEditorProps extends DataSourcePluginOptionsEditorProps {} @@ -17,7 +19,7 @@ export const ConfigEditor: React.FC = (props) => { return ( -
+
@@ -45,7 +47,9 @@ export const ConfigEditor: React.FC = (props) => {
- + + + {/* TODO: Right sidebar */} @@ -62,6 +66,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'none', }, }), + leftSticky: css({ + position: 'sticky', + top: '100px', + alignSelf: 'flex-start', + maxHeight: 'calc(100vh - 100px)', + overflowY: 'auto', + }), requiredFields: css({ marginBottom: theme.spacing(2), }), diff --git a/src/views/config-v2/DatabaseCredentialsSection.test.tsx b/src/views/config-v2/DatabaseCredentialsSection.test.tsx new file mode 100644 index 000000000..17c1f7ac7 --- /dev/null +++ b/src/views/config-v2/DatabaseCredentialsSection.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { DatabaseCredentialsSection } from './DatabaseCredentialsSection'; +import { createTestProps } from './helpers'; + +describe('DatabaseCredentialsSection', () => { + const onOptionsChangeMock = jest.fn(); + let consoleSpy: jest.SpyInstance; + + const defaultProps = createTestProps({ + options: { + jsonData: { + username: '', + }, + secureJsonData: {}, + secureJsonFields: {}, + }, + mocks: { + onOptionsChange: onOptionsChangeMock, + }, + }); + + beforeEach(() => { + // Mock console.error to suppress React act() warnings + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.clearAllMocks(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('renders username and password fields', () => { + render(); + + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + + it('calls onOptionsChange when username is changed', () => { + render(); + + const usernameInput = screen.getByLabelText(/username/i); + fireEvent.change(usernameInput, { target: { value: 'alice' } }); + + expect(onOptionsChangeMock).toHaveBeenCalled(); + const lastArgs = onOptionsChangeMock.mock.lastCall?.[0]; + expect(lastArgs.jsonData?.username).toBe('alice'); + }); + + it('calls onOptionsChange when password is changed', () => { + render(); + + const passwordInput = screen.getByLabelText(/password/i); + fireEvent.change(passwordInput, { target: { value: 'secret' } }); + + expect(onOptionsChangeMock).toHaveBeenCalled(); + + const lastArgs = onOptionsChangeMock.mock.lastCall?.[0]; + expect(lastArgs.secureJsonData?.password).toBe('secret'); + }); + + it('resets password when Reset is clicked (isConfigured=true)', () => { + const configuredProps = createTestProps({ + options: { + jsonData: { + username: 'bob', + }, + secureJsonData: { + password: 'configured', + }, + secureJsonFields: { + password: true, + }, + }, + mocks: { + onOptionsChange: onOptionsChangeMock, + }, + }); + + render(); + + const resetButton = screen.getByRole('button', { name: /reset/i }); + fireEvent.click(resetButton); + + expect(onOptionsChangeMock).toHaveBeenCalled(); + + const lastArgs = onOptionsChangeMock.mock.lastCall?.[0]; + expect(lastArgs.secureJsonFields?.password).toBe(false); + expect(lastArgs.secureJsonData?.password).toBe(''); + }); +}); diff --git a/src/views/config-v2/DatabaseCredentialsSection.tsx b/src/views/config-v2/DatabaseCredentialsSection.tsx new file mode 100644 index 000000000..23ba970a7 --- /dev/null +++ b/src/views/config-v2/DatabaseCredentialsSection.tsx @@ -0,0 +1,110 @@ +import { Box, CollapsableSection, Field, Input, SecretInput, Text, TextLink, useStyles2 } from '@grafana/ui'; +import React from 'react'; +import { CONFIG_SECTION_HEADERS, CONTAINER_MIN_WIDTH } from './constants'; +import { + DataSourcePluginOptionsEditorProps, + onUpdateDatasourceJsonDataOption, + onUpdateDatasourceSecureJsonDataOption, +} from '@grafana/data'; +import allLabels from './labelsV2'; +import { CHConfig, CHSecureConfig } from 'types/config'; +import { css } from '@emotion/css'; +import { + trackClickhouseConfigV2DatabaseCredentialsPasswordInput, + trackClickhouseConfigV2DatabaseCredentialsUserInput, +} from './tracking'; + +export interface Props extends DataSourcePluginOptionsEditorProps {} + +export const DatabaseCredentialsSection = (props: Props) => { + const { options, onOptionsChange } = props; + const { jsonData, secureJsonFields } = options; + const secureJsonData = (options.secureJsonData || {}) as CHSecureConfig; + const labels = allLabels.components.Config.ConfigEditor; + const styles = useStyles2(getStyles); + + const onResetPassword = () => { + onOptionsChange({ + ...options, + secureJsonFields: { + ...options.secureJsonFields, + password: false, + }, + secureJsonData: { + ...options.secureJsonData, + password: '', + }, + }); + }; + + return ( + + 2. {CONFIG_SECTION_HEADERS[1].label}} + isOpen={!!CONFIG_SECTION_HEADERS[1].isOpen} + > +
+ + {labels.username.tooltip}{' '} + + a read-only user + + + } + required + > + + + + + +
+
+
+ ); +}; + +const getStyles = () => ({ + credentialsSection: css({ + display: 'flex', + flexWrap: 'wrap', + gap: '8px', + + '& > div': { + flex: '1 1 300px', + minWidth: 0, + }, + }), +}); diff --git a/src/views/config-v2/HttpHeadersConfigV2.test.tsx b/src/views/config-v2/HttpHeadersConfigV2.test.tsx new file mode 100644 index 000000000..a891a9bf4 --- /dev/null +++ b/src/views/config-v2/HttpHeadersConfigV2.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { HttpHeadersConfigV2 } from './HttpHeadersConfigV2'; +import { selectors } from 'selectors'; + +describe('HttpHeadersConfigV2', () => { + const onHttpHeadersChange = jest.fn(); + const onForwardGrafanaHeadersChange = jest.fn(); + let consoleSpy: jest.SpyInstance; + + const renderWith = (overrides?: Partial>) => { + const props: React.ComponentProps = { + headers: [], + forwardGrafanaHeaders: false, + secureFields: {}, + onHttpHeadersChange, + onForwardGrafanaHeadersChange, + ...(overrides || {}), + }; + return render(); + }; + + beforeEach(() => { + // Mock console.error to suppress React act() warnings + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.clearAllMocks(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('renders top label, Add header button, and forward checkbox', () => { + renderWith(); + + expect(screen.getByText(/custom http headers/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add header/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/forward grafana http headers to data source/i)).toBeInTheDocument(); + }); + + it('adds a new header editor when Add header is clicked', () => { + renderWith(); + + const before = screen.queryAllByTestId(selectors.components.Config.HttpHeaderConfig.headerEditor).length; + fireEvent.click(screen.getByTestId(selectors.components.Config.HttpHeaderConfig.addHeaderButton)); + const after = screen.getAllByTestId(selectors.components.Config.HttpHeaderConfig.headerEditor).length; + + expect(after).toBe(before + 1); + expect(onHttpHeadersChange).not.toHaveBeenCalled(); + }); + + it('renders any initial headers passed in', () => { + renderWith({ + headers: [ + { name: 'X-Auth', value: 'abc', secure: false }, + { name: 'Foo', value: 'bar', secure: true }, + ], + }); + + const editors = screen.getAllByTestId(selectors.components.Config.HttpHeaderConfig.headerEditor); + expect(editors.length).toBe(2); + expect(screen.getAllByTestId(selectors.components.Config.HttpHeaderConfig.headerNameInput)[0]).toHaveValue( + 'X-Auth' + ); + expect(screen.getAllByTestId(selectors.components.Config.HttpHeaderConfig.headerNameInput)[1]).toHaveValue('Foo'); + }); + + it('removes a header and calls onHttpHeadersChange when Remove is clicked', () => { + renderWith({ + headers: [ + { name: 'A', value: '1', secure: false }, + { name: 'B', value: '2', secure: false }, + ], + }); + + const before = screen.getAllByTestId(selectors.components.Config.HttpHeaderConfig.headerEditor).length; + const removeButtons = screen.getAllByTestId('trash-alt'); + fireEvent.click(removeButtons[0]); + + expect(onHttpHeadersChange).toHaveBeenCalled(); + const next = onHttpHeadersChange.mock.lastCall?.[0]; + expect(next.length).toBe(before - 1); + expect(next.find((h: any) => h.name === 'A')).toBeUndefined(); + }); + + it('toggles "Forward Grafana headers" and calls onForwardGrafanaHeadersChange', () => { + renderWith({ forwardGrafanaHeaders: false }); + + const forwardCb = screen.getByLabelText(/forward grafana http headers to data source/i) as HTMLInputElement; + fireEvent.click(forwardCb); + + expect(onForwardGrafanaHeadersChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/views/config-v2/HttpHeadersConfigV2.tsx b/src/views/config-v2/HttpHeadersConfigV2.tsx new file mode 100644 index 000000000..455b4a0fb --- /dev/null +++ b/src/views/config-v2/HttpHeadersConfigV2.tsx @@ -0,0 +1,188 @@ +import React, { ChangeEvent, useMemo, useState } from 'react'; +import { Input, Field, SecretInput, Button, Stack, Checkbox, Box } from '@grafana/ui'; +import { CHHttpHeader } from 'types/config'; +import allLabels from './labelsV2'; +import { styles } from 'styles'; +import { selectors as allSelectors } from 'selectors'; +import { KeyValue } from '@grafana/data'; + +interface HttpHeadersConfigProps { + headers?: CHHttpHeader[]; + forwardGrafanaHeaders?: boolean; + secureFields: KeyValue; + onHttpHeadersChange: (v: CHHttpHeader[]) => void; + onForwardGrafanaHeadersChange: (v: boolean) => void; +} + +export const HttpHeadersConfigV2 = (props: HttpHeadersConfigProps) => { + const { secureFields, onHttpHeadersChange } = props; + const configuredSecureHeaders = useConfiguredSecureHttpHeaders(secureFields); + const [headers, setHeaders] = useState(props.headers || []); + const [forwardGrafanaHeaders, setForwardGrafanaHeaders] = useState(props.forwardGrafanaHeaders || false); + const labels = allLabels.components.Config.HttpHeadersConfig; + const selectors = allSelectors.components.Config.HttpHeaderConfig; + + const addHeader = () => setHeaders([...headers, { name: '', value: '', secure: false }]); + + const removeHeader = (index: number) => { + const nextHeaders: CHHttpHeader[] = [...headers.slice(0, index), ...headers.slice(index + 1)]; + setHeaders(nextHeaders); + onHttpHeadersChange(nextHeaders); + }; + + const updateHeader = (index: number, header: CHHttpHeader) => { + const nextHeaders: CHHttpHeader[] = [ + ...headers.slice(0, index), + { ...header, name: header.name.trim() }, + ...headers.slice(index + 1), + ]; + setHeaders(nextHeaders); + onHttpHeadersChange(nextHeaders); + }; + + const updateForwardGrafanaHeaders = (value: boolean) => { + setForwardGrafanaHeaders(value); + props.onForwardGrafanaHeadersChange(value); + }; + + return ( +
+ + <> + {headers.map((header, index) => ( + updateHeader(index, h)} + onRemove={() => removeHeader(index)} + /> + ))} + + + + + {/* Use 'checked' instead of 'value' */} + updateForwardGrafanaHeaders(e.currentTarget.checked)} + /> +
+ ); +}; + +interface HttpHeaderEditorProps { + name: string; + value: string; + secure: boolean; + isSecureConfigured: boolean; + onHeaderChange: (v: CHHttpHeader) => void; + onRemove?: () => void; +} + +const HttpHeaderEditorV2 = (props: HttpHeaderEditorProps) => { + const { onHeaderChange, onRemove } = props; + const [name, setName] = useState(props.name); + const [value, setValue] = useState(props.value); + const [secure, setSecure] = useState(props.secure); + const [isSecureConfigured, setSecureConfigured] = useState(props.isSecureConfigured); + + const labels = allLabels.components.Config.HttpHeadersConfig; + const selectors = allSelectors.components.Config.HttpHeaderConfig; + + const onUpdate = () => { + onHeaderChange({ name, value, secure }); + }; + + const headerValueLabel = secure ? labels.secureHeaderValueLabel : labels.insecureHeaderValueLabel; + + return ( +
+ + + ) => setName(e.target.value)} + onBlur={onUpdate} + /> + + + {/* Avoid 'grow'; use flex prop that Box supports */} + + + {secure ? ( + setSecureConfigured(false)} + onChange={(e: ChangeEvent) => setValue(e.target.value)} + onBlur={onUpdate} + /> + ) : ( + ) => setValue(e.target.value)} + onBlur={onUpdate} + /> + )} + + + + {!isSecureConfigured && ( + // Use 'checked' instead of 'value' + setSecure(e.currentTarget.checked)} + data-testid={selectors.forwardGrafanaHeadersSwitch} + /> + )} + + {onRemove && ( +
+ ); +}; + +/** + * Returns a Set of all secured headers that are configured + */ +export const useConfiguredSecureHttpHeaders = (secureJsonFields: KeyValue): Set => { + return useMemo(() => { + const secureHeaders = new Set(); + for (const key in secureJsonFields) { + if (key.startsWith('secureHttpHeaders.') && secureJsonFields[key]) { + secureHeaders.add(key.substring(key.indexOf('.') + 1)); + } + } + return secureHeaders; + }, [secureJsonFields]); +}; diff --git a/src/views/config-v2/HttpProtocolSettingsSection.test.tsx b/src/views/config-v2/HttpProtocolSettingsSection.test.tsx index 814e4eced..b981360ee 100644 --- a/src/views/config-v2/HttpProtocolSettingsSection.test.tsx +++ b/src/views/config-v2/HttpProtocolSettingsSection.test.tsx @@ -4,7 +4,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { HttpProtocolSettingsSection } from './HttpProtocolSettingsSection'; import { createTestProps } from './helpers'; import { Protocol } from 'types/config'; -import { selectors as allSelectors } from 'selectors'; describe('HttpProtocolSettingsSection', () => { const onOptionsChangeMock = jest.fn(); @@ -64,28 +63,10 @@ describe('HttpProtocolSettingsSection', () => { it('toggles Optional HTTP settings open/closed via the button (icon changes)', () => { render(); - // Closed by default - angle-right icon visible expect(screen.getByTestId('angle-right')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /optional http settings/i })); - // Now open - angle-down icon visible expect(screen.getByTestId('angle-down')).toBeInTheDocument(); }); - - it('calls onSwitchToggle with forwardGrafanaHeaders=true when toggled', () => { - const onSwitchToggleMock = jest.fn(); - - render(); - - fireEvent.click(screen.getByRole('button', { name: /optional http settings/i })); - - const forwardHeadersSwitch = screen.getByTestId( - allSelectors.components.Config.HttpHeaderConfig.forwardGrafanaHeadersSwitch - ); - - fireEvent.click(forwardHeadersSwitch); - - expect(onSwitchToggleMock).toHaveBeenLastCalledWith('forwardGrafanaHeaders', true); - }); }); diff --git a/src/views/config-v2/HttpProtocolSettingsSection.tsx b/src/views/config-v2/HttpProtocolSettingsSection.tsx index 11c5f81c6..b62367113 100644 --- a/src/views/config-v2/HttpProtocolSettingsSection.tsx +++ b/src/views/config-v2/HttpProtocolSettingsSection.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOption } from '@grafana/data'; import { Field, Input, Button, useTheme2 } from '@grafana/ui'; -import { HttpHeadersConfig } from 'components/configEditor/HttpHeadersConfig'; -import allLabels from './labels'; +import allLabels from './labelsV2'; import { CHConfig, CHSecureConfig, Protocol } from 'types/config'; import { css } from '@emotion/css'; import { onHttpHeadersChange } from 'views/CHConfigEditorHooks'; +import { HttpHeadersConfigV2 } from './HttpHeadersConfigV2'; export interface HttpProtocolSettingsSectionProps extends DataSourcePluginOptionsEditorProps { onSwitchToggle: ( @@ -53,7 +53,7 @@ export const HttpProtocolSettingsSection = (props: HttpProtocolSettingsSectionPr Optional HTTP settings {optionalHttpIsOpen && ( - { }); }); - it('calls scrollIntoView when link is clicked', () => { + it('scrolls to the target with offset when link is clicked', () => { render(); const firstHeader = CONFIG_SECTION_HEADERS[0]; - // add a fake target element with scrollIntoView defined const target = document.createElement('div'); target.id = firstHeader.id; - target.scrollIntoView = jest.fn(); + target.getBoundingClientRect = jest.fn(() => ({ top: 200 }) as any); document.body.appendChild(target); + Object.defineProperty(window, 'scrollY', { value: 100, writable: true }); + const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + fireEvent.click(screen.getByText(firstHeader.label)); - expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 240, behavior: 'smooth' }); + scrollToSpy.mockRestore(); document.body.removeChild(target); }); }); diff --git a/src/views/config-v2/LeftSidebar.tsx b/src/views/config-v2/LeftSidebar.tsx index 0c7615270..e4a2b5710 100644 --- a/src/views/config-v2/LeftSidebar.tsx +++ b/src/views/config-v2/LeftSidebar.tsx @@ -2,8 +2,6 @@ import React from 'react'; import { Box, InlineField, LinkButton, Space, Stack, Text, useStyles2 } from '@grafana/ui'; import { CONFIG_SECTION_HEADERS, CONFIG_SECTION_HEADERS_WITH_PDC } from './constants'; import { css } from '@emotion/css'; -import type { GrafanaTheme2 } from '@grafana/data'; - interface LeftSidebarProps { pdcInjected: boolean; } @@ -28,7 +26,8 @@ export const LeftSidebar = ({ pdcInjected }: LeftSidebarProps) => { e.preventDefault(); const target = document.getElementById(header.id); if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const y = target.getBoundingClientRect().top + window.scrollY - 60; + window.scrollTo({ top: y, behavior: 'smooth' }); } }} > @@ -53,7 +52,7 @@ export const LeftSidebar = ({ pdcInjected }: LeftSidebarProps) => { ); }; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = () => ({ inlineField: css({ display: 'flex', alignItems: 'center', diff --git a/src/views/config-v2/RemainingConfigCode.tsx b/src/views/config-v2/RemainingConfigCode.tsx deleted file mode 100644 index 84d300ad7..000000000 --- a/src/views/config-v2/RemainingConfigCode.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { Field, CertificationKey, Switch, Input, SecretInput, Button, Divider, Stack } from '@grafana/ui'; -import { ConfigSection, ConfigSubSection } from 'components/experimental/ConfigSection'; -import allLabels from './labels'; -import React, { ChangeEvent, useState } from 'react'; -import { - DataSourcePluginOptionsEditorProps, - onUpdateDatasourceJsonDataOption, - onUpdateDatasourceSecureJsonDataOption, -} from '@grafana/data'; -import { AliasTableEntry, CHConfig, CHCustomSetting, CHLogsConfig, CHSecureConfig, CHTracesConfig } from 'types/config'; -import { AliasTableConfig } from 'components/configEditor/AliasTableConfig'; -import { DefaultDatabaseTableConfig } from 'components/configEditor/DefaultDatabaseTableConfig'; -import { LogsConfig } from 'components/configEditor/LogsConfig'; -import { QuerySettingsConfig } from 'components/configEditor/QuerySettingsConfig'; -import { TracesConfig } from 'components/configEditor/TracesConfig'; -import { config } from '@grafana/runtime'; -import { TimeUnit } from 'types/queryBuilder'; -import { useConfigDefaults } from 'views/CHConfigEditorHooks'; -import { gte as versionGte } from 'semver'; - -export interface Props extends DataSourcePluginOptionsEditorProps {} - -// This code will be formatted into the new design in future iterations -export const RemainingConfigCode = (props: Props) => { - const { options, onOptionsChange } = props; - const { jsonData, secureJsonFields } = options; - const labels = allLabels.components.Config.ConfigEditor; - const secureJsonData = (options.secureJsonData || {}) as CHSecureConfig; - const hasTLSCACert = secureJsonFields && secureJsonFields.tlsCACert; - const hasTLSClientCert = secureJsonFields && secureJsonFields.tlsClientCert; - const hasTLSClientKey = secureJsonFields && secureJsonFields.tlsClientKey; - - useConfigDefaults(options, onOptionsChange); - - const hasAdditionalSettings = Boolean( - window.location.hash || // if trying to link to section on page, open all settings (React breaks this?) - options.jsonData.defaultDatabase || - options.jsonData.defaultTable || - options.jsonData.dialTimeout || - options.jsonData.queryTimeout || - options.jsonData.validateSql || - options.jsonData.enableSecureSocksProxy || - options.jsonData.customSettings || - options.jsonData.logs || - options.jsonData.traces - ); - - const [customSettings, setCustomSettings] = useState(jsonData.customSettings || []); - - const onSwitchToggle = ( - key: keyof Pick< - CHConfig, - 'secure' | 'validateSql' | 'enableSecureSocksProxy' | 'forwardGrafanaHeaders' | 'enableRowLimit' - >, - value: boolean - ) => { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - [key]: value, - }, - }); - }; - - const onTLSSettingsChange = ( - key: keyof Pick, - value: boolean - ) => { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - [key]: value, - }, - }); - }; - - const onCertificateChangeFactory = (key: keyof Omit, value: string) => { - onOptionsChange({ - ...options, - secureJsonData: { - ...secureJsonData, - [key]: value, - }, - }); - }; - const onResetClickFactory = (key: keyof Omit) => { - onOptionsChange({ - ...options, - secureJsonFields: { - ...secureJsonFields, - [key]: false, - }, - secureJsonData: { - ...secureJsonData, - [key]: '', - }, - }); - }; - - const onResetPassword = () => { - onOptionsChange({ - ...options, - secureJsonFields: { - ...options.secureJsonFields, - password: false, - }, - secureJsonData: { - ...options.secureJsonData, - password: '', - }, - }); - }; - - const onLogsConfigChange = (key: keyof CHLogsConfig, value: string | boolean | string[]) => { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - logs: { - ...options.jsonData.logs, - [key]: value, - }, - }, - }); - }; - - const onTracesConfigChange = (key: keyof CHTracesConfig, value: string | boolean) => { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - traces: { - ...options.jsonData.traces, - durationUnit: options.jsonData.traces?.durationUnit || TimeUnit.Nanoseconds, - [key]: value, - }, - }, - }); - }; - - const onAliasTableConfigChange = (aliasTables: AliasTableEntry[]) => { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - aliasTables, - }, - }); - }; - - const onCustomSettingsChange = (customSettings: CHCustomSetting[]) => { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - customSettings: customSettings.filter((s) => !!s.setting && !!s.value), - }, - }); - }; - - return ( -
- - - onTLSSettingsChange('tlsSkipVerify', e.currentTarget.checked)} - /> - - - onTLSSettingsChange('tlsAuth', e.currentTarget.checked)} - /> - - - onTLSSettingsChange('tlsAuthWithCACert', e.currentTarget.checked)} - /> - - {jsonData.tlsAuthWithCACert && ( - onCertificateChangeFactory('tlsCACert', e.currentTarget.value)} - placeholder={labels.tlsCACert.placeholder} - label={labels.tlsCACert.label} - onClick={() => onResetClickFactory('tlsCACert')} - /> - )} - {jsonData.tlsAuth && ( - <> - onCertificateChangeFactory('tlsClientCert', e.currentTarget.value)} - placeholder={labels.tlsClientCert.placeholder} - label={labels.tlsClientCert.label} - onClick={() => onResetClickFactory('tlsClientCert')} - /> - onCertificateChangeFactory('tlsClientKey', e.currentTarget.value)} - onClick={() => onResetClickFactory('tlsClientKey')} - /> - - )} - - - - - - - - - - - - onUpdateDatasourceJsonDataOption(props, 'defaultDatabase')(e)} - onDefaultTableChange={(e) => onUpdateDatasourceJsonDataOption(props, 'defaultTable')(e)} - /> - - - onUpdateDatasourceJsonDataOption(props, 'dialTimeout')(e)} - onQueryTimeoutChange={(e) => onUpdateDatasourceJsonDataOption(props, 'queryTimeout')(e)} - onConnMaxLifetimeChange={(e) => onUpdateDatasourceJsonDataOption(props, 'connMaxLifetime')(e)} - onConnMaxIdleConnsChange={(e) => onUpdateDatasourceJsonDataOption(props, 'maxIdleConns')(e)} - onConnMaxOpenConnsChange={(e) => onUpdateDatasourceJsonDataOption(props, 'maxOpenConns')(e)} - onValidateSqlChange={(e) => onUpdateDatasourceJsonDataOption(props, 'validateSql')(e)} - /> - - - onLogsConfigChange('defaultDatabase', db)} - onDefaultTableChange={(table) => onLogsConfigChange('defaultTable', table)} - onOtelEnabledChange={(v) => onLogsConfigChange('otelEnabled', v)} - onOtelVersionChange={(v) => onLogsConfigChange('otelVersion', v)} - onTimeColumnChange={(c) => onLogsConfigChange('timeColumn', c)} - onLevelColumnChange={(c) => onLogsConfigChange('levelColumn', c)} - onMessageColumnChange={(c) => onLogsConfigChange('messageColumn', c)} - onSelectContextColumnsChange={(c) => onLogsConfigChange('selectContextColumns', c)} - onContextColumnsChange={(c) => onLogsConfigChange('contextColumns', c)} - /> - - - onTracesConfigChange('defaultDatabase', db)} - onDefaultTableChange={(table) => onTracesConfigChange('defaultTable', table)} - onOtelEnabledChange={(v) => onTracesConfigChange('otelEnabled', v)} - onOtelVersionChange={(v) => onTracesConfigChange('otelVersion', v)} - onTraceIdColumnChange={(c) => onTracesConfigChange('traceIdColumn', c)} - onSpanIdColumnChange={(c) => onTracesConfigChange('spanIdColumn', c)} - onOperationNameColumnChange={(c) => onTracesConfigChange('operationNameColumn', c)} - onParentSpanIdColumnChange={(c) => onTracesConfigChange('parentSpanIdColumn', c)} - onServiceNameColumnChange={(c) => onTracesConfigChange('serviceNameColumn', c)} - onDurationColumnChange={(c) => onTracesConfigChange('durationColumn', c)} - onDurationUnitChange={(c) => onTracesConfigChange('durationUnit', c)} - onStartTimeColumnChange={(c) => onTracesConfigChange('startTimeColumn', c)} - onTagsColumnChange={(c) => onTracesConfigChange('tagsColumn', c)} - onServiceTagsColumnChange={(c) => onTracesConfigChange('serviceTagsColumn', c)} - onKindColumnChange={(c) => onTracesConfigChange('kindColumn', c)} - onStatusCodeColumnChange={(c) => onTracesConfigChange('statusCodeColumn', c)} - onStatusMessageColumnChange={(c) => onTracesConfigChange('statusMessageColumn', c)} - onStateColumnChange={(c) => onTracesConfigChange('stateColumn', c)} - onInstrumentationLibraryNameColumnChange={(c) => onTracesConfigChange('instrumentationLibraryNameColumn', c)} - onInstrumentationLibraryVersionColumnChange={(c) => - onTracesConfigChange('instrumentationLibraryVersionColumn', c) - } - onFlattenNestedChange={(c) => onTracesConfigChange('flattenNested', c)} - onEventsColumnPrefixChange={(c) => onTracesConfigChange('traceEventsColumnPrefix', c)} - onLinksColumnPrefixChange={(c) => onTracesConfigChange('traceLinksColumnPrefix', c)} - /> - - - - - - onSwitchToggle('enableRowLimit', e.currentTarget.checked)} - /> - - {config.secureSocksDSProxyEnabled && versionGte(config.buildInfo.version, '10.0.0') && ( - - onSwitchToggle('enableSecureSocksProxy', e.currentTarget.checked)} - /> - - )} - - {customSettings.map(({ setting, value }, i) => { - return ( - - - ) => { - let newSettings = customSettings.concat(); - newSettings[i] = { setting: changeEvent.target.value, value }; - setCustomSettings(newSettings); - }} - onBlur={() => onCustomSettingsChange(customSettings)} - > - - - ) => { - let newSettings = customSettings.concat(); - newSettings[i] = { setting, value: changeEvent.target.value }; - setCustomSettings(newSettings); - }} - onBlur={() => { - onCustomSettingsChange(customSettings); - }} - > - - - ); - })} - - - -
- ); -}; diff --git a/src/views/config-v2/ServerAndEncryptionSection.tsx b/src/views/config-v2/ServerAndEncryptionSection.tsx index 19b946f86..30a8e9ee1 100644 --- a/src/views/config-v2/ServerAndEncryptionSection.tsx +++ b/src/views/config-v2/ServerAndEncryptionSection.tsx @@ -13,14 +13,15 @@ import { useStyles2, Checkbox, } from '@grafana/ui'; -import allLabels from './labels'; +import allLabels from './labelsV2'; import { CHConfig, CHSecureConfig, Protocol } from 'types/config'; import { CONFIG_SECTION_HEADERS, CONTAINER_MIN_WIDTH, PROTOCOL_OPTIONS } from './constants'; import { css } from '@emotion/css'; import { trackClickhouseConfigV2HostInput, + trackClickhouseConfigV2NativeHttpToggleClicked, trackClickhouseConfigV2PortInput, - trackClickhouseConfigV2SecureConnectionToggleClicked, + trackClickhouseConfigV2SecureConnectionChecked, } from './tracking'; import { HttpProtocolSettingsSection } from './HttpProtocolSettingsSection'; @@ -87,7 +88,7 @@ export const ServerAndEncryptionSection = (props: Props) => { > 1. {CONFIG_SECTION_HEADERS[0].label}} - isOpen={CONFIG_SECTION_HEADERS[0].isOpen} + isOpen={!!CONFIG_SECTION_HEADERS[0].isOpen} > Enter the server address of your Clickhouse instance. Then select your protocol, port and security options. If @@ -100,7 +101,7 @@ export const ServerAndEncryptionSection = (props: Props) => { Grafana docs - + { />
- {labels.protocol.description}}> + {labels.protocol.tooltip}}> options={PROTOCOL_OPTIONS} value={jsonData.protocol || Protocol.Native} - onChange={(e) => onProtocolToggle(e!)} + onChange={(e) => { + trackClickhouseConfigV2NativeHttpToggleClicked({ nativeHttpToggle: e }); + onProtocolToggle(e!); + }} /> @@ -176,10 +180,10 @@ export const ServerAndEncryptionSection = (props: Props) => {
{ - trackClickhouseConfigV2SecureConnectionToggleClicked({ secureConnection: e.currentTarget.checked }); + trackClickhouseConfigV2SecureConnectionChecked({ secureConnection: e.currentTarget.checked }); onSwitchToggle('secure', e.currentTarget.checked); }} /> diff --git a/src/views/config-v2/TLSSSLSettingsSection.test.tsx b/src/views/config-v2/TLSSSLSettingsSection.test.tsx new file mode 100644 index 000000000..b6b7197c4 --- /dev/null +++ b/src/views/config-v2/TLSSSLSettingsSection.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TLSSSLSettingsSection } from './TLSSSLSettingsSection'; +import { createTestProps } from './helpers'; + +describe('TLSSSLSettingsSection', () => { + const onOptionsChangeMock = jest.fn(); + let consoleSpy: jest.SpyInstance; + + const defaultProps = createTestProps({ + options: { + jsonData: { + tlsSkipVerify: false, + tlsAuth: false, + tlsAuthWithCACert: false, + }, + secureJsonData: {}, + secureJsonFields: {}, + }, + mocks: { + onOptionsChange: onOptionsChangeMock, + }, + }); + + beforeEach(() => { + // Mock console.error to suppress React act() warnings + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.clearAllMocks(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('renders the three TLS checkboxes', () => { + render(); + const openTLSSection = screen.getByRole('button', { name: /tls\/ssl settings/i }); + fireEvent.click(openTLSSection); + + expect(screen.getByLabelText(/skip tls verify/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/tls client auth/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/with ca cert/i)).toBeInTheDocument(); + }); + + it('updates jsonData.tlsSkipVerify on click', () => { + render(); + const openTLSSection = screen.getByRole('button', { name: /tls\/ssl settings/i }); + fireEvent.click(openTLSSection); + + const cb = screen.getByLabelText(/skip tls verify/i); + fireEvent.click(cb); + + expect(onOptionsChangeMock).toHaveBeenCalled(); + const last = onOptionsChangeMock.mock.lastCall?.[0]; + expect(last.jsonData).toHaveProperty('tlsSkipVerify'); + }); + + it('shows Client Cert + Client Key inputs when tlsAuth is true', async () => { + const props = createTestProps({ + options: { + jsonData: { tlsSkipVerify: false, tlsAuth: true, tlsAuthWithCACert: false }, + secureJsonData: {}, + secureJsonFields: {}, + }, + mocks: { onOptionsChange: onOptionsChangeMock }, + }); + + render(); + + expect(screen.getByText(/client cert/i)).toBeInTheDocument(); + expect(screen.getByText(/client key/i)).toBeInTheDocument(); + }); + + it('updates secureJsonData.tlsClientCert when typing into Client certificate', () => { + const props = createTestProps({ + options: { + jsonData: { tlsSkipVerify: false, tlsAuth: true, tlsAuthWithCACert: false }, + secureJsonData: {}, + secureJsonFields: {}, + }, + mocks: { onOptionsChange: onOptionsChangeMock }, + }); + + render(); + + const input = screen.getByPlaceholderText(/client cert\. begins with/i); + fireEvent.change(input, { target: { value: '---CERT---' } }); + + const last = onOptionsChangeMock.mock.lastCall?.[0]; + expect(last.secureJsonData?.tlsClientCert).toBe('---CERT---'); + }); + + it('updates secureJsonData.tlsClientKey when typing into Client key', () => { + const props = createTestProps({ + options: { + jsonData: { tlsSkipVerify: false, tlsAuth: true, tlsAuthWithCACert: false }, + secureJsonData: {}, + secureJsonFields: {}, + }, + mocks: { onOptionsChange: onOptionsChangeMock }, + }); + + render(); + + const input = screen.getByPlaceholderText(/client key\. begins with/i); + fireEvent.change(input, { target: { value: '---CERT---' } }); + + const last = onOptionsChangeMock.mock.lastCall?.[0]; + expect(last.secureJsonData?.tlsClientKey).toBe('---CERT---'); + }); + + it('updates secureJsonData.tlsCACert when typing into CA cert', () => { + const props = createTestProps({ + options: { + jsonData: { tlsSkipVerify: false, tlsAuth: false, tlsAuthWithCACert: true }, + secureJsonData: {}, + secureJsonFields: {}, + }, + mocks: { onOptionsChange: onOptionsChangeMock }, + }); + + render(); + + const input = screen.getByPlaceholderText(/ca cert\. begins with/i); + fireEvent.change(input, { target: { value: '---CERT---' } }); + + const last = onOptionsChangeMock.mock.lastCall?.[0]; + expect(last.secureJsonData?.tlsCACert).toBe('---CERT---'); + }); + + it('shows CA certificate input when tlsAuthWithCACert is true', () => { + const props = createTestProps({ + options: { + jsonData: { tlsSkipVerify: false, tlsAuth: false, tlsAuthWithCACert: true }, + secureJsonData: {}, + secureJsonFields: { tlsCACert: false }, + }, + mocks: { onOptionsChange: onOptionsChangeMock }, + }); + + render(); + + expect(screen.getByLabelText(/ca cert/i)).toBeInTheDocument(); + }); +}); diff --git a/src/views/config-v2/TLSSSLSettingsSection.tsx b/src/views/config-v2/TLSSSLSettingsSection.tsx new file mode 100644 index 000000000..493763e9f --- /dev/null +++ b/src/views/config-v2/TLSSSLSettingsSection.tsx @@ -0,0 +1,155 @@ +import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOption } from '@grafana/data'; +import { Box, CollapsableSection, CertificationKey, Text, useStyles2, Checkbox, Stack, Badge } from '@grafana/ui'; +import React from 'react'; +import { CHConfig, CHSecureConfig } from 'types/config'; +import { CONFIG_SECTION_HEADERS, CONTAINER_MIN_WIDTH } from './constants'; +import allLabels from './labelsV2'; +import { css } from '@emotion/css'; +import { + trackClickhouseConfigV2SkipTLSVerifyToggleClicked, + trackClickhouseConfigV2TLSClientAuthToggleClicked, + trackClickhouseConfigV2WithCACertToggleClicked, +} from './tracking'; + +export interface Props extends DataSourcePluginOptionsEditorProps {} + +export const TLSSSLSettingsSection = (props: Props) => { + const { options, onOptionsChange } = props; + const { jsonData, secureJsonFields } = options; + const secureJsonData = (options.secureJsonData || {}) as CHSecureConfig; + const labels = allLabels.components.Config.ConfigEditor; + const hasTLSCACert = secureJsonFields && secureJsonFields.tlsCACert; + const hasTLSClientCert = secureJsonFields && secureJsonFields.tlsClientCert; + const hasTLSClientKey = secureJsonFields && secureJsonFields.tlsClientKey; + const styles = useStyles2(getStyles); + + const onCertificateChangeFactory = (key: keyof Omit, value: string) => { + onOptionsChange({ + ...options, + secureJsonData: { + ...secureJsonData, + [key]: value, + }, + }); + }; + const onResetClickFactory = (key: keyof Omit) => { + onOptionsChange({ + ...options, + secureJsonFields: { + ...secureJsonFields, + [key]: false, + }, + secureJsonData: { + ...secureJsonData, + [key]: '', + }, + }); + }; + + return ( + + + 3. {CONFIG_SECTION_HEADERS[2].label} + + + } + isOpen={!!(jsonData.tlsSkipVerify || jsonData.tlsAuth || jsonData.tlsAuthWithCACert)} + > + + TLS/SSL certificates are used to prove identity and encrypt traffic between Grafana and ClickHouse. + +
+ + { + trackClickhouseConfigV2SkipTLSVerifyToggleClicked({ skipTlsVerifyToggle: e.currentTarget.checked }); + onUpdateDatasourceJsonDataOption(props, 'tlsSkipVerify')(e); + }} + /> + { + trackClickhouseConfigV2TLSClientAuthToggleClicked({ clientAuthToggle: e.currentTarget.checked }); + onUpdateDatasourceJsonDataOption(props, 'tlsAuth')(e); + }} + /> + {jsonData.tlsAuth && ( +
+ onCertificateChangeFactory('tlsClientCert', e.currentTarget.value)} + placeholder={labels.tlsClientCert.placeholder} + label={labels.tlsClientCert.label} + onClick={() => onResetClickFactory('tlsClientCert')} + data-testid="tls-client-cert" + /> + onCertificateChangeFactory('tlsClientKey', e.currentTarget.value)} + onClick={() => onResetClickFactory('tlsClientKey')} + data-testid="tls-client-key" + /> +
+ )} + { + trackClickhouseConfigV2WithCACertToggleClicked({ caCertToggle: e.currentTarget.checked }); + onUpdateDatasourceJsonDataOption(props, 'tlsAuthWithCACert')(e); + }} + /> +
+ {jsonData.tlsAuthWithCACert && ( + onCertificateChangeFactory('tlsCACert', e.currentTarget.value)} + placeholder={labels.tlsCACert.placeholder} + label={labels.tlsCACert.label} + onClick={() => onResetClickFactory('tlsCACert')} + data-testid="tls-ca-cert" + /> + )} +
+
+
+
+
+ ); +}; + +const getStyles = () => ({ + contentSection: css({ + marginTop: '30px', + }), + optionsRow: css({ + display: 'flex', + gap: '50px', + }), + certsSection: css({ + marginTop: '10px', + }), + badge: css({ + marginLeft: 'auto', + }), +}); diff --git a/src/views/config-v2/constants.ts b/src/views/config-v2/constants.ts index f36271a9d..a82f52fff 100644 --- a/src/views/config-v2/constants.ts +++ b/src/views/config-v2/constants.ts @@ -9,19 +9,19 @@ export const CONTAINER_MIN_WIDTH = '450px'; export const CONFIG_SECTION_HEADERS = [ { label: 'Server and encryption', id: 'server', isOpen: true, isOptional: false }, - { label: 'Credentials', id: 'credentials', isOpen: true, isOptional: false }, - { label: 'TLS/SSL settings', id: 'tls', isOpen: true, isOptional: true }, - { label: 'Additional settings', id: 'additional', isOpen: true, isOptional: true }, - { label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: true, isOptional: null }, + { label: 'Database credentials', id: 'credentials', isOpen: true, isOptional: false }, + { label: 'TLS/SSL settings', id: 'tls', isOpen: false, isOptional: true }, + { label: 'Additional settings', id: 'additional', isOpen: false, isOptional: true }, + { label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: undefined, isOptional: null }, ]; export const CONFIG_SECTION_HEADERS_WITH_PDC = [ { label: 'Server and encryption', id: 'server', isOpen: true, isOptional: false }, - { label: 'Credentials', id: 'credentials', isOpen: true, isOptional: false }, - { label: 'TLS/SSL settings', id: 'tls', isOpen: true, isOptional: true }, - { label: 'Additional settings', id: 'additional', isOpen: true, isOptional: true }, - { label: 'Private data source connect', id: 'pdc', isOpen: true, isOptional: true }, - { label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: true, isOptional: null }, + { label: 'Database credentials', id: 'credentials', isOpen: true, isOptional: false }, + { label: 'TLS/SSL settings', id: 'tls', isOpen: false, isOptional: true }, + { label: 'Additional settings', id: 'additional', isOpen: false, isOptional: true }, + { label: 'Private data source connect', id: 'pdc', isOpen: false, isOptional: true }, + { label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: undefined, isOptional: null }, ]; export const PROTOCOL_OPTIONS = [ diff --git a/src/views/config-v2/labels.ts b/src/views/config-v2/labelsV2.ts similarity index 51% rename from src/views/config-v2/labels.ts rename to src/views/config-v2/labelsV2.ts index d0410675b..1b1b21fe9 100644 --- a/src/views/config-v2/labels.ts +++ b/src/views/config-v2/labelsV2.ts @@ -1,16 +1,14 @@ +import { ColumnHint } from 'types/queryBuilder'; + export default { components: { Config: { ConfigEditor: { serverAddress: { - label: 'Server host address', - placeholder: 'Enter URL', - }, - protocol: { - label: 'Protocol', - description: 'Native or HTTP for server protocol', - toggletip: - 'ClickHouse supports two server protocols: Native TCP and HTTP. Both protocols can be secured with TLS.\n\nNative TCP is the default and recommended option.\nHTTP is for servers configured to accept HTTP connections.', + label: 'Server address', + placeholder: 'Server address', + tooltip: 'ClickHouse host address', + error: 'Server address required', }, serverPort: { label: 'Server port', @@ -21,19 +19,19 @@ export default { tooltip: 'ClickHouse server port', error: 'Port is required', }, - secure: { - label: 'Secure Connection', - description: 'Check this box to connect securely', - }, path: { label: 'HTTP URL Path', - tooltip: 'Additional URL path for HTTP requests', + tooltip: 'Additional URL path for HTTP requests. Default: /', placeholder: 'additional-path', }, + protocol: { + label: 'Protocol', + tooltip: 'Native is the default protocol', + }, username: { label: 'Username', placeholder: 'default', - tooltip: 'ClickHouse username', + tooltip: 'We recommend configuring', }, password: { label: 'Password', @@ -64,6 +62,10 @@ export default { label: 'Client Key', placeholder: 'Client Key. Begins with -----BEGIN RSA PRIVATE KEY-----', }, + secure: { + label: 'Secure Connection', + tooltip: 'Check to connect securely with TLS', + }, secureSocksProxy: { label: 'Enable Secure Socks Proxy', tooltip: 'Enable proxying the datasource connection through the secure socks proxy to a different network.', @@ -75,6 +77,21 @@ export default { 'Enable using the Grafana row limit setting to limit the number of rows returned from Clickhouse. Ensure the appropriate permissions are set for your user. Only supported for Grafana >= 11.0.0. Defaults to false.', }, }, + HttpHeadersConfig: { + title: 'HTTP Headers', + label: 'Custom HTTP Headers', + description: 'Add Custom HTTP headers when querying the database', + headerNameLabel: 'Header Name', + headerNamePlaceholder: 'X-Custom-Header', + insecureHeaderValueLabel: 'Header Value', + secureHeaderValueLabel: 'Secure Header Value', + secureLabel: 'Secure', + addHeaderLabel: 'Add Header', + forwardGrafanaHeaders: { + label: 'Forward Grafana HTTP Headers to data source', + tooltip: 'Forward Grafana HTTP Headers to data source.', + }, + }, AliasTableConfig: { title: 'Column Alias Tables', descriptionParts: [ @@ -283,5 +300,249 @@ export default { }, }, }, + EditorTypeSwitcher: { + label: 'Editor Type', + tooltip: 'Switches between the raw SQL Editor and the Query Builder.', + switcher: { + title: 'Are you sure?', + body: 'Queries that are too complex for the Query Builder will be altered.', + confirmText: 'Continue', + dismissText: 'Cancel', + }, + cannotConvert: { + title: 'Cannot convert', + message: 'Do you want to delete your current query and use the query builder?', + confirmText: 'Yes', + }, + }, + expandBuilderButton: { + label: 'Show full query', + tooltip: 'Shows the full query builder', + }, + QueryTypeSwitcher: { + label: 'Query Type', + tooltip: 'Sets the layout for the query builder', + sqlTooltip: 'Sets the panel type for explore view', + }, + DatabaseSelect: { + label: 'Database', + tooltip: 'ClickHouse database to query from', + empty: '', + }, + ColumnsEditor: { + label: 'Columns', + tooltip: 'A list of columns to include in the query', + }, + OtelVersionSelect: { + label: 'Use OTel', + tooltip: 'Enables Open Telemetry schema versioning', + }, + LimitEditor: { + label: 'Limit', + tooltip: 'Limits the number of rows returned by the query', + }, + SqlPreview: { + label: 'SQL Preview', + tooltip: 'Preview of the generated SQL. You can safely switch to SQL Editor to customize the generated query', + }, + AggregatesEditor: { + label: 'Aggregates', + tooltip: 'Aggregate functions to use', + aliasLabel: 'as', + aliasTooltip: 'alias for this aggregate function', + addLabel: 'Aggregate', + }, + OrderByEditor: { + label: 'Order By', + tooltip: 'Order by column', + addLabel: 'Order By', + }, + FilterEditor: { + label: 'Filters', + tooltip: `List of filters`, + addLabel: 'Filter', + mapKeyPlaceholder: 'map key', + }, + GroupByEditor: { + label: 'Group By', + tooltip: 'Group the results by specific column', + }, + LogsQueryBuilder: { + logTimeColumn: { + label: 'Time', + tooltip: 'Column that contains the log timestamp', + }, + logLevelColumn: { + label: 'Log Level', + tooltip: 'Column that contains the log level', + }, + logMessageColumn: { + label: 'Message', + tooltip: 'Column that contains the log message', + }, + logLabelsColumn: { + label: 'Labels', + tooltip: 'A column with a key/value structure for log labels', + }, + liveView: { + label: 'Live View', + tooltip: 'Enable to update logs in real time', + }, + logMessageFilter: { + label: 'Message Filter', + tooltip: 'Applies a LIKE filter to the log message body', + clearButton: 'Clear', + }, + logLevelFilter: { + label: 'Level Filter', + tooltip: 'Applies a filter to the log level', + }, + }, + TimeSeriesQueryBuilder: { + simpleQueryModeLabel: 'Simple', + aggregateQueryModeLabel: 'Aggregate', + builderModeLabel: 'Builder Mode', + builderModeTooltip: 'Switches the query builder between the simple and aggregate modes', + timeColumn: { + label: 'Time', + tooltip: 'Column to use for the time series', + }, + }, + TableQueryBuilder: { + simpleQueryModeLabel: 'Simple', + aggregateQueryModeLabel: 'Aggregate', + builderModeLabel: 'Builder Mode', + builderModeTooltip: 'Switches the query builder between the simple and aggregate modes', + }, + TraceQueryBuilder: { + traceIdModeLabel: 'Trace ID', + traceSearchModeLabel: 'Trace Search', + traceModeLabel: 'Trace Mode', + traceModeTooltip: 'Switches between trace ID and trace search mode', + columnsSection: 'Columns', + filtersSection: 'Filters', + + columns: { + traceId: { + label: 'Trace ID Column', + tooltip: 'Column that contains the trace ID', + }, + spanId: { + label: 'Span ID Column', + tooltip: 'Column that contains the span ID', + }, + parentSpanId: { + label: 'Parent Span ID Column', + tooltip: 'Column that contains the parent span ID', + }, + serviceName: { + label: 'Service Name Column', + tooltip: 'Column that contains the service name', + }, + operationName: { + label: 'Operation Name Column', + tooltip: 'Column that contains the operation name', + }, + startTime: { + label: 'Start Time Column', + tooltip: 'Column that contains the start time', + }, + durationTime: { + label: 'Duration Time Column', + tooltip: 'Column that contains the duration time', + }, + durationUnit: { + label: 'Duration Unit', + tooltip: 'The unit of time used for the duration time', + }, + tags: { + label: 'Tags Column', + tooltip: 'Column that contains the trace tags', + }, + serviceTags: { + label: 'Service Tags Column', + tooltip: 'Column that contains the service tags', + }, + flattenNested: { + label: 'Use Flatten Nested', + tooltip: 'Enable if your traces table was created with flatten_nested=1', + }, + eventsPrefix: { + label: 'Events Prefix', + tooltip: 'Prefix for the events column', + }, + linksPrefix: { + label: 'Links Prefix', + tooltip: 'Prefix for the trace references column', + }, + kind: { + label: 'Kind Column', + tooltip: 'Column that contains the trace kind', + }, + statusCode: { + label: 'Status Code Column', + tooltip: 'Column that contains the trace status code', + }, + statusMessage: { + label: 'Status Message Column', + tooltip: 'Column that contains the trace status message', + }, + instrumentationLibraryName: { + label: 'Library Name Column', + tooltip: 'Column that contains the instrumentation library name (Optional)', + }, + instrumentationLibraryVersion: { + label: 'Library Version Column', + tooltip: 'Column that contains the instrumentation library version (Optional)', + }, + state: { + label: 'State Column', + tooltip: 'Column that contains the trace state', + }, + traceIdFilter: { + label: 'Trace ID', + tooltip: 'filter by a specific trace ID', + }, + }, + }, + }, + types: { + EditorType: { + sql: 'SQL Editor', + builder: 'Query Builder', + }, + QueryType: { + table: 'Table', + logs: 'Logs', + timeseries: 'Time Series', + traces: 'Traces', + }, + ColumnHint: { + [ColumnHint.Time]: 'Time', + + [ColumnHint.LogLevel]: 'Level', + [ColumnHint.LogMessage]: 'Message', + [ColumnHint.LogLabels]: 'Labels', + + [ColumnHint.TraceId]: 'Trace ID', + [ColumnHint.TraceSpanId]: 'Span ID', + [ColumnHint.TraceParentSpanId]: 'Parent Span ID', + [ColumnHint.TraceServiceName]: 'Service Name', + [ColumnHint.TraceOperationName]: 'Operation Name', + [ColumnHint.TraceDurationTime]: 'Duration Time', + [ColumnHint.TraceTags]: 'Tags', + [ColumnHint.TraceServiceTags]: 'Service Tags', + [ColumnHint.TraceStatusCode]: 'Status Code', + [ColumnHint.TraceKind]: 'Kind', + [ColumnHint.TraceStatusMessage]: 'Status Message', + [ColumnHint.TraceInstrumentationLibraryName]: 'Instrumentation Library Name', + [ColumnHint.TraceInstrumentationLibraryVersion]: 'Instrumentation Library Version', + [ColumnHint.TraceState]: 'State', + }, }, }; diff --git a/src/views/config-v2/tracking.ts b/src/views/config-v2/tracking.ts index 3750312ae..0a97c4234 100644 --- a/src/views/config-v2/tracking.ts +++ b/src/views/config-v2/tracking.ts @@ -6,7 +6,7 @@ export const trackInfluxDBConfigV2FeedbackButtonClicked = () => { reportInteraction('clickhouse_config_v2_feedback_button_clicked'); }; -// Server section +// Server and encryption section export const trackClickhouseConfigV2HostInput = () => { reportInteraction('clickhouse_config_v2_host_input'); }; @@ -19,8 +19,17 @@ export const trackClickhouseConfigV2NativeHttpToggleClicked = (props: { nativeHt reportInteraction('clickhouse_config_v2_native_http_toggle_clicked', props); }; -export const trackClickhouseConfigV2SecureConnectionToggleClicked = (props: { secureConnection: boolean }) => { - reportInteraction('clickhouse_config_v2_secure_connection_toggle_clicked', props); +export const trackClickhouseConfigV2SecureConnectionChecked = (props: { secureConnection: boolean }) => { + reportInteraction('clickhouse_config_v2_secure_connection_checked', props); +}; + +// Database credentials section +export const trackClickhouseConfigV2DatabaseCredentialsUserInput = () => { + reportInteraction('clickhouse_config_v2_database_credentials_user_input'); +}; + +export const trackClickhouseConfigV2DatabaseCredentialsPasswordInput = () => { + reportInteraction('clickhouse_config_v2_database_credentials_password_input'); }; // TLS/SSL Settings section @@ -36,6 +45,7 @@ export const trackClickhouseConfigV2WithCACertToggleClicked = (props: { caCertTo reportInteraction('clickhouse_config_v2_with_ca_cert_toggle_clicked', props); }; +// Additional settings // Default DB and Table section export const trackClickhouseConfigV2DefaultDbInput = () => { reportInteraction('clickhouse_config_v2_default_db_input'); @@ -112,6 +122,6 @@ export const trackClickhouseConfigV2EnableRowLimitToggle = (props: { rowLimitEna }; // Custom Settings section -export const trackClickhouseConfigV2CustomSettingAdded = () => { - reportInteraction('clickhouse_config_v2_custom_setting_added'); +export const trackClickhouseConfigV2CustomSettingClicked = () => { + reportInteraction('clickhouse_config_v2_custom_setting_clicked'); };