mirror of
				https://github.com/grafana/grafana.git
				synced 2025-02-25 18:55:37 -06:00 
			
		
		
		
	Live: support streaming results out-of-the-box (#32821)
This commit is contained in:
		@@ -1,60 +0,0 @@
 | 
			
		||||
package live
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Channel is the channel ID split by parts.
 | 
			
		||||
type Channel struct {
 | 
			
		||||
	// Scope is one of available channel scopes:
 | 
			
		||||
	// like ScopeGrafana, ScopePlugin, ScopeDatasource, ScopeStream.
 | 
			
		||||
	Scope string `json:"scope,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// Namespace meaning depends on the scope.
 | 
			
		||||
	// * when ScopeGrafana, namespace is a "feature"
 | 
			
		||||
	// * when ScopePlugin, namespace is the plugin name
 | 
			
		||||
	// * when ScopeDatasource, namespace is the datasource uid
 | 
			
		||||
	// * when ScopeStream, namespace is the stream ID.
 | 
			
		||||
	Namespace string `json:"namespace,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// Within each namespace, the handler can process the path as needed.
 | 
			
		||||
	Path string `json:"path,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ParseChannel parses the parts from a channel ID:
 | 
			
		||||
//   ${scope} / ${namespace} / ${path}.
 | 
			
		||||
func ParseChannel(chID string) Channel {
 | 
			
		||||
	addr := Channel{}
 | 
			
		||||
	parts := strings.SplitN(chID, "/", 3)
 | 
			
		||||
	length := len(parts)
 | 
			
		||||
	if length > 0 {
 | 
			
		||||
		addr.Scope = parts[0]
 | 
			
		||||
	}
 | 
			
		||||
	if length > 1 {
 | 
			
		||||
		addr.Namespace = parts[1]
 | 
			
		||||
	}
 | 
			
		||||
	if length > 2 {
 | 
			
		||||
		addr.Path = parts[2]
 | 
			
		||||
	}
 | 
			
		||||
	return addr
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c Channel) String() string {
 | 
			
		||||
	ch := c.Scope
 | 
			
		||||
	if c.Namespace != "" {
 | 
			
		||||
		ch += "/" + c.Namespace
 | 
			
		||||
	}
 | 
			
		||||
	if c.Path != "" {
 | 
			
		||||
		ch += "/" + c.Path
 | 
			
		||||
	}
 | 
			
		||||
	return ch
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsValid checks if all parts of the address are valid.
 | 
			
		||||
func (c *Channel) IsValid() bool {
 | 
			
		||||
	if c.Scope == ScopePush {
 | 
			
		||||
		// Push scope channels supposed to be like push/{$stream_id}.
 | 
			
		||||
		return c.Namespace != "" && c.Path == ""
 | 
			
		||||
	}
 | 
			
		||||
	return c.Scope != "" && c.Namespace != "" && c.Path != ""
 | 
			
		||||
}
 | 
			
		||||
@@ -1,110 +0,0 @@
 | 
			
		||||
package live
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/google/go-cmp/cmp"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestParseChannel(t *testing.T) {
 | 
			
		||||
	addr := ParseChannel("aaa/bbb/ccc/ddd")
 | 
			
		||||
	require.True(t, addr.IsValid())
 | 
			
		||||
 | 
			
		||||
	ex := Channel{
 | 
			
		||||
		Scope:     "aaa",
 | 
			
		||||
		Namespace: "bbb",
 | 
			
		||||
		Path:      "ccc/ddd",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if diff := cmp.Diff(addr, ex); diff != "" {
 | 
			
		||||
		t.Fatalf("Result mismatch (-want +got):\n%s", diff)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestParseChannel_IsValid(t *testing.T) {
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		name    string
 | 
			
		||||
		id      string
 | 
			
		||||
		isValid bool
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			name:    "valid",
 | 
			
		||||
			id:      "stream/cpu/test",
 | 
			
		||||
			isValid: true,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:    "valid_long_path",
 | 
			
		||||
			id:      "stream/cpu/test/other",
 | 
			
		||||
			isValid: true,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:    "invalid_no_path",
 | 
			
		||||
			id:      "grafana/bbb",
 | 
			
		||||
			isValid: false,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:    "invalid_only_scope",
 | 
			
		||||
			id:      "grafana",
 | 
			
		||||
			isValid: false,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:    "push_scope_no_path_valid",
 | 
			
		||||
			id:      "push/telegraf",
 | 
			
		||||
			isValid: true,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:    "push_scope_with_path_invalid",
 | 
			
		||||
			id:      "push/telegraf/test",
 | 
			
		||||
			isValid: false,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	for _, tt := range tests {
 | 
			
		||||
		t.Run(tt.name, func(t *testing.T) {
 | 
			
		||||
			if got := ParseChannel(tt.id); got.IsValid() != tt.isValid {
 | 
			
		||||
				t.Errorf("unexpected isValid result for %s", tt.id)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestChannel_String(t *testing.T) {
 | 
			
		||||
	type fields struct {
 | 
			
		||||
		Scope     string
 | 
			
		||||
		Namespace string
 | 
			
		||||
		Path      string
 | 
			
		||||
	}
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		name   string
 | 
			
		||||
		fields fields
 | 
			
		||||
		want   string
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			"with_all_parts",
 | 
			
		||||
			fields{Scope: ScopeStream, Namespace: "telegraf", Path: "test"},
 | 
			
		||||
			"stream/telegraf/test",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"with_scope_and_namespace",
 | 
			
		||||
			fields{Scope: ScopeStream, Namespace: "telegraf"},
 | 
			
		||||
			"stream/telegraf",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"with_scope_only",
 | 
			
		||||
			fields{Scope: ScopeStream},
 | 
			
		||||
			"stream",
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	for _, tt := range tests {
 | 
			
		||||
		t.Run(tt.name, func(t *testing.T) {
 | 
			
		||||
			got := Channel{
 | 
			
		||||
				Scope:     tt.fields.Scope,
 | 
			
		||||
				Namespace: tt.fields.Namespace,
 | 
			
		||||
				Path:      tt.fields.Path,
 | 
			
		||||
			}.String()
 | 
			
		||||
			if got != tt.want {
 | 
			
		||||
				t.Errorf("String() = %v, want %v", got, tt.want)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -100,7 +100,7 @@ func (r *PluginPathRunner) OnSubscribe(ctx context.Context, user *models.SignedI
 | 
			
		||||
		Path:          r.path,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		logger.Error("Plugin CanSubscribeToStream call error", "error", err, "path", r.path)
 | 
			
		||||
		logger.Error("Plugin OnSubscribe call error", "error", err, "path", r.path)
 | 
			
		||||
		return models.SubscribeReply{}, 0, err
 | 
			
		||||
	}
 | 
			
		||||
	if resp.Status != backend.SubscribeStreamStatusOK {
 | 
			
		||||
@@ -144,7 +144,7 @@ func (r *PluginPathRunner) OnPublish(ctx context.Context, user *models.SignedInU
 | 
			
		||||
		Data:          e.Data,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		logger.Error("Plugin CanSubscribeToStream call error", "error", err, "path", r.path)
 | 
			
		||||
		logger.Error("Plugin OnPublish call error", "error", err, "path", r.path)
 | 
			
		||||
		return models.PublishReply{}, 0, err
 | 
			
		||||
	}
 | 
			
		||||
	if resp.Status != backend.PublishStreamStatusOK {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,12 +4,14 @@ import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/centrifugal/centrifuge"
 | 
			
		||||
	"github.com/grafana/grafana-plugin-sdk-go/backend"
 | 
			
		||||
	"github.com/grafana/grafana-plugin-sdk-go/data"
 | 
			
		||||
	"github.com/grafana/grafana-plugin-sdk-go/live"
 | 
			
		||||
 | 
			
		||||
	"github.com/grafana/grafana/pkg/api/dtos"
 | 
			
		||||
	"github.com/grafana/grafana/pkg/api/response"
 | 
			
		||||
@@ -306,11 +308,11 @@ func publishStatusToHTTPError(status backend.PublishStreamStatus) (int, string)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetChannelHandler gives thread-safe access to the channel.
 | 
			
		||||
func (g *GrafanaLive) GetChannelHandler(user *models.SignedInUser, channel string) (models.ChannelHandler, Channel, error) {
 | 
			
		||||
func (g *GrafanaLive) GetChannelHandler(user *models.SignedInUser, channel string) (models.ChannelHandler, live.Channel, error) {
 | 
			
		||||
	// Parse the identifier ${scope}/${namespace}/${path}
 | 
			
		||||
	addr := ParseChannel(channel)
 | 
			
		||||
	addr := live.ParseChannel(channel)
 | 
			
		||||
	if !addr.IsValid() {
 | 
			
		||||
		return nil, Channel{}, fmt.Errorf("invalid channel: %q", channel)
 | 
			
		||||
		return nil, live.Channel{}, fmt.Errorf("invalid channel: %q", channel)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	g.channelsMu.RLock()
 | 
			
		||||
@@ -349,15 +351,15 @@ func (g *GrafanaLive) GetChannelHandler(user *models.SignedInUser, channel strin
 | 
			
		||||
// It gives thread-safe access to the channel.
 | 
			
		||||
func (g *GrafanaLive) GetChannelHandlerFactory(user *models.SignedInUser, scope string, namespace string) (models.ChannelHandlerFactory, error) {
 | 
			
		||||
	switch scope {
 | 
			
		||||
	case ScopeGrafana:
 | 
			
		||||
	case live.ScopeGrafana:
 | 
			
		||||
		return g.handleGrafanaScope(user, namespace)
 | 
			
		||||
	case ScopePlugin:
 | 
			
		||||
	case live.ScopePlugin:
 | 
			
		||||
		return g.handlePluginScope(user, namespace)
 | 
			
		||||
	case ScopeDatasource:
 | 
			
		||||
	case live.ScopeDatasource:
 | 
			
		||||
		return g.handleDatasourceScope(user, namespace)
 | 
			
		||||
	case ScopeStream:
 | 
			
		||||
	case live.ScopeStream:
 | 
			
		||||
		return g.handleStreamScope(user, namespace)
 | 
			
		||||
	case ScopePush:
 | 
			
		||||
	case live.ScopePush:
 | 
			
		||||
		return g.handlePushScope(user, namespace)
 | 
			
		||||
	default:
 | 
			
		||||
		return nil, fmt.Errorf("invalid scope: %q", scope)
 | 
			
		||||
@@ -403,7 +405,14 @@ func (g *GrafanaLive) handlePushScope(_ *models.SignedInUser, namespace string)
 | 
			
		||||
func (g *GrafanaLive) handleDatasourceScope(user *models.SignedInUser, namespace string) (models.ChannelHandlerFactory, error) {
 | 
			
		||||
	ds, err := g.DatasourceCache.GetDatasourceByUID(namespace, user, false)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("error getting datasource: %w", err)
 | 
			
		||||
		// the namespace may be an ID
 | 
			
		||||
		id, _ := strconv.ParseInt(namespace, 10, 64)
 | 
			
		||||
		if id > 0 {
 | 
			
		||||
			ds, err = g.DatasourceCache.GetDatasource(id, user, false)
 | 
			
		||||
		}
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("error getting datasource: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	streamHandler, err := g.getStreamPlugin(ds.Type)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -430,7 +439,7 @@ func (g *GrafanaLive) IsEnabled() bool {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *GrafanaLive) HandleHTTPPublish(ctx *models.ReqContext, cmd dtos.LivePublishCmd) response.Response {
 | 
			
		||||
	addr := ParseChannel(cmd.Channel)
 | 
			
		||||
	addr := live.ParseChannel(cmd.Channel)
 | 
			
		||||
	if !addr.IsValid() {
 | 
			
		||||
		return response.Error(http.StatusBadRequest, "Bad channel address", nil)
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ import (
 | 
			
		||||
 | 
			
		||||
	"github.com/grafana/grafana-plugin-sdk-go/backend"
 | 
			
		||||
	"github.com/grafana/grafana-plugin-sdk-go/data"
 | 
			
		||||
	"github.com/grafana/grafana-plugin-sdk-go/live"
 | 
			
		||||
	"github.com/grafana/grafana/pkg/models"
 | 
			
		||||
	"github.com/grafana/grafana/pkg/util"
 | 
			
		||||
)
 | 
			
		||||
@@ -111,7 +112,7 @@ func (s *ManagedStream) Push(path string, frame *data.Frame) error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// The channel this will be posted into.
 | 
			
		||||
	channel := Channel{Scope: ScopeStream, Namespace: s.id, Path: path}.String()
 | 
			
		||||
	channel := live.Channel{Scope: live.ScopeStream, Namespace: s.id, Path: path}.String()
 | 
			
		||||
	logger.Debug("Publish data to channel", "channel", channel, "dataLength", len(frameJSON))
 | 
			
		||||
	return s.publisher(channel, frameJSON)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
package live
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// ScopeGrafana contains builtin features of Grafana Core.
 | 
			
		||||
	ScopeGrafana = "grafana"
 | 
			
		||||
	// ScopePlugin passes control to a plugin.
 | 
			
		||||
	ScopePlugin = "plugin"
 | 
			
		||||
	// ScopeDatasource passes control to a datasource plugin.
 | 
			
		||||
	ScopeDatasource = "ds"
 | 
			
		||||
	// ScopeStream is a managed data frame stream.
 | 
			
		||||
	ScopeStream = "stream"
 | 
			
		||||
	// ScopePush allows sending data into managed streams. It does not support subscriptions.
 | 
			
		||||
	ScopePush = "push"
 | 
			
		||||
)
 | 
			
		||||
		Reference in New Issue
	
	Block a user