diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c43fc..a1932e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.1.0](https://github.com/parseablehq/parseable-datasource/tree/v2.1.0) (2026-09-12) + +[Full Changelog](https://github.com/parseablehq/parseable-datasource/compare/v2.0.0...v2.1.0) + +- Add Grafana Cloud Private Data Source Connect (PDC) support. + ## [v2.0.0](https://github.com/parseablehq/parseable-datasource/tree/v2.0.0) (2026-06-05) [Full Changelog](https://github.com/parseablehq/parseable-datasource/compare/v1.2.1...v2.0.0) @@ -12,10 +18,10 @@ - release 2.0.0 [\#56](https://github.com/parseablehq/parseable-datasource/pull/56) ([nitisht](https://github.com/nitisht)) - chore: upgrade deps + fix Grafana plugin validation issues [\#55](https://github.com/parseablehq/parseable-datasource/pull/55) ([praveen5959](https://github.com/praveen5959)) - prepare for release 2.0.0 [\#54](https://github.com/parseablehq/parseable-datasource/pull/54) ([nitisht](https://github.com/nitisht)) -- feat: paginated value suggestions via dataset\_stats with top-5 + show more [\#53](https://github.com/parseablehq/parseable-datasource/pull/53) ([praveen5959](https://github.com/praveen5959)) +- feat: paginated value suggestions via dataset_stats with top-5 + show more [\#53](https://github.com/parseablehq/parseable-datasource/pull/53) ([praveen5959](https://github.com/praveen5959)) - Prom ql builder [\#52](https://github.com/parseablehq/parseable-datasource/pull/52) ([praveen5959](https://github.com/praveen5959)) -- feat: PromQL-first with full Code-mode feature parity [\#51](https://github.com/parseablehq/parseable-datasource/pull/51) ([praveen5959](https://github.com/praveen5959)) -- Upgrade Grafana SDK to 12.x, modernize build and CI [\#50](https://github.com/parseablehq/parseable-datasource/pull/50) ([praveen5959](https://github.com/praveen5959)) +- feat: PromQL-first with full Code-mode feature parity [\#51](https://github.com/parseablehq/parseable-datasource/pull/51) ([praveen5959](https://github.com/praveen5959)) +- Upgrade Grafana SDK to 12.x, modernize build and CI [\#50](https://github.com/parseablehq/parseable-datasource/pull/50) ([praveen5959](https://github.com/praveen5959)) - Update branding: replace logo and update terminology from logs to telemetry data [\#49](https://github.com/parseablehq/parseable-datasource/pull/49) ([Debanitrkl](https://github.com/Debanitrkl)) - add Parseable Meta Dashboard [\#48](https://github.com/parseablehq/parseable-datasource/pull/48) ([nitisht](https://github.com/nitisht)) - fixed plugin validation error: renamed root dir - from 'dist' to plugin-id [\#47](https://github.com/parseablehq/parseable-datasource/pull/47) ([balaji-jr](https://github.com/balaji-jr)) @@ -58,7 +64,7 @@ - Remove commented code and update min reqd version [\#22](https://github.com/parseablehq/parseable-datasource/pull/22) ([nitisht](https://github.com/nitisht)) - Fix listing in dashboard panels [\#21](https://github.com/parseablehq/parseable-datasource/pull/21) ([nitisht](https://github.com/nitisht)) - Fix plugin version [\#20](https://github.com/parseablehq/parseable-datasource/pull/20) ([nitisht](https://github.com/nitisht)) -- Use send\_null flag while sending queries [\#19](https://github.com/parseablehq/parseable-datasource/pull/19) ([nitisht](https://github.com/nitisht)) +- Use send_null flag while sending queries [\#19](https://github.com/parseablehq/parseable-datasource/pull/19) ([nitisht](https://github.com/nitisht)) - Add credits [\#18](https://github.com/parseablehq/parseable-datasource/pull/18) ([nitisht](https://github.com/nitisht)) - Fix comments and sign the plugin [\#17](https://github.com/parseablehq/parseable-datasource/pull/17) ([nitisht](https://github.com/nitisht)) - Fix plugin ID as per Grafana spec [\#16](https://github.com/parseablehq/parseable-datasource/pull/16) ([nitisht](https://github.com/nitisht)) @@ -74,9 +80,7 @@ - Fix links in readme and add more info in plugin.json [\#5](https://github.com/parseablehq/parseable-datasource/pull/5) ([nitisht](https://github.com/nitisht)) - Add CI and documentation [\#4](https://github.com/parseablehq/parseable-datasource/pull/4) ([nitisht](https://github.com/nitisht)) - Prepare for plugin release [\#3](https://github.com/parseablehq/parseable-datasource/pull/3) ([nitisht](https://github.com/nitisht)) -- Use p\_timestamp as the field to be used as time [\#2](https://github.com/parseablehq/parseable-datasource/pull/2) ([nitisht](https://github.com/nitisht)) +- Use p_timestamp as the field to be used as time [\#2](https://github.com/parseablehq/parseable-datasource/pull/2) ([nitisht](https://github.com/nitisht)) - Cleanup tooltip and expand the size of query editor [\#1](https://github.com/parseablehq/parseable-datasource/pull/1) ([nitisht](https://github.com/nitisht)) - - -\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* +\* _This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)_ diff --git a/README.md b/README.md index 459d500..4010b4e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ This data source plugin allows you to query and visualize log data stored in Par Refer the Parseable Grafana documentation page: [https://www.parseable.com/docs/integrations/visualization/grafana](https://www.parseable.com/docs/integrations/visualization/grafana). +## Private Data Source Connect (PDC) + +The plugin supports querying a Parseable server in a private network from Grafana Cloud using [Private Data Source Connect](https://grafana.com/docs/grafana-cloud/observe-and-act/connect-externally-hosted/private-data-source-connect/). + +After configuring a PDC network and agent in Grafana Cloud, edit the Parseable data source, use the private Parseable URL, enable **Secure SOCKS Proxy**, and select the appropriate PDC network. Save and test the data source to verify connectivity. + +PDC is a Grafana Cloud feature. The option is shown only when Grafana enables the `secureSocksDSProxyEnabled` feature toggle. + ## Screenshots ![query editor](https://github.com/parseablehq/parseable-datasource/blob/main/src/img/query-editor.png?raw=true) @@ -34,6 +42,7 @@ allow_loading_unsigned_plugins = parseable-parseable-datasource ``` unzip parseable-datasource-*.*.*.zip -d ./parseable-parseable-datasource ``` + 11. Restart the Grafana server to load the manually installed plugin. ## Dashboards diff --git a/package.json b/package.json index d1713ec..bb2ea7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parseable", - "version": "2.0.0", + "version": "2.1.0", "description": "Parseable is a purpose built observability datalake. This plugin allows you to visualize data in Parseable, via Grafana.", "scripts": { "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", @@ -30,6 +30,7 @@ "@types/lodash": "^4.17.24", "@types/node": "^20.19.0", "@types/react-router-dom": "^5.2.0", + "@types/semver": "^7.7.1", "@types/testing-library__jest-dom": "5.14.8", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -79,6 +80,7 @@ "@prometheus-io/lezer-promql": "^0.311.2", "react": "18.2.0", "react-dom": "18.2.0", + "semver": "^7.7.3", "tslib": "2.8.1" } } diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index b434be2..efceb5c 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -29,6 +29,7 @@ import ( var ( _ backend.QueryDataHandler = (*Datasource)(nil) _ backend.CheckHealthHandler = (*Datasource)(nil) + _ backend.CallResourceHandler = (*Datasource)(nil) _ instancemgmt.InstanceDisposer = (*Datasource)(nil) ) @@ -69,6 +70,65 @@ func (d *Datasource) Dispose() { d.httpClient.CloseIdleConnections() } +// CallResource proxies UI support requests through the same SDK HTTP client +// used by queries and health checks. The SDK applies PDC's secure SOCKS +// transport when enableSecureSocksProxy is set on the data source instance. +func (d *Datasource) CallResource( + ctx context.Context, + req *backend.CallResourceRequest, + sender backend.CallResourceResponseSender, +) error { + if strings.Trim(req.Path, "/") != "proxy" { + return sender.Send(&backend.CallResourceResponse{Status: http.StatusNotFound}) + } + + resourceURL, err := url2.Parse(req.URL) + if err != nil { + return sender.Send(&backend.CallResourceResponse{Status: http.StatusBadRequest}) + } + target := resourceURL.Query().Get("target") + targetURL, err := url2.Parse(target) + if err != nil || targetURL.IsAbs() || targetURL.Host != "" || !strings.HasPrefix(targetURL.Path, "/") { + return sender.Send(&backend.CallResourceResponse{Status: http.StatusBadRequest}) + } + + baseURL, err := url2.Parse(d.settings.URL) + if err != nil { + return sender.Send(&backend.CallResourceResponse{Status: http.StatusInternalServerError}) + } + baseURL.Path = strings.TrimRight(baseURL.Path, "/") + targetURL.Path + baseURL.RawQuery = targetURL.RawQuery + + proxyReq, err := http.NewRequestWithContext(ctx, req.Method, baseURL.String(), bytes.NewReader(req.Body)) + if err != nil { + return sender.Send(&backend.CallResourceResponse{Status: http.StatusBadRequest}) + } + for _, header := range []string{"Accept", "Content-Type"} { + if value := req.GetHTTPHeader(header); value != "" { + proxyReq.Header.Set(header, value) + } + } + + resp, err := d.httpClient.Do(proxyReq) + if err != nil { + return sender.Send(&backend.CallResourceResponse{ + Status: http.StatusBadGateway, + Body: []byte(err.Error()), + }) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return sender.Send(&backend.CallResourceResponse{Status: http.StatusBadGateway}) + } + return sender.Send(&backend.CallResourceResponse{ + Status: resp.StatusCode, + Headers: resp.Header, + Body: body, + }) +} + func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { response := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -248,7 +308,12 @@ func getGrafanaFieldType(value interface{}) data.FieldType { func (d *Datasource) CheckHealth(ctx context.Context, _ *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { ctxLogger := log.DefaultLogger.FromContext(ctx) - r, err := http.NewRequestWithContext(ctx, http.MethodGet, d.settings.URL, nil) + healthURL, err := url2.JoinPath(d.settings.URL, "/api/v1/about") + if err != nil { + return newHealthCheckErrorf("could not construct health check URL"), nil + } + + r, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) if err != nil { return newHealthCheckErrorf("could not create request"), nil } diff --git a/pkg/plugin/resource_test.go b/pkg/plugin/resource_test.go new file mode 100644 index 0000000..cf1757f --- /dev/null +++ b/pkg/plugin/resource_test.go @@ -0,0 +1,85 @@ +package plugin + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +type resourceResponseSender struct { + response *backend.CallResourceResponse +} + +func (s *resourceResponseSender) Send(response *backend.CallResourceResponse) error { + s.response = response + return nil +} + +func TestCallResourceProxiesRelativeParseableRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/base/api/v1/about" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("stream") != "logs" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != `{"check":true}` { + t.Fatalf("unexpected body: %s", body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + datasource := &Datasource{ + settings: backend.DataSourceInstanceSettings{URL: server.URL + "/base"}, + httpClient: server.Client(), + } + sender := &resourceResponseSender{} + err := datasource.CallResource(context.Background(), &backend.CallResourceRequest{ + Path: "proxy", + Method: http.MethodPost, + URL: "/proxy?target=%2Fapi%2Fv1%2Fabout%3Fstream%3Dlogs", + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Body: []byte(`{"check":true}`), + }, sender) + if err != nil { + t.Fatal(err) + } + if sender.response.Status != http.StatusCreated { + t.Fatalf("unexpected status: %d", sender.response.Status) + } + if string(sender.response.Body) != `{"ok":true}` { + t.Fatalf("unexpected response: %s", sender.response.Body) + } +} + +func TestCallResourceRejectsAbsoluteTarget(t *testing.T) { + datasource := &Datasource{httpClient: http.DefaultClient} + sender := &resourceResponseSender{} + err := datasource.CallResource(context.Background(), &backend.CallResourceRequest{ + Path: "proxy", + Method: http.MethodGet, + URL: "/proxy?target=https%3A%2F%2Fexample.com%2Fsecret", + }, sender) + if err != nil { + t.Fatal(err) + } + if sender.response.Status != http.StatusBadRequest { + t.Fatalf("unexpected status: %d", sender.response.Status) + } +} diff --git a/src/components/ConfigEditor.tsx b/src/components/ConfigEditor.tsx index 4175c57..bf655d1 100644 --- a/src/components/ConfigEditor.tsx +++ b/src/components/ConfigEditor.tsx @@ -1,11 +1,17 @@ import React from 'react'; -import { DataSourceHttpSettings } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { DataSourceHttpSettings, InlineField, Switch, useStyles2 } from '@grafana/ui'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { MyDataSourceOptions, MySecureJsonData } from '../types'; +import { supportsSecureSocksProxy } from '../utils/pdc'; interface Props extends DataSourcePluginOptionsEditorProps {} export const ConfigEditor = ({ onOptionsChange, options }: Props) => { + const styles = useStyles2(getStyles); + const pdcSupported = supportsSecureSocksProxy(config.featureToggles, config.buildInfo.version); + return (
{ dataSourceConfig={options} onChange={onOptionsChange} /> + {pdcSupported && ( + +
+ + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + enableSecureSocksProxy: event.currentTarget.checked, + }, + }) + } + /> +
+
+ )}
); }; + +const getStyles = () => ({ + toggleContainer: css({ + display: 'flex', + alignItems: 'center', + minHeight: 32, + }), +}); diff --git a/src/datasource.ts b/src/datasource.ts index c191ea8..e71d450 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -2,7 +2,6 @@ import { getBackendSrv, getTemplateSrv, BackendSrvRequest, - FetchResponse, DataSourceWithBackend, } from '@grafana/runtime'; import { @@ -34,23 +33,26 @@ import { import { parseType } from './utils/fieldTypes'; import { sanitizeSql } from './utils/sqlNormalize'; import { recordPromqlQuery } from './utils/promqlHistory'; +import { buildPdcTarget } from './utils/pdc'; export class DataSource extends DataSourceWithBackend { url: string; withCredentials: boolean; headers: any; defaultEditorMode: QueryEditorMode; + private readonly pdcEnabled: boolean; constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); this.url = instanceSettings.url === undefined ? '' : instanceSettings.url; this.withCredentials = instanceSettings.withCredentials !== undefined; this.defaultEditorMode = instanceSettings.jsonData?.defaultEditorMode ?? 'builder'; + this.pdcEnabled = Boolean(instanceSettings.jsonData?.enableSecureSocksProxy); } async doRequest(query: MyQuery) { const routePath = '/api/v1'; const result = await lastValueFrom( - getBackendSrv().fetch({ + this.doFetch({ method: 'GET', url: this.url + routePath + '/readiness', params: query, @@ -779,6 +781,22 @@ export class DataSource extends DataSourceWithBackend(options: BackendSrvRequest) { + if (this.pdcEnabled) { + const target = buildPdcTarget(this.url, options.url, options.params as Record | undefined); + const requestHeaders = { + ...options.headers, + ...this.getRequestHeaders(), + }; + + return getBackendSrv().fetch({ + ...options, + url: `/api/datasources/uid/${this.uid}/resources/proxy`, + params: { target }, + headers: requestHeaders, + withCredentials: false, + }); + } + options.withCredentials = this.withCredentials; options.headers = this.headers; @@ -903,41 +921,4 @@ export class DataSource extends DataSourceWithBackend response)) - ); - - if (response.status === 200) { - return { status: 'success', message: 'Parseable server is reachable', title: 'Success' }; - } - - return { - message: response.status === 400 || !response.statusText ? errorMessageBase : response.statusText, - status: 'error', - title: 'Error', - }; - } catch (err) { - if (typeof err === 'string') { - return { - status: 'error', - message: err, - }; - } - - let error = err as FetchResponse; - let message = error.statusText ?? errorMessageBase; - if (error.data?.error?.code !== undefined) { - message += `: ${error.data.error.code}. ${error.data.error.message}`; - } - - return { status: 'error', message, title: 'Error' }; - } - } } diff --git a/src/types.ts b/src/types.ts index f3e8a5a..8e972d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,8 @@ export interface MyDataSourceOptions extends DataSourceJsonData { path?: string; username: string; defaultEditorMode?: QueryEditorMode; + /** Route backend requests through Grafana's Private Data Source Connect proxy. */ + enableSecureSocksProxy?: boolean; } /** @@ -100,7 +102,7 @@ export interface StreamPayloadConfig { placeholder?: string; name: string; label?: string; - type?: "input" | "select" | "multi-select" | "textarea"; + type?: 'input' | 'select' | 'multi-select' | 'textarea'; reloadMetric?: boolean; options?: Array>; } @@ -196,7 +198,7 @@ export interface HomeResponse { datasets?: Dataset[]; } -export type QueryEditorMode = "code" | "builder" | "monitor" | "promql"; +export type QueryEditorMode = 'code' | 'builder' | 'monitor' | 'promql'; /** * A single filter condition — matches Prism's FilterType structure. diff --git a/src/utils/pdc.test.ts b/src/utils/pdc.test.ts new file mode 100644 index 0000000..0f6dd6f --- /dev/null +++ b/src/utils/pdc.test.ts @@ -0,0 +1,36 @@ +import type { FeatureToggles } from '@grafana/data'; +import { buildPdcTarget, supportsSecureSocksProxy } from './pdc'; + +const toggles = (enabled: boolean): FeatureToggles => + ({ secureSocksDSProxyEnabled: enabled }) as unknown as FeatureToggles; + +describe('supportsSecureSocksProxy', () => { + it('supports PDC when Grafana and its feature toggle support it', () => { + expect(supportsSecureSocksProxy(toggles(true), '10.0.0')).toBe(true); + expect(supportsSecureSocksProxy(toggles(true), '12.4.2')).toBe(true); + }); + + it('hides PDC when the feature toggle is disabled', () => { + expect(supportsSecureSocksProxy(toggles(false), '12.4.2')).toBe(false); + }); + + it('hides PDC on unsupported Grafana versions', () => { + expect(supportsSecureSocksProxy(toggles(true), '9.5.21')).toBe(false); + }); +}); + +describe('buildPdcTarget', () => { + it('creates a relative target and preserves query parameters', () => { + expect( + buildPdcTarget('https://parseable.internal/base', 'https://parseable.internal/base/api/v1/labels?stream=logs', { + limit: 5, + 'match[]': ['one', 'two'], + }) + ).toBe('/api/v1/labels?stream=logs&limit=5&match%5B%5D=one&match%5B%5D=two'); + }); + + it('rejects another origin or a path outside the configured base', () => { + expect(() => buildPdcTarget('https://parseable.internal/base', 'https://example.com/api')).toThrow(); + expect(() => buildPdcTarget('https://parseable.internal/base', 'https://parseable.internal/other')).toThrow(); + }); +}); diff --git a/src/utils/pdc.ts b/src/utils/pdc.ts new file mode 100644 index 0000000..8c5666c --- /dev/null +++ b/src/utils/pdc.ts @@ -0,0 +1,32 @@ +import type { FeatureToggles } from '@grafana/data'; +import { gte } from 'semver'; + +const secureSocksFeature = 'secureSocksDSProxyEnabled' as keyof FeatureToggles; + +export const supportsSecureSocksProxy = (featureToggles: FeatureToggles, grafanaVersion: string): boolean => + Boolean(featureToggles[secureSocksFeature]) && gte(grafanaVersion, '10.0.0'); + +export const buildPdcTarget = (base: string, requestUrl: string, params?: Record): string => { + const baseUrl = new URL(base); + const targetUrl = new URL(requestUrl, baseUrl); + if (targetUrl.origin !== baseUrl.origin) { + throw new Error('PDC requests must target the configured Parseable server'); + } + + const basePath = baseUrl.pathname.replace(/\/$/, ''); + if (basePath && targetUrl.pathname !== basePath && !targetUrl.pathname.startsWith(basePath + '/')) { + throw new Error('PDC request path is outside the configured Parseable URL'); + } + + Object.entries(params ?? {}).forEach(([key, value]) => { + targetUrl.searchParams.delete(key); + const values = Array.isArray(value) ? value : [value]; + values.forEach((item) => { + if (item !== undefined && item !== null) { + targetUrl.searchParams.append(key, String(item)); + } + }); + }); + + return (targetUrl.pathname.slice(basePath.length) || '/') + targetUrl.search; +}; diff --git a/yarn.lock b/yarn.lock index e193421..611da53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2240,6 +2240,11 @@ dependencies: csstype "^3.2.2" +"@types/semver@^7.7.1": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.8.0.tgz#0bfe3ec51f5e9615bc317174cd5b88ea08b7fc2f" + integrity sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ== + "@types/sizzle@*": version "2.3.10" resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.10.tgz#277a542aff6776d8a9b15f2ac682a663e3e94bbd"