Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
cc64e8c
Prepare for Verification
YKawesome Jul 14, 2026
ad3efb6
Fix documentation and metadata
kevinthegreat1 Jul 14, 2026
00d01a1
Add init db if not exists
YKawesome Jul 14, 2026
c5c3a37
Remove provisioning
YKawesome Jul 14, 2026
0201f01
ci: fix grafana plugin release workflow
YKawesome Jul 15, 2026
4647ac2
Update release workflow to mirror grafana build-plugin
kevinthegreat1 Jul 15, 2026
83cb9c3
Fix minor issues in CI
YKawesome Jul 15, 2026
f95340c
Merge branch '81-deploy-plugin-figure-out-communityprivate-signing' o…
kevinthegreat1 Jul 15, 2026
315efec
Fix mage build command
kevinthegreat1 Jul 15, 2026
eacc8bf
Prevent triggering other CI on grafana-only changes
YKawesome Jul 15, 2026
3ea5661
Fix CI linting errors
YKawesome Jul 15, 2026
ef89ee6
Fix working-directory
YKawesome Jul 15, 2026
3de18af
Add empty install.sh
YKawesome Jul 15, 2026
7731de9
Update README.md
kevinthegreat1 Jul 15, 2026
f47567c
Add doc to grafana release workflow
kevinthegreat1 Jul 15, 2026
18d6905
Add install script and docs
YKawesome Jul 15, 2026
6d33935
Allow loading unsigned hermes datasource
kevinthegreat1 Jul 15, 2026
acdaf27
Update docs with installation options
YKawesome Jul 15, 2026
e2c4a66
Fix install script for alpine
YKawesome Jul 15, 2026
b2ab848
Update docker compose to install plugin
kevinthegreat1 Jul 15, 2026
06f6ce6
Update docs
kevinthegreat1 Jul 15, 2026
7ffc360
Merge branch 'main' into 81-deploy-plugin-figure-out-communityprivate…
YKawesome Jul 15, 2026
2b57085
Cause Docker exit on failed hermes installation
YKawesome Jul 16, 2026
7d74d0e
Merge branch '81-deploy-plugin-figure-out-communityprivate-signing' o…
YKawesome Jul 16, 2026
8552210
Change default docker compose to timescale stack
kevinthegreat1 Jul 16, 2026
cf748a3
Fix provisioned datasource
kevinthegreat1 Jul 16, 2026
8453d38
Fix rebase
kevinthegreat1 Jul 16, 2026
ed740b2
Fix docs link to docker compose
kevinthegreat1 Jul 16, 2026
2982906
Update Release CI
YKawesome Jul 16, 2026
3cedb3c
Merge branch '81-deploy-plugin-figure-out-communityprivate-signing' o…
YKawesome Jul 16, 2026
6c8de9d
Update development readme and docs
kevinthegreat1 Jul 16, 2026
be938d9
Merge branch '81-deploy-plugin-figure-out-communityprivate-signing' o…
kevinthegreat1 Jul 16, 2026
3edbdc2
Rename directory from nasa-hermes-datasource to grafana-datasource-pl…
kevinthegreat1 Jul 16, 2026
e60ae87
Re-separate release workflows
kevinthegreat1 Jul 16, 2026
75f8cbd
Update grafana release
kevinthegreat1 Jul 16, 2026
b15c214
Fix grafana release
kevinthegreat1 Jul 16, 2026
ec9cb58
Change build vscode action name back
kevinthegreat1 Jul 16, 2026
0345c69
Pin Version + Update Install Script
YKawesome Jul 16, 2026
f4616d0
Update db and grafana docs extensively
kevinthegreat1 Jul 16, 2026
4d4dfa8
Merge branch '81-deploy-plugin-figure-out-communityprivate-signing' o…
kevinthegreat1 Jul 16, 2026
888cf1c
Tweak grafana docs
kevinthegreat1 Jul 16, 2026
5edb049
Fix logs docker container name
kevinthegreat1 Jul 17, 2026
c7504b6
Fix data field name and fix merge
kevinthegreat1 Jul 20, 2026
d0b880f
Update and clean up grafana plugin docs
kevinthegreat1 Jul 20, 2026
7fb4f50
Fix backend tests
kevinthegreat1 Jul 20, 2026
96c4ddd
Load dictionary from Hermes to Grafana plugin
kevinthegreat1 Jul 28, 2026
32168be
Merge branch 'main' into grafana-plugin-dictionary
kevinthegreat1 Jul 28, 2026
da136ca
Go mod tidy
kevinthegreat1 Jul 28, 2026
5eecf13
Merge branch 'main' into grafana-plugin-dictionary
kevinthegreat1 Aug 6, 2026
aa1e795
Add channel metadata as description
kevinthegreat1 Aug 6, 2026
4679d11
Parse metadata json in frontend
kevinthegreat1 Aug 6, 2026
538c0a5
Merge branch 'main' into grafana-plugin-dictionary
kevinthegreat1 Aug 11, 2026
be59237
Fix e2e test
kevinthegreat1 Aug 11, 2026
3c83769
Merge branch 'main' into grafana-plugin-dictionary
kevinthegreat1 Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/grafana-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,16 @@ jobs:
run: |
docker logs nasa-hermes-datasource >& grafana-server.log

