From bf369043d51d454923486c9908e8394365ddae6c Mon Sep 17 00:00:00 2001 From: Matej Kubinec Date: Thu, 26 Mar 2026 15:23:33 +0100 Subject: [PATCH 1/4] PMM-14937 Provide connection timeout input --- .../ExternalServiceConnectionDetails.tsx | 12 ++++++++++ .../FormParts/FormParts.messages.ts | 4 ++++ .../FormParts/MainDetails/MainDetails.tsx | 12 ++++++++++ .../MongoDBConnectionDetails.tsx | 10 +++++++- .../MySQLConnectionDetails.tsx | 10 +++++++- .../PostgreSQLConnectionDetails.tsx | 12 ++++++++++ .../app/percona/add-instance/panel.types.ts | 1 + .../app/percona/shared/helpers/validators.ts | 23 +++++++++++++++++++ 8 files changed, 82 insertions(+), 2 deletions(-) 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..230d6b42177a8 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,7 @@ 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')], []); const trim = useCallback((value?: string) => (value ? value.trim() : value), []); const getUrlParts = () => { @@ -141,6 +142,17 @@ export const ExternalServiceConnectionDetails: FC = ({ form }) => format={trim} /> +
+ +
+
)}
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..7f5ac091e897a 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: add tooltip + timeout: 'TODO: add tooltip', }, 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..28540ed215db1 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,7 @@ 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')], []); const userPassValidators = useMemo( () => (tlsFlag || type === Databases.valkey ? [] : [validators.required]), [tlsFlag, type] @@ -71,6 +72,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..a034c39318063 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,7 @@ 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')], []); const userPassValidators = useMemo(() => (tlsFlag ? [] : [validators.required]), [tlsFlag]); const maxQueryLengthValidators = useMemo(() => [Validators.min(-1)], []); @@ -77,7 +78,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..9b9d476bccb65 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,7 @@ 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')], []); return (
@@ -87,7 +88,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..317bf291abf94 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,7 @@ 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')], []); return (
@@ -94,6 +95,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/validators.ts b/public/app/percona/shared/helpers/validators.ts index 8777e424e462c..34ac4c21c74d7 100644 --- a/public/app/percona/shared/helpers/validators.ts +++ b/public/app/percona/shared/helpers/validators.ts @@ -1,3 +1,5 @@ +import { durationToMilliseconds, isValidGoDuration, parseDuration } from '@grafana/data'; + import { Validator, VResult } from './validator.types'; export const validators = { @@ -104,6 +106,27 @@ export const validators = { requiredTrue: (value: boolean) => (value === true ? undefined : 'Required field'), + duration: (value: string) => { + if (!value) { + return undefined; + } + + return isValidGoDuration(value) ? undefined : 'Invalid duration'; + }, + + minDuration: (minDuration: string) => (value: string) => { + if (!value) { + return undefined; + } + + const min = durationToMilliseconds(parseDuration(minDuration)); + const duration = value.startsWith('-') + ? -durationToMilliseconds(parseDuration(value)) + : durationToMilliseconds(parseDuration(value)); + + return duration >= min ? undefined : `Duration should be greater or equal to ${minDuration}`; + }, + compose: (...validators: Validator[]) => (value: any, values?: Record): VResult => { From bb598bfb6b0e7729fdcece0d313dd5093c2d9fba Mon Sep 17 00:00:00 2001 From: Matej Kubinec Date: Fri, 27 Mar 2026 07:54:22 +0100 Subject: [PATCH 2/4] PMM-14937 Only support ms, s and m durations --- .../FormParts/FormParts.messages.ts | 4 +- .../percona/shared/helpers/duration.test.ts | 100 ++++++++++++++++++ public/app/percona/shared/helpers/duration.ts | 20 ++++ .../shared/helpers/validators.test.tsx | 40 +++++++ .../app/percona/shared/helpers/validators.ts | 11 +- 5 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 public/app/percona/shared/helpers/duration.test.ts create mode 100644 public/app/percona/shared/helpers/duration.ts 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 7f5ac091e897a..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 @@ -142,8 +142,8 @@ export const Messages = { username: 'Your database user name', password: 'Your database password', instanceID: 'Instance ID to use', - // todo: add tooltip - timeout: 'TODO: add tooltip', + // 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/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..e3d8131cd2dc7 --- /dev/null +++ b/public/app/percona/shared/helpers/duration.ts @@ -0,0 +1,20 @@ +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}"`); +}; diff --git a/public/app/percona/shared/helpers/validators.test.tsx b/public/app/percona/shared/helpers/validators.test.tsx index aa43d18184679..9e26f01db8e04 100644 --- a/public/app/percona/shared/helpers/validators.test.tsx +++ b/public/app/percona/shared/helpers/validators.test.tsx @@ -256,4 +256,44 @@ 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'); + }); + }); }); diff --git a/public/app/percona/shared/helpers/validators.ts b/public/app/percona/shared/helpers/validators.ts index 34ac4c21c74d7..9ce22cfd92741 100644 --- a/public/app/percona/shared/helpers/validators.ts +++ b/public/app/percona/shared/helpers/validators.ts @@ -1,6 +1,5 @@ -import { durationToMilliseconds, isValidGoDuration, parseDuration } from '@grafana/data'; - import { Validator, VResult } from './validator.types'; +import { durationToMs, isValidProtobufDuration } from './duration'; export const validators = { validatePort: (value: any) => { @@ -111,7 +110,7 @@ export const validators = { return undefined; } - return isValidGoDuration(value) ? undefined : 'Invalid duration'; + return isValidProtobufDuration(value) ? undefined : 'Invalid duration'; }, minDuration: (minDuration: string) => (value: string) => { @@ -119,10 +118,8 @@ export const validators = { return undefined; } - const min = durationToMilliseconds(parseDuration(minDuration)); - const duration = value.startsWith('-') - ? -durationToMilliseconds(parseDuration(value)) - : durationToMilliseconds(parseDuration(value)); + const min = durationToMs(minDuration); + const duration = durationToMs(value); return duration >= min ? undefined : `Duration should be greater or equal to ${minDuration}`; }, From 9045348b2288c66ab208a4528527e59d06faceee Mon Sep 17 00:00:00 2001 From: Matej Kubinec Date: Wed, 1 Apr 2026 15:51:26 +0200 Subject: [PATCH 3/4] PMM-14937 Enable connection timeout field only for PG --- .../ExternalServiceConnectionDetails.tsx | 21 ++++++++----------- .../FormParts/MainDetails/MainDetails.tsx | 6 +++--- .../MongoDBConnectionDetails.tsx | 7 ++++--- .../MySQLConnectionDetails.tsx | 7 ++++--- 4 files changed, 20 insertions(+), 21 deletions(-) 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 230d6b42177a8..31e63b6149499 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,7 +18,7 @@ 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')], []); + // const timeoutValidators = useMemo(() => [Validators.duration, Validators.minDuration('0s')], []); const trim = useCallback((value?: string) => (value ? value.trim() : value), []); const getUrlParts = () => { @@ -122,6 +122,14 @@ export const ExternalServiceConnectionDetails: FC = ({ form }) => tooltipText={Messages.form.tooltips.externalService.port} validators={portValidators} /> + {/* */}
@@ -142,17 +150,6 @@ export const ExternalServiceConnectionDetails: FC = ({ form }) => format={trim} />
-
- -
-
)}
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 28540ed215db1..d32cb695e1ef9 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,7 +18,7 @@ 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')], []); + // const timeoutValidators = useMemo(() => [Validators.duration, Validators.minDuration('0s')], []); const userPassValidators = useMemo( () => (tlsFlag || type === Databases.valkey ? [] : [validators.required]), [tlsFlag, type] @@ -72,7 +72,7 @@ export const MainDetailsFormPart: FC = ({ form, type, validators={userPassValidators} /> -
+ {/*
= ({ form, type, validators={timeoutValidators} />
-
+
*/}
); }; 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 a034c39318063..aab3ea155ece6 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,7 +17,7 @@ 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')], []); + // const timeoutValidators = useMemo(() => [Validators.duration, Validators.minDuration('0s')], []); const userPassValidators = useMemo(() => (tlsFlag ? [] : [validators.required]), [tlsFlag]); const maxQueryLengthValidators = useMemo(() => [Validators.min(-1)], []); @@ -78,14 +78,15 @@ 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 9b9d476bccb65..ec4d9428c8738 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,7 +19,7 @@ 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')], []); + // const timeoutValidators = useMemo(() => [Validators.duration, Validators.minDuration('0s')], []); return (
@@ -88,14 +88,15 @@ export const MySQLConnectionDetails: FC = ({ form, rem placeholder={Messages.form.placeholders.mysqlDetails.maxQueryLength} validators={maxQueryLengthValidators} /> - + /> */} +
); From b260b6eac78ceff684c2183774e3675b2384a0fd Mon Sep 17 00:00:00 2001 From: Matej Kubinec Date: Thu, 2 Apr 2026 10:12:40 +0200 Subject: [PATCH 4/4] PMM-14937 Add support for only specific duration units --- .../ExternalServiceConnectionDetails.tsx | 5 ++- .../FormParts/MainDetails/MainDetails.tsx | 5 ++- .../MongoDBConnectionDetails.tsx | 5 ++- .../MySQLConnectionDetails.tsx | 5 ++- .../PostgreSQLConnectionDetails.tsx | 5 ++- public/app/percona/shared/helpers/duration.ts | 14 ++++++++ .../percona/shared/helpers/validator.types.ts | 6 ++++ .../shared/helpers/validators.test.tsx | 34 +++++++++++++++++++ .../app/percona/shared/helpers/validators.ts | 23 +++++++++++-- 9 files changed, 95 insertions(+), 7 deletions(-) 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 31e63b6149499..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,7 +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')], []); + // 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 = () => { 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 d32cb695e1ef9..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,7 +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')], []); + // 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] 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 aab3ea155ece6..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,7 +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')], []); + // 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)], []); 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 ec4d9428c8738..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,7 +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')], []); + // const timeoutValidators = useMemo( + // () => [Validators.duration, Validators.minDuration('0s'), Validators.durationUnit({ s: true, m: true })], + // [] + // ); return (
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 317bf291abf94..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,7 +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')], []); + const timeoutValidators = useMemo( + () => [Validators.duration, Validators.minDuration('0s'), Validators.durationUnit({ s: true, m: true })], + [] + ); return (
diff --git a/public/app/percona/shared/helpers/duration.ts b/public/app/percona/shared/helpers/duration.ts index e3d8131cd2dc7..ceea2f2feae06 100644 --- a/public/app/percona/shared/helpers/duration.ts +++ b/public/app/percona/shared/helpers/duration.ts @@ -1,3 +1,5 @@ +import { UnitOptions } from './validator.types'; + export const isValidProtobufDuration = (durationString: string): boolean => /^-?[0-9]+(\.[0-9]+)?(ms|s|m)$/.test(durationString.trim()); @@ -18,3 +20,15 @@ export const durationToMs = (duration: string): number => { 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 9e26f01db8e04..34f00accbdd19 100644 --- a/public/app/percona/shared/helpers/validators.test.tsx +++ b/public/app/percona/shared/helpers/validators.test.tsx @@ -296,4 +296,38 @@ describe('validators compose', () => { 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 9ce22cfd92741..f7ffe8237d7d3 100644 --- a/public/app/percona/shared/helpers/validators.ts +++ b/public/app/percona/shared/helpers/validators.ts @@ -1,5 +1,5 @@ -import { Validator, VResult } from './validator.types'; -import { durationToMs, isValidProtobufDuration } from './duration'; +import { UnitOptions, Validator, VResult } from './validator.types'; +import { durationToMs, getDurationUnit, isValidProtobufDuration } from './duration'; export const validators = { validatePort: (value: any) => { @@ -113,6 +113,25 @@ export const validators = { 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;