Skip to content

Commit

Permalink
Variable query support (#177)
Browse files Browse the repository at this point in the history
* Frontend (#168)
* #168 Sample data
* #168 label mapping
* #168 Backend
* Fixed tests
---------

Co-authored-by: Vyacheslav Mitrofanov <[email protected]>
  • Loading branch information
HadesArchitect and unflag authored Nov 6, 2023
1 parent 2df4bd7 commit 2bc51e8
Show file tree
Hide file tree
Showing 15 changed files with 397 additions and 91 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ vendor
coverage
.idea
.vscode

# OS generated files
.DS_Store
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ To see the datasource in action, please follow the [Quick Demo](https://github.c
* Query configurator
* Raw CQL query editor
* Table mode
* Variables
* Annotations
* Alerting

**Contacts**:

* [![Discord Chat](https://img.shields.io/badge/discord-chat%20with%20us-green)](https://discord.gg/FU2Cb4KTyp)
* [![Github discussions](https://img.shields.io/badge/github-discussions-green)](https://github.com/HadesArchitect/GrafanaCassandraDatasource/discussions)

**TOC**
Expand All @@ -45,7 +45,7 @@ To see the datasource in action, please follow the [Quick Demo](https://github.c

You can find more detailed instructions in [the datasource wiki](https://github.com/HadesArchitect/GrafanaCassandraDatasource/wiki).

### Installation
### Installation

1. Install the plugin using grafana console tool: `grafana-cli plugins install hadesarchitect-cassandra-datasource`. The plugin will be installed into your grafana plugins directory; the default is `/var/lib/grafana/plugins`. Alternatively, download the plugin using [latest release](https://github.com/HadesArchitect/GrafanaCassandraDatasource/releases/latest), please download `cassandra-datasource-VERSION.zip` and uncompress a file into the Grafana plugins directory (`grafana/plugins`).
2. Add the Apache Cassandra Data Source as a data source at the datasource configuration page.
Expand Down Expand Up @@ -162,6 +162,9 @@ PER PARTITION LIMIT 1
```
Note that `PER PARTITION LIMIT 1` used instead of `LIMIT 1` to query one row for each partition and not just one row total.

### Variables
[Grafana Variables documentation](https://grafana.com/docs/grafana/latest/dashboards/variables/)

### Annotations
[Grafana Annotations documentation](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/)

Expand Down
33 changes: 29 additions & 4 deletions backend/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type ds interface {
GetKeyspaces(ctx context.Context) ([]string, error)
GetTables(keyspace string) ([]string, error)
GetColumns(keyspace, table, needType string) ([]string, error)
GetVariables(ctx context.Context, query string) ([]plugin.Variable, error)
CheckHealth(ctx context.Context) error
Dispose()
}
Expand All @@ -37,6 +38,7 @@ func New(fn datasource.InstanceFactoryFunc) datasource.ServeOpts {
mux.HandleFunc("/keyspaces", h.getKeyspaces)
mux.HandleFunc("/tables", h.getTables)
mux.HandleFunc("/columns", h.getColumns)
mux.HandleFunc("/variables", h.getVariables)

// QueryDataHandler
queryTypeMux := datasource.NewQueryTypeMux()
Expand Down Expand Up @@ -153,6 +155,30 @@ func (h *handler) getColumns(rw http.ResponseWriter, req *http.Request) {
writeHTTPResult(rw, columns)
}

// getVariables is a handle to fetch variable values.
func (h *handler) getVariables(rw http.ResponseWriter, req *http.Request) {
backend.Logger.Debug("Process 'variables' request")

pluginCtx := httpadapter.PluginConfigFromContext(req.Context())
p, err := h.getPluginInstance(req.Context(), pluginCtx)
if err != nil {
backend.Logger.Error("Failed to get plugin instance", "Message", err)
rw.WriteHeader(http.StatusInternalServerError)
return
}

query := req.URL.Query().Get("query")

variables, err := p.GetVariables(req.Context(), query)
if err != nil {
backend.Logger.Error("Failed to get variables", "Message", err)
rw.WriteHeader(http.StatusInternalServerError)
return
}

writeHTTPResult(rw, variables)
}

// getPluginInstance fetches plugin instance from instance manager, then
// returns it if it has been successfully asserted that it is a plugin type.
func (h *handler) getPluginInstance(ctx context.Context, pluginCtx backend.PluginContext) (ds, error) {
Expand Down Expand Up @@ -194,10 +220,9 @@ func (h *handler) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
}, nil
}

// writeHTTPResult is a simple helper to serialize the
// list of strings and put it in a http response.
func writeHTTPResult(rw http.ResponseWriter, list []string) {
jsonBytes, err := json.MarshalIndent(list, "", " ")
// writeHTTPResult is a simple helper to serialize data and put it in a http response.
func writeHTTPResult(rw http.ResponseWriter, val any) {
jsonBytes, err := json.MarshalIndent(val, "", " ")
if err != nil {
backend.Logger.Error("Failed to marshal list", "Message", err)
rw.WriteHeader(http.StatusInternalServerError)
Expand Down
30 changes: 29 additions & 1 deletion backend/handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type pluginMock struct {
onGetKeyspaces func(ctx context.Context) ([]string, error)
onGetTables func(keyspace string) ([]string, error)
onGetColumns func(keyspace, table, needType string) ([]string, error)
onGetVariables func(ctx context.Context, query string) ([]plugin.Variable, error)
onCheckHealth func(ctx context.Context) error
onDispose func()
}
Expand All @@ -52,6 +53,10 @@ func (p *pluginMock) GetColumns(keyspace, table, needType string) ([]string, err
return p.onGetColumns(keyspace, table, needType)
}

func (p *pluginMock) GetVariables(ctx context.Context, query string) ([]plugin.Variable, error) {
return p.onGetVariables(ctx, query)
}

func (p *pluginMock) CheckHealth(ctx context.Context) error {
return p.onCheckHealth(ctx)
}
Expand Down Expand Up @@ -228,7 +233,7 @@ func Test_CheckHealth(t *testing.T) {
func Test_writeHTTPResult(t *testing.T) {
testCases := []struct {
name string
input []string
input any
status int
want string
}{
Expand Down Expand Up @@ -260,6 +265,29 @@ func Test_writeHTTPResult(t *testing.T) {
"ONE",
"TWO",
"THREE"
]`,
},
{
name: "variables",
input: []plugin.Variable{
{Value: "value1", Label: "text1"},
{Value: "value2", Label: "text2"},
{Value: "value3", Label: "text3"},
},
status: http.StatusOK,
want: `[
{
"value": "value1",
"text": "text1"
},
{
"value": "value2",
"text": "text2"
},
{
"value": "value3",
"text": "text3"
}
]`,
},
}
Expand Down
6 changes: 3 additions & 3 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func newDataSource(settings backend.DataSourceInstanceSettings) (instancemgmt.In
err := json.Unmarshal(settings.JSONData, &dss)
if err != nil {
backend.Logger.Error("Failed to parse connection parameter", "Message", err)
return nil, fmt.Errorf("failed to parse connection parameters: %w", err)
return nil, fmt.Errorf("Failed to parse connection parameters: %w", err)
}

var tlsConfig *tls.Config
Expand All @@ -29,7 +29,7 @@ func newDataSource(settings backend.DataSourceInstanceSettings) (instancemgmt.In
tlsConfig, err = prepareTLSCfg(dss.CertPath, dss.RootPath, dss.CaPath, dss.AllowInsecureTLS)
if err != nil {
backend.Logger.Error("Failed to create TLS config", "Message", err)
return nil, fmt.Errorf("failed to create TLS config: %w", err)
return nil, fmt.Errorf("Failed to create TLS config: %w", err)
}
}

Expand All @@ -46,7 +46,7 @@ func newDataSource(settings backend.DataSourceInstanceSettings) (instancemgmt.In
session, err := cassandra.New(sessionSettings)
if err != nil {
backend.Logger.Error("Failed to create Cassandra connection", "Message", err)
return nil, fmt.Errorf("failed to create Cassandra connection, check Grafana logs for more details")
return nil, fmt.Errorf("Failed to create Cassandra connection, check Grafana logs for more details")
}

return plugin.New(session), nil
Expand Down
45 changes: 42 additions & 3 deletions backend/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,34 @@ func (p *Plugin) GetTables(keyspace string) ([]string, error) {
return tables, nil
}

// GetColumns fetches and returns Cassandra's list of columns of given type for provided keyspace and table.
// GetColumns fetches and returns Cassandra's list of columns
// of given type for provided keyspace and table.
func (p *Plugin) GetColumns(keyspace, table, needType string) ([]string, error) {
tables, err := p.repo.GetColumns(keyspace, table, needType)
columns, err := p.repo.GetColumns(keyspace, table, needType)
if err != nil {
return nil, fmt.Errorf("repo.GetColumns: %w", err)
}

return tables, nil
return columns, nil
}

// GetVariables fetches and returns data to create variables.
func (p *Plugin) GetVariables(ctx context.Context, query string) ([]Variable, error) {
backend.Logger.Debug("GetVariables", "query", query)

idRows, err := p.repo.Select(ctx, query)
if err != nil {
return nil, fmt.Errorf("repo.Select: %w", err)
}

vars := make([]Variable, 0, len(idRows))
for _, rows := range idRows {
for _, row := range rows {
vars = append(vars, makeVariableFromRow(row))
}
}

return vars, nil
}

// CheckHealth executes repository Ping method to check database health.
Expand Down Expand Up @@ -272,3 +292,22 @@ func removeNonTSFields(frame *data.Frame) *data.Frame {

return frame
}

// Variable is a type to transfer variable data from backend to frontend,
// where it will be put into MetricFindValue type.
// https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/types/datasource.ts#L595
type Variable struct {
Value string `json:"value"`
Label string `json:"text"`
}

func makeVariableFromRow(row cassandra.Row) Variable {
var v Variable
v.Value = fmt.Sprintf("%v", row.Fields[row.Columns[0]])
v.Label = v.Value
if len(row.Columns) > 1 {
v.Label = fmt.Sprintf("%v", row.Fields[row.Columns[1]])
}

return v
}
72 changes: 72 additions & 0 deletions backend/plugin/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,78 @@ func TestPlugin_ExecQuery(t *testing.T) {
}
}

func TestPlugin_GetVariables(t *testing.T) {
testCases := []struct {
name string
repo *repositoryMock
want []Variable
}{
{
name: "response with labels",
repo: &repositoryMock{
onSelect: func(ctx context.Context, query string, values ...interface{}) (rows map[string][]cassandra.Row, err error) {
return map[string][]cassandra.Row{
"1": {
{
Columns: []string{"Value", "Label"},
Fields: map[string]interface{}{"Value": "1", "Label": "Text1"},
},
},
"2": {
{
Columns: []string{"Value", "Label"},
Fields: map[string]interface{}{"Value": "2", "Label": "Text2"},
},
},
}, nil
},
},
want: []Variable{
{Value: "1", Label: "Text1"},
{Value: "2", Label: "Text2"},
},
},
{
name: "response without labels",
repo: &repositoryMock{
onSelect: func(ctx context.Context, query string, values ...interface{}) (rows map[string][]cassandra.Row, err error) {
return map[string][]cassandra.Row{
"1": {
{
Columns: []string{"Value"},
Fields: map[string]interface{}{"Value": "1"},
},
},
"2": {
{
Columns: []string{"Value"},
Fields: map[string]interface{}{"Value": "2"},
},
},
}, nil
},
},
want: []Variable{
{Value: "1", Label: "1"},
{Value: "2", Label: "2"},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
p := &Plugin{repo: tc.repo}
vars, err := p.GetVariables(context.TODO(), "SELECT * FROM keyspace.table")
assert.NoError(t, err)

sort.Slice(vars, func(i, j int) bool {
return vars[i].Value < vars[j].Value
})
assert.Equal(t, tc.want, vars)
})
}
}

func Test_makeDataFrameFromRows(t *testing.T) {
testCases := []struct {
name string
Expand Down
Loading

0 comments on commit 2bc51e8

Please sign in to comment.