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, ...)