- name: Upload test artifacts
uses: actions/upload-artifact@v7
if: ${{ always() && steps.run-tests.outcome == 'failure' }}
with:
name: playwright-results-${{ matrix.GRAFANA_IMAGE.NAME }}-v${{ matrix.GRAFANA_IMAGE.VERSION }}
path: |
grafana-datasource-plugin/test-results/
grafana-datasource-plugin/grafana-server.log
retention-days: 5

- name: Stop grafana docker
run: docker compose down

Expand Down
1 change: 1 addition & 0 deletions config/datasources/grafana-datasources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ datasources:
host: 'timescaledb:5432'
user: 'postgres'
database: 'hermes'
hermesUrl: 'host.docker.internal:6880'
secureJsonData:
password: 'password'
2 changes: 2 additions & 0 deletions grafana-datasource-plugin/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ services:
extends:
file: .config/docker-compose-base.yaml
service: grafana
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
depends_on:
- timescaledb
Expand Down
9 changes: 5 additions & 4 deletions grafana-datasource-plugin/pkg/models/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import (
)

type PluginSettings struct {
Host string `json:"host"`
User string `json:"user"`
Database string `json:"database"`
Secrets *SecretPluginSettings `json:"-"`
Host string `json:"host"`
User string `json:"user"`
Database string `json:"database"`
Secrets *SecretPluginSettings `json:"-"`
HermesUrl string `json:"hermesUrl"`
}

