From 4f54942616e71ef007708303c795ca97104725d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Tue, 7 Apr 2026 09:41:35 +0200 Subject: [PATCH] [ENHANCEMENT] Add UnsavedDatasourceStore and DatasourceTestConnectionButton for testing datasource connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../src/views/ViewDashboard/ViewDashboard.tsx | 99 +++---- .../DatasourceTestConnectionButton.tsx | 60 +++++ .../DatasourceTestConnectionButton/index.ts | 1 + .../HTTPSettingsEditor.test.tsx | 251 ++++++++++++++++++ .../HTTPSettingsEditor/HTTPSettingsEditor.tsx | 93 +++++-- plugin-system/src/components/index.ts | 1 + .../src/context/UnsavedDatasourceProvider.tsx | 80 ++++++ plugin-system/src/context/index.ts | 1 + plugin-system/src/runtime/datasources.ts | 20 ++ 9 files changed, 534 insertions(+), 72 deletions(-) create mode 100644 plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx create mode 100644 plugin-system/src/components/DatasourceTestConnectionButton/index.ts create mode 100644 plugin-system/src/context/UnsavedDatasourceProvider.tsx diff --git a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx index 6725cc7..3f4888e 100644 --- a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx +++ b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx @@ -20,6 +20,7 @@ import { useInitialRefreshInterval, useInitialTimeRange, usePluginBuiltinVariableDefinitions, + UnsavedDatasourceProvider, } from '@perses-dev/plugin-system'; import { ReactElement, useMemo } from 'react'; import { @@ -103,54 +104,60 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { }, [dashboardResource.metadata.name, dashboardResource.metadata.project, data]); return ( - - - + + - - - - - - - - - - + + + + + + + + + + ); } diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx new file mode 100644 index 0000000..9eadc3c --- /dev/null +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx @@ -0,0 +1,60 @@ +import { ReactElement, useCallback } from 'react'; +import { Button, ButtonProps } from '@mui/material'; +import { useUnsavedDatasourceStore } from '@perses-dev/plugin-system'; +import { useSnackbar } from '@perses-dev/components'; +import { DatasourceSpec } from '@perses-dev/core'; + +type DatasourceTestConnectionButtonProps = ( + | { + connectionType: 'proxy'; + spec: DatasourceSpec; + directUrl?: undefined; + healthCheckPath: string; + } + | { + connectionType: 'direct'; + spec?: undefined; + directUrl: string; + healthCheckPath: string; + } +) & + Omit; +export const DatasourceTestConnectionButton = ({ + healthCheckPath, + connectionType, + spec, + directUrl, + ...buttonProps +}: DatasourceTestConnectionButtonProps): ReactElement => { + const datasourceStore = useUnsavedDatasourceStore(); + const { successSnackbar, exceptionSnackbar } = useSnackbar(); + + const testConnection = useCallback( + async function isHealthy(): Promise { + switch (connectionType) { + case 'direct': + return datasourceStore.testDirectConnection(directUrl, healthCheckPath); + case 'proxy': + return datasourceStore.testProxyConnection(spec, healthCheckPath); + } + }, + [connectionType, datasourceStore, directUrl, healthCheckPath, spec] + ); + + const handleTestConnection = useCallback( + async function handleTestConnection(): Promise { + const isHealthy = await testConnection(); + if (isHealthy) { + successSnackbar('Datasource is healthy'); + } else { + exceptionSnackbar(new Error('Datasource is not healthy')); + } + }, + [exceptionSnackbar, testConnection, successSnackbar] + ); + return ( + + ); +}; diff --git a/plugin-system/src/components/DatasourceTestConnectionButton/index.ts b/plugin-system/src/components/DatasourceTestConnectionButton/index.ts new file mode 100644 index 0000000..929d6e0 --- /dev/null +++ b/plugin-system/src/components/DatasourceTestConnectionButton/index.ts @@ -0,0 +1 @@ +export * from './DatasourceTestConnectionButton'; diff --git a/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx b/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx index 1840857..214dfd6 100644 --- a/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx +++ b/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx @@ -16,8 +16,31 @@ import userEvent from '@testing-library/user-event'; import { HTTPDatasourceSpec } from '@perses-dev/core'; import { FormProvider, useForm } from 'react-hook-form'; import { ReactElement } from 'react'; +import { SnackbarContext } from '@perses-dev/components'; +import { UnsavedDatasourceStore } from '../../runtime'; import { HTTPSettingsEditor } from './HTTPSettingsEditor'; +const mockTestProxyConnection = jest.fn(); +const mockTestDirectConnection = jest.fn(); +const mockSuccessSnackbar = jest.fn(); +const mockExceptionSnackbar = jest.fn(); + +jest.mock('@perses-dev/plugin-system', () => ({ + ...jest.requireActual('@perses-dev/plugin-system'), + useUnsavedDatasourceStore: (): UnsavedDatasourceStore => ({ + testProxyConnection: mockTestProxyConnection, + testDirectConnection: mockTestDirectConnection, + }), +})); + +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 +511,231 @@ describe('HTTPSettingsEditor - Request Headers', () => { }); }); }); + +describe('HTTPSettingsEditor - Test Connection', () => { + const initialSpecDirect: HTTPDatasourceSpec = { + directUrl: '', + }; + + const initialSpecProxy: HTTPDatasourceSpec = { + proxy: { + kind: 'HTTPProxy', + spec: { + url: '', + }, + }, + }; + + const renderComponent = (value: HTTPDatasourceSpec, onChange = jest.fn()): ReturnType => { + const Wrapper = (): ReactElement => { + const methods = useForm(); + return ( + + + + ); + }; + return render(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Proxy mode', () => { + it('should show "Test Connection" button when URL is set', () => { + const value: HTTPDatasourceSpec = { + proxy: { + kind: 'HTTPProxy', + spec: { + url: 'http://localhost:9090', + }, + }, + }; + + renderComponent(value); + + 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: '', + }, + }, + }; + + renderComponent(value); + + expect(screen.getByRole('button', { name: /test connection/i })).toBeDisabled(); + }); + + it('should show success snackbar when proxy connection is healthy', async () => { + mockTestProxyConnection.mockResolvedValue(true); + + const value: HTTPDatasourceSpec = { + proxy: { + kind: 'HTTPProxy', + spec: { + url: 'http://localhost:9090', + }, + }, + }; + + renderComponent(value); + + const testButton = screen.getByRole('button', { name: /test connection/i }); + await userEvent.click(testButton); + + await waitFor(() => { + expect(mockTestProxyConnection).toHaveBeenCalledWith( + expect.objectContaining({ + default: false, + plugin: expect.objectContaining({ + kind: 'PrometheusDatasource', + spec: expect.objectContaining({ + proxy: expect.objectContaining({ + spec: expect.objectContaining({ + allowedEndpoints: [{ endpointPattern: '/api/v1/query', method: 'GET' }], + }), + }), + }), + }), + }), + '/api/v1/query' + ); + expect(mockSuccessSnackbar).toHaveBeenCalledWith('Datasource is healthy'); + }); + }); + + it('should show error snackbar when proxy connection is not healthy', async () => { + mockTestProxyConnection.mockResolvedValue(false); + + const value: HTTPDatasourceSpec = { + proxy: { + kind: 'HTTPProxy', + spec: { + url: 'http://localhost:9090', + }, + }, + }; + + renderComponent(value); + + const testButton = screen.getByRole('button', { name: /test connection/i }); + await userEvent.click(testButton); + + await waitFor(() => { + expect(mockTestProxyConnection).toHaveBeenCalled(); + expect(mockExceptionSnackbar).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + it('should append healthCheckPath to existing allowedEndpoints', async () => { + mockTestProxyConnection.mockResolvedValue(true); + + const value: HTTPDatasourceSpec = { + proxy: { + kind: 'HTTPProxy', + spec: { + url: 'http://localhost:9090', + allowedEndpoints: [{ endpointPattern: '/api/v1/labels', method: 'GET' }], + }, + }, + }; + + renderComponent(value); + + const testButton = screen.getByRole('button', { name: /test connection/i }); + await userEvent.click(testButton); + + await waitFor(() => { + expect(mockTestProxyConnection).toHaveBeenCalledWith( + expect.objectContaining({ + plugin: expect.objectContaining({ + spec: expect.objectContaining({ + proxy: expect.objectContaining({ + spec: expect.objectContaining({ + allowedEndpoints: [ + { endpointPattern: '/api/v1/labels', method: 'GET' }, + { endpointPattern: '/api/v1/query', method: 'GET' }, + ], + }), + }), + }), + }), + }), + '/api/v1/query' + ); + }); + }); + }); + + describe('Direct mode', () => { + it('should show "Test Connection" button when direct URL is set', () => { + const value: HTTPDatasourceSpec = { + directUrl: 'http://localhost:9090', + }; + + renderComponent(value); + + expect(screen.getByRole('button', { name: /test connection/i })).toBeInTheDocument(); + }); + + it('should disable "Test Connection" button when direct URL is empty', () => { + const value: HTTPDatasourceSpec = { + directUrl: '', + }; + + renderComponent(value); + + expect(screen.getByRole('button', { name: /test connection/i })).toBeDisabled(); + }); + + it('should show success snackbar when direct connection is healthy', async () => { + mockTestDirectConnection.mockResolvedValue(true); + + const value: HTTPDatasourceSpec = { + directUrl: 'http://localhost:9090', + }; + + renderComponent(value); + + const testButton = screen.getByRole('button', { name: /test connection/i }); + await userEvent.click(testButton); + + await waitFor(() => { + expect(mockTestDirectConnection).toHaveBeenCalledWith('http://localhost:9090', '/api/v1/query'); + expect(mockSuccessSnackbar).toHaveBeenCalledWith('Datasource is healthy'); + }); + }); + + it('should show error snackbar when direct connection is not healthy', async () => { + mockTestDirectConnection.mockResolvedValue(false); + + const value: HTTPDatasourceSpec = { + directUrl: 'http://localhost:9090', + }; + + renderComponent(value); + + const testButton = screen.getByRole('button', { name: /test connection/i }); + await userEvent.click(testButton); + + await waitFor(() => { + expect(mockTestDirectConnection).toHaveBeenCalledWith('http://localhost:9090', '/api/v1/query'); + 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 e7f2eae..9638542 100644 --- a/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx +++ b/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx @@ -12,13 +12,14 @@ // limitations under the License. import { RequestHeaders, HTTPDatasourceSpec } from '@perses-dev/core'; // TODO this is the proxy definition that should go to a different lib -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 { produce } from 'immer'; import { Controller, useForm, useFieldArray } from 'react-hook-form'; import MinusIcon from 'mdi-material-ui/Minus'; import PlusIcon from 'mdi-material-ui/Plus'; import { OptionsEditorRadios } from '../OptionsEditorRadios'; +import { DatasourceTestConnectionButton } from '../DatasourceTestConnectionButton'; type HeaderEntry = { name: string; @@ -35,10 +36,13 @@ export interface HTTPSettingsEditor { isReadonly?: boolean; initialSpecDirect: HTTPDatasourceSpec; initialSpecProxy: HTTPDatasourceSpec; + datasourcePluginKind?: string; + healthCheckPath?: string; } export function HTTPSettingsEditor(props: HTTPSettingsEditor): ReactElement { - const { value, onChange, isReadonly, initialSpecDirect, initialSpecProxy } = props; + const { value, onChange, isReadonly, initialSpecDirect, initialSpecProxy, datasourcePluginKind, healthCheckPath } = + props; const strDirect = 'Direct access'; const strProxy = 'Proxy'; @@ -133,6 +137,31 @@ export function HTTPSettingsEditor(props: HTTPSettingsEditor): ReactElement { /> )} /> + {datasourcePluginKind && healthCheckPath && ( + + { + draft.spec.allowedEndpoints = [ + ...(draft.spec.allowedEndpoints ?? []), + { endpointPattern: healthCheckPath, method: 'GET' }, + ]; + }) + : undefined, + }, + }, + }} + healthCheckPath={healthCheckPath} + disabled={!value.proxy?.spec.url} + /> + + )} Allowed endpoints @@ -409,31 +438,43 @@ 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; + }) + ); + }} + /> + )} + /> + {healthCheckPath && ( + + + )} - /> + ), }, ]; diff --git a/plugin-system/src/components/index.ts b/plugin-system/src/components/index.ts index f7151db..0f47c59 100644 --- a/plugin-system/src/components/index.ts +++ b/plugin-system/src/components/index.ts @@ -28,3 +28,4 @@ export * from './PluginSpecEditor'; export * from './TimeRangeControls'; export * from './Variables'; export * from './MetricLabelInput'; +export * from './DatasourceTestConnectionButton'; diff --git a/plugin-system/src/context/UnsavedDatasourceProvider.tsx b/plugin-system/src/context/UnsavedDatasourceProvider.tsx new file mode 100644 index 0000000..cae3bd0 --- /dev/null +++ b/plugin-system/src/context/UnsavedDatasourceProvider.tsx @@ -0,0 +1,80 @@ +import { DatasourceApi, DatasourceSpec, fetch } from '@perses-dev/core'; +import { ReactElement, ReactNode, useCallback, useMemo } from 'react'; +import { UnsavedDatasourceStoreContext } from '../runtime'; + +interface UnsavedDatasourceProxyBody { + method: string; + body?: Uint8Array | null; + spec: DatasourceSpec; +} + +export interface UnsavedDatasourceProviderProps { + datasourceApi: DatasourceApi; + dashboard?: string; + project?: string; + children: ReactNode; +} + +function buildUrl(proxyUrl: string, path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${proxyUrl}${normalizedPath}`; +} + +export function UnsavedDatasourceProvider({ + datasourceApi, + dashboard, + project, + children, +}: UnsavedDatasourceProviderProps): ReactElement { + const testProxyConnection = useCallback( + async function test(spec: DatasourceSpec, healthCheckPath: string): Promise { + const proxyUrl = datasourceApi.buildProxyUrl ? datasourceApi.buildProxyUrl({ dashboard, project }) : ''; + + const url = buildUrl(proxyUrl, healthCheckPath); + + const unsavedBody: UnsavedDatasourceProxyBody = { + method: 'GET', + spec: spec, + body: null, + }; + + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(unsavedBody), + }); + return resp.status === 200; + } catch { + return false; + } + }, + [dashboard, datasourceApi, project] + ); + + const testDirectConnection = useCallback(async function testDirect( + directUrl: string, + healthCheckPath: string + ): Promise { + const url = buildUrl(directUrl, healthCheckPath); + try { + const resp = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + return resp.status === 200; + } catch { + return false; + } + }, []); + const ctxValue = useMemo( + () => ({ testProxyConnection, testDirectConnection }), + [testDirectConnection, testProxyConnection] + ); + + return {children}; +} diff --git a/plugin-system/src/context/index.ts b/plugin-system/src/context/index.ts index 3b8c9af..6f66993 100644 --- a/plugin-system/src/context/index.ts +++ b/plugin-system/src/context/index.ts @@ -12,3 +12,4 @@ // limitations under the License. export * from './ValidationProvider'; +export * from './UnsavedDatasourceProvider'; diff --git a/plugin-system/src/runtime/datasources.ts b/plugin-system/src/runtime/datasources.ts index efb174f..6f75831 100644 --- a/plugin-system/src/runtime/datasources.ts +++ b/plugin-system/src/runtime/datasources.ts @@ -50,6 +50,17 @@ export interface DatasourceStore { setSavedDatasources(datasources: Record): void; } +export interface UnsavedDatasourceStore { + /** + * Tests the configuration of a datasource by calling the corresponding datasource plugin's health endpoint through the proxy. + */ + testProxyConnection(spec: DatasourceSpec, healthCheckPath: string): Promise; + /** + * Tests the configuration of a datasource by calling the corresponding datasource plugin's health endpoint directly. + */ + testDirectConnection(directUrl: string, healthCheckPath: string): Promise; +} + export interface DatasourceSelectItemGroup { group?: string; editLink?: string; @@ -76,6 +87,7 @@ export interface DatasourceSelectItemSelector extends DatasourceSelector { } export const DatasourceStoreContext = createContext(undefined); +export const UnsavedDatasourceStoreContext = createContext(undefined); export function useDatasourceStore(): DatasourceStore { const ctx = useContext(DatasourceStoreContext); @@ -85,6 +97,14 @@ export function useDatasourceStore(): DatasourceStore { return ctx; } +export function useUnsavedDatasourceStore(): UnsavedDatasourceStore { + const ctx = useContext(UnsavedDatasourceStoreContext); + if (ctx === undefined) { + throw new Error('No UnsavedDatasourceStoreContext found. Did you forget a Provider?'); + } + return ctx; +} + /** * Lists all available Datasource selection items for a given datasource plugin kind. * Returns a list, with all information that can be used in a datasource selection context (group, name, selector, kind, ...)