├── .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 |
17 |
18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grafana-report-panel", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "license": "Apache-2.0", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/dropbox/grafana-report-panel" 10 | }, 11 | "scripts": { 12 | "test": "jest", 13 | "format": "echo \"Prettier v$(npm run prettier -- --version)\" && npm run prettier -- --write \"src/*.{ts,tsx}\" \"specs/*.{ts,tsx}\"" 14 | }, 15 | "devDependencies": { 16 | "@types/jest": "^24.0.18", 17 | "@types/lodash": "^4.14.117", 18 | "grafana-sdk-mocks": "github:grafana/grafana-sdk-mocks", 19 | "jest": "^24.9.0", 20 | "ts-jest": "^24.1.0", 21 | "typescript": "^3.6.3" 22 | }, 23 | "homepage": "https://github.com/speezepearson/grafana-report-panel", 24 | "dependencies": {}, 25 | "jest": { 26 | "transform": { 27 | ".*\\.tsx?$": "ts-jest" 28 | }, 29 | "testMatch": [ 30 | "**/?(*.)+(spec|test|jest).[jt]s?(x)" 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Submitting pull requests 2 | 3 | Please begin by filling out the contributor form and asserting that 4 | 5 | The code I'm contributing is mine, and I have the right to license it. 6 | I'm granting you a license to distribute said code under the terms of this agreement. 7 | 8 | at this page: 9 | https://opensource.dropbox.com/cla/ 10 | 11 | Then create a new pull request through the github interface. 12 | 13 | ## License 14 | 15 | Unless otherwise noted: 16 | 17 | ``` 18 | Copyright (c) 2021 Dropbox, Inc. 19 | 20 | Licensed under the Apache License, Version 2.0 (the "License"); 21 | you may not use this file except in compliance with the License. 22 | You may obtain a copy of the License at 23 | 24 | http://www.apache.org/licenses/LICENSE-2.0 25 | 26 | Unless required by applicable law or agreed to in writing, software 27 | distributed under the License is distributed on an "AS IS" BASIS, 28 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 29 | See the License for the specific language governing permissions and 30 | limitations under the License. 31 | ``` 32 | -------------------------------------------------------------------------------- /src/completer.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 | export class Completer { 17 | private templateVariableCompletions = [ 18 | { 19 | caption: '$[startDate("")]', 20 | value: '$[startDate("MM/DD/YYYY")]', 21 | meta: 'replace', 22 | score: Number.MAX_VALUE, 23 | }, 24 | { 25 | caption: '$[endDate("")]', 26 | value: '$[endDate("MM/DD/YYYY")]', 27 | meta: 'replace', 28 | score: Number.MAX_VALUE, 29 | }, 30 | { 31 | caption: '$[max("", )]', 32 | value: '$[max("metric",2)]', 33 | meta: 'replace', 34 | score: Number.MAX_VALUE, 35 | }, 36 | ]; 37 | 38 | public getCompletions( 39 | editor: any, // tslint:disable-line:no-any 40 | session: any, // tslint:disable-line:no-any 41 | pos: {row: number; column: number}, 42 | prefix: string, 43 | callback: Function 44 | ) { 45 | callback(null, [...this.templateVariableCompletions]); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer.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 Remarkable from './vendors/remarkable'; 17 | import {dataRule, imageRule, imgSize} from './rules'; 18 | import {compileTemplate} from './template'; 19 | import {ReportData, TimeRange} from './types'; 20 | 21 | export class MarkdownRenderer { 22 | private remarkable: Remarkable; 23 | 24 | public constructor() { 25 | this.remarkable = new Remarkable('full', { 26 | html: true, 27 | linkify: true, 28 | typographer: true, 29 | }); 30 | this.remarkable.renderer.rules.image = imageRule; 31 | this.remarkable.inline.ruler.push('data_rule', dataRule); 32 | this.remarkable.inline.ruler.push('img_size', imgSize); 33 | } 34 | 35 | public render(rawContent: string, data: ReportData, timeRange: TimeRange): string { 36 | const template = compileTemplate(rawContent, data, timeRange); 37 | const content = template(data); 38 | return this.remarkable.render(content, {data, timeRange}); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/types.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 | export type TimeRange = any; // tslint:disable-line:no-any 17 | 18 | export type TimeSeries = any; // tslint:disable-line:no-any 19 | 20 | export type RuleState = any; // tslint:disable-line:no-any 21 | 22 | export type RemarkableToken = { 23 | type: string; 24 | level: number; 25 | content: string; 26 | }; 27 | 28 | export type VariableOption = { 29 | text: string; 30 | value: string; 31 | selected: boolean; 32 | }; 33 | 34 | export type Variable = { 35 | name: string; 36 | label: string; 37 | type: string; 38 | query: string; 39 | current: VariableOption; 40 | options: Array; 41 | }; 42 | 43 | export type VariableData = { 44 | name: string; 45 | label: string; 46 | type: string; 47 | query: string; 48 | options: Array; 49 | current: VariableOption; 50 | selectedValues: Array; 51 | }; 52 | 53 | export type ReportData = { 54 | series: {[key: string]: TimeSeries}; 55 | variables: {[key: string]: VariableData}; 56 | userData?: {[key: string]: any /* JSON */}; 57 | }; 58 | -------------------------------------------------------------------------------- /src/editor.html: -------------------------------------------------------------------------------- 1 | 15 | 16 |

Theme

17 |
18 |
19 | 20 |
21 |
22 | 23 |

Content

24 |

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 |
32 |
33 | 36 | 37 |
38 |
39 | 40 | 41 |

User Data

42 |

43 | A JSON object to be made available to your above template, via Handlebars, as "{{userData}}." 44 |

45 |
46 |
47 | 49 | 50 |
51 |
52 | -------------------------------------------------------------------------------- /src/eval.jest.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 {optional, oneOf, ensureString, ensureNumber, safeEval} from '../src/eval'; 17 | 18 | describe('validators', () => { 19 | it('can ensureString', () => { 20 | expect(() => ensureString('foo')).not.toThrow(); 21 | expect(() => ensureString(1)).toThrow(); 22 | expect(() => ensureString({} as any)).toThrow(); // tslint:disable-line:no-any 23 | expect(() => ensureString([] as any)).toThrow(); // tslint:disable-line:no-any 24 | }); 25 | 26 | it('can ensureNumber', () => { 27 | expect(() => ensureNumber(1)).not.toThrow(); 28 | expect(() => ensureNumber('foo')).toThrow(); 29 | expect(() => ensureNumber({} as any)).toThrow(); // tslint:disable-line:no-any 30 | expect(() => ensureNumber([] as any)).toThrow(); // tslint:disable-line:no-any 31 | }); 32 | 33 | it('can support optional validators', () => { 34 | let foo: any; // tslint:disable-line:no-any 35 | expect(() => optional(ensureString)(foo)).not.toThrow(); 36 | expect(() => optional(ensureString)('foo')).not.toThrow(); 37 | expect(() => optional(ensureString)(1)).toThrow(); 38 | }); 39 | 40 | it('can support oneOf validators', () => { 41 | expect(() => oneOf(ensureString, ensureNumber)('string')).not.toThrow(); 42 | expect(() => oneOf(ensureString, ensureNumber)(1)).not.toThrow(); 43 | // tslint:disable-next-line:no-any 44 | expect(() => oneOf(ensureString, ensureNumber)({} as any)).toThrow(); 45 | }); 46 | }); 47 | 48 | describe('safeEval', function() { 49 | it('should evaluate literals', () => { 50 | expect(safeEval({}, '1')).toEqual(1); 51 | expect(safeEval({}, '"foo"')).toEqual('foo'); 52 | }); 53 | 54 | it('should be able to do arithmetic', () => { 55 | expect(safeEval({}, '1 + 2 * 6 / 4')).toEqual(4); 56 | }); 57 | 58 | it('should reject non-whitelisted ops', () => { 59 | expect(() => safeEval({}, '1 ^ 2')).toThrow(/\^ is not whitelisted/); 60 | }); 61 | 62 | it('should reject non-number arithmetic', () => { 63 | expect(() => safeEval({}, '1 + "hi"')).toThrow(/argument 2 to \+ is ill-typed: not a number/); 64 | }); 65 | 66 | it('should reject indirect functions', () => { 67 | const env = {functions: {id: {argValidators: [], impl: () => {}}}}; 68 | expect(() => safeEval(env, '1()')).toThrow(/functions may only be called by name/); 69 | expect(() => safeEval(env, 'id()()')).toThrow(/functions may only be called by name/); 70 | }); 71 | 72 | it('should reject complicated literals', () => { 73 | expect(() => safeEval({}, '({a: 1})')).toThrow(/unsupported syntax: ObjectExpression/); 74 | expect(() => safeEval({}, '[]')).toThrow(/unsupported syntax: ArrayExpression/); 75 | }); 76 | 77 | it('should reject member access', () => { 78 | expect(() => safeEval({}, 'a.b')).toThrow(/unsupported syntax: MemberExpression/); 79 | expect(() => safeEval({}, 'a[0]')).toThrow(/unsupported syntax: MemberExpression/); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/img/icn-table-panel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/env.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 {EvalEnv, ensureNumber, ensureString, optional, oneOf, TypedFunction} from './eval'; 17 | import {ReportData, TimeRange} from './types'; 18 | 19 | const statGetter: (data: ReportData, stat: string) => TypedFunction = (data, stat) => ({ 20 | argValidators: [ 21 | series => { 22 | if (!data.series.hasOwnProperty(series)) { 23 | throw `no series named ${series}`; 24 | } 25 | }, 26 | ], 27 | impl: series => data.series[series].stats[stat], 28 | }); 29 | 30 | export function makeEvalEnv(data: ReportData, timeRange: TimeRange): EvalEnv { 31 | const functions = { 32 | /** 33 | * Styling helpers. 34 | */ 35 | thresholdStyles: { 36 | argValidators: [ 37 | ensureNumber, 38 | ensureNumber, 39 | ensureString, 40 | ensureNumber, 41 | ensureString, 42 | ensureNumber, 43 | ensureString, 44 | ], 45 | impl: (value, precision, ls, l, ms, h, hs) => 46 | `${value.toFixed(precision)}`, 47 | }, 48 | threshold: { 49 | argValidators: [ 50 | ensureNumber, 51 | ensureString, 52 | ensureNumber, 53 | ensureString, 54 | ensureNumber, 55 | ensureString, 56 | ], 57 | impl: (value, ls, l, ms, h, hs) => (value < l ? ls : value < h ? ms : hs), 58 | }, 59 | style: { 60 | argValidators: [ensureString, () => {}], 61 | impl: (classes, x) => `${x.toString()}`, 62 | }, 63 | 64 | /** 65 | * Value converters. 66 | */ 67 | toFixed: {argValidators: [ensureNumber, ensureNumber], impl: (x, prec) => x.toFixed(prec)}, 68 | toKMGT: { 69 | argValidators: [ensureNumber, ensureNumber], 70 | impl: (x, prec) => { 71 | for (let [exp, suffix] of [[12, 'T'], [9, 'G'], [6, 'M'], [3, 'K']] as [number, string][]) { 72 | if (x > 10 ** exp) { 73 | return `${(x / 10 ** exp).toFixed(prec)}${suffix}`; 74 | } 75 | } 76 | return x.toFixed(prec); 77 | }, 78 | }, 79 | 80 | /** 81 | * Stat getters. 82 | */ 83 | max: statGetter(data, 'max'), 84 | min: statGetter(data, 'min'), 85 | avg: statGetter(data, 'avg'), 86 | val: statGetter(data, 'val'), 87 | first: statGetter(data, 'first'), 88 | current: statGetter(data, 'current'), 89 | startDate: {argValidators: [ensureString], impl: (fmt: string) => timeRange.from.format(fmt)}, 90 | endDate: {argValidators: [ensureString], impl: (fmt: string) => timeRange.to.format(fmt)}, 91 | 92 | /** 93 | * Join strings/numbers together with a delimiter. 94 | */ 95 | join: { 96 | argValidators: [ 97 | ensureString, 98 | oneOf(ensureString, ensureNumber), 99 | optional(oneOf(ensureString, ensureNumber)), 100 | optional(oneOf(ensureString, ensureNumber)), 101 | optional(oneOf(ensureString, ensureNumber)), 102 | optional(oneOf(ensureString, ensureNumber)), 103 | optional(oneOf(ensureString, ensureNumber)), 104 | ], 105 | impl: (delim: string, ...values: (string | number)[]) => { 106 | return values.join(delim); 107 | }, 108 | }, 109 | }; 110 | return {functions, data}; 111 | } 112 | -------------------------------------------------------------------------------- /src/help.html: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |
18 |

Markdown

19 |

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 |

Handlebars

33 |

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 |
70 |
71 |

Handlebars Data

72 |

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 |
120 |
121 | -------------------------------------------------------------------------------- /src/rules.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 {utils} from './vendors/remarkable.js'; 17 | import {safeEval} from './eval'; 18 | import {makeEvalEnv} from './env'; 19 | import {ReportData, TimeRange, RuleState} from './types'; 20 | 21 | export function imageRule(tokens, idx, options /*, env */) { 22 | const src = ' src="' + utils.escapeHtml(tokens[idx].src) + '"'; 23 | const title = tokens[idx].title 24 | ? ' title="' + utils.escapeHtml(utils.replaceEntities(tokens[idx].title)) + '"' 25 | : ''; 26 | const alt = 27 | ' alt="' + 28 | (tokens[idx].alt 29 | ? utils.escapeHtml(utils.replaceEntities(utils.unescapeMd(tokens[idx].alt))) 30 | : '') + 31 | '"'; 32 | const dim = tokens[idx].height 33 | ? ' height="' + 34 | utils.escapeHtml(tokens[idx].height) + 35 | '" width="' + 36 | utils.escapeHtml(tokens[idx].width) + 37 | '"' 38 | : ''; 39 | const suffix = options.xhtmlOut ? ' /' : ''; 40 | return ''; 41 | } 42 | 43 | export function imgSize(state: RuleState, silent: boolean): boolean { 44 | let found: boolean = false; 45 | const start: number = state.pos; 46 | const max: number = state.posMax; 47 | 48 | if (state.tokens.length === 0 || state.tokens[state.tokens.length - 1].type !== 'image') { 49 | return false; 50 | } 51 | if (state.src.charCodeAt(start) !== 0x7b /* { */) { 52 | return false; 53 | } 54 | if (state.level >= state.options.maxNesting) { 55 | return false; 56 | } 57 | 58 | if (start + 1 >= max) { 59 | return false; 60 | } 61 | 62 | state.pos = start + 1; 63 | 64 | while (state.pos < max) { 65 | if (state.src.charCodeAt(state.pos) === 0x7d /* } */) { 66 | found = true; 67 | break; 68 | } 69 | 70 | state.parser.skipToken(state); 71 | } 72 | 73 | if (!found || start + 2 === state.pos) { 74 | state.pos = start; 75 | return false; 76 | } 77 | 78 | const expr: string = state.src.slice(start + 1, state.pos); 79 | const match = expr.match(/\s*height=([0-9]+)\s*,\s*width=([0-9]+)/); 80 | if (!match) { 81 | state.pos = start; 82 | return false; 83 | } 84 | 85 | if (!silent) { 86 | const imageToken = state.tokens.pop(); 87 | state.push({ 88 | type: 'image', 89 | src: imageToken.src, 90 | title: imageToken.title, 91 | alt: imageToken.alt, 92 | level: state.level, 93 | height: match[1], 94 | width: match[2], 95 | }); 96 | } 97 | 98 | state.posMax = state.pos + 1; 99 | state.pos = max; 100 | return true; 101 | } 102 | 103 | export function dataRule(state: RuleState, silent: boolean): boolean { 104 | const data: ReportData = state.env.data; 105 | const timeRange: TimeRange = state.env.timeRange; 106 | 107 | if (state.level >= state.options.maxNesting) { 108 | return false; 109 | } 110 | if (silent) { 111 | return false; 112 | } 113 | 114 | const match = /^\$\[([^\]]+)\]/.exec(state.src.slice(state.pos)); 115 | if (!match) { 116 | return false; 117 | } 118 | 119 | if (data) { 120 | const expr = match[1]; 121 | const env = makeEvalEnv(data, timeRange); 122 | let result: string; // Hack to get toString() typing. 123 | 124 | try { 125 | result = safeEval(env, expr); 126 | } catch (e) { 127 | result = `${utils.escapeHtml(e.toString())}`; 128 | } 129 | state.push({type: 'htmltag', level: state.level, content: result.toString()}); 130 | } else { 131 | state.push({type: 'text', level: state.level, content: '(Loading...)'}); 132 | } 133 | 134 | state.pos += match[0].length; 135 | return true; 136 | } 137 | -------------------------------------------------------------------------------- /src/renderer.jest.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 {MarkdownRenderer} from './renderer'; 17 | import {cache} from './template'; 18 | 19 | function wrapped(str: string) { 20 | return `

${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 { 73 | functions?: {[name: string]: TypedFunction}; 74 | data?: DataShape; 75 | } 76 | 77 | const BINARY_OPS: {[op: string]: OperationFunction} = { 78 | '+': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x + y}, 79 | '-': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x - y}, 80 | '*': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x * y}, 81 | '/': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x / y}, 82 | '**': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x ** y}, 83 | '<': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x < y}, 84 | '<=': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x <= y}, 85 | // tslint:disable-next-line:triple-equals 86 | '==': {argValidators: [() => {}, () => {}], impl: (x, y) => x == y}, 87 | // tslint:disable-next-line:triple-equals 88 | '!=': {argValidators: [() => {}, () => {}], impl: (x, y) => x != y}, 89 | '>=': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x >= y}, 90 | '>': {argValidators: [ensureNumber, ensureNumber], impl: (x, y) => x > y}, 91 | }; 92 | 93 | // tslint:disable-next-line:no-any 94 | export function callTypedFunction(funcName: string, func: TypedFunction, args: any[]): any { 95 | if (args.length < func.argValidators.filter(v => !v.optional).length) { 96 | throw new TypeError(`${func} expects ${func.argValidators.length} args, got ${args.length}`); 97 | } 98 | func.argValidators.forEach((validate, i) => { 99 | try { 100 | validate(args[i]); 101 | } catch (e) { 102 | throw new TypeError(`argument ${i + 1} to ${funcName} is ill-typed: ${e}`); 103 | } 104 | }); 105 | return func.impl(...args.slice(0, func.argValidators.length)); 106 | } 107 | 108 | // tslint:disable-next-line:no-any 109 | export function safeEvalAST(env: EvalEnv, ast: any): any { 110 | switch (ast.type) { 111 | case 'BinaryExpression': 112 | const op = BINARY_OPS[ast.operator]; 113 | if (!op) { 114 | throw new SyntaxError(`binary op ${ast.operator} is not whitelisted`); 115 | } 116 | 117 | const left = safeEvalAST(env, ast.left); 118 | const right = safeEvalAST(env, ast.right); 119 | return callTypedFunction(ast.operator, op, [left, right]); 120 | 121 | case 'CallExpression': 122 | if (ast.callee.type !== 'Identifier') { 123 | throw new SyntaxError( 124 | `functions may only be called by name; got ${JSON.stringify(ast.callee)}` 125 | ); 126 | } 127 | if (!(env.functions && env.functions.hasOwnProperty(ast.callee.name))) { 128 | throw new ReferenceError(`no function named ${ast.callee.name}`); 129 | } 130 | const func = env.functions[ast.callee.name]; 131 | const args = ast.arguments.map(x => safeEvalAST(env, x)); 132 | return callTypedFunction(ast.callee.name, func, args); 133 | 134 | case 'Literal': 135 | const type = typeof ast.value; 136 | if (!(type === 'string' || type === 'number')) { 137 | throw new TypeError(`refusing to touch literal of type ${type}`); 138 | } 139 | return ast.value; 140 | 141 | case 'ConditionalExpression': 142 | return safeEvalAST(env, ast.test) 143 | ? safeEvalAST(env, ast.consequent) 144 | : safeEvalAST(env, ast.alternate); 145 | 146 | case 'TemplateLiteral': 147 | let result = ast.quasis[0].value.raw; 148 | for (let i = 0; i < ast.expressions.length; i++) { 149 | result += safeEvalAST(env, ast.expressions[i]).toString(); 150 | result += ast.quasis[i + 1].value.raw; 151 | } 152 | return result; 153 | } 154 | 155 | throw new SyntaxError(`unsupported syntax: ${ast.type}`); 156 | } 157 | 158 | // tslint:disable-next-line:no-any 159 | export function safeEval(env: EvalEnv, s: string): any { 160 | const { 161 | body: [{expression: ast}], 162 | } = Esprima.parseScript(s); 163 | return safeEvalAST(env, ast); 164 | } 165 | -------------------------------------------------------------------------------- /src/module.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 | /// 17 | // TODO(blink): We can probably get rid of the above if we add grafana-sdk-mocks to //npm and 18 | // then include it as a dependency in the relevant BUILD targets. 19 | 20 | import _ from 'lodash'; 21 | import TimeSeries from 'app/core/time_series2'; 22 | import {MetricsPanelCtrl} from 'app/plugins/sdk'; 23 | import {Completer} from './completer'; 24 | import {EXAMPLE_CONTENT, EXAMPLE_USER_DATA, REPORT_STYLING, THEME} from './constants'; 25 | import {MarkdownRenderer} from './renderer'; 26 | import {ReportData, Variable, VariableData} from './types'; 27 | 28 | export type TimeSeries = any; // tslint:disable-line:no-any 29 | 30 | export class ReportPanelCtrl extends MetricsPanelCtrl { 31 | public static templateUrl = 'module.html'; 32 | public static scrollable = true; 33 | 34 | // Set and populate defaults 35 | public panelDefaults = { 36 | theme: THEME.Light, 37 | content: EXAMPLE_CONTENT, 38 | userDataStr: EXAMPLE_USER_DATA, 39 | }; 40 | 41 | private renderer: MarkdownRenderer; 42 | private content: string; 43 | private data: ReportData = {series: {}, variables: {}}; 44 | private hasReceivedData: boolean = false; 45 | 46 | /** @ngInject */ 47 | constructor($scope, $injector, templateSrv, private $sce) { 48 | super($scope, $injector); 49 | 50 | _.defaults(this.panel, this.panelDefaults); 51 | 52 | this.events.on('init-edit-mode', this.onInitEditMode.bind(this)); 53 | this.events.on('refresh', this.onRefresh.bind(this)); 54 | this.events.on('render', this.onRender.bind(this)); 55 | this.events.on('data-received', this.onDataReceived.bind(this)); 56 | this.events.on('data-error', this.onDataError.bind(this)); 57 | this.events.on('data-snapshot-load', this.onDataReceived.bind(this)); 58 | 59 | $scope.$watch( 60 | 'ctrl.panel.theme', 61 | _.throttle(() => { 62 | this.render(); 63 | }, 1000) 64 | ); 65 | 66 | $scope.$watch( 67 | 'ctrl.panel.content', 68 | _.debounce(() => { 69 | this.render(); 70 | }, 1000) 71 | ); 72 | 73 | $scope.$watch( 74 | 'ctrl.panel.userDataStr', 75 | _.debounce(() => { 76 | this.render(); 77 | }, 1000) 78 | ); 79 | } 80 | 81 | public onInitEditMode() { 82 | this.addEditorTab('Options', 'public/plugins/report/editor.html'); 83 | this.addEditorTab('Help', 'public/plugins/report/help.html'); 84 | this.editorTabIndex = 1; 85 | } 86 | 87 | public onRefresh() { 88 | this.render(); 89 | } 90 | 91 | public onRender() { 92 | this.renderMarkdown(this.panel.content, this.panel.userDataStr); 93 | this.renderingCompleted(); 94 | if (this.hasReceivedData) { 95 | window.dispatchEvent(new Event("reportrendered")); 96 | } 97 | } 98 | 99 | public onDataError(err) { 100 | this.onDataReceived([]); 101 | } 102 | 103 | // tslint:disable-next-line:no-any 104 | public onDataReceived(dataList: any[]) { 105 | const data: ReportData = {series: {}, variables: {}}; 106 | 107 | // Get each series and map by alias. 108 | dataList.forEach(val => { 109 | const series = this.createSeriesData(val); 110 | data.series[series.alias] = series; 111 | }); 112 | 113 | // Get each template variable and map by name. 114 | this.templateSrv.variables.forEach(variable => { 115 | data.variables[variable.name] = this.createVariableData(variable); 116 | }); 117 | 118 | this.data = data; 119 | this.hasReceivedData = true; 120 | this.onRender(); 121 | } 122 | 123 | private createSeriesData(seriesData): TimeSeries { 124 | const series = new TimeSeries({ 125 | datapoints: seriesData.datapoints || [], 126 | alias: seriesData.target, 127 | }); 128 | series.flotpairs = series.getFlotPairs(this.panel.nullPointMode); 129 | return series; 130 | } 131 | 132 | private createVariableData(variable: Variable): VariableData { 133 | const selectedValues = 134 | variable.current.value === '$__all' 135 | ? variable.options.map(option => option.value).filter(value => value !== '$__all') 136 | : [variable.current.value]; 137 | 138 | return { 139 | name: variable.name, 140 | label: variable.label || variable.name, 141 | type: variable.type, 142 | query: variable.query, 143 | options: variable.options, 144 | current: variable.current, 145 | selectedValues: selectedValues, 146 | }; 147 | } 148 | 149 | public getCompleter(): Completer { 150 | return new Completer(); 151 | } 152 | 153 | public getThemes(): Array { 154 | return [THEME.Light, THEME.Dark]; 155 | } 156 | 157 | public getDataJson(): string { 158 | return JSON.stringify(this.data, null, 2); 159 | } 160 | 161 | private renderMarkdown(content: string, userDataStr: string) { 162 | if (!this.renderer) { 163 | this.renderer = new MarkdownRenderer(); 164 | } 165 | 166 | let userData: any; 167 | try { 168 | userData = JSON.parse(userDataStr); 169 | } catch (error) { 170 | userData = {error}; 171 | } 172 | 173 | this.$scope.$applyAsync(() => { 174 | this.updateContent(this.renderer.render(content, {...this.data, userData}, this.timeSrv.timeRange())); 175 | }); 176 | } 177 | 178 | private updateContent(html: string) { 179 | try { 180 | const fullHtml = this.templateSrv.replace(html, this.panel.scopedVars); 181 | const iframe = document.createElement('iframe'); 182 | iframe.classList.add('rendered-markdown-container'); 183 | iframe.width = iframe.height = '100%'; 184 | iframe.frameBorder = '0'; 185 | iframe.setAttribute('sandbox', ''); // Typed as readonly so cannot directly set. 186 | iframe.srcdoc = ` 187 | 188 | 189 | ${REPORT_STYLING} 190 | 191 | 192 | ${fullHtml} 193 | 194 | `; 195 | this.content = this.$sce.trustAsHtml(iframe.outerHTML); 196 | } catch (e) { 197 | this.content = this.$sce.trustAsHtml(html); 198 | } 199 | } 200 | } 201 | 202 | export {ReportPanelCtrl as PanelCtrl}; 203 | -------------------------------------------------------------------------------- /src/constants.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 | export const enum THEME { 17 | Light = 'light', 18 | Dark = 'dark', 19 | } 20 | 21 | export const EXAMPLE_CONTENT = `# Report Panel 22 | 23 | Panels are parsed as Markdown and support two types of variable interpolation: 24 | 25 | - Inline evaluation with the following syntax: '$[]' . 26 | - Handlebars with several helpers for evaluating values. 27 | 28 | ## Sample Markdown (with inline $[] eval syntax) 29 | 30 | | Series | Max | Min | 31 | ------ | --- | --- 32 | A-series | $[thresholdStyles(max("A-series"), 2, 'red', 50, 'gray', 80, 'green')] | $[thresholdStyles(min("A-series"), 2, 'red', 50, 'gray', 80, 'green')] 33 | 34 | 35 | ## Sample HTML 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {{#each series}} 48 | 49 | 50 | 55 | 60 | 65 | 66 | {{/each}} 67 | 68 |
SeriesMaxMinDiff
{{@key}} 51 | {{#eval}} 52 | thresholdStyles(min("{{@key}}"), 2, 'red', 50, 'gray', 80, 'green') 53 | {{/eval}} 54 | 56 | {{#max @key as |maxValue|}} 57 | {{thresholdStyles maxValue 2 'red' 50 'gray' 80 'green'}} 58 | {{/max}} 59 | 61 | {{#eval}} 62 | toFixed(max("{{@key}}") - min("{{@key}}"), 2) 63 | {{/eval}} 64 |
69 | 70 | 71 | ## Using custom user data 72 | 73 | {{#each userData.metricsToList}} 74 | - [{{name}}]({{link}}) -- currently {{#eval}} current("{{name}}") {{/eval}} 75 | {{/each}} 76 | `; 77 | 78 | export const EXAMPLE_USER_DATA = JSON.stringify( 79 | { 80 | "metricsToList": [ 81 | {"name": "A-series", "link": "http://example.com/A-series"}, 82 | {"name": "B-series", "link": "http://example.com/B-series"}, 83 | {"name": "C-series", "link": "http://example.com/C-series"} 84 | ] 85 | }, 86 | null, 87 | 2 88 | ); 89 | 90 | /* Most of this styling is copy-pasted from the computed styles for a Markdown table in Grafana. 91 | * Since the report is sandboxed in an iframe, Grafana's styles don't apply to it -- 92 | * so we steal the ones we want. 93 | */ 94 | export const REPORT_STYLING = ``; 256 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright (c) 2019 Dropbox, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------