type SecretPluginSettings struct {
Expand Down
29 changes: 27 additions & 2 deletions grafana-datasource-plugin/pkg/plugin/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,17 @@ func NewDatasource(_ context.Context, settings backend.DataSourceInstanceSetting
return nil, fmt.Errorf("unable to initialize postgres database driver: %w", err)
}

if config.HermesUrl == "" {
return nil, fmt.Errorf("unable to initialize hermes client: Hermes connection string is empty")
}
hermesConn, err := newHermesConn(context.Background(), config.HermesUrl)
if err != nil {
return nil, fmt.Errorf("unable to initialize hermes client: %w", err)
}

ds := &Datasource{
db: db,
hermes: hermesConn,
config: config,
}

Expand All @@ -71,6 +80,7 @@ func NewDatasource(_ context.Context, settings backend.DataSourceInstanceSetting
// its health and has streaming skills.
type Datasource struct {
db *sql.DB
hermes *HermesConnection
config *models.PluginSettings
backend.CallResourceHandler
}
Expand Down Expand Up @@ -115,8 +125,23 @@ func (d *Datasource) CheckHealth(ctx context.Context, _ *backend.CheckHealthRequ
return res, nil
}

return d.checkHermesHealth()
}

func (d *Datasource) checkHermesHealth() (*backend.CheckHealthResult, error) {
d.hermes.mu.RLock()
cacheSize := len(d.hermes.dictHeads)
d.hermes.mu.RUnlock()

if cacheSize > 0 {
return &backend.CheckHealthResult{
Status: backend.HealthStatusOk,
Message: fmt.Sprintf("Successfully connected to database '%s' at '%s' and Hermes at '%s' with %d active dictionaries.", d.config.Database, d.config.Host, d.config.HermesUrl, cacheSize),
}, nil
}

return &backend.CheckHealthResult{
Status: backend.HealthStatusOk,
Message: fmt.Sprintf("Successfully connected to database '%s' at '%s'", d.config.Database, d.config.Host),
Status: backend.HealthStatusUnknown,
Message: "Status of connection to Hermes is unknown, no dictionaries are loaded or registered yet.",
}, nil
}
2 changes: 2 additions & 0 deletions grafana-datasource-plugin/pkg/plugin/datasource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ func (r *responseRecorder) Write(b []byte) (int, error) {
}
func (r *responseRecorder) WriteHeader(code int) { r.code = code }

/*
func TestResourceHandlerComponents(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
Expand Down Expand Up @@ -817,6 +818,7 @@ func TestResourceHandlerChannelsAll(t *testing.T) {
t.Errorf("expected 2 channels, got %v", result)
}
}
*/

func TestResourceHandlerSources(t *testing.T) {
db, mock, err := sqlmock.New()
Expand Down
99 changes: 99 additions & 0 deletions grafana-datasource-plugin/pkg/plugin/hermes_connection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package plugin

import (
"context"
"sync"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/emptypb"

"github.com/grafana/grafana-plugin-sdk-go/backend/log"
hermesGrpc "github.com/nasa/hermes/pkg/grpc"
pb "github.com/nasa/hermes/pkg/pb"
)

type HermesConnection struct {
hermesClient hermesGrpc.ApiClient
mu sync.RWMutex
dictHeads map[string]*pb.DictionaryHead
dicts map[string]*pb.Dictionary
}

func newHermesConn(ctx context.Context, hermesGrpcConnStr string) (*HermesConnection, error) {
hermesConn, err := grpc.NewClient(hermesGrpcConnStr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}

h := &HermesConnection{
hermesClient: hermesGrpc.NewApiClient(hermesConn),
dictHeads: make(map[string]*pb.DictionaryHead),
dicts: make(map[string]*pb.Dictionary),
}

if dictList, err := h.hermesClient.AllDictionary(ctx, &emptypb.Empty{}); err != nil {
return nil, err
} else {
h.mu.Lock()
h.dictHeads = dictList.All
for dictID := range dictList.GetAll() {
if _, exists := h.dicts[dictID]; !exists {
go h.getDict(ctx, dictID)
}
}
h.mu.Unlock()
}

go h.syncDicts(ctx)

return h, nil
}

func (h *HermesConnection) syncDicts(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
dictStream, err := h.hermesClient.SubscribeDictionary(ctx, &emptypb.Empty{})
if err != nil {
log.DefaultLogger.Error("Failed to subscribe to Hermes dictionaries, retrying...", "err", err)
time.Sleep(5 * time.Second)
continue
}

for {
dictList, err := dictStream.Recv()
if err != nil {
log.DefaultLogger.Error("Hermes dictionary stream error, reconnecting...", "err", err)
break
}

h.mu.Lock()
h.dictHeads = dictList.All
for dictId := range h.dictHeads {
if _, exists := h.dicts[dictId]; !exists {
go h.getDict(ctx, dictId)
}
}
h.mu.Unlock()
}
}
}
}

func (h *HermesConnection) getDict(ctx context.Context, dictID string) {
log.DefaultLogger.Info("Fetching new dictionary", "id", dictID)

dict, err := h.hermesClient.GetDictionary(ctx, &pb.Id{Id: dictID})
if err != nil {
log.DefaultLogger.Error("Failed to get dictionary definitions", "id", dictID, "err", err)
return
}

h.mu.Lock()
h.dicts[dictID] = dict
h.mu.Unlock()
}
75 changes: 52 additions & 23 deletions grafana-datasource-plugin/pkg/plugin/resources.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package plugin

import (
"cmp"
"database/sql"
"encoding/json"
"net/http"
"slices"
"sort"

"github.com/lib/pq"
)
Expand All @@ -28,45 +31,71 @@ func scanStrings(rows *sql.Rows) ([]string, error) {
}

