From 85419d854a216bff5d97c21900e2a389427a0693 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Mon, 23 Dec 2024 14:26:10 -0500 Subject: [PATCH 01/25] rename and try ci --- .github/workflows/ci.yml | 138 ++++++++++++++++++++++++++++++++++----- src/plugin.json | 4 +- 2 files changed, 122 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec3e72..61254ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,16 +3,127 @@ name: CI on: push: branches: - - master - main pull_request: branches: - - master - main permissions: read-all jobs: + build-linux-amd64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - uses: magefile/mage-action@v3 + with: + version: latest + args: coverage + - uses: magefile/mage-action@v3 + with: + version: latest + args: -v build:Linux + - name: Upload Linux AMD64 artifact + uses: actions/upload-artifact@v4 + with: + name: linux-amd64-dist + path: dist/ + + build-linux-arm64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - uses: magefile/mage-action@v3 + with: + version: latest + args: coverage + - uses: magefile/mage-action@v3 + env: + GOARCH: arm64 + with: + version: latest + args: -v build:LinuxARM64 + - name: Upload Linux ARM64 artifact + uses: actions/upload-artifact@v4 + with: + name: linux-arm64-dist + path: dist/ + + build-macos-arm64: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - uses: magefile/mage-action@v3 + with: + version: latest + args: coverage + - uses: magefile/mage-action@v3 + with: + version: latest + args: -v build:DarwinARM64 + - name: Upload macOS ARM64 artifact + uses: actions/upload-artifact@v4 + with: + name: macos-arm64-dist + path: dist/ + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - uses: magefile/mage-action@v3 + with: + version: latest + args: coverage + - uses: magefile/mage-action@v3 + with: + version: latest + args: -v build:Windows + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: windows-dist + path: dist/ + + generate-manifest: + needs: [build-linux-amd64, build-linux-arm64, build-macos-arm64, build-windows] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.21' + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: all-artifacts + - name: Prepare dist directory + run: | + mkdir -p dist + cp -r all-artifacts/*/* dist/ + - uses: magefile/mage-action@v3 + with: + version: latest + args: -v build:GenerateManifestFile + - name: Upload final artifacts with manifest + uses: actions/upload-artifact@v4 + with: + name: final-dist + path: dist/ + + build: name: Build, lint and unit tests runs-on: ubuntu-latest @@ -52,26 +163,17 @@ jobs: then echo "has-backend=true" >> $GITHUB_OUTPUT fi - - name: Setup Go environment - if: steps.check-for-backend.outputs.has-backend == 'true' uses: actions/setup-go@v5 with: - go-version: '1.21' - - - name: Test backend - if: steps.check-for-backend.outputs.has-backend == 'true' - uses: magefile/mage-action@v3 - with: - version: latest - args: coverage + go-version: '1.23' - - name: Build backend - if: steps.check-for-backend.outputs.has-backend == 'true' - uses: magefile/mage-action@v3 - with: - version: latest - args: -v build:Linux build:GenerateManifestFile + - name: Download backend dist directory + needs: generate-manifest + run: | + mkdir -p dist + ls all-artifacts + cp -r all-artifacts/*/* dist/ - name: Check for E2E id: check-for-e2e diff --git a/src/plugin.json b/src/plugin.json index a6fc2fa..491eb31 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -2,12 +2,12 @@ "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", "type": "datasource", "name": "Duckdb-Datasource", - "id": "motherduck-duckdb-datasource", + "id": "grafana-duckdb-datasource", "metrics": true, "backend": true, "executable": "gpx_duckdb_datasource", "info": { - "description": "Duck db and mother duck datasource for grafana", + "description": "DuckDB and MotherDuck Data source for Grafana", "author": { "name": "Motherduck" }, From 61239d8da885483bde4c25505704b216ef7cafe1 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Mon, 23 Dec 2024 14:30:45 -0500 Subject: [PATCH 02/25] update --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61254ee..c0a2203 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,7 @@ jobs: build: name: Build, lint and unit tests runs-on: ubuntu-latest + needs: generate-manifest outputs: plugin-id: ${{ steps.metadata.outputs.plugin-id }} plugin-version: ${{ steps.metadata.outputs.plugin-version }} @@ -169,7 +170,6 @@ jobs: go-version: '1.23' - name: Download backend dist directory - needs: generate-manifest run: | mkdir -p dist ls all-artifacts From 53d60de742d2db1f6f16a45f698830c0f082f30d Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Mon, 23 Dec 2024 14:34:51 -0500 Subject: [PATCH 03/25] recursive submodule --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0a2203..74f418b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-go@v5 with: go-version: '1.23' @@ -36,6 +38,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-go@v5 with: go-version: '1.23' @@ -59,6 +63,8 @@ jobs: runs-on: macos-14 steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-go@v5 with: go-version: '1.23' @@ -80,6 +86,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-go@v5 with: go-version: '1.23' @@ -102,6 +110,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-go@v5 with: go-version: '1.21' From 062d2168f8883b40c3609638bb9db42eadf32f61 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Mon, 23 Dec 2024 14:47:00 -0500 Subject: [PATCH 04/25] manually install mage? --- .github/workflows/ci.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74f418b..60c70ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: path: dist/ build-macos-arm64: - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 with: @@ -68,14 +68,11 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.23' - - uses: magefile/mage-action@v3 - with: - version: latest - args: coverage - - uses: magefile/mage-action@v3 - with: - version: latest - args: -v build:DarwinARM64 + - name: install mage + run: | + go install github.com/magefile/mage@latest + mage coverage + mage -v build:DarwinARM64 - name: Upload macOS ARM64 artifact uses: actions/upload-artifact@v4 with: From a389337c6ef35bd0f6ac9f1c7456838341b1da17 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Mon, 23 Dec 2024 14:52:30 -0500 Subject: [PATCH 05/25] use container? --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60c70ac..da52655 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: build-linux-arm64: runs-on: ubuntu-latest + container: + image: arm64v8/golang:1.23 + options: --platform linux/arm64 steps: - uses: actions/checkout@v4 with: @@ -111,7 +114,7 @@ jobs: submodules: recursive - uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.23' - name: Download all artifacts uses: actions/download-artifact@v4 with: From af481faa3073f78d0a6b2d5a7aa617fc68b5c5c6 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Mon, 23 Dec 2024 15:10:27 -0500 Subject: [PATCH 06/25] skip linux arm build for now --- .github/workflows/ci.yml | 56 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da52655..34d0783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,33 +34,33 @@ jobs: name: linux-amd64-dist path: dist/ - build-linux-arm64: - runs-on: ubuntu-latest - container: - image: arm64v8/golang:1.23 - options: --platform linux/arm64 - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - uses: actions/setup-go@v5 - with: - go-version: '1.23' - - uses: magefile/mage-action@v3 - with: - version: latest - args: coverage - - uses: magefile/mage-action@v3 - env: - GOARCH: arm64 - with: - version: latest - args: -v build:LinuxARM64 - - name: Upload Linux ARM64 artifact - uses: actions/upload-artifact@v4 - with: - name: linux-arm64-dist - path: dist/ + # build-linux-arm64: + # runs-on: ubuntu-latest + # container: + # image: arm64v8/golang:1.23 + # options: --platform linux/arm64 + # steps: + # - uses: actions/checkout@v4 + # with: + # submodules: recursive + # - uses: actions/setup-go@v5 + # with: + # go-version: '1.23' + # - uses: magefile/mage-action@v3 + # with: + # version: latest + # args: coverage + # - uses: magefile/mage-action@v3 + # env: + # GOARCH: arm64 + # with: + # version: latest + # args: -v build:LinuxARM64 + # - name: Upload Linux ARM64 artifact + # uses: actions/upload-artifact@v4 + # with: + # name: linux-arm64-dist + # path: dist/ build-macos-arm64: runs-on: macos-latest @@ -106,7 +106,7 @@ jobs: path: dist/ generate-manifest: - needs: [build-linux-amd64, build-linux-arm64, build-macos-arm64, build-windows] + needs: [build-linux-amd64, build-macos-arm64, build-windows] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From cff7ca958f8069e96c425b229de651941f2e2b6e Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Mon, 23 Dec 2024 16:19:42 -0500 Subject: [PATCH 07/25] add artifacts to job --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d0783..4381c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,15 +149,17 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: all-artifacts - name: Setup Node.js environment uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - - name: Install dependencies run: npm ci - - name: Check types run: npm run typecheck - name: Lint From 85e4a9a6beb30e017ab4a8a5afaf2fb3a8207eec Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Thu, 2 Jan 2025 12:42:29 -0500 Subject: [PATCH 08/25] update license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 9c8f3ea..c8dc1a8 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2024 MotherDuck Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From ca31821fdade1b1d402433efdd6bab553371867b Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Thu, 2 Jan 2025 21:28:51 -0500 Subject: [PATCH 09/25] rename config vars, implement a datasource wrapper --- pkg/main.go | 5 +- pkg/models/settings.go | 4 +- pkg/plugin/datasource.go | 121 +++++++++-------------- pkg/plugin/duckdb_driver.go | 13 ++- provisioning/datasources/datasources.yml | 2 +- src/components/ConfigEditor.tsx | 28 +++--- src/img/logo.svg | 2 +- src/sqlUtil.ts | 3 - src/types.ts | 2 +- 9 files changed, 75 insertions(+), 105 deletions(-) diff --git a/pkg/main.go b/pkg/main.go index 542fd79..414be09 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/sqlds/v3" "github.com/motherduck/duckdb-datasource/pkg/plugin" ) @@ -22,13 +21,13 @@ func main() { // ID). When datasource configuration changed Dispose method will be called and // new datasource instance created using NewSampleDatasource factory. - if err := datasource.Manage("motherduck-duckdb-datasource", datasourceFactory, datasource.ManageOpts{}); err != nil { + if err := datasource.Manage("grafana-duckdb-datasource", datasourceFactory, datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) } } func datasourceFactory(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - ds := sqlds.NewDatasource(&plugin.DuckDBDriver{}) + ds := plugin.NewDatasource(&plugin.DuckDBDriver{}) return ds.NewDatasource(ctx, s) } diff --git a/pkg/models/settings.go b/pkg/models/settings.go index b002632..a1bde0b 100644 --- a/pkg/models/settings.go +++ b/pkg/models/settings.go @@ -13,7 +13,7 @@ type PluginSettings struct { } type SecretPluginSettings struct { - ApiKey string `json:"apiKey"` + MotherDuckToken string `json:"motherduckToken"` } func LoadPluginSettings(source backend.DataSourceInstanceSettings) (*PluginSettings, error) { @@ -30,6 +30,6 @@ func LoadPluginSettings(source backend.DataSourceInstanceSettings) (*PluginSetti func loadSecretPluginSettings(source map[string]string) *SecretPluginSettings { return &SecretPluginSettings{ - ApiKey: source["apiKey"], + MotherDuckToken: source["motherduckToken"], } } diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 8479146..88ac1c5 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -2,14 +2,11 @@ package plugin import ( "context" - "encoding/json" - "fmt" - "time" + "sync" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/motherduck/duckdb-datasource/pkg/models" + "github.com/grafana/sqlds/v3" ) // Make sure Datasource implements required interfaces. This is important to do @@ -18,99 +15,69 @@ import ( // backend.CheckHealthHandler interfaces. Plugin should not implement all these // interfaces - only those which are required for a particular task. var ( - _ backend.QueryDataHandler = (*Datasource)(nil) - _ backend.CheckHealthHandler = (*Datasource)(nil) - _ instancemgmt.InstanceDisposer = (*Datasource)(nil) + _ backend.QueryDataHandler = (*SQLDatasourceWithDebug)(nil) + _ backend.CheckHealthHandler = (*SQLDatasourceWithDebug)(nil) + _ instancemgmt.InstanceDisposer = (*SQLDatasourceWithDebug)(nil) ) -// NewDatasource creates a new datasource instance. -func NewDatasource(_ context.Context, _ backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return &Datasource{}, nil +// NewDatasource creates a new `SQLDatasource`. +// It uses the provided settings argument to call the ds.Driver to connect to the SQL server +func (ds *SQLDatasourceWithDebug) NewDatasource(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return ds.SQLDatasource.NewDatasource(ctx, settings) } -// Datasource is an example datasource which can respond to data queries, reports -// its health and has streaming skills. -type Datasource struct{} +// SQLDatasourceWithDebug +type SQLDatasourceWithDebug struct { + *sqlds.SQLDatasource +} + +// NewDatasource initializes the Datasource wrapper and instance manager +func NewDatasource(c sqlds.Driver) *SQLDatasourceWithDebug { + return &SQLDatasourceWithDebug{ + SQLDatasource: sqlds.NewDatasource(c), + } +} // Dispose here tells plugin SDK that plugin wants to clean up resources when a new instance -// created. As soon as datasource settings change detected by SDK old datasource instance will -// be disposed and a new one will be created using NewSampleDatasource factory function. -func (d *Datasource) Dispose() { - // Clean up datasource instance resources. +// created. As soon as SQLDatasourceWithDebug settings change detected by SDK old SQLDatasourceWithDebug instance will +// be disposed and a new one will be created using NewSampleSQLDatasourceWithDebug factory function. +func (d *SQLDatasourceWithDebug) Dispose() { + + d.SQLDatasource.Dispose() + + // Clean up SQLDatasourceWithDebug instance resources. } // QueryData handles multiple queries and returns multiple responses. // req contains the queries []DataQuery (where each query contains RefID as a unique identifier). // The QueryDataResponse contains a map of RefID to the response for each query, and each response // contains Frames ([]*Frame). -func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - // create response struct - response := backend.NewQueryDataResponse() +func (d *SQLDatasourceWithDebug) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + var wg = sync.WaitGroup{} - // loop over queries and execute them individually. - for _, q := range req.Queries { - res := d.query(ctx, req.PluginContext, q) + wg.Add(len(req.Queries)) - // save the response in a hashmap - // based on with RefID as identifier - response.Responses[q.RefID] = res - } - - return response, nil -} - -type queryModel struct{} - -func (d *Datasource) query(_ context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse { - var response backend.DataResponse - - // Unmarshal the JSON into our queryModel. - var qm queryModel - - err := json.Unmarshal(query.JSON, &qm) - if err != nil { - return backend.ErrDataResponse(backend.StatusBadRequest, fmt.Sprintf("json unmarshal: %v", err.Error())) + // Execute each query and store the results by query RefID + for _, q := range req.Queries { + go func(query backend.DataQuery) { + // log query + backend.Logger.Info("Going to query", "query", query) + wg.Done() + }(q) } - // create data frame response. - // For an overview on data frames and how grafana handles them: - // https://grafana.com/developers/plugin-tools/introduction/data-frames - frame := data.NewFrame("response") + wg.Wait() - // add fields. - frame.Fields = append(frame.Fields, - data.NewField("time", nil, []time.Time{query.TimeRange.From, query.TimeRange.To}), - data.NewField("values", nil, []int64{10, 20}), - ) + response, err := d.SQLDatasource.QueryData(ctx, req) - // add the frames to the response. - response.Frames = append(response.Frames, frame) - - return response + return response, err } // CheckHealth handles health checks sent from Grafana to the plugin. // The main use case for these health checks is the test button on the -// datasource configuration page which allows users to verify that -// a datasource is working as expected. -func (d *Datasource) CheckHealth(_ context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - res := &backend.CheckHealthResult{} - config, err := models.LoadPluginSettings(*req.PluginContext.DataSourceInstanceSettings) - - if err != nil { - res.Status = backend.HealthStatusError - res.Message = "Unable to load settings" - return res, nil - } - - if config.Secrets.ApiKey == "" { - res.Status = backend.HealthStatusError - res.Message = "API key is missing" - return res, nil - } +// SQLDatasourceWithDebug configuration page which allows users to verify that +// a SQLDatasourceWithDebug is working as expected. +func (d *SQLDatasourceWithDebug) CheckHealth(_ context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - return &backend.CheckHealthResult{ - Status: backend.HealthStatusOk, - Message: "Data source is working", - }, nil + return d.SQLDatasource.CheckHealth(context.Background(), req) } diff --git a/pkg/plugin/duckdb_driver.go b/pkg/plugin/duckdb_driver.go index c9b3c94..bce77ec 100644 --- a/pkg/plugin/duckdb_driver.go +++ b/pkg/plugin/duckdb_driver.go @@ -44,8 +44,8 @@ func (d *DuckDBDriver) Connect(ctx context.Context, settings backend.DataSourceI return nil, err } - if config.Secrets.ApiKey != "" { - os.Setenv("motherduck_token", config.Secrets.ApiKey) + if config.Secrets.MotherDuckToken != "" { + os.Setenv("motherduck_token", config.Secrets.MotherDuckToken) } // // join config as url parmaeters // config, err := parseConfig(settings) @@ -77,11 +77,18 @@ func (d *DuckDBDriver) Connect(ctx context.Context, settings backend.DataSourceI // queryString := strings.Join(parts, "&") // dbString := strings.Join([]string{dbPath, queryString}, "?") connector, err := duckdb.NewConnector(config.Path, func(execer driver.ExecerContext) error { + // read env variable GF_PATHS_HOME + homePath := os.Getenv("GF_PATHS_HOME") + bootQueries := []string{ "INSTALL 'motherduck'", "LOAD 'motherduck'", } + if homePath != "" { + bootQueries = append(bootQueries, "SET home_directory='"+homePath+"'") + } + for _, query := range bootQueries { _, err = execer.ExecContext(context.Background(), query, nil) if err != nil { @@ -125,7 +132,7 @@ func (d *DuckDBDriver) Converters() []sqlutil.Converter { return GetConverterList() } -// Originally from https://github.com/snakedotdev/grafana-duckdb-datasource +// From https://github.com/snakedotdev/grafana-duckdb-datasource // Apache 2.0 Licensed // Copyright snakedotdev // Modified from original version diff --git a/provisioning/datasources/datasources.yml b/provisioning/datasources/datasources.yml index 5a4fce7..042b38e 100644 --- a/provisioning/datasources/datasources.yml +++ b/provisioning/datasources/datasources.yml @@ -11,4 +11,4 @@ datasources: jsonData: path: '/resources' secureJsonData: - apiKey: 'api-key' + motherduckToken: 'motherduck-token' diff --git a/src/components/ConfigEditor.tsx b/src/components/ConfigEditor.tsx index 3e94626..c71d1d5 100644 --- a/src/components/ConfigEditor.tsx +++ b/src/components/ConfigEditor.tsx @@ -20,50 +20,50 @@ export function ConfigEditor(props: Props) { }; // Secure field (only sent to the backend) - const onAPIKeyChange = (event: ChangeEvent) => { + const onMotherDuckTokenChange = (event: ChangeEvent) => { onOptionsChange({ ...options, secureJsonData: { - apiKey: event.target.value, + motherDuckToken: event.target.value, }, }); }; - const onResetAPIKey = () => { + const onResetMotherDuckToken = () => { onOptionsChange({ ...options, secureJsonFields: { ...options.secureJsonFields, - apiKey: false, + motherDuckToken: false, }, secureJsonData: { ...options.secureJsonData, - apiKey: '', + motherDuckToken: '', }, }); }; return ( <> - + - + diff --git a/src/img/logo.svg b/src/img/logo.svg index 3d284de..ef03312 100644 --- a/src/img/logo.svg +++ b/src/img/logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/sqlUtil.ts b/src/sqlUtil.ts index 8ccf06c..4de3fb2 100644 --- a/src/sqlUtil.ts +++ b/src/sqlUtil.ts @@ -64,9 +64,6 @@ export function getFieldConfig(type: string): { raqbFieldType: RAQBFieldTypes; i } case 'timestamp': case 'timestamp with time zone': - case 'timestamp without time zone': { - return { raqbFieldType: 'datetime', icon: 'clock-nine' }; - } default: return { raqbFieldType: 'text', icon: 'text' }; } diff --git a/src/types.ts b/src/types.ts index a946962..2992350 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,5 +30,5 @@ export interface DuckDBDataSourceOptions extends SQLOptions { * Value that is used in the backend, but never sent over HTTP to the frontend */ export interface SecureJsonData { - apiKey?: string; + motherDuckToken?: string; } From 13a7154ed5d3b2fa97a557ad756c4222a3596833 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Thu, 2 Jan 2025 21:38:24 -0500 Subject: [PATCH 10/25] fix go path and test? --- go.mod | 2 +- pkg/main.go | 2 +- pkg/plugin/datasource_test.go | 3 ++- pkg/plugin/duckdb_driver.go | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 552fe02..45f855e 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/motherduck/duckdb-datasource +module github.com/motherduckdb/grafana-duckdb-datasource go 1.23 diff --git a/pkg/main.go b/pkg/main.go index 414be09..071b3cc 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/motherduck/duckdb-datasource/pkg/plugin" + "github.com/motherduckdb/grafana-duckdb-datasource/pkg/plugin" ) func main() { diff --git a/pkg/plugin/datasource_test.go b/pkg/plugin/datasource_test.go index 1e68821..ec4b2c6 100644 --- a/pkg/plugin/datasource_test.go +++ b/pkg/plugin/datasource_test.go @@ -8,7 +8,8 @@ import ( ) func TestQueryData(t *testing.T) { - ds := Datasource{} + ds := NewDatasource(&DuckDBDriver{}) + _, err := ds.NewDatasource(context.Background(), backend.DataSourceInstanceSettings{}) resp, err := ds.QueryData( context.Background(), diff --git a/pkg/plugin/duckdb_driver.go b/pkg/plugin/duckdb_driver.go index bce77ec..e05d589 100644 --- a/pkg/plugin/duckdb_driver.go +++ b/pkg/plugin/duckdb_driver.go @@ -18,7 +18,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/sqlds/v3" "github.com/marcboeker/go-duckdb" - "github.com/motherduck/duckdb-datasource/pkg/models" + "github.com/motherduckdb/grafana-duckdb-datasource/pkg/models" ) type DuckDBDriver struct { From 2ca72eb81874bc3f6c30f89621d68be90cf0882d Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 10:13:59 -0500 Subject: [PATCH 11/25] fix test --- pkg/plugin/datasource.go | 23 ++++++----------------- pkg/plugin/datasource_test.go | 11 +++++++++-- pkg/plugin/duckdb_driver.go | 10 ++++------ 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 88ac1c5..531a922 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -2,7 +2,6 @@ package plugin import ( "context" - "sync" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" @@ -23,7 +22,12 @@ var ( // NewDatasource creates a new `SQLDatasource`. // It uses the provided settings argument to call the ds.Driver to connect to the SQL server func (ds *SQLDatasourceWithDebug) NewDatasource(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return ds.SQLDatasource.NewDatasource(ctx, settings) + newSqlDs, err := ds.SQLDatasource.NewDatasource(ctx, settings) + if err != nil { + return nil, err + } + ds.SQLDatasource = newSqlDs.(*sqlds.SQLDatasource) + return ds, nil } // SQLDatasourceWithDebug @@ -53,21 +57,6 @@ func (d *SQLDatasourceWithDebug) Dispose() { // The QueryDataResponse contains a map of RefID to the response for each query, and each response // contains Frames ([]*Frame). func (d *SQLDatasourceWithDebug) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - var wg = sync.WaitGroup{} - - wg.Add(len(req.Queries)) - - // Execute each query and store the results by query RefID - for _, q := range req.Queries { - go func(query backend.DataQuery) { - // log query - backend.Logger.Info("Going to query", "query", query) - wg.Done() - }(q) - } - - wg.Wait() - response, err := d.SQLDatasource.QueryData(ctx, req) return response, err diff --git a/pkg/plugin/datasource_test.go b/pkg/plugin/datasource_test.go index ec4b2c6..217e8bd 100644 --- a/pkg/plugin/datasource_test.go +++ b/pkg/plugin/datasource_test.go @@ -2,6 +2,7 @@ package plugin import ( "context" + "encoding/json" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -9,13 +10,18 @@ import ( func TestQueryData(t *testing.T) { ds := NewDatasource(&DuckDBDriver{}) - _, err := ds.NewDatasource(context.Background(), backend.DataSourceInstanceSettings{}) + _, err := ds.NewDatasource(context.Background(), backend.DataSourceInstanceSettings{ + JSONData: []byte(`{"path":""}`), + }) resp, err := ds.QueryData( context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, Queries: []backend.DataQuery{ - {RefID: "A"}, + {RefID: "A", JSON: json.RawMessage(`{"rawSql": "from duckdb_settings();"}`)}, }, }, ) @@ -23,6 +29,7 @@ func TestQueryData(t *testing.T) { t.Error(err) } + backend.Logger.Info("Response", "resp", resp) if len(resp.Responses) != 1 { t.Fatal("QueryData must return a response") } diff --git a/pkg/plugin/duckdb_driver.go b/pkg/plugin/duckdb_driver.go index e05d589..a0e2949 100644 --- a/pkg/plugin/duckdb_driver.go +++ b/pkg/plugin/duckdb_driver.go @@ -24,11 +24,8 @@ import ( type DuckDBDriver struct { } -var allowedSettings = []string{"access_mode", "checkpoint_threshold", "debug_checkpoint_abort", "debug_force_external", "debug_force_no_cross_product", "debug_asof_iejoin", "prefer_range_joins", "debug_window_mode", "default_collation", "default_order", "default_null_order", "disabled_filesystems", "disabled_optimizers", "enable_external_access", "enable_fsst_vectors", "allow_unsigned_extensions", "custom_extension_repository", "autoinstall_extension_repository", "autoinstall_known_extensions", "autoload_known_extensions", "enable_object_cache", "enable_http_metadata_cache", "enable_profiling", "enable_progress_bar", "enable_progress_bar_print", "explain_output", "extension_directory", "external_threads", "file_search_path", "force_compression", "force_bitpacking_mode", "home_directory", "log_query_path", "lock_configuration", "immediate_transaction_mode", "integer_division", "max_expression_depth", "max_memory", "memory_limit", "null_order", "ordered_aggregate_threshold", "password", "perfect_ht_threshold", "pivot_filter_threshold", "pivot_limit", "preserve_identifier_case", "preserve_insertion_order", "profiler_history_size", "profile_output", "profiling_mode", "profiling_output", "progress_bar_time", "schema", "search_path", "temp_directory", "threads", "username", "arrow_large_buffer_size", "user", "wal_autocheckpoint", "worker_threads", "allocator_flush_threshold", "duckdb_api", "custom_user_agent", "motherduck_saas_mode", "motherduck_database_uuid", "motherduck_use_tls", "motherduck_background_catalog_refresh_long_poll_timeout", "motherduck_background_catalog_refresh_inactivity_timeout", "motherduck_lease_timeout", "motherduck_database_name", "motherduck_port", "motherduck_log_level", "pandas_analyze_sample", "motherduck_host", "motherduck_background_catalog_refresh", "binary_as_string", "motherduck_token", "Calendar", "TimeZone"} - // parse config from settings.JSONData func parseConfig(settings backend.DataSourceInstanceSettings) (map[string]string, error) { - config := make(map[string]string) err := json.Unmarshal(settings.JSONData, &config) if err != nil { @@ -38,7 +35,6 @@ func parseConfig(settings backend.DataSourceInstanceSettings) (map[string]string } func (d *DuckDBDriver) Connect(ctx context.Context, settings backend.DataSourceInstanceSettings, msg json.RawMessage) (*sql.DB, error) { - config, err := models.LoadPluginSettings(settings) if err != nil { return nil, err @@ -47,6 +43,7 @@ func (d *DuckDBDriver) Connect(ctx context.Context, settings backend.DataSourceI if config.Secrets.MotherDuckToken != "" { os.Setenv("motherduck_token", config.Secrets.MotherDuckToken) } + // // join config as url parmaeters // config, err := parseConfig(settings) @@ -76,15 +73,16 @@ func (d *DuckDBDriver) Connect(ctx context.Context, settings backend.DataSourceI // Join all parts with '&' to form the final query string // queryString := strings.Join(parts, "&") // dbString := strings.Join([]string{dbPath, queryString}, "?") + connector, err := duckdb.NewConnector(config.Path, func(execer driver.ExecerContext) error { - // read env variable GF_PATHS_HOME - homePath := os.Getenv("GF_PATHS_HOME") bootQueries := []string{ "INSTALL 'motherduck'", "LOAD 'motherduck'", } + // read env variable GF_PATHS_HOME + homePath := os.Getenv("GF_PATHS_HOME") if homePath != "" { bootQueries = append(bootQueries, "SET home_directory='"+homePath+"'") } From 698cca9e92305896b53a0ab66e6ba5733b62bba4 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 10:29:28 -0500 Subject: [PATCH 12/25] try building ubuntu arm64 --- .github/workflows/ci.yml | 76 +++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4381c89..4607eab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,33 +34,49 @@ jobs: name: linux-amd64-dist path: dist/ - # build-linux-arm64: - # runs-on: ubuntu-latest - # container: - # image: arm64v8/golang:1.23 - # options: --platform linux/arm64 - # steps: - # - uses: actions/checkout@v4 - # with: - # submodules: recursive - # - uses: actions/setup-go@v5 - # with: - # go-version: '1.23' - # - uses: magefile/mage-action@v3 - # with: - # version: latest - # args: coverage - # - uses: magefile/mage-action@v3 - # env: - # GOARCH: arm64 - # with: - # version: latest - # args: -v build:LinuxARM64 - # - name: Upload Linux ARM64 artifact - # uses: actions/upload-artifact@v4 - # with: - # name: linux-arm64-dist - # path: dist/ + build-linux-arm64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + with: + platforms: linux/arm64 + + - name: Create temporary Dockerfile + run: | + cat > Dockerfile << 'EOF' + FROM arm64v8/golang:1.23 + + RUN go install github.com/magefile/mage@latest + RUN mage -init + + RUN apt-get update && apt-get install -y \ + gcc-aarch64-linux-gnu \ + g++-aarch64-linux-gnu + + RUN rm -rf /var/lib/apt/lists/* + + WORKDIR /repo + + EOF + + - name: Build and run Container + shell: sh + + run: | + docker build --platform linux/arm64 -t go-arm64 . + docker run --platform linux/arm64 -v `pwd`:/repo --name go-arm64 go-arm64 mage coverage + docker run --platform linux/arm64 -v `pwd`:/repo --name go-arm64 go-arm64 mage -v build:LinuxARM64 + + - name: Upload Linux ARM64 artifact + uses: actions/upload-artifact@v4 + with: + name: linux-arm64-dist + path: dist/ build-macos-arm64: runs-on: macos-latest @@ -181,12 +197,6 @@ jobs: with: go-version: '1.23' - - name: Download backend dist directory - run: | - mkdir -p dist - ls all-artifacts - cp -r all-artifacts/*/* dist/ - - name: Check for E2E id: check-for-e2e run: | From 60de9da13402337f22c38ccf2ae2114a386c4e2f Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 10:31:47 -0500 Subject: [PATCH 13/25] rm test logging --- pkg/plugin/datasource_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/plugin/datasource_test.go b/pkg/plugin/datasource_test.go index 217e8bd..08b163b 100644 --- a/pkg/plugin/datasource_test.go +++ b/pkg/plugin/datasource_test.go @@ -28,8 +28,6 @@ func TestQueryData(t *testing.T) { if err != nil { t.Error(err) } - - backend.Logger.Info("Response", "resp", resp) if len(resp.Responses) != 1 { t.Fatal("QueryData must return a response") } From 2b870412195835951a2cbf3f548bc61aa6474b64 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 10:55:39 -0500 Subject: [PATCH 14/25] fix arm64 --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++-- src/components/ConfigEditor.tsx | 4 ++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4607eab..171e42a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,14 @@ jobs: shell: sh run: | + cat > run.sh << 'EOF' + mage coverage + mage -v build:LinuxARM64 + EOF + + chmod +x run.sh docker build --platform linux/arm64 -t go-arm64 . - docker run --platform linux/arm64 -v `pwd`:/repo --name go-arm64 go-arm64 mage coverage - docker run --platform linux/arm64 -v `pwd`:/repo --name go-arm64 go-arm64 mage -v build:LinuxARM64 + docker run --platform linux/arm64 -v `pwd`:/repo --name go-arm64 go-arm64 ./run.sh - name: Upload Linux ARM64 artifact uses: actions/upload-artifact@v4 @@ -197,6 +202,14 @@ jobs: with: go-version: '1.23' + - name: Download backend dist directory + run: | + mkdir -p dist + ls all-artifacts + ls all-artifacts/*/ + ls dist + cp -r all-artifacts/*/* dist/ + - name: Check for E2E id: check-for-e2e run: | @@ -268,6 +281,15 @@ jobs: path: dist name: ${{ needs.build.outputs.plugin-id }}-${{ needs.build.outputs.plugin-version }} + - name: Download backend dist directory + run: | + mkdir -p dist + ls all-artifacts + ls all-artifacts/*/ + ls dist + cp -r all-artifacts/*/* dist/ + + - name: Execute permissions on binary if: needs.build.outputs.has-backend == 'true' run: | diff --git a/src/components/ConfigEditor.tsx b/src/components/ConfigEditor.tsx index c71d1d5..2532f45 100644 --- a/src/components/ConfigEditor.tsx +++ b/src/components/ConfigEditor.tsx @@ -45,7 +45,7 @@ export function ConfigEditor(props: Props) { return ( <> - + - + Date: Fri, 3 Jan 2025 11:01:31 -0500 Subject: [PATCH 15/25] nit? --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 171e42a..713cc84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,10 +69,10 @@ jobs: run: | cat > run.sh << 'EOF' - mage coverage - mage -v build:LinuxARM64 + mage coverage + mage -v build:LinuxARM64 EOF - + chmod +x run.sh docker build --platform linux/arm64 -t go-arm64 . docker run --platform linux/arm64 -v `pwd`:/repo --name go-arm64 go-arm64 ./run.sh @@ -143,7 +143,7 @@ jobs: - name: Prepare dist directory run: | mkdir -p dist - cp -r all-artifacts/*/* dist/ + cp -r all-artifacts/final-dist/* dist/ - uses: magefile/mage-action@v3 with: version: latest From 480cba9de253c84ac88686cf0f9ebfbf16e17edc Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 11:02:09 -0500 Subject: [PATCH 16/25] fixed the wrong place --- .github/workflows/ci.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 713cc84..6394698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: run: | cat > run.sh << 'EOF' mage coverage - mage -v build:LinuxARM64 + mage -v build:LinuxARM64 EOF chmod +x run.sh @@ -143,7 +143,7 @@ jobs: - name: Prepare dist directory run: | mkdir -p dist - cp -r all-artifacts/final-dist/* dist/ + cp -r all-artifacts/*/* dist/ - uses: magefile/mage-action@v3 with: version: latest @@ -205,10 +205,7 @@ jobs: - name: Download backend dist directory run: | mkdir -p dist - ls all-artifacts - ls all-artifacts/*/ - ls dist - cp -r all-artifacts/*/* dist/ + cp -r all-artifacts/final-dist/* dist/ - name: Check for E2E id: check-for-e2e From 477e03184cf341a7f615de29497ab6e3baa66333 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 11:09:29 -0500 Subject: [PATCH 17/25] fix? --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6394698..feb53d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,10 +69,12 @@ jobs: run: | cat > run.sh << 'EOF' + !#/bin/sh mage coverage - mage -v build:LinuxARM64 + mage -v build:LinuxARM64 EOF + cat run.sh chmod +x run.sh docker build --platform linux/arm64 -t go-arm64 . docker run --platform linux/arm64 -v `pwd`:/repo --name go-arm64 go-arm64 ./run.sh From 701c6d8270267850a82d8b0991fac77a51967376 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 11:15:46 -0500 Subject: [PATCH 18/25] fix logo, and backend dist directory preparation --- .github/workflows/ci.yml | 6 ++---- src/img/logo.svg | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feb53d2..3b3a208 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,10 +142,12 @@ jobs: uses: actions/download-artifact@v4 with: path: all-artifacts + - name: Prepare dist directory run: | mkdir -p dist cp -r all-artifacts/*/* dist/ + - uses: magefile/mage-action@v3 with: version: latest @@ -283,12 +285,8 @@ jobs: - name: Download backend dist directory run: | mkdir -p dist - ls all-artifacts - ls all-artifacts/*/ - ls dist cp -r all-artifacts/*/* dist/ - - name: Execute permissions on binary if: needs.build.outputs.has-backend == 'true' run: | diff --git a/src/img/logo.svg b/src/img/logo.svg index ef03312..a58d306 100644 --- a/src/img/logo.svg +++ b/src/img/logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 962e0ff7755b9d2bdaee4268b6ad6eccff98b55f Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 11:17:21 -0500 Subject: [PATCH 19/25] fix shebang --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b3a208..7f59d13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: run: | cat > run.sh << 'EOF' - !#/bin/sh + #!/bin/sh mage coverage mage -v build:LinuxARM64 EOF From 014d6a2c22f45ad54caf1c3b1937836e7a49c248 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 11:55:46 -0500 Subject: [PATCH 20/25] more fixes --- .github/workflows/ci.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f59d13..8bb9618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,9 +57,13 @@ jobs: RUN apt-get update && apt-get install -y \ gcc-aarch64-linux-gnu \ g++-aarch64-linux-gnu - + RUN rm -rf /var/lib/apt/lists/* + # Address Issue: error obtaining VCS status: exit status 128 + # Use -buildvcs=false to disable VCS stamping. + ENV GOFLAGS=-buildvcs=false + WORKDIR /repo EOF @@ -236,17 +240,22 @@ jobs: echo "plugin-version=${GRAFANA_PLUGIN_VERSION}" >> $GITHUB_OUTPUT echo "archive=${GRAFANA_PLUGIN_ARTIFACT}" >> $GITHUB_OUTPUT + - name: Add execute permissions on binary + if: needs.build.outputs.has-backend == 'true' + run: | + chmod +x ./dist/gpx_* + - name: Package plugin id: package-plugin run: | mv dist ${{ steps.metadata.outputs.plugin-id }} - zip ${{ steps.metadata.outputs.archive }} ${{ steps.metadata.outputs.plugin-id }} -r + zip -r ${{ steps.metadata.outputs.archive }} ${{ steps.metadata.outputs.plugin-id }} - name: Archive Build uses: actions/upload-artifact@v4 with: name: ${{ steps.metadata.outputs.plugin-id }}-${{ steps.metadata.outputs.plugin-version }} - path: ${{ steps.metadata.outputs.plugin-id }} + path: ${{ steps.metadata.outputs.archive }} retention-days: 5 resolve-versions: From 026f1537a187bc9bf9c795ee7a220efab0d7990c Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 12:07:50 -0500 Subject: [PATCH 21/25] fix again --- .github/workflows/ci.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bb9618..cbde193 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -255,7 +255,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ steps.metadata.outputs.plugin-id }}-${{ steps.metadata.outputs.plugin-version }} - path: ${{ steps.metadata.outputs.archive }} + path: ${{ steps.metadata.outputs.plugin-id }} retention-days: 5 resolve-versions: @@ -291,14 +291,11 @@ jobs: path: dist name: ${{ needs.build.outputs.plugin-id }}-${{ needs.build.outputs.plugin-version }} - - name: Download backend dist directory - run: | - mkdir -p dist - cp -r all-artifacts/*/* dist/ - - name: Execute permissions on binary if: needs.build.outputs.has-backend == 'true' run: | + ls + ls dist/ chmod +x ./dist/gpx_* - name: Setup Node.js environment From ac7e1c7e62a02aad3f9a6a282fc21b3ebbf919bf Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 17:02:36 -0500 Subject: [PATCH 22/25] implement file watcher, and fix e2e test! --- docker-compose.yaml | 3 +- package.json | 2 +- pkg/plugin/datasource.go | 91 ++++++++++++++--- pkg/plugin/duckdb_driver.go | 122 ++++------------------- provisioning/datasources/datasources.yml | 8 +- src/datasource.ts | 2 + src/queryDefaults.ts | 2 +- tests/configEditor.spec.ts | 20 ++-- tests/queryEditor.spec.ts | 24 ++--- 9 files changed, 120 insertions(+), 154 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 85bf033..8ce3e8d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -22,11 +22,10 @@ services: - ./dist:/var/lib/grafana/plugins/motherduck-duckdb-datasource - ./provisioning:/etc/grafana/provisioning - .:/root/motherduck-duckdb-datasource - - /Users/louisa/.duckdb:/root/.duckdb environment: NODE_ENV: development GF_LOG_FILTERS: plugin.motherduck-duckdb-datasource:debug - GF_LOG_LEVEL: debug + GF_LOG_LEVEL: info GF_DATAPROXY_LOGGING: 1 GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: motherduck-duckdb-datasource diff --git a/package.json b/package.json index 3d8c228..693f73b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", "lint:fix": "npm run lint -- --fix", - "e2e": "playwright test", + "e2e": "playwright test --trace on", "server": "docker-compose up --build", "sign": "npx --yes @grafana/sign-plugin@latest", "postinstall": "patch-package" diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 531a922..791b4b3 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -2,9 +2,14 @@ package plugin import ( "context" + "os" + "strings" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/motherduckdb/grafana-duckdb-datasource/pkg/models" + "github.com/grafana/sqlds/v3" ) @@ -14,49 +19,104 @@ import ( // backend.CheckHealthHandler interfaces. Plugin should not implement all these // interfaces - only those which are required for a particular task. var ( - _ backend.QueryDataHandler = (*SQLDatasourceWithDebug)(nil) - _ backend.CheckHealthHandler = (*SQLDatasourceWithDebug)(nil) - _ instancemgmt.InstanceDisposer = (*SQLDatasourceWithDebug)(nil) + _ backend.QueryDataHandler = (*SQLDataSourceWrapper)(nil) + _ backend.CheckHealthHandler = (*SQLDataSourceWrapper)(nil) + _ instancemgmt.InstanceDisposer = (*SQLDataSourceWrapper)(nil) ) // NewDatasource creates a new `SQLDatasource`. // It uses the provided settings argument to call the ds.Driver to connect to the SQL server -func (ds *SQLDatasourceWithDebug) NewDatasource(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { +func (ds *SQLDataSourceWrapper) NewDatasource(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + ds.settings = settings + + config, err := models.LoadPluginSettings(settings) + if err != nil { + return nil, err + } + + ds.fileWatcher = NewFileWatcher(config.Path) + newSqlDs, err := ds.SQLDatasource.NewDatasource(ctx, settings) if err != nil { return nil, err } ds.SQLDatasource = newSqlDs.(*sqlds.SQLDatasource) + return ds, nil } -// SQLDatasourceWithDebug -type SQLDatasourceWithDebug struct { +type FileWatcher struct { + path string + isLocalFile bool + lastModified time.Time +} + +func NewFileWatcher(path string) *FileWatcher { + // If path is empty (in-memory duckdb) or connecting to motherduck, then file watcher is not needed. + isLocalFile := !(strings.HasPrefix(path, "md:") || path == "") + + return &FileWatcher{path: path, isLocalFile: isLocalFile, lastModified: time.Now()} +} + +func (f *FileWatcher) HasUpdate() bool { + if !f.isLocalFile { + backend.Logger.Debug("File watcher is not needed for non-local file (", "path=", f.path, ")") + return false + } + + info, err := os.Stat(f.path) + if err != nil { + return false + } + backend.Logger.Debug("Checking file modification", "path", f.path, "lastModified", f.lastModified, "currentModified", info.ModTime()) + + if info.ModTime().After(f.lastModified) { + f.lastModified = info.ModTime() + return true + } + + return false +} + +// SQLDataSourceWrapper +type SQLDataSourceWrapper struct { *sqlds.SQLDatasource + + fileWatcher *FileWatcher + settings backend.DataSourceInstanceSettings } // NewDatasource initializes the Datasource wrapper and instance manager -func NewDatasource(c sqlds.Driver) *SQLDatasourceWithDebug { - return &SQLDatasourceWithDebug{ +func NewDatasource(c sqlds.Driver) *SQLDataSourceWrapper { + return &SQLDataSourceWrapper{ SQLDatasource: sqlds.NewDatasource(c), } } // Dispose here tells plugin SDK that plugin wants to clean up resources when a new instance -// created. As soon as SQLDatasourceWithDebug settings change detected by SDK old SQLDatasourceWithDebug instance will +// created. As soon as SQLDataSourceWrapper settings change detected by SDK old SQLDataSourceWrapper instance will // be disposed and a new one will be created using NewSampleSQLDatasourceWithDebug factory function. -func (d *SQLDatasourceWithDebug) Dispose() { +func (d *SQLDataSourceWrapper) Dispose() { d.SQLDatasource.Dispose() - // Clean up SQLDatasourceWithDebug instance resources. + // Clean up SQLDataSourceWrapper instance resources. } // QueryData handles multiple queries and returns multiple responses. // req contains the queries []DataQuery (where each query contains RefID as a unique identifier). // The QueryDataResponse contains a map of RefID to the response for each query, and each response // contains Frames ([]*Frame). -func (d *SQLDatasourceWithDebug) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (d *SQLDataSourceWrapper) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + if d.fileWatcher.HasUpdate() { + backend.Logger.Debug("DuckDB file has been modified, reloading DataSource.") + newSqlDs, err := d.SQLDatasource.NewDatasource(ctx, d.settings) + if err != nil { + return nil, err + } + d.SQLDatasource = newSqlDs.(*sqlds.SQLDatasource) + } + response, err := d.SQLDatasource.QueryData(ctx, req) return response, err @@ -64,9 +124,8 @@ func (d *SQLDatasourceWithDebug) QueryData(ctx context.Context, req *backend.Que // CheckHealth handles health checks sent from Grafana to the plugin. // The main use case for these health checks is the test button on the -// SQLDatasourceWithDebug configuration page which allows users to verify that -// a SQLDatasourceWithDebug is working as expected. -func (d *SQLDatasourceWithDebug) CheckHealth(_ context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - +// SQLDataSourceWrapper configuration page which allows users to verify that +// a SQLDataSourceWrapper is working as expected. +func (d *SQLDataSourceWrapper) CheckHealth(_ context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { return d.SQLDatasource.CheckHealth(context.Background(), req) } diff --git a/pkg/plugin/duckdb_driver.go b/pkg/plugin/duckdb_driver.go index a0e2949..c4d1aad 100644 --- a/pkg/plugin/duckdb_driver.go +++ b/pkg/plugin/duckdb_driver.go @@ -9,6 +9,7 @@ import ( "reflect" "regexp" "strconv" + "strings" "time" "github.com/mitchellh/mapstructure" @@ -21,6 +22,14 @@ import ( "github.com/motherduckdb/grafana-duckdb-datasource/pkg/models" ) +type ConfigError struct { + Msg string +} + +func (e *ConfigError) Error() string { + return e.Msg +} + type DuckDBDriver struct { } @@ -40,6 +49,10 @@ func (d *DuckDBDriver) Connect(ctx context.Context, settings backend.DataSourceI return nil, err } + if strings.HasPrefix(config.Path, "md:") && config.Secrets.MotherDuckToken == "" { + return nil, &ConfigError{"MotherDuck Token is missing for motherduck connection"} + } + if config.Secrets.MotherDuckToken != "" { os.Setenv("motherduck_token", config.Secrets.MotherDuckToken) } @@ -76,15 +89,16 @@ func (d *DuckDBDriver) Connect(ctx context.Context, settings backend.DataSourceI connector, err := duckdb.NewConnector(config.Path, func(execer driver.ExecerContext) error { - bootQueries := []string{ - "INSTALL 'motherduck'", - "LOAD 'motherduck'", + bootQueries := []string{} + + if strings.HasPrefix(config.Path, "md:") { + bootQueries = append(bootQueries, "INSTALL 'motherduck';", "LOAD 'motherduck';") } // read env variable GF_PATHS_HOME homePath := os.Getenv("GF_PATHS_HOME") if homePath != "" { - bootQueries = append(bootQueries, "SET home_directory='"+homePath+"'") + bootQueries = append(bootQueries, "SET home_directory='"+homePath+"';") } for _, query := range bootQueries { @@ -347,105 +361,5 @@ func GetConverterList() []sqlutil.Converter { }, }, } - //{ - // Name: "handle FLOAT4", - // InputScanType: reflect.TypeOf(sql.NullInt16{}), - // InputTypeName: "FLOAT4", - // FrameConverter: sqlutil.FrameConverter{ - // FieldType: data.FieldTypeNullableInt8, - // ConverterFunc: func(in interface{}) (interface{}, error) { return in, nil }, - // }, - // ConversionFunc: - // Replacer: &sqlutil.StringFieldReplacer{ - // OutputFieldType: data.FieldTypeNullableFloat64, - // ReplaceFunc: func(in *string) (any, error) { - // if in == nil { - // return nil, nil - // } - // v, err := strconv.ParseFloat(*in, 64) - // if err != nil { - // return nil, err - // } - // return &v, nil - // }, - // }, - //}, - //{ - // Name: "handle FLOAT8", - // InputScanKind: reflect.Interface, - // InputTypeName: "FLOAT8", - // ConversionFunc: func(in *string) (*string, error) { return in, nil }, - // Replacer: &sqlutil.StringFieldReplacer{ - // OutputFieldType: data.FieldTypeNullableFloat64, - // ReplaceFunc: func(in *string) (any, error) { - // if in == nil { - // return nil, nil - // } - // v, err := strconv.ParseFloat(*in, 64) - // if err != nil { - // return nil, err - // } - // return &v, nil - // }, - // }, - //}, - //{ - // Name: "handle NUMERIC", - // InputScanKind: reflect.Interface, - // InputTypeName: "NUMERIC", - // ConversionFunc: func(in *string) (*string, error) { return in, nil }, - // Replacer: &sqlutil.StringFieldReplacer{ - // OutputFieldType: data.FieldTypeNullableFloat64, - // ReplaceFunc: func(in *string) (any, error) { - // if in == nil { - // return nil, nil - // } - // v, err := strconv.ParseFloat(*in, 64) - // if err != nil { - // return nil, err - // } - // return &v, nil - // }, - // }, - //}, - //{ - // Name: "handle DECIMAL", - // InputScanKind: reflect.Interface, - // InputTypeName: "DECIMAL(15,2)", - // ConversionFunc: func(in *string) (*string, error) { return in, nil }, - // Replacer: &sqlutil.StringFieldReplacer{ - // OutputFieldType: data.FieldTypeNullableFloat64, - // ReplaceFunc: func(in *string) (any, error) { - // if in == nil { - // return nil, nil - // } - // v, err := strconv.ParseFloat(*in, 64) - // if err != nil { - // return nil, err - // } - // return &v, nil - // }, - // }, - //}, - //{ - // Name: "handle INT2", - // InputScanKind: reflect.Interface, - // InputTypeName: "INT2", - // ConversionFunc: func(in *string) (*string, error) { return in, nil }, - // Replacer: &sqlutil.StringFieldReplacer{ - // OutputFieldType: data.FieldTypeNullableInt16, - // ReplaceFunc: func(in *string) (any, error) { - // if in == nil { - // return nil, nil - // } - // i64, err := strconv.ParseInt(*in, 10, 16) - // if err != nil { - // return nil, err - // } - // v := int16(i64) - // return &v, nil - // }, - // }, - //}, return append(converters, strConverters...) } diff --git a/provisioning/datasources/datasources.yml b/provisioning/datasources/datasources.yml index 042b38e..057a587 100644 --- a/provisioning/datasources/datasources.yml +++ b/provisioning/datasources/datasources.yml @@ -1,14 +1,12 @@ apiVersion: 1 datasources: - - name: 'duckdb-datasource' - type: 'motherduck-duckdb-datasource' + - name: 'inmemory-duckdb-datasource' + type: 'grafana-duckdb-datasource' access: proxy isDefault: false orgId: 1 version: 1 editable: true jsonData: - path: '/resources' - secureJsonData: - motherduckToken: 'motherduck-token' + path: '' diff --git a/src/datasource.ts b/src/datasource.ts index a4b6333..c284842 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -149,11 +149,13 @@ export class DuckDBDataSource extends SqlDatasource { async fetchTables(): Promise { const tables = await this.runSql<{ table: string[] }>(showTablesQuery(), { refId: 'tables' }); + console.log("fetched tables", tables); return tables.fields.table?.values.flat() ?? []; } async fetchFields(query: SQLQuery, order?: boolean): Promise { const { table } = query; + console.log("fetching fields for table", table); if (table === undefined) { // if no table-name, we are not able to query for fields return []; diff --git a/src/queryDefaults.ts b/src/queryDefaults.ts index aab3703..0de14d6 100644 --- a/src/queryDefaults.ts +++ b/src/queryDefaults.ts @@ -14,7 +14,7 @@ export function applyQueryDefaults(q?: SQLQuery): SQLQuery { ...q, refId: q?.refId || 'A', format: q?.format !== undefined ? q.format : QueryFormat.Table, - rawSql: q?.rawSql || '', + rawSql: q?.rawSql || 'select 42', editorMode, dataset: "default", sql: q?.sql || { diff --git a/tests/configEditor.spec.ts b/tests/configEditor.spec.ts index 7bf5fea..f742c84 100644 --- a/tests/configEditor.spec.ts +++ b/tests/configEditor.spec.ts @@ -1,26 +1,24 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { DuckDBDataSourceOptions, MySecureJsonData } from '../src/types'; test('"Save & test" should be successful when configuration is valid', async ({ createDataSourceConfigPage, - readProvisionedDataSource, page, }) => { - const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' }); - const configPage = await createDataSourceConfigPage({ type: ds.type }); - await page.getByRole('textbox', { name: 'Path' }).fill(ds.jsonData.path ?? ''); - await page.getByRole('textbox', { name: 'API Key' }).fill(ds.secureJsonData?.apiKey ?? ''); + // const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' }); + const configPage = await createDataSourceConfigPage({ type: "grafana-duckdb-datasource" }); + await page.getByRole('textbox', { name: 'DB Path' }).fill(""); + await page.getByRole('textbox', { name: 'MotherDuck Token' }).fill(""); await expect(configPage.saveAndTest()).toBeOK(); }); test('"Save & test" should fail when configuration is invalid', async ({ createDataSourceConfigPage, - readProvisionedDataSource, page, }) => { - const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' }); - const configPage = await createDataSourceConfigPage({ type: ds.type }); - await page.getByRole('textbox', { name: 'Path' }).fill(ds.jsonData.path ?? ''); + // const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' }); + const configPage = await createDataSourceConfigPage({ type: "grafana-duckdb-datasource" }); + await page.getByRole('textbox', { name: 'DB Path' }).fill("md:"); + await page.getByRole('textbox', { name: 'MotherDuck Token' }).fill(""); await expect(configPage.saveAndTest()).not.toBeOK(); - await expect(configPage).toHaveAlert('error', { hasText: 'API key is missing' }); + await expect(configPage).toHaveAlert('error', { hasText: 'MotherDuck Token is missing for motherduck connection' }); }); diff --git a/tests/queryEditor.spec.ts b/tests/queryEditor.spec.ts index 3cfd90a..013ed44 100644 --- a/tests/queryEditor.spec.ts +++ b/tests/queryEditor.spec.ts @@ -1,22 +1,18 @@ import { test, expect } from '@grafana/plugin-e2e'; -test('should trigger new query when Constant field is changed', async ({ - panelEditPage, - readProvisionedDataSource, -}) => { - const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' }); - await panelEditPage.datasource.set(ds.name); - await panelEditPage.getQueryEditorRow('A').getByRole('textbox', { name: 'Query Text' }).fill('test query'); - const queryReq = panelEditPage.waitForQueryDataRequest(); - await panelEditPage.getQueryEditorRow('A').getByRole('spinbutton').fill('10'); - await expect(await queryReq).toBeTruthy(); -}); test('data query should return values 10 and 20', async ({ panelEditPage, readProvisionedDataSource }) => { const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' }); await panelEditPage.datasource.set(ds.name); - await panelEditPage.getQueryEditorRow('A').getByRole('textbox', { name: 'Query Text' }).fill('test query'); + await panelEditPage.getQueryEditorRow('A').getByRole("radiogroup").getByLabel("Code").click(); + await panelEditPage.getQueryEditorRow('A').getByLabel("Editor content;Press Alt+F1 for Accessibility Options.").fill('select 10 as val union select 20 as val'); await panelEditPage.setVisualization('Table'); - await expect(panelEditPage.refreshPanel()).toBeOK(); - await expect(panelEditPage.panel.data).toContainText(['10', '20']); + await panelEditPage.getQueryEditorRow('A').getByLabel("Query editor Run button").click(); + await expect(panelEditPage.panel.data).toContainText(['10']); + await expect(panelEditPage.panel.data).toContainText(['20']); }); + +test('updating duckdb file should update the data', async ({ panelEditPage, readProvisionedDataSource }) => { + + +}); \ No newline at end of file From 858e4af0e32f15c805fe36a46d288730be183afc Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 17:12:53 -0500 Subject: [PATCH 23/25] lint --- tests/queryEditor.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/queryEditor.spec.ts b/tests/queryEditor.spec.ts index 013ed44..78d23eb 100644 --- a/tests/queryEditor.spec.ts +++ b/tests/queryEditor.spec.ts @@ -15,4 +15,4 @@ test('data query should return values 10 and 20', async ({ panelEditPage, readPr test('updating duckdb file should update the data', async ({ panelEditPage, readProvisionedDataSource }) => { -}); \ No newline at end of file +}); From df43933dc3dc02371ca9ebd11ec0741814321dc1 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 18:44:50 -0500 Subject: [PATCH 24/25] update README, and version compatibility --- README.md | 197 ++++++++++++++++++++++++++++++++---------------- src/plugin.json | 2 +- 2 files changed, 134 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 57cc01e..3fb0be4 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,143 @@ -# Grafana data source plugin template +# Grafana DuckDB Data Source Plugin -This template is a starting point for building a Data Source Plugin for Grafana. +The DuckDB data source plugin lets you query and visualize DuckDB data in Grafana. DuckDB is an in-process SQL OLAP database management system that provides fast analytics on local files. DuckDB's SQL dialect is derived from PostgreSQL so this plugin works similarly to the Grafana Postgres plugin and works for most SQL queries that would work in Postgres. -## What are Grafana data source plugins? +The plugin is maintained by [MotherDuck](https://motherduck.com), a data platform that provides a cloud-based serverless DuckDB as a service, with additional features like data sharing, read scaling and more. -Grafana supports a wide range of data sources, including Prometheus, MySQL, and even Datadog. There’s a good chance you can already visualize metrics from the systems you have set up. In some cases, though, you already have an in-house metrics solution that you’d like to add to your Grafana dashboards. Grafana Data Source Plugins enables integrating such solutions with Grafana. +## Version Compatibility +Requires Grafana Version 10.4.0 or later. -## Getting started +## Screenshots -### Backend +![Query Editor](./ui-example.png) -1. Update [Grafana plugin SDK for Go](https://grafana.com/developers/plugin-tools/introduction/grafana-plugin-sdk-for-go) dependency to the latest minor version: - ```bash - go get -u github.com/grafana/grafana-plugin-sdk-go - go mod tidy - ``` +## Features -2. Build backend plugin binaries for Linux, Windows and Darwin: +- Query editor with syntax highlighting and auto-completion. +- Import data from various file formats (CSV, Parquet, JSON) through DuckDB extensions. +- Automatically reload the DuckDB file when the file has changed, allowing for data updates via hot-swapping the file. +- Connect to and query data in MotherDuck. - ```bash - mage -v - ``` +## Installation -3. List all available Mage targets for additional commands: +Download the plugin for your (OS, architecture) from the [releases page](https://github.com/motherduckdb/grafana-duckdb-datasource/releases). - ```bash - mage -l - ``` +Since the plugin is currently unsigned, modify the grafana.ini file to allow unsigned plugins: + +```ini +... +allow_loading_unsigned_plugins = grafana-duckdb-datasource +... + +``` + +Then, move the plugin zip to the Grafana plugins directory and unzip it: + + +```bash +mv grafana-duckdb-datasource-.zip +unzip grafana-duckdb-datasource-.zip -d YOUR_PLUGIN_DIR/grafana-duckdb-datasource +``` + +Finally, restart the Grafana server. + + +## Configuration + +### Data Source Options + +| Name | Description | Required | +|-------------------|-------------------------------------------------------|----------| +| Path | Path to DuckDB database file, if empty, connects to duckDB in in-memory mode. | Yes | +| MotherDuck Token | Token for MotherDuck API access | No | + +### Query Editor Options + +The query editor supports standard SQL syntax and includes special Grafana macros for time range filtering and variable interpolation. + +### Macros + +| Macro | Description | Example | +|---------------------|----------------------------------------------------|---------| +| $__timeFilter | Adds a time range filter using the dashboard's time range | `WHERE $__timeFilter(time_column)` | +| $__timeFrom | Start of the dashboard time range | `WHERE time_column > $__timeFrom` | +| $__timeTo | End of the dashboard time range | `WHERE time_column < $__timeTo` | +| $__interval | Dashboard time range interval | `GROUP BY time_bucket($__interval, time_column)` | +| $__unixEpochFilter | Time range filter for Unix timestamps | `WHERE $__unixEpochFilter(timestamp_column)` | + + +## Query Examples + +### Time Series Data + +```sql +SELECT + time_bucket($__interval, timestamp) AS time, + avg(value) as average, + max(value) as maximum +FROM metrics +WHERE $__timeFilter(timestamp) +GROUP BY 1 +ORDER BY 1 +``` + +### Table Query + +```sql +SELECT + name, + value, + timestamp +FROM metrics +WHERE $__timeFilter(timestamp) +LIMIT 100 +``` + +## File Import Support + +Through a rich eacosystem of extensions, DuckDB supports reading data from various file formats: -### Frontend +```sql +-- CSV import +SELECT * FROM read_csv_auto('path/to/file.csv'); + +-- Parquet import +SELECT * FROM read_parquet('path/to/file.parquet'); + +-- JSON import +SELECT * FROM read_json_auto('path/to/file.json'); +``` + +## Known Issues + +### Updating data in the DuckDB file +DuckDB's [concurrency support](https://duckdb.org/docs/connect/concurrency.html#handling-concurrency) does not allow multiple processes to attach the same DuckDB database file at the same time, if at least one of them requires read-write access. This means another process cannot connect to the same DuckDB database file to write to it while Grafana has it as a data source, so real time updates t. There are a few ways to work around this: + - Copy the DuckDB file for updates, then copy the updated DuckDB file to overwrite the original file. The plugin will automatically reload the file when it detects a change. + - Write to other file formats, , and read using DuckDB extensions. Note that this may not directly querying the DuckDB file. + - Host the database using MotherDuck, which allows writing to the database while querying it from Grafana at the same time. + +## Local Development + +### Prerequisites + +- Node.js (v14+) +- Go (v1.21+), Mage, gcc (building the backend requires CGO) + +### Building Locally + +#### Backend + +To build the backend plugin binary for your platform, run: + +```bash +mage -v build: build:GenerateManifestFile +``` +possible values for `` are: `Linux`, `Windows`, `Darwin`, `DarwinARM64`, `LinuxARM64`, `LinuxARM`. + +Note: There's no clear way to cross-compile the plugin since it involves cross-compiling DuckDB via CGO. + +#### Frontend 1. Install dependencies @@ -85,49 +193,10 @@ Grafana supports a wide range of data sources, including Prometheus, MySQL, and npm run lint:fix ``` -# Distributing your plugin - -When distributing a Grafana plugin either within the community or privately the plugin must be signed so the Grafana application can verify its authenticity. This can be done with the `@grafana/sign-plugin` package. - -_Note: It's not necessary to sign a plugin during development. The docker development environment that is scaffolded with `@grafana/create-plugin` caters for running the plugin without a signature._ - -## Initial steps - -Before signing a plugin please read the Grafana [plugin publishing and signing criteria](https://grafana.com/legal/plugins/#plugin-publishing-and-signing-criteria) documentation carefully. - -`@grafana/create-plugin` has added the necessary commands and workflows to make signing and distributing a plugin via the grafana plugins catalog as straightforward as possible. - -Before signing a plugin for the first time please consult the Grafana [plugin signature levels](https://grafana.com/legal/plugins/#what-are-the-different-classifications-of-plugins) documentation to understand the differences between the types of signature level. - -1. Create a [Grafana Cloud account](https://grafana.com/signup). -2. Make sure that the first part of the plugin ID matches the slug of your Grafana Cloud account. - - _You can find the plugin ID in the `plugin.json` file inside your plugin directory. For example, if your account slug is `acmecorp`, you need to prefix the plugin ID with `acmecorp-`._ -3. Create a Grafana Cloud API key with the `PluginPublisher` role. -4. Keep a record of this API key as it will be required for signing a plugin - -## Signing a plugin - -### Using Github actions release workflow - -If the plugin is using the github actions supplied with `@grafana/create-plugin` signing a plugin is included out of the box. The [release workflow](./.github/workflows/release.yml) can prepare everything to make submitting your plugin to Grafana as easy as possible. Before being able to sign the plugin however a secret needs adding to the Github repository. - -1. Please navigate to "settings > secrets > actions" within your repo to create secrets. -2. Click "New repository secret" -3. Name the secret "GRAFANA_API_KEY" -4. Paste your Grafana Cloud API key in the Secret field -5. Click "Add secret" - -#### Push a version tag - -To trigger the workflow we need to push a version tag to github. This can be achieved with the following steps: - -1. Run `npm version ` -2. Run `git push origin main --follow-tags` - -## Learn more +## Links -Below you can find source code for existing app plugins and other related documentation. +- [DuckDB Documentation](https://duckdb.org/docs/) +- [Grafana Documentation](https://grafana.com/docs/) +- [MotherDuck Documentation](https://motherduck.com/docs) +- [Plugin Development Guide](https://grafana.com/docs/grafana/latest/developers/plugins/) -- [Basic data source plugin example](https://github.com/grafana/grafana-plugin-examples/tree/master/examples/datasource-basic#readme) -- [`plugin.json` documentation](https://grafana.com/developers/plugin-tools/reference-plugin-json) -- [How to sign a plugin?](https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin) diff --git a/src/plugin.json b/src/plugin.json index 491eb31..f4a72a8 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -22,7 +22,7 @@ "updated": "%TODAY%" }, "dependencies": { - "grafanaDependency": ">=10.3.3", + "grafanaDependency": ">=10.4.0", "plugins": [] } } From 1ad8c22138628fbcfff3450d729ee0663adf3369 Mon Sep 17 00:00:00 2001 From: Louisa Huang Date: Fri, 3 Jan 2025 18:58:21 -0500 Subject: [PATCH 25/25] update --- README.md | 4 ++-- ui-example.png | Bin 0 -> 87610 bytes 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 ui-example.png diff --git a/README.md b/README.md index 3fb0be4..9f822f5 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ SELECT * FROM read_json_auto('path/to/file.json'); ### Updating data in the DuckDB file DuckDB's [concurrency support](https://duckdb.org/docs/connect/concurrency.html#handling-concurrency) does not allow multiple processes to attach the same DuckDB database file at the same time, if at least one of them requires read-write access. This means another process cannot connect to the same DuckDB database file to write to it while Grafana has it as a data source, so real time updates t. There are a few ways to work around this: - Copy the DuckDB file for updates, then copy the updated DuckDB file to overwrite the original file. The plugin will automatically reload the file when it detects a change. - - Write to other file formats, , and read using DuckDB extensions. Note that this may not directly querying the DuckDB file. - - Host the database using MotherDuck, which allows writing to the database while querying it from Grafana at the same time. + - Write to other file formats, and read using DuckDB extensions. Note that this may be much less performant than directly querying the DuckDB file. + - Host the database using MotherDuck, which allows writing to the database while querying it from Grafana and other clients at the same time. ## Local Development diff --git a/ui-example.png b/ui-example.png new file mode 100644 index 0000000000000000000000000000000000000000..0d8dde4fd9e1a3ac1a0251489dd4250755405c0a GIT binary patch literal 87610 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 literal 0 HcmV?d00001