diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.tsx index 31a0e3186dc81..1296c760e93a0 100644 --- a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.tsx +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.tsx @@ -18,6 +18,10 @@ export const ExternalServiceConnectionDetails: FC = ({ form }) => const selectedOption = formValues?.metricsParameters; const urlValue = formValues?.url; const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); + // const timeoutValidators = useMemo( + // () => [Validators.duration, Validators.minDuration('0s'), Validators.durationUnit({ s: true, m: true })], + // [] + // ); const trim = useCallback((value?: string) => (value ? value.trim() : value), []); const getUrlParts = () => { @@ -121,6 +125,14 @@ export const ExternalServiceConnectionDetails: FC = ({ form }) => tooltipText={Messages.form.tooltips.externalService.port} validators={portValidators} /> + {/* */}
diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.messages.ts b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.messages.ts index 990914941b592..42c4b574a2fb1 100644 --- a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.messages.ts +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.messages.ts @@ -30,6 +30,7 @@ export const Messages = { username: 'Username', password: 'Password', instanceID: 'Instance ID', + timeout: 'Connection timeout', }, postgresqlDetails: { database: 'Database', @@ -97,6 +98,7 @@ export const Messages = { username: 'Username', password: 'Password', instanceID: 'Instance ID', + timeout: 'Connection timeout', }, postgresqlDetails: { database: 'Database (default: postgres)', @@ -140,6 +142,8 @@ export const Messages = { username: 'Your database user name', password: 'Your database password', instanceID: 'Instance ID to use', + // todo: update tooltip + timeout: 'Connection timeout for the database connection (e.g. 10s, 500ms, 5m)', }, postgresqlDetails: { database: 'Database name', diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MainDetails/MainDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MainDetails/MainDetails.tsx index ce75591154845..dadb4603726ec 100644 --- a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MainDetails/MainDetails.tsx +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MainDetails/MainDetails.tsx @@ -18,6 +18,10 @@ export const MainDetailsFormPart: FC = ({ form, type, const tlsFlag = formValues && formValues['tls']; const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); + // const timeoutValidators = useMemo( + // () => [Validators.duration, Validators.minDuration('0s'), Validators.durationUnit({ s: true, m: true })], + // [] + // ); const userPassValidators = useMemo( () => (tlsFlag || type === Databases.valkey ? [] : [validators.required]), [tlsFlag, type] @@ -71,6 +75,17 @@ export const MainDetailsFormPart: FC = ({ form, type, validators={userPassValidators} />
+ {/*
+ +
+
*/}
); }; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MongoDBConnectionDetails/MongoDBConnectionDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MongoDBConnectionDetails/MongoDBConnectionDetails.tsx index fd72d28049a6c..44e01b350560b 100644 --- a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MongoDBConnectionDetails/MongoDBConnectionDetails.tsx +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MongoDBConnectionDetails/MongoDBConnectionDetails.tsx @@ -17,6 +17,10 @@ export const MongoDBConnectionDetails: FC = ({ form, r const tlsFlag = formValues && formValues['tls']; const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); + // const timeoutValidators = useMemo( + // () => [Validators.duration, Validators.minDuration('0s'), Validators.durationUnit({ s: true, m: true })], + // [] + // ); const userPassValidators = useMemo(() => (tlsFlag ? [] : [validators.required]), [tlsFlag]); const maxQueryLengthValidators = useMemo(() => [Validators.min(-1)], []); @@ -77,6 +81,14 @@ export const MongoDBConnectionDetails: FC = ({ form, r placeholder={Messages.form.placeholders.mongodbDetails.maxQueryLength} validators={maxQueryLengthValidators} /> + {/* */}
diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MySQLConnectionDetails/MySQLConnectionDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MySQLConnectionDetails/MySQLConnectionDetails.tsx index 963831744de24..71d7bfd4152d9 100644 --- a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MySQLConnectionDetails/MySQLConnectionDetails.tsx +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MySQLConnectionDetails/MySQLConnectionDetails.tsx @@ -19,6 +19,10 @@ export const MySQLConnectionDetails: FC = ({ form, rem const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); const userPassValidators = useMemo(() => (tlsFlag ? [] : [validators.required]), [tlsFlag]); const maxQueryLengthValidators = useMemo(() => [Validators.min(-1)], []); + // const timeoutValidators = useMemo( + // () => [Validators.duration, Validators.minDuration('0s'), Validators.durationUnit({ s: true, m: true })], + // [] + // ); return (
@@ -87,6 +91,14 @@ export const MySQLConnectionDetails: FC = ({ form, rem placeholder={Messages.form.placeholders.mysqlDetails.maxQueryLength} validators={maxQueryLengthValidators} /> + {/* */}
diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.tsx index 6f916f0391181..d6c671c2f5cea 100644 --- a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.tsx +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.tsx @@ -19,6 +19,10 @@ export const PostgreSQLConnectionDetails: FC = ({ form const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); const userPassValidators = useMemo(() => (tlsFlag ? [] : [validators.required]), [tlsFlag]); const maxQueryLengthValidators = useMemo(() => [Validators.min(-1)], []); + const timeoutValidators = useMemo( + () => [Validators.duration, Validators.minDuration('0s'), Validators.durationUnit({ s: true, m: true })], + [] + ); return (
@@ -94,6 +98,17 @@ export const PostgreSQLConnectionDetails: FC = ({ form tooltipText={Messages.form.tooltips.postgresqlDetails.maxQueryLength} />
+
+ +
+
); }; diff --git a/public/app/percona/add-instance/panel.types.ts b/public/app/percona/add-instance/panel.types.ts index f9799a6a6e841..b08c8e43ed48b 100644 --- a/public/app/percona/add-instance/panel.types.ts +++ b/public/app/percona/add-instance/panel.types.ts @@ -34,6 +34,7 @@ export interface RemoteInstanceCredentials { tls?: boolean; tls_skip_verify?: boolean; pmm_agent_id?: string; + timeout?: string; } export enum InstanceTypesExtra { diff --git a/public/app/percona/shared/helpers/duration.test.ts b/public/app/percona/shared/helpers/duration.test.ts new file mode 100644 index 0000000000000..ccde05032c65a --- /dev/null +++ b/public/app/percona/shared/helpers/duration.test.ts @@ -0,0 +1,100 @@ +import { durationToMs, isValidProtobufDuration } from './duration'; + +describe('isValidProtobufDuration', () => { + it('should accept integer seconds', () => { + expect(isValidProtobufDuration('1s')).toBe(true); + expect(isValidProtobufDuration('60s')).toBe(true); + expect(isValidProtobufDuration('0s')).toBe(true); + }); + + it('should accept fractional seconds', () => { + expect(isValidProtobufDuration('1.5s')).toBe(true); + expect(isValidProtobufDuration('0.001s')).toBe(true); + }); + + it('should accept negative durations', () => { + expect(isValidProtobufDuration('-1s')).toBe(true); + expect(isValidProtobufDuration('-1.5s')).toBe(true); + }); + + it('should accept millisecond notation', () => { + expect(isValidProtobufDuration('500ms')).toBe(true); + expect(isValidProtobufDuration('1ms')).toBe(true); + expect(isValidProtobufDuration('0ms')).toBe(true); + expect(isValidProtobufDuration('1.5ms')).toBe(true); + }); + + it('should trim whitespace', () => { + expect(isValidProtobufDuration(' 1s ')).toBe(true); + expect(isValidProtobufDuration(' 500ms ')).toBe(true); + }); + + it('should reject bare numbers', () => { + expect(isValidProtobufDuration('1')).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValidProtobufDuration('')).toBe(false); + }); + + it('should accept minute notation', () => { + expect(isValidProtobufDuration('1m')).toBe(true); + expect(isValidProtobufDuration('0m')).toBe(true); + expect(isValidProtobufDuration('60m')).toBe(true); + expect(isValidProtobufDuration('0.5m')).toBe(true); + expect(isValidProtobufDuration('-1m')).toBe(true); + }); + + it('should reject other units', () => { + expect(isValidProtobufDuration('1h')).toBe(false); + }); +}); + +describe('durationToMs', () => { + it('should convert seconds to milliseconds', () => { + expect(durationToMs('1s')).toBe(1000); + expect(durationToMs('2s')).toBe(2000); + expect(durationToMs('0s')).toBe(0); + }); + + it('should convert fractional seconds to milliseconds', () => { + expect(durationToMs('1.5s')).toBe(1500); + expect(durationToMs('0.5s')).toBe(500); + }); + + it('should convert milliseconds', () => { + expect(durationToMs('500ms')).toBe(500); + expect(durationToMs('1ms')).toBe(1); + expect(durationToMs('0ms')).toBe(0); + }); + + it('should convert fractional milliseconds', () => { + expect(durationToMs('1.5ms')).toBe(1.5); + }); + + it('should trim whitespace', () => { + expect(durationToMs(' 1s ')).toBe(1000); + expect(durationToMs(' 500ms ')).toBe(500); + }); + + it('should convert minutes to milliseconds', () => { + expect(durationToMs('1m')).toBe(60000); + expect(durationToMs('2m')).toBe(120000); + expect(durationToMs('0m')).toBe(0); + }); + + it('should convert fractional minutes to milliseconds', () => { + expect(durationToMs('0.5m')).toBe(30000); + expect(durationToMs('1.5m')).toBe(90000); + }); + + it('should trim whitespace for minutes', () => { + expect(durationToMs(' 1m ')).toBe(60000); + }); + + it('should throw on invalid input', () => { + expect(() => durationToMs('1h')).toThrow('Invalid duration: "1h"'); + expect(() => durationToMs('1')).toThrow('Invalid duration: "1"'); + expect(() => durationToMs('')).toThrow(); + }); +}); diff --git a/public/app/percona/shared/helpers/duration.ts b/public/app/percona/shared/helpers/duration.ts new file mode 100644 index 0000000000000..ceea2f2feae06 --- /dev/null +++ b/public/app/percona/shared/helpers/duration.ts @@ -0,0 +1,34 @@ +import { UnitOptions } from './validator.types'; + +export const isValidProtobufDuration = (durationString: string): boolean => + /^-?[0-9]+(\.[0-9]+)?(ms|s|m)$/.test(durationString.trim()); + +export const durationToMs = (duration: string): number => { + const trimmed = duration.trim(); + + if (trimmed.endsWith('ms')) { + return parseFloat(trimmed.slice(0, -2)); + } + + if (trimmed.endsWith('m')) { + return parseFloat(trimmed.slice(0, -1)) * 60 * 1000; + } + + if (trimmed.endsWith('s')) { + return parseFloat(trimmed.slice(0, -1)) * 1000; + } + + throw new Error(`Invalid duration: "${duration}"`); +}; + +export const hasValidUnit = (duration: string, options: UnitOptions): boolean => { + const unit = getDurationUnit(duration); + return !!unit && !!options[unit as keyof UnitOptions]; +}; + +const DURATION_RE = /^-?\d+(?:\.\d+)?(ms|s|m)$/; + +export const getDurationUnit = (value: string): string | undefined => { + const match = value.trim().match(DURATION_RE); + return match?.[1]; +}; diff --git a/public/app/percona/shared/helpers/validator.types.ts b/public/app/percona/shared/helpers/validator.types.ts index d792c0e6ce4cf..f48dc5533b9df 100644 --- a/public/app/percona/shared/helpers/validator.types.ts +++ b/public/app/percona/shared/helpers/validator.types.ts @@ -1,3 +1,9 @@ export type VResult = string | undefined; export type Validator = (value: any, values?: Record, meta?: any) => VResult; + +export type UnitOptions = { + ms?: boolean; + s?: boolean; + m?: boolean; +}; diff --git a/public/app/percona/shared/helpers/validators.test.tsx b/public/app/percona/shared/helpers/validators.test.tsx index aa43d18184679..34f00accbdd19 100644 --- a/public/app/percona/shared/helpers/validators.test.tsx +++ b/public/app/percona/shared/helpers/validators.test.tsx @@ -256,4 +256,78 @@ describe('validators compose', () => { expect(validate(120, {})).toEqual(errorMessage); }); + + describe('validate duration', () => { + it('return undefined when value is valid', () => { + expect(validators.duration('1s')).toBeUndefined(); + expect(validators.duration('1ms')).toBeUndefined(); + expect(validators.duration('1.5s')).toBeUndefined(); + expect(validators.duration('1.5ms')).toBeUndefined(); + expect(validators.duration('1m')).toBeUndefined(); + expect(validators.duration('0.5m')).toBeUndefined(); + expect(validators.duration('60m')).toBeUndefined(); + }); + + it('return error message when value is invalid', () => { + expect(validators.duration('1')).toEqual('Invalid duration'); + expect(validators.duration('1m.1s')).toEqual('Invalid duration'); + expect(validators.duration('1h.1m')).toEqual('Invalid duration'); + expect(validators.duration('1d.1h')).toEqual('Invalid duration'); + expect(validators.duration('1w.1d')).toEqual('Invalid duration'); + expect(validators.duration('1y.1w')).toEqual('Invalid duration'); + expect(validators.duration('1h')).toEqual('Invalid duration'); + }); + }); + + describe('validate min duration', () => { + it('return undefined when value is valid', () => { + expect(validators.minDuration('1s')('1s')).toBeUndefined(); + expect(validators.minDuration('1ms')('1ms')).toBeUndefined(); + expect(validators.minDuration('1m')('1m')).toBeUndefined(); + expect(validators.minDuration('1m')('2m')).toBeUndefined(); + expect(validators.minDuration('30s')('1m')).toBeUndefined(); + expect(validators.minDuration('1m')('90s')).toBeUndefined(); + }); + + it('return error message when value is invalid', () => { + expect(validators.minDuration('1s')('0s')).toEqual('Duration should be greater or equal to 1s'); + expect(validators.minDuration('1ms')('0s')).toEqual('Duration should be greater or equal to 1ms'); + expect(validators.minDuration('1m')('30s')).toEqual('Duration should be greater or equal to 1m'); + expect(validators.minDuration('2m')('1m')).toEqual('Duration should be greater or equal to 2m'); + }); + }); + + describe('validate duration unit', () => { + it('return undefined when value is valid', () => { + const values = { + ms: '1ms', + s: '1s', + m: '1m', + }; + + for (const [unit, value] of Object.entries(values)) { + expect(validators.durationUnit({ [unit]: true })(value)).toBeUndefined(); + + for (const [otherUnit, otherValue] of Object.entries(values)) { + if (unit !== otherUnit) { + expect(validators.durationUnit({ [unit]: true })(otherValue)).toEqual( + `Invalid unit. Allowed units: ${unit}` + ); + } + } + } + }); + + it('allows only s and m when configured', () => { + const validator = validators.durationUnit({ s: true, m: true, ms: false }); + + expect(validator('1s')).toBeUndefined(); + expect(validator('1m')).toBeUndefined(); + expect(validator('1ms')).toEqual('Invalid unit. Allowed units: s, m'); + }); + + it('returns invalid duration when format is wrong', () => { + expect(validators.durationUnit({ s: true, m: true })('1')).toEqual('Invalid duration'); + }); + }); }); diff --git a/public/app/percona/shared/helpers/validators.ts b/public/app/percona/shared/helpers/validators.ts index 8777e424e462c..f7ffe8237d7d3 100644 --- a/public/app/percona/shared/helpers/validators.ts +++ b/public/app/percona/shared/helpers/validators.ts @@ -1,4 +1,5 @@ -import { Validator, VResult } from './validator.types'; +import { UnitOptions, Validator, VResult } from './validator.types'; +import { durationToMs, getDurationUnit, isValidProtobufDuration } from './duration'; export const validators = { validatePort: (value: any) => { @@ -104,6 +105,44 @@ export const validators = { requiredTrue: (value: boolean) => (value === true ? undefined : 'Required field'), + duration: (value: string) => { + if (!value) { + return undefined; + } + + return isValidProtobufDuration(value) ? undefined : 'Invalid duration'; + }, + + durationUnit: (options: UnitOptions) => (value: string) => { + if (!value) { + return undefined; + } + + const allowed = Object.keys(options).filter((unit) => options[unit as keyof UnitOptions]); + const unit = getDurationUnit(value); + + if (!unit) { + return 'Invalid duration'; + } + + if (!allowed.includes(unit)) { + return `Invalid unit. Allowed units: ${allowed.join(', ')}`; + } + + return undefined; + }, + + minDuration: (minDuration: string) => (value: string) => { + if (!value) { + return undefined; + } + + const min = durationToMs(minDuration); + const duration = durationToMs(value); + + return duration >= min ? undefined : `Duration should be greater or equal to ${minDuration}`; + }, + compose: (...validators: Validator[]) => (value: any, values?: Record): VResult => {