From bcda5824775bd63177629fc42c4a5359def2a63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 27 Jul 2026 15:27:20 +0200 Subject: [PATCH 1/5] [FEATURE] Add test datasource connection button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- client/src/model/datasource-api.test.ts | 194 ++++++++++++++++++ client/src/model/datasource-api.ts | 73 ++++++- client/src/types/globals.d.ts | 20 ++ .../Datasources/DatasourceEditor.tsx | 16 +- .../DatasourceEditorForm.tsx | 16 +- .../DatasourceTestConnectionButton.test.tsx | 96 +++++++++ .../DatasourceTestConnectionButton.tsx | 42 ++++ .../DatasourceTestConnectionButton/index.ts | 14 ++ .../HTTPSettingsEditor.test.tsx | 122 +++++++++++ .../HTTPSettingsEditor/HTTPSettingsEditor.tsx | 75 ++++--- .../components/PluginEditor/PluginEditor.tsx | 2 + .../PluginEditor/plugin-editor-api.ts | 3 +- .../PluginSpecEditor.test.tsx | 117 ++++++++++- .../PluginSpecEditor/PluginSpecEditor.tsx | 48 ++++- plugin-system/src/components/index.ts | 1 + plugin-system/src/model/datasource.ts | 1 + plugin-system/src/model/plugin-base.ts | 1 + .../src/test/test-plugins/ernie/index.tsx | 29 ++- .../src/test/test-plugins/ernie/plugin.json | 10 + 19 files changed, 840 insertions(+), 40 deletions(-) create mode 100644 client/src/model/datasource-api.test.ts create mode 100644 client/src/types/globals.d.ts create mode 100644 plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx create mode 100644 plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx create mode 100644 plugin-system/src/components/DatasourceTestConnectionButton/index.ts diff --git a/client/src/model/datasource-api.test.ts b/client/src/model/datasource-api.test.ts new file mode 100644 index 00000000..e1ecb19c --- /dev/null +++ b/client/src/model/datasource-api.test.ts @@ -0,0 +1,194 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; +import { buildProxyUrl, createTestDatasourceConnection } from './datasource-api'; + +describe('buildProxyUrl', () => { + beforeEach(() => { + delete window.PERSES_APP_CONFIG; + }); + + test.each([ + { + title: 'should build global datasource proxy url', + input: { name: 'datasourceA' }, + expected: '/proxy/globaldatasources/datasourceA', + }, + { + title: 'should build unsaved global datasource proxy url', + input: {}, + expected: '/proxy/unsaved/globaldatasources', + }, + { + title: 'should build project datasource proxy url', + input: { project: 'projectA', name: 'datasourceA' }, + expected: '/proxy/projects/projectA/datasources/datasourceA', + }, + { + title: 'should build unsaved project datasource proxy url', + input: { project: 'projectA' }, + expected: '/proxy/unsaved/projects/projectA/datasources', + }, + { + title: 'should build dashboard datasource proxy url', + input: { project: 'projectA', dashboard: 'dashboardA', name: 'datasourceA' }, + expected: '/proxy/projects/projectA/dashboards/dashboardA/datasources/datasourceA', + }, + { + title: 'should build unsaved dashboard datasource proxy url', + input: { project: 'projectA', dashboard: 'dashboardA' }, + expected: '/proxy/unsaved/projects/projectA/dashboards/dashboardA/datasources', + }, + ])('$title', ({ input, expected }) => { + expect(buildProxyUrl(input)).toEqual(expected); + }); + + it('should URL-encode special characters in project, dashboard, and name', () => { + expect(buildProxyUrl({ project: 'my project', dashboard: 'my/dashboard', name: 'my datasource' })).toEqual( + '/proxy/projects/my%20project/dashboards/my%2Fdashboard/datasources/my%20datasource' + ); + }); + + it('should prepend api_prefix from window.PERSES_APP_CONFIG', () => { + window.PERSES_APP_CONFIG = { api_prefix: '/api/v1' }; + expect(buildProxyUrl({ name: 'datasourceA' })).toEqual('/api/v1/proxy/globaldatasources/datasourceA'); + }); + + it('should use empty prefix when api_prefix is absent', () => { + window.PERSES_APP_CONFIG = {}; + expect(buildProxyUrl({ name: 'datasourceA' })).toEqual('/proxy/globaldatasources/datasourceA'); + }); +}); + +describe('createTestDatasourceConnection', () => { + const mockFetch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + delete window.PERSES_APP_CONFIG; + globalThis.fetch = mockFetch; + }); + + const makeSpec = (pluginSpec: UnknownSpec): DatasourceSpec => ({ + default: false, + plugin: { kind: 'TestDatasource', spec: pluginSpec }, + }); + + describe('direct URL mode', () => { + it('calls the directUrl + healthCheckPath with GET', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({ directUrl: 'http://localhost:9090' }); + + await testConnection(spec, '/api/v1/query'); + + expect(mockFetch).toHaveBeenCalledWith('http://localhost:9090/api/v1/query', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + }); + + it('normalizes healthCheckPath that lacks a leading slash', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({ directUrl: 'http://localhost:9090' }); + + await testConnection(spec, 'api/v1/query'); + + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/v1/query'), expect.anything()); + }); + }); + + describe('proxy mode', () => { + it('POSTs to the unsaved global proxy endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } }); + + await testConnection(spec, '/api/v1/query'); + + expect(mockFetch).toHaveBeenCalledWith( + '/proxy/unsaved/globaldatasources/api/v1/query', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('POSTs to the unsaved project proxy endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const testConnection = createTestDatasourceConnection({ project: 'myProject' }); + const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } }); + + await testConnection(spec, '/api/v1/query'); + + expect(mockFetch).toHaveBeenCalledWith( + '/proxy/unsaved/projects/myProject/datasources/api/v1/query', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('POSTs to the unsaved dashboard proxy endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const testConnection = createTestDatasourceConnection({ project: 'myProject', dashboard: 'myDashboard' }); + const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } }); + + await testConnection(spec, '/api/v1/query'); + + expect(mockFetch).toHaveBeenCalledWith( + '/proxy/unsaved/projects/myProject/dashboards/myDashboard/datasources/api/v1/query', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('includes the full spec in the POST body', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } }); + + await testConnection(spec, '/api/v1/query'); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody).toMatchObject({ method: 'GET', body: null, spec }); + }); + }); + + describe('unsupported spec', () => { + it('throws for a spec with neither directUrl nor proxy', async () => { + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({ someOtherField: 'value' }); + + await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i); + }); + + it('throws for an empty HTTP spec (directUrl undefined, no proxy)', async () => { + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({}); + + await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i); + }); + + it('throws for a spec with directUrl set to undefined', async () => { + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({ directUrl: undefined }); + + await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i); + }); + + it('throws for a non-object spec', async () => { + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec('not-an-object' as unknown as UnknownSpec); + + await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i); + }); + }); +}); diff --git a/client/src/model/datasource-api.ts b/client/src/model/datasource-api.ts index eeefe728..050377dc 100644 --- a/client/src/model/datasource-api.ts +++ b/client/src/model/datasource-api.ts @@ -11,6 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { DatasourceSpec, HTTPDatasourceSpec } from '@perses-dev/spec'; +import { fetch } from '../util'; import { DatasourceResource, DatasourceSelector, GlobalDatasourceResource } from './datasource'; /** @@ -19,7 +21,8 @@ import { DatasourceResource, DatasourceSelector, GlobalDatasourceResource } from export interface BuildDatasourceProxyUrlParams { project?: string; dashboard?: string; - name: string; + /** Omit to generate an unsaved-datasource proxy URL */ + name?: string; } /** @@ -27,6 +30,74 @@ export interface BuildDatasourceProxyUrlParams { */ export type BuildDatasourceProxyUrlFunc = (params: BuildDatasourceProxyUrlParams) => string; +/** + * Builds a proxy URL for a datasource. When name is omitted the URL targets the + * unsaved-datasource proxy endpoint (used for testing connectivity before saving). + * Reads api_prefix from window.PERSES_APP_CONFIG if available. + */ +export function buildProxyUrl({ project, dashboard, name }: BuildDatasourceProxyUrlParams): string { + const apiPrefix = (typeof window !== 'undefined' && window.PERSES_APP_CONFIG?.api_prefix) || ''; + let url = `${!project && !dashboard ? 'globaldatasources' : 'datasources'}`; + if (dashboard) url = `dashboards/${encodeURIComponent(dashboard)}/${url}`; + if (project) url = `projects/${encodeURIComponent(project)}/${url}`; + url = name === undefined ? `unsaved/${url}` : `${url}/${encodeURIComponent(name)}`; + return `${apiPrefix}/proxy/${url}`; +} + +interface UnsavedDatasourceProxyBody { + method: string; + body?: Uint8Array | null; + spec: DatasourceSpec; +} + +function hasDirectUrl(pluginSpec: unknown): pluginSpec is { directUrl: string } { + return ( + typeof pluginSpec === 'object' && + pluginSpec !== null && + typeof (pluginSpec as Record)['directUrl'] === 'string' + ); +} + +function hasHTTPProxy( + pluginSpec: unknown +): pluginSpec is HTTPDatasourceSpec & { proxy: NonNullable } { + if (typeof pluginSpec !== 'object' || pluginSpec === null) return false; + const proxy = (pluginSpec as Record)['proxy']; + return typeof proxy === 'object' && proxy !== null; +} + +/** + * Creates a function that tests connectivity for an unsaved datasource. + * Supports directUrl (direct mode) and HTTPProxy (proxy mode). + * Throws for any spec that does not match either shape. + */ +export function createTestDatasourceConnection({ project, dashboard }: { project?: string; dashboard?: string } = {}): ( + spec: DatasourceSpec, + healthCheckPath: string +) => Promise { + return async (spec: DatasourceSpec, healthCheckPath: string): Promise => { + const normalizedPath = healthCheckPath.startsWith('/') ? healthCheckPath : `/${healthCheckPath}`; + const pluginSpec = spec.plugin.spec; + + if (hasDirectUrl(pluginSpec)) { + await fetch(`${pluginSpec.directUrl}${normalizedPath}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + } else if (hasHTTPProxy(pluginSpec)) { + const proxyUrl = buildProxyUrl({ project, dashboard }); + const body: UnsavedDatasourceProxyBody = { method: 'GET', spec, body: null }; + await fetch(`${proxyUrl}${normalizedPath}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } else { + throw new Error(`Unsupported datasource spec type for plugin kind '${spec.plugin.kind}'`); + } + }; +} + /** * The external API contract for fetching datasource resources. * This defines the interface that must be implemented to provide diff --git a/client/src/types/globals.d.ts b/client/src/types/globals.d.ts new file mode 100644 index 00000000..b8264b06 --- /dev/null +++ b/client/src/types/globals.d.ts @@ -0,0 +1,20 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +declare global { + interface Window { + PERSES_APP_CONFIG?: { api_prefix?: string }; + } +} + +export {}; diff --git a/dashboards/src/components/Datasources/DatasourceEditor.tsx b/dashboards/src/components/Datasources/DatasourceEditor.tsx index c894e2af..6cc27582 100644 --- a/dashboards/src/components/Datasources/DatasourceEditor.tsx +++ b/dashboards/src/components/Datasources/DatasourceEditor.tsx @@ -29,10 +29,10 @@ import PencilIcon from 'mdi-material-ui/Pencil'; import TrashIcon from 'mdi-material-ui/TrashCan'; import { DatasourceSpec } from '@perses-dev/spec'; import { DatasourceEditorForm, ValidationProvider } from '@perses-dev/plugin-system'; -import { ReactElement, useState } from 'react'; +import { ReactElement, useMemo, useState } from 'react'; import { useImmer } from 'use-immer'; -import { Action, DatasourceDefinition } from '@perses-dev/client'; -import { useDiscardChangesConfirmationDialog } from '../../context'; +import { Action, createTestDatasourceConnection, DatasourceDefinition } from '@perses-dev/client'; +import { useDashboard, useDiscardChangesConfirmationDialog } from '../../context'; export function DatasourceEditor(props: { datasources: Record; @@ -42,6 +42,15 @@ export function DatasourceEditor(props: { const [datasources, setDatasources] = useImmer(props.datasources); const [datasourceFormAction, setDatasourceFormAction] = useState('update'); const [datasourceEdit, setDatasourceEdit] = useState(null); + const { dashboard } = useDashboard(); + const testConnection = useMemo( + () => + createTestDatasourceConnection({ + project: dashboard.metadata.project, + dashboard: dashboard.metadata.name, + }), + [dashboard.metadata.project, dashboard.metadata.name] + ); const defaultSpec: DatasourceSpec = { default: false, plugin: { @@ -113,6 +122,7 @@ export function DatasourceEditor(props: { onClose={() => { setDatasourceEdit(null); }} + testConnection={testConnection} /> ) : ( diff --git a/plugin-system/src/components/DatasourceEditorForm/DatasourceEditorForm.tsx b/plugin-system/src/components/DatasourceEditorForm/DatasourceEditorForm.tsx index 3c35c974..431a1209 100644 --- a/plugin-system/src/components/DatasourceEditorForm/DatasourceEditorForm.tsx +++ b/plugin-system/src/components/DatasourceEditorForm/DatasourceEditorForm.tsx @@ -16,7 +16,7 @@ import { Box, Divider, FormControlLabel, Grid, Stack, Switch, TextField, Typogra import { DiscardChangesConfirmationDialog, FormActions, getSubmitText, getTitleAction } from '@perses-dev/components'; import { DispatchWithoutAction, ReactElement, useState } from 'react'; import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form'; -import { Action, DatasourceDefinition } from '@perses-dev/client'; +import { Action, DatasourceDefinition, DatasourceSpec } from '@perses-dev/client'; import { useValidationSchemas } from '../../context'; import { PluginEditor } from '../PluginEditor'; @@ -29,10 +29,21 @@ interface DatasourceEditorFormProps { onSave: (def: DatasourceDefinition) => void; onClose: DispatchWithoutAction; onDelete?: DispatchWithoutAction; + testConnection?: (spec: DatasourceSpec, healthCheckPath: string) => Promise; } export function DatasourceEditorForm(props: DatasourceEditorFormProps): ReactElement { - const { initialDatasourceDefinition, action, isDraft, isReadonly, onActionChange, onSave, onClose, onDelete } = props; + const { + initialDatasourceDefinition, + action, + isDraft, + isReadonly, + onActionChange, + onSave, + onClose, + onDelete, + testConnection, + } = props; const [isDiscardDialogOpened, setDiscardDialogOpened] = useState(false); const titleAction = getTitleAction(action, isDraft); @@ -222,6 +233,7 @@ export function DatasourceEditorForm(props: DatasourceEditorFormProps): ReactEle onChange={(v) => { field.onChange({ kind: v.selection.kind, spec: v.spec }); }} + testConnection={testConnection} /> )} /> diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx new file mode 100644 index 00000000..3576d97c --- /dev/null +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx @@ -0,0 +1,96 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarContext } from '@perses-dev/components'; +import { DatasourceTestConnectionButton } from './DatasourceTestConnectionButton'; + +const mockSuccessSnackbar = jest.fn(); +const mockExceptionSnackbar = jest.fn(); + +jest.mock('@perses-dev/components', () => ({ + ...jest.requireActual('@perses-dev/components'), + useSnackbar: (): Partial => ({ + successSnackbar: mockSuccessSnackbar, + exceptionSnackbar: mockExceptionSnackbar, + }), +})); + +describe('DatasourceTestConnectionButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the button with "Test Connection" label', () => { + render(); + expect(screen.getByRole('button', { name: /test connection/i })).toBeInTheDocument(); + }); + + it('calls testConnection when clicked', async () => { + const mockTestConnection = jest.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /test connection/i })); + + await waitFor(() => { + expect(mockTestConnection).toHaveBeenCalledTimes(1); + }); + }); + + it('shows success snackbar when testConnection resolves', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /test connection/i })); + + await waitFor(() => { + expect(mockSuccessSnackbar).toHaveBeenCalledWith('Datasource is healthy'); + }); + }); + + it('shows error snackbar when testConnection rejects with an Error', async () => { + const error = new Error('connection refused'); + render(); + + await userEvent.click(screen.getByRole('button', { name: /test connection/i })); + + await waitFor(() => { + expect(mockExceptionSnackbar).toHaveBeenCalledWith(error); + }); + }); + + it('wraps non-Error rejections in a generic Error', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /test connection/i })); + + await waitFor(() => { + expect(mockExceptionSnackbar).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + it('respects the disabled prop', () => { + render(); + expect(screen.getByRole('button', { name: /test connection/i })).toBeDisabled(); + }); + + it('does not call testConnection when disabled', async () => { + const mockTestConnection = jest.fn(); + render(); + + const button = screen.getByRole('button', { name: /test connection/i }); + expect(button).toBeDisabled(); + // disabled buttons do not fire click handlers; confirm testConnection is untouched + expect(mockTestConnection).not.toHaveBeenCalled(); + }); +}); diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx new file mode 100644 index 00000000..ca93a587 --- /dev/null +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx @@ -0,0 +1,42 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement, useCallback } from 'react'; +import { Button, ButtonProps } from '@mui/material'; +import { useSnackbar } from '@perses-dev/components'; + +type DatasourceTestConnectionButtonProps = { + testConnection: () => Promise; +} & Omit; + +export const DatasourceTestConnectionButton = ({ + testConnection, + ...buttonProps +}: DatasourceTestConnectionButtonProps): ReactElement => { + const { successSnackbar, exceptionSnackbar } = useSnackbar(); + + const handleClick = useCallback(async (): Promise => { + try { + await testConnection(); + successSnackbar('Datasource is healthy'); + } catch (e) { + exceptionSnackbar(e instanceof Error ? e : new Error('Datasource is not healthy')); + } + }, [testConnection, successSnackbar, exceptionSnackbar]); + + return ( + + ); +}; diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/index.ts b/plugin-system/src/components/DatasourceTestConnectionButton/index.ts new file mode 100644 index 00000000..78063c01 --- /dev/null +++ b/plugin-system/src/components/DatasourceTestConnectionButton/index.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './DatasourceTestConnectionButton'; diff --git a/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx b/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx index 60b850af..da9f2b5c 100644 --- a/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx +++ b/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx @@ -15,9 +15,21 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FormProvider, useForm } from 'react-hook-form'; import { ReactElement } from 'react'; +import { SnackbarContext } from '@perses-dev/components'; import { HTTPDatasourceSpec } from '@perses-dev/spec'; import { HTTPSettingsEditor } from './HTTPSettingsEditor'; +const mockSuccessSnackbar = jest.fn(); +const mockExceptionSnackbar = jest.fn(); + +jest.mock('@perses-dev/components', () => ({ + ...jest.requireActual('@perses-dev/components'), + useSnackbar: (): Partial => ({ + successSnackbar: mockSuccessSnackbar, + exceptionSnackbar: mockExceptionSnackbar, + }), +})); + describe('HTTPSettingsEditor - Request Headers', () => { const initialSpecDirect: HTTPDatasourceSpec = { directUrl: '', @@ -488,3 +500,113 @@ describe('HTTPSettingsEditor - Request Headers', () => { }); }); }); + +describe('HTTPSettingsEditor - Test Connection', () => { + const initialSpecDirect: HTTPDatasourceSpec = { + directUrl: '', + }; + + const initialSpecProxy: HTTPDatasourceSpec = { + proxy: { + kind: 'HTTPProxy', + spec: { + url: '', + }, + }, + }; + + const renderWithTestConnection = ( + value: HTTPDatasourceSpec, + testConnection?: () => Promise, + onChange = jest.fn() + ): ReturnType => { + const Wrapper = (): ReactElement => { + const methods = useForm(); + return ( + + + + ); + }; + return render(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not show "Test Connection" button when testConnection is not provided', () => { + const value: HTTPDatasourceSpec = { + proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } }, + }; + + renderWithTestConnection(value, undefined); + + expect(screen.queryByRole('button', { name: /test connection/i })).not.toBeInTheDocument(); + }); + + it('should show "Test Connection" button when testConnection is provided', () => { + const value: HTTPDatasourceSpec = { + proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } }, + }; + + renderWithTestConnection(value, jest.fn().mockResolvedValue(undefined)); + + expect(screen.getByRole('button', { name: /test connection/i })).toBeInTheDocument(); + }); + + it('should disable "Test Connection" button when proxy URL is empty', () => { + const value: HTTPDatasourceSpec = { + proxy: { kind: 'HTTPProxy', spec: { url: '' } }, + }; + + renderWithTestConnection(value, jest.fn().mockResolvedValue(undefined)); + + expect(screen.getByRole('button', { name: /test connection/i })).toBeDisabled(); + }); + + it('should disable "Test Connection" button when direct URL is empty', () => { + const value: HTTPDatasourceSpec = { directUrl: '' }; + + renderWithTestConnection(value, jest.fn().mockResolvedValue(undefined)); + + expect(screen.getByRole('button', { name: /test connection/i })).toBeDisabled(); + }); + + it('should show success snackbar when testConnection resolves', async () => { + const mockTestConnection = jest.fn().mockResolvedValue(undefined); + const value: HTTPDatasourceSpec = { + proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } }, + }; + + renderWithTestConnection(value, mockTestConnection); + + await userEvent.click(screen.getByRole('button', { name: /test connection/i })); + + await waitFor(() => { + expect(mockTestConnection).toHaveBeenCalledTimes(1); + expect(mockSuccessSnackbar).toHaveBeenCalledWith('Datasource is healthy'); + }); + }); + + it('should show error snackbar when testConnection rejects', async () => { + const mockTestConnection = jest.fn().mockRejectedValue(new Error('connection refused')); + const value: HTTPDatasourceSpec = { + proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } }, + }; + + renderWithTestConnection(value, mockTestConnection); + + await userEvent.click(screen.getByRole('button', { name: /test connection/i })); + + await waitFor(() => { + expect(mockExceptionSnackbar).toHaveBeenCalledWith(expect.any(Error)); + }); + }); +}); diff --git a/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx b/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx index 7fc1012f..eb89cf73 100644 --- a/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx +++ b/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx @@ -11,8 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Grid, IconButton, MenuItem, TextField, Typography } from '@mui/material'; +import { Box, Grid, IconButton, MenuItem, TextField, Typography } from '@mui/material'; import React, { Fragment, ReactElement, useState } from 'react'; +import { z } from 'zod'; import { produce } from 'immer'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; import MinusIcon from 'mdi-material-ui/Minus'; @@ -20,6 +21,9 @@ import PlusIcon from 'mdi-material-ui/Plus'; import { HTTPDatasourceSpec } from '@perses-dev/spec'; import { RequestHeaders } from '@perses-dev/client'; import { OptionsEditorRadios } from '../OptionsEditorRadios'; +import { DatasourceTestConnectionButton } from '../DatasourceTestConnectionButton'; + +const urlSchema = z.string().url(); type HeaderEntry = { name: string; @@ -36,10 +40,11 @@ export interface HTTPSettingsEditor { isReadonly?: boolean; initialSpecDirect: HTTPDatasourceSpec; initialSpecProxy: HTTPDatasourceSpec; + testConnection?: () => Promise; } export function HTTPSettingsEditor(props: HTTPSettingsEditor): ReactElement { - const { value, onChange, isReadonly, initialSpecDirect, initialSpecProxy } = props; + const { value, onChange, isReadonly, initialSpecDirect, initialSpecProxy, testConnection } = props; const strDirect = 'Direct access'; const strProxy = 'Proxy'; @@ -134,6 +139,14 @@ export function HTTPSettingsEditor(props: HTTPSettingsEditor): ReactElement { /> )} /> + {testConnection && ( + + + + )} Allowed endpoints @@ -410,31 +423,41 @@ export function HTTPSettingsEditor(props: HTTPSettingsEditor): ReactElement { { label: strDirect, content: ( - ( - { - field.onChange(e); - onChange( - produce(value, (draft) => { - draft.directUrl = e.target.value; - }) - ); - }} - /> + <> + ( + { + field.onChange(e); + onChange( + produce(value, (draft) => { + draft.directUrl = e.target.value; + }) + ); + }} + /> + )} + /> + {testConnection && ( + + + )} - /> + ), }, ]; diff --git a/plugin-system/src/components/PluginEditor/PluginEditor.tsx b/plugin-system/src/components/PluginEditor/PluginEditor.tsx index b585e630..49279a19 100644 --- a/plugin-system/src/components/PluginEditor/PluginEditor.tsx +++ b/plugin-system/src/components/PluginEditor/PluginEditor.tsx @@ -39,6 +39,7 @@ export function PluginEditor(props: PluginEditorProps): ReactElement { isReadonly, onRunQuery, filteredQueryPlugins, + testConnection, ...others } = props; @@ -97,6 +98,7 @@ export function PluginEditor(props: PluginEditorProps): ReactElement { value={value.spec} onChange={handleSpecChange} isReadonly={isReadonly} + testConnection={testConnection} /> diff --git a/plugin-system/src/components/PluginEditor/plugin-editor-api.ts b/plugin-system/src/components/PluginEditor/plugin-editor-api.ts index 83fe3574..aef0887c 100644 --- a/plugin-system/src/components/PluginEditor/plugin-editor-api.ts +++ b/plugin-system/src/components/PluginEditor/plugin-editor-api.ts @@ -12,7 +12,7 @@ // limitations under the License. import { BoxProps } from '@mui/material'; -import { UnknownSpec } from '@perses-dev/spec'; +import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; import { useState, useRef, useEffect } from 'react'; import { produce } from 'immer'; import { PanelPlugin, PluginType } from '../../model'; @@ -44,6 +44,7 @@ export interface PluginEditorProps extends Omit { filteredQueryPlugins?: string[]; onChange: (next: PluginEditorValue) => void; onRunQuery?: () => void; + testConnection?: (spec: DatasourceSpec, healthCheckPath: string) => Promise; } export interface PluginEditorRef { diff --git a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.test.tsx b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.test.tsx index 60d7524e..dd45b14f 100644 --- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.test.tsx +++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.test.tsx @@ -12,7 +12,8 @@ // limitations under the License. import userEvent from '@testing-library/user-event'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import { DatasourceSpec } from '@perses-dev/spec'; import { renderWithContext } from '../../test'; import { PluginSpecEditor, PluginSpecEditorProps } from './PluginSpecEditor'; @@ -55,3 +56,117 @@ describe('PluginSpecEditor', () => { } }); }); + +describe('PluginSpecEditor - boundTestConnection', () => { + const renderComponent = (props: PluginSpecEditorProps): void => { + renderWithContext(); + }; + + it('does not pass testConnection when testConnection prop is absent', async () => { + renderComponent({ + pluginSelection: { type: 'Datasource', kind: 'ErnieDatasource' }, + value: { url: 'http://localhost:9090' }, + onChange: jest.fn(), + }); + await screen.findByLabelText('ErnieDatasource editor'); + expect(screen.queryByRole('button', { name: 'test-connection-trigger' })).not.toBeInTheDocument(); + }); + + it('does not pass testConnection when plugin has no healthCheckPath', async () => { + const testConnection = jest.fn(); + renderComponent({ + pluginSelection: { type: 'Variable', kind: 'ErnieVariable1' }, + value: {}, + onChange: jest.fn(), + testConnection, + }); + await screen.findByLabelText('ErnieVariable editor'); + expect(screen.queryByRole('button', { name: 'test-connection-trigger' })).not.toBeInTheDocument(); + }); + + it('passes a bound testConnection when plugin has healthCheckPath', async () => { + const testConnection = jest.fn().mockResolvedValue(undefined); + renderComponent({ + pluginSelection: { type: 'Datasource', kind: 'ErnieDatasource' }, + value: { url: 'http://localhost:9090' }, + onChange: jest.fn(), + testConnection, + }); + await screen.findByLabelText('ErnieDatasource editor'); + expect(screen.getByRole('button', { name: 'test-connection-trigger' })).toBeInTheDocument(); + }); + + it('calls testConnection with the full DatasourceSpec and healthCheckPath', async () => { + const testConnection = jest.fn().mockResolvedValue(undefined); + const pluginSpec = { url: 'http://localhost:9090' }; + renderComponent({ + pluginSelection: { type: 'Datasource', kind: 'ErnieDatasource' }, + value: pluginSpec, + onChange: jest.fn(), + testConnection, + }); + await screen.findByLabelText('ErnieDatasource editor'); + await userEvent.click(screen.getByRole('button', { name: 'test-connection-trigger' })); + + await waitFor(() => { + expect(testConnection).toHaveBeenCalledWith( + expect.objectContaining>({ + default: false, + plugin: { kind: 'ErnieDatasource', spec: pluginSpec }, + }), + '/api/v1/query' + ); + }); + }); + + it('augments allowedEndpoints with the healthCheckPath when proxy spec is present', async () => { + const testConnection = jest.fn().mockResolvedValue(undefined); + const pluginSpec = { + proxy: { + kind: 'HTTPProxy' as const, + spec: { url: 'http://localhost:9090', allowedEndpoints: [] }, + }, + }; + renderComponent({ + pluginSelection: { type: 'Datasource', kind: 'ErnieDatasource' }, + value: pluginSpec, + onChange: jest.fn(), + testConnection, + }); + await screen.findByLabelText('ErnieDatasource editor'); + await userEvent.click(screen.getByRole('button', { name: 'test-connection-trigger' })); + + await waitFor(() => { + const calledSpec: DatasourceSpec = testConnection.mock.calls[0][0]; + const allowedEndpoints = (calledSpec.plugin.spec as typeof pluginSpec).proxy.spec.allowedEndpoints; + expect(allowedEndpoints).toContainEqual({ endpointPattern: '/api/v1/query', method: 'GET' }); + }); + }); + + it('does not duplicate allowedEndpoints when healthCheckPath already present', async () => { + const testConnection = jest.fn().mockResolvedValue(undefined); + const pluginSpec = { + proxy: { + kind: 'HTTPProxy' as const, + spec: { + url: 'http://localhost:9090', + allowedEndpoints: [{ endpointPattern: '/api/v1/query', method: 'GET' }], + }, + }, + }; + renderComponent({ + pluginSelection: { type: 'Datasource', kind: 'ErnieDatasource' }, + value: pluginSpec, + onChange: jest.fn(), + testConnection, + }); + await screen.findByLabelText('ErnieDatasource editor'); + await userEvent.click(screen.getByRole('button', { name: 'test-connection-trigger' })); + + await waitFor(() => { + const calledSpec: DatasourceSpec = testConnection.mock.calls[0][0]; + const allowedEndpoints = (calledSpec.plugin.spec as typeof pluginSpec).proxy.spec.allowedEndpoints; + expect(allowedEndpoints?.filter((e) => e.endpointPattern === '/api/v1/query')).toHaveLength(1); + }); + }); +}); diff --git a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx index 7f2a9551..60310be9 100644 --- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx +++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx @@ -12,24 +12,60 @@ // limitations under the License. import { ErrorAlert } from '@perses-dev/components'; -import { UnknownSpec } from '@perses-dev/spec'; -import { ReactElement } from 'react'; +import { DatasourceSpec, UnknownSpec, HTTPProxy } from '@perses-dev/spec'; +import { ReactElement, useMemo } from 'react'; import { CircularProgress, Stack } from '@mui/material'; -import { OptionsEditorProps } from '../../model'; +import { produce } from 'immer'; +import { DatasourcePlugin, OptionsEditorProps, Plugin } from '../../model'; import { usePlugin } from '../../runtime'; import { PluginEditorSelection } from '../PluginEditor'; -export interface PluginSpecEditorProps extends OptionsEditorProps { +export interface PluginSpecEditorProps extends Omit, 'testConnection'> { pluginSelection: PluginEditorSelection; isEditor?: boolean; + testConnection?: (spec: DatasourceSpec, healthCheckPath: string) => Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasHTTPProxy(spec: UnknownSpec): spec is { proxy: HTTPProxy } { + return isRecord(spec) && isRecord(spec['proxy']) && spec['proxy']['kind'] === 'HTTPProxy'; +} + +function isDatasourcePlugin(plugin: Plugin): plugin is DatasourcePlugin { + return 'createClient' in plugin; } export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | null { const { pluginSelection: { type: pluginType, kind: pluginKind }, + value, + testConnection, ...others } = props; const { data: plugin, isLoading, error } = usePlugin(pluginType, pluginKind); + + const healthCheckPath = plugin && isDatasourcePlugin(plugin) ? plugin.healthCheckPath : undefined; + + const boundTestConnection = useMemo((): (() => Promise) | undefined => { + if (!testConnection || !healthCheckPath) return undefined; + return () => { + const augmentedPluginSpec = hasHTTPProxy(value) + ? produce(value, (draft) => { + const existing = draft.proxy.spec.allowedEndpoints ?? []; + const alreadyAllowed = existing.some((e) => e.endpointPattern === healthCheckPath && e.method === 'GET'); + if (!alreadyAllowed) { + draft.proxy.spec.allowedEndpoints = [...existing, { endpointPattern: healthCheckPath, method: 'GET' }]; + } + }) + : value; + const spec: DatasourceSpec = { default: false, plugin: { kind: pluginKind, spec: augmentedPluginSpec } }; + return testConnection(spec, healthCheckPath); + }; + }, [testConnection, healthCheckPath, pluginKind, value]); + if (error) { return ; } @@ -51,5 +87,7 @@ export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | n } const { OptionsEditorComponent } = plugin; - return OptionsEditorComponent ? : null; + return OptionsEditorComponent ? ( + + ) : null; } diff --git a/plugin-system/src/components/index.ts b/plugin-system/src/components/index.ts index da728a4c..a4165f8e 100644 --- a/plugin-system/src/components/index.ts +++ b/plugin-system/src/components/index.ts @@ -30,3 +30,4 @@ export * from './PluginSpecEditor'; export * from './TimeRangeControls'; export * from './Variables'; export * from './MetricLabelInput'; +export * from './DatasourceTestConnectionButton'; diff --git a/plugin-system/src/model/datasource.ts b/plugin-system/src/model/datasource.ts index 4e601027..61cf435e 100644 --- a/plugin-system/src/model/datasource.ts +++ b/plugin-system/src/model/datasource.ts @@ -21,6 +21,7 @@ export interface DatasourcePlugin extends createClient: (spec: Spec, options: DatasourceClientOptions) => Client; // Provide builtin variable definitions available on the datasource. Optional getBuiltinVariableDefinitions?: () => BuiltinVariableDefinition[]; + healthCheckPath?: string; } export interface DatasourceClientOptions { diff --git a/plugin-system/src/model/plugin-base.ts b/plugin-system/src/model/plugin-base.ts index f59f4bf6..d3818af8 100644 --- a/plugin-system/src/model/plugin-base.ts +++ b/plugin-system/src/model/plugin-base.ts @@ -38,6 +38,7 @@ export interface OptionsEditorProps { value: Spec; onChange: (next: Spec) => void; isReadonly?: boolean; + testConnection?: () => Promise; } /** diff --git a/plugin-system/src/test/test-plugins/ernie/index.tsx b/plugin-system/src/test/test-plugins/ernie/index.tsx index 1912d3d4..e4c56bfa 100644 --- a/plugin-system/src/test/test-plugins/ernie/index.tsx +++ b/plugin-system/src/test/test-plugins/ernie/index.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { VariablePlugin, VariableOption } from '../../../model'; +import { DatasourcePlugin, OptionsEditorProps, VariableOption, VariablePlugin } from '../../../model'; const data: VariableOption[] = [ { label: 'Grover', value: 'Grover' }, @@ -55,7 +55,34 @@ const ErnieVariable2: VariablePlugin<{ variableOption2: string }> = { createInitialOptions: () => ({ variableOption2: '' }), }; +type ErnieDatasourceSpec = { url?: string }; + +const ErnieDatasource: DatasourcePlugin = { + createClient: () => ({}), + createInitialOptions: () => ({}), + healthCheckPath: '/api/v1/query', + OptionsEditorComponent: function ErnieDatasourceEditor({ + value, + onChange, + testConnection, + }: OptionsEditorProps) { + return ( +
+ + onChange({ ...value, url: e.target.value })} + /> + {testConnection && } +
+ ); + }, +}; + export const plugins = { 'Variable:ErnieVariable1::1.0.0': ErnieVariable1, 'Variable:ErnieVariable2::1.0.0': ErnieVariable2, + 'Datasource:ErnieDatasource::1.0.0': ErnieDatasource, }; diff --git a/plugin-system/src/test/test-plugins/ernie/plugin.json b/plugin-system/src/test/test-plugins/ernie/plugin.json index 95f09d3e..f029fe68 100644 --- a/plugin-system/src/test/test-plugins/ernie/plugin.json +++ b/plugin-system/src/test/test-plugins/ernie/plugin.json @@ -32,6 +32,16 @@ "description": "A variable plugin that exists here in the metadata, but is not in the plugin module" } } + }, + { + "kind": "Datasource", + "spec": { + "name": "ErnieDatasource", + "display": { + "name": "Ernie Datasource", + "description": "A datasource plugin for testing" + } + } } ] } From 6f1cf34e202a6c27a9065b5d39c1387305bc9a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Tue, 28 Jul 2026 12:34:54 +0200 Subject: [PATCH 2/5] [ENHANCEMENT] Disable test connection button while request is in flight to prevent duplicate calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../DatasourceTestConnectionButton.test.tsx | 18 +++++++++++++----- .../DatasourceTestConnectionButton.tsx | 9 +++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx index 3576d97c..5aac238b 100644 --- a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx @@ -84,13 +84,21 @@ describe('DatasourceTestConnectionButton', () => { expect(screen.getByRole('button', { name: /test connection/i })).toBeDisabled(); }); - it('does not call testConnection when disabled', async () => { - const mockTestConnection = jest.fn(); - render(); + it('disables the button while testConnection is in flight and re-enables after', async () => { + let resolve!: () => void; + const mockTestConnection = jest.fn( + () => new Promise((res) => { resolve = res; }) + ); + render(); const button = screen.getByRole('button', { name: /test connection/i }); + await userEvent.click(button); + + // button is disabled while in flight — a second request cannot be triggered expect(button).toBeDisabled(); - // disabled buttons do not fire click handlers; confirm testConnection is untouched - expect(mockTestConnection).not.toHaveBeenCalled(); + expect(mockTestConnection).toHaveBeenCalledTimes(1); + + resolve(); + await waitFor(() => expect(button).not.toBeDisabled()); }); }); diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx index ca93a587..12af72a0 100644 --- a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement, useCallback } from 'react'; +import { ReactElement, useCallback, useState } from 'react'; import { Button, ButtonProps } from '@mui/material'; import { useSnackbar } from '@perses-dev/components'; @@ -21,21 +21,26 @@ type DatasourceTestConnectionButtonProps = { export const DatasourceTestConnectionButton = ({ testConnection, + disabled, ...buttonProps }: DatasourceTestConnectionButtonProps): ReactElement => { const { successSnackbar, exceptionSnackbar } = useSnackbar(); + const [isTesting, setIsTesting] = useState(false); const handleClick = useCallback(async (): Promise => { + setIsTesting(true); try { await testConnection(); successSnackbar('Datasource is healthy'); } catch (e) { exceptionSnackbar(e instanceof Error ? e : new Error('Datasource is not healthy')); + } finally { + setIsTesting(false); } }, [testConnection, successSnackbar, exceptionSnackbar]); return ( - ); From c714e9ec94c3e34b926f697c60812977f898acc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Tue, 28 Jul 2026 12:51:06 +0200 Subject: [PATCH 3/5] [BUGFIX] Normalize trailing slash in directUrl before appending health check path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- client/src/model/datasource-api.test.ts | 13 +++++++++++++ client/src/model/datasource-api.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/client/src/model/datasource-api.test.ts b/client/src/model/datasource-api.test.ts index e1ecb19c..45b3dd5a 100644 --- a/client/src/model/datasource-api.test.ts +++ b/client/src/model/datasource-api.test.ts @@ -99,6 +99,19 @@ describe('createTestDatasourceConnection', () => { }); }); + it('does not produce a double slash when directUrl ends with a trailing slash', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const testConnection = createTestDatasourceConnection(); + const spec = makeSpec({ directUrl: 'http://localhost:9090/' }); + + await testConnection(spec, '/api/v1/query'); + + expect(mockFetch).toHaveBeenCalledWith('http://localhost:9090/api/v1/query', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + }); + it('normalizes healthCheckPath that lacks a leading slash', async () => { mockFetch.mockResolvedValue({ ok: true }); const testConnection = createTestDatasourceConnection(); diff --git a/client/src/model/datasource-api.ts b/client/src/model/datasource-api.ts index 050377dc..7dd08b49 100644 --- a/client/src/model/datasource-api.ts +++ b/client/src/model/datasource-api.ts @@ -80,7 +80,7 @@ export function createTestDatasourceConnection({ project, dashboard }: { project const pluginSpec = spec.plugin.spec; if (hasDirectUrl(pluginSpec)) { - await fetch(`${pluginSpec.directUrl}${normalizedPath}`, { + await fetch(`${pluginSpec.directUrl.replace(/\/$/, '')}${normalizedPath}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); From 7901be82518c67edd3a5a67dfecedcd8f6cc26ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Tue, 28 Jul 2026 16:10:12 +0200 Subject: [PATCH 4/5] [ENHANCEMENT] Extract datasource testConnection binding into DatasourceSpecEditor component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../PluginSpecEditor/DatasourceSpecEditor.tsx | 63 +++++++++++++++++++ .../PluginSpecEditor/PluginSpecEditor.tsx | 62 +++++++----------- plugin-system/src/model/datasource.ts | 15 ++++- plugin-system/src/model/plugin-base.ts | 5 +- .../src/test/test-plugins/ernie/index.tsx | 4 +- 5 files changed, 104 insertions(+), 45 deletions(-) create mode 100644 plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx diff --git a/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx new file mode 100644 index 00000000..4908d559 --- /dev/null +++ b/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx @@ -0,0 +1,63 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DatasourceSpec, HTTPProxy, UnknownSpec } from '@perses-dev/spec'; +import { ReactElement, useMemo } from 'react'; +import { produce } from 'immer'; +import { DatasourcePlugin, OptionsEditorProps } from '../../model'; + +export interface DatasourceSpecEditorProps extends OptionsEditorProps { + plugin: DatasourcePlugin; + pluginKind: string; + testConnection?: (spec: DatasourceSpec, healthCheckPath: string) => Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasHTTPProxy(spec: UnknownSpec): spec is { proxy: HTTPProxy } { + return isRecord(spec) && isRecord(spec['proxy']) && spec['proxy']['kind'] === 'HTTPProxy'; +} + +export function DatasourceSpecEditor({ + plugin, + pluginKind, + value, + testConnection, + ...others +}: DatasourceSpecEditorProps): ReactElement | null { + const healthCheckPath = plugin.healthCheckPath; + + const boundTestConnection = useMemo((): (() => Promise) | undefined => { + if (!testConnection || !healthCheckPath) return undefined; + return () => { + const augmentedPluginSpec = hasHTTPProxy(value) + ? produce(value, (draft) => { + const existing = draft.proxy.spec.allowedEndpoints ?? []; + const alreadyAllowed = existing.some((e) => e.endpointPattern === healthCheckPath && e.method === 'GET'); + if (!alreadyAllowed) { + draft.proxy.spec.allowedEndpoints = [...existing, { endpointPattern: healthCheckPath, method: 'GET' }]; + } + }) + : value; + const spec: DatasourceSpec = { default: false, plugin: { kind: pluginKind, spec: augmentedPluginSpec } }; + return testConnection(spec, healthCheckPath); + }; + }, [testConnection, healthCheckPath, pluginKind, value]); + + const { OptionsEditorComponent } = plugin; + return OptionsEditorComponent ? ( + + ) : null; +} diff --git a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx index 60310be9..8739c09c 100644 --- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx +++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx @@ -12,30 +12,25 @@ // limitations under the License. import { ErrorAlert } from '@perses-dev/components'; -import { DatasourceSpec, UnknownSpec, HTTPProxy } from '@perses-dev/spec'; -import { ReactElement, useMemo } from 'react'; +import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; +import { ReactElement } from 'react'; import { CircularProgress, Stack } from '@mui/material'; -import { produce } from 'immer'; -import { DatasourcePlugin, OptionsEditorProps, Plugin } from '../../model'; +import { DatasourcePlugin, OptionsEditorProps, Plugin, PluginType } from '../../model'; import { usePlugin } from '../../runtime'; import { PluginEditorSelection } from '../PluginEditor'; +import { DatasourceSpecEditor } from './DatasourceSpecEditor'; -export interface PluginSpecEditorProps extends Omit, 'testConnection'> { +export interface PluginSpecEditorProps extends OptionsEditorProps { pluginSelection: PluginEditorSelection; isEditor?: boolean; testConnection?: (spec: DatasourceSpec, healthCheckPath: string) => Promise; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function hasHTTPProxy(spec: UnknownSpec): spec is { proxy: HTTPProxy } { - return isRecord(spec) && isRecord(spec['proxy']) && spec['proxy']['kind'] === 'HTTPProxy'; -} - -function isDatasourcePlugin(plugin: Plugin): plugin is DatasourcePlugin { - return 'createClient' in plugin; +function isDatasourcePlugin( + pluginType: PluginType, + plugin: Plugin +): plugin is DatasourcePlugin { + return pluginType === 'Datasource' && 'createClient' in plugin; } export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | null { @@ -47,25 +42,6 @@ export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | n } = props; const { data: plugin, isLoading, error } = usePlugin(pluginType, pluginKind); - const healthCheckPath = plugin && isDatasourcePlugin(plugin) ? plugin.healthCheckPath : undefined; - - const boundTestConnection = useMemo((): (() => Promise) | undefined => { - if (!testConnection || !healthCheckPath) return undefined; - return () => { - const augmentedPluginSpec = hasHTTPProxy(value) - ? produce(value, (draft) => { - const existing = draft.proxy.spec.allowedEndpoints ?? []; - const alreadyAllowed = existing.some((e) => e.endpointPattern === healthCheckPath && e.method === 'GET'); - if (!alreadyAllowed) { - draft.proxy.spec.allowedEndpoints = [...existing, { endpointPattern: healthCheckPath, method: 'GET' }]; - } - }) - : value; - const spec: DatasourceSpec = { default: false, plugin: { kind: pluginKind, spec: augmentedPluginSpec } }; - return testConnection(spec, healthCheckPath); - }; - }, [testConnection, healthCheckPath, pluginKind, value]); - if (error) { return ; } @@ -85,9 +61,19 @@ export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | n if (pluginType === 'Panel') { throw new Error('This editor should not be used for panel type. Please use Panel Spec Editor instead.'); } - const { OptionsEditorComponent } = plugin; - return OptionsEditorComponent ? ( - - ) : null; + if (isDatasourcePlugin(pluginType, plugin)) { + return ( + + ); + } + + const { OptionsEditorComponent } = plugin; + return OptionsEditorComponent ? : null; } diff --git a/plugin-system/src/model/datasource.ts b/plugin-system/src/model/datasource.ts index 61cf435e..3b065839 100644 --- a/plugin-system/src/model/datasource.ts +++ b/plugin-system/src/model/datasource.ts @@ -12,12 +12,16 @@ // limitations under the License. import { BuiltinVariableDefinition, UnknownSpec } from '@perses-dev/spec'; -import { Plugin } from './plugin-base'; +import { OptionsEditorProps, Plugin } from './plugin-base'; /** * Plugin that defines options for an external system that Perses talks to for data. */ -export interface DatasourcePlugin extends Plugin { +export interface DatasourcePlugin< + Spec = UnknownSpec, + Client = unknown, + OptionsEditorPropsType = DatasourceEditorProps, +> extends Plugin { createClient: (spec: Spec, options: DatasourceClientOptions) => Client; // Provide builtin variable definitions available on the datasource. Optional getBuiltinVariableDefinitions?: () => BuiltinVariableDefinition[]; @@ -36,3 +40,10 @@ export interface DatasourceClient { kind?: string; healthCheck?: () => Promise; } + +/** + * Common props passed to datasource options editor components. + */ +export interface DatasourceEditorProps extends OptionsEditorProps { + testConnection?: () => Promise; +} diff --git a/plugin-system/src/model/plugin-base.ts b/plugin-system/src/model/plugin-base.ts index d3818af8..73c6766d 100644 --- a/plugin-system/src/model/plugin-base.ts +++ b/plugin-system/src/model/plugin-base.ts @@ -17,11 +17,11 @@ import React from 'react'; /** * Base type of all plugin implementations. */ -export interface Plugin { +export interface Plugin> { /** * React component for editing the plugin's options in the UI. */ - OptionsEditorComponent?: React.ComponentType>; + OptionsEditorComponent?: React.ComponentType; /** * Callback for creating the initial options for the plugin. @@ -38,7 +38,6 @@ export interface OptionsEditorProps { value: Spec; onChange: (next: Spec) => void; isReadonly?: boolean; - testConnection?: () => Promise; } /** diff --git a/plugin-system/src/test/test-plugins/ernie/index.tsx b/plugin-system/src/test/test-plugins/ernie/index.tsx index e4c56bfa..17795985 100644 --- a/plugin-system/src/test/test-plugins/ernie/index.tsx +++ b/plugin-system/src/test/test-plugins/ernie/index.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { DatasourcePlugin, OptionsEditorProps, VariableOption, VariablePlugin } from '../../../model'; +import { DatasourceEditorProps, DatasourcePlugin, VariableOption, VariablePlugin } from '../../../model'; const data: VariableOption[] = [ { label: 'Grover', value: 'Grover' }, @@ -65,7 +65,7 @@ const ErnieDatasource: DatasourcePlugin = { value, onChange, testConnection, - }: OptionsEditorProps) { + }: DatasourceEditorProps) { return (
From 3dc45bf2a33af60d23c13b08f882f17e6bcc3fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Tue, 28 Jul 2026 16:21:15 +0200 Subject: [PATCH 5/5] [BUGFIX] Escape regex special characters in healthCheckPath before inserting into allowedEndpoints endpointPattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../DatasourceTestConnectionButton.test.tsx | 5 ++++- .../components/PluginSpecEditor/DatasourceSpecEditor.tsx | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx index 5aac238b..10b1760f 100644 --- a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx @@ -87,7 +87,10 @@ describe('DatasourceTestConnectionButton', () => { it('disables the button while testConnection is in flight and re-enables after', async () => { let resolve!: () => void; const mockTestConnection = jest.fn( - () => new Promise((res) => { resolve = res; }) + () => + new Promise((res) => { + resolve = res; + }) ); render(); diff --git a/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx index 4908d559..e317fa1c 100644 --- a/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx +++ b/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx @@ -22,6 +22,10 @@ export interface DatasourceSpecEditorProps extends OptionsEditorProps Promise; } +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -42,12 +46,13 @@ export function DatasourceSpecEditor({ const boundTestConnection = useMemo((): (() => Promise) | undefined => { if (!testConnection || !healthCheckPath) return undefined; return () => { + const escapedPattern = escapeRegExp(healthCheckPath); const augmentedPluginSpec = hasHTTPProxy(value) ? produce(value, (draft) => { const existing = draft.proxy.spec.allowedEndpoints ?? []; - const alreadyAllowed = existing.some((e) => e.endpointPattern === healthCheckPath && e.method === 'GET'); + const alreadyAllowed = existing.some((e) => e.endpointPattern === escapedPattern && e.method === 'GET'); if (!alreadyAllowed) { - draft.proxy.spec.allowedEndpoints = [...existing, { endpointPattern: healthCheckPath, method: 'GET' }]; + draft.proxy.spec.allowedEndpoints = [...existing, { endpointPattern: escapedPattern, method: 'GET' }]; } }) : value;