mirror of
https://github.com/grafana/grafana.git
synced 2025-01-24 07:17:08 -06:00
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:
parent
a71eadf379
commit
2aafa39879
@ -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}`;
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
250
packages/grafana-runtime/src/measurement/collector.test.ts
Normal file
250
packages/grafana-runtime/src/measurement/collector.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
209
packages/grafana-runtime/src/measurement/collector.ts
Normal file
209
packages/grafana-runtime/src/measurement/collector.ts
Normal file
@ -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;
|
||||
};
|
||||
}
|
3
packages/grafana-runtime/src/measurement/index.ts
Normal file
3
packages/grafana-runtime/src/measurement/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './collector';
|
||||
export * from './query';
|
77
packages/grafana-runtime/src/measurement/query.ts
Normal file
77
packages/grafana-runtime/src/measurement/query.ts
Normal file
@ -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
|
||||
})
|
||||
);
|
||||
}
|
73
packages/grafana-runtime/src/measurement/types.ts
Normal file
73
packages/grafana-runtime/src/measurement/types.ts
Normal file
@ -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
|
||||
|
@ -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
pkg/models/measurement.go
Normal file
50
pkg/models/measurement.go
Normal 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"`
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
41
pkg/services/live/features/measurements.go
Normal file
41
pkg/services/live/features/measurements.go
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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/*
|
||||
|
40
public/app/features/live/measurements/measurementsSupport.ts
Normal file
40
public/app/features/live/measurements/measurementsSupport.ts
Normal file
@ -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 = {
|
||||
|
@ -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}>
|
||||
|
37
public/app/plugins/datasource/testdata/components/GrafanaLiveEditor.tsx
vendored
Normal file
37
public/app/plugins/datasource/testdata/components/GrafanaLiveEditor.tsx
vendored
Normal file
@ -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>
|
||||
);
|
||||
};
|
@ -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};
|
||||
|
@ -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;
|
||||
`,
|
||||
},
|
||||
}));
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user