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>
This commit is contained in:
Ryan McKinley
2020-10-22 09:10:26 +02:00
committed by GitHub
co-authored by dependabot[bot] Emil Tullstedt Ivana Huckova Arve Knudsen Torkel Ödegaard ozhuang Amos Law Alex Khomenko Will Browne Hugo Häggmark The Rock Guy Sofia Papagiannaki Carl Bergquist Jack Westbrook Dominik Prokop Elliot Pryde Zoltán Bedi Andrej Ocenas Alexander Zobnin Kyle Brandt Brian Gann J-F-Far acoder77 Peter Holmberg Krzysztof Dąbrowski maknik
parent a71eadf379
commit 2aafa39879
35 changed files with 1470 additions and 213 deletions
@@ -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}`;
+4 -7
View File
@@ -1,4 +1,3 @@
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}
*/
@@ -28,11 +27,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
*/
@@ -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 {
+1
View File
@@ -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';
@@ -0,0 +1,250 @@
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);
});
});
@@ -0,0 +1,209 @@
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;
};
}
@@ -0,0 +1,3 @@
export * from './types';
export * from './collector';
export * from './query';
@@ -0,0 +1,77 @@
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
})
);
}
@@ -0,0 +1,73 @@
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
+5 -4
View File
@@ -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)
}
+50
View File
@@ -0,0 +1,50 @@
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"`
}
+37 -16
View File
@@ -1,27 +1,48 @@
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]
}
return ChannelIdentifier{}, fmt.Errorf("Invalid channel id: %s", id)
if length > 1 {
addr.Namespace = parts[1]
}
if length > 2 {
addr.Path = parts[2]
}
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
}
+10 -12
View File
@@ -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())
}
+8 -7
View File
@@ -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
}
+2 -9
View File
@@ -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
@@ -0,0 +1,41 @@
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
}
+37 -41
View File
@@ -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)
}
}
}
+37 -24
View File
@@ -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
+9
View File
@@ -259,6 +259,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",
@@ -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,
};
+16 -2
View File
@@ -1,16 +1,24 @@
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/*
@@ -0,0 +1,40 @@
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 [];
}
}
@@ -1,13 +1,16 @@
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));
})
);
}
@@ -1,4 +1,5 @@
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 = {
+16 -8
View File
@@ -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.id === 'grafana_api') {
stringInput = 'datasources';
if (scenario.stringInput) {
update.stringInput = scenario.stringInput;
}
onUpdate({
...query,
scenarioId: item.value!,
stringInput,
});
if (scenario.id === 'grafana_api') {
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(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}>
@@ -0,0 +1,37 @@
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>
);
};
+30 -1
View File
@@ -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++}`
);
}
+1
View File
@@ -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};
+194 -17
View File
@@ -1,7 +1,8 @@
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;
`,
},
}));
+24 -1
View File
@@ -1,14 +1,37 @@
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,
});
});
+10
View File
@@ -1,5 +1,15 @@
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
}