Commit 2aafa398 by Ryan McKinley Committed by GitHub

Live: support real time measurements (alpha) (#28022)

* improve reduce transformer

* add measurment classes

* sync with new grafana measure format

* use address for live

* use plural in URL

* set the field name

* fix build

* find changes

* POST http to channel

* Yarn: Update lock file (#28014)

* Loki: Run instant query only in Explore (#27974)

* Run instant query only in Explore

* Replace forEach with for loop

* don't cast

* Docs: Fixed row display in table (#28031)

* Plugins: Let descendant plugins inherit their root's signature (#27970)

* plugins: Let descendant plugins inherit their root's signature

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Registry: Fix service shutdown mode trigger location (#28025)

* Add Alex Khomenko as member (#28032)

* show history

* fix confirm

* fix confirm

* add tests

* fix lint

* add more errors

* set values

* remove unrelated changes

* unrelated changes

* Update pkg/models/live.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Update pkg/models/live.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Update pkg/services/live/live.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Update pkg/services/live/pluginHandler.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Update pkg/services/live/pluginHandler.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Update pkg/services/live/pluginHandler.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* use measurments for testdata endpoints

* add live to testdata

* add live to testdata

* Update pkg/services/live/channel.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Apply suggestions from code review

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* update comment formats

* uprevert testdata

* Apply suggestions from code review

Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>

* Apply suggestions from code review

* CloudWatch: Add EC2CapacityReservations Namespace (#28309)

* API: Fix short URLs (#28300)

* API: Fix short URLs

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Chore: Add cloud-middleware as code owners (#28310)

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* SQLStore: Run tests as integration tests (#28265)

* sqlstore: Run tests as integration tests

* Truncate database instead of re-creating it on each test

* Fix test description

See https://github.com/grafana/grafana/pull/12129

* Fix lint issues

* Fix postgres dialect after review suggestion

* Rename and document functions after review suggestion

* Add periods

* Fix auto-increment value for mysql dialect

Co-authored-by: Emil Tullstedt <emil.tullstedt@grafana.com>

* Drone: Fix grafana-mixin linting (#28308)

* Drone: Fix Starlark script

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* grafana-mixin: Move build logic to scripts

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Drone: Use mixin scripts

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* CI build image: Install jsonnetfmt and mixtool

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Makefile: Print commands

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* should only ignore the file in the grafana mixin root folder (#28306)

Signed-off-by: bergquist <carl.bergquist@gmail.com>

* fix: for graph size not taking up full height or width

* Graph NG: fix toggling queries and extract Graph component from graph3 panel (#28290)

* Fix issue when data and config is not in sync

* Extract GraphNG component from graph panel and add some tests coverage

* Update packages/grafana-ui/src/components/uPlot/hooks.test.ts

* Update packages/grafana-ui/src/components/uPlot/hooks.test.ts

* Update packages/grafana-ui/src/components/uPlot/hooks.test.ts

* Fix grid color and annotations refresh

* Drone: Use ${DRONE_TAG} in release pipelines, since it should work (#28299)

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Explore: respect min_refresh_interval (#27988)

* Explore: respect min_refresh_interval

Fixes #27494

* fixup! Explore: respect min_refresh_interval

* fixup! Explore: respect min_refresh_interval

* UI: export defaultIntervals from refresh picker

* fixup! Explore: respect min_refresh_interval

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>

* Loki: Base maxDataPoints limits on query type (#28298)

* Base maxLines and maxDataPoints based on query type

* Allow overriding the limit to higher value

* Bump tree-kill from 1.2.1 to 1.2.2 (#27405)

Bumps [tree-kill](https://github.com/pkrumins/node-tree-kill) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/pkrumins/node-tree-kill/releases)
- [Commits](https://github.com/pkrumins/node-tree-kill/compare/v1.2.1...v1.2.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump handlebars from 4.4.3 to 4.7.6 (#27416)

Bumps [handlebars](https://github.com/wycats/handlebars.js) from 4.4.3 to 4.7.6.
- [Release notes](https://github.com/wycats/handlebars.js/releases)
- [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/master/release-notes.md)
- [Commits](https://github.com/wycats/handlebars.js/compare/v4.4.3...v4.7.6)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Build(deps): Bump http-proxy from 1.18.0 to 1.18.1 (#27507)

Bumps [http-proxy](https://github.com/http-party/node-http-proxy) from 1.18.0 to 1.18.1.
- [Release notes](https://github.com/http-party/node-http-proxy/releases)
- [Changelog](https://github.com/http-party/node-http-proxy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/http-party/node-http-proxy/compare/1.18.0...1.18.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Automation: Add backport github action (#28318)

* BackendSrv: Fixes queue countdown when unsubscribe is before response (#28323)

* GraphNG: Use AxisSide enum (#28320)

* IssueTriage: Needs more info automation and messages (#28137)

* IssueTriage: Needs more info automation and messages

* Updated

* Updated

* Updated wording

* SAML: IdP-initiated SSO docs (#28280)

* SAML: IdP-initiated SSO docs

* Update docs/sources/enterprise/saml.md

Co-authored-by: Emil Tullstedt <emil.tullstedt@grafana.com>

* Apply suggestions from code review

Co-authored-by: Emil Tullstedt <emil.tullstedt@grafana.com>

Co-authored-by: Emil Tullstedt <emil.tullstedt@grafana.com>

* Loki: Run instant query only when doing metric query (#28325)

* Run instant query only when doing metric query

* Update public/app/plugins/datasource/loki/datasource.ts

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

* Automation: Tweaks to more info message (#28332)

* AlertingNG: remove warn/crit from eval prototype (#28334)

and misc cleanup

* area/grafana/toolkit: update e2e docker image (#28335)

* add xvfb to image

* comment out toolkit inclusion

* add latest tag

* update packages for cypress

* cleanup script

* Update auth-proxy.md (#28339)

Fix a minor grammar mistake: 'handling' to 'handle'.

* Git: Create .gitattributes for windows line endings (#28340)

With this set, Windows users will have text files converted from Windows style line endings (\r\n) to Unix style line endings (\n) when they’re added to the repository.
https://www.edwardthomson.com/blog/git_for_windows_line_endings.html

* Docs: Add docs for valuepicker (#28327)

* Templating: Replace all '$tag' in tag values query (#28343)

* Docs: Add missing records from grafana-ui 7.2.1 CHANGELOG (#28302)

* Dashboard links: Places drop down list so it's always visible (#28330)

* calculating whether to place the list on the right or left edge of the parent

* change naming and add import of createRef

* Automation: Update backport github action trigger (#28352)

It seems like GitHub has solved the problem of running github actions on PRs from forks with access to secrets. 

https://github.blog/2020-08-03-github-actions-improvements-for-fork-and-pull-request-workflows/#improvements-for-public-repository-forks

If I change the event that triggers it to pull_request_target the action is run in the context of the base instead of the merged PR branch

* ColorSchemes: Adds more color schemes and text colors that depend on the background (#28305)

* Adding more color modes and text colors that depend on the background color

* Updates

* Updated

* Another big value fix

* Fixing unit tests

* Updated

* Updated test

* Update

* Updated

* Updated

* Updated

* Updated

* Added new demo dashboard

* Updated

* updated

* Updated

* Updateed

* added beta notice

* Fixed e2e test

* Fix typos

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* revert pseduo code

* apply feedback

* remove HTTP for now

* fix backend test

* change to datasource

* clear input for streams

* fix docs?

* consistent measure vs measurements

* better jsdocs

* fix a few jsdoc errors

* fix comment style

* Remove commented out code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Clean up code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Clean up code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Clean up code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Clean up code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Clean up code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Clean up code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Clean up code

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Update pkg/models/live.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Fix build

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* set the stringField

Co-authored-by: Torkel Ödegaard <torkel@grafana.org>
Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
Co-authored-by: ozhuang <ozhuang.95@gmail.com>
Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
Co-authored-by: Amos Law <ahlaw.dev@gmail.com>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
Co-authored-by: The Rock Guy <fabian.bracco@gvcgroup.com.au>
Co-authored-by: Sofia Papagiannaki <papagian@users.noreply.github.com>
Co-authored-by: Emil Tullstedt <emil.tullstedt@grafana.com>
Co-authored-by: Carl Bergquist <carl@grafana.com>
Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: Elliot Pryde <elliot.pryde@elliotpryde.com>
Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
Co-authored-by: Kyle Brandt <kyle@grafana.com>
Co-authored-by: Brian Gann <briangann@users.noreply.github.com>
Co-authored-by: J-F-Far <joel.f.farthing@gmail.com>
Co-authored-by: acoder77 <73009264+acoder77@users.noreply.github.com>
Co-authored-by: Peter Holmberg <peterholmberg@users.noreply.github.com>
Co-authored-by: Krzysztof Dąbrowski <krzysdabro@live.com>
Co-authored-by: maknik <mooniczkam@gmail.com>
parent a71eadf3
......@@ -2,7 +2,7 @@ import { map } from 'rxjs/operators';
import { DataTransformerID } from './ids';
import { DataTransformerInfo } from '../../types/transformations';
import { DataFrame, Field } from '../../types/dataFrame';
import { DataFrame, Field, TIME_SERIES_VALUE_FIELD_NAME } from '../../types/dataFrame';
import { ArrayVector } from '../../vector';
export enum ConcatenateFrameNameMode {
......@@ -73,7 +73,7 @@ export function concatenateFields(data: DataFrame[], opts: ConcatenateTransforme
} else if (opts.frameNameMode === ConcatenateFrameNameMode.Label) {
copy.labels = { ...f.labels };
copy.labels[frameNameLabel] = frame.name;
} else if (!copy.name || copy.name === 'Value') {
} else if (!copy.name || copy.name === TIME_SERIES_VALUE_FIELD_NAME) {
copy.name = frame.name;
} else {
copy.name = `${frame.name} · ${f.name}`;
......
import { SelectableValue } from './select';
import { Observable } from 'rxjs';
/**
......@@ -17,7 +16,7 @@ export enum LiveChannelScope {
/**
* @alpha -- experimental
*/
export interface LiveChannelConfig<TMessage = any> {
export interface LiveChannelConfig<TMessage = any, TController = any> {
/**
* The path definition. either static, or it may contain variables identifed with {varname}
*/
......@@ -29,11 +28,6 @@ export interface LiveChannelConfig<TMessage = any> {
description?: string;
/**
* When variables exist, this list will identify each one
*/
variables?: Array<SelectableValue<string>>;
/**
* The channel keeps track of who else is connected to the same channel
*/
hasPresence?: boolean;
......@@ -46,6 +40,9 @@ export interface LiveChannelConfig<TMessage = any> {
/** convert the raw stream message into a message that should be broadcast */
processMessage?: (msg: any) => TMessage;
/** some channels are managed by an explicit interface */
getController?: () => TController;
}
export enum LiveChannelConnectionState {
......
......@@ -6,6 +6,7 @@
export * from './services';
export * from './config';
export * from './types';
export * from './measurement';
export { loadPluginCss, SystemJS, PluginCssOptions } from './utils/plugin';
export { reportMetaAnalytics } from './utils/analytics';
export { DataSourceWithBackend, HealthCheckResult, HealthStatus } from './utils/DataSourceWithBackend';
......
import { MeasurementCollector } from './collector';
import { MeasurementAction } from './types';
describe('MeasurementCollector', () => {
it('should collect values', () => {
const collector = new MeasurementCollector();
collector.addBatch({
measurements: [
{
name: 'test',
labels: { host: 'a' },
time: 100,
values: {
f0: 0,
f1: 1,
f2: 'hello',
},
},
{
name: 'test',
labels: { host: 'b' },
time: 101,
values: {
f0: 0,
f1: 1,
f2: 'hello',
},
config: {
f2: {
unit: 'mph',
},
},
},
{
name: 'test',
time: 102,
labels: { host: 'a' }, // should append to first value
values: {
// note the missing values for f0/1
f2: 'world',
},
},
],
});
const frames = collector.getData();
expect(frames.length).toEqual(2);
expect(frames[0]).toMatchInlineSnapshot(`
Object {
"fields": Array [
Object {
"config": Object {},
"labels": undefined,
"name": "time",
"type": "time",
"values": Array [
100,
102,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "a",
},
"name": "f0",
"type": "number",
"values": Array [
0,
null,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "a",
},
"name": "f1",
"type": "number",
"values": Array [
1,
null,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "a",
},
"name": "f2",
"type": "string",
"values": Array [
"hello",
"world",
],
},
],
"meta": Object {
"custom": Object {
"labels": Object {
"host": "a",
},
},
},
"name": "test",
"refId": undefined,
}
`);
expect(frames[1]).toMatchInlineSnapshot(`
Object {
"fields": Array [
Object {
"config": Object {},
"labels": undefined,
"name": "time",
"type": "time",
"values": Array [
101,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "b",
},
"name": "f0",
"type": "number",
"values": Array [
0,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "b",
},
"name": "f1",
"type": "number",
"values": Array [
1,
],
},
Object {
"config": Object {
"unit": "mph",
},
"labels": Object {
"host": "b",
},
"name": "f2",
"type": "string",
"values": Array [
"hello",
],
},
],
"meta": Object {
"custom": Object {
"labels": Object {
"host": "b",
},
},
},
"name": "test",
"refId": undefined,
}
`);
collector.addBatch({
action: MeasurementAction.Replace,
measurements: [
{
name: 'test',
time: 105,
labels: { host: 'a' },
values: {
f1: 10,
},
},
],
});
const frames2 = collector.getData();
expect(frames2.length).toEqual(2);
expect(frames2[0].length).toEqual(1); // not three!
expect(frames2[0]).toMatchInlineSnapshot(`
Object {
"fields": Array [
Object {
"config": Object {},
"labels": undefined,
"name": "time",
"type": "time",
"values": Array [
105,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "a",
},
"name": "f0",
"type": "number",
"values": Array [
null,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "a",
},
"name": "f1",
"type": "number",
"values": Array [
10,
],
},
Object {
"config": Object {},
"labels": Object {
"host": "a",
},
"name": "f2",
"type": "string",
"values": Array [
null,
],
},
],
"meta": Object {
"custom": Object {
"labels": Object {
"host": "a",
},
},
},
"name": "test",
"refId": undefined,
}
`);
collector.addBatch({
action: MeasurementAction.Clear,
measurements: [],
});
expect(collector.getData().length).toEqual(0);
});
});
import {
CircularDataFrame,
Labels,
formatLabels,
FieldType,
DataFrame,
matchAllLabels,
parseLabels,
CircularVector,
ArrayVector,
} from '@grafana/data';
import { Measurement, MeasurementBatch, LiveMeasurements, MeasurementsQuery, MeasurementAction } from './types';
interface MeasurementCacheConfig {
append?: 'head' | 'tail';
capacity?: number;
}
/** This is a cache scoped to a the measurement name
*
* @alpha -- experimental
*/
export class MeasurementCache {
readonly frames: Record<string, CircularDataFrame> = {}; // key is the labels
constructor(public name: string, private config: MeasurementCacheConfig) {
if (!this.config) {
this.config = {
append: 'tail',
capacity: 600, // Default capacity 10min @ 1hz
};
}
}
getFrames(match?: Labels): DataFrame[] {
const frames = Object.values(this.frames);
if (!match) {
return frames;
}
return frames.filter(f => {
return matchAllLabels(match, f.meta?.custom?.labels);
});
}
addMeasurement(m: Measurement, action: MeasurementAction): DataFrame {
const key = m.labels ? formatLabels(m.labels) : '';
let frame = this.frames[key];
if (!frame) {
frame = new CircularDataFrame(this.config);
frame.name = this.name;
frame.addField({
name: 'time',
type: FieldType.time,
});
for (const [key, value] of Object.entries(m.values)) {
frame.addFieldFor(value, key).labels = m.labels;
}
frame.meta = {
custom: {
labels: m.labels,
},
};
this.frames[key] = frame;
}
// Clear existing values
if (action === MeasurementAction.Replace) {
for (const field of frame.fields) {
(field.values as ArrayVector).buffer.length = 0; // same buffer, but reset to empty length
}
}
// Add the timestamp
frame.values['time'].add(m.time || Date.now());
// Attach field config to the current fields
if (m.config) {
for (const [key, value] of Object.entries(m.config)) {
const f = frame.fields.find(f => f.name === key);
if (f) {
f.config = value;
}
}
}
// Append all values (a row)
for (const [key, value] of Object.entries(m.values)) {
let v = frame.values[key];
if (!v) {
const f = frame.addFieldFor(value, key);
f.labels = m.labels;
v = f.values;
}
v.add(value);
}
// Make sure all fields have the same length
frame.validate();
return frame;
}
}
/**
* @alpha -- experimental
*/
export class MeasurementCollector implements LiveMeasurements {
measurements = new Map<string, MeasurementCache>();
config: MeasurementCacheConfig = {
append: 'tail',
capacity: 600, // Default capacity 10min @ 1hz
};
//------------------------------------------------------
// Public
//------------------------------------------------------
getData(query?: MeasurementsQuery): DataFrame[] {
const { name, labels, fields } = query || {};
let data: DataFrame[] = [];
if (name) {
// for now we only match exact names
const m = this.measurements.get(name);
if (m) {
data = m.getFrames(labels);
}
} else {
for (const f of this.measurements.values()) {
data.push.apply(data, f.getFrames(labels));
}
}
if (fields && fields.length) {
let filtered: DataFrame[] = [];
for (const frame of data) {
const match = frame.fields.filter(f => fields.includes(f.name));
if (match.length > 0) {
filtered.push({ ...frame, fields: match }); // Copy the frame with fewer fields
}
}
}
return data;
}
getDistinctNames(): string[] {
return Object.keys(this.measurements);
}
getDistinctLabels(name: string): Labels[] {
const m = this.measurements.get(name);
if (m) {
return Object.keys(m.frames).map(k => parseLabels(k));
}
return [];
}
setCapacity(size: number) {
this.config.capacity = size;
// Now update all the circular buffers
for (const wrap of this.measurements.values()) {
for (const frame of Object.values(wrap.frames)) {
for (const field of frame.fields) {
(field.values as CircularVector).setCapacity(size);
}
}
}
}
getCapacity() {
return this.config.capacity!;
}
clear() {
this.measurements.clear();
}
//------------------------------------------------------
// Collector
//------------------------------------------------------
addBatch = (batch: MeasurementBatch) => {
let action = batch.action ?? MeasurementAction.Append;
if (action === MeasurementAction.Clear) {
this.measurements.clear();
action = MeasurementAction.Append;
}
// Change the local buffer size
if (batch.capacity && batch.capacity !== this.config.capacity) {
this.setCapacity(batch.capacity);
}
for (const measure of batch.measurements) {
const name = measure.name || '';
let m = this.measurements.get(name);
if (!m) {
m = new MeasurementCache(name, this.config);
this.measurements.set(name, m);
}
if (measure.values) {
m.addMeasurement(measure, action);
} else {
console.log('invalid measurement', measure);
}
}
return this;
};
}
export * from './types';
export * from './collector';
export * from './query';
import {
DataQueryResponse,
isLiveChannelMessageEvent,
isLiveChannelStatusEvent,
isValidLiveChannelAddress,
LiveChannelAddress,
LoadingState,
} from '@grafana/data';
import { LiveMeasurements, MeasurementsQuery } from './types';
import { getGrafanaLiveSrv } from '../services/live';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
/**
* @alpha -- experimental
*/
export function getLiveMeasurements(addr: LiveChannelAddress): LiveMeasurements | undefined {
if (!isValidLiveChannelAddress(addr)) {
return undefined;
}
const live = getGrafanaLiveSrv();
if (!live) {
return undefined;
}
const channel = live.getChannel<LiveMeasurements>(addr);
const getController = channel?.config?.getController;
return getController ? getController() : undefined;
}
/**
* When you know the stream will be managed measurements
*
* @alpha -- experimental
*/
export function getLiveMeasurementsObserver(
addr: LiveChannelAddress,
requestId: string,
query?: MeasurementsQuery
): Observable<DataQueryResponse> {
const rsp: DataQueryResponse = { data: [] };
if (!addr || !addr.path) {
return of(rsp); // Address not configured yet
}
const live = getGrafanaLiveSrv();
if (!live) {
// This will only happen with the feature flag is not enabled
rsp.error = { message: 'Grafana live is not initalized' };
return of(rsp);
}
rsp.key = requestId;
return live
.getChannel<LiveMeasurements>(addr)
.getStream()
.pipe(
map(evt => {
if (isLiveChannelMessageEvent(evt)) {
rsp.data = evt.message.getData(query);
if (!rsp.data.length) {
// ?? skip when data is empty ???
}
delete rsp.error;
rsp.state = LoadingState.Streaming;
} else if (isLiveChannelStatusEvent(evt)) {
if (evt.error != null) {
rsp.error = rsp.error;
rsp.state = LoadingState.Error;
}
}
return { ...rsp }; // send event on all status messages
})
);
}
import { DataFrame, Labels, FieldConfig } from '@grafana/data';
/**
* the raw channel events are batches of Measurements
*
* @alpha -- experimental
*/
export interface Measurement {
name: string;
time?: number; // Missing will use the browser time
values: Record<string, any>;
config?: Record<string, FieldConfig>;
labels?: Labels;
}
/**
* @alpha -- experimental
*/
export enum MeasurementAction {
/** The measurements will be added to the client buffer */
Append = 'append',
/** The measurements will replace the client buffer */
Replace = 'replace',
/** All measurements will be removed from the client buffer before processing */
Clear = 'clear',
}
/**
* List of Measurements sent in a batch
*
* @alpha -- experimental
*/
export interface MeasurementBatch {
/**
* The default action is to append values to the client buffer
*/
action?: MeasurementAction;
/**
* List of measurements to process
*/
measurements: Measurement[];
/**
* This will set the capacity on the client buffer for everything
* in the measurement channel
*/
capacity?: number;
}
/**
* @alpha -- experimental
*/
export interface MeasurementsQuery {
name?: string;
labels?: Labels;
fields?: string[]; // only include the fields with these names
}
/**
* Channels that receive Measurements can collect them into frames
*
* @alpha -- experimental
*/
export interface LiveMeasurements {
getData(query?: MeasurementsQuery): DataFrame[];
getDistinctNames(): string[];
getDistinctLabels(name: string): Labels[];
setCapacity(size: number): void;
getCapacity(): number;
}
......@@ -6,6 +6,9 @@
*/
import { UrlQueryMap } from '@grafana/data';
/**
* @public
*/
export interface LocationUpdate {
/**
* Target path where you automatically wants to navigate the user.
......
......@@ -39,7 +39,7 @@ export const setGrafanaLiveSrv = (instance: GrafanaLiveSrv) => {
};
/**
* Used to retrieve the {@link GrafanaLiveSrv} that allows you to subscribe to
* Used to retrieve the GrafanaLiveSrv that allows you to subscribe to
* server side events and streams
*
* @alpha -- experimental
......
......@@ -2,7 +2,7 @@ package models
import "github.com/centrifugal/centrifuge"
// ChannelPublisher writes data into a channel
// ChannelPublisher writes data into a channel. Note that pemissions are not checked.
type ChannelPublisher func(channel string, data []byte) error
// ChannelHandler defines the core channel behavior
......@@ -17,9 +17,10 @@ type ChannelHandler interface {
OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error)
}
// ChannelHandlerProvider -- this should be implemented by any core feature
type ChannelHandlerProvider interface {
// This is called fast and often -- it must be synchrnozed
// ChannelHandlerFactory should be implemented by all core features.
type ChannelHandlerFactory interface {
// GetHandlerForPath gets a ChannelHandler for a path.
// This is called fast and often -- it must be synchronized
GetHandlerForPath(path string) (ChannelHandler, error)
}
......
package models
import "github.com/grafana/grafana-plugin-sdk-go/data"
// NOTE:
// this likely should go in the Plugin SDK since it will be useful from plugins
// Measurement is a single measurement value.
type Measurement struct {
// Name of the measurement.
Name string `json:"name,omitempty"`
// Time is the measurement time. Units are usually ms, but depends on the channel
Time int64 `json:"time,omitempty"`
// Values is the measurement's values. The value type is typically number or string.
Values map[string]interface{} `json:"values,omitempty"`
// Config is an optional list of field configs.
Config map[string]data.FieldConfig `json:"config,omitempty"`
// Labels are applied to all values.
Labels map[string]string `json:"labels,omitempty"`
}
// MeasurementAction defines what should happen when you send a list of measurements.
type MeasurementAction string
const (
// MeasurementActionAppend means new values should be added to a client buffer. This is the default action
MeasurementActionAppend MeasurementAction = "append"
// MeasurementActionReplace means new values should replace any existing values.
MeasurementActionReplace MeasurementAction = "replace"
// MeasurementActionClear means all existing values should be remoed before adding.
MeasurementActionClear MeasurementAction = "clear"
)
// MeasurementBatch is a collection of measurements all sent at once.
type MeasurementBatch struct {
// Action is the action in question, the default is append.
Action MeasurementAction `json:"action,omitempty"`
// Measurements is the array of measurements.
Measurements []Measurement `json:"measurements,omitempty"`
// Capacity is the suggested size of the client buffer
Capacity int64 `json:"capacity,omitempty"`
}
package live
import (
"fmt"
"strings"
)
// ChannelIdentifier is the channel id split by parts
type ChannelIdentifier struct {
Scope string // grafana, ds, or plugin
Namespace string // feature, id, or name
Path string // path within the channel handler
// ChannelAddress is the channel ID split by parts.
type ChannelAddress struct {
// Scope is "grafana", "ds", or "plugin".
Scope string `json:"scope,omitempty"`
// Namespace meaning depends on the scope.
// * when grafana, namespace is a "feature"
// * when ds, namespace is the datasource id
// * when plugin, namespace is the plugin name
Namespace string `json:"namespace,omitempty"`
// Within each namespace, the handler can process the path as needed.
Path string `json:"path,omitempty"`
}
// ParseChannelIdentifier parses the parts from a channel id:
// ${scope} / ${namespace} / ${path}
func ParseChannelIdentifier(id string) (ChannelIdentifier, error) {
// ParseChannelAddress parses the parts from a channel ID:
// ${scope} / ${namespace} / ${path}.
func ParseChannelAddress(id string) ChannelAddress {
addr := ChannelAddress{}
parts := strings.SplitN(id, "/", 3)
if len(parts) == 3 {
return ChannelIdentifier{
Scope: parts[0],
Namespace: parts[1],
Path: parts[2],
}, nil
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 ChannelIdentifier{}, fmt.Errorf("Invalid channel id: %s", id)
return addr
}
// IsValid checks if all parts of the address are valid.
func (ca *ChannelAddress) IsValid() bool {
return ca.Scope != "" && ca.Namespace != "" && ca.Path != ""
}
// ToChannelID converts this to a single string.
func (ca *ChannelAddress) ToChannelID() string {
return ca.Scope + "/" + ca.Namespace + "/" + ca.Path
}
......@@ -4,27 +4,25 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
)
func TestParseChannelIdentifier(t *testing.T) {
ident, err := ParseChannelIdentifier("aaa/bbb/ccc/ddd")
if err != nil {
t.FailNow()
}
func TestParseChannelAddress_Valid(t *testing.T) {
addr := ParseChannelAddress("aaa/bbb/ccc/ddd")
require.True(t, addr.IsValid())
ex := ChannelIdentifier{
ex := ChannelAddress{
Scope: "aaa",
Namespace: "bbb",
Path: "ccc/ddd",
}
if diff := cmp.Diff(ident, ex); diff != "" {
if diff := cmp.Diff(addr, ex); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
}
// Check an invalid identifier
_, err = ParseChannelIdentifier("aaa/bbb")
if err == nil {
t.FailNow()
}
func TestParseChannelAddress_Invalid(t *testing.T) {
addr := ParseChannelAddress("aaa/bbb")
require.False(t, addr.IsValid())
}
......@@ -6,27 +6,28 @@ import (
)
// BroadcastRunner will simply broadcast all events to `grafana/broadcast/*` channels
// This makes no assumptions about the shape of the data and will broadcast it to anyone listening
type BroadcastRunner struct{}
// This assumes that data is a JSON object
type BroadcastRunner struct {
}
// GetHandlerForPath called on init
func (g *BroadcastRunner) GetHandlerForPath(path string) (models.ChannelHandler, error) {
return g, nil // for now all channels share config
func (b *BroadcastRunner) GetHandlerForPath(path string) (models.ChannelHandler, error) {
return b, nil // for now all channels share config
}
// GetChannelOptions called fast and often
func (g *BroadcastRunner) GetChannelOptions(id string) centrifuge.ChannelOptions {
func (b *BroadcastRunner) GetChannelOptions(id string) centrifuge.ChannelOptions {
return centrifuge.ChannelOptions{}
}
// OnSubscribe for now allows anyone to subscribe to any dashboard
func (g *BroadcastRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error {
func (b *BroadcastRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error {
// anyone can subscribe
return nil
}
// OnPublish called when an event is received from the websocket
func (g *BroadcastRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) {
func (b *BroadcastRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) {
// expect the data to be the right shape?
return e.Data, nil
}
......@@ -17,14 +17,7 @@ type dashboardEvent struct {
// DashboardHandler manages all the `grafana/dashboard/*` channels
type DashboardHandler struct {
publisher models.ChannelPublisher
}
// CreateDashboardHandler Initialize a dashboard handler
func CreateDashboardHandler(p models.ChannelPublisher) DashboardHandler {
return DashboardHandler{
publisher: p,
}
Publisher models.ChannelPublisher
}
// GetHandlerForPath called on init
......@@ -58,7 +51,7 @@ func (g *DashboardHandler) publish(event dashboardEvent) error {
if err != nil {
return err
}
return g.publisher("grafana/dashboard/"+event.UID, msg)
return g.Publisher("grafana/dashboard/"+event.UID, msg)
}
// DashboardSaved will broadcast to all connected dashboards
......
package features
import (
"github.com/centrifugal/centrifuge"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
)
var (
logger = log.New("live.features") // scoped to all features?
)
// MeasurementsRunner will simply broadcast all events to `grafana/broadcast/*` channels.
// This makes no assumptions about the shape of the data and will broadcast it to anyone listening
type MeasurementsRunner struct {
}
// GetHandlerForPath gets the handler for a path.
// It's called on init.
func (m *MeasurementsRunner) GetHandlerForPath(path string) (models.ChannelHandler, error) {
return m, nil // for now all channels share config
}
// GetChannelOptions gets channel options.
// It gets called fast and often.
func (m *MeasurementsRunner) GetChannelOptions(id string) centrifuge.ChannelOptions {
return centrifuge.ChannelOptions{}
}
// OnSubscribe for now allows anyone to subscribe to any dashboard.
func (m *MeasurementsRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error {
// anyone can subscribe
return nil
}
// OnPublish is called when an event is received from the websocket.
func (m *MeasurementsRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) {
// currently generic... but should be stricter
// logger.Debug("Measurements runner got event on channel", "channel", e.Channel)
return e.Data, nil
}
......@@ -7,48 +7,43 @@ import (
"time"
"github.com/centrifugal/centrifuge"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/models"
)
// TestdataRunner manages all the `grafana/dashboard/*` channels
type testdataRunner struct {
// testDataRunner manages all the `grafana/dashboard/*` channels.
type testDataRunner struct {
publisher models.ChannelPublisher
running bool
speedMillis int
dropPercent float64
channel string
name string
}
// TestdataSupplier manages all the `grafana/testdata/*` channels
type TestdataSupplier struct {
publisher models.ChannelPublisher
// TestDataSupplier manages all the `grafana/testdata/*` channels.
type TestDataSupplier struct {
Publisher models.ChannelPublisher
}
// CreateTestdataSupplier Initialize a dashboard handler
func CreateTestdataSupplier(p models.ChannelPublisher) TestdataSupplier {
return TestdataSupplier{
publisher: p,
}
}
// GetHandlerForPath called on init
func (g *TestdataSupplier) GetHandlerForPath(path string) (models.ChannelHandler, error) {
// GetHandlerForPath gets the channel handler for a path.
// Called on init.
func (g *TestDataSupplier) GetHandlerForPath(path string) (models.ChannelHandler, error) {
channel := "grafana/testdata/" + path
if path == "random-2s-stream" {
return &testdataRunner{
publisher: g.publisher,
return &testDataRunner{
publisher: g.Publisher,
running: false,
speedMillis: 2000,
dropPercent: 0,
channel: channel,
name: path,
}, nil
}
if path == "random-flakey-stream" {
return &testdataRunner{
publisher: g.publisher,
return &testDataRunner{
publisher: g.Publisher,
running: false,
speedMillis: 400,
dropPercent: .6,
......@@ -59,13 +54,14 @@ func (g *TestdataSupplier) GetHandlerForPath(path string) (models.ChannelHandler
return nil, fmt.Errorf("unknown channel")
}
// GetChannelOptions called fast and often
func (g *testdataRunner) GetChannelOptions(id string) centrifuge.ChannelOptions {
// GetChannelOptions gets channel options.
// Called fast and often.
func (g *testDataRunner) GetChannelOptions(id string) centrifuge.ChannelOptions {
return centrifuge.ChannelOptions{}
}
// OnSubscribe for now allows anyone to subscribe to any dashboard
func (g *testdataRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error {
// OnSubscribe for now allows anyone to subscribe to any dashboard.
func (g *testDataRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error {
if !g.running {
g.running = true
......@@ -77,26 +73,26 @@ func (g *testdataRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.Subscrib
return nil
}
// OnPublish called when an event is received from the websocket
func (g *testdataRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) {
// OnPublish is called when an event is received from the websocket.
func (g *testDataRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) {
return nil, fmt.Errorf("can not publish to testdata")
}
type randomWalkMessage struct {
Time int64
Value float64
Min float64
Max float64
}
// RunRandomCSV just for an example
func (g *testdataRunner) runRandomCSV() {
// runRandomCSV is just for an example.
func (g *testDataRunner) runRandomCSV() {
spread := 50.0
walker := rand.Float64() * 100
ticker := time.NewTicker(time.Duration(g.speedMillis) * time.Millisecond)
line := randomWalkMessage{}
measurement := models.Measurement{
Name: g.name,
Time: 0,
Values: make(map[string]interface{}, 5),
}
msg := models.MeasurementBatch{
Measurements: []models.Measurement{measurement}, // always a single measurement
}
for t := range ticker.C {
if rand.Float64() <= g.dropPercent {
......@@ -105,12 +101,12 @@ func (g *testdataRunner) runRandomCSV() {
delta := rand.Float64() - 0.5
walker += delta
line.Time = t.UnixNano() / int64(time.Millisecond)
line.Value = walker
line.Min = walker - ((rand.Float64() * spread) + 0.01)
line.Max = walker + ((rand.Float64() * spread) + 0.01)
measurement.Time = t.UnixNano() / int64(time.Millisecond)
measurement.Values["value"] = walker
measurement.Values["min"] = walker - ((rand.Float64() * spread) + 0.01)
measurement.Values["max"] = walker + ((rand.Float64() * spread) + 0.01)
bytes, err := json.Marshal(&line)
bytes, err := json.Marshal(&msg)
if err != nil {
logger.Warn("unable to marshal line", "error", err)
continue
......@@ -118,7 +114,7 @@ func (g *testdataRunner) runRandomCSV() {
err = g.publisher(g.channel, bytes)
if err != nil {
logger.Warn("write", "channel", g.channel, "line", line)
logger.Warn("write", "channel", g.channel, "measurement", measurement)
}
}
}
......@@ -20,7 +20,7 @@ var (
// CoreGrafanaScope list of core features
type CoreGrafanaScope struct {
Features map[string]models.ChannelHandlerProvider
Features map[string]models.ChannelHandlerFactory
// The generic service to advertise dashboard changes
Dashboards models.DashboardActivityChannel
......@@ -47,7 +47,7 @@ func InitializeBroker() (*GrafanaLive, error) {
channels: make(map[string]models.ChannelHandler),
channelsMu: sync.RWMutex{},
GrafanaScope: CoreGrafanaScope{
Features: make(map[string]models.ChannelHandlerProvider),
Features: make(map[string]models.ChannelHandlerFactory),
},
}
......@@ -83,13 +83,17 @@ func InitializeBroker() (*GrafanaLive, error) {
glive.node = node
// Initialize the main features
dash := features.CreateDashboardHandler(glive.Publish)
tds := features.CreateTestdataSupplier(glive.Publish)
dash := &features.DashboardHandler{
Publisher: glive.Publish,
}
glive.GrafanaScope.Dashboards = &dash
glive.GrafanaScope.Features["dashboard"] = &dash
glive.GrafanaScope.Features["testdata"] = &tds
glive.GrafanaScope.Dashboards = dash
glive.GrafanaScope.Features["dashboard"] = dash
glive.GrafanaScope.Features["testdata"] = &features.TestDataSupplier{
Publisher: glive.Publish,
}
glive.GrafanaScope.Features["broadcast"] = &features.BroadcastRunner{}
glive.GrafanaScope.Features["measurements"] = &features.MeasurementsRunner{}
// Set ConnectHandler called when client successfully connected to Node. Your code
// inside handler must be synchronized since it will be called concurrently from
......@@ -232,11 +236,11 @@ func (g *GrafanaLive) GetChannelHandler(channel string) (models.ChannelHandler,
}
// Parse the identifier ${scope}/${namespace}/${path}
id, err := ParseChannelIdentifier(channel)
if err != nil {
return nil, err
addr := ParseChannelAddress(channel)
if !addr.IsValid() {
return nil, fmt.Errorf("invalid channel: %q", channel)
}
logger.Info("initChannel", "channel", channel, "id", id)
logger.Info("initChannel", "channel", channel, "address", addr)
g.channelsMu.Lock()
defer g.channelsMu.Unlock()
......@@ -245,39 +249,48 @@ func (g *GrafanaLive) GetChannelHandler(channel string) (models.ChannelHandler,
return c, nil
}
c, err = g.initChannel(id)
getter, err := g.GetChannelHandlerFactory(addr.Scope, addr.Namespace)
if err != nil {
return nil, err
}
// First access will initialize
c, err = getter.GetHandlerForPath(addr.Path)
if err != nil {
return nil, err
}
g.channels[channel] = c
return c, nil
}
func (g *GrafanaLive) initChannel(id ChannelIdentifier) (models.ChannelHandler, error) {
if id.Scope == "grafana" {
p, ok := g.GrafanaScope.Features[id.Namespace]
// GetChannelHandlerFactory gets a ChannelHandlerFactory for a namespace.
// It gives threadsafe access to the channel.
func (g *GrafanaLive) GetChannelHandlerFactory(scope string, name string) (models.ChannelHandlerFactory, error) {
if scope == "grafana" {
p, ok := g.GrafanaScope.Features[name]
if ok {
return p.GetHandlerForPath(id.Path)
return p, nil
}
return nil, fmt.Errorf("Unknown feature: %s", id.Namespace)
return nil, fmt.Errorf("unknown feature: %q", name)
}
if id.Scope == "ds" {
return nil, fmt.Errorf("todo... look up datasource: %s", id.Namespace)
if scope == "ds" {
return nil, fmt.Errorf("todo... look up datasource: %q", name)
}
if id.Scope == "plugin" {
p, ok := plugins.Plugins[id.Namespace]
if scope == "plugin" {
p, ok := plugins.Plugins[name]
if ok {
h := &PluginHandler{
Plugin: p,
}
return h.GetHandlerForPath(id.Path)
return h, nil
}
return nil, fmt.Errorf("unknown plugin: %s", id.Namespace)
return nil, fmt.Errorf("unknown plugin: %q", name)
}
return nil, fmt.Errorf("invalid scope: %s", id.Scope)
return nil, fmt.Errorf("invalid scope: %q", scope)
}
// Publish sends the data to the channel without checking permissions etc
......
......@@ -260,6 +260,15 @@ func init() {
})
registerScenario(&Scenario{
Id: "live",
Name: "Grafana Live",
Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
// Real work is in javascript client
return tsdb.NewQueryResult()
},
})
registerScenario(&Scenario{
Id: "grafana_api",
Name: "Grafana API",
Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
......
......@@ -168,7 +168,6 @@ export function getDashboardChannelsFeature(): CoreGrafanaLiveFeature {
const dashboardConfig: LiveChannelConfig = {
path: '${uid}',
description: 'Dashboard change events',
variables: [{ value: 'uid', label: '${uid}', description: 'unique id for a dashboard' }],
hasPresence: true,
canPublish: () => true,
};
......
import { LiveChannelConfig } from '@grafana/data';
import { MeasurementCollector } from '@grafana/runtime';
import { getDashboardChannelsFeature } from './dashboard/dashboardWatcher';
import { LiveMeasurementsSupport } from './measurements/measurementsSupport';
import { grafanaLiveCoreFeatures } from './scopes';
export function registerLiveFeatures() {
const channels = [
const random2s = new MeasurementCollector();
const randomFlakey = new MeasurementCollector();
const channels: LiveChannelConfig[] = [
{
path: 'random-2s-stream',
description: 'Random stream with points every 2s',
getController: () => random2s,
processMessage: random2s.addBatch,
},
{
path: 'random-flakey-stream',
description: 'Random stream with flakey data points',
getController: () => randomFlakey,
processMessage: randomFlakey.addBatch,
},
];
......@@ -39,7 +47,13 @@ export function registerLiveFeatures() {
},
getSupportedPaths: () => [broadcastConfig],
},
description: 'Broadcast will send/recieve any events on a channel',
description: 'Broadcast will send/receive any JSON object in a channel',
});
grafanaLiveCoreFeatures.register({
name: 'measurements',
support: new LiveMeasurementsSupport(),
description: 'These channels listen for measurements and produce DataFrames',
});
// dashboard/*
......
import { LiveChannelSupport, LiveChannelConfig } from '@grafana/data';
import { MeasurementCollector } from '@grafana/runtime';
interface MeasurementChannel {
config: LiveChannelConfig;
collector: MeasurementCollector;
}
export class LiveMeasurementsSupport implements LiveChannelSupport {
private cache: Record<string, MeasurementChannel> = {};
/**
* Get the channel handler for the path, or throw an error if invalid
*/
getChannelConfig(path: string): LiveChannelConfig | undefined {
let c = this.cache[path];
if (!c) {
// Create a new cache for each path
const collector = new MeasurementCollector();
c = this.cache[path] = {
collector,
config: {
path,
processMessage: collector.addBatch, // << this converts the stream from a single event to the whole cache
getController: () => collector,
canPublish: () => true,
},
};
}
return c.config;
}
/**
* Return a list of supported channels
*/
getSupportedPaths(): LiveChannelConfig[] {
// this should ask the server what channels it has seen
return [];
}
}
import defaults from 'lodash/defaults';
import React, { PureComponent } from 'react';
import { InlineField, Select } from '@grafana/ui';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { InlineField, Select, FeatureInfoBox } from '@grafana/ui';
import { QueryEditorProps, SelectableValue, LiveChannelScope, FeatureState } from '@grafana/data';
import { getLiveMeasurements, LiveMeasurements } from '@grafana/runtime';
import { GrafanaDatasource } from '../datasource';
import { defaultQuery, GrafanaQuery, GrafanaQueryType } from '../types';
type Props = QueryEditorProps<GrafanaDatasource, GrafanaQuery>;
const labelWidth = 12;
export class QueryEditor extends PureComponent<Props> {
queryTypes: Array<SelectableValue<GrafanaQueryType>> = [
{
......@@ -15,6 +18,11 @@ export class QueryEditor extends PureComponent<Props> {
value: GrafanaQueryType.RandomWalk,
description: 'Random signal within the selected time rage',
},
{
label: 'Live Measurements',
value: GrafanaQueryType.LiveMeasurements,
description: 'Stream real-time measurements from Grafana',
},
];
onQueryTypeChange = (sel: SelectableValue<GrafanaQueryType>) => {
......@@ -23,18 +31,137 @@ export class QueryEditor extends PureComponent<Props> {
onRunQuery();
};
onChannelChange = (sel: SelectableValue<string>) => {
const { onChange, query, onRunQuery } = this.props;
onChange({ ...query, channel: sel?.value });
onRunQuery();
};
onMeasurementNameChanged = (sel: SelectableValue<string>) => {
const { onChange, query, onRunQuery } = this.props;
onChange({
...query,
measurements: {
...query.measurements,
name: sel?.value,
},
});
onRunQuery();
};
renderMeasurementsQuery() {
let { channel, measurements } = this.props.query;
const channels: Array<SelectableValue<string>> = [];
let currentChannel = channels.find(c => c.value === channel);
if (channel && !currentChannel) {
currentChannel = {
value: channel,
label: channel,
description: `Connected to ${channel}`,
};
channels.push(currentChannel);
}
if (!measurements) {
measurements = {};
}
const names: Array<SelectableValue<string>> = [
{ value: '', label: 'All measurements', description: 'Show every measurement streamed to this channel' },
];
let info: LiveMeasurements | undefined = undefined;
if (channel) {
info = getLiveMeasurements({
scope: LiveChannelScope.Grafana,
namespace: 'measurements',
path: channel,
});
let foundName = false;
if (info) {
for (const name of info.getDistinctNames()) {
names.push({
value: name,
label: name,
});
if (name === measurements.name) {
foundName = true;
}
}
} else {
console.log('NO INFO for', channel);
}
if (measurements.name && !foundName) {
names.push({
label: measurements.name,
value: measurements.name,
description: `Frames with name ${measurements.name}`,
});
}
}
return (
<>
<div className="gf-form">
<InlineField label="Channel" grow={true} labelWidth={labelWidth}>
<Select
options={channels}
value={currentChannel || ''}
onChange={this.onChannelChange}
allowCustomValue={true}
backspaceRemovesValue={true}
placeholder="Select measurements channel"
isClearable={true}
noOptionsMessage="Enter channel name"
formatCreateLabel={(input: string) => `Conncet to: ${input}`}
/>
</InlineField>
</div>
{channel && (
<div className="gf-form">
<InlineField label="Measurement" grow={true} labelWidth={labelWidth}>
<Select
options={names}
value={names.find(v => v.value === measurements?.name) || names[0]}
onChange={this.onMeasurementNameChanged}
allowCustomValue={true}
backspaceRemovesValue={true}
placeholder="Filter by name"
isClearable={true}
noOptionsMessage="Filter by name"
formatCreateLabel={(input: string) => `Show: ${input}`}
isSearchable={true}
/>
</InlineField>
</div>
)}
<FeatureInfoBox title="Grafana Live - Measurements" featureState={FeatureState.alpha}>
<p>
This supports real-time event streams in Grafana core. This feature is under heavy development. Expect the
interfaces and structures to change as this becomes more production ready.
</p>
</FeatureInfoBox>
</>
);
}
render() {
const query = defaults(this.props.query, defaultQuery);
return (
<div className="gf-form">
<InlineField label="Query type" grow={true}>
<Select
options={this.queryTypes}
value={this.queryTypes.find(v => v.value === query.queryType) || this.queryTypes[0]}
onChange={this.onQueryTypeChange}
/>
</InlineField>
</div>
<>
<div className="gf-form">
<InlineField label="Query type" grow={true} labelWidth={labelWidth}>
<Select
options={this.queryTypes}
value={this.queryTypes.find(v => v.value === query.queryType) || this.queryTypes[0]}
onChange={this.onQueryTypeChange}
/>
</InlineField>
</div>
{query.queryType === GrafanaQueryType.LiveMeasurements && this.renderMeasurementsQuery()}
</>
);
}
}
......@@ -7,6 +7,11 @@ import { GrafanaQuery, GrafanaAnnotationQuery, GrafanaAnnotationType } from './t
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
getBackendSrv: () => backendSrv,
getTemplateSrv: () => ({
replace: (val: string) => {
return val.replace('$var2', 'replaced__delimiter__replaced2').replace('$var', 'replaced');
},
}),
}));
describe('grafana data source', () => {
......@@ -25,13 +30,7 @@ describe('grafana data source', () => {
return Promise.resolve([]);
});
const templateSrvStub = {
replace: (val: string) => {
return val.replace('$var2', 'replaced__delimiter__replaced2').replace('$var', 'replaced');
},
};
ds = new GrafanaDatasource({} as DataSourceInstanceSettings, templateSrvStub as any);
ds = new GrafanaDatasource({} as DataSourceInstanceSettings);
});
describe('with tags that have template variables', () => {
......
......@@ -6,47 +6,55 @@ import {
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
LiveChannelScope,
} from '@grafana/data';
import { GrafanaQuery, GrafanaAnnotationQuery, GrafanaAnnotationType } from './types';
import { getBackendSrv, getTemplateSrv, TemplateSrv, toDataQueryResponse } from '@grafana/runtime';
import { GrafanaQuery, GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQueryType } from './types';
import { getBackendSrv, getTemplateSrv, toDataQueryResponse, getLiveMeasurementsObserver } from '@grafana/runtime';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
let counter = 100;
export class GrafanaDatasource extends DataSourceApi<GrafanaQuery> {
constructor(
instanceSettings: DataSourceInstanceSettings,
private readonly templateSrv: TemplateSrv = getTemplateSrv()
) {
constructor(instanceSettings: DataSourceInstanceSettings) {
super(instanceSettings);
}
query(request: DataQueryRequest<GrafanaQuery>): Observable<DataQueryResponse> {
const { intervalMs, maxDataPoints, range, requestId } = request;
// Yes, this implementaiton ignores multiple targets! But that matches exisitng behavior
const params: Record<string, any> = {
intervalMs,
maxDataPoints,
from: range.from.valueOf(),
to: range.to.valueOf(),
};
return getBackendSrv()
.fetch({
url: '/api/tsdb/testdata/random-walk',
method: 'GET',
params,
requestId,
})
.pipe(
map((rsp: any) => {
return toDataQueryResponse(rsp);
}),
catchError(err => {
return of(toDataQueryResponse(err));
})
);
const queries: Array<Observable<DataQueryResponse>> = [];
for (const target of request.targets) {
if (target.hide) {
continue;
}
if (target.queryType === GrafanaQueryType.LiveMeasurements) {
const { channel, measurements } = target;
if (channel) {
queries.push(
getLiveMeasurementsObserver(
{
scope: LiveChannelScope.Grafana,
namespace: 'measurements',
path: channel,
},
`${request.requestId}.${counter++}`,
measurements
)
);
}
} else {
queries.push(getRandomWalk(request));
}
}
// With a single query just return the results
if (queries.length === 1) {
return queries[0];
}
if (queries.length > 1) {
// HELP!!!
return queries[0];
}
return of(); // nothing
}
metricFindQuery(options: any) {
......@@ -54,6 +62,7 @@ export class GrafanaDatasource extends DataSourceApi<GrafanaQuery> {
}
annotationQuery(options: AnnotationQueryRequest<GrafanaQuery>): Promise<AnnotationEvent[]> {
const templateSrv = getTemplateSrv();
const annotation = (options.annotation as unknown) as GrafanaAnnotationQuery;
const params: any = {
from: options.range.from.valueOf(),
......@@ -80,7 +89,7 @@ export class GrafanaDatasource extends DataSourceApi<GrafanaQuery> {
const delimiter = '__delimiter__';
const tags = [];
for (const t of params.tags) {
const renderedValues = this.templateSrv.replace(t, {}, (value: any) => {
const renderedValues = templateSrv.replace(t, {}, (value: any) => {
if (typeof value === 'string') {
return value;
}
......@@ -105,3 +114,32 @@ export class GrafanaDatasource extends DataSourceApi<GrafanaQuery> {
return Promise.resolve();
}
}
// Note that the query does not actually matter
function getRandomWalk(request: DataQueryRequest): Observable<DataQueryResponse> {
const { intervalMs, maxDataPoints, range, requestId } = request;
// Yes, this implementation ignores multiple targets! But that matches existing behavior
const params: Record<string, any> = {
intervalMs,
maxDataPoints,
from: range.from.valueOf(),
to: range.to.valueOf(),
};
return getBackendSrv()
.fetch({
url: '/api/tsdb/testdata/random-walk',
method: 'GET',
params,
requestId,
})
.pipe(
map((rsp: any) => {
return toDataQueryResponse(rsp);
}),
catchError(err => {
return of(toDataQueryResponse(err));
})
);
}
import { AnnotationQuery, DataQuery } from '@grafana/data';
import { MeasurementsQuery } from '@grafana/runtime';
//----------------------------------------------
// Query
......@@ -6,12 +7,13 @@ import { AnnotationQuery, DataQuery } from '@grafana/data';
export enum GrafanaQueryType {
RandomWalk = 'randomWalk',
RandomStream = 'randomStream',
HostMetrics = 'hostmetrics',
LiveMeasurements = 'measurements',
}
export interface GrafanaQuery extends DataQuery {
queryType: GrafanaQueryType; // RandomWalk by default
channel?: string;
measurements?: MeasurementsQuery;
}
export const defaultQuery: GrafanaQuery = {
......
......@@ -14,6 +14,7 @@ import { TestDataQuery, Scenario } from './types';
import { PredictablePulseEditor } from './components/PredictablePulseEditor';
import { CSVWaveEditor } from './components/CSVWaveEditor';
import { defaultQuery } from './constants';
import { GrafanaLiveEditor } from './components/GrafanaLiveEditor';
const showLabelsFor = ['random_walk', 'predictable_pulse', 'predictable_csv_wave'];
const endpoints = [
......@@ -59,17 +60,23 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
return;
}
let stringInput = scenario.stringInput ?? '';
const update = { ...query, scenarioId: item.value! };
if (scenario.stringInput) {
update.stringInput = scenario.stringInput;
}
if (scenario.id === 'grafana_api') {
stringInput = 'datasources';
update.stringInput = 'datasources';
} else if (scenario.id === 'streaming_client') {
update.stringInput = '';
} else if (scenario.id === 'live') {
if (!update.channel) {
update.channel = 'random-2s-stream'; // default stream
}
}
onUpdate({
...query,
scenarioId: item.value!,
stringInput,
});
onUpdate(update);
};
const onInputChange = (e: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
......@@ -179,6 +186,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
{scenarioId === 'manual_entry' && <ManualEntryEditor onChange={onUpdate} query={query} onRunQuery={onRunQuery} />}
{scenarioId === 'random_walk' && <RandomWalkEditor onChange={onInputChange} query={query} />}
{scenarioId === 'streaming_client' && <StreamingClientEditor onChange={onStreamClientChange} query={query} />}
{scenarioId === 'live' && <GrafanaLiveEditor onChange={onUpdate} query={query} />}
{scenarioId === 'logs' && (
<InlineFieldRow>
<InlineField label="Lines" labelWidth={14}>
......
import React from 'react';
import { InlineField, InlineFieldRow, Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { EditorProps } from '../QueryEditor';
const liveTestDataChannels = [
{
label: 'random-2s-stream',
value: 'random-2s-stream',
description: 'Random stream with points every 2s',
},
{
label: 'random-flakey-stream',
value: 'random-flakey-stream',
description: 'Stream that returns data in random intervals',
},
];
export const GrafanaLiveEditor = ({ onChange, query }: EditorProps) => {
const onChannelChange = ({ value }: SelectableValue<string>) => {
onChange({ ...query, channel: value });
};
return (
<InlineFieldRow>
<InlineField label="Channel" labelWidth={14}>
<Select
width={32}
onChange={onChannelChange}
placeholder="Select channel"
options={liveTestDataChannels}
value={liveTestDataChannels.find(f => f.value === query.channel)}
/>
</InlineField>
</InlineFieldRow>
);
};
......@@ -17,9 +17,16 @@ import {
TimeRange,
DataTopic,
AnnotationEvent,
LiveChannelScope,
} from '@grafana/data';
import { Scenario, TestDataQuery } from './types';
import { getBackendSrv, toDataQueryError, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import {
getBackendSrv,
toDataQueryError,
getTemplateSrv,
TemplateSrv,
getLiveMeasurementsObserver,
} from '@grafana/runtime';
import { queryMetricTree } from './metricTree';
import { from, merge, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
......@@ -49,6 +56,9 @@ export class TestDataDataSource extends DataSourceApi<TestDataQuery> {
}
switch (target.scenarioId) {
case 'live':
streams.push(runGrafanaLiveQuery(target, options));
break;
case 'streaming_client':
streams.push(runStream(target, options));
break;
......@@ -225,3 +235,22 @@ function runGrafanaAPI(target: TestDataQuery, req: DataQueryRequest<TestDataQuer
})
);
}
let liveQueryCounter = 1000;
function runGrafanaLiveQuery(
target: TestDataQuery,
req: DataQueryRequest<TestDataQuery>
): Observable<DataQueryResponse> {
if (!target.channel) {
throw new Error(`Missing channel config`);
}
return getLiveMeasurementsObserver(
{
scope: LiveChannelScope.Grafana,
namespace: 'testdata',
path: target.channel,
},
`testStream.${liveQueryCounter++}`
);
}
......@@ -25,6 +25,7 @@ export interface TestDataQuery extends DataQuery {
labels?: string;
lines?: number;
levelColumn?: boolean;
channel?: string; // for grafana live
}
export interface StreamingQuery {
......
......@@ -5,7 +5,6 @@ import {
LiveChannelScope,
LiveChannelAddress,
LiveChannelSupport,
LiveChannelConfig,
SelectableValue,
StandardEditorProps,
FeatureState,
......@@ -28,7 +27,6 @@ interface State {
namespaces: Array<SelectableValue<string>>;
paths: Array<SelectableValue<string>>;
support?: LiveChannelSupport;
config?: LiveChannelConfig;
}
export class LiveChannelEditor extends PureComponent<Props, State> {
......@@ -48,12 +46,12 @@ export class LiveChannelEditor extends PureComponent<Props, State> {
}
async updateSelectOptions() {
const { scope, namespace, path } = this.props.value;
const { value } = this.props;
const { scope, namespace } = value;
const srv = getGrafanaLiveCentrifugeSrv();
const namespaces = await srv.scopes[scope].listNamespaces();
const support = namespace ? await srv.scopes[scope].getChannelSupport(namespace) : undefined;
const paths = support ? await support.getSupportedPaths() : undefined;
const config = support && path ? await support.getChannelConfig(path) : undefined;
this.setState({
namespaces,
......@@ -65,20 +63,25 @@ export class LiveChannelEditor extends PureComponent<Props, State> {
description: p.description,
}))
: [],
config,
});
}
onScopeChanged = (v: SelectableValue<LiveChannelScope>) => {
if (v.value) {
this.props.onChange({ scope: v.value } as LiveChannelAddress);
this.props.onChange({
scope: v.value,
namespace: (undefined as unknown) as string,
path: (undefined as unknown) as string,
} as LiveChannelAddress);
}
};
onNamespaceChanged = (v: SelectableValue<string>) => {
const update = {
scope: this.props.value?.scope,
path: (undefined as unknown) as string,
} as LiveChannelAddress;
if (v.value) {
update.namespace = v.value;
}
......@@ -122,7 +125,10 @@ export class LiveChannelEditor extends PureComponent<Props, State> {
<Label>Namespace</Label>
<Select
options={namespaces}
value={namespaces.find(s => s.value === namespace) || namespace || ''}
value={
namespaces.find(s => s.value === namespace) ??
(namespace ? { label: namespace, value: namespace } : undefined)
}
onChange={this.onNamespaceChanged}
allowCustomValue={true}
backspaceRemovesValue={true}
......@@ -135,7 +141,7 @@ export class LiveChannelEditor extends PureComponent<Props, State> {
<Label>Path</Label>
<Select
options={paths}
value={paths.find(s => s.value === path) || path || ''}
value={findPathOption(paths, path)}
onChange={this.onPathChanged}
allowCustomValue={true}
backspaceRemovesValue={true}
......@@ -148,6 +154,17 @@ export class LiveChannelEditor extends PureComponent<Props, State> {
}
}
function findPathOption(paths: Array<SelectableValue<string>>, path?: string): SelectableValue<string> | undefined {
const v = paths.find(s => s.value === path);
if (v) {
return v;
}
if (path) {
return { label: path, value: path };
}
return undefined;
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
dropWrap: css`
margin-bottom: ${theme.spacing.sm};
......
import React, { PureComponent } from 'react';
import { Unsubscribable, PartialObserver } from 'rxjs';
import { CustomScrollbar, FeatureInfoBox, Label } from '@grafana/ui';
import { FeatureInfoBox, stylesFactory, Button, JSONFormatter, CustomScrollbar } from '@grafana/ui';
import {
GrafanaTheme,
PanelProps,
LiveChannelStatusEvent,
isValidLiveChannelAddress,
......@@ -9,9 +10,16 @@ import {
LiveChannelEvent,
isLiveChannelStatusEvent,
isLiveChannelMessageEvent,
LiveChannelConnectionState,
PanelData,
LoadingState,
applyFieldOverrides,
} from '@grafana/data';
import { LivePanelOptions } from './types';
import { getGrafanaLiveSrv } from '@grafana/runtime';
import { TablePanel } from '../table/TablePanel';
import { LivePanelOptions, MessageDisplayMode } from './types';
import { config, getGrafanaLiveSrv, MeasurementCollector } from '@grafana/runtime';
import { css, cx } from 'emotion';
import CodeEditor from '@grafana/ui/src/components/Monaco/CodeEditor';
interface Props extends PanelProps<LivePanelOptions> {}
......@@ -20,17 +28,19 @@ interface State {
channel?: LiveChannel;
status?: LiveChannelStatusEvent;
message?: any;
changed: number;
}
export class LivePanel extends PureComponent<Props, State> {
private readonly isValid: boolean;
subscription?: Unsubscribable;
styles = getStyles(config.theme);
constructor(props: Props) {
super(props);
this.isValid = !!getGrafanaLiveSrv();
this.state = {};
this.state = { changed: 0 };
}
async componentDidMount() {
......@@ -52,9 +62,9 @@ export class LivePanel extends PureComponent<Props, State> {
streamObserver: PartialObserver<LiveChannelEvent> = {
next: (event: LiveChannelEvent) => {
if (isLiveChannelStatusEvent(event)) {
this.setState({ status: event });
this.setState({ status: event, changed: Date.now() });
} else if (isLiveChannelMessageEvent(event)) {
this.setState({ message: event.message });
this.setState({ message: event.message, changed: Date.now() });
} else {
console.log('ignore', event);
}
......@@ -115,11 +125,155 @@ export class LivePanel extends PureComponent<Props, State> {
);
}
onSaveJSON = (text: string) => {
const { options, onOptionsChange } = this.props;
try {
const json = JSON.parse(text);
onOptionsChange({ ...options, json });
} catch (err) {
console.log('Error reading JSON', err);
}
};
onPublishClicked = async () => {
const { channel } = this.state;
if (!channel?.publish) {
console.log('channel does not support publishing');
return;
}
const json = this.props.options?.json;
if (json) {
const rsp = await channel.publish(json);
console.log('GOT', rsp);
} else {
console.log('nothing to publish');
}
};
renderMessage(height: number) {
const { options } = this.props;
const { message } = this.state;
if (!message) {
return (
<div>
<h4>Waiting for data:</h4>
{options.channel?.scope}/{options.channel?.namespace}/{options.channel?.path}
</div>
);
}
if (options.message === MessageDisplayMode.JSON) {
return <JSONFormatter json={message} open={5} />;
}
if (options.message === MessageDisplayMode.Auto) {
if (message instanceof MeasurementCollector) {
const data: PanelData = {
series: applyFieldOverrides({
data: message.getData(),
theme: config.theme,
getDataSourceSettingsByUid: () => undefined,
replaceVariables: (v: string) => v,
fieldConfig: {
defaults: {},
overrides: [],
},
}),
state: LoadingState.Streaming,
} as PanelData;
const props = {
...this.props,
options: { frameIndex: 0, showHeader: true },
} as PanelProps<any>;
return <TablePanel {...props} data={data} height={height} />;
}
}
return <pre>{JSON.stringify(message)}</pre>;
}
renderPublish(height: number) {
const { channel } = this.state;
if (!channel?.publish) {
return <div>This channel does not support publishing</div>;
}
const { options } = this.props;
return (
<>
<CodeEditor
height={height - 32}
language="json"
value={options.json ? JSON.stringify(options.json, null, 2) : '{ }'}
onBlur={this.onSaveJSON}
onSave={this.onSaveJSON}
showMiniMap={false}
showLineNumbers={true}
/>
<div style={{ height: 32 }}>
<Button onClick={this.onPublishClicked}>Publish</Button>
</div>
</>
);
}
renderStatus() {
const { status } = this.state;
if (status?.state === LiveChannelConnectionState.Connected) {
return; // nothing
}
let statusClass = '';
if (status) {
statusClass = this.styles.status[status.state];
}
return <div className={cx(statusClass, this.styles.statusWrap)}>{status?.state}</div>;
}
renderBody() {
const { status } = this.state;
const { options, height } = this.props;
if (options.publish) {
// Only the publish form
if (options.message === MessageDisplayMode.None) {
return <div>{this.renderPublish(height)}</div>;
}
// Both message and publish
const halfHeight = height / 2;
return (
<div>
<div style={{ height: halfHeight, overflow: 'hidden' }}>
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
{this.renderMessage(halfHeight)}
</CustomScrollbar>
</div>
<div>{this.renderPublish(halfHeight)}</div>
</div>
);
}
if (options.message === MessageDisplayMode.None) {
return <pre>{JSON.stringify(status)}</pre>;
}
// Only message
return (
<div style={{ overflow: 'hidden', height }}>
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
{this.renderMessage(height)}
</CustomScrollbar>
</div>
);
}
render() {
if (!this.isValid) {
return this.renderNotEnabled();
}
const { channel, status, message, error } = this.state;
const { channel, error } = this.state;
if (!channel) {
return (
<FeatureInfoBox
......@@ -132,7 +286,6 @@ export class LivePanel extends PureComponent<Props, State> {
</FeatureInfoBox>
);
}
if (error) {
return (
<div>
......@@ -141,16 +294,40 @@ export class LivePanel extends PureComponent<Props, State> {
</div>
);
}
return (
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
<Label>Status</Label>
<pre>{JSON.stringify(status)}</pre>
<br />
<Label>Message</Label>
<pre>{JSON.stringify(message)}</pre>
</CustomScrollbar>
<>
{this.renderStatus()}
{this.renderBody()}
</>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
statusWrap: css`
margin: auto;
position: absolute;
top: 0;
right: 0;
background: ${theme.colors.panelBg};
padding: 10px;
z-index: ${theme.zIndex.modal};
`,
status: {
[LiveChannelConnectionState.Pending]: css`
border: 1px solid ${theme.palette.brandPrimary};
`,
[LiveChannelConnectionState.Connected]: css`
border: 1px solid ${theme.palette.brandSuccess};
`,
[LiveChannelConnectionState.Disconnected]: css`
border: 1px solid ${theme.palette.brandWarning};
`,
[LiveChannelConnectionState.Shutdown]: css`
border: 1px solid ${theme.palette.brandDanger};
`,
[LiveChannelConnectionState.Invalid]: css`
border: 1px solid red;
`,
},
}));
import { PanelPlugin } from '@grafana/data';
import { LiveChannelEditor } from './LiveChannelEditor';
import { LivePanel } from './LivePanel';
import { LivePanelOptions } from './types';
import { LivePanelOptions, MessageDisplayMode } from './types';
export const plugin = new PanelPlugin<LivePanelOptions>(LivePanel).setPanelOptions(builder => {
builder.addCustomEditor({
category: ['Channel'],
id: 'channel',
path: 'channel',
name: 'Channel',
editor: LiveChannelEditor,
defaultValue: {},
});
builder
.addRadio({
path: 'message',
name: 'Show Message',
description: 'Display the last message received on this channel',
settings: {
options: [
{ value: MessageDisplayMode.Raw, label: 'Raw Text' },
{ value: MessageDisplayMode.JSON, label: 'JSON' },
{ value: MessageDisplayMode.Auto, label: 'Auto' },
{ value: MessageDisplayMode.None, label: 'None' },
],
},
defaultValue: MessageDisplayMode.JSON,
})
.addBooleanSwitch({
path: 'publish',
name: 'Show Publish',
description: 'Display a form to publish values',
defaultValue: false,
});
});
import { LiveChannelAddress } from '@grafana/data';
export enum MessageDisplayMode {
Raw = 'raw', // Raw JSON string
JSON = 'json', // formatted JSON
Auto = 'auto', // pick a good display
None = 'none', // do not display
}
export interface LivePanelOptions {
channel?: LiveChannelAddress;
message?: MessageDisplayMode;
publish?: boolean;
json?: any; // object
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment