Skip to content

Commit aba9294

Browse files
Julien NahumJulien Nahum
Julien Nahum
authored and
Julien Nahum
committed
Added custom code widgets
1 parent 07d8360 commit aba9294

File tree

19 files changed

+373470
-32
lines changed

19 files changed

+373470
-32
lines changed

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ Model statistics dashboard for your Laravel Application
1111

1212
This Laravel packages gives you a statistic dashboard for you Laravel application. Think of it as a light version of
1313
[Grafana](https://grafana.com/), but built-in your Laravel application, and much easier to get started with.
14-
No code knowledge is required to use Laravel Model Stats, users can do everything from the web interface.
14+
No code knowledge is required to use Laravel Model Stats, users can do everything from the web interface. It also
15+
optionally supports custom-code widgets, allowing you to define your widget data with
16+
code, just like you would do with tinker.
1517

1618
---
1719

@@ -31,7 +33,7 @@ php artisan migrate
3133
```
3234

3335

34-
## Available Widgets
36+
## Available No-Code Widgets
3537

3638
Different type of widgets (daily count, daily average, etc.) are available. When creating a widget,
3739
you choose a Model, an aggregation type and the column(s) for the graph. You can then resize and move the widgets around on your dashboard.
@@ -45,6 +47,27 @@ The aggregation types currently available:
4547

4648
For each widget type, date can be any column: `created_at`,`updated_at`,`custom_date`.
4749

50+
## Custom Code Widgets
51+
52+
You can also use custom code widgets, allowing you to define your widget data with
53+
code, just like you would do with tinker.
54+
55+
Your code must define a `$result` variable containing the data to return to the choosen chart. You can use the `$dateFrom` and `$dateTo` variable.
56+
57+
Example custom code for a bar chart:
58+
59+
```php
60+
$result = [
61+
'a' => 10,
62+
'b' => 20
63+
];
64+
```
65+
66+
Note that for safety reasons, your code won't be allowed to perform any write operations on the database.
67+
You can only use the code to query data and transform it in-memory.
68+
Even if disabling write operations makes things sage, **remote code execution is always a
69+
very risky operation**. Be sure that your dashboard authorization is properly configured. You may want to disable custom code widgets by setting the `MODEL_STATS_CUSTOM_CODE` env variable to `false`.
70+
4871
## Dashboard Authorization
4972

5073
The ModelStats dashboard may be accessed at the `/stats` route. By default, you will only be able to access this
@@ -95,7 +118,8 @@ Please review [our security policy](../../security/policy) on how to report secu
95118

96119
## Inspiration
97120
- [Grafana](https://grafana.com/): for the dashboard/widget aspect
98-
- [Laravel Telescope](https://github.com/laravel/telescope): for many things in the package structure (front-end, authorization...)
121+
- [Laravel/Telescope](https://github.com/laravel/telescope): for many things in the package structure (front-end, authorization...)
122+
- [Spatie/Laravel-Web-Tinker](https://github.com/spatie/laravel-web-tinker): for their web tinker implementation, which is used for custom code widgets
99123

100124
## License
101125

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"require": {
1919
"php": "^8.0",
2020
"spatie/laravel-package-tools": "^1.4.3",
21-
"illuminate/contracts": "^8.37"
21+
"illuminate/contracts": "^8.37",
22+
"illuminate/support": "^5.8|^6.0|^7.0|^8.0",
23+
"laravel/tinker": "^1.0|^2.0"
2224
},
2325
"require-dev": {
2426
"brianium/paratest": "^6.2",

config/model-stats.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717
*/
1818

1919
'enabled' => env('MODEL_STATS_ENABLED', true),
20+
'allow_custom_code' => env('MODEL_STATS_CUSTOM_CODE', true),
2021

2122
/*
2223
|--------------------------------------------------------------------------
2324
| Route Middleware
2425
|--------------------------------------------------------------------------
2526
|
26-
| These middleware will be assigned to every Telescope route, giving you
27+
| These middleware will be assigned to every ModelStats route, giving you
2728
| the chance to add your own middleware to this list or change any of
2829
| the existing middleware. Or, you can simply stick with this list.
2930
|

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"dependencies": {
3131
"chart.js": "^2.9.4",
32+
"codemirror": "^5.63.1",
3233
"vue-chartjs": "^3.5.1",
3334
"vue-grid-layout": "^2.3.12"
3435
}

public/app.css

Lines changed: 255353 additions & 2 deletions
Large diffs are not rendered by default.

public/app.js

Lines changed: 117354 additions & 2 deletions
Large diffs are not rendered by default.

public/mix-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"/app.js": "/app.js?id=a15abf91bccabf4ac23f",
3-
"/app.css": "/app.css?id=1b1e2e3e366c06d4c19a"
2+
"/app.js": "/app.js?id=1d359e4a46e6fb75ddb1",
3+
"/app.css": "/app.css?id=19e0f7b7376df74f5ecb"
44
}

resources/js/components/App.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default {
3333
},
3434
3535
data: () => ({
36-
frontEndVersion: 5,
36+
frontEndVersion: 6,
3737
alert: {
3838
type: null,
3939
autoClose: 0,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<template>
2+
<div class="custom-code">
3+
<label v-if="label" class="text-gray-700 dark:text-gray-300 uppercase font-bold text-xs">
4+
{{ label }} - <button @click="executeCode" class="bg-blue-500 text-white text-xs p-1 rounded hover:bg-blue-400 pointer">Run Code</button>
5+
</label>
6+
<section class="border shadow-sm mt-1 relative">
7+
<textarea ref="codeEditor" class="z-0"/>
8+
<div class="pl-8 border-t bg-blue-100 bg-opacity-70 text-blue-900 absolute inset-x-0 bottom-0 text-sm backdrop-filter backdrop-blur-sm z-20">
9+
Make sure to put your resulting data in a variable named <span class="font-semibold">$result</span>.<br>You can use the variables <span class="font-semibold">$dateFrom</span> and <span class="font-semibold">$dateTo</span> anywhere in your code.
10+
</div>
11+
</section>
12+
<div v-if="error" class="text-sm text-red-500 -bottom-3"
13+
v-html="error"></div>
14+
</div>
15+
16+
</template>
17+
18+
<script>
19+
import 'codemirror/mode/php/php';
20+
import CodeMirror from 'codemirror';
21+
import axios from 'axios';
22+
23+
export default {
24+
data: () => ({
25+
value: '',
26+
codeEditor: null,
27+
error: null,
28+
}),
29+
props: ['path', 'label'],
30+
mounted() {
31+
const config = {
32+
autofocus: true,
33+
extraKeys: {
34+
'Cmd-Enter': () => {
35+
this.executeCode();
36+
},
37+
'Ctrl-Enter': () => {
38+
this.executeCode();
39+
},
40+
},
41+
indentWithTabs: true,
42+
lineNumbers: true,
43+
lineWrapping: true,
44+
mode: 'text/x-php',
45+
tabSize: 4,
46+
};
47+
this.codeEditor = CodeMirror.fromTextArea(this.$refs.codeEditor, config);
48+
this.codeEditor.on('change', editor => {
49+
localStorage.setItem('tinker-tool', editor.getValue());
50+
});
51+
let value = localStorage.getItem('tinker-tool');
52+
if (typeof value === 'string') {
53+
this.codeEditor.setValue(value);
54+
this.codeEditor.execCommand('goDocEnd');
55+
}
56+
},
57+
methods: {
58+
executeCode() {
59+
this.error = null
60+
let code = this.codeEditor.getValue().trim();
61+
if (code === '') {
62+
this.error = 'You must type some code to execute.'
63+
return;
64+
}
65+
this.$emit('execute', code);
66+
},
67+
},
68+
};
69+
</script>
70+
71+
<style src="codemirror/lib/codemirror.css"/>
72+
<style src="codemirror/theme/idea.css"/>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<template>
2+
<div class="w-full">
3+
<section class="output shadow-sm mt-2 border text-red-900 bg-red-100 p-4" v-if="value.code_executed && !value.valid_output">
4+
<p class="font-semibold text-red-900">
5+
Invalid Output Format
6+
</p>
7+
<p class="text-red-900" v-if="chartType == 'line_chart'">
8+
For line charts, output (in <span class="font-semibold">$result</span> var) must be an associative array,
9+
with dates as keys, and numbers as values.
10+
<br>Example:
11+
<pre>[
12+
"2020-02-21" => 12,
13+
"2020-02-22" => 14
14+
]</pre></p>
15+
<p class="text-red-900" v-else-if="chartType == 'bar_chart'">
16+
For bar charts, output (in <span class="font-semibold">$result</span> var) must be an associative array,
17+
with column name as keys, and numbers as values.
18+
<br>Example:
19+
<pre>[
20+
"facebook" => 12,
21+
"twitter" => 14
22+
]</pre>
23+
24+
</p>
25+
</section>
26+
<section class="output shadow-sm mt-2 border"
27+
:class="value.code_executed?['text-blue-900 bg-blue-100']:['text-red-900 bg-red-100']">
28+
<p class="p-4 pb-0 font-semibold" :class="value.code_executed?['text-blue-900']:['text-red-900']">
29+
Code Execution Result
30+
</p>
31+
<pre class="p-4 pt-0 overflow-x-scroll"><code v-html="value.output"></code></pre>
32+
</section>
33+
</div>
34+
</template>
35+
36+
<script>
37+
export default {
38+
props: ['value', 'chartType'],
39+
};
40+
</script>

resources/js/components/widgets/WidgetList.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
:key="widget.id">
2323
<component class="w-full h-full"
2424
@delete="deleteWidget(widget)"
25-
:is="typeToComponent[widget.aggregate_type]"
25+
:is="getWidgetComponent(widget)"
2626
:widget="widget"/>
2727
</grid-item>
2828
</grid-layout>
@@ -32,6 +32,7 @@
3232
import DailyCount from "./components/DailyCount";
3333
import GroupByCount from "./components/GroupByCount";
3434
import PeriodTotal from "./components/PeriodTotal";
35+
import CustomCode from "./components/CustomCode";
3536
import VueGridLayout from 'vue-grid-layout';
3637
import clone from 'lodash/clone'
3738
@@ -41,6 +42,7 @@ export default {
4142
PeriodTotal: PeriodTotal,
4243
DailyCount: DailyCount,
4344
GroupByCount: GroupByCount,
45+
CustomCode: CustomCode,
4446
GridLayout: VueGridLayout.GridLayout,
4547
GridItem: VueGridLayout.GridItem
4648
},
@@ -64,6 +66,12 @@ export default {
6466
},
6567
6668
methods: {
69+
getWidgetComponent(widget) {
70+
if (widget.custom_code) {
71+
return 'custom-code'
72+
}
73+
return this.typeToComponent[widget.aggregate_type]
74+
},
6775
initLayout() {
6876
this.gridLayout = clone(this.widgets).map((widget)=>{
6977
return {...widget.position, ...widget }

0 commit comments

Comments
 (0)