diff --git a/client/src/model/datasource-api.test.ts b/client/src/model/datasource-api.test.ts new file mode 100644 index 00000000..45b3dd5a --- /dev/null +++ b/client/src/model/datasource-api.test.ts @@ -0,0 +1,207 @@ +// 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('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(); + 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..7dd08b49 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.replace(/\/$/, '')}${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..10b1760f --- /dev/null +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.test.tsx @@ -0,0 +1,107 @@ +// 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('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(); + 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 new file mode 100644 index 00000000..12af72a0 --- /dev/null +++ b/plugin-system/src/components/DatasourceTestConnectionButton/DatasourceTestConnectionButton.tsx @@ -0,0 +1,47 @@ +// 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, useState } from 'react'; +import { Button, ButtonProps } from '@mui/material'; +import { useSnackbar } from '@perses-dev/components'; + +type DatasourceTestConnectionButtonProps = { + testConnection: () => Promise; +} & Omit; + +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 ( + + ); +}; 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/DatasourceSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx new file mode 100644 index 00000000..e317fa1c --- /dev/null +++ b/plugin-system/src/components/PluginSpecEditor/DatasourceSpecEditor.tsx @@ -0,0 +1,68 @@ +// 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 escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +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 escapedPattern = escapeRegExp(healthCheckPath); + const augmentedPluginSpec = hasHTTPProxy(value) + ? produce(value, (draft) => { + const existing = draft.proxy.spec.allowedEndpoints ?? []; + const alreadyAllowed = existing.some((e) => e.endpointPattern === escapedPattern && e.method === 'GET'); + if (!alreadyAllowed) { + draft.proxy.spec.allowedEndpoints = [...existing, { endpointPattern: escapedPattern, 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.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..8739c09c 100644 --- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx +++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx @@ -12,24 +12,36 @@ // limitations under the License. import { ErrorAlert } from '@perses-dev/components'; -import { UnknownSpec } from '@perses-dev/spec'; +import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; import { ReactElement } from 'react'; import { CircularProgress, Stack } from '@mui/material'; -import { OptionsEditorProps } 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 OptionsEditorProps { pluginSelection: PluginEditorSelection; isEditor?: boolean; + testConnection?: (spec: DatasourceSpec, healthCheckPath: string) => Promise; +} + +function isDatasourcePlugin( + pluginType: PluginType, + plugin: Plugin +): plugin is DatasourcePlugin { + return pluginType === 'Datasource' && '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); + if (error) { return ; } @@ -49,7 +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/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..3b065839 100644 --- a/plugin-system/src/model/datasource.ts +++ b/plugin-system/src/model/datasource.ts @@ -12,15 +12,20 @@ // 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[]; + healthCheckPath?: string; } export interface DatasourceClientOptions { @@ -35,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 f59f4bf6..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. diff --git a/plugin-system/src/test/test-plugins/ernie/index.tsx b/plugin-system/src/test/test-plugins/ernie/index.tsx index 1912d3d4..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 { VariablePlugin, VariableOption } from '../../../model'; +import { DatasourceEditorProps, DatasourcePlugin, 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, + }: DatasourceEditorProps) { + 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" + } + } } ] }