├── .gitignore ├── tsconfig.json ├── plugin.json ├── src ├── module.html ├── completer.ts ├── renderer.ts ├── types.ts ├── editor.html ├── eval.jest.ts ├── img │ └── icn-table-panel.svg ├── env.ts ├── help.html ├── rules.ts ├── renderer.jest.ts ├── template.ts ├── eval.ts ├── module.ts └── constants.ts ├── package.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test-results/ 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "system", 4 | "target": "es5", 5 | "noImplicitAny": false, 6 | "removeComments": false, 7 | "preserveConstEnums": true, 8 | "outDir": "dist", 9 | "rootDir": "src", 10 | "sourceMap": true, 11 | "allowJs": false, 12 | "esModuleInterop": true 13 | }, 14 | "include": [ "**/*.ts" ], 15 | "exclude": [ "**/*.d.ts", "**/*.js" ] 16 | } 17 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "panel", 3 | "name": "Report", 4 | "id": "report", 5 | "info": { 6 | "description": "Markdown Report Panel for Grafana", 7 | "author": { 8 | "name": "Dropbox Inc.", 9 | "url": "https://www.dropbox.com" 10 | }, 11 | "logos": { 12 | "small": "img/icn-table-panel.svg", 13 | "large": "img/icn-table-panel.svg" 14 | }, 15 | "version": "1.0.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/module.html: -------------------------------------------------------------------------------- 1 | 15 | 16 |
25 | This area uses 26 | Markdown 27 | and 28 | Handlebars. 29 | HTML/CSS are supported, but not JS. See the "Help" tab for more information. 30 |
31 |43 | A JSON object to be made available to your above template, via Handlebars, as "{{userData}}." 44 |
45 |20 | The document will be parsed as Markdown, which means you can use all the shorthands you're 21 | used to such as `## Header` and `- list item`, OR, you can use raw HTML. Additionally, 22 | we've exposed a special syntax for injecting inline values based on the panel metrics. 23 |
24 |
25 | ## Sample Markdown /w Table
26 |
27 | | Series | Max |
28 | | ------ | --- |
29 | | A-series | $[thresholdStyles(max("A-series"), 2, 'red', 50, 'gray', 80, 'green')] |
30 |
31 |
32 | 34 | Prior to parsing the template as markdown, we compile and render it with Handlebars. This 35 | allows authors to iterate over series or variables as well as create content conditionally. 36 | In addition to the standard Handlebars syntax/helpers, we've exposed some custom helpers 37 | for working with metrics. Some examples: 38 |
39 |{{!-- Looping over series to create a list of max values --}}
40 | Series:
41 | {{#each series}}- {{label}}: {{stats.max}}}{{/each}}
42 |
43 | {{!-- Print the stats from a series --}}
44 | {{min "A-Series"}}
45 | {{max "A-Series"}}
46 | {{avg "A-Series"}}
47 | {{val "A-Series"}}
48 | {{first "A-Series"}}
49 | {{current "A-Series"}}
50 |
51 | {{!-- Print dates --}}
52 | {{startDate "format"}}
53 | {{endDate "format"}}
54 |
55 | {{!-- Evaluate Javascript (only limited syntax supported) --}}
56 | {{eval "1 + 4"}}
57 | {{!-- or in block form --}}
58 | {{#eval}}
59 | {{max "A-Series"}} / {{min "A-Series"}}
60 | {{/eval}}
61 |
62 | {{!-- Order a collection --}}
63 | {{#orderBy series "stats.max" "asc" as |ordered|}}
64 | {{#each ordered}}
65 | - {{label}}: {{stats.max}}}
66 | {{/each}}
67 | {{/orderBy}}
68 |
69 | The following data shape is available to be used in the template:
73 |{
74 | series: {
75 | [id: string]: {
76 | label: string;
77 | id: string;
78 | alias: string;
79 | stats: {
80 | total: number;
81 | max: number;
82 | min: number;
83 | logmin: number;
84 | avg: number;
85 | current: number;
86 | first: number;
87 | delta: number;
88 | diff: number;
89 | range: number;
90 | timeStep: number;
91 | count: number;
92 | };
93 | datapoints: [number /* value */, number /* timestamp */][];
94 | flotpairs: [number /* timestamp */, number /* value */][];
95 | };
96 | };
97 | variables: {
98 | [name: string]: {
99 | name: string;
100 | label: string;
101 | type: string;
102 | query: string;
103 | options: Array<{
104 | selected: bool;
105 | text: string;
106 | value: string;
107 | }>;
108 | current: {
109 | selected: bool;
110 | text: string;
111 | value: string;
112 | };
113 | selectedValues: string[];
114 | };
115 | }
116 | // ... and whatever you've defined in the Data section above.
117 | }
118 |
119 | ${str}
\n`; 21 | } 22 | 23 | function errWrapped(err: string) { 24 | return wrapped(`${err}`); 25 | } 26 | 27 | describe('MarkdownRenderer', () => { 28 | let mr: MarkdownRenderer; 29 | const mockData = {series: {}, variables: {}}; 30 | const mockTimeRange = {}; 31 | 32 | beforeEach(() => { 33 | mr = new MarkdownRenderer(); 34 | }); 35 | 36 | describe('eval', () => { 37 | it('renders normal objects as HTML tags', () => { 38 | expect(mr.render('$[1]', mockData, mockTimeRange)).toEqual(wrapped('1')); 39 | expect(mr.render('$["1"]', mockData, mockTimeRange)).toEqual(wrapped('1')); 40 | expect(mr.render('$["hi"]', mockData, mockTimeRange)).toEqual( 41 | wrapped('hi') 42 | ); 43 | }); 44 | 45 | it('catches errors', () => { 46 | expect(mr.render('$[some invalid syntax]', mockData, mockTimeRange)).toEqual( 47 | errWrapped('Error: Line 1: Unexpected identifier') 48 | ); 49 | }); 50 | 51 | it('escapes errors, to make their messages clearer', () => { 52 | expect(mr.render('$[max("<>")]', mockData, mockTimeRange)).toEqual( 53 | errWrapped('Error: argument 1 to max is ill-typed: no series named <>') 54 | ); 55 | }); 56 | }); 57 | 58 | describe('handlebars', () => { 59 | it('escapes variables even with {{{var}}}', () => { 60 | const data: any = {myVar: ''}; // tslint:disable-line:no-any 61 | expect(mr.render('{{myVar}}', data, mockTimeRange)).toEqual( 62 | wrapped('<script>alert(“haha!”)</script>') 63 | ); 64 | expect(mr.render('{{{myVar}}}', data, mockTimeRange)).toEqual( 65 | wrapped('<script>alert(“haha!”)</script>') 66 | ); 67 | }); 68 | 69 | it('can use `eval` as an inline helper', () => { 70 | expect(mr.render('{{eval "1+1"}}', mockData, mockTimeRange)).toEqual(wrapped('2')); 71 | }); 72 | 73 | it('can use `eval` as a block helper', () => { 74 | expect(mr.render('{{#eval}} 1 + 1 {{/eval}}', mockData, mockTimeRange)).toEqual(wrapped('2')); 75 | }); 76 | 77 | it('can use `join` as a block or inline helper', () => { 78 | expect(mr.render('{{join ", " "foo" "bar" 1 2}}', mockData, mockTimeRange)).toEqual( 79 | wrapped('foo, bar, 1, 2') 80 | ); 81 | expect( 82 | mr.render('{{#join "-" "foo" "bar" as |str|}}{{str}}{{/join}}', mockData, mockTimeRange) 83 | ).toEqual(wrapped('foo-bar')); 84 | }); 85 | 86 | it('can use `orderBy` helper only in block form', () => { 87 | expect(mr.render('{{orderBy series}}', mockData, mockTimeRange)).toMatch( 88 | '`orderBy` can only be used as a block helper' 89 | ); 90 | }); 91 | 92 | it('can use `orderBy` helper', () => { 93 | const data = { 94 | ...mockData, 95 | series: {a: {name: 'a4', val: 4}, b: {name: 'b2', val: 2}, c: {name: 'a3', val: 3}}, 96 | }; 97 | 98 | // One property with default direction. 99 | expect( 100 | mr.render( 101 | '{{#orderBy series "val"}}{{#each this}}{{name}} {{/each}}{{/orderBy}}', 102 | data, 103 | mockTimeRange 104 | ) 105 | ).toEqual(wrapped('b2 a3 a4')); 106 | 107 | // One property with desc direction. 108 | expect( 109 | mr.render( 110 | '{{#orderBy series "val" "desc"}}{{#each this}}{{name}} {{/each}}{{/orderBy}}', 111 | data, 112 | mockTimeRange 113 | ) 114 | ).toEqual(wrapped('a4 a3 b2')); 115 | 116 | // One property with desc direction using blockParam 117 | expect( 118 | mr.render( 119 | '{{#orderBy series "val" "desc" as |sorted|}}{{#each sorted}}{{name}} {{/each}}{{/orderBy}}', 120 | data, 121 | mockTimeRange 122 | ) 123 | ).toEqual(wrapped('a4 a3 b2')); 124 | 125 | // Multiple properties 126 | expect( 127 | mr.render( 128 | '{{#orderBy series "name" "asc" "val" "asc"}}{{#each this}}{{name}} {{/each}}{{/orderBy}}', 129 | data, 130 | mockTimeRange 131 | ) 132 | ).toEqual(wrapped('a3 a4 b2')); 133 | }); 134 | 135 | it('gets the template from the cache if the input data is the same', () => { 136 | const data = {...mockData}; 137 | const timeRange = {...mockTimeRange}; 138 | 139 | // Set the cache so we hit it. 140 | cache.template = () => 'from the cache'; 141 | cache.lastContent = 'same'; 142 | cache.lastData = data; 143 | cache.lastTimeRange = timeRange; 144 | 145 | expect(mr.render('same', data, timeRange)).toEqual(wrapped('from the cache')); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/template.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021 Dropbox, Inc 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | import _ from 'lodash'; 17 | import Handlebars from './vendors/handlebars'; 18 | import {makeEvalEnv} from './env'; 19 | import {callTypedFunction, safeEval} from './eval'; 20 | import {ReportData, TimeRange} from './types'; 21 | 22 | // We cache the compiled template and only update on input changes. 23 | export let cache: { 24 | template: Function; 25 | lastContent: string; 26 | lastData: ReportData; 27 | lastTimeRange: TimeRange; 28 | } | null = null; 29 | 30 | // Helper to output a safe error string. 31 | function safeError(err: string | Error): string /* string-ish */ { 32 | return new Handlebars.SafeString( 33 | `${Handlebars.escapeExpression(err.toString())}` 34 | ); 35 | } 36 | 37 | /** 38 | * Compile the handlebars template. 39 | */ 40 | export function compileTemplate(rawContent: string, data: ReportData, timeRange: TimeRange) { 41 | const cachedTemplate = getTemplateFromCache(rawContent, data, timeRange); 42 | if (cachedTemplate) { 43 | return cachedTemplate; 44 | } 45 | 46 | // For security, we force all handlebars variables to be escaped. 47 | const sanitizedContent = rawContent.replace(/\{\{\{([^\}]+)\}\}\}/g, '{{$1}}'); 48 | 49 | // Register our custom helpers. 50 | registerHelpers(data, timeRange); 51 | 52 | // Compile the template. 53 | cache = { 54 | template: Handlebars.compile(sanitizedContent), 55 | lastContent: rawContent, 56 | lastData: data, 57 | lastTimeRange: timeRange, 58 | }; 59 | 60 | return cache.template; 61 | } 62 | 63 | /** 64 | * Checks if the input params to the compilation process have changed and returns the cached 65 | * template if they have not. 66 | */ 67 | function getTemplateFromCache(rawContent: string, data: ReportData, timeRange: TimeRange) { 68 | if ( 69 | cache && 70 | _.isEqual(rawContent, cache.lastContent) && 71 | _.isEqual(data, cache.lastData) && 72 | _.isEqual(timeRange, cache.lastTimeRange) 73 | ) { 74 | return cache.template; 75 | } 76 | return null; 77 | } 78 | 79 | /** 80 | * Register our custom helpers. 81 | */ 82 | function registerHelpers(data: ReportData, timeRange: TimeRange) { 83 | const env = makeEvalEnv(data, timeRange); 84 | 85 | // Create a helper to eval (concats args together to form the expression). 86 | // tslint:disable-next-line:no-any 87 | Handlebars.registerHelper('eval', function(...args: any[]) { 88 | let options = args[args.length - 1]; 89 | let expr: string; 90 | if (options.fn) { 91 | // tslint:disable-next-line:no-invalid-this 92 | expr = options.fn(this, options); 93 | } else { 94 | expr = args.slice(0, args.length - 1).join(''); 95 | } 96 | try { 97 | return new Handlebars.SafeString(safeEval(env, expr)); 98 | } catch (err) { 99 | return safeError(err); 100 | } 101 | }); 102 | 103 | // Create helpers for each of the EvalEnv functions. If called in block form, they set the 104 | // return value as the inner `this` context. Useful for composition. 105 | _.each(env.functions, (func, name) => { 106 | // tslint:disable-next-line:no-any 107 | Handlebars.registerHelper(name, function(...args: any[]) { 108 | let options = args[args.length - 1]; 109 | try { 110 | let ret = callTypedFunction(name, func, args.slice(0, args.length - 1)); 111 | if (options.fn) { 112 | return options.fn(ret, {blockParams: [ret]}); 113 | } else { 114 | return new Handlebars.SafeString(ret); 115 | } 116 | } catch (err) { 117 | return safeError(err); 118 | } 119 | }); 120 | }); 121 | 122 | // Create a helper to order an array via lodash.orderBy. Does not mutate the input array, instead 123 | // it exposes the sorted array as {{this}} (or blockParam) in the nested template context. 124 | // 125 | // Example Usage: 126 | // 127 | // {{#orderBy series "stats.avg" "asc" as |orderedSeries|}} 128 | // {{#each orderedSeries}}{{label}}{{/each}} 129 | // {{/orderBy}} 130 | // 131 | // tslint:disable-next-line:no-any 132 | Handlebars.registerHelper('orderBy', function(collection: any[] | {}, ...args: any[]) { 133 | let options = args.pop(); 134 | if (!options.fn) { 135 | return safeError( 136 | '`orderBy` can only be used as a block helper like: {{#orderBy collection "prop" "asc"}}{{/orderBy}}' 137 | ); 138 | } 139 | 140 | // Convert ['propA', 'dirA', 'propB', 'dirB', ....] to [['propA', 'propB'], ['dirA', 'dirB']] 141 | // Lodash seems to recover pretty gracefully if you pass 'bad' iteratees so we don't need 142 | // to validate. 143 | let iteratees = [[], []]; 144 | while (args.length) { 145 | iteratees[0].push(args.shift()); 146 | if (args.length) { 147 | iteratees[1].push(args.shift()); 148 | } 149 | } 150 | 151 | try { 152 | let ordered = _.orderBy(collection, ...iteratees); 153 | return options.fn(ordered, {blockParams: [ordered]}); 154 | } catch (err) { 155 | return safeError(err); 156 | } 157 | }); 158 | } 159 | -------------------------------------------------------------------------------- /src/eval.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021 Dropbox, Inc 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | import Esprima = require('./vendors/esprima.js'); 17 | 18 | export interface ArgValidator { 19 | (arg: string | number): void; 20 | optional?: boolean; 21 | } 22 | 23 | export interface TypedFunction { 24 | impl: (...args: any[]) => any; // tslint:disable-line:no-any 25 | argValidators: ArgValidator[]; 26 | } 27 | 28 | export interface OperationFunction extends TypedFunction { 29 | impl: (x: number, y: number) => number | boolean; 30 | } 31 | 32 | export class TypeError extends Error {} 33 | 34 | export const optional = (validator: ArgValidator): ArgValidator => { 35 | const opt = x => { 36 | if (typeof x !== 'undefined') { 37 | validator(x); 38 | } 39 | }; 40 | opt.optional = true; 41 | return opt; 42 | }; 43 | 44 | export const oneOf = (...validators: ArgValidator[]): ArgValidator => { 45 | return x => { 46 | let errs: string[] = []; 47 | validators.forEach(validator => { 48 | try { 49 | validator(x); 50 | } catch (err) { 51 | errs.push(err); 52 | } 53 | }); 54 | if (errs.length === validators.length) { 55 | throw `not one of (${errs.join(',')})`; 56 | } 57 | }; 58 | }; 59 | 60 | export const ensureNumber: ArgValidator = x => { 61 | if (typeof x !== 'number') { 62 | throw 'not a number'; 63 | } 64 | }; 65 | 66 | export const ensureString: ArgValidator = x => { 67 | if (typeof x !== 'string') { 68 | throw 'not a string'; 69 | } 70 | }; 71 | 72 | export interface EvalEnv| Series | 41 |Max | 42 |Min | 43 |Diff | 44 |
|---|---|---|---|
| {{@key}} | 50 |51 | {{#eval}} 52 | thresholdStyles(min("{{@key}}"), 2, 'red', 50, 'gray', 80, 'green') 53 | {{/eval}} 54 | | 55 |56 | {{#max @key as |maxValue|}} 57 | {{thresholdStyles maxValue 2 'red' 50 'gray' 80 'green'}} 58 | {{/max}} 59 | | 60 |61 | {{#eval}} 62 | toFixed(max("{{@key}}") - min("{{@key}}"), 2) 63 | {{/eval}} 64 | | 65 |