mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Testdata: introduce basic simulation framework (#47863)
This commit is contained in:
parent
f4e285b8b4
commit
89fa35a53f
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -59,6 +59,7 @@ go.sum @grafana/backend-platform
|
||||
/pkg/services/searchV2/ @grafana/grafana-edge-squad
|
||||
/pkg/services/store/ @grafana/grafana-edge-squad
|
||||
/pkg/infra/filestore/ @grafana/grafana-edge-squad
|
||||
pkg/tsdb/testdatasource/sims/ @grafana/grafana-edge-squad
|
||||
|
||||
# Alerting
|
||||
/pkg/services/ngalert @grafana/alerting-squad-backend
|
||||
|
@ -1,135 +0,0 @@
|
||||
package testdatasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
)
|
||||
|
||||
func (s *Service) handleFlightPathScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
resp := backend.NewQueryDataResponse()
|
||||
|
||||
for _, q := range req.Queries {
|
||||
_, err := simplejson.NewJson(q.JSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse query json: %v", err)
|
||||
}
|
||||
|
||||
timeWalkerMs := q.TimeRange.From.UnixNano() / int64(time.Millisecond)
|
||||
to := q.TimeRange.To.UnixNano() / int64(time.Millisecond)
|
||||
stepMillis := q.Interval.Milliseconds()
|
||||
|
||||
cfg := newFlightConfig()
|
||||
|
||||
maxPoints := q.MaxDataPoints * 2
|
||||
f := cfg.initFields()
|
||||
for i := int64(0); i < maxPoints && timeWalkerMs < to; i++ {
|
||||
t := time.Unix(timeWalkerMs/int64(1e+3), (timeWalkerMs%int64(1e+3))*int64(1e+6)).UTC()
|
||||
f.append(cfg.getNextPoint(t))
|
||||
|
||||
timeWalkerMs += stepMillis
|
||||
}
|
||||
|
||||
// When close to now, link to the live streaming channel
|
||||
if q.TimeRange.To.Add(time.Second * 2).After(time.Now()) {
|
||||
f.frame.Meta = &data.FrameMeta{
|
||||
Channel: "plugin/testdata/flight-5hz-stream",
|
||||
}
|
||||
}
|
||||
|
||||
respD := resp.Responses[q.RefID]
|
||||
respD.Frames = append(respD.Frames, f.frame)
|
||||
resp.Responses[q.RefID] = respD
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type flightDataPoint struct {
|
||||
time time.Time
|
||||
lat float64 // gps
|
||||
lng float64 // gps
|
||||
heading float64 // degree
|
||||
altitude float64 // above ground
|
||||
}
|
||||
|
||||
type flightConfig struct {
|
||||
centerLat float64
|
||||
centerLng float64
|
||||
radius float64
|
||||
altitudeMin float64
|
||||
altitudeMax float64
|
||||
periodS float64 // angular speed
|
||||
}
|
||||
|
||||
func newFlightConfig() *flightConfig {
|
||||
return &flightConfig{
|
||||
centerLat: 37.83, // San francisco
|
||||
centerLng: -122.42487,
|
||||
radius: 0.01, // raw gps degrees
|
||||
altitudeMin: 350,
|
||||
altitudeMax: 400,
|
||||
periodS: 10, //model.Get("period").MustFloat64(10),
|
||||
}
|
||||
}
|
||||
|
||||
type flightFields struct {
|
||||
time *data.Field
|
||||
lat *data.Field
|
||||
lng *data.Field
|
||||
heading *data.Field
|
||||
altitude *data.Field
|
||||
|
||||
frame *data.Frame
|
||||
}
|
||||
|
||||
func (f *flightConfig) initFields() *flightFields {
|
||||
ff := &flightFields{
|
||||
time: data.NewFieldFromFieldType(data.FieldTypeTime, 0),
|
||||
lat: data.NewFieldFromFieldType(data.FieldTypeFloat64, 0),
|
||||
lng: data.NewFieldFromFieldType(data.FieldTypeFloat64, 0),
|
||||
heading: data.NewFieldFromFieldType(data.FieldTypeFloat64, 0),
|
||||
altitude: data.NewFieldFromFieldType(data.FieldTypeFloat64, 0),
|
||||
}
|
||||
ff.time.Name = "time"
|
||||
ff.lat.Name = "lat"
|
||||
ff.lng.Name = "lng"
|
||||
ff.heading.Name = "heading"
|
||||
ff.altitude.Name = "altitude"
|
||||
|
||||
ff.frame = data.NewFrame("", ff.time, ff.lat, ff.lng, ff.heading, ff.altitude)
|
||||
return ff
|
||||
}
|
||||
|
||||
func (f *flightFields) append(v flightDataPoint) {
|
||||
f.frame.AppendRow(v.time, v.lat, v.lng, v.heading, v.altitude)
|
||||
}
|
||||
|
||||
func (f *flightFields) set(idx int, v flightDataPoint) {
|
||||
f.time.Set(idx, v.time)
|
||||
f.lat.Set(idx, v.lat)
|
||||
f.lng.Set(idx, v.lng)
|
||||
f.heading.Set(idx, v.heading)
|
||||
f.altitude.Set(idx, v.altitude)
|
||||
}
|
||||
|
||||
func (f *flightConfig) getNextPoint(t time.Time) flightDataPoint {
|
||||
periodNS := int64(f.periodS * float64(time.Second))
|
||||
ms := t.UnixNano() % periodNS
|
||||
per := float64(ms) / float64(periodNS)
|
||||
rad := per * 2.0 * math.Pi // 0 >> 2Pi
|
||||
delta := f.altitudeMax - f.altitudeMin
|
||||
|
||||
return flightDataPoint{
|
||||
time: t,
|
||||
lat: f.centerLat + math.Sin(rad)*f.radius,
|
||||
lng: f.centerLng + math.Cos(rad)*f.radius,
|
||||
heading: (rad * 180) / math.Pi, // (math.Atanh(rad) * 180.0) / math.Pi, // in degrees
|
||||
altitude: f.altitudeMin + (delta * per), // clif
|
||||
}
|
||||
}
|
@ -23,6 +23,8 @@ func (s *Service) registerRoutes() *http.ServeMux {
|
||||
mux.Handle("/test", createJSONHandler(s.logger))
|
||||
mux.Handle("/test/json", createJSONHandler(s.logger))
|
||||
mux.HandleFunc("/boom", s.testPanicHandler)
|
||||
mux.HandleFunc("/sims", s.sims.GetSimulationHandler)
|
||||
mux.HandleFunc("/sim/", s.sims.GetSimulationHandler)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,7 @@ const (
|
||||
predictablePulseQuery queryType = "predictable_pulse"
|
||||
predictableCSVWaveQuery queryType = "predictable_csv_wave"
|
||||
streamingClientQuery queryType = "streaming_client"
|
||||
flightPath queryType = "flight_path"
|
||||
simulation queryType = "simulation"
|
||||
usaQueryKey queryType = "usa"
|
||||
liveQuery queryType = "live"
|
||||
grafanaAPIQuery queryType = "grafana_api"
|
||||
@ -135,9 +135,9 @@ Timestamps will line up evenly on timeStepSeconds (For example, 60 seconds means
|
||||
})
|
||||
|
||||
s.registerScenario(&Scenario{
|
||||
ID: string(flightPath),
|
||||
Name: "Flight path",
|
||||
handler: s.handleFlightPathScenario,
|
||||
ID: string(simulation),
|
||||
Name: "Simulation",
|
||||
handler: s.sims.QueryData,
|
||||
})
|
||||
|
||||
s.registerScenario(&Scenario{
|
||||
|
305
pkg/tsdb/testdatasource/sims/engine.go
Normal file
305
pkg/tsdb/testdatasource/sims/engine.go
Normal file
@ -0,0 +1,305 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
var (
|
||||
_ backend.StreamHandler = (*SimulationEngine)(nil)
|
||||
_ backend.QueryDataHandler = (*SimulationEngine)(nil)
|
||||
)
|
||||
|
||||
type SimulationEngine struct {
|
||||
logger log.Logger
|
||||
|
||||
// Lookup by Type
|
||||
registry map[string]simulationInfo
|
||||
|
||||
// The running instances
|
||||
running map[string]Simulation
|
||||
|
||||
// safe changes
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) register(info simulationInfo) error {
|
||||
if info.create == nil {
|
||||
return fmt.Errorf("invalid simulation -- missing create function: " + info.Type)
|
||||
}
|
||||
if info.Type == "" {
|
||||
return fmt.Errorf("missing type")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
_, ok := s.registry[info.Type]
|
||||
if ok {
|
||||
return fmt.Errorf("already registered")
|
||||
}
|
||||
|
||||
s.registry[info.Type] = info
|
||||
return nil
|
||||
}
|
||||
|
||||
type simulationInitializer = func() simulationInfo
|
||||
|
||||
func NewSimulationEngine() (*SimulationEngine, error) {
|
||||
s := &SimulationEngine{
|
||||
registry: make(map[string]simulationInfo),
|
||||
running: make(map[string]Simulation),
|
||||
logger: log.New("tsdb.sims"),
|
||||
}
|
||||
// Initialize each type
|
||||
initializers := []simulationInitializer{
|
||||
newFlightSimInfo,
|
||||
newSinewaveInfo,
|
||||
}
|
||||
|
||||
for _, init := range initializers {
|
||||
err := s.register(init())
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) Lookup(info simulationState) (Simulation, error) {
|
||||
hz := info.Key.TickHZ
|
||||
if hz < (1 / 60.0) {
|
||||
return nil, fmt.Errorf("frequency is too slow")
|
||||
}
|
||||
if hz > 50 {
|
||||
return nil, fmt.Errorf("frequency is too fast")
|
||||
}
|
||||
if info.Key.Type == "" {
|
||||
return nil, fmt.Errorf("missing simulation type")
|
||||
}
|
||||
|
||||
key := info.Key.String()
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
v, ok := s.running[key]
|
||||
if ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
t, ok := s.registry[info.Key.Type]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown simulation type")
|
||||
}
|
||||
|
||||
v, err := t.create(info)
|
||||
if err == nil {
|
||||
s.running[key] = v
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
type simulationQuery struct {
|
||||
simulationState
|
||||
Last bool `json:"last"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type dumbQueryQrapper struct {
|
||||
Sim simulationQuery `json:"sim"`
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
resp := backend.NewQueryDataResponse()
|
||||
|
||||
for _, q := range req.Queries {
|
||||
wrap := &dumbQueryQrapper{}
|
||||
err := json.Unmarshal(q.JSON, wrap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse query json: %v", err)
|
||||
}
|
||||
sq := wrap.Sim
|
||||
if sq.Key.TickHZ == 0 {
|
||||
sq.Key.TickHZ = 10
|
||||
}
|
||||
|
||||
sim, err := s.Lookup(sq.simulationState)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching simulation: %v", err)
|
||||
}
|
||||
|
||||
if sim == nil {
|
||||
return nil, fmt.Errorf("invalid simulation: %v", sq)
|
||||
}
|
||||
|
||||
frame := sim.NewFrame(0)
|
||||
if sq.Last {
|
||||
v := sim.GetValues(q.TimeRange.To)
|
||||
appendFrameRow(frame, v)
|
||||
} else {
|
||||
timeWalkerMs := q.TimeRange.From.UnixNano() / int64(time.Millisecond)
|
||||
to := q.TimeRange.To.UnixNano() / int64(time.Millisecond)
|
||||
stepMillis := q.Interval.Milliseconds()
|
||||
|
||||
maxPoints := q.MaxDataPoints * 2
|
||||
for i := int64(0); i < maxPoints && timeWalkerMs < to; i++ {
|
||||
t := time.UnixMilli(timeWalkerMs).UTC()
|
||||
appendFrameRow(frame, sim.GetValues(t))
|
||||
timeWalkerMs += stepMillis
|
||||
}
|
||||
}
|
||||
|
||||
if sq.Stream && req.PluginContext.DataSourceInstanceSettings != nil {
|
||||
uid := req.PluginContext.DataSourceInstanceSettings.UID
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Channel: fmt.Sprintf("ds/%s/sim/%s", uid, sim.GetState().Key.String()),
|
||||
}
|
||||
}
|
||||
|
||||
respD := resp.Responses[q.RefID]
|
||||
respD.Frames = append(respD.Frames, frame)
|
||||
resp.Responses[q.RefID] = respD
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) getSimFromPath(path string) (Simulation, error) {
|
||||
idx := strings.Index(path, "sim/")
|
||||
if idx >= 0 {
|
||||
path = path[idx+4:]
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("missing frequency")
|
||||
}
|
||||
if !strings.HasSuffix(parts[1], "hz") {
|
||||
return nil, fmt.Errorf("invalid path frequency. Expecting `hz` suffix")
|
||||
}
|
||||
hz, err := strconv.ParseFloat(strings.TrimSuffix(parts[1], "hz"), 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing frequency from: %s", parts[1])
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
parts = append(parts, "") // empty UID
|
||||
}
|
||||
|
||||
key := simulationKey{
|
||||
Type: parts[0],
|
||||
TickHZ: hz,
|
||||
UID: parts[2],
|
||||
}
|
||||
if path != key.String() {
|
||||
return nil, fmt.Errorf("path should match: %s", key.String())
|
||||
}
|
||||
|
||||
return s.Lookup(simulationState{
|
||||
Key: key,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) GetSimulationHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
var result interface{}
|
||||
path := req.URL.Path
|
||||
if path == "/sims" {
|
||||
v := make([]simulationInfo, 0, len(s.registry))
|
||||
for _, value := range s.registry {
|
||||
v = append(v, value)
|
||||
}
|
||||
result = v
|
||||
} else if strings.HasPrefix(path, "/sim/") {
|
||||
rw.WriteHeader(400)
|
||||
sim, err := s.getSimFromPath(path)
|
||||
if err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// With a POST, update the values
|
||||
if req.Method == "POST" {
|
||||
body, err := getBodyFromRequest(req)
|
||||
if err == nil {
|
||||
err = sim.SetConfig(body)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
result = sim.GetState()
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(&result)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to marshal response body to JSON", "error", err)
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
if _, err := rw.Write(bytes); err != nil {
|
||||
s.logger.Error("Failed to write response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
|
||||
sim, err := s.getSimFromPath(req.Path) // includes sim
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frame := sim.NewFrame(1)
|
||||
setFrameRow(frame, 0, sim.GetValues(time.Now()))
|
||||
initial, err := backend.NewInitialFrame(frame, data.IncludeAll)
|
||||
|
||||
return &backend.SubscribeStreamResponse{
|
||||
Status: backend.SubscribeStreamStatusOK,
|
||||
InitialData: initial,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
|
||||
sim, err := s.getSimFromPath(req.Path) // includes sim
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
frequency := 1.0 / sim.GetState().Key.TickHZ
|
||||
ticker := time.NewTicker(time.Duration(frequency * float64(time.Second)))
|
||||
defer ticker.Stop()
|
||||
|
||||
mode := data.IncludeDataOnly
|
||||
|
||||
frame := sim.NewFrame(1)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
|
||||
case t := <-ticker.C:
|
||||
setFrameRow(frame, 0, sim.GetValues(t))
|
||||
err := sender.SendFrame(frame, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SimulationEngine) PublishStream(_ context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
|
||||
return &backend.PublishStreamResponse{
|
||||
Status: backend.PublishStreamStatusPermissionDenied,
|
||||
}, nil
|
||||
}
|
64
pkg/tsdb/testdatasource/sims/engine_test.go
Normal file
64
pkg/tsdb/testdatasource/sims/engine_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCoreSimulationRegistry(t *testing.T) {
|
||||
sims, err := NewSimulationEngine()
|
||||
require.NoError(t, err)
|
||||
v, err := sims.Lookup(simulationState{
|
||||
Key: simulationKey{
|
||||
Type: "flight",
|
||||
TickHZ: 1,
|
||||
},
|
||||
Config: map[string]interface{}{
|
||||
"period": 100,
|
||||
"radius": 0.05,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := json.MarshalIndent(v.GetState(), "", " ")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"key": {
|
||||
"type": "flight",
|
||||
"tick": 1
|
||||
},
|
||||
"config": {
|
||||
"centerLat":37.83,
|
||||
"centerLng":-122.42487,
|
||||
"altitudeMax":400,
|
||||
"altitudeMin":350,
|
||||
"period":100,
|
||||
"radius":0.05
|
||||
}
|
||||
}`, string(cfg))
|
||||
|
||||
path := v.GetState().Key.String()
|
||||
found, err := sims.getSimFromPath("sim/" + path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v, found)
|
||||
|
||||
found, err = sims.getSimFromPath("/sim/" + path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v, found)
|
||||
|
||||
found, err = sims.getSimFromPath(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v, found)
|
||||
|
||||
// In valid paths
|
||||
_, err = sims.getSimFromPath("flight/1.00hz")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = sims.getSimFromPath("flight/1")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = sims.getSimFromPath("flight/1/")
|
||||
require.Error(t, err)
|
||||
}
|
131
pkg/tsdb/testdatasource/sims/flight_path.go
Normal file
131
pkg/tsdb/testdatasource/sims/flight_path.go
Normal file
@ -0,0 +1,131 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type flightSim struct {
|
||||
key simulationKey
|
||||
cfg flightConfig
|
||||
}
|
||||
|
||||
var (
|
||||
_ Simulation = (*flightSim)(nil)
|
||||
)
|
||||
|
||||
type flightDataPoint struct {
|
||||
time time.Time
|
||||
lat float64 // gps
|
||||
lng float64 // gps
|
||||
heading float64 // degree
|
||||
altitude float64 // above ground
|
||||
}
|
||||
|
||||
type flightConfig struct {
|
||||
CenterLat float64 `json:"centerLat"`
|
||||
CenterLng float64 `json:"centerLng"`
|
||||
Radius float64 `json:"radius"`
|
||||
AltitudeMin float64 `json:"altitudeMin"`
|
||||
AltitudeMax float64 `json:"altitudeMax"`
|
||||
Period float64 `json:"period"` // angular speed (seconds)
|
||||
}
|
||||
|
||||
func (s *flightSim) GetState() simulationState {
|
||||
return simulationState{
|
||||
Key: s.key,
|
||||
Config: s.cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *flightSim) SetConfig(vals map[string]interface{}) error {
|
||||
return updateConfigObjectFromJSON(s.cfg, vals)
|
||||
}
|
||||
|
||||
func (s *flightSim) NewFrame(size int) *data.Frame {
|
||||
frame := data.NewFrameOfFieldTypes("", size,
|
||||
data.FieldTypeTime, // time
|
||||
data.FieldTypeFloat64, // lat
|
||||
data.FieldTypeFloat64, // lng
|
||||
data.FieldTypeFloat64, // heading
|
||||
data.FieldTypeFloat64, // altitude
|
||||
)
|
||||
frame.Fields[0].Name = "time"
|
||||
frame.Fields[1].Name = "lat"
|
||||
frame.Fields[2].Name = "lng"
|
||||
frame.Fields[3].Name = "heading"
|
||||
frame.Fields[4].Name = "altitude"
|
||||
return frame
|
||||
}
|
||||
|
||||
func (s *flightSim) GetValues(t time.Time) map[string]interface{} {
|
||||
p := s.cfg.getNextPoint(t)
|
||||
|
||||
return map[string]interface{}{
|
||||
"time": p.time,
|
||||
"lat": p.lat,
|
||||
"lng": p.lng,
|
||||
"heading": p.heading,
|
||||
"altitude": p.altitude,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *flightSim) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newFlightSimInfo() simulationInfo {
|
||||
sf := flightConfig{
|
||||
CenterLat: 37.83, // San francisco
|
||||
CenterLng: -122.42487,
|
||||
Radius: 0.01, // raw gps degrees
|
||||
AltitudeMin: 350,
|
||||
AltitudeMax: 400,
|
||||
Period: 10, //model.Get("period").MustFloat64(10),
|
||||
}
|
||||
|
||||
df := data.NewFrame("")
|
||||
df.Fields = append(df.Fields, data.NewField("centerLat", nil, []float64{sf.CenterLat}))
|
||||
df.Fields = append(df.Fields, data.NewField("centerLng", nil, []float64{sf.CenterLng}))
|
||||
df.Fields = append(df.Fields, data.NewField("radius", nil, []float64{sf.Radius}))
|
||||
df.Fields = append(df.Fields, data.NewField("altitudeMin", nil, []float64{sf.AltitudeMin}))
|
||||
df.Fields = append(df.Fields, data.NewField("altitudeMax", nil, []float64{sf.AltitudeMax}))
|
||||
|
||||
f := data.NewField("period", nil, []float64{sf.Period})
|
||||
f.Config = &data.FieldConfig{Unit: "s"} // seconds
|
||||
df.Fields = append(df.Fields, f)
|
||||
|
||||
return simulationInfo{
|
||||
Type: "flight",
|
||||
Name: "Flight",
|
||||
Description: "simple circling airplain",
|
||||
SetupFields: df,
|
||||
OnlyForward: false,
|
||||
create: func(cfg simulationState) (Simulation, error) {
|
||||
s := &flightSim{
|
||||
key: cfg.Key,
|
||||
cfg: sf, // default value
|
||||
}
|
||||
err := updateConfigObjectFromJSON(&s.cfg, cfg.Config) // override any fields
|
||||
return s, err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *flightConfig) getNextPoint(t time.Time) flightDataPoint {
|
||||
periodNS := int64(f.Period * float64(time.Second))
|
||||
ms := t.UnixNano() % periodNS
|
||||
per := float64(ms) / float64(periodNS)
|
||||
rad := per * 2.0 * math.Pi // 0 >> 2Pi
|
||||
delta := f.AltitudeMax - f.AltitudeMin
|
||||
|
||||
return flightDataPoint{
|
||||
time: t,
|
||||
lat: f.CenterLat + math.Sin(rad)*f.Radius,
|
||||
lng: f.CenterLng + math.Cos(rad)*f.Radius,
|
||||
heading: (rad * 180) / math.Pi, // (math.Atanh(rad) * 180.0) / math.Pi, // in degrees
|
||||
altitude: f.AltitudeMin + (delta * per), // clif
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package testdatasource
|
||||
package sims
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -10,39 +10,47 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/experimental"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFlightPathScenario(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
s := &Service{
|
||||
cfg: cfg,
|
||||
}
|
||||
func TestFlightPathQuery(t *testing.T) {
|
||||
s, err := NewSimulationEngine()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("simple flight", func(t *testing.T) {
|
||||
sq := &simulationQuery{}
|
||||
sq.Key = simulationKey{
|
||||
Type: "flight",
|
||||
TickHZ: 1,
|
||||
}
|
||||
sq.Stream = true
|
||||
sb, err := json.Marshal(map[string]interface{}{
|
||||
"sim": sq,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Date(2020, time.January, 10, 23, 0, 0, 0, time.UTC)
|
||||
qr := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: "X",
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: start,
|
||||
To: start.Add(time.Second * 10),
|
||||
},
|
||||
Interval: time.Second,
|
||||
MaxDataPoints: 10,
|
||||
JSON: json.RawMessage(`{}`), // always 10s?
|
||||
JSON: sb,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rsp, err := s.handleFlightPathScenario(context.Background(), qr)
|
||||
rsp, err := s.QueryData(context.Background(), qr)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rsp)
|
||||
for k, v := range rsp.Responses {
|
||||
dr := v
|
||||
filePath := filepath.Join("testdata", fmt.Sprintf("flight-simple-%s.txt", k))
|
||||
filePath := filepath.Join("testdata", fmt.Sprintf("flight_path_query_%s.txt", k))
|
||||
err = experimental.CheckGoldenDataResponse(filePath, &dr, true)
|
||||
require.NoError(t, err)
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
🌟 This was machine generated. Do not edit. 🌟
|
||||
|
||||
Frame[0]
|
||||
Name:
|
||||
Frame[0]
|
||||
Name:
|
||||
Dimensions: 5 Fields by 10 Rows
|
||||
+-------------------------------+--------------------+---------------------+-----------------+-----------------+
|
||||
| Name: time | Name: lat | Name: lng | Name: heading | Name: altitude |
|
||||
@ -22,4 +22,4 @@ Dimensions: 5 Fields by 10 Rows
|
||||
|
||||
|
||||
====== TEST DATA RESPONSE (arrow base64) ======
|
||||
FRAME=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
|
||||
FRAME=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
|
51
pkg/tsdb/testdatasource/sims/types.go
Normal file
51
pkg/tsdb/testdatasource/sims/types.go
Normal file
@ -0,0 +1,51 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type simulationKey struct {
|
||||
Type string `json:"type"`
|
||||
TickHZ float64 `json:"tick"` // events/second
|
||||
UID string `json:"uid,omitempty"` // allows many instances of the same type
|
||||
}
|
||||
|
||||
func (k simulationKey) String() string {
|
||||
hz := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", k.TickHZ), "0"), ".")
|
||||
if k.UID != "" {
|
||||
return fmt.Sprintf("%s/%shz/%s", k.Type, hz, k.UID)
|
||||
}
|
||||
return fmt.Sprintf("%s/%shz", k.Type, hz)
|
||||
}
|
||||
|
||||
type simulationState struct {
|
||||
// Identify the simulation instance
|
||||
Key simulationKey `json:"key"`
|
||||
|
||||
// Saved in panel options, and used to set initial values
|
||||
Config interface{} `json:"config"`
|
||||
}
|
||||
|
||||
type simulationInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OnlyForward bool `json:"forward"`
|
||||
SetupFields *data.Frame `json:"setup"` // the default setup fields (and types)
|
||||
|
||||
// Create a simulation instance
|
||||
create func(q simulationState) (Simulation, error)
|
||||
}
|
||||
|
||||
type Simulation interface {
|
||||
io.Closer
|
||||
GetState() simulationState
|
||||
SetConfig(vals map[string]interface{}) error
|
||||
NewFrame(size int) *data.Frame
|
||||
GetValues(t time.Time) map[string]interface{}
|
||||
}
|
76
pkg/tsdb/testdatasource/sims/utils.go
Normal file
76
pkg/tsdb/testdatasource/sims/utils.go
Normal file
@ -0,0 +1,76 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
// updateConfigObjectFromJSON will use json serialization to update any properties
|
||||
func updateConfigObjectFromJSON(cfg interface{}, input interface{}) error {
|
||||
current, err := asStringMap(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := asStringMap(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for k, v := range next {
|
||||
if v == nil {
|
||||
delete(current, k)
|
||||
} else {
|
||||
current[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, cfg)
|
||||
}
|
||||
|
||||
func asStringMap(input interface{}) (map[string]interface{}, error) {
|
||||
v, ok := input.(map[string]interface{})
|
||||
if ok {
|
||||
return v, nil
|
||||
}
|
||||
v = make(map[string]interface{})
|
||||
b, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func setFrameRow(frame *data.Frame, idx int, values map[string]interface{}) {
|
||||
for _, field := range frame.Fields {
|
||||
v, ok := values[field.Name]
|
||||
if ok {
|
||||
field.Set(idx, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendFrameRow(frame *data.Frame, values map[string]interface{}) {
|
||||
for _, field := range frame.Fields {
|
||||
v, ok := values[field.Name]
|
||||
if ok {
|
||||
field.Append(v)
|
||||
} else {
|
||||
field.Extend(1) // fill with nullable value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getBodyFromRequest(req *http.Request) (map[string]interface{}, error) {
|
||||
result := make(map[string]interface{}, 10)
|
||||
|
||||
err := json.NewDecoder(req.Body).Decode(&result)
|
||||
// TODO? create the map based on form parameters not JSON post
|
||||
return result, err
|
||||
}
|
30
pkg/tsdb/testdatasource/sims/utils_test.go
Normal file
30
pkg/tsdb/testdatasource/sims/utils_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSimulationUtils(t *testing.T) {
|
||||
a := map[string]interface{}{
|
||||
"hello": "world",
|
||||
"bool": true,
|
||||
"number": 10,
|
||||
}
|
||||
|
||||
err := updateConfigObjectFromJSON(&a, map[string]interface{}{
|
||||
"bool": false,
|
||||
"number": 5,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := json.MarshalIndent(a, "", " ")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"hello": "world",
|
||||
"bool": false,
|
||||
"number": 5
|
||||
}`, string(cfg))
|
||||
}
|
115
pkg/tsdb/testdatasource/sims/waveform.go
Normal file
115
pkg/tsdb/testdatasource/sims/waveform.go
Normal file
@ -0,0 +1,115 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type waveformSim struct {
|
||||
key simulationKey
|
||||
cfg waveformConfig
|
||||
calculator func(x float64, cfg *waveformConfig) float64
|
||||
}
|
||||
|
||||
var (
|
||||
_ Simulation = (*waveformSim)(nil)
|
||||
)
|
||||
|
||||
type waveformConfig struct {
|
||||
Period float64 `json:"period"` // seconds
|
||||
Offset float64 `json:"offset,omitempty"` // Y shift
|
||||
Phase float64 `json:"phase,omitempty"` // X shift // +- 1 (will scale the )
|
||||
Amplitude float64 `json:"amplitude"` // Y size
|
||||
Noise float64 `json:"noise,omitempty"` // random noise to add
|
||||
}
|
||||
|
||||
func (s *waveformSim) GetState() simulationState {
|
||||
return simulationState{
|
||||
Key: s.key,
|
||||
Config: s.cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *waveformSim) SetConfig(vals map[string]interface{}) error {
|
||||
return updateConfigObjectFromJSON(s.cfg, vals)
|
||||
}
|
||||
|
||||
func (s *waveformSim) NewFrame(size int) *data.Frame {
|
||||
frame := data.NewFrameOfFieldTypes("", size,
|
||||
data.FieldTypeTime, // time
|
||||
data.FieldTypeFloat64, // value
|
||||
)
|
||||
frame.Fields[0].Name = data.TimeSeriesTimeFieldName
|
||||
frame.Fields[1].Name = data.TimeSeriesValueFieldName
|
||||
return frame
|
||||
}
|
||||
|
||||
func (s *waveformSim) GetValues(t time.Time) map[string]interface{} {
|
||||
x := 0.0
|
||||
if s.cfg.Period > 0 {
|
||||
periodMS := s.cfg.Period * 1000
|
||||
ms := t.UnixMilli() % int64(periodMS)
|
||||
x = ((float64(ms) / periodMS) * 2 * math.Pi) // 0 >> 2Pi
|
||||
}
|
||||
|
||||
v := s.calculator(x, &s.cfg)
|
||||
|
||||
noise := s.cfg.Noise
|
||||
if noise > 0 {
|
||||
gen := rand.New(rand.NewSource(t.UnixMilli())) // consistent for the value
|
||||
v += (gen.Float64() * 2.0 * noise) - noise
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
data.TimeSeriesTimeFieldName: t,
|
||||
data.TimeSeriesValueFieldName: v,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *waveformSim) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newSinewaveInfo() simulationInfo {
|
||||
sf := waveformConfig{
|
||||
Period: 10,
|
||||
Amplitude: 1,
|
||||
Offset: 0,
|
||||
Phase: 0,
|
||||
}
|
||||
|
||||
df := data.NewFrame("")
|
||||
df.Fields = append(df.Fields, data.NewField("period", nil, []float64{sf.Period}))
|
||||
df.Fields = append(df.Fields, data.NewField("offset", nil, []float64{sf.Offset}))
|
||||
df.Fields = append(df.Fields, data.NewField("phase", nil, []float64{sf.Phase}))
|
||||
df.Fields = append(df.Fields, data.NewField("amplitude", nil, []float64{sf.Amplitude}))
|
||||
df.Fields = append(df.Fields, data.NewField("noise", nil, []float64{sf.Noise}))
|
||||
|
||||
f := data.NewField("period", nil, []float64{sf.Period})
|
||||
f.Config = &data.FieldConfig{Unit: "s"} // seconds
|
||||
df.Fields = append(df.Fields, f)
|
||||
|
||||
return simulationInfo{
|
||||
Type: "sine",
|
||||
Name: "Sine",
|
||||
Description: "Sinewave generator",
|
||||
SetupFields: df,
|
||||
OnlyForward: false,
|
||||
create: func(cfg simulationState) (Simulation, error) {
|
||||
s := &waveformSim{
|
||||
key: cfg.Key,
|
||||
cfg: sf, // default value
|
||||
calculator: sinewaveCalculator,
|
||||
}
|
||||
err := updateConfigObjectFromJSON(&s.cfg, cfg.Config) // override any fields
|
||||
return s, err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sinewaveCalculator(x float64, cfg *waveformConfig) float64 {
|
||||
return (math.Sin(x) * cfg.Amplitude) + cfg.Offset
|
||||
}
|
@ -15,22 +15,18 @@ import (
|
||||
|
||||
var random20HzStreamRegex = regexp.MustCompile(`random-20Hz-stream(-\d+)?`)
|
||||
|
||||
func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
|
||||
func (s *Service) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
|
||||
s.logger.Debug("Allowing access to stream", "path", req.Path, "user", req.PluginContext.User)
|
||||
|
||||
if strings.HasPrefix(req.Path, "sim/") {
|
||||
return s.sims.SubscribeStream(ctx, req)
|
||||
}
|
||||
|
||||
initialData, err := backend.NewInitialFrame(s.frame, data.IncludeSchemaOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For flight simulations, send the more complex schema
|
||||
if strings.HasPrefix(req.Path, "flight") {
|
||||
ff := newFlightConfig().initFields()
|
||||
initialData, err = backend.NewInitialFrame(ff.frame, data.IncludeSchemaOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(req.Path, "-labeled") {
|
||||
initialData, err = backend.NewInitialFrame(s.labelFrame, data.IncludeSchemaOnly)
|
||||
if err != nil {
|
||||
@ -49,8 +45,13 @@ func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStrea
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) PublishStream(_ context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
|
||||
func (s *Service) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
|
||||
s.logger.Debug("Attempt to publish into stream", "path", req.Path, "user", req.PluginContext.User)
|
||||
|
||||
if strings.HasPrefix(req.Path, "sim/") {
|
||||
return s.sims.PublishStream(ctx, req)
|
||||
}
|
||||
|
||||
return &backend.PublishStreamResponse{
|
||||
Status: backend.PublishStreamStatusPermissionDenied,
|
||||
}, nil
|
||||
@ -58,6 +59,11 @@ func (s *Service) PublishStream(_ context.Context, req *backend.PublishStreamReq
|
||||
|
||||
func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error {
|
||||
s.logger.Debug("New stream call", "path", request.Path)
|
||||
|
||||
if strings.HasPrefix(request.Path, "sim/") {
|
||||
return s.sims.RunStream(ctx, request, sender)
|
||||
}
|
||||
|
||||
var conf testStreamConfig
|
||||
switch {
|
||||
case request.Path == "random-2s-stream":
|
||||
@ -75,11 +81,6 @@ func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamReque
|
||||
Drop: 0.2, // keep 80%
|
||||
Labeled: true,
|
||||
}
|
||||
case request.Path == "flight-5hz-stream":
|
||||
conf = testStreamConfig{
|
||||
Interval: 200 * time.Millisecond,
|
||||
Flight: newFlightConfig(),
|
||||
}
|
||||
case random20HzStreamRegex.MatchString(request.Path):
|
||||
conf = testStreamConfig{
|
||||
Interval: 50 * time.Millisecond,
|
||||
@ -93,7 +94,6 @@ func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamReque
|
||||
type testStreamConfig struct {
|
||||
Interval time.Duration
|
||||
Drop float64
|
||||
Flight *flightConfig
|
||||
Labeled bool
|
||||
}
|
||||
|
||||
@ -104,12 +104,6 @@ func (s *Service) runTestStream(ctx context.Context, path string, conf testStrea
|
||||
ticker := time.NewTicker(conf.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var flight *flightFields
|
||||
if conf.Flight != nil {
|
||||
flight = conf.Flight.initFields()
|
||||
flight.append(conf.Flight.getNextPoint(time.Now()))
|
||||
}
|
||||
|
||||
labelFrame := data.NewFrame("labeled",
|
||||
data.NewField("labels", nil, make([]string, 2)),
|
||||
data.NewField("Time", nil, make([]time.Time, 2)),
|
||||
@ -131,37 +125,30 @@ func (s *Service) runTestStream(ctx context.Context, path string, conf testStrea
|
||||
mode = data.IncludeAll
|
||||
}
|
||||
|
||||
if flight != nil {
|
||||
flight.set(0, conf.Flight.getNextPoint(t))
|
||||
if err := sender.SendFrame(flight.frame, mode); err != nil {
|
||||
delta := rand.Float64() - 0.5
|
||||
walker += delta
|
||||
|
||||
if conf.Labeled {
|
||||
secA := t.Second() / 3
|
||||
secB := t.Second() / 7
|
||||
|
||||
labelFrame.Fields[0].Set(0, fmt.Sprintf("s=A,s=p%d,x=X", secA))
|
||||
labelFrame.Fields[1].Set(0, t)
|
||||
labelFrame.Fields[2].Set(0, walker)
|
||||
|
||||
labelFrame.Fields[0].Set(1, fmt.Sprintf("s=B,s=p%d,x=X", secB))
|
||||
labelFrame.Fields[1].Set(1, t)
|
||||
labelFrame.Fields[2].Set(1, walker+10)
|
||||
if err := sender.SendFrame(labelFrame, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
delta := rand.Float64() - 0.5
|
||||
walker += delta
|
||||
|
||||
if conf.Labeled {
|
||||
secA := t.Second() / 3
|
||||
secB := t.Second() / 7
|
||||
|
||||
labelFrame.Fields[0].Set(0, fmt.Sprintf("s=A,s=p%d,x=X", secA))
|
||||
labelFrame.Fields[1].Set(0, t)
|
||||
labelFrame.Fields[2].Set(0, walker)
|
||||
|
||||
labelFrame.Fields[0].Set(1, fmt.Sprintf("s=B,s=p%d,x=X", secB))
|
||||
labelFrame.Fields[1].Set(1, t)
|
||||
labelFrame.Fields[2].Set(1, walker+10)
|
||||
if err := sender.SendFrame(labelFrame, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
s.frame.Fields[0].Set(0, t)
|
||||
s.frame.Fields[1].Set(0, walker) // Value
|
||||
s.frame.Fields[2].Set(0, walker-((rand.Float64()*spread)+0.01)) // Min
|
||||
s.frame.Fields[3].Set(0, walker+((rand.Float64()*spread)+0.01)) // Max
|
||||
if err := sender.SendFrame(s.frame, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
s.frame.Fields[0].Set(0, t)
|
||||
s.frame.Fields[1].Set(0, walker) // Value
|
||||
s.frame.Fields[2].Set(0, walker-((rand.Float64()*spread)+0.01)) // Min
|
||||
s.frame.Fields[3].Set(0, walker+((rand.Float64()*spread)+0.01)) // Max
|
||||
if err := sender.SendFrame(s.frame, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/testdatasource/sims"
|
||||
)
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles) *Service {
|
||||
@ -34,6 +35,12 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles) *Serv
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
var err error
|
||||
s.sims, err = sims.NewSimulationEngine()
|
||||
if err != nil {
|
||||
s.logger.Error("unable to initialize SimulationEngine", "err", err)
|
||||
}
|
||||
|
||||
s.registerScenarios()
|
||||
s.resourceHandler = httpadapter.New(s.registerRoutes())
|
||||
|
||||
@ -49,6 +56,7 @@ type Service struct {
|
||||
queryMux *datasource.QueryTypeMux
|
||||
resourceHandler backend.CallResourceHandler
|
||||
features featuremgmt.FeatureToggles
|
||||
sims *sims.SimulationEngine
|
||||
}
|
||||
|
||||
func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
|
@ -22,6 +22,7 @@ import { CSVFileEditor } from './components/CSVFileEditor';
|
||||
import { CSVContentEditor } from './components/CSVContentEditor';
|
||||
import { USAQueryEditor, usaQueryModes } from './components/USAQueryEditor';
|
||||
import ErrorEditor from './components/ErrorEditor';
|
||||
import { SimulationQueryEditor } from './components/SimulationQueryEditor';
|
||||
|
||||
const showLabelsFor = ['random_walk', 'predictable_pulse'];
|
||||
const endpoints = [
|
||||
@ -57,7 +58,12 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
|
||||
});
|
||||
}
|
||||
|
||||
return datasource.getScenarios();
|
||||
const vals = await datasource.getScenarios();
|
||||
const hideAlias = ['simulation'];
|
||||
return vals.map((v) => ({
|
||||
...v,
|
||||
hideAliasField: hideAlias.includes(v.id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onUpdate = (query: TestDataQuery) => {
|
||||
@ -101,6 +107,9 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
|
||||
case 'live':
|
||||
update.channel = 'random-2s-stream'; // default stream
|
||||
break;
|
||||
case 'simulation':
|
||||
update.sim = { key: { type: 'flight', tick: 10 } }; // default stream
|
||||
break;
|
||||
case 'predictable_pulse':
|
||||
update.pulseWave = defaultPulseQuery;
|
||||
break;
|
||||
@ -194,18 +203,20 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
|
||||
/>
|
||||
</InlineField>
|
||||
)}
|
||||
<InlineField label="Alias" labelWidth={14}>
|
||||
<Input
|
||||
width={32}
|
||||
id={`alias-${query.refId}`}
|
||||
type="text"
|
||||
placeholder="optional"
|
||||
pattern='[^<>&\\"]+'
|
||||
name="alias"
|
||||
value={query.alias}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</InlineField>
|
||||
{Boolean(!currentScenario?.hideAliasField) && (
|
||||
<InlineField label="Alias" labelWidth={14}>
|
||||
<Input
|
||||
width={32}
|
||||
id={`alias-${query.refId}`}
|
||||
type="text"
|
||||
placeholder="optional"
|
||||
pattern='[^<>&\\"]+'
|
||||
name="alias"
|
||||
value={query.alias}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</InlineField>
|
||||
)}
|
||||
{showLabels && (
|
||||
<InlineField
|
||||
label="Labels"
|
||||
@ -238,6 +249,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
|
||||
{scenarioId === 'random_walk' && <RandomWalkEditor onChange={onInputChange} query={query} />}
|
||||
{scenarioId === 'streaming_client' && <StreamingClientEditor onChange={onStreamClientChange} query={query} />}
|
||||
{scenarioId === 'live' && <GrafanaLiveEditor onChange={onUpdate} query={query} />}
|
||||
{scenarioId === 'simulation' && <SimulationQueryEditor onChange={onUpdate} query={query} />}
|
||||
{scenarioId === 'raw_frame' && <RawFrameEditor onChange={onUpdate} query={query} />}
|
||||
{scenarioId === 'csv_file' && <CSVFileEditor onChange={onUpdate} query={query} />}
|
||||
{scenarioId === 'csv_content' && <CSVContentEditor onChange={onUpdate} query={query} />}
|
||||
|
83
public/app/plugins/datasource/testdata/components/SimulationQueryEditor.tsx
vendored
Normal file
83
public/app/plugins/datasource/testdata/components/SimulationQueryEditor.tsx
vendored
Normal file
@ -0,0 +1,83 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { InlineField, InlineFieldRow, InlineSwitch, Input, Label, Select } from '@grafana/ui';
|
||||
import React, { FormEvent } from 'react';
|
||||
import { EditorProps } from '../QueryEditor';
|
||||
import { SimulationQuery } from '../types';
|
||||
|
||||
export const SimulationQueryEditor = ({ onChange, query }: EditorProps) => {
|
||||
const simQuery = query.sim ?? ({} as SimulationQuery);
|
||||
const simKey = simQuery.key ?? ({} as typeof simQuery.key);
|
||||
const options = [
|
||||
{ label: 'Flight', value: 'flight' },
|
||||
{ label: 'Sine', value: 'sine' },
|
||||
];
|
||||
|
||||
const onUpdateKey = (key: typeof simQuery.key) => {
|
||||
onChange({ ...query, sim: { ...simQuery, key } });
|
||||
};
|
||||
|
||||
const onUIDChanged = (e: FormEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target as HTMLInputElement;
|
||||
onUpdateKey({ ...simKey, uid: value ?? undefined });
|
||||
};
|
||||
|
||||
const onTickChanged = (e: FormEvent<HTMLInputElement>) => {
|
||||
const tick = e.currentTarget.valueAsNumber;
|
||||
onUpdateKey({ ...simKey, tick });
|
||||
};
|
||||
|
||||
const onTypeChange = (v: SelectableValue<string>) => {
|
||||
onUpdateKey({ ...simKey, type: v.value! });
|
||||
};
|
||||
|
||||
const onToggleStream = () => {
|
||||
onChange({ ...query, sim: { ...simQuery, stream: !simQuery.stream } });
|
||||
};
|
||||
|
||||
const onToggleLast = () => {
|
||||
onChange({ ...query, sim: { ...simQuery, last: !simQuery.last } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
<InlineField labelWidth={14} label="Simulation" tooltip="">
|
||||
<Select
|
||||
menuShouldPortal
|
||||
options={options}
|
||||
value={options.find((item) => item.value === simQuery.key?.type)}
|
||||
onChange={onTypeChange}
|
||||
width={32}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
|
||||
<InlineFieldRow>
|
||||
<InlineField labelWidth={14} label="Stream" tooltip="connect to the live channel">
|
||||
<InlineSwitch value={Boolean(simQuery.stream)} onChange={onToggleStream} />
|
||||
</InlineField>
|
||||
|
||||
<InlineField label="Interval" tooltip="the rate a simulation will spit out events">
|
||||
<Input
|
||||
width={10}
|
||||
type="number"
|
||||
value={simKey.tick}
|
||||
onChange={onTickChanged}
|
||||
min={1 / 10}
|
||||
max={50}
|
||||
suffix="hz"
|
||||
/>
|
||||
</InlineField>
|
||||
|
||||
<InlineField label="Last" tooltip="Only return the last value">
|
||||
<Label>
|
||||
<InlineSwitch value={Boolean(simQuery.last)} onChange={onToggleLast} />
|
||||
</Label>
|
||||
</InlineField>
|
||||
<InlineField label="UID" tooltip="A UID will allow multiple simulations to run at the same time">
|
||||
<Input type="text" placeholder="optional" value={simQuery.key.uid} onChange={onUIDChanged} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
</>
|
||||
);
|
||||
};
|
13
public/app/plugins/datasource/testdata/types.ts
vendored
13
public/app/plugins/datasource/testdata/types.ts
vendored
@ -5,6 +5,7 @@ export interface Scenario {
|
||||
name: string;
|
||||
stringInput: string;
|
||||
description?: string;
|
||||
hideAliasField?: boolean;
|
||||
}
|
||||
|
||||
export interface TestDataQuery extends DataQuery {
|
||||
@ -13,6 +14,7 @@ export interface TestDataQuery extends DataQuery {
|
||||
stringInput?: string;
|
||||
stream?: StreamingQuery;
|
||||
pulseWave?: PulseWaveQuery;
|
||||
sim?: SimulationQuery;
|
||||
csvWave?: CSVWave[];
|
||||
labels?: string;
|
||||
lines?: number;
|
||||
@ -40,6 +42,17 @@ export interface StreamingQuery {
|
||||
url?: string; // the Fetch URL
|
||||
}
|
||||
|
||||
export interface SimulationQuery {
|
||||
key: {
|
||||
type: string;
|
||||
tick: number;
|
||||
uid?: string;
|
||||
};
|
||||
config?: Record<string, any>;
|
||||
stream?: boolean;
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
export interface PulseWaveQuery {
|
||||
timeStep?: number;
|
||||
onCount?: number;
|
||||
|
Loading…
Reference in New Issue
Block a user