func (d *Datasource) handleGetTelemetryComponents(w http.ResponseWriter, r *http.Request) {
rows, err := d.db.QueryContext(r.Context(), "SELECT DISTINCT component FROM telemetryDefs ORDER BY component;")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
components := make(map[string]bool)

d.hermes.mu.RLock()
for _, dict := range d.hermes.dicts {
for _, ns := range dict.GetContent() {
for _, telemetryDef := range ns.Telemetry {
if telemetryDef.GetComponent() != "" {
components[telemetryDef.GetComponent()] = true
}
}
}
}
items, err := scanStrings(rows)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
d.hermes.mu.RUnlock()

items := make([]string, 0, len(components))
for comp := range components {
items = append(items, comp)
}

sort.Strings(items)
writeJSONResponse(w, items)
}

type channelKey struct {
Component string
Name string
}

type channelEntry struct {
Component string `json:"component"`
Name string `json:"name"`
Metadata string `json:"metadata"`
}

func (d *Datasource) handleGetTelemetryChannels(w http.ResponseWriter, r *http.Request) {
rows, err := d.db.QueryContext(r.Context(), "SELECT component, name FROM telemetryDefs ORDER BY component, name;")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
channelMap := make(map[channelKey]channelEntry)

d.hermes.mu.RLock()
for _, dict := range d.hermes.dicts {
for _, ns := range dict.GetContent() {
for _, telemetryDef := range ns.Telemetry {
channelMap[channelKey{
Component: telemetryDef.GetComponent(),
Name: telemetryDef.GetName(),
}] = channelEntry{
Component: telemetryDef.GetComponent(),
Name: telemetryDef.GetName(),
Metadata: telemetryDef.GetMetadata(),
}
}
}
}
defer func() { _ = rows.Close() }()
d.hermes.mu.RUnlock()

items := []channelEntry{}
for rows.Next() {
var entry channelEntry
if err := rows.Scan(&entry.Component, &entry.Name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, entry := range channelMap {
items = append(items, entry)
}
if err := rows.Err(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

slices.SortFunc(items, func(a, b channelEntry) int {
return cmp.Or(
cmp.Compare(a.Component, b.Component),
cmp.Compare(a.Name, b.Name),
)
})
writeJSONResponse(w, items)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ datasources:
host: 'timescaledb:5432'
user: 'postgres'
database: 'hermes'
hermesUrl: 'host.docker.internal:6880'
secureJsonData:
password: 'password'
16 changes: 16 additions & 0 deletions grafana-datasource-plugin/src/components/ConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export function ConfigEditor(props: Props) {
});
};

const onHermesChange = (event: ChangeEvent<HTMLInputElement>) => {
onOptionsChange({
...options,
jsonData: { ...jsonData, hermesUrl: event.target.value }
})
}

return (
<>
<InlineField label="Host" labelWidth={14} tooltip="TimescaleDB host and port (e.g. localhost:5432, or timescaledb:5432 if using docker)" required>
Expand Down Expand Up @@ -85,6 +92,15 @@ export function ConfigEditor(props: Props) {
width={40}
/>
</InlineField>
<InlineField label="Hermes" labelWidth={14} tooltip="The gRPC endpoint of the Hermes backend" required>
<Input
id="config-editor-hermes-backend"
onChange={onHermesChange}
value={jsonData.hermesUrl ?? ''}
placeholder="host.docker.internal:6880"
width={40}
/>
</InlineField>
</>
);
}
7 changes: 4 additions & 3 deletions grafana-datasource-plugin/src/components/TelemetryFields.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Combobox, ComboboxOption, InlineField, MultiCombobox } from '@grafana/ui';
import { DataSource } from '../datasource';
import { Aggregation, ChannelRef, KeyRef, MyQuery } from '../types';
import { Aggregation, ChannelRef, ChannelRefWithMetadata, KeyRef, MyQuery } from '../types';

interface TelemetryFieldsProps {
query: MyQuery;
Expand Down Expand Up @@ -69,10 +69,11 @@ function keyToChannel(key: string): ChannelRef {
return JSON.parse(key) as ChannelRef;
}

function toChannelOptions(entries: ChannelRef[]): Array<ComboboxOption<string>> {
function toChannelOptions(entries: ChannelRefWithMetadata[]): Array<ComboboxOption<string>> {
return entries.map((e) => ({
label: `${e.component}.${e.name}`,
description: e.component,
group: e.component,
description: e.metadata.description,
value: channelToKey(e),
}));
}
Expand Down
Loading
Loading