Compare commits
303 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
144949e9f4 | ||
|
|
d0a655f570 | ||
|
|
a9e14e4cb4 | ||
|
|
f7b52e506d | ||
|
|
4932997498 | ||
|
|
09ecc36bcd | ||
|
|
4d8abbaa12 | ||
|
|
9f143a7e05 | ||
|
|
e51efaa9e2 | ||
|
|
64edab9df4 | ||
|
|
85f408ae94 | ||
|
|
26ed91cad9 | ||
|
|
bb828b5996 | ||
|
|
2583e9ac9e | ||
|
|
acaced7122 | ||
|
|
795ba12f3a | ||
|
|
face099460 | ||
|
|
2690e9daef | ||
|
|
ec5ef65911 | ||
|
|
237b097684 | ||
|
|
6f6d98fae2 | ||
|
|
583f57f2af | ||
|
|
4270fd0d19 | ||
|
|
02eeea50e3 | ||
|
|
54207d1c0f | ||
|
|
16b9bf1529 | ||
|
|
1adeadd48e | ||
|
|
fada3c2ed7 | ||
|
|
c1cd6114de | ||
|
|
79d02060ef | ||
|
|
3ce67b0701 | ||
|
|
8aab840633 | ||
|
|
0ce8da44c1 | ||
|
|
856a3b8b96 | ||
|
|
0e59f580c3 | ||
|
|
d0cf72bbd2 | ||
|
|
65096e6b88 | ||
|
|
c31ed666b5 | ||
|
|
09e39ef6da | ||
|
|
75a9ef88d1 | ||
|
|
0eb81662d3 | ||
|
|
541134a88f | ||
|
|
ee8aada530 | ||
|
|
fa2ef65103 | ||
|
|
d73991cb0a | ||
|
|
a8e5203b58 | ||
|
|
bdf7cd7bf4 | ||
|
|
c3bd551b3a | ||
|
|
e045485d8c | ||
|
|
fa0992c49f | ||
|
|
21ea5a1981 | ||
|
|
a53a3b3343 | ||
|
|
ddb7c82575 | ||
|
|
fbb221fcac | ||
|
|
0d832ba833 | ||
|
|
870d70b4f2 | ||
|
|
33dbeb5552 | ||
|
|
9457bf2bc5 | ||
|
|
797b27af13 | ||
|
|
f6bbe3ecd8 | ||
|
|
f0c603d36f | ||
|
|
f87c6b2a10 | ||
|
|
4186b1cbf2 | ||
|
|
dce732ec3c | ||
|
|
0c744eded6 | ||
|
|
4c1a231811 | ||
|
|
c53179892c | ||
|
|
1f5af9ba2d | ||
|
|
45e2690a81 | ||
|
|
2ff504db09 | ||
|
|
0c89e58d8c | ||
|
|
e1dc75e2d8 | ||
|
|
6dd027c994 | ||
|
|
263c1f1d75 | ||
|
|
52ee688259 | ||
|
|
ce4c3a74b5 | ||
|
|
72e493bef0 | ||
|
|
14903c888c | ||
|
|
f2bdf0e9f1 | ||
|
|
02871fdd66 | ||
|
|
fd9f1bca8e | ||
|
|
739181a7ec | ||
|
|
a0c406251f | ||
|
|
c420e063bd | ||
|
|
24ba9fb598 | ||
|
|
c872764541 | ||
|
|
3e52924859 | ||
|
|
51cc895d12 | ||
|
|
0a1f33fede | ||
|
|
b539df007b | ||
|
|
1a6cb090fe | ||
|
|
5e2602c3dc | ||
|
|
8a388db603 | ||
|
|
e0018871b1 | ||
|
|
be508d9c9d | ||
|
|
e670e67ef5 | ||
|
|
32dae6e181 | ||
|
|
0f0a7ed119 | ||
|
|
e407a8c59e | ||
|
|
6c4d95ebfd | ||
|
|
7e54f2456e | ||
|
|
2419179877 | ||
|
|
58a120e5c8 | ||
|
|
19315b6174 | ||
|
|
b014f9afd9 | ||
|
|
794e128504 | ||
|
|
de25074861 | ||
|
|
4da70dd23a | ||
|
|
b840ee542a | ||
|
|
a51939df32 | ||
|
|
c3098f023a | ||
|
|
857a744c74 | ||
|
|
62fd3a207c | ||
|
|
ae3acfbc98 | ||
|
|
2bf32aeab5 | ||
|
|
3642407de8 | ||
|
|
f9333c5ffd | ||
|
|
b2fb45fe16 | ||
|
|
1864a4ea77 | ||
|
|
c6e34dd900 | ||
|
|
589b36d074 | ||
|
|
575ef6fec7 | ||
|
|
dd5c009d89 | ||
|
|
3fa26a6b25 | ||
|
|
1d14f5a8b6 | ||
|
|
7f5d5db0ef | ||
|
|
592909d890 | ||
|
|
5113f42781 | ||
|
|
60ddf07be9 | ||
|
|
c8f1b1b247 | ||
|
|
31f2807295 | ||
|
|
08edca4fbf | ||
|
|
cb2a740beb | ||
|
|
e0f6f4f563 | ||
|
|
34652110ca | ||
|
|
d4d4bda519 | ||
|
|
e83463a3cc | ||
|
|
33216fd197 | ||
|
|
b332332f79 | ||
|
|
ff81f7a9d0 | ||
|
|
b8379c4508 | ||
|
|
0fbd3a59bd | ||
|
|
b03d7b370f | ||
|
|
789a8b0cf0 | ||
|
|
c9dd02ace3 | ||
|
|
ad5906c7b6 | ||
|
|
afc40fcbe3 | ||
|
|
185f50787b | ||
|
|
6c33676f73 | ||
|
|
0290002444 | ||
|
|
fc5195e817 | ||
|
|
efd5c3dca1 | ||
|
|
2f438feec2 | ||
|
|
07ae9dfddf | ||
|
|
64575c5f7d | ||
|
|
e0fa339644 | ||
|
|
b72a86e514 | ||
|
|
62f0414afa | ||
|
|
200a02b87b | ||
|
|
da5dbeaf0f | ||
|
|
4b6d099f72 | ||
|
|
842661ada6 | ||
|
|
f5148c87c8 | ||
|
|
16164c0bbc | ||
|
|
f38ddb840b | ||
|
|
f86fe26ffe | ||
|
|
162360bf45 | ||
|
|
612aaa7880 | ||
|
|
e91f3fe53d | ||
|
|
f0fe4d64bc | ||
|
|
07cc6aca6a | ||
|
|
23bf81efbb | ||
|
|
a55105e5ee | ||
|
|
5832a426bc | ||
|
|
38dc709108 | ||
|
|
5696d3359b | ||
|
|
1b4fa84753 | ||
|
|
2db4eeec05 | ||
|
|
fe5e8aa5fe | ||
|
|
13e35d24a2 | ||
|
|
5e0fab88a3 | ||
|
|
bf8797264b | ||
|
|
14bde967bd | ||
|
|
596ce69789 | ||
|
|
c5491dcb73 | ||
|
|
3f6340f0a1 | ||
|
|
351f0870a9 | ||
|
|
f2638a4c5e | ||
|
|
2bd00d5ca0 | ||
|
|
00a40dd450 | ||
|
|
16fb75b56c | ||
|
|
094cf45ce3 | ||
|
|
d6984b3da9 | ||
|
|
53fc6f4cde | ||
|
|
49da10cf0b | ||
|
|
a3e10910bf | ||
|
|
3ff9edc424 | ||
|
|
69414d4083 | ||
|
|
e06b7a7775 | ||
|
|
c006e4d922 | ||
|
|
df6fe0863b | ||
|
|
d55a29911c | ||
|
|
d0e49d27fd | ||
|
|
1299bfc93e | ||
|
|
be999646d4 | ||
|
|
e57d32f122 | ||
|
|
08fa8da735 | ||
|
|
fe8d88497f | ||
|
|
4ab31a529e | ||
|
|
466725d5c2 | ||
|
|
908b337797 | ||
|
|
fea5258903 | ||
|
|
5521e4ea3e | ||
|
|
6cc01596cb | ||
|
|
0694538482 | ||
|
|
ac05ad40c0 | ||
|
|
239b0182fb | ||
|
|
4c57e5da4b | ||
|
|
298d039028 | ||
|
|
021a066074 | ||
|
|
7dc2f5a658 | ||
|
|
57bd8bafac | ||
|
|
7d5216aba9 | ||
|
|
076ab0c465 | ||
|
|
be37e89e16 | ||
|
|
0bdc841084 | ||
|
|
96086d0b5d | ||
|
|
9c8719e4bf | ||
|
|
d273dfa325 | ||
|
|
38b74bf5dc | ||
|
|
ab5cd3b279 | ||
|
|
55695c04a2 | ||
|
|
8c5ae39b10 | ||
|
|
06d2fc4d16 | ||
|
|
b392ca6d2f | ||
|
|
8eb216eadd | ||
|
|
e6300c47cd | ||
|
|
4743d3eeeb | ||
|
|
ccecf0dc36 | ||
|
|
2720e1ea92 | ||
|
|
44d561f0a1 | ||
|
|
482e092f49 | ||
|
|
9d11a5bc4e | ||
|
|
80aa321b57 | ||
|
|
4f6bce3817 | ||
|
|
9ef7a0e4af | ||
|
|
f5f984c6c5 | ||
|
|
cb9488b01c | ||
|
|
dba81c010a | ||
|
|
598f0ee7d6 | ||
|
|
0226e54f96 | ||
|
|
b591b87f48 | ||
|
|
99e4607500 | ||
|
|
00b4896c3d | ||
|
|
817a37c241 | ||
|
|
01bcada53f | ||
|
|
2a370ea9b2 | ||
|
|
7ea828ffcf | ||
|
|
3a98d93bf4 | ||
|
|
73a72b7f1f | ||
|
|
89545a99f3 | ||
|
|
2f0bc3bd9b | ||
|
|
8df4409866 | ||
|
|
1686a15839 | ||
|
|
397413edfe | ||
|
|
5b293fa421 | ||
|
|
2335d90af6 | ||
|
|
0778bee453 | ||
|
|
2b3916a98a | ||
|
|
1e90dfc556 | ||
|
|
07c001dc09 | ||
|
|
91a29302d9 | ||
|
|
dddd31741d | ||
|
|
dbe24f1d09 | ||
|
|
3fd44835fa | ||
|
|
93217b495c | ||
|
|
cdbbddda7a | ||
|
|
00df9296bf | ||
|
|
cb301d34a6 | ||
|
|
f7e6ebc69f | ||
|
|
01a13f31f3 | ||
|
|
e0c37faee8 | ||
|
|
f87f4970be | ||
|
|
4d409ea1ae | ||
|
|
ee30314a3e | ||
|
|
0d87f5afee | ||
|
|
1b83c3c5d6 | ||
|
|
34233fde2f | ||
|
|
e95dd5f6e7 | ||
|
|
901e6986a0 | ||
|
|
aa78929743 | ||
|
|
1879977b83 | ||
|
|
b4de579a74 | ||
|
|
23f15ff9e5 | ||
|
|
498e038bbb | ||
|
|
bb1f1c19cf | ||
|
|
74e3aa4e46 | ||
|
|
07a8e3ebcb | ||
|
|
89966dd006 | ||
|
|
45ac82b1dd | ||
|
|
d94e5c7965 | ||
|
|
e0c1b3199a | ||
|
|
fdbbdf7394 |
@@ -1,12 +1,11 @@
|
|||||||
version: '3.4'
|
version: '3.7'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app-dev:
|
app-dev:
|
||||||
container_name: trmm-app-dev
|
container_name: trmm-app-dev
|
||||||
image: node:16-alpine
|
image: node:20-alpine
|
||||||
restart: always
|
restart: always
|
||||||
command: /bin/sh -c "npm install --cache ~/.npm && npm run serve"
|
command: /bin/sh -c "npm install --cache ~/.npm && npm i -g @quasar/cli && npm run serve"
|
||||||
user: 1000:1000
|
|
||||||
working_dir: /workspace/web
|
working_dir: /workspace/web
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspace:cached
|
- ..:/workspace:cached
|
||||||
|
|||||||
8
.github/workflows/build-release.yml
vendored
8
.github/workflows/build-release.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
|||||||
name: Build web
|
name: Build web
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: "20.18.0"
|
||||||
|
|
||||||
- run: touch env-config.js
|
- run: touch env-config.js
|
||||||
|
|
||||||
@@ -29,6 +29,6 @@ jobs:
|
|||||||
run: tar -czvf trmm-web-${{github.ref_name}}.tar.gz dist/
|
run: tar -czvf trmm-web-${{github.ref_name}}.tar.gz dist/
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: trmm-web-${{github.ref_name}}.tar.gz
|
files: trmm-web-${{github.ref_name}}.tar.gz
|
||||||
|
|||||||
6
.github/workflows/frontend-linting.yml
vendored
6
.github/workflows/frontend-linting.yml
vendored
@@ -9,11 +9,11 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: "20.18.0"
|
||||||
- run: npm install
|
- run: npm install
|
||||||
|
|
||||||
- name: Run Prettier formatting
|
- name: Run Prettier formatting
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -9,13 +9,12 @@
|
|||||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"files.watcherExclude": {
|
"files.watcherExclude": {
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.git/objects/**": true,
|
"**/.git/objects/**": true,
|
||||||
"**/.git/subtree-cache/**": true,
|
"**/.git/subtree-cache/**": true,
|
||||||
"**/node_modules/": true,
|
"**/node_modules/": true,
|
||||||
"/node_modules/**": true,
|
"/node_modules/**": true,
|
||||||
"**/env/": true,
|
"**/env/": true,
|
||||||
"/env/**": true
|
"/env/**": true
|
||||||
}
|
},
|
||||||
}
|
"prettier.prettierPath": "./node_modules/prettier"
|
||||||
}
|
}
|
||||||
|
|||||||
9761
package-lock.json
generated
9761
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.101.25",
|
"version": "0.101.52",
|
||||||
"private": true,
|
"private": true,
|
||||||
"productName": "Tactical RMM",
|
"productName": "Tactical RMM",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,31 +10,38 @@
|
|||||||
"format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore"
|
"format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "1.16.4",
|
"@quasar/extras": "1.16.13",
|
||||||
"apexcharts": "3.41.0",
|
"@vueuse/core": "11.2.0",
|
||||||
"axios": "1.4.0",
|
"@vueuse/integrations": "11.2.0",
|
||||||
"dotenv": "16.3.1",
|
"@vueuse/shared": "11.2.0",
|
||||||
"qrcode.vue": "3.4.0",
|
"apexcharts": "3.54.1",
|
||||||
"quasar": "2.12.2",
|
"axios": "1.7.7",
|
||||||
"vue": "3.2.47",
|
"dotenv": "16.4.5",
|
||||||
"vue3-ace-editor": "2.2.2",
|
"monaco-editor": "0.50.0",
|
||||||
"vue3-apexcharts": "1.4.1",
|
"pinia": "2.2.6",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
|
"quasar": "2.17.2",
|
||||||
|
"vue": "3.5.12",
|
||||||
|
"vue-router": "4.4.5",
|
||||||
|
"vue3-apexcharts": "1.7.0",
|
||||||
"vuedraggable": "4.1.0",
|
"vuedraggable": "4.1.0",
|
||||||
"vue-router": "4.1.6",
|
"vuex": "4.1.0",
|
||||||
"vuex": "4.1.0"
|
"@xterm/xterm": "5.5.0",
|
||||||
|
"@xterm/addon-fit": "0.10.0",
|
||||||
|
"yaml": "2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@quasar/cli": "^2.2.1",
|
"@intlify/unplugin-vue-i18n": "4.0.0",
|
||||||
"@intlify/unplugin-vue-i18n": "^0.12.0",
|
"@quasar/app-vite": "1.10.2",
|
||||||
"@quasar/app-vite": "^1.4.3",
|
"@quasar/cli": "2.4.1",
|
||||||
"@types/node": "^20.3.3",
|
"@types/node": "22.7.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
"@typescript-eslint/eslint-plugin": "7.16.0",
|
||||||
"@typescript-eslint/parser": "^5.61.0",
|
"@typescript-eslint/parser": "7.16.0",
|
||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.20",
|
||||||
"eslint": "8.44.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-prettier": "8.8.0",
|
"eslint-config-prettier": "9.1.0",
|
||||||
"eslint-plugin-vue": "8.7.1",
|
"eslint-plugin-vue": "8.7.1",
|
||||||
"prettier": "2.8.8",
|
"prettier": "3.3.3",
|
||||||
"typescript": "5.1.6"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// Configuration for your app
|
// Configuration for your app
|
||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||||
|
|
||||||
|
const { mergeConfig } = require("vite");
|
||||||
const { configure } = require("quasar/wrappers");
|
const { configure } = require("quasar/wrappers");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
@@ -29,15 +30,15 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
// app boot file (/src/boot)
|
// app boot file (/src/boot)
|
||||||
// --> boot files are part of "main.js"
|
// --> boot files are part of "main.js"
|
||||||
// https://v2.quasar.dev/quasar-cli-vite/boot-files
|
// https://v2.quasar.dev/quasar-cli-vite/boot-files
|
||||||
boot: ["axios"],
|
boot: ["pinia", "axios", "monaco", "integrations"],
|
||||||
|
|
||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
|
||||||
css: ["app.sass"],
|
css: ["app.sass"],
|
||||||
|
|
||||||
// https://github.com/quasarframework/quasar/tree/dev/extras
|
// https://github.com/quasarframework/quasar/tree/dev/extras
|
||||||
extras: [
|
extras: [
|
||||||
// 'ionicons-v4',
|
"ionicons-v4",
|
||||||
"mdi-v5",
|
"mdi-v7",
|
||||||
"fontawesome-v6",
|
"fontawesome-v6",
|
||||||
// 'eva-icons',
|
// 'eva-icons',
|
||||||
// 'themify',
|
// 'themify',
|
||||||
@@ -51,8 +52,8 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||||
build: {
|
build: {
|
||||||
target: {
|
target: {
|
||||||
browser: ["es2021"],
|
browser: ["es2022"],
|
||||||
node: "node16",
|
node: "node20",
|
||||||
},
|
},
|
||||||
|
|
||||||
vueRouterMode: "history", // available values: 'hash', 'history'
|
vueRouterMode: "history", // available values: 'hash', 'history'
|
||||||
@@ -78,9 +79,22 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
// polyfillModulePreload: true,
|
// polyfillModulePreload: true,
|
||||||
distDir: "dist/",
|
distDir: "dist/",
|
||||||
|
|
||||||
// extendViteConf (viteConf) {},
|
/* eslint-disable quotes */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
extendViteConf(viteConf, { isServer, isClient }) {
|
||||||
|
viteConf.build = mergeConfig(viteConf.build, {
|
||||||
|
chunkSizeWarningLimit: 1600,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: `[hash].js`,
|
||||||
|
chunkFileNames: `[hash].js`,
|
||||||
|
assetFileNames: `[hash].[ext]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* eslint-enable quotes */
|
||||||
// viteVuePluginOptions: {},
|
// viteVuePluginOptions: {},
|
||||||
|
|
||||||
// vitePlugins: []
|
// vitePlugins: []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,34 @@ export async function resetTwoFactor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessions api
|
||||||
|
export async function fetchUserSessions(id) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(`${baseUrl}/users/${id}/sessions/`);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllUserSessions(id) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.delete(`${baseUrl}/users/${id}/sessions/`);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserSession(id) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.delete(`${baseUrl}/sessions/${id}/`);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// role api function
|
// role api function
|
||||||
export async function fetchRoles(params = {}) {
|
export async function fetchRoles(params = {}) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function openAgentWindow(agent_id) {
|
|||||||
|
|
||||||
export function runRemoteBackground(agent_id, agentPlatform) {
|
export function runRemoteBackground(agent_id, agentPlatform) {
|
||||||
const url = router.resolve(
|
const url = router.resolve(
|
||||||
`/remotebackground/${agent_id}?agentPlatform=${agentPlatform}`
|
`/remotebackground/${agent_id}?agentPlatform=${agentPlatform}`,
|
||||||
).href;
|
).href;
|
||||||
openURL(url, null, {
|
openURL(url, null, {
|
||||||
popup: true,
|
popup: true,
|
||||||
@@ -129,7 +129,7 @@ export async function refreshAgentWMI(agent_id) {
|
|||||||
export async function runScript(agent_id, payload) {
|
export async function runScript(agent_id, payload) {
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
`${baseUrl}/${agent_id}/runscript/`,
|
`${baseUrl}/${agent_id}/runscript/`,
|
||||||
payload
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ export async function fetchAgentProcesses(agent_id, params = {}) {
|
|||||||
export async function killAgentProcess(agent_id, pid, params = {}) {
|
export async function killAgentProcess(agent_id, pid, params = {}) {
|
||||||
const { data } = await axios.delete(
|
const { data } = await axios.delete(
|
||||||
`${baseUrl}/${agent_id}/processes/${pid}/`,
|
`${baseUrl}/${agent_id}/processes/${pid}/`,
|
||||||
{ params: params }
|
{ params: params },
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ export async function fetchAgentEventLog(agent_id, logType, days, params = {}) {
|
|||||||
try {
|
try {
|
||||||
const { data } = await axios.get(
|
const { data } = await axios.get(
|
||||||
`${baseUrl}/${agent_id}/eventlog/${logType}/${days}/`,
|
`${baseUrl}/${agent_id}/eventlog/${logType}/${days}/`,
|
||||||
{ params: params }
|
{ params: params },
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -191,10 +191,15 @@ export async function agentRebootNow(agent_id) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function agentShutdown(agent_id) {
|
||||||
|
const { data } = await axios.post(`${baseUrl}/${agent_id}/shutdown/`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendAgentRecoverMesh(agent_id, params = {}) {
|
export async function sendAgentRecoverMesh(agent_id, params = {}) {
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
`${baseUrl}/${agent_id}/meshcentral/recover/`,
|
`${baseUrl}/${agent_id}/meshcentral/recover/`,
|
||||||
{ params: params }
|
{ params: params },
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/api/alerts.ts
Normal file
13
src/api/alerts.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import type { AlertTemplate } from "@/types/alerts";
|
||||||
|
|
||||||
|
export async function saveAlertTemplate(id: number, payload: AlertTemplate) {
|
||||||
|
const { data } = await axios.put(`alerts/templates/${id}/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAlertTemplate(payload: AlertTemplate) {
|
||||||
|
const { data } = await axios.post("alerts/templates/", payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@ export async function resetCheck(id) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetAllChecksStatus(agent_id) {
|
||||||
|
const { data } = await axios.post(`${baseUrl}/${agent_id}/resetall/`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runAgentChecks(agent_id) {
|
export async function runAgentChecks(agent_id) {
|
||||||
const { data } = await axios.post(`${baseUrl}/${agent_id}/run/`);
|
const { data } = await axios.post(`${baseUrl}/${agent_id}/run/`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { openURL } from "quasar";
|
|
||||||
|
|
||||||
const baseUrl = "/core";
|
|
||||||
|
|
||||||
export async function fetchCustomFields(params = {}) {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get(`${baseUrl}/customfields/`, {
|
|
||||||
params: params,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDashboardInfo(params = {}) {
|
|
||||||
const { data } = await axios.get(`${baseUrl}/dashinfo/`, { params: params });
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchURLActions(params = {}) {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get(`${baseUrl}/urlaction/`, {
|
|
||||||
params: params,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runURLAction(payload) {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.patch(`${baseUrl}/urlaction/run/`, payload);
|
|
||||||
openURL(data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateScript(payload) {
|
|
||||||
const { data } = await axios.post(`${baseUrl}/openai/generate/`, payload);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
104
src/api/core.ts
Normal file
104
src/api/core.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { openURL } from "quasar";
|
||||||
|
import { router } from "@/router";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
URLAction,
|
||||||
|
TestRunURLActionRequest,
|
||||||
|
TestRunURLActionResponse,
|
||||||
|
} from "@/types/core/urlactions";
|
||||||
|
|
||||||
|
import type { CoreSetting } from "@/types/core/settings";
|
||||||
|
|
||||||
|
const baseUrl = "/core";
|
||||||
|
|
||||||
|
export async function fetchCoreSettings(params = {}): Promise<CoreSetting> {
|
||||||
|
const { data } = await axios.get("/core/settings/", { params: params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboardInfo(params = {}) {
|
||||||
|
const { data } = await axios.get(`${baseUrl}/dashinfo/`, { params: params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCustomFields(params = {}) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(`${baseUrl}/customfields/`, {
|
||||||
|
params: params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchURLActions(params = {}): Promise<URLAction[]> {
|
||||||
|
const { data } = await axios.get(`${baseUrl}/urlaction/`, {
|
||||||
|
params: params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveURLAction(action: URLAction) {
|
||||||
|
const { data } = await axios.post(`${baseUrl}/urlaction/`, action);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editURLAction(id: number, action: URLAction) {
|
||||||
|
const { data } = await axios.put(`${baseUrl}/urlaction/${id}/`, action);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeURLAction(id: number) {
|
||||||
|
const { data } = await axios.delete(`${baseUrl}/urlaction/${id}/`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunURLActionRequest {
|
||||||
|
agent_id?: string;
|
||||||
|
client?: number;
|
||||||
|
site?: number;
|
||||||
|
action: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runURLAction(payload: RunURLActionRequest) {
|
||||||
|
const { data } = await axios.patch(`${baseUrl}/urlaction/run/`, payload);
|
||||||
|
openURL(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTestURLAction(
|
||||||
|
payload: TestRunURLActionRequest,
|
||||||
|
): Promise<TestRunURLActionResponse> {
|
||||||
|
const { data } = await axios.post(`${baseUrl}/urlaction/run/test/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkWebTermPerms(): Promise<{
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
}> {
|
||||||
|
const ret = await axios.post(`${baseUrl}/webtermperms/`);
|
||||||
|
return { message: ret.data, status: ret.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openWebTerminal(): void {
|
||||||
|
const url: string = router.resolve("/webterm").href;
|
||||||
|
openURL(url, undefined, {
|
||||||
|
popup: true,
|
||||||
|
scrollbars: false,
|
||||||
|
location: false,
|
||||||
|
status: false,
|
||||||
|
toolbar: false,
|
||||||
|
menubar: false,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Build out type for openai payload
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export async function generateScript(payload: any) {
|
||||||
|
const { data } = await axios.post(`${baseUrl}/openai/generate/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ export async function testScript(agent_id, payload) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testScriptOnServer(payload) {
|
||||||
|
const { data } = await axios.post("core/serverscript/test/", payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveScript(payload) {
|
export async function saveScript(payload) {
|
||||||
const { data } = await axios.post(`${baseUrl}/`, payload);
|
const { data } = await axios.post(`${baseUrl}/`, payload);
|
||||||
return data;
|
return data;
|
||||||
@@ -56,7 +61,7 @@ export async function fetchScriptSnippet(id, params = {}) {
|
|||||||
export async function editScriptSnippet(payload) {
|
export async function editScriptSnippet(payload) {
|
||||||
const { data } = await axios.put(
|
const { data } = await axios.put(
|
||||||
`${baseUrl}/snippets/${payload.id}/`,
|
`${baseUrl}/snippets/${payload.id}/`,
|
||||||
payload
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/assets/trmm_256.png
Normal file
BIN
src/assets/trmm_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { Notify } from "quasar";
|
import { Notify } from "quasar";
|
||||||
|
|
||||||
export const getBaseUrl = () => {
|
export const getBaseUrl = () => {
|
||||||
@@ -9,13 +10,24 @@ export const getBaseUrl = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ({ app, router, store }) {
|
export function setErrorMessage(data, message) {
|
||||||
|
console.log(data);
|
||||||
|
return [
|
||||||
|
() => {
|
||||||
|
message;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ({ app, router }) {
|
||||||
app.config.globalProperties.$axios = axios;
|
app.config.globalProperties.$axios = axios;
|
||||||
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
axios.interceptors.request.use(
|
axios.interceptors.request.use(
|
||||||
function (config) {
|
function (config) {
|
||||||
|
const auth = useAuthStore();
|
||||||
config.baseURL = getBaseUrl();
|
config.baseURL = getBaseUrl();
|
||||||
const token = store.state.token;
|
const token = auth.token;
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
config.headers.Authorization = `Token ${token}`;
|
config.headers.Authorization = `Token ${token}`;
|
||||||
}
|
}
|
||||||
@@ -23,7 +35,7 @@ export default function ({ app, router, store }) {
|
|||||||
},
|
},
|
||||||
function (err) {
|
function (err) {
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
@@ -54,12 +66,20 @@ export default function ({ app, router, store }) {
|
|||||||
// perms
|
// perms
|
||||||
else if (error.response.status === 403) {
|
else if (error.response.status === 403) {
|
||||||
// don't notify user if method is GET
|
// don't notify user if method is GET
|
||||||
if (error.config.method === "get" || error.config.method === "patch")
|
if (
|
||||||
|
error.config.method === "get" ||
|
||||||
|
error.config.method === "patch" ||
|
||||||
|
error.config.url === "accounts/ssoproviders/token/"
|
||||||
|
)
|
||||||
return Promise.reject({ ...error });
|
return Promise.reject({ ...error });
|
||||||
text = error.response.data.detail;
|
text = error.response.data.detail;
|
||||||
}
|
}
|
||||||
// catch all for other 400 error messages
|
// catch all for other 400 error messages
|
||||||
else if (error.response.status >= 400 && error.response.status < 500) {
|
else if (
|
||||||
|
error.response.status >= 400 &&
|
||||||
|
error.response.status < 500 &&
|
||||||
|
error.response.status !== 423
|
||||||
|
) {
|
||||||
if (error.config.responseType === "blob") {
|
if (error.config.responseType === "blob") {
|
||||||
text = (await error.response.data.text()).replace(/^"|"$/g, "");
|
text = (await error.response.data.text()).replace(/^"|"$/g, "");
|
||||||
} else if (error.response.data.non_field_errors) {
|
} else if (error.response.data.non_field_errors) {
|
||||||
@@ -74,7 +94,7 @@ export default function ({ app, router, store }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text || error.response) {
|
if ((text || error.response) && error.response.status !== 423) {
|
||||||
Notify.create({
|
Notify.create({
|
||||||
color: "negative",
|
color: "negative",
|
||||||
message: text ? text : "",
|
message: text ? text : "",
|
||||||
@@ -86,6 +106,6 @@ export default function ({ app, router, store }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject({ ...error });
|
return Promise.reject({ ...error });
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/boot/integrations.ts
Normal file
10
src/boot/integrations.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { boot } from "quasar/wrappers";
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
|
app.config.globalProperties.$integrations = {
|
||||||
|
fileBarIntegrations: [],
|
||||||
|
clientMenuIntegrations: [],
|
||||||
|
siteMenuIntegrations: [],
|
||||||
|
agentMenuIntegrations: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
23
src/boot/monaco.ts
Normal file
23
src/boot/monaco.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||||
|
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||||
|
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||||
|
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||||
|
|
||||||
|
import { boot } from "quasar/wrappers";
|
||||||
|
|
||||||
|
export default boot(() => {
|
||||||
|
self.MonacoEnvironment = {
|
||||||
|
getWorker(_: unknown, label: string) {
|
||||||
|
if (label === "json") {
|
||||||
|
return new jsonWorker();
|
||||||
|
}
|
||||||
|
if (label === "css" || label === "scss" || label === "less") {
|
||||||
|
return new cssWorker();
|
||||||
|
}
|
||||||
|
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||||
|
return new htmlWorker();
|
||||||
|
}
|
||||||
|
return new editorWorker();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
11
src/boot/pinia.ts
Normal file
11
src/boot/pinia.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { boot } from "quasar/wrappers";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
app.use(pinia);
|
||||||
|
|
||||||
|
// You can add Pinia plugins here
|
||||||
|
// pinia.use(SomePiniaPlugin)
|
||||||
|
});
|
||||||
@@ -1,157 +1,202 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="width: 900px; max-width: 90vw">
|
<q-card style="width: 65vw; max-width: 70vw; min-height: 50vh">
|
||||||
<q-card>
|
<q-bar>
|
||||||
<q-bar>
|
<q-btn
|
||||||
|
ref="refresh"
|
||||||
|
@click="getUsers"
|
||||||
|
class="q-mr-sm"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
push
|
||||||
|
icon="refresh"
|
||||||
|
/>User Administration
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div class="q-gutter-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
ref="refresh"
|
ref="new"
|
||||||
@click="getUsers"
|
label="New"
|
||||||
class="q-mr-sm"
|
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
push
|
push
|
||||||
icon="refresh"
|
unelevated
|
||||||
/>User Administration
|
no-caps
|
||||||
<q-space />
|
icon="add"
|
||||||
<q-btn dense flat icon="close" v-close-popup>
|
@click="showAddUserModal"
|
||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
/>
|
||||||
</q-btn>
|
|
||||||
</q-bar>
|
|
||||||
<div class="q-pa-md">
|
|
||||||
<div class="q-gutter-sm">
|
|
||||||
<q-btn
|
|
||||||
ref="new"
|
|
||||||
label="New"
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
push
|
|
||||||
unelevated
|
|
||||||
no-caps
|
|
||||||
icon="add"
|
|
||||||
@click="showAddUserModal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<q-table
|
|
||||||
dense
|
|
||||||
:rows="users"
|
|
||||||
:columns="columns"
|
|
||||||
v-model:pagination="pagination"
|
|
||||||
row-key="id"
|
|
||||||
binary-state-sort
|
|
||||||
hide-pagination
|
|
||||||
virtual-scroll
|
|
||||||
>
|
|
||||||
<!-- header slots -->
|
|
||||||
<template v-slot:header-cell-is_active="props">
|
|
||||||
<q-th :props="props" auto-width>
|
|
||||||
<q-icon name="power_settings_new" size="1.5em">
|
|
||||||
<q-tooltip>Enable User</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</q-th>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- No data Slot -->
|
|
||||||
<template v-slot:no-data>
|
|
||||||
<div class="full-width row flex-center q-gutter-sm">
|
|
||||||
<span v-if="users.length === 0">No Users</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- body slots -->
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr
|
|
||||||
:props="props"
|
|
||||||
class="cursor-pointer"
|
|
||||||
@dblclick="showEditUserModal(props.row)"
|
|
||||||
>
|
|
||||||
<!-- context menu -->
|
|
||||||
<q-menu context-menu>
|
|
||||||
<q-list dense style="min-width: 200px">
|
|
||||||
<q-item
|
|
||||||
clickable
|
|
||||||
v-close-popup
|
|
||||||
@click="showEditUserModal(props.row)"
|
|
||||||
>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="edit" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>Edit</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item
|
|
||||||
clickable
|
|
||||||
v-close-popup
|
|
||||||
@click="deleteUser(props.row)"
|
|
||||||
:disable="props.row.username === logged_in_user"
|
|
||||||
>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="delete" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>Delete</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-separator></q-separator>
|
|
||||||
|
|
||||||
<q-item
|
|
||||||
clickable
|
|
||||||
v-close-popup
|
|
||||||
@click="ResetPassword(props.row)"
|
|
||||||
id="context-reset"
|
|
||||||
>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="autorenew" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>Reset Password</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item
|
|
||||||
clickable
|
|
||||||
v-close-popup
|
|
||||||
@click="reset2FA(props.row)"
|
|
||||||
id="context-reset"
|
|
||||||
>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="autorenew" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>Reset Two-Factor Auth</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-separator></q-separator>
|
|
||||||
|
|
||||||
<q-item clickable v-close-popup>
|
|
||||||
<q-item-section>Close</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-menu>
|
|
||||||
<!-- enabled checkbox -->
|
|
||||||
<q-td>
|
|
||||||
<q-checkbox
|
|
||||||
dense
|
|
||||||
@update:model-value="toggleEnabled(props.row)"
|
|
||||||
v-model="props.row.is_active"
|
|
||||||
:disable="props.row.username === logged_in_user"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
<q-td>{{ props.row.username }}</q-td>
|
|
||||||
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
|
|
||||||
<q-td>{{ props.row.email }}</q-td>
|
|
||||||
<q-td v-if="props.row.last_login">{{
|
|
||||||
formatDate(props.row.last_login)
|
|
||||||
}}</q-td>
|
|
||||||
<q-td v-else>Never</q-td>
|
|
||||||
<q-td>{{ props.row.last_login_ip }}</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
<q-table
|
||||||
</div>
|
dense
|
||||||
|
:rows="users"
|
||||||
|
:columns="columns"
|
||||||
|
v-model:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
binary-state-sort
|
||||||
|
hide-pagination
|
||||||
|
virtual-scroll
|
||||||
|
>
|
||||||
|
<!-- header slots -->
|
||||||
|
<template v-slot:header-cell-is_active="props">
|
||||||
|
<q-th :props="props" auto-width>
|
||||||
|
<q-icon name="power_settings_new" size="1.5em">
|
||||||
|
<q-tooltip>Enable User</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:header-cell-sso="props">
|
||||||
|
<q-th :props="props" auto-width></q-th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No data Slot -->
|
||||||
|
<template v-slot:no-data>
|
||||||
|
<div class="full-width row flex-center q-gutter-sm">
|
||||||
|
<span v-if="users.length === 0">No Users</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- body slots -->
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr
|
||||||
|
:props="props"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@dblclick="showEditUserModal(props.row)"
|
||||||
|
>
|
||||||
|
<!-- context menu -->
|
||||||
|
<q-menu context-menu>
|
||||||
|
<q-list dense style="min-width: 200px">
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="showEditUserModal(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="edit" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Edit</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="deleteUser(props.row)"
|
||||||
|
:disable="props.row.username === logged_in_user"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="delete" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Delete</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="ResetPassword(props.row)"
|
||||||
|
id="context-reset"
|
||||||
|
:disable="props.row.social_accounts.length !== 0"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="autorenew" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Reset Password</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="reset2FA(props.row)"
|
||||||
|
id="context-reset"
|
||||||
|
:disable="props.row.social_accounts.length !== 0"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="autorenew" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Reset Two-Factor Auth</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="showSSOAccounts(props.row)"
|
||||||
|
id="context-reset"
|
||||||
|
:disable="props.row.social_accounts.length === 0"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="groups" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Show Connected SSO Accounts</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="showSessions(props.row)"
|
||||||
|
id="context-reset"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="groups" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Show Active Sessions</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup>
|
||||||
|
<q-item-section>Close</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
<!-- enabled checkbox -->
|
||||||
|
<q-td>
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
@update:model-value="toggleEnabled(props.row)"
|
||||||
|
v-model="props.row.is_active"
|
||||||
|
:disable="props.row.username === logged_in_user"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-chip
|
||||||
|
v-if="props.row.social_accounts.length > 0"
|
||||||
|
color="primary"
|
||||||
|
dense
|
||||||
|
>SSO</q-chip
|
||||||
|
>
|
||||||
|
</q-td>
|
||||||
|
<q-td>{{ props.row.username }}</q-td>
|
||||||
|
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
|
||||||
|
<q-td>{{ props.row.email }}</q-td>
|
||||||
|
<q-td v-if="props.row.last_login">{{
|
||||||
|
formatDate(props.row.last_login)
|
||||||
|
}}</q-td>
|
||||||
|
<q-td v-else>Never</q-td>
|
||||||
|
<q-td>{{ props.row.last_login_ip }}</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import mixins from "@/mixins/mixins";
|
import mixins from "@/mixins/mixins";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { mapState, useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
|
import { useQuasar } from "quasar";
|
||||||
|
|
||||||
|
import { mapState as piniaMapState } from "pinia";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import UserForm from "@/components/modals/admin/UserForm.vue";
|
import UserForm from "@/components/modals/admin/UserForm.vue";
|
||||||
import UserResetPasswordForm from "@/components/modals/admin/UserResetPasswordForm.vue";
|
import UserResetPasswordForm from "@/components/modals/admin/UserResetPasswordForm.vue";
|
||||||
|
import SSOAccountsTable from "@/ee/sso/components/SSOAccountsTable.vue";
|
||||||
|
import UserSessionsTable from "@/components/accounts/UserSessionsTable.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "AdminManager",
|
name: "AdminManager",
|
||||||
@@ -161,8 +206,30 @@ export default {
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const formatDate = computed(() => store.getters.formatDate);
|
const formatDate = computed(() => store.getters.formatDate);
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
function showSSOAccounts(user) {
|
||||||
|
$q.dialog({
|
||||||
|
component: SSOAccountsTable,
|
||||||
|
componentProps: {
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showSessions(user) {
|
||||||
|
$q.dialog({
|
||||||
|
component: UserSessionsTable,
|
||||||
|
componentProps: {
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
formatDate,
|
formatDate,
|
||||||
|
showSSOAccounts,
|
||||||
|
showSessions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -175,6 +242,13 @@ export default {
|
|||||||
field: "is_active",
|
field: "is_active",
|
||||||
align: "left",
|
align: "left",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "sso",
|
||||||
|
label: "",
|
||||||
|
field: "sso",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "username",
|
name: "username",
|
||||||
label: "Username",
|
label: "Username",
|
||||||
@@ -316,7 +390,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...piniaMapState(useAuthStore, {
|
||||||
logged_in_user: (state) => state.username,
|
logged_in_user: (state) => state.username,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
<template v-slot:header-cell-plat="props">
|
<template v-slot:header-cell-plat="props">
|
||||||
<q-th auto-width :props="props"></q-th>
|
<q-th auto-width :props="props"></q-th>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-slot:header-cell-mon-type="props">
|
||||||
|
<q-th auto-width :props="props"></q-th>
|
||||||
|
</template>
|
||||||
<template v-slot:header-cell-checks-status="props">
|
<template v-slot:header-cell-checks-status="props">
|
||||||
<q-th :props="props">
|
<q-th :props="props">
|
||||||
<q-icon name="fas fa-check-double" size="1.2em">
|
<q-icon name="fas fa-check-double" size="1.2em">
|
||||||
@@ -170,7 +173,7 @@
|
|||||||
overdueAlert(
|
overdueAlert(
|
||||||
'dashboard',
|
'dashboard',
|
||||||
props.row,
|
props.row,
|
||||||
props.row.overdue_dashboard_alert
|
props.row.overdue_dashboard_alert,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
v-model="props.row.overdue_dashboard_alert"
|
v-model="props.row.overdue_dashboard_alert"
|
||||||
@@ -206,6 +209,20 @@
|
|||||||
</q-icon>
|
</q-icon>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
|
<q-td key="mon-type" :props="props">
|
||||||
|
<q-icon
|
||||||
|
v-if="props.row.monitoring_type === 'server'"
|
||||||
|
name="dns"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<q-tooltip>Server</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon v-else name="computer" size="sm" color="primary">
|
||||||
|
<q-tooltip>Workstation</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
<q-td key="checks-status" :props="props">
|
<q-td key="checks-status" :props="props">
|
||||||
<q-icon
|
<q-icon
|
||||||
v-if="props.row.maintenance_mode"
|
v-if="props.row.maintenance_mode"
|
||||||
@@ -431,8 +448,8 @@ export default {
|
|||||||
return false;
|
return false;
|
||||||
else if (availability === "expired") {
|
else if (availability === "expired") {
|
||||||
let now = new Date();
|
let now = new Date();
|
||||||
let lastSeen = date.extractDate(row.last_seen, "MM DD YYYY HH:mm");
|
let last_seen = new Date(row.last_seen);
|
||||||
let diff = date.getDateDiff(now, lastSeen, "days");
|
let diff = date.getDateDiff(now, last_seen, "days");
|
||||||
if (diff < 30) return false;
|
if (diff < 30) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "resolved_action_name",
|
name: "resolved_action_name",
|
||||||
label: "Resolve Action",
|
label: "Resolved Action",
|
||||||
field: "resolved_action_name",
|
field: "resolved_action_name",
|
||||||
align: "left",
|
align: "left",
|
||||||
},
|
},
|
||||||
@@ -326,7 +326,7 @@ export default {
|
|||||||
this.refresh();
|
this.refresh();
|
||||||
this.$q.loading.hide();
|
this.$q.loading.hide();
|
||||||
this.notifySuccess(
|
this.notifySuccess(
|
||||||
`Alert template ${template.name} was deleted!`
|
`Alert template ${template.name} was deleted!`,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -149,6 +149,49 @@
|
|||||||
</q-list>
|
</q-list>
|
||||||
</q-menu>
|
</q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
<!-- integrations -->
|
||||||
|
<q-btn size="md" dense no-caps flat label="Reporting">
|
||||||
|
<q-menu auto-close>
|
||||||
|
<q-list
|
||||||
|
v-if="
|
||||||
|
$integrations &&
|
||||||
|
$integrations.fileBarIntegrations &&
|
||||||
|
$integrations.fileBarIntegrations.length > 0
|
||||||
|
"
|
||||||
|
dense
|
||||||
|
style="min-width: 100px"
|
||||||
|
>
|
||||||
|
<q-item
|
||||||
|
v-for="integration in $integrations.fileBarIntegrations"
|
||||||
|
:key="integration.name"
|
||||||
|
@click="
|
||||||
|
integration.type === 'dialog'
|
||||||
|
? $q.dialog({ component: integration.component })
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:to="integration.type === 'route' ? integration.uri : undefined"
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
>
|
||||||
|
<q-item-section>{{ integration.name }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-list v-else dense style="min-width: 100px">
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="
|
||||||
|
notifyWarning(
|
||||||
|
'Reporting feature requires a valid code signing token. Please check the docs for more info.',
|
||||||
|
10000,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<q-item-section>Reporting Manager</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
<!-- help -->
|
<!-- help -->
|
||||||
<q-btn v-if="!hosted" size="md" dense no-caps flat label="Help">
|
<q-btn v-if="!hosted" size="md" dense no-caps flat label="Help">
|
||||||
<q-menu auto-close>
|
<q-menu auto-close>
|
||||||
@@ -234,6 +277,9 @@ import ServerMaintenance from "@/components/modals/core/ServerMaintenance.vue";
|
|||||||
import CodeSign from "@/components/modals/coresettings/CodeSign.vue";
|
import CodeSign from "@/components/modals/coresettings/CodeSign.vue";
|
||||||
import PermissionsManager from "@/components/accounts/PermissionsManager.vue";
|
import PermissionsManager from "@/components/accounts/PermissionsManager.vue";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
import { notifyWarning } from "@/utils/notify";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "FileBar",
|
name: "FileBar",
|
||||||
mixins: [mixins],
|
mixins: [mixins],
|
||||||
@@ -396,6 +442,11 @@ export default {
|
|||||||
component: DeploymentTable,
|
component: DeploymentTable,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
showReportsManager() {
|
||||||
|
this.$q.dialog({
|
||||||
|
component: ReportsManager,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
287
src/components/FileBrowser.vue
Normal file
287
src/components/FileBrowser.vue
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<q-splitter v-model="splitter" :style="{ height: height }">
|
||||||
|
<!-- folder view -->
|
||||||
|
<template #before>
|
||||||
|
<q-tree
|
||||||
|
ref="folderTree"
|
||||||
|
v-model:selected="selectedTreeNode"
|
||||||
|
node-key="id"
|
||||||
|
filter="filter"
|
||||||
|
no-selection-unset
|
||||||
|
selected-color="primary"
|
||||||
|
:filter-method="(node: QTreeFileNode/*, filter */) => node.type === 'folder'"
|
||||||
|
:nodes="nodes"
|
||||||
|
@update:selected="onFolderSelection"
|
||||||
|
@lazy-load="loadNodeChildren"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- file/folder list -->
|
||||||
|
<template #after>
|
||||||
|
<q-table
|
||||||
|
ref="tableRef"
|
||||||
|
v-model:selected="selectedTableNodes"
|
||||||
|
:rows="tableRows"
|
||||||
|
:columns="tableColumns"
|
||||||
|
:loading="loading"
|
||||||
|
dense
|
||||||
|
no-data-label="Folder is Empty"
|
||||||
|
binary-state-sort
|
||||||
|
virtual-scroll
|
||||||
|
selection="multiple"
|
||||||
|
row-key="id"
|
||||||
|
:pagination="{ sortBy: 'type', descending: true }"
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
:table-class="{
|
||||||
|
'table-bgcolor': !$q.dark.isActive,
|
||||||
|
'table-bgcolor-dark': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
:style="{ 'max-height': height }"
|
||||||
|
class="tbl-sticky"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<slot
|
||||||
|
name="action-bar"
|
||||||
|
v-bind="{ selectedTreeNode: folderTree?.getNodeByKey(selectedTreeNode) as QTreeFileNode, selectedTableNodes: selectedTableNodes as FileSystemNodeTable[]}"
|
||||||
|
></slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="slotProps">
|
||||||
|
<q-tr
|
||||||
|
class="cursor-pointer"
|
||||||
|
@dblclick.prevent="doubleClickTableRow(slotProps.row)"
|
||||||
|
>
|
||||||
|
<!-- Context Menu -->
|
||||||
|
<slot
|
||||||
|
name="table-menu"
|
||||||
|
v-bind="{ item: slotProps.row as FileSystemNodeTable, selectedTreeNode: folderTree?.getNodeByKey(selectedTreeNode) as QTreeFileNode }"
|
||||||
|
></slot>
|
||||||
|
|
||||||
|
<!-- rows -->
|
||||||
|
<q-td>
|
||||||
|
<q-checkbox v-model="slotProps.selected" dense />
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td>
|
||||||
|
<q-icon
|
||||||
|
class="q-mr-sm"
|
||||||
|
:color="
|
||||||
|
slotProps.row.type === 'folder' ? 'yellow-9' : 'primary'
|
||||||
|
"
|
||||||
|
size="sm"
|
||||||
|
:name="
|
||||||
|
slotProps.row.type === 'folder' ? 'folder' : 'description'
|
||||||
|
"
|
||||||
|
/>{{ slotProps.row.name }}
|
||||||
|
</q-td>
|
||||||
|
<q-td>{{ slotProps.row.type }}</q-td>
|
||||||
|
<q-td>{{ slotProps.row.size }}</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</template>
|
||||||
|
</q-splitter>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// composition imports
|
||||||
|
import { ref, toRef, onMounted } from "vue";
|
||||||
|
import { isDefined } from "@vueuse/core";
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import type { QTableColumn, QTreeLazyLoadParams, QTree, QTable } from "quasar";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LazyLoadCallbackParams,
|
||||||
|
FileSystemNodeTable,
|
||||||
|
QTreeFileNode,
|
||||||
|
} from "../types/filebrowser";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "lazy-load", callback: LazyLoadCallbackParams): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
nodes: QTreeFileNode[];
|
||||||
|
loading?: boolean;
|
||||||
|
separator?: "windows" | "unix";
|
||||||
|
height?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
separator: "unix",
|
||||||
|
loading: false,
|
||||||
|
height: "200px",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// expose public methods
|
||||||
|
defineExpose({
|
||||||
|
getNodeByKey: (nodeKey: string): QTreeFileNode =>
|
||||||
|
folderTree.value?.getNodeByKey(nodeKey),
|
||||||
|
reloadTable: reloadTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileSeparator = props.separator === "unix" ? "/" : "\\";
|
||||||
|
const folderTree = ref<InstanceType<typeof QTree> | null>(null);
|
||||||
|
const tableRef = ref<InstanceType<typeof QTable> | null>(null);
|
||||||
|
|
||||||
|
const selectedTreeNode = ref(fileSeparator);
|
||||||
|
const selectedTableNodes = ref([] as FileSystemNodeTable[]);
|
||||||
|
const splitter = ref(25);
|
||||||
|
const nodes = toRef(props, "nodes");
|
||||||
|
const tableRows = ref([] as FileSystemNodeTable[]);
|
||||||
|
const tableColumns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
field: "name",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
label: "Type",
|
||||||
|
field: "type",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "size",
|
||||||
|
label: "Size",
|
||||||
|
field: "size",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function doubleClickTableRow(file: FileSystemNodeTable) {
|
||||||
|
if (file.type == "file") return;
|
||||||
|
|
||||||
|
selectedTreeNode.value = file.id;
|
||||||
|
onFolderSelection(file.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadTable(parentNodeKey: string = selectedTreeNode.value) {
|
||||||
|
tableRows.value = [];
|
||||||
|
selectedTableNodes.value = [];
|
||||||
|
const node: QTreeFileNode = folderTree.value?.getNodeByKey(parentNodeKey);
|
||||||
|
if (isDefined(node.children)) {
|
||||||
|
tableRows.value = parseNodeChildrenIntoTable(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFolderSelection(nodeKey: string) {
|
||||||
|
!folderTree.value?.isExpanded(nodeKey)
|
||||||
|
? folderTree.value?.setExpanded(nodeKey, true)
|
||||||
|
: undefined;
|
||||||
|
reloadTable(nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNodeChildren({ node, key, done, fail }: QTreeLazyLoadParams) {
|
||||||
|
const isDone = (loadedChildren: QTreeFileNode[]) => {
|
||||||
|
done(loadedChildren);
|
||||||
|
reloadTable(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFail = () => {
|
||||||
|
fail();
|
||||||
|
};
|
||||||
|
|
||||||
|
// re-emit lazy load event so parent can call api
|
||||||
|
emit("lazy-load", {
|
||||||
|
isDone,
|
||||||
|
isFail,
|
||||||
|
path: node.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// parses children of node into table rows
|
||||||
|
function parseNodeChildrenIntoTable(
|
||||||
|
node: QTreeFileNode
|
||||||
|
): FileSystemNodeTable[] {
|
||||||
|
if (isDefined(node.children)) {
|
||||||
|
return node.children.map((childNode) => ({
|
||||||
|
id: childNode.id,
|
||||||
|
name: childNode.label as string,
|
||||||
|
path: childNode.path,
|
||||||
|
type: childNode.type,
|
||||||
|
size: childNode.size,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: figure this shit out multiple selection with shift-click
|
||||||
|
// let storedSelectedRow: FileSystemNodeTable;
|
||||||
|
|
||||||
|
// function onSelection({
|
||||||
|
// rows,
|
||||||
|
// added,
|
||||||
|
// evt,
|
||||||
|
// }: {
|
||||||
|
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
// rows: readonly unknown[];
|
||||||
|
// added: boolean;
|
||||||
|
// evt: Event;
|
||||||
|
// }) {
|
||||||
|
// // ignore selection change from header of not from a direct click event
|
||||||
|
// if (!isDefined(tableRef.value) || rows.length !== 1 || !isDefined(evt)) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const oldSelectedRow = storedSelectedRow;
|
||||||
|
// const newSelectedRow = rows[0] as FileSystemNodeTable;
|
||||||
|
// const { ctrlKey, shiftKey } = evt as KeyboardEvent;
|
||||||
|
|
||||||
|
// if (!shiftKey) {
|
||||||
|
// storedSelectedRow = newSelectedRow;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // wait for the default selection to be performed
|
||||||
|
// nextTick(() => {
|
||||||
|
// if (!isDefined(tableRef.value)) return;
|
||||||
|
// if (shiftKey === true) {
|
||||||
|
// const tableRows = tableRef.value.filteredSortedRows;
|
||||||
|
// let firstIndex = tableRows.indexOf(oldSelectedRow);
|
||||||
|
// let lastIndex = tableRows.indexOf(newSelectedRow);
|
||||||
|
|
||||||
|
// if (firstIndex < 0) {
|
||||||
|
// firstIndex = 0;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (firstIndex > lastIndex) {
|
||||||
|
// [firstIndex, lastIndex] = [lastIndex, firstIndex];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const rangeRows = tableRows.slice(
|
||||||
|
// firstIndex,
|
||||||
|
// lastIndex + 1
|
||||||
|
// ) as FileSystemNodeTable[];
|
||||||
|
// // we need the original row object so we can match them against the rows in range
|
||||||
|
// const selectedRows = selectedTableNodes.value.map(
|
||||||
|
// toRaw(storedSelectedRow)
|
||||||
|
// ) as FileSystemNodeTable[];
|
||||||
|
|
||||||
|
// selectedTableNodes.value = added
|
||||||
|
// ? selectedRows.concat(
|
||||||
|
// rangeRows.filter((row) => selectedRows.includes(row) === false)
|
||||||
|
// )
|
||||||
|
// : selectedRows.filter((row) => rangeRows.includes(row) === false);
|
||||||
|
// } else if (ctrlKey !== true && added === true) {
|
||||||
|
// selectedTableNodes.value = [newSelectedRow];
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// make sure the table on the right is always populated and selected node is expanded
|
||||||
|
selectedTreeNode.value = nodes.value[0].id;
|
||||||
|
folderTree.value?.setExpanded(selectedTreeNode.value, true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -27,6 +27,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
<div class="text-subtitle2">Reporting</div>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section class="row">
|
||||||
|
<div class="q-gutter-sm">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="localRole.can_view_reports"
|
||||||
|
label="Reporting Viewer"
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="localRole.can_manage_reports"
|
||||||
|
label="Reporting Manager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
<div class="text-subtitle2">Accounts</div>
|
<div class="text-subtitle2">Accounts</div>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<q-card-section class="row">
|
<q-card-section class="row">
|
||||||
@@ -70,10 +85,6 @@
|
|||||||
v-model="localRole.can_uninstall_agents"
|
v-model="localRole.can_uninstall_agents"
|
||||||
label="Uninstall Agents"
|
label="Uninstall Agents"
|
||||||
/>
|
/>
|
||||||
<q-checkbox
|
|
||||||
v-model="localRole.can_ping_agents"
|
|
||||||
label="Ping Agents"
|
|
||||||
/>
|
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
v-model="localRole.can_update_agents"
|
v-model="localRole.can_update_agents"
|
||||||
label="Update Agents"
|
label="Update Agents"
|
||||||
@@ -96,7 +107,7 @@
|
|||||||
/>
|
/>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
v-model="localRole.can_reboot_agents"
|
v-model="localRole.can_reboot_agents"
|
||||||
label="Reboot Agents"
|
label="Shutdown / Reboot Agents"
|
||||||
/>
|
/>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
v-model="localRole.can_send_wol"
|
v-model="localRole.can_send_wol"
|
||||||
@@ -140,6 +151,14 @@
|
|||||||
v-model="localRole.can_edit_core_settings"
|
v-model="localRole.can_edit_core_settings"
|
||||||
label="Edit Global Settings"
|
label="Edit Global Settings"
|
||||||
/>
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="localRole.can_view_global_keystore"
|
||||||
|
label="View Global Key Store"
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="localRole.can_edit_global_keystore"
|
||||||
|
label="Edit Global Key Store"
|
||||||
|
/>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
v-model="localRole.can_do_server_maint"
|
v-model="localRole.can_do_server_maint"
|
||||||
label="Do Server Maintenance"
|
label="Do Server Maintenance"
|
||||||
@@ -168,6 +187,11 @@
|
|||||||
v-model="localRole.can_manage_customfields"
|
v-model="localRole.can_manage_customfields"
|
||||||
label="Edit Custom Fields"
|
label="Edit Custom Fields"
|
||||||
/>
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-if="!hosted"
|
||||||
|
v-model="localRole.can_use_webterm"
|
||||||
|
label="Use TRMM Server Web Terminal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
@@ -317,6 +341,11 @@
|
|||||||
v-model="localRole.can_manage_scripts"
|
v-model="localRole.can_manage_scripts"
|
||||||
label="Manage Scripts"
|
label="Manage Scripts"
|
||||||
/>
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-if="!hosted"
|
||||||
|
v-model="localRole.can_run_server_scripts"
|
||||||
|
label="Run Scripts on TRMM Server"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
@@ -398,7 +427,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// composition imports
|
// composition imports
|
||||||
import { ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useStore } from "vuex";
|
||||||
import { useDialogPluginComponent } from "quasar";
|
import { useDialogPluginComponent } from "quasar";
|
||||||
import { saveRole, editRole } from "@/api/accounts";
|
import { saveRole, editRole } from "@/api/accounts";
|
||||||
import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
|
import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
|
||||||
@@ -416,6 +446,10 @@ export default {
|
|||||||
// quasar setup
|
// quasar setup
|
||||||
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
// store
|
||||||
|
const store = useStore();
|
||||||
|
const hosted = computed(() => store.state.hosted);
|
||||||
|
|
||||||
// dropdown setup
|
// dropdown setup
|
||||||
const { clientOptions } = useClientDropdown(true);
|
const { clientOptions } = useClientDropdown(true);
|
||||||
const { siteOptions } = useSiteDropdown(true);
|
const { siteOptions } = useSiteDropdown(true);
|
||||||
@@ -432,7 +466,6 @@ export default {
|
|||||||
can_uninstall_agents: false,
|
can_uninstall_agents: false,
|
||||||
can_update_agents: false,
|
can_update_agents: false,
|
||||||
can_edit_agent: false,
|
can_edit_agent: false,
|
||||||
can_ping_agents: false,
|
|
||||||
can_manage_procs: false,
|
can_manage_procs: false,
|
||||||
can_view_eventlogs: false,
|
can_view_eventlogs: false,
|
||||||
can_send_cmd: false,
|
can_send_cmd: false,
|
||||||
@@ -452,6 +485,8 @@ export default {
|
|||||||
// settings perms
|
// settings perms
|
||||||
can_view_core_settings: false,
|
can_view_core_settings: false,
|
||||||
can_edit_core_settings: false,
|
can_edit_core_settings: false,
|
||||||
|
can_view_global_keystore: false,
|
||||||
|
can_edit_global_keystore: false,
|
||||||
can_do_server_maint: false,
|
can_do_server_maint: false,
|
||||||
can_code_sign: false,
|
can_code_sign: false,
|
||||||
can_run_urlactions: false,
|
can_run_urlactions: false,
|
||||||
@@ -501,6 +536,12 @@ export default {
|
|||||||
can_manage_roles: false,
|
can_manage_roles: false,
|
||||||
can_view_clients: [],
|
can_view_clients: [],
|
||||||
can_view_sites: [],
|
can_view_sites: [],
|
||||||
|
// server scripts and web terminal
|
||||||
|
can_run_server_scripts: false,
|
||||||
|
can_use_webterm: false,
|
||||||
|
// reporting perms
|
||||||
|
can_view_reports: false,
|
||||||
|
can_manage_reports: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -528,7 +569,7 @@ export default {
|
|||||||
role.value[key] = newValue;
|
role.value[key] = newValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -537,6 +578,7 @@ export default {
|
|||||||
loading,
|
loading,
|
||||||
clientOptions,
|
clientOptions,
|
||||||
siteOptions,
|
siteOptions,
|
||||||
|
hosted,
|
||||||
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
||||||
|
|||||||
151
src/components/accounts/UserSessionsTable.vue
Normal file
151
src/components/accounts/UserSessionsTable.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card style="width: 60vw; max-width: 90vw; min-height: 40vh">
|
||||||
|
<q-bar>
|
||||||
|
User Sessions for {{ user.username }}
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
:table-class="{
|
||||||
|
'table-bgcolor': !$q.dark.isActive,
|
||||||
|
'table-bgcolor-dark': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
|
||||||
|
class="tbl-sticky"
|
||||||
|
:rows="sessions"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="{ rowsPerPage: 0, sortBy: 'display', descending: true }"
|
||||||
|
row-key="id"
|
||||||
|
binary-state-sort
|
||||||
|
virtual-scroll
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
label="Remove All Sessions"
|
||||||
|
@click="removeAllSessions"
|
||||||
|
size="sm"
|
||||||
|
color="negative"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr>
|
||||||
|
<!-- rows -->
|
||||||
|
<td>{{ formatDate(props.row.created) }}</td>
|
||||||
|
<td>{{ formatDate(props.row.expiry) }}</td>
|
||||||
|
<td>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
@click="removeSession(props.row)"
|
||||||
|
label="Disconnect"
|
||||||
|
color="negative"
|
||||||
|
></q-btn>
|
||||||
|
</td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useDialogPluginComponent, useQuasar, type QTableColumn } from "quasar";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { formatDate } from "@/utils/format";
|
||||||
|
import {
|
||||||
|
fetchUserSessions,
|
||||||
|
deleteAllUserSessions,
|
||||||
|
deleteUserSession,
|
||||||
|
} from "@/api/accounts";
|
||||||
|
|
||||||
|
//types
|
||||||
|
import type { SSOUser } from "@/ee/sso/types/sso";
|
||||||
|
import type { AuthToken } from "@/types/accounts";
|
||||||
|
|
||||||
|
const columns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "created",
|
||||||
|
label: "Created",
|
||||||
|
field: "created",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expiry",
|
||||||
|
label: "Expires",
|
||||||
|
field: "expiry",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
label: "",
|
||||||
|
field: "action",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
user: SSOUser;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const sessions = ref([] as AuthToken[]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
function removeSession(token: AuthToken) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Disconnect session for ${token.user}?`,
|
||||||
|
message: "This user will be signed out immediately.",
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await deleteUserSession(token.digest);
|
||||||
|
notifySuccess("Login session deleted successfully");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
await getSessions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllSessions() {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Disconnect all sessions for ${props.user.username}?`,
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await deleteAllUserSessions(props.user.id);
|
||||||
|
notifySuccess("Login sessions deleted successfully");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
onDialogHide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessions() {
|
||||||
|
sessions.value = await fetchUserSessions(props.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getSessions);
|
||||||
|
</script>
|
||||||
@@ -176,6 +176,13 @@
|
|||||||
</q-menu>
|
</q-menu>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup @click="shutdown(agent)">
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon size="xs" name="power" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Shutdown</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
<q-item clickable v-close-popup @click="showPolicyAdd(agent)">
|
<q-item clickable v-close-popup @click="showPolicyAdd(agent)">
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon size="xs" name="policy" />
|
<q-icon size="xs" name="policy" />
|
||||||
@@ -183,6 +190,24 @@
|
|||||||
<q-item-section>Assign Automation Policy</q-item-section>
|
<q-item-section>Assign Automation Policy</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-if="
|
||||||
|
$integrations &&
|
||||||
|
$integrations.agentMenuIntegrations &&
|
||||||
|
$integrations.agentMenuIntegrations.length > 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon size="xs" name="analytics" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Reporting</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="keyboard_arrow_right" />
|
||||||
|
</q-item-section>
|
||||||
|
<integrations-context-menu type="agent" :id="agent.agent_id" />
|
||||||
|
</q-item>
|
||||||
|
|
||||||
<q-item clickable v-close-popup @click="showAgentRecovery(agent)">
|
<q-item clickable v-close-popup @click="showAgentRecovery(agent)">
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon size="xs" name="fas fa-first-aid" />
|
<q-icon size="xs" name="fas fa-first-aid" />
|
||||||
@@ -213,6 +238,7 @@ import { fetchURLActions, runURLAction } from "@/api/core";
|
|||||||
import {
|
import {
|
||||||
editAgent,
|
editAgent,
|
||||||
agentRebootNow,
|
agentRebootNow,
|
||||||
|
agentShutdown,
|
||||||
sendAgentPing,
|
sendAgentPing,
|
||||||
removeAgent,
|
removeAgent,
|
||||||
runRemoteBackground,
|
runRemoteBackground,
|
||||||
@@ -232,9 +258,13 @@ import RebootLater from "@/components/modals/agents/RebootLater.vue";
|
|||||||
import EditAgent from "@/components/modals/agents/EditAgent.vue";
|
import EditAgent from "@/components/modals/agents/EditAgent.vue";
|
||||||
import SendCommand from "@/components/modals/agents/SendCommand.vue";
|
import SendCommand from "@/components/modals/agents/SendCommand.vue";
|
||||||
import RunScript from "@/components/modals/agents/RunScript.vue";
|
import RunScript from "@/components/modals/agents/RunScript.vue";
|
||||||
|
import IntegrationsContextMenu from "@/components/ui/IntegrationsContextMenu.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "AgentActionMenu",
|
name: "AgentActionMenu",
|
||||||
|
components: {
|
||||||
|
IntegrationsContextMenu,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
agent: !Object,
|
agent: !Object,
|
||||||
},
|
},
|
||||||
@@ -272,16 +302,21 @@ export default {
|
|||||||
async function getURLActions() {
|
async function getURLActions() {
|
||||||
menuLoading.value = true;
|
menuLoading.value = true;
|
||||||
try {
|
try {
|
||||||
urlActions.value = await fetchURLActions();
|
urlActions.value = (await fetchURLActions())
|
||||||
|
.filter((action) => action.action_type === "web")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
if (urlActions.value.length === 0) {
|
if (urlActions.value.length === 0) {
|
||||||
notifyWarning(
|
notifyWarning(
|
||||||
"No URL Actions configured. Go to Settings > Global Settings > URL Actions"
|
"No URL Actions configured. Go to Settings > Global Settings > URL Actions",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
menuLoading.value = true;
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
menuLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSendCommand(agent) {
|
function showSendCommand(agent) {
|
||||||
@@ -342,7 +377,7 @@ export default {
|
|||||||
notifySuccess(
|
notifySuccess(
|
||||||
`Maintenance mode was ${
|
`Maintenance mode was ${
|
||||||
agent.maintenance_mode ? "disabled" : "enabled"
|
agent.maintenance_mode ? "disabled" : "enabled"
|
||||||
} on ${agent.hostname}`
|
} on ${agent.hostname}`,
|
||||||
);
|
);
|
||||||
store.commit("setRefreshSummaryTab", true);
|
store.commit("setRefreshSummaryTab", true);
|
||||||
refreshDashboard();
|
refreshDashboard();
|
||||||
@@ -415,6 +450,32 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shutdown(agent) {
|
||||||
|
$q.dialog({
|
||||||
|
title:
|
||||||
|
'Please type <code style="color:red">yes</code> in the box below to confirm shutdown.',
|
||||||
|
prompt: {
|
||||||
|
model: "",
|
||||||
|
type: "text",
|
||||||
|
isValid: (val) => val === "yes",
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Shutdown", color: "negative" },
|
||||||
|
persistent: true,
|
||||||
|
html: true,
|
||||||
|
}).onOk(async () => {
|
||||||
|
$q.loading.show();
|
||||||
|
try {
|
||||||
|
await agentShutdown(agent.agent_id);
|
||||||
|
notifySuccess(`${agent.hostname} will now be shutdown`);
|
||||||
|
$q.loading.hide();
|
||||||
|
} catch (e) {
|
||||||
|
$q.loading.hide();
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showPolicyAdd(agent) {
|
function showPolicyAdd(agent) {
|
||||||
$q.dialog({
|
$q.dialog({
|
||||||
component: PolicyAdd,
|
component: PolicyAdd,
|
||||||
@@ -483,7 +544,7 @@ export default {
|
|||||||
notifySuccess(data);
|
notifySuccess(data);
|
||||||
refreshDashboard(
|
refreshDashboard(
|
||||||
false /* clearTreeSelected */,
|
false /* clearTreeSelected */,
|
||||||
true /* clearSubTable */
|
true /* clearSubTable */,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -512,6 +573,7 @@ export default {
|
|||||||
runChecks,
|
runChecks,
|
||||||
showRebootLaterModal,
|
showRebootLaterModal,
|
||||||
rebootNow,
|
rebootNow,
|
||||||
|
shutdown,
|
||||||
showPolicyAdd,
|
showPolicyAdd,
|
||||||
showAgentRecovery,
|
showAgentRecovery,
|
||||||
pingAgent,
|
pingAgent,
|
||||||
|
|||||||
@@ -295,7 +295,12 @@
|
|||||||
</q-td>
|
</q-td>
|
||||||
<q-td v-else></q-td>
|
<q-td v-else></q-td>
|
||||||
<!-- name -->
|
<!-- name -->
|
||||||
<q-td>{{ props.row.name }}</q-td>
|
<q-td
|
||||||
|
>{{ props.row.name
|
||||||
|
}}<q-tooltip v-if="props.row?.win_task_name" :delay="700">{{
|
||||||
|
props.row.win_task_name
|
||||||
|
}}</q-tooltip></q-td
|
||||||
|
>
|
||||||
<!-- sync status -->
|
<!-- sync status -->
|
||||||
<q-td v-if="props.row.task_result.sync_status === 'notsynced'"
|
<q-td v-if="props.row.task_result.sync_status === 'notsynced'"
|
||||||
>Will sync on next agent checkin</q-td
|
>Will sync on next agent checkin</q-td
|
||||||
@@ -441,7 +446,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchAgentTasks(selectedAgent.value);
|
const result = await fetchAgentTasks(selectedAgent.value);
|
||||||
tasks.value = result.filter(
|
tasks.value = result.filter(
|
||||||
(task) => task.sync_status !== "pendingdeletion"
|
(task) => task.sync_status !== "pendingdeletion",
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -495,7 +500,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const result = await runTask(
|
const result = await runTask(
|
||||||
task.id,
|
task.id,
|
||||||
task.policy ? { agent_id: selectedAgent.value } : {}
|
task.policy ? { agent_id: selectedAgent.value } : {},
|
||||||
);
|
);
|
||||||
notifySuccess(result);
|
notifySuccess(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -119,6 +119,16 @@
|
|||||||
no-caps
|
no-caps
|
||||||
icon="play_arrow"
|
icon="play_arrow"
|
||||||
@click="runChecks"
|
@click="runChecks"
|
||||||
|
class="q-mr-md"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
label="Reset All Checks Status"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
push
|
||||||
|
no-caps
|
||||||
|
icon="restart_alt"
|
||||||
|
@click="resetAllChecks"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -360,7 +370,13 @@
|
|||||||
style="cursor: pointer; text-decoration: underline"
|
style="cursor: pointer; text-decoration: underline"
|
||||||
class="text-primary"
|
class="text-primary"
|
||||||
@click="showPingInfo(props.row)"
|
@click="showPingInfo(props.row)"
|
||||||
>Last Output</span
|
>{{
|
||||||
|
grep(props.row.check_result.more_info, [
|
||||||
|
"transmitted",
|
||||||
|
"received",
|
||||||
|
"packet loss",
|
||||||
|
])
|
||||||
|
}}</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else-if="
|
v-else-if="
|
||||||
@@ -369,7 +385,7 @@
|
|||||||
style="cursor: pointer; text-decoration: underline"
|
style="cursor: pointer; text-decoration: underline"
|
||||||
class="text-primary"
|
class="text-primary"
|
||||||
@click="showScriptOutput(props.row.check_result)"
|
@click="showScriptOutput(props.row.check_result)"
|
||||||
>Last Output</span
|
>{{ processOutput(props.row.check_result) }}</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else-if="
|
v-else-if="
|
||||||
@@ -382,7 +398,9 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else-if="
|
v-else-if="
|
||||||
props.row.check_type === 'diskspace' ||
|
['diskspace', 'cpuload', 'memory'].includes(
|
||||||
|
props.row.check_type,
|
||||||
|
) ||
|
||||||
(props.row.check_type === 'winsvc' && props.row.check_result.id)
|
(props.row.check_type === 'winsvc' && props.row.check_result.id)
|
||||||
"
|
"
|
||||||
>{{ props.row.check_result.more_info }}</span
|
>{{ props.row.check_result.more_info }}</span
|
||||||
@@ -415,6 +433,7 @@ import {
|
|||||||
updateCheck,
|
updateCheck,
|
||||||
removeCheck,
|
removeCheck,
|
||||||
resetCheck,
|
resetCheck,
|
||||||
|
resetAllChecksStatus,
|
||||||
runAgentChecks,
|
runAgentChecks,
|
||||||
} from "@/api/checks";
|
} from "@/api/checks";
|
||||||
import { fetchAgentChecks } from "@/api/agents";
|
import { fetchAgentChecks } from "@/api/agents";
|
||||||
@@ -499,6 +518,40 @@ export default {
|
|||||||
descending: false,
|
descending: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO this will break when we add translations
|
||||||
|
function grep(text, stringsToMatch) {
|
||||||
|
try {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const matched = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (stringsToMatch.every((str) => line.includes(str))) {
|
||||||
|
matched.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched.length > 0 ? matched.join("\n") : "Last Output";
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return "Last Output";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processOutput(result) {
|
||||||
|
try {
|
||||||
|
if (result.stdout && result.stdout.trim() !== "") {
|
||||||
|
return result.stdout.substring(0, 60);
|
||||||
|
} else if (result.stderr && result.stderr.trim() !== "") {
|
||||||
|
return result.stderr.substring(0, 60);
|
||||||
|
} else {
|
||||||
|
return "Last Output";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return "Last Output";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getAlertSeverity(check) {
|
function getAlertSeverity(check) {
|
||||||
if (check.check_result.alert_severity) {
|
if (check.check_result.alert_severity) {
|
||||||
return check.check_result.alert_severity;
|
return check.check_result.alert_severity;
|
||||||
@@ -572,7 +625,7 @@ export default {
|
|||||||
notifySuccess(result);
|
notifySuccess(result);
|
||||||
refreshDashboard(
|
refreshDashboard(
|
||||||
false /* clearTreeSelected */,
|
false /* clearTreeSelected */,
|
||||||
false /* clearSubTable */
|
false /* clearSubTable */,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -580,6 +633,26 @@ export default {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetAllChecks() {
|
||||||
|
$q.dialog({
|
||||||
|
title: "Are you sure?",
|
||||||
|
message: "Reset all checks status",
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Reset", color: "negative" },
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await resetAllChecksStatus(selectedAgent.value);
|
||||||
|
await getChecks();
|
||||||
|
notifySuccess(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showEventInfo(data) {
|
function showEventInfo(data) {
|
||||||
$q.dialog({
|
$q.dialog({
|
||||||
component: EventLogCheckOutput,
|
component: EventLogCheckOutput,
|
||||||
@@ -635,6 +708,7 @@ export default {
|
|||||||
componentProps: {
|
componentProps: {
|
||||||
check: check,
|
check: check,
|
||||||
parent: !check ? { agent: selectedAgent.value } : undefined,
|
parent: !check ? { agent: selectedAgent.value } : undefined,
|
||||||
|
plat: type === "script" ? agentPlatform.value : undefined,
|
||||||
},
|
},
|
||||||
}).onOk(getChecks);
|
}).onOk(getChecks);
|
||||||
}
|
}
|
||||||
@@ -674,6 +748,9 @@ export default {
|
|||||||
formatDate,
|
formatDate,
|
||||||
getAlertSeverity,
|
getAlertSeverity,
|
||||||
runChecks,
|
runChecks,
|
||||||
|
resetAllChecks,
|
||||||
|
grep,
|
||||||
|
processOutput,
|
||||||
|
|
||||||
// dialogs
|
// dialogs
|
||||||
showScriptOutput,
|
showScriptOutput,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
:color="dash_warning_color"
|
:color="dash_warning_color"
|
||||||
class="q-mr-sm"
|
class="q-mr-sm"
|
||||||
>
|
>
|
||||||
<q-tooltip>Agent offline</q-tooltip>
|
<q-tooltip>{{ store.getters.formatDate(summary.last_seen) }}</q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
<q-icon
|
<q-icon
|
||||||
v-else
|
v-else
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
:color="dash_positive_color"
|
:color="dash_positive_color"
|
||||||
class="q-mr-sm"
|
class="q-mr-sm"
|
||||||
>
|
>
|
||||||
<q-tooltip>Agent online</q-tooltip>
|
<q-tooltip>{{ store.getters.formatDate(summary.last_seen) }}</q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
<b>{{ summary.hostname }}</b>
|
<b>{{ summary.hostname }}</b>
|
||||||
<span v-if="summary.maintenance_mode">
|
<span v-if="summary.maintenance_mode">
|
||||||
@@ -267,7 +267,11 @@ export default {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const serial_number = computed(() => {
|
const serial_number = computed(() => {
|
||||||
return summary.value.wmi_detail.bios?.[0]?.[0]?.SerialNumber;
|
if (summary.value.plat === "windows") {
|
||||||
|
return summary.value.wmi_detail.bios?.[0]?.[0]?.SerialNumber;
|
||||||
|
} else {
|
||||||
|
return summary.value.wmi_detail.serialnumber;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const cpu = computed(() => {
|
const cpu = computed(() => {
|
||||||
@@ -280,7 +284,7 @@ export default {
|
|||||||
function diskBarColor(percent) {
|
function diskBarColor(percent) {
|
||||||
if (percent < 80) {
|
if (percent < 80) {
|
||||||
return dash_positive_color.value;
|
return dash_positive_color.value;
|
||||||
} else if (percent > 80 && percent < 95) {
|
} else if (percent >= 80 && percent < 95) {
|
||||||
return dash_warning_color.value;
|
return dash_warning_color.value;
|
||||||
} else {
|
} else {
|
||||||
return dash_negative_color.value;
|
return dash_negative_color.value;
|
||||||
@@ -311,11 +315,11 @@ export default {
|
|||||||
const ret = [];
|
const ret = [];
|
||||||
for (const customField of summary.value.custom_fields) {
|
for (const customField of summary.value.custom_fields) {
|
||||||
const definition = customFieldsDefinitions.value.find(
|
const definition = customFieldsDefinitions.value.find(
|
||||||
(def) => def.id === customField.field
|
(def) => def.id === customField.field,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
definition &&
|
definition &&
|
||||||
!definition.hide_in_ui &&
|
!definition.hide_in_summary &&
|
||||||
customField.value?.length > 0
|
customField.value?.length > 0
|
||||||
) {
|
) {
|
||||||
ret.push({
|
ret.push({
|
||||||
@@ -381,6 +385,7 @@ export default {
|
|||||||
dash_negative_color,
|
dash_negative_color,
|
||||||
serial_number,
|
serial_number,
|
||||||
cpu,
|
cpu,
|
||||||
|
store,
|
||||||
|
|
||||||
// methods
|
// methods
|
||||||
getSummary,
|
getSummary,
|
||||||
|
|||||||
@@ -17,70 +17,85 @@
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
>
|
>
|
||||||
<template v-slot:top>
|
<template v-slot:top>
|
||||||
<q-btn
|
<div class="q-gutter-md flex flex-center items-center">
|
||||||
v-if="isPolling"
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
push
|
|
||||||
@click="stopPoll"
|
|
||||||
icon="stop"
|
|
||||||
label="Stop Live Refresh"
|
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
v-else
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
push
|
|
||||||
@click="startPoll"
|
|
||||||
icon="play_arrow"
|
|
||||||
label="Resume Live Refresh"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<q-space />
|
|
||||||
|
|
||||||
<div class="q-pa-md q-gutter-sm">
|
|
||||||
<q-btn
|
<q-btn
|
||||||
:disable="pollInterval === 1"
|
v-if="isPolling"
|
||||||
dense
|
dense
|
||||||
@click="pollIntervalChanged('subtract')"
|
flat
|
||||||
push
|
push
|
||||||
icon="remove"
|
@click="stopPoll"
|
||||||
size="sm"
|
icon="stop"
|
||||||
color="grey"
|
label="Stop Live Refresh"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
v-else
|
||||||
dense
|
dense
|
||||||
|
flat
|
||||||
push
|
push
|
||||||
icon="add"
|
@click="startPoll"
|
||||||
size="sm"
|
icon="play_arrow"
|
||||||
color="grey"
|
label="Resume Live Refresh"
|
||||||
@click="pollIntervalChanged('add')"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="text-overline">
|
|
||||||
<q-badge
|
|
||||||
align="middle"
|
|
||||||
size="sm"
|
|
||||||
class="text-h6"
|
|
||||||
color="blue"
|
|
||||||
:label="pollInterval"
|
|
||||||
/>
|
|
||||||
Refresh interval (seconds)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-space />
|
<div class="flex flex-center q-ml-md">
|
||||||
<q-input v-model="filter" outlined label="Search" dense clearable>
|
<q-icon name="fas fa-microchip" class="q-mr-xs" />
|
||||||
<template v-slot:prepend>
|
<div class="text-caption q-mr-sm">
|
||||||
<q-icon name="search" />
|
CPU Usage:
|
||||||
</template>
|
<span class="text-body1 text-weight-medium"
|
||||||
</q-input>
|
>{{ totalCpuUsage }}%</span
|
||||||
<!-- file download doesn't work so disabling -->
|
>
|
||||||
<export-table-btn
|
</div>
|
||||||
v-show="false"
|
|
||||||
class="q-ml-sm"
|
<q-icon name="fas fa-memory" class="q-mr-xs" />
|
||||||
:columns="columns"
|
<div class="text-caption">
|
||||||
:data="processes"
|
RAM Usage:
|
||||||
/>
|
<span class="text-body1 text-weight-medium"
|
||||||
|
>{{ bytes2Human(totalRamUsage) }}/{{ total_ram }} GB</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-space />
|
||||||
|
|
||||||
|
<div class="q-pa-md q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
:disable="pollInterval === 1"
|
||||||
|
dense
|
||||||
|
@click="pollIntervalChanged('subtract')"
|
||||||
|
push
|
||||||
|
icon="remove"
|
||||||
|
size="sm"
|
||||||
|
color="grey"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
push
|
||||||
|
icon="add"
|
||||||
|
size="sm"
|
||||||
|
color="grey"
|
||||||
|
@click="pollIntervalChanged('add')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-overline">
|
||||||
|
<q-badge
|
||||||
|
align="middle"
|
||||||
|
size="sm"
|
||||||
|
class="text-h6"
|
||||||
|
color="blue"
|
||||||
|
:label="pollInterval"
|
||||||
|
/>
|
||||||
|
Refresh interval (seconds)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-space />
|
||||||
|
|
||||||
|
<q-input v-model="filter" outlined label="Search" dense clearable>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
<q-tr :props="props" class="cursor-pointer">
|
<q-tr :props="props" class="cursor-pointer">
|
||||||
@@ -121,9 +136,6 @@ import {
|
|||||||
import { bytes2Human } from "@/utils/format";
|
import { bytes2Human } from "@/utils/format";
|
||||||
import { notifySuccess } from "@/utils/notify";
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
|
||||||
// ui imports
|
|
||||||
import ExportTableBtn from "@/components/ui/ExportTableBtn.vue";
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "name",
|
||||||
@@ -164,7 +176,6 @@ const columns = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { ExportTableBtn },
|
|
||||||
name: "ProcessManager",
|
name: "ProcessManager",
|
||||||
props: {
|
props: {
|
||||||
agent_id: !String,
|
agent_id: !String,
|
||||||
@@ -175,52 +186,71 @@ export default {
|
|||||||
const poll = ref(null);
|
const poll = ref(null);
|
||||||
const isPolling = computed(() => !!poll.value);
|
const isPolling = computed(() => !!poll.value);
|
||||||
|
|
||||||
async function startPoll() {
|
function startPoll() {
|
||||||
await getProcesses();
|
stopPoll();
|
||||||
if (processes.value.length > 0) {
|
getProcesses();
|
||||||
refreshProcesses();
|
poll.value = setInterval(() => {
|
||||||
}
|
getProcesses();
|
||||||
|
}, pollInterval.value * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPoll() {
|
function stopPoll() {
|
||||||
clearInterval(poll.value);
|
if (poll.value) {
|
||||||
poll.value = null;
|
clearInterval(poll.value);
|
||||||
|
poll.value = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollIntervalChanged(action) {
|
function pollIntervalChanged(action) {
|
||||||
if (action === "subtract" && pollInterval.value <= 1) {
|
|
||||||
stopPoll();
|
|
||||||
startPoll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === "add") {
|
if (action === "add") {
|
||||||
pollInterval.value++;
|
pollInterval.value++;
|
||||||
} else {
|
} else if (action === "subtract" && pollInterval.value > 1) {
|
||||||
pollInterval.value--;
|
pollInterval.value--;
|
||||||
}
|
}
|
||||||
stopPoll();
|
if (isPolling.value) {
|
||||||
startPoll();
|
startPoll();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// process manager logic
|
// process manager logic
|
||||||
const processes = ref([]);
|
const processes = ref([]);
|
||||||
const filter = ref("");
|
const filter = ref("");
|
||||||
const memory = ref(null);
|
const total_ram = ref(0);
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const totalCpuUsage = computed(() => {
|
||||||
|
if (!Array.isArray(processes.value) || processes.value.length === 0) {
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = processes.value.reduce((acc, proc) => {
|
||||||
|
const cpuPercent = parseFloat(proc.cpu_percent);
|
||||||
|
|
||||||
|
if (isNaN(cpuPercent)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc + cpuPercent;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return total.toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRamUsage = computed(() => {
|
||||||
|
return processes.value.reduce((acc, proc) => acc + proc.membytes, 0);
|
||||||
|
});
|
||||||
|
|
||||||
async function getProcesses() {
|
async function getProcesses() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
processes.value = await fetchAgentProcesses(props.agent_id);
|
try {
|
||||||
|
processes.value = await fetchAgentProcesses(props.agent_id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshProcesses() {
|
|
||||||
poll.value = setInterval(() => {
|
|
||||||
getProcesses(props.agent_id);
|
|
||||||
}, pollInterval.value * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function killProcess(pid) {
|
async function killProcess(pid) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
let result = "";
|
let result = "";
|
||||||
@@ -235,11 +265,8 @@ export default {
|
|||||||
|
|
||||||
// lifecycle hooks
|
// lifecycle hooks
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
memory.value = await fetchAgent(props.agent_id).total_ram;
|
total_ram.value = (await fetchAgent(props.agent_id)).total_ram;
|
||||||
await getProcesses();
|
startPoll();
|
||||||
if (processes.value.length > 0) {
|
|
||||||
refreshProcesses();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => clearInterval(poll.value));
|
onBeforeUnmount(() => clearInterval(poll.value));
|
||||||
@@ -248,10 +275,12 @@ export default {
|
|||||||
// reactive data
|
// reactive data
|
||||||
processes,
|
processes,
|
||||||
filter,
|
filter,
|
||||||
memory,
|
total_ram,
|
||||||
isPolling,
|
isPolling,
|
||||||
pollInterval,
|
pollInterval,
|
||||||
loading,
|
loading,
|
||||||
|
totalCpuUsage,
|
||||||
|
totalRamUsage,
|
||||||
|
|
||||||
// non-reactive data
|
// non-reactive data
|
||||||
columns,
|
columns,
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export default {
|
|||||||
pagination: {
|
pagination: {
|
||||||
rowsPerPage: 0,
|
rowsPerPage: 0,
|
||||||
sortBy: "name",
|
sortBy: "name",
|
||||||
descending: true,
|
descending: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -321,7 +321,7 @@ export default {
|
|||||||
runTask(task) {
|
runTask(task) {
|
||||||
if (!task.enabled) {
|
if (!task.enabled) {
|
||||||
this.notifyError(
|
this.notifyError(
|
||||||
"Task cannot be run when it's disabled. Enable it first."
|
"Task cannot be run when it's disabled. Enable it first.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-dialog ref="dialog" @hide="onHide">
|
<q-dialog ref="dialog" @hide="onHide">
|
||||||
<q-card class="q-dialog-plugin" style="width: 90vw">
|
<q-card class="q-dialog-plugin" style="min-width: 70vw">
|
||||||
<q-bar>
|
<q-bar>
|
||||||
|
<q-btn
|
||||||
|
ref="refresh"
|
||||||
|
@click="refresh"
|
||||||
|
class="q-mr-sm"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
push
|
||||||
|
icon="refresh"
|
||||||
|
/>
|
||||||
{{ title.slice(0, 27) }}
|
{{ title.slice(0, 27) }}
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn dense flat icon="close" v-close-popup>
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
@@ -281,6 +290,13 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
refresh() {
|
||||||
|
if (this.type === "task") {
|
||||||
|
this.getTaskData();
|
||||||
|
} else {
|
||||||
|
this.getCheckData();
|
||||||
|
}
|
||||||
|
},
|
||||||
show() {
|
show() {
|
||||||
this.$refs.dialog.show();
|
this.$refs.dialog.show();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-bar>
|
</q-bar>
|
||||||
<q-card-section v-if="scriptOptions.length === 0">
|
<q-card-section v-if="filterByPlatformOptions.length === 0">
|
||||||
<p>You need to upload a script first</p>
|
<p>You need to upload a script first</p>
|
||||||
<p>Settings -> Script Manager</p>
|
<p>Settings -> Script Manager</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
:rules="[(val) => !!val || '*Required']"
|
:rules="[(val) => !!val || '*Required']"
|
||||||
outlined
|
outlined
|
||||||
v-model="state.script"
|
v-model="state.script"
|
||||||
:options="scriptOptions"
|
:options="filterByPlatformOptions"
|
||||||
label="Select script"
|
label="Select script"
|
||||||
mapOptions
|
mapOptions
|
||||||
:disable="!!check"
|
:disable="!!check"
|
||||||
@@ -140,6 +140,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
check: Object,
|
check: Object,
|
||||||
parent: Object, // {agent: agent.agent_id} or {policy: policy.id}
|
parent: Object, // {agent: agent.agent_id} or {policy: policy.id}
|
||||||
|
plat: String,
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
// setup quasar dialog
|
// setup quasar dialog
|
||||||
@@ -148,11 +149,13 @@ export default {
|
|||||||
// setup script dropdown
|
// setup script dropdown
|
||||||
const {
|
const {
|
||||||
script,
|
script,
|
||||||
scriptOptions,
|
filterByPlatformOptions,
|
||||||
defaultTimeout,
|
defaultTimeout,
|
||||||
defaultArgs,
|
defaultArgs,
|
||||||
defaultEnvVars,
|
defaultEnvVars,
|
||||||
} = useScriptDropdown(props.check ? props.check.script : undefined, {
|
} = useScriptDropdown({
|
||||||
|
script: props.check ? props.check.script : undefined,
|
||||||
|
plat: props.plat,
|
||||||
onMount: true,
|
onMount: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@ export default {
|
|||||||
|
|
||||||
// non-reactive data
|
// non-reactive data
|
||||||
failOptions,
|
failOptions,
|
||||||
scriptOptions,
|
filterByPlatformOptions,
|
||||||
severityOptions,
|
severityOptions,
|
||||||
envVarsLabel,
|
envVarsLabel,
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div v-if="scriptInfo.stdout">
|
<div v-if="scriptInfo.stdout">
|
||||||
Standard Output
|
<script-output-copy-clip
|
||||||
|
label="Standard Output"
|
||||||
|
:data="scriptInfo.stdout"
|
||||||
|
/>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<pre>{{ scriptInfo.stdout }}</pre>
|
<pre>{{ scriptInfo.stdout }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="scriptInfo.stderr">
|
<div v-if="scriptInfo.stderr">
|
||||||
Standard Error
|
<script-output-copy-clip
|
||||||
|
label="Standard Error"
|
||||||
|
:data="scriptInfo.stderr"
|
||||||
|
/>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<pre>{{ scriptInfo.stderr }}</pre>
|
<pre>{{ scriptInfo.stderr }}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,8 +49,13 @@ import { computed } from "vue";
|
|||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
import { useDialogPluginComponent } from "quasar";
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
|
||||||
|
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ScriptOutput",
|
name: "ScriptOutput",
|
||||||
|
components: {
|
||||||
|
ScriptOutputCopyClip,
|
||||||
|
},
|
||||||
emits: [...useDialogPluginComponent.emits],
|
emits: [...useDialogPluginComponent.emits],
|
||||||
props: { scriptInfo: !Object },
|
props: { scriptInfo: !Object },
|
||||||
setup() {
|
setup() {
|
||||||
|
|||||||
@@ -116,7 +116,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapState } from "vuex";
|
import { mapState as piniaMapState } from "pinia";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import mixins from "@/mixins/mixins";
|
import mixins from "@/mixins/mixins";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -145,7 +146,7 @@ export default {
|
|||||||
title() {
|
title() {
|
||||||
return this.user ? "Edit User" : "Add User";
|
return this.user ? "Edit User" : "Add User";
|
||||||
},
|
},
|
||||||
...mapState({
|
...piniaMapState(useAuthStore, {
|
||||||
logged_in_user: (state) => state.username,
|
logged_in_user: (state) => state.username,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -83,12 +83,29 @@
|
|||||||
<tactical-dropdown
|
<tactical-dropdown
|
||||||
:rules="[(val) => !!val || '*Required']"
|
:rules="[(val) => !!val || '*Required']"
|
||||||
v-model="state.script"
|
v-model="state.script"
|
||||||
:options="filteredScriptOptions"
|
:options="filterByPlatformOptions"
|
||||||
label="Select Script"
|
label="Select Script"
|
||||||
outlined
|
outlined
|
||||||
mapOptions
|
mapOptions
|
||||||
filterable
|
filterable
|
||||||
/>
|
>
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="info"
|
||||||
|
@click="openScriptURL"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
v-if="syntax"
|
||||||
|
class="bg-white text-primary text-body1"
|
||||||
|
v-html="formatScriptSyntax(syntax)"
|
||||||
|
/>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
</tactical-dropdown>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="mode === 'script'" class="q-pt-none">
|
<q-card-section v-if="mode === 'script'" class="q-pt-none">
|
||||||
<tactical-dropdown
|
<tactical-dropdown
|
||||||
@@ -153,6 +170,39 @@
|
|||||||
</q-checkbox>
|
</q-checkbox>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="mode === 'script'" class="q-pt-none">
|
||||||
|
<div class="q-gutter-sm">
|
||||||
|
<q-checkbox
|
||||||
|
label="Save results to Custom Field"
|
||||||
|
v-model="collector"
|
||||||
|
@update:model-value="
|
||||||
|
state.custom_field = null;
|
||||||
|
state.collector_all_output = false;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="state.save_to_agent_note"
|
||||||
|
label="Save results to Agent Note"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="mode === 'script' && collector">
|
||||||
|
<tactical-dropdown
|
||||||
|
:rules="[(val) => !!val || '*Required']"
|
||||||
|
outlined
|
||||||
|
v-model="state.custom_field"
|
||||||
|
:options="customFieldOptions"
|
||||||
|
label="Select custom field"
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="state.collector_all_output"
|
||||||
|
label="Save all output"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section v-if="mode === 'script' || mode === 'command'">
|
<q-card-section v-if="mode === 'script' || mode === 'command'">
|
||||||
<q-input
|
<q-input
|
||||||
v-model.number="state.timeout"
|
v-model.number="state.timeout"
|
||||||
@@ -210,16 +260,23 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// composition imports
|
// composition imports
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
import {
|
||||||
import { useStore } from "vuex";
|
ref,
|
||||||
import { useDialogPluginComponent } from "quasar";
|
reactive,
|
||||||
|
computed,
|
||||||
|
watch,
|
||||||
|
onMounted,
|
||||||
|
defineComponent,
|
||||||
|
} from "vue";
|
||||||
|
import { useDialogPluginComponent, openURL } from "quasar";
|
||||||
import { useScriptDropdown } from "@/composables/scripts";
|
import { useScriptDropdown } from "@/composables/scripts";
|
||||||
import { useAgentDropdown } from "@/composables/agents";
|
import { useAgentDropdown } from "@/composables/agents";
|
||||||
import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
|
import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
|
||||||
|
import { useCustomFieldDropdown } from "@/composables/core";
|
||||||
import { runBulkAction } from "@/api/agents";
|
import { runBulkAction } from "@/api/agents";
|
||||||
import { notifySuccess } from "@/utils/notify";
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { formatScriptSyntax } from "@/utils/format";
|
||||||
import { cmdPlaceholder } from "@/composables/agents";
|
import { cmdPlaceholder } from "@/composables/agents";
|
||||||
import { removeExtraOptionCategories } from "@/utils/format";
|
|
||||||
import { envVarsLabel, runAsUserToolTip } from "@/constants/constants";
|
import { envVarsLabel, runAsUserToolTip } from "@/constants/constants";
|
||||||
|
|
||||||
// ui imports
|
// ui imports
|
||||||
@@ -251,7 +308,7 @@ const patchModeOptions = [
|
|||||||
{ label: "Install", value: "install" },
|
{ label: "Install", value: "install" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "BulkAction",
|
name: "BulkAction",
|
||||||
components: { TacticalDropdown },
|
components: { TacticalDropdown },
|
||||||
emits: [...useDialogPluginComponent.emits],
|
emits: [...useDialogPluginComponent.emits],
|
||||||
@@ -259,14 +316,8 @@ export default {
|
|||||||
mode: !String,
|
mode: !String,
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
// setup vuex store
|
|
||||||
const store = useStore();
|
|
||||||
const showCommunityScripts = computed(
|
|
||||||
() => store.state.showCommunityScripts
|
|
||||||
);
|
|
||||||
|
|
||||||
const shellOptions = computed(() => {
|
const shellOptions = computed(() => {
|
||||||
if (state.value.osType === "windows") {
|
if (state.osType === "windows") {
|
||||||
return [
|
return [
|
||||||
{ label: "CMD", value: "cmd" },
|
{ label: "CMD", value: "cmd" },
|
||||||
{ label: "Powershell", value: "powershell" },
|
{ label: "Powershell", value: "powershell" },
|
||||||
@@ -293,18 +344,26 @@ export default {
|
|||||||
// dropdown setup
|
// dropdown setup
|
||||||
const {
|
const {
|
||||||
script,
|
script,
|
||||||
scriptOptions,
|
plat,
|
||||||
|
filterByPlatformOptions,
|
||||||
defaultTimeout,
|
defaultTimeout,
|
||||||
defaultArgs,
|
defaultArgs,
|
||||||
defaultEnvVars,
|
defaultEnvVars,
|
||||||
|
syntax,
|
||||||
|
link,
|
||||||
getScriptOptions,
|
getScriptOptions,
|
||||||
} = useScriptDropdown();
|
} = useScriptDropdown();
|
||||||
const { agents, agentOptions, getAgentOptions } = useAgentDropdown();
|
const { agents, agentOptions, getAgentOptions } = useAgentDropdown();
|
||||||
const { site, siteOptions, getSiteOptions } = useSiteDropdown();
|
const { site, siteOptions, getSiteOptions } = useSiteDropdown();
|
||||||
const { client, clientOptions, getClientOptions } = useClientDropdown();
|
const { client, clientOptions, getClientOptions } = useClientDropdown();
|
||||||
|
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
|
||||||
|
|
||||||
|
function openScriptURL() {
|
||||||
|
link.value ? openURL(link.value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// bulk action logic
|
// bulk action logic
|
||||||
const state = ref({
|
const state = reactive({
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
target: "client",
|
target: "client",
|
||||||
monType: "all",
|
monType: "all",
|
||||||
@@ -312,6 +371,9 @@ export default {
|
|||||||
cmd: "",
|
cmd: "",
|
||||||
shell: "cmd",
|
shell: "cmd",
|
||||||
custom_shell: null,
|
custom_shell: null,
|
||||||
|
custom_field: null,
|
||||||
|
collector_all_output: false,
|
||||||
|
save_to_agent_note: false,
|
||||||
patchMode: "scan",
|
patchMode: "scan",
|
||||||
offlineAgents: false,
|
offlineAgents: false,
|
||||||
client,
|
client,
|
||||||
@@ -324,35 +386,42 @@ export default {
|
|||||||
run_as_user: false,
|
run_as_user: false,
|
||||||
});
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const collector = ref(false);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => state.value.target,
|
() => state.target,
|
||||||
() => {
|
() => {
|
||||||
client.value = null;
|
client.value = null;
|
||||||
site.value = null;
|
site.value = null;
|
||||||
agents.value = [];
|
agents.value = [];
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
plat.value = state.osType;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => state.value.osType,
|
() => state.osType,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
state.value.custom_shell = null;
|
state.custom_shell = null;
|
||||||
state.value.run_as_user = false;
|
state.run_as_user = false;
|
||||||
|
|
||||||
if (newValue === "windows") {
|
if (newValue === "windows") {
|
||||||
state.value.shell = "cmd";
|
state.shell = "cmd";
|
||||||
} else {
|
} else {
|
||||||
state.value.shell = "/bin/bash";
|
state.shell = "/bin/bash";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// set plat to filter script options
|
||||||
|
if (newValue === "all") plat.value = undefined;
|
||||||
|
else plat.value = newValue;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await runBulkAction(state.value);
|
const data = await runBulkAction(state);
|
||||||
notifySuccess(data);
|
notifySuccess(data);
|
||||||
onDialogHide();
|
onDialogHide();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -362,9 +431,7 @@ export default {
|
|||||||
|
|
||||||
const supportsRunAsUser = () => {
|
const supportsRunAsUser = () => {
|
||||||
const modes = ["script", "command"];
|
const modes = ["script", "command"];
|
||||||
return (
|
return state.osType === "windows" && modes.includes(state.mode);
|
||||||
state.value.osType === "windows" && modes.includes(state.value.mode)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// set modal title and caption
|
// set modal title and caption
|
||||||
@@ -372,25 +439,10 @@ export default {
|
|||||||
return props.mode === "command"
|
return props.mode === "command"
|
||||||
? "Run Bulk Command"
|
? "Run Bulk Command"
|
||||||
: props.mode === "script"
|
: props.mode === "script"
|
||||||
? "Run Bulk Script"
|
? "Run Bulk Script"
|
||||||
: props.mode === "patch"
|
: props.mode === "patch"
|
||||||
? "Bulk Patch Management"
|
? "Bulk Patch Management"
|
||||||
: "";
|
: "";
|
||||||
});
|
|
||||||
|
|
||||||
const filteredScriptOptions = computed(() => {
|
|
||||||
if (props.mode !== "script") return [];
|
|
||||||
if (state.value.osType === "all") return scriptOptions.value;
|
|
||||||
|
|
||||||
return removeExtraOptionCategories(
|
|
||||||
scriptOptions.value.filter(
|
|
||||||
(script) =>
|
|
||||||
script.category ||
|
|
||||||
!script.supported_platforms ||
|
|
||||||
script.supported_platforms.length === 0 ||
|
|
||||||
script.supported_platforms.includes(state.value.osType)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// component lifecycle hooks
|
// component lifecycle hooks
|
||||||
@@ -398,7 +450,7 @@ export default {
|
|||||||
getAgentOptions();
|
getAgentOptions();
|
||||||
getSiteOptions();
|
getSiteOptions();
|
||||||
getClientOptions();
|
getClientOptions();
|
||||||
if (props.mode === "script") getScriptOptions(showCommunityScripts.value);
|
if (props.mode === "script") getScriptOptions();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -406,8 +458,10 @@ export default {
|
|||||||
state,
|
state,
|
||||||
agentOptions,
|
agentOptions,
|
||||||
clientOptions,
|
clientOptions,
|
||||||
|
collector,
|
||||||
|
customFieldOptions,
|
||||||
siteOptions,
|
siteOptions,
|
||||||
filteredScriptOptions,
|
filterByPlatformOptions,
|
||||||
loading,
|
loading,
|
||||||
shellOptions,
|
shellOptions,
|
||||||
filteredOsTypeOptions,
|
filteredOsTypeOptions,
|
||||||
@@ -419,6 +473,7 @@ export default {
|
|||||||
patchModeOptions,
|
patchModeOptions,
|
||||||
runAsUserToolTip,
|
runAsUserToolTip,
|
||||||
envVarsLabel,
|
envVarsLabel,
|
||||||
|
syntax,
|
||||||
|
|
||||||
//computed
|
//computed
|
||||||
modalTitle,
|
modalTitle,
|
||||||
@@ -427,11 +482,13 @@ export default {
|
|||||||
submit,
|
submit,
|
||||||
cmdPlaceholder,
|
cmdPlaceholder,
|
||||||
supportsRunAsUser,
|
supportsRunAsUser,
|
||||||
|
openScriptURL,
|
||||||
|
formatScriptSyntax,
|
||||||
|
|
||||||
// quasar dialog plugin
|
// quasar dialog plugin
|
||||||
dialogRef,
|
dialogRef,
|
||||||
onDialogHide,
|
onDialogHide,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
<q-radio
|
<q-radio
|
||||||
v-model="goarch"
|
v-model="goarch"
|
||||||
:val="GOARCH_ARM64"
|
:val="GOARCH_ARM64"
|
||||||
label="Apple Silicon (M1, M2)"
|
label="Apple Silicon (M-Series)"
|
||||||
v-show="agentOS === 'darwin'"
|
v-show="agentOS === 'darwin'"
|
||||||
/>
|
/>
|
||||||
<q-radio
|
<q-radio
|
||||||
|
|||||||
@@ -39,9 +39,9 @@
|
|||||||
<q-form @submit.prevent="sendScript">
|
<q-form @submit.prevent="sendScript">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<tactical-dropdown
|
<tactical-dropdown
|
||||||
:rules="[(val) => !!val || '*Required']"
|
:rules="[(val: number) => !!val || '*Required']"
|
||||||
v-model="state.script"
|
v-model="state.script"
|
||||||
:options="filteredScriptOptions"
|
:options="filterByPlatformOptions"
|
||||||
label="Select script"
|
label="Select script"
|
||||||
outlined
|
outlined
|
||||||
mapOptions
|
mapOptions
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
new-value-mode="add"
|
new-value-mode="add"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section v-if="!state.run_on_server">
|
||||||
<q-option-group
|
<q-option-group
|
||||||
v-model="state.output"
|
v-model="state.output"
|
||||||
:options="outputOptions"
|
:options="outputOptions"
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="state.output === 'collector'">
|
<q-card-section v-if="state.output === 'collector'">
|
||||||
<tactical-dropdown
|
<tactical-dropdown
|
||||||
:rules="[(val) => !!val || '*Required']"
|
:rules="[(val: number) => !!val || '*Required']"
|
||||||
outlined
|
outlined
|
||||||
v-model="state.custom_field"
|
v-model="state.custom_field"
|
||||||
:options="customFieldOptions"
|
:options="customFieldOptions"
|
||||||
@@ -140,10 +140,30 @@
|
|||||||
/>
|
/>
|
||||||
<q-checkbox v-model="state.save_all_output" label="Save all output" />
|
<q-checkbox v-model="state.save_all_output" label="Save all output" />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="agent.plat === 'windows'">
|
<q-card-section>
|
||||||
<q-checkbox v-model="state.run_as_user" label="Run As User">
|
<q-checkbox
|
||||||
|
v-if="agent.plat === 'windows' && !state.run_on_server"
|
||||||
|
v-model="state.run_as_user"
|
||||||
|
label="Run As User"
|
||||||
|
>
|
||||||
<q-tooltip>{{ runAsUserToolTip }}</q-tooltip>
|
<q-tooltip>{{ runAsUserToolTip }}</q-tooltip>
|
||||||
</q-checkbox>
|
</q-checkbox>
|
||||||
|
<q-checkbox
|
||||||
|
v-if="!hosted"
|
||||||
|
:disable="!server_scripts_enabled"
|
||||||
|
v-model="state.run_on_server"
|
||||||
|
label="Run On Server"
|
||||||
|
@update:model-value="ret = null"
|
||||||
|
>
|
||||||
|
<q-tooltip v-if="!server_scripts_enabled"
|
||||||
|
>Enable server side scripts globally to activate this
|
||||||
|
feature.</q-tooltip
|
||||||
|
>
|
||||||
|
<q-tooltip v-else
|
||||||
|
>Run the script on the Tactical RMM server in the context of this
|
||||||
|
agent.</q-tooltip
|
||||||
|
>
|
||||||
|
</q-checkbox>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -175,29 +195,70 @@
|
|||||||
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
|
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
|
||||||
style="max-height: 50vh"
|
style="max-height: 50vh"
|
||||||
>
|
>
|
||||||
<pre>{{ ret }}</pre>
|
<script-output-copy-clip
|
||||||
|
v-if="!state.run_on_server"
|
||||||
|
label="Output"
|
||||||
|
:data="ret"
|
||||||
|
/>
|
||||||
|
<q-separator />
|
||||||
|
<pre v-if="!state.run_on_server">{{ ret }}</pre>
|
||||||
|
<q-card-section v-if="state.run_on_server" class="scroll">
|
||||||
|
<div>
|
||||||
|
Run Time:
|
||||||
|
<code>{{ ret.execution_time }} seconds</code>
|
||||||
|
<br />Return Code:
|
||||||
|
<code>{{ ret.retcode }}</code>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div v-if="ret.stdout">
|
||||||
|
<script-output-copy-clip
|
||||||
|
label="Standard Output"
|
||||||
|
:data="ret.stdout"
|
||||||
|
/>
|
||||||
|
<q-separator />
|
||||||
|
<pre>{{ ret.stdout }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="ret.stderr">
|
||||||
|
<script-output-copy-clip
|
||||||
|
label="Standard Error"
|
||||||
|
:data="ret.stderr"
|
||||||
|
/>
|
||||||
|
<q-separator />
|
||||||
|
<pre>{{ ret.stderr }}</pre>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
// composition imports
|
// composition imports
|
||||||
import { ref, watch, computed } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useStore } from "vuex";
|
||||||
import { useDialogPluginComponent, openURL } from "quasar";
|
import { useDialogPluginComponent, openURL } from "quasar";
|
||||||
import { useScriptDropdown } from "@/composables/scripts";
|
import { useScriptDropdown } from "@/composables/scripts";
|
||||||
import { useCustomFieldDropdown } from "@/composables/core";
|
import { useCustomFieldDropdown } from "@/composables/core";
|
||||||
import { runScript } from "@/api/agents";
|
import { runScript } from "@/api/agents";
|
||||||
import { notifySuccess } from "@/utils/notify";
|
import { notifySuccess } from "@/utils/notify";
|
||||||
import { envVarsLabel, runAsUserToolTip } from "@/constants/constants";
|
import { envVarsLabel, runAsUserToolTip } from "@/constants/constants";
|
||||||
import {
|
import { formatScriptSyntax } from "@/utils/format";
|
||||||
formatScriptSyntax,
|
|
||||||
removeExtraOptionCategories,
|
|
||||||
} from "@/utils/format";
|
|
||||||
|
|
||||||
//ui imports
|
//ui imports
|
||||||
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
|
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { Agent } from "@/types/agents";
|
||||||
|
|
||||||
|
// store
|
||||||
|
const store = useStore();
|
||||||
|
const hosted = computed(() => store.state.hosted);
|
||||||
|
const server_scripts_enabled = computed(
|
||||||
|
() => store.state.server_scripts_enabled,
|
||||||
|
);
|
||||||
|
|
||||||
// static data
|
// static data
|
||||||
const outputOptions = [
|
const outputOptions = [
|
||||||
@@ -208,110 +269,72 @@ const outputOptions = [
|
|||||||
{ label: "Save results to Agent Notes", value: "note" },
|
{ label: "Save results to Agent Notes", value: "note" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
// emits
|
||||||
name: "RunScript",
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
emits: [...useDialogPluginComponent.emits],
|
|
||||||
components: { TacticalDropdown },
|
|
||||||
props: {
|
|
||||||
agent: !Object,
|
|
||||||
script: Number,
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
// setup quasar dialog plugin
|
|
||||||
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
|
||||||
|
|
||||||
// setup dropdowns
|
// props
|
||||||
const {
|
const props = defineProps<{
|
||||||
script,
|
agent: Agent;
|
||||||
scriptOptions,
|
script?: number;
|
||||||
defaultTimeout,
|
}>();
|
||||||
defaultArgs,
|
|
||||||
defaultEnvVars,
|
|
||||||
syntax,
|
|
||||||
link,
|
|
||||||
} = useScriptDropdown(props.script, {
|
|
||||||
onMount: true,
|
|
||||||
filterByPlatform: props.agent.plat,
|
|
||||||
});
|
|
||||||
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
|
|
||||||
|
|
||||||
// main run script functionaity
|
// setup quasar dialog plugin
|
||||||
const state = ref({
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
output: "wait",
|
|
||||||
emails: [],
|
|
||||||
emailMode: "default",
|
|
||||||
custom_field: null,
|
|
||||||
save_all_output: false,
|
|
||||||
script,
|
|
||||||
args: defaultArgs,
|
|
||||||
env_vars: defaultEnvVars,
|
|
||||||
timeout: defaultTimeout,
|
|
||||||
run_as_user: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ret = ref(null);
|
// setup dropdowns
|
||||||
const loading = ref(false);
|
const {
|
||||||
const maximized = ref(false);
|
script,
|
||||||
|
filterByPlatformOptions,
|
||||||
|
defaultTimeout,
|
||||||
|
defaultArgs,
|
||||||
|
defaultEnvVars,
|
||||||
|
syntax,
|
||||||
|
link,
|
||||||
|
} = useScriptDropdown({
|
||||||
|
script: props.script,
|
||||||
|
plat: props.agent.plat,
|
||||||
|
onMount: true,
|
||||||
|
});
|
||||||
|
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
|
||||||
|
|
||||||
async function sendScript() {
|
// main run script functionaity
|
||||||
ret.value = null;
|
const state = ref({
|
||||||
loading.value = true;
|
output: "wait",
|
||||||
|
emails: [],
|
||||||
|
emailMode: "default",
|
||||||
|
custom_field: null,
|
||||||
|
save_all_output: false,
|
||||||
|
script,
|
||||||
|
args: defaultArgs,
|
||||||
|
env_vars: defaultEnvVars,
|
||||||
|
timeout: defaultTimeout,
|
||||||
|
run_as_user: false,
|
||||||
|
run_on_server: false,
|
||||||
|
});
|
||||||
|
|
||||||
ret.value = await runScript(props.agent.agent_id, state.value);
|
const ret = ref(null);
|
||||||
loading.value = false;
|
const loading = ref(false);
|
||||||
if (state.value.output === "forget") {
|
const maximized = ref(false);
|
||||||
onDialogHide();
|
|
||||||
notifySuccess(ret.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openScriptURL() {
|
async function sendScript() {
|
||||||
link.value ? openURL(link.value) : null;
|
ret.value = null;
|
||||||
}
|
loading.value = true;
|
||||||
|
|
||||||
const filteredScriptOptions = computed(() => {
|
ret.value = await runScript(props.agent.agent_id, state.value);
|
||||||
return removeExtraOptionCategories(
|
loading.value = false;
|
||||||
scriptOptions.value.filter(
|
if (state.value.output === "forget") {
|
||||||
(script) =>
|
onDialogHide();
|
||||||
script.category ||
|
if (ret.value) notifySuccess(ret.value);
|
||||||
!script.supported_platforms ||
|
}
|
||||||
script.supported_platforms.length === 0 ||
|
}
|
||||||
script.supported_platforms.includes(props.agent.plat)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// watchers
|
function openScriptURL() {
|
||||||
watch(
|
link.value ? openURL(link.value) : null;
|
||||||
[() => state.value.output, () => state.value.emailMode],
|
}
|
||||||
() => (state.value.emails = [])
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
// watchers
|
||||||
// reactive data
|
watch(
|
||||||
state,
|
[() => state.value.output, () => state.value.emailMode],
|
||||||
loading,
|
() => (state.value.emails = []),
|
||||||
filteredScriptOptions,
|
);
|
||||||
link,
|
|
||||||
syntax,
|
|
||||||
ret,
|
|
||||||
maximized,
|
|
||||||
customFieldOptions,
|
|
||||||
|
|
||||||
// non-reactive data
|
|
||||||
outputOptions,
|
|
||||||
runAsUserToolTip,
|
|
||||||
envVarsLabel,
|
|
||||||
|
|
||||||
//methods
|
|
||||||
formatScriptSyntax,
|
|
||||||
sendScript,
|
|
||||||
openScriptURL,
|
|
||||||
|
|
||||||
// quasar dialog plugin
|
|
||||||
dialogRef,
|
|
||||||
onDialogHide,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -104,6 +104,9 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
|
<q-card-section v-if="ret !== null"
|
||||||
|
><script-output-copy-clip label="Output" :data="ret" /> <q-separator
|
||||||
|
/></q-card-section>
|
||||||
<q-card-section
|
<q-card-section
|
||||||
v-if="ret !== null"
|
v-if="ret !== null"
|
||||||
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
|
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
|
||||||
@@ -124,8 +127,13 @@ import { sendAgentCommand } from "@/api/agents";
|
|||||||
import { cmdPlaceholder } from "@/composables/agents";
|
import { cmdPlaceholder } from "@/composables/agents";
|
||||||
import { runAsUserToolTip } from "@/constants/constants";
|
import { runAsUserToolTip } from "@/constants/constants";
|
||||||
|
|
||||||
|
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "SendCommand",
|
name: "SendCommand",
|
||||||
|
components: {
|
||||||
|
ScriptOutputCopyClip,
|
||||||
|
},
|
||||||
emits: [...useDialogPluginComponent.emits],
|
emits: [...useDialogPluginComponent.emits],
|
||||||
props: {
|
props: {
|
||||||
agent: !Object,
|
agent: !Object,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-dialog ref="dialog" @hide="onHide">
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
<q-card style="width: 90vw; max-width: 90vw">
|
<q-card style="width: 90vw; max-width: 90vw">
|
||||||
<q-bar>
|
<q-bar>
|
||||||
{{ title }}
|
{{ alertTemplate ? "Edit Alert Template" : "Add Alert Template" }}
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn dense flat icon="close" v-close-popup>
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
@@ -150,50 +150,62 @@
|
|||||||
<span style="text-decoration: underline; cursor: help"
|
<span style="text-decoration: underline; cursor: help"
|
||||||
>Alert Failure Settings
|
>Alert Failure Settings
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
The selected script will run when an alert is triggered. This
|
The selected action will run when an alert is triggered.
|
||||||
script will run on any online agent.
|
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-select
|
<q-option-group
|
||||||
class="q-mb-sm"
|
v-model="template.action_type"
|
||||||
label="Failure action"
|
class="q-pb-sm"
|
||||||
|
:options="actionTypeOptions"
|
||||||
dense
|
dense
|
||||||
options-dense
|
inline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<tactical-dropdown
|
||||||
|
v-if="template.action_type == 'script'"
|
||||||
|
class="q-mb-sm"
|
||||||
|
label="Failure script"
|
||||||
outlined
|
outlined
|
||||||
clearable
|
clearable
|
||||||
v-model="template.action"
|
v-model="template.action"
|
||||||
:options="scriptOptions"
|
:options="scriptOptions"
|
||||||
map-options
|
mapOptions
|
||||||
emit-value
|
filterable
|
||||||
@update:model-value="setScriptDefaults('failure')"
|
:rules="[(val) => !!val || '*Required']"
|
||||||
>
|
/>
|
||||||
<template v-slot:option="scope">
|
|
||||||
<q-item
|
<tactical-dropdown
|
||||||
v-if="!scope.opt.category"
|
v-else-if="template.action_type == 'server'"
|
||||||
v-bind="scope.itemProps"
|
class="q-mb-sm"
|
||||||
class="q-pl-lg"
|
label="Failure script"
|
||||||
>
|
outlined
|
||||||
<q-item-section>
|
clearable
|
||||||
<q-item-label v-html="scope.opt.label"></q-item-label>
|
v-model="template.action"
|
||||||
</q-item-section>
|
:options="serverScriptOptions"
|
||||||
</q-item>
|
mapOptions
|
||||||
<q-item-label
|
filterable
|
||||||
v-if="scope.opt.category"
|
/>
|
||||||
v-bind="scope.itemProps"
|
|
||||||
header
|
<tactical-dropdown
|
||||||
class="q-pa-sm"
|
v-else
|
||||||
>{{ scope.opt.category }}</q-item-label
|
class="q-mb-sm"
|
||||||
>
|
label="Failure Web Hook"
|
||||||
</template>
|
outlined
|
||||||
</q-select>
|
clearable
|
||||||
|
v-model="template.action_rest"
|
||||||
|
:options="restActionOptions"
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
v-if="template.action_type !== 'rest'"
|
||||||
class="q-mb-sm"
|
class="q-mb-sm"
|
||||||
dense
|
dense
|
||||||
label="Failure action arguments (press Enter after typing each argument)"
|
label="Failure script arguments (press Enter after typing each argument)"
|
||||||
filled
|
filled
|
||||||
v-model="template.action_args"
|
v-model="template.action_args"
|
||||||
use-input
|
use-input
|
||||||
@@ -205,9 +217,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
v-if="template.action_type !== 'rest'"
|
||||||
class="q-mb-sm"
|
class="q-mb-sm"
|
||||||
dense
|
dense
|
||||||
label="Failure action environment vars (press Enter after typing each key=value pair)"
|
label="Failure script environment vars (press Enter after typing each key=value pair)"
|
||||||
filled
|
filled
|
||||||
v-model="template.action_env_vars"
|
v-model="template.action_env_vars"
|
||||||
use-input
|
use-input
|
||||||
@@ -219,16 +232,15 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
|
v-if="template.action_type !== 'rest'"
|
||||||
class="q-mb-sm"
|
class="q-mb-sm"
|
||||||
label="Failure action timeout (seconds)"
|
label="Failure script timeout (seconds)"
|
||||||
outlined
|
outlined
|
||||||
type="number"
|
type="number"
|
||||||
v-model.number="template.action_timeout"
|
v-model.number="template.action_timeout"
|
||||||
dense
|
dense
|
||||||
:rules="[
|
:rules="[
|
||||||
(val) => !!val || 'Failure action timeout is required',
|
(val) => !!val || 'Failure script timeout is required',
|
||||||
(val) => val > 0 || 'Timeout must be greater than 0',
|
|
||||||
(val) => val <= 60 || 'Timeout must be 60 or less',
|
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -237,50 +249,61 @@
|
|||||||
<span style="text-decoration: underline; cursor: help"
|
<span style="text-decoration: underline; cursor: help"
|
||||||
>Alert Resolved Settings
|
>Alert Resolved Settings
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
The selected script will run when an alert is resolved. This
|
The selected action will run when an alert is resolved.
|
||||||
script will run on any online agent.
|
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-select
|
<q-option-group
|
||||||
class="q-mb-sm"
|
v-model="template.resolved_action_type"
|
||||||
label="Resolved Action"
|
class="q-pb-sm"
|
||||||
|
:options="actionTypeOptions"
|
||||||
dense
|
dense
|
||||||
options-dense
|
inline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<tactical-dropdown
|
||||||
|
v-if="template.resolved_action_type === 'script'"
|
||||||
|
class="q-mb-sm"
|
||||||
|
label="Resolved Script"
|
||||||
outlined
|
outlined
|
||||||
clearable
|
clearable
|
||||||
v-model="template.resolved_action"
|
v-model="template.resolved_action"
|
||||||
:options="scriptOptions"
|
:options="scriptOptions"
|
||||||
map-options
|
mapOptions
|
||||||
emit-value
|
filterable
|
||||||
@update:model-value="setScriptDefaults('resolved')"
|
/>
|
||||||
>
|
|
||||||
<template v-slot:option="scope">
|
<tactical-dropdown
|
||||||
<q-item
|
v-else-if="template.resolved_action_type === 'server'"
|
||||||
v-if="!scope.opt.category"
|
class="q-mb-sm"
|
||||||
v-bind="scope.itemProps"
|
label="Resolved Script"
|
||||||
class="q-pl-lg"
|
outlined
|
||||||
>
|
clearable
|
||||||
<q-item-section>
|
v-model="template.resolved_action"
|
||||||
<q-item-label v-html="scope.opt.label"></q-item-label>
|
:options="serverScriptOptions"
|
||||||
</q-item-section>
|
mapOptions
|
||||||
</q-item>
|
filterable
|
||||||
<q-item-label
|
/>
|
||||||
v-if="scope.opt.category"
|
|
||||||
v-bind="scope.itemProps"
|
<tactical-dropdown
|
||||||
header
|
v-else
|
||||||
class="q-pa-sm"
|
class="q-mb-sm"
|
||||||
>{{ scope.opt.category }}</q-item-label
|
label="Resolved Web Hook"
|
||||||
>
|
outlined
|
||||||
</template>
|
clearable
|
||||||
</q-select>
|
v-model="template.resolved_action_rest"
|
||||||
|
:options="restActionOptions"
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
v-if="template.resolved_action_type !== 'rest'"
|
||||||
class="q-mb-sm"
|
class="q-mb-sm"
|
||||||
dense
|
dense
|
||||||
label="Resolved action arguments (press Enter after typing each argument)"
|
label="Resolved script arguments (press Enter after typing each argument)"
|
||||||
filled
|
filled
|
||||||
v-model="template.resolved_action_args"
|
v-model="template.resolved_action_args"
|
||||||
use-input
|
use-input
|
||||||
@@ -292,6 +315,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
v-if="template.resolved_action_type !== 'rest'"
|
||||||
class="q-mb-sm"
|
class="q-mb-sm"
|
||||||
dense
|
dense
|
||||||
label="Resolved action environment vars (press Enter after typing each key=value pair)"
|
label="Resolved action environment vars (press Enter after typing each key=value pair)"
|
||||||
@@ -306,16 +330,15 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
|
v-if="template.resolved_action_type !== 'rest'"
|
||||||
class="q-mb-sm"
|
class="q-mb-sm"
|
||||||
label="Resolved action timeout (seconds)"
|
label="Resolved script timeout (seconds)"
|
||||||
outlined
|
outlined
|
||||||
type="number"
|
type="number"
|
||||||
v-model.number="template.resolved_action_timeout"
|
v-model.number="template.resolved_action_timeout"
|
||||||
dense
|
dense
|
||||||
:rules="[
|
:rules="[
|
||||||
(val) => !!val || 'Resolved action timeout is required',
|
(val) => !!val || 'Resolved script timeout is required',
|
||||||
(val) => val > 0 || 'Timeout must be greater than 0',
|
|
||||||
(val) => val <= 60 || 'Timeout must be 60 or less',
|
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -324,7 +347,7 @@
|
|||||||
<span style="text-decoration: underline; cursor: help"
|
<span style="text-decoration: underline; cursor: help"
|
||||||
>Run actions only on
|
>Run actions only on
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
The selected script will only run on the following types of
|
The selected action will only run on the following types of
|
||||||
alerts
|
alerts
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</span>
|
</span>
|
||||||
@@ -674,7 +697,7 @@
|
|||||||
left-label
|
left-label
|
||||||
/>
|
/>
|
||||||
<q-toggle
|
<q-toggle
|
||||||
v-model="template.check_text_on_resolved"
|
v-model="template.task_text_on_resolved"
|
||||||
label="Text"
|
label="Text"
|
||||||
color="green"
|
color="green"
|
||||||
left-label
|
left-label
|
||||||
@@ -688,18 +711,23 @@
|
|||||||
v-if="step > 1"
|
v-if="step > 1"
|
||||||
flat
|
flat
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="$refs.stepper.previous()"
|
@click="stepper?.previous()"
|
||||||
label="Back"
|
label="Back"
|
||||||
class="q-mr-xs"
|
class="q-mr-xs"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="step < 5"
|
v-if="step < 5"
|
||||||
@click="$refs.stepper.next()"
|
@click="stepper?.next()"
|
||||||
color="primary"
|
color="primary"
|
||||||
label="Next"
|
label="Next"
|
||||||
/>
|
/>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn @click="onSubmit" color="primary" label="Submit" />
|
<q-btn
|
||||||
|
@click="onSubmit"
|
||||||
|
color="primary"
|
||||||
|
label="Submit"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
</q-stepper-navigation>
|
</q-stepper-navigation>
|
||||||
</template>
|
</template>
|
||||||
</q-stepper>
|
</q-stepper>
|
||||||
@@ -707,195 +735,279 @@
|
|||||||
</q-dialog>
|
</q-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import mixins from "@/mixins/mixins";
|
import { computed, ref, reactive, watch, nextTick } from "vue";
|
||||||
import { mapGetters } from "vuex";
|
import { useStore } from "vuex";
|
||||||
|
import { useQuasar, useDialogPluginComponent, type QStepper } from "quasar";
|
||||||
|
import { useScriptDropdown } from "@/composables/scripts";
|
||||||
|
import { useURLActionDropdown } from "@/composables/core";
|
||||||
|
import { notifyError, notifySuccess } from "@/utils/notify";
|
||||||
|
import { addAlertTemplate, saveAlertTemplate } from "@/api/alerts";
|
||||||
|
import { isValidEmail } from "@/utils/validation";
|
||||||
|
|
||||||
export default {
|
// components
|
||||||
name: "AlertTemplateForm",
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
emits: ["hide", "ok", "cancel"],
|
|
||||||
mixins: [mixins],
|
// types
|
||||||
props: { alertTemplate: Object },
|
import type { AlertTemplate, AlertSeverity } from "@/types/alerts";
|
||||||
data() {
|
|
||||||
return {
|
// store
|
||||||
step: 1,
|
const store = useStore();
|
||||||
template: {
|
const hosted = computed(() => store.state.hosted);
|
||||||
name: "",
|
const server_scripts_enabled = computed(
|
||||||
is_active: true,
|
() => store.state.server_scripts_enabled,
|
||||||
action: null,
|
);
|
||||||
action_args: [],
|
|
||||||
action_env_vars: [],
|
// props
|
||||||
action_timeout: 15,
|
const props = defineProps<{
|
||||||
resolved_action: null,
|
alertTemplate?: AlertTemplate;
|
||||||
resolved_action_args: [],
|
}>();
|
||||||
resolved_action_env_vars: [],
|
|
||||||
resolved_action_timeout: 15,
|
// emits
|
||||||
email_recipients: [],
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
email_from: "",
|
|
||||||
text_recipients: [],
|
// setup quasar plugins
|
||||||
agent_email_on_resolved: false,
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
agent_text_on_resolved: false,
|
const $q = useQuasar();
|
||||||
agent_always_email: null,
|
|
||||||
agent_always_text: null,
|
const step = ref(1);
|
||||||
agent_always_alert: null,
|
|
||||||
agent_periodic_alert_days: 0,
|
// setup script dropdowns
|
||||||
agent_script_actions: true,
|
const {
|
||||||
check_email_alert_severity: [],
|
script: failureAction,
|
||||||
check_text_alert_severity: [],
|
defaultArgs: failureArgs,
|
||||||
check_dashboard_alert_severity: [],
|
defaultEnvVars: failureEnvVars,
|
||||||
check_email_on_resolved: false,
|
defaultTimeout: failureTimeout,
|
||||||
check_text_on_resolved: false,
|
serverScriptOptions,
|
||||||
check_always_email: null,
|
scriptOptions,
|
||||||
check_always_text: null,
|
} = useScriptDropdown({ script: props.alertTemplate?.action, onMount: true });
|
||||||
check_always_alert: null,
|
|
||||||
check_periodic_alert_days: 0,
|
const {
|
||||||
check_script_actions: true,
|
script: resolvedAction,
|
||||||
task_email_alert_severity: [],
|
defaultArgs: resolvedArgs,
|
||||||
task_text_alert_severity: [],
|
defaultEnvVars: resolvedEnvVars,
|
||||||
task_dashboard_alert_severity: [],
|
defaultTimeout: resolvedTimeout,
|
||||||
task_email_on_resolved: false,
|
} = useScriptDropdown({
|
||||||
task_text_on_resolved: false,
|
script: props.alertTemplate?.resolved_action,
|
||||||
task_always_email: null,
|
onMount: true,
|
||||||
task_always_text: null,
|
});
|
||||||
task_always_alert: null,
|
|
||||||
task_periodic_alert_days: 0,
|
// setup custom field dropdown
|
||||||
task_script_actions: true,
|
const { restActionOptions } = useURLActionDropdown({ onMount: true });
|
||||||
},
|
|
||||||
scriptOptions: [],
|
// alert template form logic
|
||||||
severityOptions: [
|
const template: AlertTemplate = props.alertTemplate
|
||||||
{ label: "Error", value: "error" },
|
? reactive(Object.assign({}, { ...props.alertTemplate }))
|
||||||
{ label: "Warning", value: "warning" },
|
: reactive({
|
||||||
{ label: "Informational", value: "info" },
|
id: 0,
|
||||||
],
|
name: "",
|
||||||
thumbStyle: {
|
is_active: true,
|
||||||
right: "2px",
|
action_type: "script",
|
||||||
borderRadius: "5px",
|
action: failureAction,
|
||||||
backgroundColor: "#027be3",
|
action_rest: undefined,
|
||||||
width: "5px",
|
action_args: failureArgs,
|
||||||
opacity: 0.75,
|
action_env_vars: failureEnvVars,
|
||||||
},
|
action_timeout: failureTimeout,
|
||||||
};
|
resolved_action_type: "script",
|
||||||
|
resolved_action: resolvedAction,
|
||||||
|
resolved_action_rest: undefined,
|
||||||
|
resolved_action_args: resolvedArgs,
|
||||||
|
resolved_action_env_vars: resolvedEnvVars,
|
||||||
|
resolved_action_timeout: resolvedTimeout,
|
||||||
|
email_recipients: [] as string[],
|
||||||
|
email_from: "",
|
||||||
|
text_recipients: [] as string[],
|
||||||
|
agent_email_on_resolved: false,
|
||||||
|
agent_text_on_resolved: false,
|
||||||
|
agent_always_email: null,
|
||||||
|
agent_always_text: null,
|
||||||
|
agent_always_alert: null,
|
||||||
|
agent_periodic_alert_days: 0,
|
||||||
|
agent_script_actions: true,
|
||||||
|
check_email_alert_severity: [] as AlertSeverity[],
|
||||||
|
check_text_alert_severity: [] as AlertSeverity[],
|
||||||
|
check_dashboard_alert_severity: [] as AlertSeverity[],
|
||||||
|
check_email_on_resolved: false,
|
||||||
|
check_text_on_resolved: false,
|
||||||
|
check_always_email: null,
|
||||||
|
check_always_text: null,
|
||||||
|
check_always_alert: null,
|
||||||
|
check_periodic_alert_days: 0,
|
||||||
|
check_script_actions: true,
|
||||||
|
task_email_alert_severity: [] as AlertSeverity[],
|
||||||
|
task_text_alert_severity: [] as AlertSeverity[],
|
||||||
|
task_dashboard_alert_severity: [] as AlertSeverity[],
|
||||||
|
task_email_on_resolved: false,
|
||||||
|
task_text_on_resolved: false,
|
||||||
|
task_always_email: null,
|
||||||
|
task_always_text: null,
|
||||||
|
task_always_alert: null,
|
||||||
|
task_periodic_alert_days: 0,
|
||||||
|
task_script_actions: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset selected script if action type is changed
|
||||||
|
watch(
|
||||||
|
() => template.action_type,
|
||||||
|
() => {
|
||||||
|
template.action_rest = undefined;
|
||||||
|
template.action = undefined;
|
||||||
|
template.action_args = [];
|
||||||
|
template.action_env_vars = [];
|
||||||
|
template.action_timeout = 30;
|
||||||
},
|
},
|
||||||
computed: {
|
);
|
||||||
...mapGetters(["showCommunityScripts"]),
|
|
||||||
title() {
|
watch(
|
||||||
return this.editing ? "Edit Alert Template" : "Add Alert Template";
|
() => template.resolved_action_type,
|
||||||
},
|
() => {
|
||||||
editing() {
|
template.resolved_action_rest = undefined;
|
||||||
return !!this.alertTemplate;
|
template.resolved_action = undefined;
|
||||||
},
|
template.resolved_action_args = [];
|
||||||
|
template.resolved_action_env_vars = [];
|
||||||
|
template.resolved_action_timeout = 30;
|
||||||
},
|
},
|
||||||
methods: {
|
);
|
||||||
setScriptDefaults(type) {
|
|
||||||
if (type === "failure") {
|
// sync selected script to scriptdropdown
|
||||||
const script = this.scriptOptions.find(
|
// only add watchers if editting template
|
||||||
(i) => i.value === this.template.action
|
if (props.alertTemplate) {
|
||||||
);
|
watch(
|
||||||
this.template.action_args = script.args;
|
() => template.action,
|
||||||
this.template.action_env_vars = script.env_vars;
|
(newValue) => {
|
||||||
} else if (type === "resolved") {
|
if (newValue) {
|
||||||
const script = this.scriptOptions.find(
|
failureAction.value = newValue;
|
||||||
(i) => i.value === this.template.resolved_action
|
|
||||||
);
|
// wait for the script change to happen
|
||||||
this.template.resolved_action_args = script.args;
|
nextTick(() => {
|
||||||
this.template.resolved_action_env_vars = script.env_vars;
|
template.action_args = failureArgs.value;
|
||||||
}
|
template.action_env_vars = failureEnvVars.value;
|
||||||
},
|
template.action_timeout = failureTimeout.value;
|
||||||
toggleAddEmail() {
|
|
||||||
this.$q
|
|
||||||
.dialog({
|
|
||||||
title: "Add email",
|
|
||||||
prompt: {
|
|
||||||
model: "",
|
|
||||||
isValid: (val) => this.isValidEmail(val),
|
|
||||||
type: "email",
|
|
||||||
},
|
|
||||||
cancel: true,
|
|
||||||
ok: { label: "Add", color: "primary" },
|
|
||||||
persistent: false,
|
|
||||||
})
|
|
||||||
.onOk((data) => {
|
|
||||||
this.template.email_recipients.push(data);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
toggleAddSMSNumber() {
|
);
|
||||||
this.$q
|
|
||||||
.dialog({
|
watch(
|
||||||
title: "Add number",
|
() => template.resolved_action,
|
||||||
message:
|
(newValue) => {
|
||||||
"Use E.164 format: must have the <b>+</b> symbol and <span class='text-red'>country code</span>, followed by the <span class='text-green'>phone number</span> e.g. <b>+<span class='text-red'>1</span><span class='text-green'>2131231234</span></b>",
|
if (newValue) {
|
||||||
prompt: {
|
resolvedAction.value = newValue;
|
||||||
model: "",
|
|
||||||
},
|
// wait for the script change to happen
|
||||||
html: true,
|
nextTick(() => {
|
||||||
cancel: true,
|
template.resolved_action_args = resolvedArgs.value;
|
||||||
ok: { label: "Add", color: "primary" },
|
template.resolved_action_env_vars = resolvedEnvVars.value;
|
||||||
persistent: false,
|
template.resolved_action_timeout = resolvedTimeout.value;
|
||||||
})
|
|
||||||
.onOk((data) => {
|
|
||||||
this.template.text_recipients.push(data);
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
removeEmail(email) {
|
|
||||||
const removed = this.template.email_recipients.filter((k) => k !== email);
|
|
||||||
this.template.email_recipients = removed;
|
|
||||||
},
|
|
||||||
removeSMSNumber(num) {
|
|
||||||
const removed = this.template.text_recipients.filter((k) => k !== num);
|
|
||||||
this.template.text_recipients = removed;
|
|
||||||
},
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.template.name) {
|
|
||||||
this.notifyError("Name needs to be set");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$q.loading.show();
|
|
||||||
|
|
||||||
if (this.editing) {
|
|
||||||
this.$axios
|
|
||||||
.put(`alerts/templates/${this.template.id}/`, this.template)
|
|
||||||
.then(() => {
|
|
||||||
this.$q.loading.hide();
|
|
||||||
this.onOk();
|
|
||||||
this.notifySuccess("Alert Template edited!");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.$q.loading.hide();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.$axios
|
|
||||||
.post("alerts/templates/", this.template)
|
|
||||||
.then(() => {
|
|
||||||
this.$q.loading.hide();
|
|
||||||
this.onOk();
|
|
||||||
this.notifySuccess("Alert Template was added!");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.$q.loading.hide();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
show() {
|
);
|
||||||
this.$refs.dialog.show();
|
}
|
||||||
},
|
|
||||||
hide() {
|
const severityOptions = [
|
||||||
this.$refs.dialog.hide();
|
{ label: "Error", value: "error" },
|
||||||
},
|
{ label: "Warning", value: "warning" },
|
||||||
onHide() {
|
{ label: "Informational", value: "info" },
|
||||||
this.$emit("hide");
|
];
|
||||||
},
|
|
||||||
onOk() {
|
const staticActionTypeOptions = [
|
||||||
this.$emit("ok");
|
{ label: "Send a Web Hook", value: "rest" },
|
||||||
this.hide();
|
{ label: "Run script on Agent", value: "script" },
|
||||||
},
|
{ label: "Run script on TRMM Server", value: "server" },
|
||||||
},
|
];
|
||||||
mounted() {
|
|
||||||
this.getScriptOptions(this.showCommunityScripts).then(
|
const actionTypeOptions = computed(() => {
|
||||||
(options) => (this.scriptOptions = Object.freeze(options))
|
// don't show for hosted at all
|
||||||
|
if (hosted.value) {
|
||||||
|
return staticActionTypeOptions.filter(
|
||||||
|
(option) => option.value !== "server",
|
||||||
);
|
);
|
||||||
// Copy alertTemplate prop locally
|
}
|
||||||
if (this.editing) Object.assign(this.template, this.alertTemplate);
|
// disable the server script radio button if feature is disabled globally
|
||||||
},
|
const modifiedOptions = staticActionTypeOptions.map((option) => {
|
||||||
};
|
if (!server_scripts_enabled.value && option.value === "server") {
|
||||||
|
return { ...option, disable: true };
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
});
|
||||||
|
|
||||||
|
return modifiedOptions;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepper = ref<QStepper | null>(null);
|
||||||
|
function toggleAddEmail() {
|
||||||
|
$q.dialog({
|
||||||
|
title: "Add email",
|
||||||
|
prompt: {
|
||||||
|
model: "",
|
||||||
|
isValid: (val) => isValidEmail(val),
|
||||||
|
type: "email",
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Add", color: "primary" },
|
||||||
|
persistent: false,
|
||||||
|
}).onOk((data) => {
|
||||||
|
template.email_recipients.push(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAddSMSNumber() {
|
||||||
|
$q.dialog({
|
||||||
|
title: "Add number",
|
||||||
|
message:
|
||||||
|
"Use E.164 format: must have the <b>+</b> symbol and <span class='text-red'>country code</span>, followed by the <span class='text-green'>phone number</span> e.g. <b>+<span class='text-red'>1</span><span class='text-green'>2131231234</span></b>",
|
||||||
|
prompt: {
|
||||||
|
model: "",
|
||||||
|
},
|
||||||
|
html: true,
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Add", color: "primary" },
|
||||||
|
persistent: false,
|
||||||
|
}).onOk((data: string) => {
|
||||||
|
template.text_recipients.push(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmail(email: string) {
|
||||||
|
const removed = template.email_recipients.filter((k) => k !== email);
|
||||||
|
template.email_recipients = removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSMSNumber(num: string) {
|
||||||
|
const removed = template.text_recipients.filter((k) => k !== num);
|
||||||
|
template.text_recipients = removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
// TODO rework this ghetto form validation
|
||||||
|
if (!template.name) {
|
||||||
|
notifyError("Name needs to be set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
if (props.alertTemplate) {
|
||||||
|
try {
|
||||||
|
await saveAlertTemplate(template.id, template);
|
||||||
|
notifySuccess("Alert Template edited!");
|
||||||
|
onDialogOK();
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await addAlertTemplate(template);
|
||||||
|
notifySuccess("Alert Template edited!");
|
||||||
|
onDialogOK();
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -191,24 +191,6 @@
|
|||||||
}}</q-badge>
|
}}</q-badge>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:body-cell-alert_time="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
{{ formatDate(props.value) }}
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:body-cell-resolve_on="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
{{ formatDate(props.value) }}
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:body-cell-snoozed_until="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
{{ formatDate(props.value) }}
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
</q-table>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -265,6 +247,21 @@ export default {
|
|||||||
field: "alert_time",
|
field: "alert_time",
|
||||||
align: "left",
|
align: "left",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
format: (a) => this.formatDate(a),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "client",
|
||||||
|
label: "Client",
|
||||||
|
field: "client",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "site",
|
||||||
|
label: "Site",
|
||||||
|
field: "site",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hostname",
|
name: "hostname",
|
||||||
@@ -296,11 +293,12 @@ export default {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "resolve_on",
|
name: "resolved_on",
|
||||||
label: "Resolved On",
|
label: "Resolved On",
|
||||||
field: "resolve_on",
|
field: "resolved_on",
|
||||||
align: "left",
|
align: "left",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
format: (a) => this.formatDate(a),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snoozed_until",
|
name: "snoozed_until",
|
||||||
@@ -308,6 +306,7 @@ export default {
|
|||||||
field: "snoozed_until",
|
field: "snoozed_until",
|
||||||
align: "left",
|
align: "left",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
format: (a) => this.formatDate(a),
|
||||||
},
|
},
|
||||||
{ name: "actions", label: "Actions", align: "left" },
|
{ name: "actions", label: "Actions", align: "left" },
|
||||||
],
|
],
|
||||||
@@ -328,7 +327,7 @@ export default {
|
|||||||
return this.columns.map((column) => {
|
return this.columns.map((column) => {
|
||||||
if (column.name === "snoozed_until") {
|
if (column.name === "snoozed_until") {
|
||||||
if (this.includeSnoozed) return column.name;
|
if (this.includeSnoozed) return column.name;
|
||||||
} else if (column.name === "resolve_on") {
|
} else if (column.name === "resolved_on") {
|
||||||
if (this.includeResolved) return column.name;
|
if (this.includeResolved) return column.name;
|
||||||
} else {
|
} else {
|
||||||
return column.name;
|
return column.name;
|
||||||
@@ -340,7 +339,7 @@ export default {
|
|||||||
getClients() {
|
getClients() {
|
||||||
this.$axios.get("/clients/").then((r) => {
|
this.$axios.get("/clients/").then((r) => {
|
||||||
this.clientsOptions = Object.freeze(
|
this.clientsOptions = Object.freeze(
|
||||||
r.data.map((client) => ({ label: client.name, value: client.id }))
|
r.data.map((client) => ({ label: client.name, value: client.id })),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -142,6 +142,11 @@
|
|||||||
v-model="localField.hide_in_ui"
|
v-model="localField.hide_in_ui"
|
||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
|
<q-toggle
|
||||||
|
label="Hide in Summary Tab"
|
||||||
|
v-model="localField.hide_in_summary"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="Cancel" v-close-popup />
|
<q-btn flat label="Cancel" v-close-popup />
|
||||||
@@ -172,6 +177,7 @@ export default {
|
|||||||
default_value_bool: false,
|
default_value_bool: false,
|
||||||
default_values_multiple: [],
|
default_values_multiple: [],
|
||||||
hide_in_ui: false,
|
hide_in_ui: false,
|
||||||
|
hide_in_summary: false,
|
||||||
},
|
},
|
||||||
modelOptions: [
|
modelOptions: [
|
||||||
{ label: "Client", value: "client" },
|
{ label: "Client", value: "client" },
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
<!-- name -->
|
<!-- name -->
|
||||||
<q-td>
|
<q-td>
|
||||||
{{ props.row.name }}
|
{{ props.row.name }}
|
||||||
|
<q-tooltip :delay="600">ID: {{ props.row.id }}</q-tooltip>
|
||||||
</q-td>
|
</q-td>
|
||||||
<!-- type -->
|
<!-- type -->
|
||||||
<q-td>
|
<q-td>
|
||||||
@@ -57,6 +58,10 @@
|
|||||||
<q-td>
|
<q-td>
|
||||||
<q-icon v-if="props.row.hide_in_ui" name="check" />
|
<q-icon v-if="props.row.hide_in_ui" name="check" />
|
||||||
</q-td>
|
</q-td>
|
||||||
|
<!-- hide in summary tab -->
|
||||||
|
<q-td>
|
||||||
|
<q-icon v-if="props.row.hide_in_summary" name="check" />
|
||||||
|
</q-td>
|
||||||
<!-- default value -->
|
<!-- default value -->
|
||||||
<q-td v-if="props.row.type === 'checkbox'">
|
<q-td v-if="props.row.type === 'checkbox'">
|
||||||
{{ props.row.default_value_bool }}
|
{{ props.row.default_value_bool }}
|
||||||
@@ -123,6 +128,13 @@ export default {
|
|||||||
align: "left",
|
align: "left",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "hide_in_summary",
|
||||||
|
label: "Hide in Summary Tab",
|
||||||
|
field: "hide_in_summary",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "default_value",
|
name: "default_value",
|
||||||
label: "Default Value",
|
label: "Default Value",
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
<q-tab name="customfields" label="Custom Fields" />
|
<q-tab name="customfields" label="Custom Fields" />
|
||||||
<q-tab name="keystore" label="Key Store" />
|
<q-tab name="keystore" label="Key Store" />
|
||||||
<q-tab name="urlactions" label="URL Actions" />
|
<q-tab name="urlactions" label="URL Actions" />
|
||||||
|
<q-tab name="webhooks" label="Web Hooks" />
|
||||||
<q-tab name="retention" label="Retention" />
|
<q-tab name="retention" label="Retention" />
|
||||||
<q-tab name="apikeys" label="API Keys" />
|
<q-tab name="apikeys" label="API Keys" />
|
||||||
|
<q-tab name="sso" label="Single Sign-On (SSO)" />
|
||||||
<!-- <q-tab name="openai" label="Open AI" /> -->
|
<!-- <q-tab name="openai" label="Open AI" /> -->
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,6 +43,51 @@
|
|||||||
<q-tooltip> Runs at 35mins past every hour </q-tooltip>
|
<q-tooltip> Runs at 35mins past every hour </q-tooltip>
|
||||||
</q-checkbox>
|
</q-checkbox>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
<q-card-section v-if="!hosted" class="row">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="settings.enable_server_scripts"
|
||||||
|
label="Enable server side scripts"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
>Allow running scripts on TRMM server for alert
|
||||||
|
failure/resolve actions</q-tooltip
|
||||||
|
>
|
||||||
|
</q-checkbox>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="warning"
|
||||||
|
@click="
|
||||||
|
openURL(
|
||||||
|
'https://docs.tacticalrmm.com/functions/permissions/#permissions-with-extra-security-implications',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-if="!hosted" class="row">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="settings.enable_server_webterminal"
|
||||||
|
label="Enable web terminal"
|
||||||
|
>
|
||||||
|
<q-tooltip>Enable the web terminal</q-tooltip>
|
||||||
|
</q-checkbox>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
roundenable_server_webterminal
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="warning"
|
||||||
|
@click="
|
||||||
|
openURL(
|
||||||
|
'https://docs.tacticalrmm.com/functions/permissions/#permissions-with-extra-security-implications',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</q-card-section>
|
||||||
<q-card-section class="row">
|
<q-card-section class="row">
|
||||||
<div class="col-4">Default agent timezone:</div>
|
<div class="col-4">Default agent timezone:</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
@@ -71,7 +118,7 @@
|
|||||||
icon="info"
|
icon="info"
|
||||||
@click="
|
@click="
|
||||||
openURL(
|
openURL(
|
||||||
'https://quasar.dev/quasar-utils/date-utils#format-for-display'
|
'https://quasar.dev/quasar-utils/date-utils#format-for-display',
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -125,6 +172,24 @@
|
|||||||
class="col-6"
|
class="col-6"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
<q-card-section class="row">
|
||||||
|
<div class="col-4 flex items-center">
|
||||||
|
Receive notifications on:
|
||||||
|
</div>
|
||||||
|
<div class="col-2"></div>
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
v-model="settings.notify_on_info_alerts"
|
||||||
|
class="col-3"
|
||||||
|
label="Informational Alerts"
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
v-model="settings.notify_on_warning_alerts"
|
||||||
|
class="col-3"
|
||||||
|
label="Warning Alerts"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
<q-card-section class="row">
|
<q-card-section class="row">
|
||||||
<div class="col-4">Agent Debug Level:</div>
|
<div class="col-4">Agent Debug Level:</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
@@ -216,7 +281,7 @@
|
|||||||
<div class="text-subtitle2">SMTP Settings</div>
|
<div class="text-subtitle2">SMTP Settings</div>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<q-card-section class="row">
|
<q-card-section class="row">
|
||||||
<div class="col-2">From:</div>
|
<div class="col-2">From email:</div>
|
||||||
<div class="col-4"></div>
|
<div class="col-4"></div>
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
@@ -226,6 +291,16 @@
|
|||||||
:rules="[(val) => isValidEmail(val) || 'Invalid email']"
|
:rules="[(val) => isValidEmail(val) || 'Invalid email']"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
<q-card-section class="row">
|
||||||
|
<div class="col-2">From name:</div>
|
||||||
|
<div class="col-4"></div>
|
||||||
|
<q-input
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
v-model="settings.smtp_from_name"
|
||||||
|
class="col-6 q-pa-none"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
<q-card-section class="row">
|
<q-card-section class="row">
|
||||||
<div class="col-2">Host:</div>
|
<div class="col-2">Host:</div>
|
||||||
<div class="col-4"></div>
|
<div class="col-4"></div>
|
||||||
@@ -379,7 +454,7 @@
|
|||||||
<q-tab-panel name="meshcentral">
|
<q-tab-panel name="meshcentral">
|
||||||
<div class="text-subtitle2">MeshCentral Settings</div>
|
<div class="text-subtitle2">MeshCentral Settings</div>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<q-card-section class="row">
|
<q-card-section class="row" v-if="!hosted">
|
||||||
<div class="col-4">Username:</div>
|
<div class="col-4">Username:</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -395,7 +470,7 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="row">
|
<q-card-section class="row" v-if="!hosted">
|
||||||
<div class="col-4">Mesh Site:</div>
|
<div class="col-4">Mesh Site:</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -405,7 +480,7 @@
|
|||||||
class="col-6"
|
class="col-6"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="row">
|
<q-card-section class="row" v-if="!hosted">
|
||||||
<div class="col-4">Mesh Token:</div>
|
<div class="col-4">Mesh Token:</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -415,7 +490,7 @@
|
|||||||
class="col-6"
|
class="col-6"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="row">
|
<q-card-section class="row" v-if="!hosted">
|
||||||
<div class="col-4">Mesh Device Group Name:</div>
|
<div class="col-4">Mesh Device Group Name:</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -425,29 +500,81 @@
|
|||||||
class="col-6"
|
class="col-6"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="row">
|
<q-card-section class="row" v-if="!hosted">
|
||||||
<div class="col-4">
|
<div class="col-4 flex items-center">
|
||||||
Disable Auto Login for Remote Control and Remote background:
|
Sync Mesh Perms with TRMM:
|
||||||
|
<q-icon
|
||||||
|
right
|
||||||
|
name="ion-information-circle-outline"
|
||||||
|
size="sm"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<q-tooltip class="text-caption">
|
||||||
|
It is recommended to keep this option enabled;
|
||||||
|
otherwise, all TRMM users will have full permissions in
|
||||||
|
MeshCentral regardless of their permissions in TRMM.
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
dense
|
dense
|
||||||
v-model="settings.mesh_disable_auto_login"
|
:model-value="settings.sync_mesh_with_trmm"
|
||||||
|
@update:model-value="confirmSyncChange"
|
||||||
class="col-6"
|
class="col-6"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section class="row items-center">
|
||||||
|
<div class="col-4 flex items-center">
|
||||||
|
Company Name:
|
||||||
|
<q-icon
|
||||||
|
name="ion-information-circle-outline"
|
||||||
|
size="sm"
|
||||||
|
class="q-ml-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<q-tooltip class="text-caption">
|
||||||
|
Adding your company name here will append it to the
|
||||||
|
user's full name that appears when doing a remote
|
||||||
|
control session, for example: 'John Doe - Amidaware
|
||||||
|
Inc.'
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-2"></div>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
v-model="settings.mesh_company_name"
|
||||||
|
class="col-6"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- custom fields -->
|
||||||
<q-tab-panel name="customfields">
|
<q-tab-panel name="customfields">
|
||||||
<CustomFields />
|
<CustomFields />
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- key store -->
|
||||||
<q-tab-panel name="keystore">
|
<q-tab-panel name="keystore">
|
||||||
<KeyStoreTable />
|
<KeyStoreTable />
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- url actions -->
|
||||||
<q-tab-panel name="urlactions">
|
<q-tab-panel name="urlactions">
|
||||||
<URLActionsTable />
|
<URLActionsTable type="web" />
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- web hooks -->
|
||||||
|
<q-tab-panel name="webhooks">
|
||||||
|
<URLActionsTable type="rest" />
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- retention -->
|
||||||
<q-tab-panel name="retention">
|
<q-tab-panel name="retention">
|
||||||
<q-card-section class="row">
|
<q-card-section class="row">
|
||||||
<div class="col-4">Check History (days):</div>
|
<div class="col-4">Check History (days):</div>
|
||||||
@@ -510,6 +637,11 @@
|
|||||||
<APIKeysTable />
|
<APIKeysTable />
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- sso integration -->
|
||||||
|
<q-tab-panel name="sso">
|
||||||
|
<SSOProvidersTable />
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
<!-- Open AI -->
|
<!-- Open AI -->
|
||||||
<!-- <q-tab-panel name="openai">
|
<!-- <q-tab-panel name="openai">
|
||||||
<div class="text-subtitle2">Open AI</div>
|
<div class="text-subtitle2">Open AI</div>
|
||||||
@@ -559,7 +691,8 @@
|
|||||||
v-show="
|
v-show="
|
||||||
tab !== 'customfields' &&
|
tab !== 'customfields' &&
|
||||||
tab !== 'keystore' &&
|
tab !== 'keystore' &&
|
||||||
tab !== 'urlactions'
|
tab !== 'urlactions' &&
|
||||||
|
tab !== 'sso'
|
||||||
"
|
"
|
||||||
label="Save"
|
label="Save"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -596,6 +729,7 @@ import CustomFields from "@/components/modals/coresettings/CustomFields.vue";
|
|||||||
import KeyStoreTable from "@/components/modals/coresettings/KeyStoreTable.vue";
|
import KeyStoreTable from "@/components/modals/coresettings/KeyStoreTable.vue";
|
||||||
import URLActionsTable from "@/components/modals/coresettings/URLActionsTable.vue";
|
import URLActionsTable from "@/components/modals/coresettings/URLActionsTable.vue";
|
||||||
import APIKeysTable from "@/components/core/APIKeysTable.vue";
|
import APIKeysTable from "@/components/core/APIKeysTable.vue";
|
||||||
|
import SSOProvidersTable from "@/ee/sso/components/SSOProvidersTable.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "EditCoreSettings",
|
name: "EditCoreSettings",
|
||||||
@@ -605,6 +739,7 @@ export default {
|
|||||||
KeyStoreTable,
|
KeyStoreTable,
|
||||||
URLActionsTable,
|
URLActionsTable,
|
||||||
APIKeysTable,
|
APIKeysTable,
|
||||||
|
SSOProvidersTable,
|
||||||
},
|
},
|
||||||
mixins: [mixins],
|
mixins: [mixins],
|
||||||
data() {
|
data() {
|
||||||
@@ -635,6 +770,18 @@ export default {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
hosted() {
|
||||||
|
return this.$store.state.hosted;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
tab(newTab, oldTab) {
|
||||||
|
if (oldTab === "sso") {
|
||||||
|
this.getCoreSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openURL(url) {
|
openURL(url) {
|
||||||
openURL(url);
|
openURL(url);
|
||||||
@@ -669,6 +816,19 @@ export default {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
confirmSyncChange(newValue) {
|
||||||
|
this.$q
|
||||||
|
.dialog({
|
||||||
|
title: "Are you sure?",
|
||||||
|
message:
|
||||||
|
"This operation may take several minutes to complete in the background and can be very CPU/disk intensive, depending on your hardware and number of agents. Please allow time for the sync to fully complete.",
|
||||||
|
ok: { label: "Yes", color: "primary" },
|
||||||
|
cancel: { label: "No", color: "negative" },
|
||||||
|
})
|
||||||
|
.onOk(() => {
|
||||||
|
this.settings.sync_mesh_with_trmm = newValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
showResetPatchPolicy() {
|
showResetPatchPolicy() {
|
||||||
this.$q.dialog({
|
this.$q.dialog({
|
||||||
component: ResetPatchPolicy,
|
component: ResetPatchPolicy,
|
||||||
@@ -711,13 +871,13 @@ export default {
|
|||||||
},
|
},
|
||||||
removeEmail(email) {
|
removeEmail(email) {
|
||||||
const removed = this.settings.email_alert_recipients.filter(
|
const removed = this.settings.email_alert_recipients.filter(
|
||||||
(k) => k !== email
|
(k) => k !== email,
|
||||||
);
|
);
|
||||||
this.settings.email_alert_recipients = removed;
|
this.settings.email_alert_recipients = removed;
|
||||||
},
|
},
|
||||||
removeSMSNumber(num) {
|
removeSMSNumber(num) {
|
||||||
const removed = this.settings.sms_alert_recipients.filter(
|
const removed = this.settings.sms_alert_recipients.filter(
|
||||||
(k) => k !== num
|
(k) => k !== num,
|
||||||
);
|
);
|
||||||
this.settings.sms_alert_recipients = removed;
|
this.settings.sms_alert_recipients = removed;
|
||||||
},
|
},
|
||||||
@@ -758,6 +918,7 @@ export default {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.$emit("close");
|
this.$emit("close");
|
||||||
|
this.$store.dispatch("getDashInfo", false);
|
||||||
this.notifySuccess("Settings were edited!");
|
this.notifySuccess("Settings were edited!");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,8 +27,16 @@
|
|||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
v-model="localKey.value"
|
v-model="localKey.value"
|
||||||
|
:type="isPwd ? 'password' : 'text'"
|
||||||
:rules="[(val) => !!val || '*Required']"
|
:rules="[(val) => !!val || '*Required']"
|
||||||
/>
|
><template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="isPwd ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="isPwd = !isPwd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
@@ -50,6 +58,7 @@ export default {
|
|||||||
props: { globalKey: Object },
|
props: { globalKey: Object },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
isPwd: true,
|
||||||
localKey: {
|
localKey: {
|
||||||
name: "",
|
name: "",
|
||||||
value: "",
|
value: "",
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="text-subtitle2">Global Key Store</div>
|
<div class="text-subtitle2">Global Key Store</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
color="grey-5"
|
||||||
|
text-color="black"
|
||||||
|
class="q-mr-sm"
|
||||||
|
:label="isPwd ? 'Show values' : 'Hide values'"
|
||||||
|
:icon="isPwd ? 'visibility_off' : 'visibility'"
|
||||||
|
@click="isPwd = !isPwd"
|
||||||
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
size="sm"
|
size="sm"
|
||||||
color="grey-5"
|
color="grey-5"
|
||||||
@@ -61,7 +70,7 @@
|
|||||||
</q-td>
|
</q-td>
|
||||||
<!-- value -->
|
<!-- value -->
|
||||||
<q-td>
|
<q-td>
|
||||||
{{ props.row.value }}
|
{{ isPwd ? "****" : props.row.value }}
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,6 +88,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
keystore: [],
|
keystore: [],
|
||||||
|
isPwd: true,
|
||||||
pagination: {
|
pagination: {
|
||||||
rowsPerPage: 0,
|
rowsPerPage: 0,
|
||||||
sortBy: "name",
|
sortBy: "name",
|
||||||
|
|||||||
160
src/components/modals/coresettings/TestURLAction.vue
Normal file
160
src/components/modals/coresettings/TestURLAction.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card class="q-dialog-plugin" style="width: 80vw">
|
||||||
|
<q-bar>
|
||||||
|
Testing {{ urlAction.name }}
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-option-group
|
||||||
|
v-model="runAgainst"
|
||||||
|
:options="runAgainstOptions"
|
||||||
|
inline
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="runAgainst === 'agent'">
|
||||||
|
<tactical-dropdown
|
||||||
|
v-model="agent"
|
||||||
|
:options="agentOptions"
|
||||||
|
label="Agents"
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-else-if="runAgainst === 'site'">
|
||||||
|
<tactical-dropdown
|
||||||
|
v-model="site"
|
||||||
|
:options="siteOptions"
|
||||||
|
label="Sites"
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-else-if="runAgainst === 'client'">
|
||||||
|
<tactical-dropdown
|
||||||
|
v-model="client"
|
||||||
|
:options="clientOptions"
|
||||||
|
label="Client"
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section style="height: 60vh" class="scroll">
|
||||||
|
<div>
|
||||||
|
URL:
|
||||||
|
<code>{{ return_url }}</code>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
Body
|
||||||
|
<q-separator />
|
||||||
|
<code>{{ return_request }}</code>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
Response
|
||||||
|
<q-separator />
|
||||||
|
<code>{{ return_result }}</code>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Close" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
:loading="loading"
|
||||||
|
flat
|
||||||
|
label="Run"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, reactive, computed } from "vue";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { useAgentDropdown } from "@/composables/agents";
|
||||||
|
import { useSiteDropdown, useClientDropdown } from "@/composables/clients";
|
||||||
|
import { runTestURLAction } from "@/api/core";
|
||||||
|
import { URLAction } from "@/types/core/urlactions";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
|
|
||||||
|
// define emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// define props
|
||||||
|
const props = defineProps<{ urlAction: URLAction }>();
|
||||||
|
|
||||||
|
// setup quasar
|
||||||
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
// setup dropdowns
|
||||||
|
const { agent, agentOptions } = useAgentDropdown({ onMount: true });
|
||||||
|
const { client, clientOptions } = useClientDropdown(true);
|
||||||
|
const { site, siteOptions } = useSiteDropdown(true);
|
||||||
|
|
||||||
|
const runAgainst = ref<"agent" | "site" | "client" | "none">("none");
|
||||||
|
|
||||||
|
const runAgainstOptions = [
|
||||||
|
{ label: "Agent", value: "agent" },
|
||||||
|
{ label: "Site", value: "site" },
|
||||||
|
{ label: "Client", value: "client" },
|
||||||
|
{ label: "None", value: "none" },
|
||||||
|
];
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const runAgainstID = computed(() => {
|
||||||
|
if (runAgainst.value === "agent") return agent.value;
|
||||||
|
else if (runAgainst.value === "site") return site.value;
|
||||||
|
else if (runAgainst.value === "client") return client.value;
|
||||||
|
else return 0;
|
||||||
|
});
|
||||||
|
const state = reactive({
|
||||||
|
pattern: props.urlAction.pattern,
|
||||||
|
rest_body: props.urlAction.rest_body,
|
||||||
|
rest_headers: props.urlAction.rest_headers,
|
||||||
|
rest_method: props.urlAction.rest_method,
|
||||||
|
run_instance_type: runAgainst,
|
||||||
|
run_instance_id: runAgainstID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const return_url = ref("");
|
||||||
|
const return_result = ref("");
|
||||||
|
const return_request = ref("");
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url, result, body } = await runTestURLAction(state);
|
||||||
|
|
||||||
|
return_result.value = result;
|
||||||
|
return_url.value = url;
|
||||||
|
return_request.value = body;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-dialog ref="dialog" @hide="onHide">
|
<q-dialog
|
||||||
<q-card class="q-dialog-plugin" style="width: 60vw">
|
ref="dialogRef"
|
||||||
|
@hide="onDialogHide"
|
||||||
|
@show="loadEditor"
|
||||||
|
@before-hide="cleanupEditors"
|
||||||
|
>
|
||||||
|
<q-card
|
||||||
|
class="q-dialog-plugin"
|
||||||
|
:style="`width: ${props.type === 'web' ? 50 : 60}vw; max-width: ${props.type === 'web' ? 60 : 70}vw`"
|
||||||
|
>
|
||||||
<q-bar>
|
<q-bar>
|
||||||
{{ title }}
|
{{
|
||||||
|
props.action
|
||||||
|
? props.type === "web"
|
||||||
|
? "Edit URL Action"
|
||||||
|
: "Edit Web Hook"
|
||||||
|
: props.type === "web"
|
||||||
|
? "Add URL Action"
|
||||||
|
: "Add Web Hook"
|
||||||
|
}}
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn dense flat icon="close" v-close-popup>
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-bar>
|
</q-bar>
|
||||||
<q-form @submit="submit">
|
|
||||||
|
<div style="max-height: 80vh" class="scroll">
|
||||||
<!-- name -->
|
<!-- name -->
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -26,6 +43,8 @@
|
|||||||
label="Description"
|
label="Description"
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
|
type="textarea"
|
||||||
|
rows="2"
|
||||||
v-model="localAction.desc"
|
v-model="localAction.desc"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -41,89 +60,186 @@
|
|||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-actions align="right">
|
<q-card-section v-if="type === 'rest'">
|
||||||
<q-btn flat label="Cancel" v-close-popup />
|
<q-select
|
||||||
<q-btn flat label="Submit" color="primary" type="submit" />
|
v-model="localAction.rest_method"
|
||||||
</q-card-actions>
|
label="Method"
|
||||||
</q-form>
|
:options="URLActionMethods"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
map-options
|
||||||
|
emit-value
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-show="type === 'rest'">
|
||||||
|
<q-toolbar>
|
||||||
|
<q-tabs v-model="tab" dense shrink>
|
||||||
|
<q-tab
|
||||||
|
name="body"
|
||||||
|
label="Request Body"
|
||||||
|
:ripple="false"
|
||||||
|
:disable="disableBodyTab"
|
||||||
|
/>
|
||||||
|
<q-tab name="headers" label="Request Headers" :ripple="false" />
|
||||||
|
</q-tabs>
|
||||||
|
</q-toolbar>
|
||||||
|
<div ref="editorDiv" :style="{ height: '30vh' }"></div>
|
||||||
|
</q-card-section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
v-if="type === 'rest'"
|
||||||
|
flat
|
||||||
|
label="Test"
|
||||||
|
color="primary"
|
||||||
|
@click="testWebHook"
|
||||||
|
/>
|
||||||
|
<q-btn flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn flat label="Submit" color="primary" @click="submit" />
|
||||||
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import mixins from "@/mixins/mixins";
|
// composition imports
|
||||||
|
import { ref, computed, reactive, watch } from "vue";
|
||||||
|
import { useDialogPluginComponent, useQuasar, extend } from "quasar";
|
||||||
|
import { editURLAction, saveURLAction } from "@/api/core";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { URLAction, URLActionType } from "@/types/core/urlactions";
|
||||||
|
|
||||||
export default {
|
// ui imports
|
||||||
name: "URLActionsForm",
|
import TestURLAction from "@/components/modals/coresettings/TestURLAction.vue";
|
||||||
emits: ["hide", "ok", "cancel"],
|
|
||||||
mixins: [mixins],
|
|
||||||
props: { action: Object },
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
localAction: {
|
|
||||||
name: "",
|
|
||||||
desc: "",
|
|
||||||
pattern: "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
title() {
|
|
||||||
return this.editing ? "Edit URL Action" : "Add URL Action";
|
|
||||||
},
|
|
||||||
editing() {
|
|
||||||
return !!this.action;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
submit() {
|
|
||||||
this.$q.loading.show();
|
|
||||||
|
|
||||||
let data = {
|
import * as monaco from "monaco-editor";
|
||||||
...this.localAction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.editing) {
|
// define emits
|
||||||
this.$axios
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
.put(`/core/urlaction/${data.id}/`, data)
|
|
||||||
.then(() => {
|
// define props
|
||||||
this.$q.loading.hide();
|
const props = defineProps<{ type: URLActionType; action?: URLAction }>();
|
||||||
this.onOk();
|
|
||||||
this.notifySuccess("Url Action was edited!");
|
// setup quasar
|
||||||
})
|
const $q = useQuasar();
|
||||||
.catch(() => {
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
this.$q.loading.hide();
|
|
||||||
});
|
// static data
|
||||||
} else {
|
const URLActionMethods = [
|
||||||
this.$axios
|
{ value: "get", label: "GET" },
|
||||||
.post("/core/urlaction/", data)
|
{ value: "post", label: "POST" },
|
||||||
.then(() => {
|
{ value: "put", label: "PUT" },
|
||||||
this.$q.loading.hide();
|
{ value: "delete", label: "DELETE" },
|
||||||
this.onOk();
|
{ value: "patch", label: "PATCH" },
|
||||||
this.notifySuccess("URL Action was added!");
|
];
|
||||||
})
|
|
||||||
.catch(() => {
|
const localAction: URLAction = props.action
|
||||||
this.$q.loading.hide();
|
? reactive(extend({}, props.action))
|
||||||
});
|
: reactive({
|
||||||
}
|
name: "",
|
||||||
},
|
desc: "",
|
||||||
show() {
|
pattern: "",
|
||||||
this.$refs.dialog.show();
|
action_type: props.type,
|
||||||
},
|
rest_body: "{\n \n}",
|
||||||
hide() {
|
rest_method: "post",
|
||||||
this.$refs.dialog.hide();
|
rest_headers: `{\n "Content-Type": "application/json"\n}`, // eslint-disable-line
|
||||||
},
|
} as URLAction);
|
||||||
onHide() {
|
|
||||||
this.$emit("hide");
|
const disableBodyTab = computed(() =>
|
||||||
},
|
["get", "delete"].includes(localAction.rest_method),
|
||||||
onOk() {
|
);
|
||||||
this.$emit("ok");
|
const tab = ref(disableBodyTab.value ? "headers" : "body");
|
||||||
this.hide();
|
|
||||||
},
|
watch(
|
||||||
|
() => localAction.rest_method,
|
||||||
|
() => {
|
||||||
|
disableBodyTab.value ? (tab.value = "headers") : undefined;
|
||||||
},
|
},
|
||||||
mounted() {
|
);
|
||||||
// If pk prop is set that means we are editing
|
|
||||||
if (this.action) Object.assign(this.localAction, this.action);
|
async function submit() {
|
||||||
},
|
$q.loading.show();
|
||||||
};
|
|
||||||
|
try {
|
||||||
|
props.action
|
||||||
|
? await editURLAction(localAction.id, localAction)
|
||||||
|
: await saveURLAction(localAction);
|
||||||
|
onDialogOK();
|
||||||
|
notifySuccess("Url Action was edited!");
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
$q.loading.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorDiv = ref<HTMLElement | null>(null);
|
||||||
|
let editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
var modelBodyUri = monaco.Uri.parse("model://body"); // a made up unique URI for our model
|
||||||
|
var modelHeadersUri = monaco.Uri.parse("model://headers"); // a made up unique URI for our model
|
||||||
|
var modelBody = monaco.editor.createModel(
|
||||||
|
localAction.rest_body,
|
||||||
|
"json",
|
||||||
|
modelBodyUri,
|
||||||
|
);
|
||||||
|
|
||||||
|
var modelHeaders = monaco.editor.createModel(
|
||||||
|
localAction.rest_headers,
|
||||||
|
"json",
|
||||||
|
modelHeadersUri,
|
||||||
|
);
|
||||||
|
|
||||||
|
function testWebHook() {
|
||||||
|
$q.dialog({
|
||||||
|
component: TestURLAction,
|
||||||
|
componentProps: {
|
||||||
|
urlAction: localAction,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch tab change and change model
|
||||||
|
watch(tab, (newValue, oldValue) => {
|
||||||
|
if (oldValue === "body") {
|
||||||
|
localAction.rest_body = editor.getValue();
|
||||||
|
} else if (oldValue === "headers") {
|
||||||
|
localAction.rest_headers = editor.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValue === "body") {
|
||||||
|
editor.setModel(modelBody);
|
||||||
|
editor.setValue(localAction.rest_body);
|
||||||
|
} else if (newValue === "headers") {
|
||||||
|
editor.setModel(modelHeaders);
|
||||||
|
editor.setValue(localAction.rest_headers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadEditor() {
|
||||||
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
|
|
||||||
|
if (!editorDiv.value) return;
|
||||||
|
|
||||||
|
editor = monaco.editor.create(editorDiv.value, {
|
||||||
|
model: tab.value === "body" ? modelBody : modelHeaders,
|
||||||
|
theme: theme,
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
quickSuggestions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
if (tab.value === "body") {
|
||||||
|
localAction.rest_body = editor.getValue();
|
||||||
|
} else if (tab.value === "headers") {
|
||||||
|
localAction.rest_headers = editor.getValue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupEditors() {
|
||||||
|
modelBody.dispose();
|
||||||
|
modelHeaders.dispose();
|
||||||
|
editor.dispose();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="text-subtitle2">URL Actions</div>
|
<div class="text-subtitle2">
|
||||||
|
{{
|
||||||
|
props.type === "web"
|
||||||
|
? "URL Actions"
|
||||||
|
: "Web Hooks for Alert Failure/Resolved Actions"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn
|
<q-btn
|
||||||
size="sm"
|
size="sm"
|
||||||
color="grey-5"
|
color="grey-5"
|
||||||
icon="fas fa-plus"
|
icon="fas fa-plus"
|
||||||
text-color="black"
|
text-color="black"
|
||||||
label="Add URL Action"
|
:label="`Add ${props.type === 'web' ? 'URL Action' : 'Web Hook'}`"
|
||||||
@click="addAction"
|
@click="addURLAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
@@ -17,31 +23,36 @@
|
|||||||
dense
|
dense
|
||||||
:rows="actions"
|
:rows="actions"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
v-model:pagination="pagination"
|
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: true }"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
binary-state-sort
|
binary-state-sort
|
||||||
hide-pagination
|
hide-pagination
|
||||||
virtual-scroll
|
virtual-scroll
|
||||||
:rows-per-page-options="[0]"
|
:rows-per-page-options="[0]"
|
||||||
no-data-label="No URL Actions added yet"
|
:no-data-label="`No ${props.type === 'web' ? 'URL Actions' : 'Web Hooks'} added yet`"
|
||||||
|
:loading="loading"
|
||||||
>
|
>
|
||||||
<!-- body slots -->
|
<!-- body slots -->
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
<q-tr
|
<q-tr
|
||||||
:props="props"
|
:props="props"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
@dblclick="editAction(props.row)"
|
@dblclick="editURLAction(props.row)"
|
||||||
>
|
>
|
||||||
<!-- context menu -->
|
<!-- context menu -->
|
||||||
<q-menu context-menu>
|
<q-menu context-menu>
|
||||||
<q-list dense style="min-width: 200px">
|
<q-list dense style="min-width: 200px">
|
||||||
<q-item clickable v-close-popup @click="editAction(props.row)">
|
<q-item clickable v-close-popup @click="editURLAction(props.row)">
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon name="edit" />
|
<q-icon name="edit" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>Edit</q-item-section>
|
<q-item-section>Edit</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item clickable v-close-popup @click="deleteAction(props.row)">
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="deleteURLAction(props.row)"
|
||||||
|
>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon name="delete" />
|
<q-icon name="delete" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
@@ -57,15 +68,15 @@
|
|||||||
</q-menu>
|
</q-menu>
|
||||||
<!-- name -->
|
<!-- name -->
|
||||||
<q-td>
|
<q-td>
|
||||||
{{ props.row.name }}
|
{{ truncateText(props.row.name, 30) }}
|
||||||
</q-td>
|
</q-td>
|
||||||
<!-- desc -->
|
<!-- desc -->
|
||||||
<q-td>
|
<q-td>
|
||||||
{{ props.row.desc }}
|
{{ truncateText(props.row.desc, 20) }}
|
||||||
</q-td>
|
</q-td>
|
||||||
<!-- pattern -->
|
<!-- pattern -->
|
||||||
<q-td>
|
<q-td>
|
||||||
{{ props.row.pattern }}
|
{{ truncateText(props.row.pattern, 20) }}
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -73,105 +84,103 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { QTableColumn, useQuasar } from "quasar";
|
||||||
|
import { fetchURLActions, removeURLAction } from "@/api/core";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { truncateText } from "@/utils/format";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
import URLActionsForm from "@/components/modals/coresettings/URLActionsForm.vue";
|
import URLActionsForm from "@/components/modals/coresettings/URLActionsForm.vue";
|
||||||
import mixins from "@/mixins/mixins";
|
|
||||||
|
|
||||||
export default {
|
// types
|
||||||
name: "URLActionTable",
|
import { type URLActionType, type URLAction } from "@/types/core/urlactions";
|
||||||
mixins: [mixins],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
actions: [],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 0,
|
|
||||||
sortBy: "name",
|
|
||||||
descending: true,
|
|
||||||
},
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
label: "Name",
|
|
||||||
field: "name",
|
|
||||||
align: "left",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "desc",
|
|
||||||
label: "Description",
|
|
||||||
field: "desc",
|
|
||||||
align: "left",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pattern",
|
|
||||||
label: "Pattern",
|
|
||||||
field: "pattern",
|
|
||||||
align: "left",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getURLActions() {
|
|
||||||
this.$q.loading.show();
|
|
||||||
|
|
||||||
this.$axios
|
// define props
|
||||||
.get("/core/urlaction/")
|
const props = defineProps<{ type: URLActionType }>();
|
||||||
.then((r) => {
|
|
||||||
this.$q.loading.hide();
|
// setup quasar
|
||||||
this.actions = r.data;
|
const $q = useQuasar();
|
||||||
})
|
|
||||||
.catch(() => {
|
const loading = ref(false);
|
||||||
this.$q.loading.hide();
|
|
||||||
});
|
const actions = ref([] as URLAction[]);
|
||||||
},
|
|
||||||
addAction() {
|
const columns: QTableColumn[] = [
|
||||||
this.$q
|
{
|
||||||
.dialog({
|
name: "name",
|
||||||
component: URLActionsForm,
|
label: "Name",
|
||||||
})
|
field: "name",
|
||||||
.onOk(() => {
|
align: "left",
|
||||||
this.getURLActions();
|
sortable: true,
|
||||||
});
|
|
||||||
},
|
|
||||||
editAction(action) {
|
|
||||||
this.$q
|
|
||||||
.dialog({
|
|
||||||
component: URLActionsForm,
|
|
||||||
componentProps: {
|
|
||||||
action: action,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.onOk(() => {
|
|
||||||
this.getURLActions();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deleteAction(action) {
|
|
||||||
this.$q
|
|
||||||
.dialog({
|
|
||||||
title: `Delete URL Action: ${action.name}?`,
|
|
||||||
cancel: true,
|
|
||||||
ok: { label: "Delete", color: "negative" },
|
|
||||||
})
|
|
||||||
.onOk(() => {
|
|
||||||
this.$q.loading.show();
|
|
||||||
this.$axios
|
|
||||||
.delete(`/core/urlaction/${action.id}/`)
|
|
||||||
.then(() => {
|
|
||||||
this.getURLActions();
|
|
||||||
this.$q.loading.hide();
|
|
||||||
this.notifySuccess(`URL Action: ${action.name} was deleted!`);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.$q.loading.hide();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
mounted() {
|
{
|
||||||
this.getURLActions();
|
name: "desc",
|
||||||
|
label: "Description",
|
||||||
|
field: "desc",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
|
name: "pattern",
|
||||||
|
label: "URL Pattern",
|
||||||
|
field: "pattern",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function getURLActions() {
|
||||||
|
$q.loading.show();
|
||||||
|
try {
|
||||||
|
const result = await fetchURLActions();
|
||||||
|
actions.value = result.filter(
|
||||||
|
(action) => action.action_type === props.type,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q.loading.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addURLAction() {
|
||||||
|
$q.dialog({
|
||||||
|
component: URLActionsForm,
|
||||||
|
componentProps: {
|
||||||
|
type: props.type,
|
||||||
|
},
|
||||||
|
}).onOk(getURLActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editURLAction(action: URLAction) {
|
||||||
|
$q.dialog({
|
||||||
|
component: URLActionsForm,
|
||||||
|
componentProps: {
|
||||||
|
type: props.type,
|
||||||
|
action: action,
|
||||||
|
},
|
||||||
|
}).onOk(getURLActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteURLAction(action: URLAction) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Delete URL Action: ${action.name}?`,
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await removeURLAction(action.id);
|
||||||
|
await getURLActions();
|
||||||
|
notifySuccess(`URL Action: ${action.name} was deleted!`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onMounted(getURLActions);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-dialog ref="dialog" @hide="onHide">
|
<q-dialog ref="dialog" @hide="onHide">
|
||||||
<q-card class="q-dialog-plugin" style="min-width: 85vh">
|
<q-card class="q-dialog-plugin" style="min-width: 60vw">
|
||||||
<q-splitter v-model="splitterModel">
|
<q-splitter v-model="splitterModel">
|
||||||
<template v-slot:before>
|
<template v-slot:before>
|
||||||
<q-tabs dense v-model="tab" vertical class="text-primary">
|
<q-tabs dense v-model="tab" vertical class="text-primary">
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
icon="info"
|
icon="info"
|
||||||
@click="
|
@click="
|
||||||
openURL(
|
openURL(
|
||||||
'https://quasar.dev/quasar-utils/date-utils#format-for-display'
|
'https://quasar.dev/quasar-utils/date-utils#format-for-display',
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -313,16 +313,19 @@ export default {
|
|||||||
},
|
},
|
||||||
getURLActions() {
|
getURLActions() {
|
||||||
this.$axios.get("/core/urlaction/").then((r) => {
|
this.$axios.get("/core/urlaction/").then((r) => {
|
||||||
if (r.data.length === 0) {
|
this.urlActions = r.data
|
||||||
|
.filter((action) => action.action_type === "web")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((action) => ({
|
||||||
|
label: action.name,
|
||||||
|
value: action.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.urlActions.length === 0) {
|
||||||
this.notifyWarning(
|
this.notifyWarning(
|
||||||
"No URL Actions configured. Go to Settings > Global Settings > URL Actions"
|
"No URL Actions configured. Go to Settings > Global Settings > URL Actions",
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
this.urlActions = r.data.map((action) => ({
|
|
||||||
label: action.name,
|
|
||||||
value: action.id,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getUserPrefs() {
|
getUserPrefs() {
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-dialog
|
<q-dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
|
maximized
|
||||||
|
no-esc-dismiss
|
||||||
@hide="onDialogHide"
|
@hide="onDialogHide"
|
||||||
persistent
|
@show="loadEditor"
|
||||||
@keydown.esc="onDialogHide"
|
@before-hide="unloadEditor"
|
||||||
:maximized="maximized"
|
@keydown.esc.stop="closeEditor"
|
||||||
>
|
>
|
||||||
<q-card
|
<q-card class="q-dialog-plugin">
|
||||||
class="q-dialog-plugin"
|
|
||||||
:style="maximized ? '' : 'width: 90vw; max-width: 90vw'"
|
|
||||||
>
|
|
||||||
<q-bar>
|
<q-bar>
|
||||||
<span class="q-pr-sm">{{ title }}</span>
|
<span class="q-pr-sm">{{ title }}</span>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -23,424 +22,477 @@
|
|||||||
@click="generateScriptOpenAI"
|
@click="generateScriptOpenAI"
|
||||||
/>
|
/>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn
|
<q-btn dense flat icon="close" @click="closeEditor">
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="minimize"
|
|
||||||
@click="maximized = false"
|
|
||||||
:disable="!maximized"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="maximized" class="bg-white text-primary"
|
|
||||||
>Minimize</q-tooltip
|
|
||||||
>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="crop_square"
|
|
||||||
@click="maximized = true"
|
|
||||||
:disable="maximized"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="!maximized" class="bg-white text-primary"
|
|
||||||
>Maximize</q-tooltip
|
|
||||||
>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn dense flat icon="close" v-close-popup>
|
|
||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-bar>
|
</q-bar>
|
||||||
<q-form @submit="submitForm">
|
<q-banner
|
||||||
<q-banner
|
v-if="script.script_body && missingShebang"
|
||||||
v-if="missingShebang"
|
dense
|
||||||
dense
|
inline-actions
|
||||||
inline-actions
|
class="text-black bg-warning"
|
||||||
class="text-black bg-warning"
|
>
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon class="text-center" name="warning" color="black" /> </template
|
||||||
|
>Shell/Python scripts on Linux/Mac need a shebang at the top of the
|
||||||
|
script e.g. <code>#!/bin/bash</code> or <code>#!/usr/bin/python3</code
|
||||||
|
><br />Add one to get rid of this warning. Ignore if windows.
|
||||||
|
</q-banner>
|
||||||
|
<div class="row q-pa-sm">
|
||||||
|
<q-scroll-area
|
||||||
|
:thumb-style="{
|
||||||
|
right: '4px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
width: '5px',
|
||||||
|
opacity: '0.75',
|
||||||
|
}"
|
||||||
|
:bar-style="{
|
||||||
|
right: '2px',
|
||||||
|
borderRadius: '9px',
|
||||||
|
width: '9px',
|
||||||
|
opacity: '0.2',
|
||||||
|
}"
|
||||||
|
class="col-4 q-mb-none q-pb-none"
|
||||||
|
:style="{ height: `${$q.screen.height - 106}px` }"
|
||||||
>
|
>
|
||||||
<template v-slot:avatar>
|
<div class="q-gutter-sm q-pr-sm">
|
||||||
<q-icon
|
<q-input
|
||||||
class="text-center"
|
filled
|
||||||
name="warning"
|
dense
|
||||||
color="black"
|
:readonly="readonly"
|
||||||
/> </template
|
v-model="script.name"
|
||||||
>Shell/Python scripts on Linux/Mac need a shebang at the top of the
|
label="Name"
|
||||||
script e.g. <code>#!/bin/bash</code> or <code>#!/usr/bin/python3</code
|
:rules="[(val) => !!val || '*Required']"
|
||||||
><br />Add one to get rid of this warning. Ignore if windows.
|
hide-bottom-space
|
||||||
</q-banner>
|
/>
|
||||||
<div class="row q-pa-sm">
|
<q-input
|
||||||
<q-scroll-area
|
filled
|
||||||
:thumb-style="{
|
dense
|
||||||
right: '4px',
|
:readonly="readonly"
|
||||||
borderRadius: '5px',
|
v-model="script.description"
|
||||||
width: '5px',
|
label="Description"
|
||||||
opacity: 0.75,
|
type="textarea"
|
||||||
}"
|
rows="2"
|
||||||
:bar-style="{
|
/>
|
||||||
right: '2px',
|
<q-select
|
||||||
borderRadius: '9px',
|
:readonly="readonly"
|
||||||
width: '9px',
|
options-dense
|
||||||
opacity: 0.2,
|
filled
|
||||||
}"
|
dense
|
||||||
class="col-4 q-mb-none q-pb-none"
|
v-model="script.shell"
|
||||||
:style="{ height: `${maximized ? '82vh' : '64vh'}` }"
|
:options="shellOptions"
|
||||||
>
|
emit-value
|
||||||
<div class="q-gutter-sm q-pr-sm">
|
map-options
|
||||||
<q-input
|
label="Shell Type"
|
||||||
filled
|
/>
|
||||||
dense
|
<tactical-dropdown
|
||||||
:readonly="readonly"
|
v-model="script.supported_platforms"
|
||||||
v-model="formScript.name"
|
:options="agentPlatformOptions"
|
||||||
label="Name"
|
label="Supported Platforms (All supported if blank)"
|
||||||
:rules="[(val) => !!val || '*Required']"
|
clearable
|
||||||
hide-bottom-space
|
mapOptions
|
||||||
/>
|
filled
|
||||||
<q-input
|
multiple
|
||||||
filled
|
:readonly="readonly"
|
||||||
dense
|
/>
|
||||||
:readonly="readonly"
|
<tactical-dropdown
|
||||||
v-model="formScript.description"
|
filled
|
||||||
label="Description"
|
v-model="script.category"
|
||||||
/>
|
:options="categories"
|
||||||
<q-select
|
use-input
|
||||||
:readonly="readonly"
|
clearable
|
||||||
options-dense
|
new-value-mode="add-unique"
|
||||||
filled
|
filterable
|
||||||
dense
|
label="Category"
|
||||||
v-model="formScript.shell"
|
:readonly="readonly"
|
||||||
:options="shellOptions"
|
hide-bottom-space
|
||||||
emit-value
|
/>
|
||||||
map-options
|
<tactical-dropdown
|
||||||
label="Shell Type"
|
v-model="script.args"
|
||||||
/>
|
label="Script Arguments (press Enter after typing each argument)"
|
||||||
<tactical-dropdown
|
filled
|
||||||
v-model="formScript.supported_platforms"
|
use-input
|
||||||
:options="agentPlatformOptions"
|
multiple
|
||||||
label="Supported Platforms (All supported if blank)"
|
hide-dropdown-icon
|
||||||
clearable
|
input-debounce="0"
|
||||||
mapOptions
|
new-value-mode="add"
|
||||||
filled
|
:readonly="readonly"
|
||||||
multiple
|
/>
|
||||||
:readonly="readonly"
|
<tactical-dropdown
|
||||||
/>
|
v-model="script.env_vars"
|
||||||
<tactical-dropdown
|
:label="envVarsLabel"
|
||||||
filled
|
filled
|
||||||
v-model="formScript.category"
|
use-input
|
||||||
:options="categories"
|
multiple
|
||||||
use-input
|
hide-dropdown-icon
|
||||||
clearable
|
input-debounce="0"
|
||||||
new-value-mode="add-unique"
|
new-value-mode="add"
|
||||||
filterable
|
:readonly="readonly"
|
||||||
label="Category"
|
/>
|
||||||
:readonly="readonly"
|
<q-input
|
||||||
hide-bottom-space
|
type="number"
|
||||||
/>
|
filled
|
||||||
<tactical-dropdown
|
dense
|
||||||
v-model="formScript.args"
|
:readonly="readonly"
|
||||||
label="Script Arguments (press Enter after typing each argument)"
|
v-model.number="script.default_timeout"
|
||||||
filled
|
label="Timeout (seconds)"
|
||||||
use-input
|
:rules="[(val) => val >= 5 || 'Minimum is 5']"
|
||||||
multiple
|
hide-bottom-space
|
||||||
hide-dropdown-icon
|
/>
|
||||||
input-debounce="0"
|
<q-checkbox
|
||||||
new-value-mode="add"
|
v-model="script.run_as_user"
|
||||||
:readonly="readonly"
|
label="Run As User (Windows only)"
|
||||||
/>
|
>
|
||||||
<tactical-dropdown
|
<q-tooltip
|
||||||
v-model="formScript.env_vars"
|
>Setting this value on the script model will always override any
|
||||||
:label="envVarsLabel"
|
'Run As User' checkboxes in the UI and force this script to
|
||||||
filled
|
always be run in the context of the logged in user. If no user
|
||||||
use-input
|
is logged in, the script will run as SYSTEM.
|
||||||
multiple
|
</q-tooltip>
|
||||||
hide-dropdown-icon
|
</q-checkbox>
|
||||||
input-debounce="0"
|
<q-input
|
||||||
new-value-mode="add"
|
label="Syntax"
|
||||||
:readonly="readonly"
|
type="textarea"
|
||||||
/>
|
style="height: 150px; overflow-y: auto; resize: none"
|
||||||
<q-input
|
v-model="script.syntax"
|
||||||
type="number"
|
dense
|
||||||
filled
|
filled
|
||||||
dense
|
:readonly="readonly"
|
||||||
:readonly="readonly"
|
/>
|
||||||
v-model.number="formScript.default_timeout"
|
</div>
|
||||||
label="Timeout (seconds)"
|
</q-scroll-area>
|
||||||
:rules="[(val) => val >= 5 || 'Minimum is 5']"
|
<div
|
||||||
hide-bottom-space
|
ref="scriptEditor"
|
||||||
/>
|
class="col-8 q-mb-none q-pb-none"
|
||||||
<q-checkbox
|
:style="{ height: `${$q.screen.height - 106}px` }"
|
||||||
v-model="formScript.run_as_user"
|
></div>
|
||||||
label="Run As User (Windows only)"
|
</div>
|
||||||
>
|
<q-card-actions>
|
||||||
<q-tooltip
|
<tactical-dropdown
|
||||||
>Setting this value on the script model will always override
|
style="width: 450px"
|
||||||
any 'Run As User' checkboxes in the UI and force this script
|
dense
|
||||||
to always be run in the context of the logged in user. If no
|
:loading="agentLoading"
|
||||||
user is logged in, the script will not run and an error will
|
filled
|
||||||
be returned.
|
v-model="agent"
|
||||||
</q-tooltip>
|
:options="agentOptions"
|
||||||
</q-checkbox>
|
label="Agent to run test script on"
|
||||||
<q-input
|
mapOptions
|
||||||
label="Syntax"
|
filterable
|
||||||
type="textarea"
|
>
|
||||||
style="height: 150px; overflow-y: auto; resize: none"
|
<template v-slot:after>
|
||||||
v-model="formScript.syntax"
|
<q-btn
|
||||||
dense
|
size="md"
|
||||||
filled
|
color="primary"
|
||||||
:readonly="readonly"
|
dense
|
||||||
/>
|
flat
|
||||||
</div>
|
label="Test Script"
|
||||||
</q-scroll-area>
|
:disable="
|
||||||
<v-ace-editor
|
!agent || !script.script_body || !script.default_timeout
|
||||||
v-model:value="formScript.script_body"
|
"
|
||||||
class="col-8"
|
@click="openTestScriptModal('agent')"
|
||||||
:lang="lang"
|
/>
|
||||||
:theme="$q.dark.isActive ? 'tomorrow_night_eighties' : 'tomorrow'"
|
<q-btn
|
||||||
:style="{ height: `${maximized ? '82vh' : '64vh'}` }"
|
v-if="!hosted"
|
||||||
wrap
|
size="md"
|
||||||
:printMargin="false"
|
color="secondary"
|
||||||
:options="{ fontSize: '14px' }"
|
dense
|
||||||
/>
|
flat
|
||||||
</div>
|
label="Test on Server"
|
||||||
<q-card-actions>
|
:disable="
|
||||||
<tactical-dropdown
|
!script.script_body ||
|
||||||
style="width: 350px"
|
!script.default_timeout ||
|
||||||
dense
|
!server_scripts_enabled
|
||||||
:loading="agentLoading"
|
"
|
||||||
filled
|
@click="openTestScriptModal('server')"
|
||||||
v-model="agent"
|
/>
|
||||||
:options="agentOptions"
|
</template>
|
||||||
label="Agent to run test script on"
|
</tactical-dropdown>
|
||||||
mapOptions
|
<q-space />
|
||||||
filterable
|
<q-btn dense flat label="Cancel" @click="closeEditor" />
|
||||||
>
|
<q-btn
|
||||||
<template v-slot:after>
|
v-if="!readonly"
|
||||||
<q-btn
|
:loading="loading"
|
||||||
size="md"
|
dense
|
||||||
color="primary"
|
flat
|
||||||
dense
|
label="Save"
|
||||||
flat
|
color="primary"
|
||||||
label="Test Script"
|
@click="submit"
|
||||||
:disable="
|
/>
|
||||||
!agent ||
|
</q-card-actions>
|
||||||
!formScript.script_body ||
|
|
||||||
!formScript.default_timeout
|
|
||||||
"
|
|
||||||
@click="openTestScriptModal"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</tactical-dropdown>
|
|
||||||
<q-space />
|
|
||||||
<q-btn dense flat label="Cancel" v-close-popup />
|
|
||||||
<q-btn
|
|
||||||
v-if="!readonly"
|
|
||||||
:loading="loading"
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
label="Save"
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
/>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-form>
|
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
// composable imports
|
// composable imports
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, reactive, watch, computed, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
import { useQuasar, useDialogPluginComponent } from "quasar";
|
import { useQuasar, useDialogPluginComponent } from "quasar";
|
||||||
import { saveScript, editScript, downloadScript } from "@/api/scripts";
|
import { saveScript, editScript, downloadScript } from "@/api/scripts";
|
||||||
import { useAgentDropdown, agentPlatformOptions } from "@/composables/agents";
|
import { useAgentDropdown, agentPlatformOptions } from "@/composables/agents";
|
||||||
import { generateScript } from "@/api/core";
|
import { generateScript } from "@/api/core";
|
||||||
import { notifySuccess } from "@/utils/notify";
|
import { notifyError, notifySuccess } from "@/utils/notify";
|
||||||
|
|
||||||
// ui imports
|
// ui imports
|
||||||
import TestScriptModal from "@/components/scripts/TestScriptModal.vue";
|
import TestScriptModal from "@/components/scripts/TestScriptModal.vue";
|
||||||
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
import { VAceEditor } from "vue3-ace-editor";
|
import * as monaco from "monaco-editor";
|
||||||
|
|
||||||
// imports for ace editor
|
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-powershell";
|
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-python";
|
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-batchfile";
|
import jsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-sh";
|
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||||
import "ace-builds/src-noconflict/theme-tomorrow_night_eighties";
|
|
||||||
import "ace-builds/src-noconflict/theme-tomorrow";
|
// https://github.com/microsoft/monaco-editor/issues/4045#issuecomment-1723787448
|
||||||
|
self.MonacoEnvironment = {
|
||||||
|
getWorker: function (workerId, label) {
|
||||||
|
switch (label) {
|
||||||
|
case "json":
|
||||||
|
return new jsonWorker();
|
||||||
|
case "css":
|
||||||
|
case "scss":
|
||||||
|
case "less":
|
||||||
|
return new cssWorker();
|
||||||
|
case "html":
|
||||||
|
case "handlebars":
|
||||||
|
case "razor":
|
||||||
|
return new htmlWorker();
|
||||||
|
case "typescript":
|
||||||
|
case "javascript":
|
||||||
|
return new jsWorker();
|
||||||
|
default:
|
||||||
|
return new editorWorker();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { Script } from "@/types/scripts";
|
||||||
|
|
||||||
// static data
|
// static data
|
||||||
import { shellOptions } from "@/composables/scripts";
|
import { shellOptions } from "@/composables/scripts";
|
||||||
import { envVarsLabel } from "@/constants/constants";
|
import { envVarsLabel } from "@/constants/constants";
|
||||||
|
|
||||||
export default {
|
// props
|
||||||
name: "ScriptFormModal",
|
const props = withDefaults(
|
||||||
emits: [...useDialogPluginComponent.emits],
|
defineProps<{
|
||||||
components: {
|
script?: Script;
|
||||||
TacticalDropdown,
|
categories?: string[];
|
||||||
VAceEditor,
|
readonly: boolean;
|
||||||
|
clone?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
clone: false,
|
||||||
|
readonly: false,
|
||||||
},
|
},
|
||||||
props: {
|
);
|
||||||
script: Object,
|
|
||||||
categories: !Array,
|
// emits
|
||||||
readonly: {
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
// setup quasar plugins
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// setup store
|
||||||
|
const store = useStore();
|
||||||
|
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
|
||||||
|
|
||||||
|
// setup agent dropdown
|
||||||
|
const { agent, agentOptions, getAgentOptions } = useAgentDropdown();
|
||||||
|
const hosted = computed(() => store.state.hosted);
|
||||||
|
const server_scripts_enabled = computed(
|
||||||
|
() => store.state.server_scripts_enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
// script form logic
|
||||||
|
const script: Script = props.script
|
||||||
|
? reactive(Object.assign({}, { ...props.script, script_body: "" }))
|
||||||
|
: reactive({
|
||||||
|
name: "",
|
||||||
|
shell: "powershell",
|
||||||
|
default_timeout: 90,
|
||||||
|
args: [],
|
||||||
|
script_body: "",
|
||||||
|
run_as_user: false,
|
||||||
|
env_vars: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (props.clone) script.name = `(Copy) ${script.name}`;
|
||||||
|
const loading = ref(false);
|
||||||
|
const agentLoading = ref(false);
|
||||||
|
|
||||||
|
const missingShebang = computed(() => {
|
||||||
|
if (script.shell === "shell" || script.shell === "python") {
|
||||||
|
return !script.script_body.startsWith("#!");
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.script) {
|
||||||
|
return props.readonly
|
||||||
|
? `Viewing ${script.name}`
|
||||||
|
: props.clone
|
||||||
|
? `Copying ${script.name}`
|
||||||
|
: `Editing ${script.name}`;
|
||||||
|
} else {
|
||||||
|
return "Adding new script";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// convert highlighter language to match what ace expects
|
||||||
|
const lang = computed(() => {
|
||||||
|
switch (script.shell) {
|
||||||
|
case "cmd":
|
||||||
|
return "bat";
|
||||||
|
case "powershell":
|
||||||
|
return "powershell";
|
||||||
|
case "python":
|
||||||
|
return "python";
|
||||||
|
case "shell":
|
||||||
|
case "nushell":
|
||||||
|
return "shell";
|
||||||
|
case "deno":
|
||||||
|
return "typescript";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
let result = "";
|
||||||
|
try {
|
||||||
|
// edit existing script
|
||||||
|
if (props.script && !props.clone) {
|
||||||
|
result = await editScript(script);
|
||||||
|
|
||||||
|
// add or save cloned script
|
||||||
|
} else {
|
||||||
|
result = await saveScript(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDialogOK();
|
||||||
|
notifySuccess(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTestScriptModal(ctx: string) {
|
||||||
|
if (ctx === "server" && !script.script_body.startsWith("#!")) {
|
||||||
|
notifyError(
|
||||||
|
"A shebang is required at the top of the script to specify the interpreter's path. Please ensure your script begins with a shebang line.",
|
||||||
|
7000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$q.dialog({
|
||||||
|
component: TestScriptModal,
|
||||||
|
componentProps: {
|
||||||
|
script: { ...script },
|
||||||
|
agent: agent.value,
|
||||||
|
ctx: ctx,
|
||||||
},
|
},
|
||||||
clone: {
|
});
|
||||||
type: Boolean,
|
}
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
// setup quasar plugins
|
|
||||||
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
|
||||||
const $q = useQuasar();
|
|
||||||
|
|
||||||
// setup store
|
const scriptEditor = ref<HTMLElement | null>(null);
|
||||||
const store = useStore();
|
let editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
|
|
||||||
|
|
||||||
// setup agent dropdown
|
function loadEditor() {
|
||||||
const { agent, agentOptions, getAgentOptions } = useAgentDropdown();
|
var model = monaco.editor.createModel(script.script_body, lang.value);
|
||||||
|
|
||||||
// script form logic
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
const script = props.script
|
|
||||||
? ref(Object.assign({}, { ...props.script, script_body: "" }))
|
|
||||||
: ref({
|
|
||||||
shell: "powershell",
|
|
||||||
default_timeout: 90,
|
|
||||||
args: [],
|
|
||||||
script_body: "",
|
|
||||||
run_as_user: false,
|
|
||||||
env_vars: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (props.clone) script.value.name = `(Copy) ${script.value.name}`;
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const maximized = ref(false);
|
editor = monaco.editor.create(scriptEditor.value!, {
|
||||||
const loading = ref(false);
|
readOnly: props.readonly,
|
||||||
const agentLoading = ref(false);
|
automaticLayout: true,
|
||||||
|
model: model,
|
||||||
|
theme: theme,
|
||||||
|
});
|
||||||
|
|
||||||
const missingShebang = computed(() => {
|
editor.onDidChangeModelContent(() => {
|
||||||
if (script.value.shell === "shell" || script.value.shell === "python") {
|
script.script_body = editor.getValue();
|
||||||
return !script.value.script_body.includes("#!");
|
});
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const title = computed(() => {
|
// get code if editing or cloning script
|
||||||
if (props.script) {
|
if (props.script)
|
||||||
return props.readonly
|
downloadScript(script.id, { with_snippets: props.readonly }).then((r) => {
|
||||||
? `Viewing ${script.value.name}`
|
script.script_body = r.code;
|
||||||
: props.clone
|
editor.setValue(r.code);
|
||||||
? `Copying ${script.value.name}`
|
|
||||||
: `Editing ${script.value.name}`;
|
|
||||||
} else {
|
|
||||||
return "Adding new script";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// convert highlighter language to match what ace expects
|
// need to add this in the download function otherwise the above will trigger an edit
|
||||||
const lang = computed(() => {
|
watch(
|
||||||
if (script.value.shell === "cmd") return "batchfile";
|
() => script.script_body,
|
||||||
else if (script.value.shell === "powershell") return "powershell";
|
() => {
|
||||||
else if (script.value.shell === "python") return "python";
|
edited.value = true;
|
||||||
else if (script.value.shell === "shell") return "sh";
|
},
|
||||||
else return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
// get code if editing or cloning script
|
|
||||||
if (props.script)
|
|
||||||
downloadScript(script.value.id, { with_snippets: props.readonly }).then(
|
|
||||||
(r) => {
|
|
||||||
script.value.script_body = r.code;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
async function submitForm() {
|
|
||||||
loading.value = true;
|
|
||||||
let result = "";
|
|
||||||
try {
|
|
||||||
// edit existing script
|
|
||||||
if (props.script && !props.clone) {
|
|
||||||
result = await editScript(script.value);
|
|
||||||
|
|
||||||
// add or save cloned script
|
|
||||||
} else {
|
|
||||||
result = await saveScript(script.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDialogOK();
|
|
||||||
notifySuccess(result);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openTestScriptModal() {
|
|
||||||
$q.dialog({
|
|
||||||
component: TestScriptModal,
|
|
||||||
componentProps: {
|
|
||||||
script: { ...script.value },
|
|
||||||
agent: agent.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateScriptOpenAI() {
|
|
||||||
$q.dialog({
|
|
||||||
title: "Ask ChatGPT what you need!",
|
|
||||||
prompt: {
|
|
||||||
model: `${lang.value} code that `,
|
|
||||||
type: "text",
|
|
||||||
},
|
|
||||||
cancel: true,
|
|
||||||
persistent: true,
|
|
||||||
}).onOk(async (data) => {
|
|
||||||
const completion = await generateScript({
|
|
||||||
prompt: data,
|
|
||||||
});
|
|
||||||
script.value.script_body = completion;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// component life cycle hooks
|
|
||||||
onMounted(async () => {
|
|
||||||
agentLoading.value = true;
|
|
||||||
await getAgentOptions();
|
|
||||||
agentLoading.value = false;
|
|
||||||
});
|
});
|
||||||
|
else {
|
||||||
|
watch(
|
||||||
|
() => script.script_body,
|
||||||
|
() => {
|
||||||
|
edited.value = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// watch for changes in language
|
||||||
// reactive data
|
watch(lang, () => {
|
||||||
formScript: script.value,
|
monaco.editor.setModelLanguage(model, lang.value);
|
||||||
maximized,
|
});
|
||||||
loading,
|
}
|
||||||
agentOptions,
|
|
||||||
agent,
|
|
||||||
agentLoading,
|
|
||||||
lang,
|
|
||||||
missingShebang,
|
|
||||||
|
|
||||||
// non-reactive data
|
function unloadEditor() {
|
||||||
shellOptions,
|
editor.getModel()?.dispose();
|
||||||
agentPlatformOptions,
|
editor.dispose();
|
||||||
envVarsLabel,
|
onDialogHide();
|
||||||
|
}
|
||||||
|
|
||||||
//computed
|
function generateScriptOpenAI() {
|
||||||
title,
|
$q.dialog({
|
||||||
openAIEnabled,
|
title: "Ask ChatGPT what you need!",
|
||||||
|
prompt: {
|
||||||
|
model: `${lang.value} code that `,
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(async (data) => {
|
||||||
|
const completion = await generateScript({
|
||||||
|
prompt: data,
|
||||||
|
});
|
||||||
|
script.script_body = completion;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
//methods
|
// add are you sure prompt to unsaved script
|
||||||
submitForm,
|
const edited = ref(false);
|
||||||
openTestScriptModal,
|
|
||||||
generateScriptOpenAI,
|
|
||||||
|
|
||||||
// quasar dialog plugin
|
function closeEditor() {
|
||||||
dialogRef,
|
if (edited.value)
|
||||||
onDialogHide,
|
$q.dialog({
|
||||||
};
|
title: "You have unsaved changes. Are you sure you want to close?",
|
||||||
},
|
cancel: true,
|
||||||
};
|
ok: true,
|
||||||
|
}).onOk(async () => {
|
||||||
|
unloadEditor();
|
||||||
|
});
|
||||||
|
else unloadEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// component life cycle hooks
|
||||||
|
onMounted(async () => {
|
||||||
|
agentLoading.value = true;
|
||||||
|
await getAgentOptions();
|
||||||
|
agentLoading.value = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -175,6 +175,28 @@
|
|||||||
>
|
>
|
||||||
<q-tooltip> Shell </q-tooltip>
|
<q-tooltip> Shell </q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="props.node.shell === 'nushell'"
|
||||||
|
name="mdi-code-greater-than"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<q-tooltip> Nushell </q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="props.node.shell === 'deno'"
|
||||||
|
name="mdi-language-typescript"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<q-tooltip> Deno </q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
|
||||||
|
<!-- is community script icon -->
|
||||||
|
<img
|
||||||
|
v-if="props.node.script_type === 'builtin'"
|
||||||
|
class="vertical-middle"
|
||||||
|
:src="trmmLogo"
|
||||||
|
style="height: 20px; max-width: 20px"
|
||||||
|
/>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="q-pl-xs text-weight-bold"
|
class="q-pl-xs text-weight-bold"
|
||||||
@@ -463,6 +485,22 @@
|
|||||||
>
|
>
|
||||||
<q-tooltip> Shell </q-tooltip>
|
<q-tooltip> Shell </q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="props.row.shell === 'nushell'"
|
||||||
|
size="sm"
|
||||||
|
name="mdi-code-greater-than"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<q-tooltip> Nushell </q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="props.row.shell === 'deno'"
|
||||||
|
size="sm"
|
||||||
|
name="mdi-language-typescript"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<q-tooltip> Deno </q-tooltip>
|
||||||
|
</q-icon>
|
||||||
</q-td>
|
</q-td>
|
||||||
<!-- supported platforms -->
|
<!-- supported platforms -->
|
||||||
<q-td key="supported_platforms" :props="props">
|
<q-td key="supported_platforms" :props="props">
|
||||||
@@ -488,6 +526,12 @@
|
|||||||
:props="props"
|
:props="props"
|
||||||
:style="{ color: props.row.hidden ? 'grey' : '' }"
|
:style="{ color: props.row.hidden ? 'grey' : '' }"
|
||||||
>
|
>
|
||||||
|
<!-- is community script icon -->
|
||||||
|
<img
|
||||||
|
v-if="props.row.script_type === 'builtin'"
|
||||||
|
:src="trmmLogo"
|
||||||
|
style="height: 20px; max-width: 20px"
|
||||||
|
/>
|
||||||
{{ truncateText(props.row.name, 50) }}
|
{{ truncateText(props.row.name, 50) }}
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
v-if="props.row.name.length >= 50"
|
v-if="props.row.name.length >= 50"
|
||||||
@@ -495,6 +539,7 @@
|
|||||||
>
|
>
|
||||||
{{ props.row.name }}
|
{{ props.row.name }}
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
|
<q-tooltip :delay="600">ID: {{ props.row.id }}</q-tooltip>
|
||||||
</q-td>
|
</q-td>
|
||||||
<!-- args -->
|
<!-- args -->
|
||||||
<q-td key="args" :props="props">
|
<q-td key="args" :props="props">
|
||||||
@@ -550,6 +595,8 @@ import ScriptFormModal from "@/components/scripts/ScriptFormModal.vue";
|
|||||||
import ScriptSnippets from "@/components/scripts/ScriptSnippets.vue";
|
import ScriptSnippets from "@/components/scripts/ScriptSnippets.vue";
|
||||||
import TacticalTable from "@/components/ui/TacticalTable.vue";
|
import TacticalTable from "@/components/ui/TacticalTable.vue";
|
||||||
|
|
||||||
|
import trmmLogo from "@/assets/trmm_256.png";
|
||||||
|
|
||||||
// static data
|
// static data
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@@ -620,7 +667,7 @@ export default {
|
|||||||
// setup vuex store
|
// setup vuex store
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const showCommunityScripts = computed(
|
const showCommunityScripts = computed(
|
||||||
() => store.state.showCommunityScripts
|
() => store.state.showCommunityScripts,
|
||||||
);
|
);
|
||||||
|
|
||||||
// setup quasar plugins
|
// setup quasar plugins
|
||||||
@@ -721,7 +768,7 @@ export default {
|
|||||||
return showCommunityScripts.value
|
return showCommunityScripts.value
|
||||||
? scripts.value.filter((i) => !i.hidden)
|
? scripts.value.filter((i) => !i.hidden)
|
||||||
: scripts.value.filter(
|
: scripts.value.filter(
|
||||||
(i) => i.script_type !== "builtin" && !i.hidden
|
(i) => i.script_type !== "builtin" && !i.hidden,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -884,6 +931,7 @@ export default {
|
|||||||
loading,
|
loading,
|
||||||
showCommunityScripts,
|
showCommunityScripts,
|
||||||
showHiddenScripts,
|
showHiddenScripts,
|
||||||
|
trmmLogo,
|
||||||
|
|
||||||
// computed
|
// computed
|
||||||
visibleScripts,
|
visibleScripts,
|
||||||
|
|||||||
26
src/components/scripts/ScriptOutputCopyClip.vue
Normal file
26
src/components/scripts/ScriptOutputCopyClip.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row q-gutter-sm items-center">
|
||||||
|
<div class="col-auto">{{ label }}</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn dense flat size="md" icon="content_copy" @click="copyText">
|
||||||
|
<q-tooltip>Copy to Clipboard</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { copyOutput } from "@/utils/helpers";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: String,
|
||||||
|
data: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyText = () => {
|
||||||
|
copyOutput(props.data);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-dialog
|
<q-dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
|
maximized
|
||||||
@hide="onDialogHide"
|
@hide="onDialogHide"
|
||||||
persistent
|
@show="loadEditor"
|
||||||
@keydown.esc="onDialogHide"
|
@before-hide="unloadEditor"
|
||||||
:maximized="maximized"
|
|
||||||
>
|
>
|
||||||
<q-card
|
<q-card class="q-dialog-plugin">
|
||||||
class="q-dialog-plugin"
|
|
||||||
:style="maximized ? '' : 'width: 70vw; max-width: 90vw'"
|
|
||||||
>
|
|
||||||
<q-bar>
|
<q-bar>
|
||||||
<span class="q-pr-sm">{{ title }}</span>
|
<span class="q-pr-sm">{{ title }}</span>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -23,90 +20,62 @@
|
|||||||
@click="generateScriptOpenAI"
|
@click="generateScriptOpenAI"
|
||||||
/>
|
/>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="minimize"
|
|
||||||
@click="maximized = false"
|
|
||||||
:disable="!maximized"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="maximized" class="bg-white text-primary"
|
|
||||||
>Minimize</q-tooltip
|
|
||||||
>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="crop_square"
|
|
||||||
@click="maximized = true"
|
|
||||||
:disable="maximized"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="!maximized" class="bg-white text-primary"
|
|
||||||
>Maximize</q-tooltip
|
|
||||||
>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn dense flat icon="close" v-close-popup>
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-bar>
|
</q-bar>
|
||||||
<q-form @submit="submitForm">
|
<div class="row">
|
||||||
<div class="row">
|
<q-input
|
||||||
<q-input
|
:rules="[(val: string) => !!val || '*Required']"
|
||||||
:rules="[(val) => !!val || '*Required']"
|
class="q-pa-sm col-4"
|
||||||
class="q-pa-sm col-4"
|
v-model="snippet.name"
|
||||||
v-model="formSnippet.name"
|
label="Name"
|
||||||
label="Name"
|
filled
|
||||||
filled
|
dense
|
||||||
dense
|
|
||||||
/>
|
|
||||||
<q-select
|
|
||||||
v-model="formSnippet.shell"
|
|
||||||
:options="shellOptions"
|
|
||||||
class="q-pa-sm col-2"
|
|
||||||
label="Shell Type"
|
|
||||||
options-dense
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
/>
|
|
||||||
<q-input
|
|
||||||
class="q-pa-sm col-6"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="formSnippet.desc"
|
|
||||||
label="Description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-ace-editor
|
|
||||||
v-model:value="formSnippet.code"
|
|
||||||
:lang="lang"
|
|
||||||
:theme="$q.dark.isActive ? 'tomorrow_night_eighties' : 'tomorrow'"
|
|
||||||
:style="{ height: `${maximized ? '80vh' : '70vh'}` }"
|
|
||||||
wrap
|
|
||||||
:printMargin="false"
|
|
||||||
:options="{ fontSize: '14px' }"
|
|
||||||
/>
|
/>
|
||||||
<q-card-actions align="right">
|
<q-select
|
||||||
<q-btn dense flat label="Cancel" v-close-popup />
|
v-model="snippet.shell"
|
||||||
<q-btn
|
:options="shellOptions"
|
||||||
:loading="loading"
|
class="q-pa-sm col-2"
|
||||||
dense
|
label="Shell Type"
|
||||||
flat
|
options-dense
|
||||||
label="Save"
|
filled
|
||||||
color="primary"
|
dense
|
||||||
type="submit"
|
emit-value
|
||||||
/>
|
map-options
|
||||||
</q-card-actions>
|
/>
|
||||||
</q-form>
|
<q-input
|
||||||
|
class="q-pa-sm col-6"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="snippet.desc"
|
||||||
|
label="Description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="snippetEditor"
|
||||||
|
:style="{ height: `${$q.screen.height - 132}px` }"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn dense flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
:loading="loading"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Save"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
// composable imports
|
// composable imports
|
||||||
import { ref, computed } from "vue";
|
import { ref, watch, reactive, computed } from "vue";
|
||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
import { useQuasar } from "quasar";
|
import { useQuasar } from "quasar";
|
||||||
import { generateScript } from "@/api/core";
|
import { generateScript } from "@/api/core";
|
||||||
@@ -115,117 +84,152 @@ import { saveScriptSnippet, editScriptSnippet } from "@/api/scripts";
|
|||||||
import { notifySuccess } from "@/utils/notify";
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
|
||||||
// ui imports
|
// ui imports
|
||||||
import { VAceEditor } from "vue3-ace-editor";
|
import * as monaco from "monaco-editor";
|
||||||
|
|
||||||
// imports for ace editor
|
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-powershell";
|
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-python";
|
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-batchfile";
|
import jsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||||
import "ace-builds/src-noconflict/mode-sh";
|
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||||
import "ace-builds/src-noconflict/theme-tomorrow_night_eighties";
|
|
||||||
import "ace-builds/src-noconflict/theme-tomorrow";
|
// https://github.com/microsoft/monaco-editor/issues/4045#issuecomment-1723787448
|
||||||
|
self.MonacoEnvironment = {
|
||||||
|
getWorker: function (workerId, label) {
|
||||||
|
switch (label) {
|
||||||
|
case "json":
|
||||||
|
return new jsonWorker();
|
||||||
|
case "css":
|
||||||
|
case "scss":
|
||||||
|
case "less":
|
||||||
|
return new cssWorker();
|
||||||
|
case "html":
|
||||||
|
case "handlebars":
|
||||||
|
case "razor":
|
||||||
|
return new htmlWorker();
|
||||||
|
case "typescript":
|
||||||
|
case "javascript":
|
||||||
|
return new jsWorker();
|
||||||
|
default:
|
||||||
|
return new editorWorker();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { ScriptSnippet } from "@/types/scripts";
|
||||||
|
|
||||||
// static data
|
// static data
|
||||||
import { shellOptions } from "@/composables/scripts";
|
import { shellOptions } from "@/composables/scripts";
|
||||||
|
|
||||||
export default {
|
// props
|
||||||
name: "ScriptFormModal",
|
const props = defineProps<{ snippet?: ScriptSnippet }>();
|
||||||
emits: [...useDialogPluginComponent.emits],
|
|
||||||
components: {
|
|
||||||
VAceEditor,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
snippet: Object,
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
// setup quasar plugins
|
|
||||||
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
|
||||||
|
|
||||||
// setup quasar
|
// emits
|
||||||
const $q = useQuasar();
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
// setup store
|
// quasar dialog setup
|
||||||
const store = useStore();
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
|
|
||||||
|
|
||||||
// snippet form logic
|
// setup quasar
|
||||||
const snippet = props.snippet
|
const $q = useQuasar();
|
||||||
? ref(Object.assign({}, props.snippet))
|
|
||||||
: ref({ name: "", code: "", shell: "powershell" });
|
|
||||||
const maximized = ref(false);
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
const title = computed(() => {
|
// setup store
|
||||||
if (props.snippet) {
|
const store = useStore();
|
||||||
return `Editing ${snippet.value.name}`;
|
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
|
||||||
} else {
|
|
||||||
return "Adding New Script Snippet";
|
// snippet form logic
|
||||||
}
|
const snippet: ScriptSnippet = props.snippet
|
||||||
|
? reactive(Object.assign({}, props.snippet))
|
||||||
|
: reactive({ name: "", code: "", shell: "powershell" });
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.snippet) {
|
||||||
|
return `Editing ${snippet.name}`;
|
||||||
|
} else {
|
||||||
|
return "Adding New Script Snippet";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// convert highlighter language to match what ace expects
|
||||||
|
const lang = computed(() => {
|
||||||
|
switch (snippet.shell) {
|
||||||
|
case "cmd":
|
||||||
|
return "bat";
|
||||||
|
case "powershell":
|
||||||
|
return "powershell";
|
||||||
|
case "python":
|
||||||
|
return "python";
|
||||||
|
case "shell":
|
||||||
|
case "nushell":
|
||||||
|
return "shell";
|
||||||
|
case "deno":
|
||||||
|
return "typescript";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = props.snippet
|
||||||
|
? await editScriptSnippet(snippet)
|
||||||
|
: await saveScriptSnippet(snippet);
|
||||||
|
onDialogOK();
|
||||||
|
notifySuccess(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippetEditor = ref<HTMLElement | null>(null);
|
||||||
|
let editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
|
||||||
|
function loadEditor() {
|
||||||
|
var model = monaco.editor.createModel(snippet.code, lang.value);
|
||||||
|
|
||||||
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
editor = monaco.editor.create(snippetEditor.value!, {
|
||||||
|
automaticLayout: true,
|
||||||
|
model: model,
|
||||||
|
theme: theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
snippet.code = editor.getValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// watch for changes in language
|
||||||
|
watch(lang, () => {
|
||||||
|
monaco.editor.setModelLanguage(model, lang.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadEditor() {
|
||||||
|
editor.getModel()?.dispose();
|
||||||
|
editor.dispose();
|
||||||
|
onDialogHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateScriptOpenAI() {
|
||||||
|
$q.dialog({
|
||||||
|
title: "Ask ChatGPT what you need!",
|
||||||
|
prompt: {
|
||||||
|
model: `${lang.value} code that `,
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(async (data) => {
|
||||||
|
const completion = await generateScript({
|
||||||
|
prompt: data,
|
||||||
});
|
});
|
||||||
|
snippet.code = completion;
|
||||||
// convert highlighter language to match what ace expects
|
});
|
||||||
const lang = computed(() => {
|
}
|
||||||
if (snippet.value.shell === "cmd") return "batchfile";
|
|
||||||
else if (snippet.value.shell === "powershell") return "powershell";
|
|
||||||
else if (snippet.value.shell === "python") return "python";
|
|
||||||
else if (snippet.value.shell === "shell") return "sh";
|
|
||||||
else return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submitForm() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const result = props.snippet
|
|
||||||
? await editScriptSnippet(snippet.value)
|
|
||||||
: await saveScriptSnippet(snippet.value);
|
|
||||||
onDialogOK();
|
|
||||||
notifySuccess(result);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateScriptOpenAI() {
|
|
||||||
$q.dialog({
|
|
||||||
title: "Ask ChatGPT what you need!",
|
|
||||||
prompt: {
|
|
||||||
model: `${lang.value} code that `,
|
|
||||||
type: "text",
|
|
||||||
},
|
|
||||||
cancel: true,
|
|
||||||
persistent: true,
|
|
||||||
}).onOk(async (data) => {
|
|
||||||
const completion = await generateScript({
|
|
||||||
prompt: data,
|
|
||||||
});
|
|
||||||
snippet.value.code = completion;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// reactive data
|
|
||||||
formSnippet: snippet.value,
|
|
||||||
maximized,
|
|
||||||
lang,
|
|
||||||
loading,
|
|
||||||
|
|
||||||
// non-reactive data
|
|
||||||
shellOptions,
|
|
||||||
|
|
||||||
//computed
|
|
||||||
title,
|
|
||||||
openAIEnabled,
|
|
||||||
|
|
||||||
//methods
|
|
||||||
submitForm,
|
|
||||||
generateScriptOpenAI,
|
|
||||||
|
|
||||||
// quasar dialog plugin
|
|
||||||
dialogRef,
|
|
||||||
onDialogHide,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -124,6 +124,22 @@
|
|||||||
>
|
>
|
||||||
<q-tooltip> Shell </q-tooltip>
|
<q-tooltip> Shell </q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="props.row.shell === 'nushell'"
|
||||||
|
name="mdi-nushell"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<q-tooltip> Nushell </q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="props.row.shell === 'deno'"
|
||||||
|
name="mdi-typescript"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<q-tooltip> Deno </q-tooltip>
|
||||||
|
</q-icon>
|
||||||
</q-td>
|
</q-td>
|
||||||
<!-- name -->
|
<!-- name -->
|
||||||
<q-td>{{ props.row.name }}</q-td>
|
<q-td>{{ props.row.name }}</q-td>
|
||||||
|
|||||||
@@ -42,15 +42,7 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-file
|
<q-file label="Script Upload" v-model="file" filled dense counter>
|
||||||
label="Script Upload"
|
|
||||||
v-model="file"
|
|
||||||
hint="Supported file types: .ps1, .bat, .py, .sh"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
counter
|
|
||||||
accept=".ps1, .bat, .py, .sh"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="attach_file" />
|
<q-icon name="attach_file" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,8 +8,25 @@
|
|||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-bar>
|
</q-bar>
|
||||||
<q-card-section class="scroll" style="max-height: 70vh; height: 70vh">
|
<q-card-section style="height: 70vh" class="scroll">
|
||||||
<pre v-if="ret">{{ ret }}</pre>
|
<div>
|
||||||
|
Run Time:
|
||||||
|
<code>{{ ret.execution_time }} seconds</code>
|
||||||
|
<br />Return Code:
|
||||||
|
<code>{{ ret.retcode }}</code>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div v-if="ret.stdout">
|
||||||
|
<script-output-copy-clip label="Standard Output" :data="ret.stdout" />
|
||||||
|
<q-separator />
|
||||||
|
<pre>{{ ret.stdout }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="ret.stderr">
|
||||||
|
<script-output-copy-clip label="Standard Error" :data="ret.stderr" />
|
||||||
|
<q-separator />
|
||||||
|
<pre>{{ ret.stderr }}</pre>
|
||||||
|
</div>
|
||||||
<q-inner-loading :showing="loading" />
|
<q-inner-loading :showing="loading" />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -19,22 +36,32 @@
|
|||||||
<script>
|
<script>
|
||||||
// composition imports
|
// composition imports
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { testScript } from "@/api/scripts";
|
import { testScript, testScriptOnServer } from "@/api/scripts";
|
||||||
import { useDialogPluginComponent } from "quasar";
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "TestScriptModal",
|
name: "TestScriptModal",
|
||||||
|
components: {
|
||||||
|
ScriptOutputCopyClip,
|
||||||
|
},
|
||||||
emits: [...useDialogPluginComponent.emits],
|
emits: [...useDialogPluginComponent.emits],
|
||||||
props: {
|
props: {
|
||||||
script: !Object,
|
script: !Object,
|
||||||
agent: !String,
|
agent: !String,
|
||||||
|
ctx: !String,
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
// setup quasar dialog plugin
|
// setup quasar dialog plugin
|
||||||
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
|
||||||
// main run script functionality
|
// main run script functionality
|
||||||
const ret = ref(null);
|
const ret = ref({
|
||||||
|
execution_time: "",
|
||||||
|
retcode: "",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function runTestScript() {
|
async function runTestScript() {
|
||||||
@@ -48,7 +75,11 @@ export default {
|
|||||||
env_vars: props.script.env_vars,
|
env_vars: props.script.env_vars,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
ret.value = await testScript(props.agent, data);
|
if (props.ctx === "server") {
|
||||||
|
ret.value = await testScriptOnServer(data);
|
||||||
|
} else {
|
||||||
|
ret.value = await testScript(props.agent, data);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,181 +87,183 @@
|
|||||||
:done="step > 2"
|
:done="step > 2"
|
||||||
:error="!isValidStep2"
|
:error="!isValidStep2"
|
||||||
>
|
>
|
||||||
<q-form @submit.prevent="addAction">
|
<div class="scroll" style="max-height: 60vh">
|
||||||
<div class="row q-pa-sm q-gutter-x-xs items-center">
|
<q-form @submit.prevent="addAction">
|
||||||
<div class="text-subtitle2 col-12">Action Type:</div>
|
<div class="row q-pa-sm q-gutter-x-xs items-center">
|
||||||
<q-option-group
|
<div class="text-subtitle2 col-12">Action Type:</div>
|
||||||
class="col-12"
|
<q-option-group
|
||||||
inline
|
class="col-12"
|
||||||
v-model="actionType"
|
inline
|
||||||
:options="[
|
v-model="actionType"
|
||||||
{ label: 'Script', value: 'script' },
|
:options="[
|
||||||
{ label: 'Command', value: 'cmd' },
|
{ label: 'Script', value: 'script' },
|
||||||
]"
|
{ label: 'Command', value: 'cmd' },
|
||||||
/>
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
<tactical-dropdown
|
<tactical-dropdown
|
||||||
v-if="actionType === 'script'"
|
v-if="actionType === 'script'"
|
||||||
class="col-3"
|
class="col-3"
|
||||||
label="Select script"
|
label="Select script"
|
||||||
v-model="script"
|
v-model="script"
|
||||||
:options="scriptOptions"
|
:options="scriptOptions"
|
||||||
filled
|
filled
|
||||||
mapOptions
|
mapOptions
|
||||||
filterable
|
filterable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
v-if="actionType === 'script'"
|
v-if="actionType === 'script'"
|
||||||
class="col-3"
|
class="col-3"
|
||||||
dense
|
dense
|
||||||
label="Script Arguments (press Enter after typing each argument)"
|
label="Script Arguments (press Enter after typing each argument)"
|
||||||
filled
|
filled
|
||||||
v-model="defaultArgs"
|
v-model="defaultArgs"
|
||||||
use-input
|
use-input
|
||||||
use-chips
|
use-chips
|
||||||
multiple
|
multiple
|
||||||
hide-dropdown-icon
|
hide-dropdown-icon
|
||||||
input-debounce="0"
|
input-debounce="0"
|
||||||
new-value-mode="add"
|
new-value-mode="add"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
v-if="actionType === 'script'"
|
v-if="actionType === 'script'"
|
||||||
class="col-3"
|
class="col-3"
|
||||||
dense
|
dense
|
||||||
:label="envVarsLabel"
|
:label="envVarsLabel"
|
||||||
filled
|
filled
|
||||||
v-model="defaultEnvVars"
|
v-model="defaultEnvVars"
|
||||||
use-input
|
use-input
|
||||||
use-chips
|
use-chips
|
||||||
multiple
|
multiple
|
||||||
hide-dropdown-icon
|
hide-dropdown-icon
|
||||||
input-debounce="0"
|
input-debounce="0"
|
||||||
new-value-mode="add"
|
new-value-mode="add"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-if="actionType === 'script'"
|
v-if="actionType === 'script'"
|
||||||
class="col-2"
|
class="col-2"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="defaultTimeout"
|
v-model.number="defaultTimeout"
|
||||||
type="number"
|
type="number"
|
||||||
label="Timeout (seconds)"
|
label="Timeout (seconds)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-if="actionType === 'cmd'"
|
v-if="actionType === 'cmd'"
|
||||||
label="Command"
|
label="Command"
|
||||||
v-model="command"
|
v-model="command"
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
class="col-7"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-if="actionType === 'cmd'"
|
||||||
|
class="col-2"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="defaultTimeout"
|
||||||
|
type="number"
|
||||||
|
label="Timeout (seconds)"
|
||||||
|
/>
|
||||||
|
<q-option-group
|
||||||
|
v-if="actionType === 'cmd'"
|
||||||
|
class="col-2 q-pl-sm"
|
||||||
|
inline
|
||||||
|
v-model="shell"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Batch', value: 'cmd' },
|
||||||
|
{ label: 'Powershell', value: 'powershell' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
class="col-1"
|
||||||
|
type="submit"
|
||||||
|
style="width: 50px"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="add"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
<div class="text-subtitle2 q-pa-sm">
|
||||||
|
Actions:
|
||||||
|
<q-checkbox
|
||||||
|
class="float-right"
|
||||||
|
label="Continue on Errors"
|
||||||
|
v-model="state.continue_on_error"
|
||||||
dense
|
dense
|
||||||
filled
|
>
|
||||||
class="col-7"
|
<q-tooltip>Continue task if an action fails</q-tooltip>
|
||||||
/>
|
</q-checkbox>
|
||||||
<q-input
|
|
||||||
v-if="actionType === 'cmd'"
|
|
||||||
class="col-2"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="defaultTimeout"
|
|
||||||
type="number"
|
|
||||||
label="Timeout (seconds)"
|
|
||||||
/>
|
|
||||||
<q-option-group
|
|
||||||
v-if="actionType === 'cmd'"
|
|
||||||
class="col-2 q-pl-sm"
|
|
||||||
inline
|
|
||||||
v-model="shell"
|
|
||||||
:options="[
|
|
||||||
{ label: 'Batch', value: 'cmd' },
|
|
||||||
{ label: 'Powershell', value: 'powershell' },
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
class="col-1"
|
|
||||||
type="submit"
|
|
||||||
style="width: 50px"
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
icon="add"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
<div class="q-pt-sm" style="height: 150px">
|
||||||
<div class="text-subtitle2 q-pa-sm">
|
<draggable
|
||||||
Actions:
|
class="q-list"
|
||||||
<q-checkbox
|
handle=".handle"
|
||||||
class="float-right"
|
ghost-class="ghost"
|
||||||
label="Continue on Errors"
|
v-model="state.actions"
|
||||||
v-model="state.continue_on_error"
|
item-key="index"
|
||||||
dense
|
>
|
||||||
>
|
<template v-slot:item="{ index, element }">
|
||||||
<q-tooltip>Continue task if an action fails</q-tooltip>
|
<q-item>
|
||||||
</q-checkbox>
|
<q-item-section avatar>
|
||||||
</div>
|
|
||||||
<div class="scroll q-pt-sm" style="height: 40vh; max-height: 40vh">
|
|
||||||
<draggable
|
|
||||||
class="q-list"
|
|
||||||
handle=".handle"
|
|
||||||
ghost-class="ghost"
|
|
||||||
v-model="state.actions"
|
|
||||||
item-key="index"
|
|
||||||
>
|
|
||||||
<template v-slot:item="{ index, element }">
|
|
||||||
<q-item>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon
|
|
||||||
class="handle"
|
|
||||||
style="cursor: move"
|
|
||||||
name="drag_handle"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section v-if="element.type === 'script'">
|
|
||||||
<q-item-label>
|
|
||||||
<q-icon size="sm" name="description" color="primary" />
|
|
||||||
{{ element.name }}
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
Arguments: {{ element.script_args }}
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
Env Vars: {{ element.env_vars }}
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
Timeout: {{ element.timeout }}
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section v-else>
|
|
||||||
<q-item-label>
|
|
||||||
<q-icon size="sm" name="terminal" color="primary" />
|
|
||||||
|
|
||||||
<q-icon
|
<q-icon
|
||||||
size="sm"
|
class="handle"
|
||||||
:name="
|
style="cursor: move"
|
||||||
element.shell === 'cmd'
|
name="drag_handle"
|
||||||
? 'mdi-microsoft-windows'
|
|
||||||
: 'mdi-powershell'
|
|
||||||
"
|
|
||||||
color="primary"
|
|
||||||
/>
|
/>
|
||||||
{{ element.command }}
|
</q-item-section>
|
||||||
</q-item-label>
|
<q-item-section v-if="element.type === 'script'">
|
||||||
<q-item-label caption>
|
<q-item-label>
|
||||||
Timeout: {{ element.timeout }}
|
<q-icon size="sm" name="description" color="primary" />
|
||||||
</q-item-label>
|
{{ element.name }}
|
||||||
</q-item-section>
|
</q-item-label>
|
||||||
<q-item-section side>
|
<q-item-label caption>
|
||||||
<q-icon
|
Arguments: {{ element.script_args }}
|
||||||
class="cursor-pointer"
|
</q-item-label>
|
||||||
color="negative"
|
<q-item-label caption>
|
||||||
name="close"
|
Env Vars: {{ element.env_vars }}
|
||||||
@click="removeAction(index)"
|
</q-item-label>
|
||||||
/>
|
<q-item-label caption>
|
||||||
</q-item-section>
|
Timeout: {{ element.timeout }}
|
||||||
</q-item>
|
</q-item-label>
|
||||||
</template>
|
</q-item-section>
|
||||||
</draggable>
|
<q-item-section v-else>
|
||||||
|
<q-item-label>
|
||||||
|
<q-icon size="sm" name="terminal" color="primary" />
|
||||||
|
|
||||||
|
<q-icon
|
||||||
|
size="sm"
|
||||||
|
:name="
|
||||||
|
element.shell === 'cmd'
|
||||||
|
? 'mdi-microsoft-windows'
|
||||||
|
: 'mdi-powershell'
|
||||||
|
"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
{{ element.command }}
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
Timeout: {{ element.timeout }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon
|
||||||
|
class="cursor-pointer"
|
||||||
|
color="negative"
|
||||||
|
name="close"
|
||||||
|
@click="removeAction(index)"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-step>
|
</q-step>
|
||||||
|
|
||||||
@@ -283,7 +285,7 @@
|
|||||||
<q-card-section
|
<q-card-section
|
||||||
v-if="
|
v-if="
|
||||||
['runonce', 'daily', 'weekly', 'monthly'].includes(
|
['runonce', 'daily', 'weekly', 'monthly'].includes(
|
||||||
state.task_type
|
state.task_type,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="row"
|
class="row"
|
||||||
@@ -314,6 +316,22 @@
|
|||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section
|
||||||
|
v-if="
|
||||||
|
state.task_type === 'onboarding' ||
|
||||||
|
state.task_type === 'runonce'
|
||||||
|
"
|
||||||
|
class="row"
|
||||||
|
>
|
||||||
|
<span v-if="state.task_type === 'onboarding'"
|
||||||
|
>This task will run as soon as it's created on the
|
||||||
|
agent.</span
|
||||||
|
>
|
||||||
|
<span v-else-if="state.task_type === 'runonce'"
|
||||||
|
>Start Time must be in the future for run once tasks.</span
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
<!-- daily options -->
|
<!-- daily options -->
|
||||||
<q-card-section v-if="state.task_type === 'daily'" class="row">
|
<q-card-section v-if="state.task_type === 'daily'" class="row">
|
||||||
<!-- daily interval -->
|
<!-- daily interval -->
|
||||||
@@ -579,7 +597,8 @@
|
|||||||
<q-card-section
|
<q-card-section
|
||||||
v-if="
|
v-if="
|
||||||
state.task_type !== 'checkfailure' &&
|
state.task_type !== 'checkfailure' &&
|
||||||
state.task_type !== 'manual'
|
state.task_type !== 'manual' &&
|
||||||
|
state.task_type !== 'onboarding'
|
||||||
"
|
"
|
||||||
class="row"
|
class="row"
|
||||||
>
|
>
|
||||||
@@ -617,7 +636,7 @@
|
|||||||
(val) =>
|
(val) =>
|
||||||
convertPeriodToSeconds(val) >=
|
convertPeriodToSeconds(val) >=
|
||||||
convertPeriodToSeconds(
|
convertPeriodToSeconds(
|
||||||
state.task_repetition_interval
|
state.task_repetition_interval,
|
||||||
) ||
|
) ||
|
||||||
'Repetition duration must be greater than repetition interval',
|
'Repetition duration must be greater than repetition interval',
|
||||||
]"
|
]"
|
||||||
@@ -712,7 +731,7 @@
|
|||||||
@click="
|
@click="
|
||||||
validateStep(
|
validateStep(
|
||||||
step === 1 ? $refs.taskGeneralForm : undefined,
|
step === 1 ? $refs.taskGeneralForm : undefined,
|
||||||
$refs.stepper
|
$refs.stepper,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -736,7 +755,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// composition imports
|
// composition imports
|
||||||
import { ref, watch, onMounted } from "vue";
|
import { ref, watch, onMounted, defineComponent } from "vue";
|
||||||
import { useDialogPluginComponent } from "quasar";
|
import { useDialogPluginComponent } from "quasar";
|
||||||
import draggable from "vuedraggable";
|
import draggable from "vuedraggable";
|
||||||
import { saveTask, updateTask } from "@/api/tasks";
|
import { saveTask, updateTask } from "@/api/tasks";
|
||||||
@@ -769,6 +788,7 @@ const taskTypeOptions = [
|
|||||||
{ label: "Monthly", value: "monthly" },
|
{ label: "Monthly", value: "monthly" },
|
||||||
{ label: "Run Once", value: "runonce" },
|
{ label: "Run Once", value: "runonce" },
|
||||||
{ label: "On check failure", value: "checkfailure" },
|
{ label: "On check failure", value: "checkfailure" },
|
||||||
|
{ label: "Onboarding", value: "onboarding" },
|
||||||
{ label: "Manual", value: "manual" },
|
{ label: "Manual", value: "manual" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -823,7 +843,7 @@ const taskInstancePolicyOptions = [
|
|||||||
{ label: "Stop Existing", value: 3 },
|
{ label: "Stop Existing", value: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
components: { TacticalDropdown, draggable },
|
components: { TacticalDropdown, draggable },
|
||||||
name: "AddAutomatedTask",
|
name: "AddAutomatedTask",
|
||||||
emits: [...useDialogPluginComponent.emits],
|
emits: [...useDialogPluginComponent.emits],
|
||||||
@@ -838,18 +858,19 @@ export default {
|
|||||||
// setup dropdowns
|
// setup dropdowns
|
||||||
const {
|
const {
|
||||||
script,
|
script,
|
||||||
|
scriptName,
|
||||||
scriptOptions,
|
scriptOptions,
|
||||||
defaultTimeout,
|
defaultTimeout,
|
||||||
defaultArgs,
|
defaultArgs,
|
||||||
defaultEnvVars,
|
defaultEnvVars,
|
||||||
} = useScriptDropdown(undefined, {
|
} = useScriptDropdown({
|
||||||
onMount: true,
|
onMount: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// set defaultTimeout to 30
|
// set defaultTimeout to 30
|
||||||
defaultTimeout.value = 30;
|
defaultTimeout.value = 30;
|
||||||
|
|
||||||
const { checkOptions, getCheckOptions } = useCheckDropdown();
|
const { checkOptions, getCheckOptions } = useCheckDropdown(props.parent);
|
||||||
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
|
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
|
||||||
|
|
||||||
// add task logic
|
// add task logic
|
||||||
@@ -932,9 +953,7 @@ export default {
|
|||||||
if (actionType.value === "script") {
|
if (actionType.value === "script") {
|
||||||
task.value.actions.push({
|
task.value.actions.push({
|
||||||
type: "script",
|
type: "script",
|
||||||
name: scriptOptions.value.find(
|
name: scriptName.value,
|
||||||
(option) => option.value === script.value
|
|
||||||
).label,
|
|
||||||
script: script.value,
|
script: script.value,
|
||||||
timeout: defaultTimeout.value,
|
timeout: defaultTimeout.value,
|
||||||
script_args: defaultArgs.value,
|
script_args: defaultArgs.value,
|
||||||
@@ -1019,13 +1038,13 @@ export default {
|
|||||||
// remove milliseconds and Z to work with native date input
|
// remove milliseconds and Z to work with native date input
|
||||||
task.value.run_time_date = formatDateInputField(
|
task.value.run_time_date = formatDateInputField(
|
||||||
task.value.run_time_date,
|
task.value.run_time_date,
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (task.value.expire_date)
|
if (task.value.expire_date)
|
||||||
task.value.expire_date = formatDateInputField(
|
task.value.expire_date = formatDateInputField(
|
||||||
task.value.expire_date,
|
task.value.expire_date,
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// set task type if monthlydow is being used
|
// set task type if monthlydow is being used
|
||||||
@@ -1069,7 +1088,7 @@ export default {
|
|||||||
task.value.monthly_weeks_of_month = [];
|
task.value.monthly_weeks_of_month = [];
|
||||||
task.value.task_instance_policy = 0;
|
task.value.task_instance_policy = 0;
|
||||||
task.value.expire_date = null;
|
task.value.expire_date = null;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// check the collector box when editing task and custom field is set
|
// check the collector box when editing task and custom field is set
|
||||||
@@ -1159,7 +1178,7 @@ export default {
|
|||||||
onDialogHide,
|
onDialogHide,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
33
src/components/ui/IntegrationsContextMenu.vue
Normal file
33
src/components/ui/IntegrationsContextMenu.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<q-menu anchor="top end" self="top start">
|
||||||
|
<q-list>
|
||||||
|
<q-item
|
||||||
|
v-for="integration in $integrations[type + 'MenuIntegrations']"
|
||||||
|
:key="integration.name"
|
||||||
|
dense
|
||||||
|
clickable
|
||||||
|
@click="
|
||||||
|
integration.type === 'dialog'
|
||||||
|
? $q.dialog({
|
||||||
|
component: integration.component,
|
||||||
|
componentProps: integration.props
|
||||||
|
? integration.props(id, type)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:to="integration.type === 'route' ? integration.uri : undefined"
|
||||||
|
v-close-popup
|
||||||
|
>
|
||||||
|
<q-item-section>{{ integration.name }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
type: "client" | "agent" | "site";
|
||||||
|
id: string | number;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -25,13 +25,21 @@
|
|||||||
:key="mapOptions ? scope.opt.value : scope.opt"
|
:key="mapOptions ? scope.opt.value : scope.opt"
|
||||||
>
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label
|
<q-item-label v-html="mapOptions ? scope.opt.label : scope.opt" />
|
||||||
v-html="mapOptions ? scope.opt.label : scope.opt"
|
</q-item-section>
|
||||||
></q-item-label>
|
<q-item-section
|
||||||
|
v-if="
|
||||||
|
(filtered && mapOptions && scope.opt.cat) || scope.opt.img_right
|
||||||
|
"
|
||||||
|
side
|
||||||
|
>
|
||||||
|
{{ scope.opt.cat || "" }}
|
||||||
|
<img
|
||||||
|
v-if="scope.opt.img_right"
|
||||||
|
:src="scope.opt.img_right"
|
||||||
|
style="height: 20px; max-width: 20px"
|
||||||
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section v-if="filtered && mapOptions && scope.opt.cat" side>{{
|
|
||||||
scope.opt.cat
|
|
||||||
}}</q-item-section>
|
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
v-if="scope.opt.category"
|
v-if="scope.opt.category"
|
||||||
@@ -80,7 +88,7 @@ export default {
|
|||||||
|
|
||||||
if (!props.mapOptions)
|
if (!props.mapOptions)
|
||||||
filteredOptions.value = props.options.filter(
|
filteredOptions.value = props.options.filter(
|
||||||
(v) => v.toLowerCase().indexOf(needle) > -1
|
(v) => v.toLowerCase().indexOf(needle) > -1,
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
filteredOptions.value = props.options.filter((v) => {
|
filteredOptions.value = props.options.filter((v) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { fetchUsers } from "@/api/accounts";
|
import { fetchUsers, fetchRoles } from "@/api/accounts";
|
||||||
import { formatUserOptions } from "@/utils/format";
|
import { formatUserOptions } from "@/utils/format";
|
||||||
|
|
||||||
export function useUserDropdown(onMount = false) {
|
export function useUserDropdown(onMount = false) {
|
||||||
@@ -44,3 +44,26 @@ export function useUserDropdown(onMount = false) {
|
|||||||
getDynamicUserOptions,
|
getDynamicUserOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRoleDropdown(opts = {}) {
|
||||||
|
const roleOptions = ref([]);
|
||||||
|
async function getRoleOptions() {
|
||||||
|
const roles = await fetchRoles();
|
||||||
|
roleOptions.value = roles.map((role) => ({
|
||||||
|
value: role.id,
|
||||||
|
label: role.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.onMount) {
|
||||||
|
onMounted(getRoleOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
//data
|
||||||
|
roleOptions,
|
||||||
|
|
||||||
|
//methods
|
||||||
|
getRoleOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { computed, ref } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
import { fetchAgents } from "@/api/agents";
|
import { fetchAgents } from "@/api/agents";
|
||||||
import { formatAgentOptions } from "@/utils/format";
|
import { formatAgentOptions } from "@/utils/format";
|
||||||
|
|
||||||
// agent dropdown
|
// agent dropdown
|
||||||
export function useAgentDropdown() {
|
export function useAgentDropdown(opts = {}) {
|
||||||
const agent = ref(null);
|
const agent = ref(null);
|
||||||
const agents = ref([]);
|
const agents = ref([]);
|
||||||
const agentOptions = ref([]);
|
const agentOptions = ref([]);
|
||||||
@@ -13,10 +13,14 @@ export function useAgentDropdown() {
|
|||||||
async function getAgentOptions(flat = false) {
|
async function getAgentOptions(flat = false) {
|
||||||
agentOptions.value = formatAgentOptions(
|
agentOptions.value = formatAgentOptions(
|
||||||
await fetchAgents({ detail: false }),
|
await fetchAgents({ detail: false }),
|
||||||
flat
|
flat,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.onMount) {
|
||||||
|
onMounted(getAgentOptions);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
//data
|
//data
|
||||||
agent,
|
agent,
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { fetchCustomFields } from "@/api/core";
|
|
||||||
import { formatCustomFieldOptions } from "@/utils/format";
|
|
||||||
|
|
||||||
export function useCustomFieldDropdown({ onMount = false }) {
|
|
||||||
const customFieldOptions = ref([]);
|
|
||||||
|
|
||||||
// type can be "client", "site", or "agent"
|
|
||||||
async function getCustomFieldOptions(model = null, flat = false) {
|
|
||||||
const params = {};
|
|
||||||
|
|
||||||
if (model) params[model] = model;
|
|
||||||
customFieldOptions.value = formatCustomFieldOptions(
|
|
||||||
await fetchCustomFields(params),
|
|
||||||
flat
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onMount) onMounted(getCustomFieldOptions);
|
|
||||||
|
|
||||||
return {
|
|
||||||
//data
|
|
||||||
customFieldOptions,
|
|
||||||
|
|
||||||
//methods
|
|
||||||
getCustomFieldOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
88
src/composables/core.ts
Normal file
88
src/composables/core.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { fetchCustomFields, fetchURLActions } from "@/api/core";
|
||||||
|
import {
|
||||||
|
formatCustomFieldOptions,
|
||||||
|
formatURLActionOptions,
|
||||||
|
} from "@/utils/format";
|
||||||
|
import type { CustomField } from "@/types/core/customfields";
|
||||||
|
import type { URLAction } from "@/types/core/urlactions";
|
||||||
|
|
||||||
|
export interface URLActionOption extends URLAction {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomFieldOption extends CustomField {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCustomFieldDropdownParams {
|
||||||
|
onMount?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomFieldDropdown(opts: UseCustomFieldDropdownParams) {
|
||||||
|
const customFieldOptions = ref([] as CustomFieldOption[]);
|
||||||
|
|
||||||
|
// type can be "client", "site", or "agent"
|
||||||
|
async function getCustomFieldOptions(model = null, flat = false) {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (model) params[model] = model;
|
||||||
|
customFieldOptions.value = formatCustomFieldOptions(
|
||||||
|
await fetchCustomFields(params),
|
||||||
|
flat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restActionOptions = computed(() =>
|
||||||
|
customFieldOptions.value.filter((option) => option.type === "rest"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.onMount) onMounted(getCustomFieldOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customFieldOptions,
|
||||||
|
restActionOptions,
|
||||||
|
|
||||||
|
//methods
|
||||||
|
getCustomFieldOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseURLActionDropdownParams {
|
||||||
|
onMount?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useURLActionDropdown(opts: UseURLActionDropdownParams) {
|
||||||
|
const urlActionOptions = ref([] as URLActionOption[]);
|
||||||
|
|
||||||
|
// type can be "client", "site", or "agent"
|
||||||
|
async function getURLActionOptions(flat = false) {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
urlActionOptions.value = formatURLActionOptions(
|
||||||
|
await fetchURLActions(params),
|
||||||
|
flat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webActionOptions = computed(() =>
|
||||||
|
urlActionOptions.value.filter((action) => action.action_type === "web"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const restActionOptions = computed(() =>
|
||||||
|
urlActionOptions.value.filter((action) => action.action_type === "rest"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts?.onMount) onMounted(getURLActionOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
urlActionOptions,
|
||||||
|
restActionOptions,
|
||||||
|
webActionOptions,
|
||||||
|
|
||||||
|
//methods
|
||||||
|
getURLActionOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/composables/filebrowser.ts
Normal file
58
src/composables/filebrowser.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { uid } from "quasar";
|
||||||
|
|
||||||
|
import type { QTreeFileNode } from "../types/filebrowser";
|
||||||
|
|
||||||
|
export function useFileBrowser() {
|
||||||
|
function createFileNode(
|
||||||
|
name: string,
|
||||||
|
path: string,
|
||||||
|
size = "0",
|
||||||
|
asset_id?: string
|
||||||
|
): QTreeFileNode {
|
||||||
|
return {
|
||||||
|
id: uid(),
|
||||||
|
label: name,
|
||||||
|
path: path,
|
||||||
|
type: "file",
|
||||||
|
icon: "description",
|
||||||
|
asset_id: asset_id,
|
||||||
|
size: `${size}b`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFolderNode(
|
||||||
|
name: string,
|
||||||
|
path: string,
|
||||||
|
icon = "folder",
|
||||||
|
color = "yellow-9"
|
||||||
|
): QTreeFileNode {
|
||||||
|
return {
|
||||||
|
id: uid(),
|
||||||
|
label: name,
|
||||||
|
path: path,
|
||||||
|
type: "folder",
|
||||||
|
icon: icon,
|
||||||
|
iconColor: color,
|
||||||
|
selectable: true,
|
||||||
|
lazy: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFile(path: string, separator: "/" | "\\" = "/"): string {
|
||||||
|
const file = path.split(separator).pop();
|
||||||
|
return file ? file : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPath(path: string, separator: "/" | "\\" = "/"): string {
|
||||||
|
const pathArray = path.split(separator);
|
||||||
|
pathArray.pop();
|
||||||
|
return pathArray.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createFolderNode,
|
||||||
|
createFileNode,
|
||||||
|
getFile,
|
||||||
|
getPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { ref, watch, computed, onMounted } from "vue";
|
|
||||||
import { useStore } from "vuex";
|
|
||||||
import { fetchScripts } from "@/api/scripts";
|
|
||||||
import { formatScriptOptions } from "@/utils/format";
|
|
||||||
|
|
||||||
// script dropdown
|
|
||||||
export function useScriptDropdown(setScript = null, { onMount = false } = {}) {
|
|
||||||
const scriptOptions = ref([]);
|
|
||||||
const defaultTimeout = ref(30);
|
|
||||||
const defaultArgs = ref([]);
|
|
||||||
const defaultEnvVars = ref([]);
|
|
||||||
const script = ref(setScript);
|
|
||||||
const syntax = ref("");
|
|
||||||
const link = ref("");
|
|
||||||
const baseUrl =
|
|
||||||
"https://github.com/amidaware/community-scripts/blob/main/scripts/";
|
|
||||||
|
|
||||||
// specify parameters to filter out community scripts
|
|
||||||
async function getScriptOptions(showCommunityScripts = false) {
|
|
||||||
scriptOptions.value = Object.freeze(
|
|
||||||
formatScriptOptions(await fetchScripts({ showCommunityScripts }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// watch scriptPk for changes and update the default timeout and args
|
|
||||||
watch([script, scriptOptions], () => {
|
|
||||||
if (script.value && scriptOptions.value.length > 0) {
|
|
||||||
const tmpScript = scriptOptions.value.find(
|
|
||||||
(i) => i.value === script.value
|
|
||||||
);
|
|
||||||
defaultTimeout.value = tmpScript.timeout;
|
|
||||||
defaultArgs.value = tmpScript.args;
|
|
||||||
defaultEnvVars.value = tmpScript.env_vars;
|
|
||||||
syntax.value = tmpScript.syntax;
|
|
||||||
link.value =
|
|
||||||
tmpScript.script_type === "builtin"
|
|
||||||
? `${baseUrl}${tmpScript.filename}`
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// vuex show community scripts
|
|
||||||
const store = useStore();
|
|
||||||
const showCommunityScripts = computed(() => store.state.showCommunityScripts);
|
|
||||||
|
|
||||||
if (onMount) onMounted(() => getScriptOptions(showCommunityScripts.value));
|
|
||||||
|
|
||||||
return {
|
|
||||||
//data
|
|
||||||
script,
|
|
||||||
scriptOptions,
|
|
||||||
defaultTimeout,
|
|
||||||
defaultArgs,
|
|
||||||
defaultEnvVars,
|
|
||||||
syntax,
|
|
||||||
link,
|
|
||||||
|
|
||||||
//methods
|
|
||||||
getScriptOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const shellOptions = [
|
|
||||||
{ label: "Powershell", value: "powershell" },
|
|
||||||
{ label: "Batch", value: "cmd" },
|
|
||||||
{ label: "Python", value: "python" },
|
|
||||||
{ label: "Shell", value: "shell" },
|
|
||||||
];
|
|
||||||
141
src/composables/scripts.ts
Normal file
141
src/composables/scripts.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { ref, watch, computed, onMounted } from "vue";
|
||||||
|
import { useStore } from "vuex";
|
||||||
|
import { fetchScripts } from "@/api/scripts";
|
||||||
|
import {
|
||||||
|
formatScriptOptions,
|
||||||
|
removeExtraOptionCategories,
|
||||||
|
} from "@/utils/format";
|
||||||
|
import type { Script } from "@/types/scripts";
|
||||||
|
import { AgentPlatformType } from "@/types/agents";
|
||||||
|
|
||||||
|
export interface ScriptOption extends Script {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface useScriptDropdownParams {
|
||||||
|
script?: number; // set a selected script on init
|
||||||
|
plat?: AgentPlatformType; // set a platform for filterByPlatform
|
||||||
|
onMount?: boolean; // loads script options on mount
|
||||||
|
}
|
||||||
|
|
||||||
|
// script dropdown
|
||||||
|
export function useScriptDropdown(opts?: useScriptDropdownParams) {
|
||||||
|
const scriptOptions = ref([] as ScriptOption[]);
|
||||||
|
const defaultTimeout = ref(30);
|
||||||
|
const defaultArgs = ref([] as string[]);
|
||||||
|
const defaultEnvVars = ref([] as string[]);
|
||||||
|
const script = ref(opts?.script);
|
||||||
|
const scriptName = ref("");
|
||||||
|
const syntax = ref<string | undefined>("");
|
||||||
|
const link = ref<string | undefined>("");
|
||||||
|
const plat = ref<AgentPlatformType | undefined>(opts?.plat);
|
||||||
|
const baseUrl =
|
||||||
|
"https://github.com/amidaware/community-scripts/blob/main/scripts/";
|
||||||
|
|
||||||
|
// specify parameters to filter out community scripts
|
||||||
|
async function getScriptOptions() {
|
||||||
|
scriptOptions.value = Object.freeze(
|
||||||
|
formatScriptOptions(
|
||||||
|
await fetchScripts({
|
||||||
|
showCommunityScripts: showCommunityScripts.value,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) as ScriptOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch scriptPk for changes and update the default timeout and args
|
||||||
|
watch([script, scriptOptions], () => {
|
||||||
|
if (script.value && scriptOptions.value.length > 0) {
|
||||||
|
const tmpScript = scriptOptions.value.find(
|
||||||
|
(i) => i.value === script.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tmpScript) {
|
||||||
|
defaultTimeout.value = tmpScript.default_timeout;
|
||||||
|
defaultArgs.value = tmpScript.args;
|
||||||
|
defaultEnvVars.value = tmpScript.env_vars;
|
||||||
|
syntax.value = tmpScript.syntax;
|
||||||
|
scriptName.value = tmpScript.label;
|
||||||
|
link.value =
|
||||||
|
tmpScript.script_type === "builtin"
|
||||||
|
? `${baseUrl}${tmpScript.filename}`
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// vuex show community scripts
|
||||||
|
const store = useStore();
|
||||||
|
const showCommunityScripts = computed(() => store.state.showCommunityScripts);
|
||||||
|
|
||||||
|
// filter for only getting server tasks
|
||||||
|
const serverScriptOptions = computed(
|
||||||
|
() =>
|
||||||
|
removeExtraOptionCategories(
|
||||||
|
scriptOptions.value.filter(
|
||||||
|
(script) =>
|
||||||
|
script.category ||
|
||||||
|
!script.supported_platforms ||
|
||||||
|
script.supported_platforms.length === 0 ||
|
||||||
|
script.supported_platforms.includes("linux"),
|
||||||
|
),
|
||||||
|
) as ScriptOption[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterByPlatformOptions = computed(() => {
|
||||||
|
if (!plat.value) {
|
||||||
|
return scriptOptions.value;
|
||||||
|
} else {
|
||||||
|
return removeExtraOptionCategories(
|
||||||
|
scriptOptions.value.filter(
|
||||||
|
(script) =>
|
||||||
|
script.category ||
|
||||||
|
!script.supported_platforms ||
|
||||||
|
script.supported_platforms.length === 0 ||
|
||||||
|
script.supported_platforms.includes(plat.value!),
|
||||||
|
),
|
||||||
|
) as ScriptOption[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
defaultTimeout.value = 30;
|
||||||
|
defaultArgs.value = [];
|
||||||
|
defaultEnvVars.value = [];
|
||||||
|
script.value = undefined;
|
||||||
|
syntax.value = "";
|
||||||
|
link.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.onMount) onMounted(() => getScriptOptions());
|
||||||
|
|
||||||
|
return {
|
||||||
|
//data
|
||||||
|
script,
|
||||||
|
defaultTimeout,
|
||||||
|
defaultArgs,
|
||||||
|
defaultEnvVars,
|
||||||
|
scriptName,
|
||||||
|
syntax,
|
||||||
|
link,
|
||||||
|
plat,
|
||||||
|
|
||||||
|
scriptOptions, // unfiltered options
|
||||||
|
serverScriptOptions, // only scripts that can run on server
|
||||||
|
filterByPlatformOptions, // use the returned plat to change options
|
||||||
|
|
||||||
|
//methods
|
||||||
|
getScriptOptions,
|
||||||
|
reset, // resets dropdown selection state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shellOptions = [
|
||||||
|
{ label: "Powershell", value: "powershell" },
|
||||||
|
{ label: "Batch", value: "cmd" },
|
||||||
|
{ label: "Python", value: "python" },
|
||||||
|
{ label: "Shell", value: "shell" },
|
||||||
|
{ label: "Nushell", value: "nushell" },
|
||||||
|
{ label: "Deno", value: "deno" },
|
||||||
|
];
|
||||||
@@ -4,7 +4,7 @@ export const GOARCH_ARM64 = "arm64";
|
|||||||
export const GOARCH_ARM32 = "arm";
|
export const GOARCH_ARM32 = "arm";
|
||||||
|
|
||||||
export const runAsUserToolTip =
|
export const runAsUserToolTip =
|
||||||
"Run in the context of the logged in user. If no user is logged in, the script will not run and an error will be returned.";
|
"Run in the context of the logged in user. If no user is logged in, the script will run as SYSTEM";
|
||||||
|
|
||||||
export const envVarsLabel =
|
export const envVarsLabel =
|
||||||
"Environment vars (press Enter after typing each key=value pair)";
|
"Environment vars (press Enter after typing each key=value pair)";
|
||||||
|
|||||||
30
src/ee/LICENSE.md
Normal file
30
src/ee/LICENSE.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## Tactical RMM Enterprise Edition (EE) License Agreement (the "Agreement")
|
||||||
|
|
||||||
|
Copyright (c) 2023 Amidaware Inc. All rights reserved.
|
||||||
|
|
||||||
|
This Agreement is entered into between the licensee ("You" or the "Licensee") and Amidaware Inc. ("Amidaware") and governs the use of the enterprise features of the Tactical RMM Software (hereinafter referred to as the "Software").
|
||||||
|
|
||||||
|
The EE features of the Software, including but not limited to SSO (Single Sign-On), Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference.
|
||||||
|
|
||||||
|
## License Grant
|
||||||
|
|
||||||
|
Subject to the terms of this Agreement and upon the Licensee's possession of a valid and authorized sponsorship token issued by Amidaware, Amidaware hereby grants to the Licensee a limited, non-exclusive, non-transferable, and revocable right and license to use the Software.
|
||||||
|
|
||||||
|
## Restrictions
|
||||||
|
|
||||||
|
The Licensee acknowledges and agrees that, notwithstanding any other provision of this Agreement:
|
||||||
|
|
||||||
|
a) The Licensee shall not copy, merge, publish, distribute, sublicense, or sell the Software or any derivative thereof;
|
||||||
|
|
||||||
|
b) The Licensee shall not, in any manner, circumvent, bypass, or tamper with the license key functionality embedded in the Software, nor shall the Licensee remove, alter, or obscure any features that are protected by such license keys. For the avoidance of doubt, the Software's code contains protective measures that enable specific EE features, and the Licensee is strictly prohibited from modifying or removing any licensing code with the intent to enable or unlock these EE features without proper authorization.
|
||||||
|
|
||||||
|
## Termination
|
||||||
|
|
||||||
|
1. **Breach**: If the Licensee breaches any term of this Agreement, Amidaware reserves the right to terminate this Agreement and the Licensee's rights granted hereunder immediately, without prior notice.
|
||||||
|
2. **Legal Action**: Any breach of this Agreement may result in Amidaware pursuing legal action against the Licensee. The Licensee acknowledges and agrees that, upon any breach, Amidaware may seek remedies, including injunctive relief, damages, and legal fees, and will be entitled to prosecute the Licensee to the full extent of the law.
|
||||||
|
3. **Effects of Termination**: Upon termination, the Licensee shall immediately cease all use of the Software and delete all copies of the Software from its systems and confirm such deletion in writing to Amidaware.
|
||||||
|
|
||||||
|
## Updates & Amendments
|
||||||
|
|
||||||
|
1. **Software Updates**: Amidaware may, from time to time, release updates or upgrades to the Software. These updates or upgrades might be subject to additional terms presented to you at the time of download or installation.
|
||||||
|
2. **Amendments to this Agreement**: Amidaware reserves the right to modify the terms of this Agreement at any given time. Any such modifications will be effective immediately upon posting on Amidaware's official website or by direct communication to the Licensee. The continued use of the Software after such modifications will constitute the Licensee's acceptance of the revised terms.
|
||||||
629
src/ee/reporting/api/reporting.ts
Normal file
629
src/ee/reporting/api/reporting.ts
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { ref, type Ref } from "vue";
|
||||||
|
import { router } from "@/router";
|
||||||
|
import type {
|
||||||
|
ReportFormat,
|
||||||
|
ReportDependencies,
|
||||||
|
ReportTemplate,
|
||||||
|
ReportHTMLTemplate,
|
||||||
|
ReportDataQuery,
|
||||||
|
UploadAssetsResponse,
|
||||||
|
RunReportPreviewRequest,
|
||||||
|
RunReportRequest,
|
||||||
|
VariableAnalysis,
|
||||||
|
SharedTemplate,
|
||||||
|
} from "../types/reporting";
|
||||||
|
import type { QTreeFileNode } from "@/types/filebrowser";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { exportFile, Dialog } from "quasar";
|
||||||
|
import { until } from "@vueuse/shared";
|
||||||
|
|
||||||
|
import ReportDependencyPrompt from "../components/ReportDependencyPrompt.vue";
|
||||||
|
|
||||||
|
const baseUrl = "/reporting";
|
||||||
|
|
||||||
|
export interface useReportingTemplates {
|
||||||
|
reportTemplates: Ref<ReportTemplate[]>;
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isError: Ref<boolean>;
|
||||||
|
getReportTemplates: (dependsOn?: string[]) => void;
|
||||||
|
addReportTemplate: (payload: ReportTemplate) => void;
|
||||||
|
editReportTemplate: (
|
||||||
|
id: number,
|
||||||
|
payload: ReportTemplate,
|
||||||
|
options?: { dontNotify?: boolean },
|
||||||
|
) => void;
|
||||||
|
deleteReportTemplate: (id: number) => void;
|
||||||
|
renderedPreview: Ref<string>;
|
||||||
|
renderedVariables: Ref<string>;
|
||||||
|
runReportPreview: (payload: RunReportPreviewRequest) => void;
|
||||||
|
runReportPreviewDebug: (payload: RunReportPreviewRequest) => void;
|
||||||
|
reportData: Ref<string>;
|
||||||
|
runReport: (
|
||||||
|
id: number,
|
||||||
|
payload: RunReportRequest,
|
||||||
|
forDownload?: boolean,
|
||||||
|
) => void;
|
||||||
|
openReport: (
|
||||||
|
id: number,
|
||||||
|
format: ReportFormat,
|
||||||
|
dependsOn: string[],
|
||||||
|
dependencies?: ReportDependencies,
|
||||||
|
newWindow?: boolean,
|
||||||
|
) => void;
|
||||||
|
exportReport: (id: number) => void;
|
||||||
|
importReport: (payload: { overwrite: boolean; template: string }) => void;
|
||||||
|
downloadReport: (
|
||||||
|
template: ReportTemplate,
|
||||||
|
format: ReportFormat,
|
||||||
|
dependencies?: ReportDependencies,
|
||||||
|
) => void;
|
||||||
|
getSharedTemplates: () => void;
|
||||||
|
sharedTemplates: Ref<SharedTemplate[]>;
|
||||||
|
importSharedTemplates: (payload: {
|
||||||
|
templates: SharedTemplate[];
|
||||||
|
overwrite: boolean;
|
||||||
|
}) => void;
|
||||||
|
variableAnalysis: Ref<VariableAnalysis>;
|
||||||
|
getAllowedValues: (payload: {
|
||||||
|
variables: string;
|
||||||
|
dependencies: ReportDependencies;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// reporting endpoints
|
||||||
|
export function useReportTemplates(): useReportingTemplates {
|
||||||
|
const reportTemplates = ref<ReportTemplate[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isError = ref(false);
|
||||||
|
const renderedPreview = ref("");
|
||||||
|
const renderedVariables = ref("");
|
||||||
|
const reportData = ref("");
|
||||||
|
const variableAnalysis = ref<VariableAnalysis>({});
|
||||||
|
const sharedTemplates = ref<SharedTemplate[]>([]);
|
||||||
|
|
||||||
|
function getReportTemplates(dependsOn?: string[]) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
|
||||||
|
const query = {} as { dependsOn?: string[] };
|
||||||
|
if (dependsOn) {
|
||||||
|
query.dependsOn = dependsOn;
|
||||||
|
}
|
||||||
|
axios
|
||||||
|
.get(`${baseUrl}/templates/`, { params: query })
|
||||||
|
.then(({ data }) => {
|
||||||
|
reportTemplates.value = data;
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteReportTemplate(id: number) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
axios
|
||||||
|
.delete(`${baseUrl}/templates/${id}/`)
|
||||||
|
.then(() => {
|
||||||
|
reportTemplates.value = reportTemplates.value.filter(
|
||||||
|
(template) => template.id != id,
|
||||||
|
);
|
||||||
|
notifySuccess("The report template was successfully removed");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addReportTemplate(payload: ReportTemplate) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/`, payload)
|
||||||
|
.then(({ data }: { data: ReportTemplate }) => {
|
||||||
|
reportTemplates.value.push(data);
|
||||||
|
notifySuccess("The report template was added successfully");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function editReportTemplate(
|
||||||
|
id: number,
|
||||||
|
payload: ReportTemplate,
|
||||||
|
options?: { dontNotify?: boolean },
|
||||||
|
) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
axios
|
||||||
|
.put(`${baseUrl}/templates/${id}/`, payload)
|
||||||
|
.then(({ data }: { data: ReportTemplate }) => {
|
||||||
|
const index = reportTemplates.value.findIndex(
|
||||||
|
(template) => template.id === id,
|
||||||
|
);
|
||||||
|
reportTemplates.value[index] = data;
|
||||||
|
options?.dontNotify ||
|
||||||
|
notifySuccess("The report template was edited successfully");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runReportPreviewDebug(payload: RunReportPreviewRequest) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
renderedPreview.value = "";
|
||||||
|
renderedVariables.value = "";
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/preview/`, payload)
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (payload.format === "html") renderedPreview.value = data.template;
|
||||||
|
else renderedPreview.value = `<pre>${data.template}</pre>`;
|
||||||
|
renderedVariables.value = JSON.stringify(data.variables, undefined, 4);
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runReportPreview(payload: RunReportPreviewRequest) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
renderedPreview.value = "";
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/preview/`, payload, {
|
||||||
|
responseType: payload.format !== "pdf" ? "json" : "blob",
|
||||||
|
})
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (payload.format === "html") renderedPreview.value = data;
|
||||||
|
else if (payload.format === "pdf")
|
||||||
|
renderedPreview.value = URL.createObjectURL(data);
|
||||||
|
else renderedPreview.value = `<pre>${data}</pre>`;
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runReport(
|
||||||
|
id: number,
|
||||||
|
payload: RunReportRequest,
|
||||||
|
forDownload?: boolean,
|
||||||
|
): void {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/${id}/run/`, payload, {
|
||||||
|
responseType: payload.format !== "pdf" ? "json" : "blob",
|
||||||
|
})
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (payload.format === "html" || forDownload) reportData.value = data;
|
||||||
|
else if (payload.format === "pdf")
|
||||||
|
reportData.value = URL.createObjectURL(data);
|
||||||
|
else reportData.value = `<pre>${data}</pre>`;
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadReport(
|
||||||
|
template: ReportTemplate,
|
||||||
|
format: ReportFormat,
|
||||||
|
dependencies: ReportDependencies = {},
|
||||||
|
) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
reportData.value = "";
|
||||||
|
|
||||||
|
const needsPrompt =
|
||||||
|
template.depends_on?.filter((dep) => !dependencies[dep]) || [];
|
||||||
|
|
||||||
|
let extension;
|
||||||
|
if (format === "plaintext") extension = "csv";
|
||||||
|
else extension = format;
|
||||||
|
|
||||||
|
// get filename
|
||||||
|
Dialog.create({
|
||||||
|
title: "Confirm File Name",
|
||||||
|
prompt: {
|
||||||
|
model: `${template.name}.${extension}`,
|
||||||
|
isValid: (val) => !!val,
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(async (name: string) => {
|
||||||
|
// get dependencies
|
||||||
|
if (needsPrompt.length > 0) {
|
||||||
|
Dialog.create({
|
||||||
|
component: ReportDependencyPrompt,
|
||||||
|
componentProps: { dependsOn: needsPrompt },
|
||||||
|
})
|
||||||
|
.onOk((deps) => (dependencies = { ...dependencies, ...deps }))
|
||||||
|
.onDismiss(() => {
|
||||||
|
runReport(
|
||||||
|
template.id,
|
||||||
|
{
|
||||||
|
format: format,
|
||||||
|
dependencies: dependencies,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// no dependencies run report
|
||||||
|
runReport(
|
||||||
|
template.id,
|
||||||
|
{
|
||||||
|
format: format,
|
||||||
|
dependencies: dependencies,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await until(isLoading).not.toBeTruthy();
|
||||||
|
if (isError.value) return;
|
||||||
|
|
||||||
|
exportFile(name, reportData.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReport(
|
||||||
|
id: number,
|
||||||
|
format: ReportFormat,
|
||||||
|
dependsOn: string[],
|
||||||
|
dependencies?: ReportDependencies,
|
||||||
|
newWindow?: boolean,
|
||||||
|
) {
|
||||||
|
const dependencyString = JSON.stringify(dependencies) || "{}";
|
||||||
|
const dependsOnString =
|
||||||
|
dependsOn.length > 0 ? JSON.stringify(dependsOn) : null;
|
||||||
|
|
||||||
|
const params = dependsOnString
|
||||||
|
? `format=${format}&dependsOn=${dependsOnString}&dependencies=${dependencyString}`
|
||||||
|
: `format=${format}`;
|
||||||
|
|
||||||
|
const url = router.resolve(`/reports/${id}?${params}`).href;
|
||||||
|
|
||||||
|
if (newWindow === undefined || newWindow) {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
} else {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportReport(id: number) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/${id}/export/`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
exportFile(
|
||||||
|
`${data.template.name}-export.json`,
|
||||||
|
JSON.stringify(data, null, 2),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function importReport(payload: { overwrite: boolean; template: string }) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/import/`, payload)
|
||||||
|
.then(({ data }: { data: ReportTemplate }) => {
|
||||||
|
const index = reportTemplates.value.findIndex(
|
||||||
|
(report) => report.id === data.id,
|
||||||
|
);
|
||||||
|
if (index !== -1) reportTemplates.value[index] = data;
|
||||||
|
else reportTemplates.value.push(data);
|
||||||
|
|
||||||
|
notifySuccess("Report Template was successfully imported.");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSharedTemplates() {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get(`${baseUrl}/templates/shared/`)
|
||||||
|
.then(({ data }: { data: SharedTemplate[] }) => {
|
||||||
|
sharedTemplates.value = data;
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function importSharedTemplates(payload: {
|
||||||
|
templates: SharedTemplate[];
|
||||||
|
overwrite: boolean;
|
||||||
|
}) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/shared/`, payload)
|
||||||
|
.then(() => {
|
||||||
|
notifySuccess("Shared templates imported successfully");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedValues(payload: {
|
||||||
|
variables: string;
|
||||||
|
dependencies: ReportDependencies;
|
||||||
|
}) {
|
||||||
|
isLoading.value = true;
|
||||||
|
isError.value = false;
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/templates/preview/analysis/`, payload)
|
||||||
|
.then(({ data }: { data: VariableAnalysis }) => {
|
||||||
|
variableAnalysis.value = data;
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportTemplates,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
getReportTemplates,
|
||||||
|
addReportTemplate,
|
||||||
|
editReportTemplate,
|
||||||
|
deleteReportTemplate,
|
||||||
|
renderedPreview,
|
||||||
|
renderedVariables,
|
||||||
|
runReportPreview,
|
||||||
|
runReportPreviewDebug,
|
||||||
|
reportData,
|
||||||
|
runReport,
|
||||||
|
openReport,
|
||||||
|
exportReport,
|
||||||
|
importReport,
|
||||||
|
downloadReport,
|
||||||
|
getSharedTemplates,
|
||||||
|
sharedTemplates,
|
||||||
|
importSharedTemplates,
|
||||||
|
variableAnalysis,
|
||||||
|
getAllowedValues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSharedReportTemplates = useReportTemplates();
|
||||||
|
|
||||||
|
// reporting asset endpoints
|
||||||
|
export async function fetchReportAssets(
|
||||||
|
path?: string,
|
||||||
|
folderOnly?: boolean,
|
||||||
|
): Promise<QTreeFileNode[]> {
|
||||||
|
const params = {} as { path?: string; folders?: boolean };
|
||||||
|
if (path) params.path = path;
|
||||||
|
if (folderOnly) params.folders = true;
|
||||||
|
|
||||||
|
const { data } = await axios.get(`${baseUrl}/assets/`, { params: params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllReportAssets(
|
||||||
|
foldersOnly?: boolean,
|
||||||
|
): Promise<QTreeFileNode[]> {
|
||||||
|
const params = {} as { onlyFolders?: boolean };
|
||||||
|
if (foldersOnly) params.onlyFolders = true;
|
||||||
|
|
||||||
|
const { data } = await axios.get(`${baseUrl}/assets/all/`, {
|
||||||
|
params: params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameReportAsset(
|
||||||
|
path: string,
|
||||||
|
newName: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const payload = { path, newName };
|
||||||
|
const { data } = await axios.put(`${baseUrl}/assets/rename/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAssetFolder(path: string): Promise<string> {
|
||||||
|
const payload = { path };
|
||||||
|
const { data } = await axios.post(`${baseUrl}/assets/newfolder/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAssets(paths: string[]): Promise<undefined> {
|
||||||
|
const payload = { paths };
|
||||||
|
const { data } = await axios.post(`${baseUrl}/assets/delete/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadAsset(path: string): Promise<Blob> {
|
||||||
|
const params = path ? { path } : {};
|
||||||
|
const { data } = await axios.get(`${baseUrl}/assets/download/`, {
|
||||||
|
responseType: "blob",
|
||||||
|
params: params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAssets(
|
||||||
|
form: FormData,
|
||||||
|
path = "",
|
||||||
|
): Promise<UploadAssetsResponse> {
|
||||||
|
form.append("parentPath", path);
|
||||||
|
const { data } = await axios.post(`${baseUrl}/assets/upload/`, form);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// reporting html templates endpoints
|
||||||
|
export interface useReportingHTMLTemplates {
|
||||||
|
reportHTMLTemplates: Ref<ReportHTMLTemplate[]>;
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isError: Ref<boolean>;
|
||||||
|
getReportHTMLTemplates: () => void;
|
||||||
|
addReportHTMLTemplate: (payload: ReportHTMLTemplate) => void;
|
||||||
|
editReportHTMLTemplate: (id: number, payload: ReportHTMLTemplate) => void;
|
||||||
|
deleteReportHTMLTemplate: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportingHTMLTemplates(): useReportingHTMLTemplates {
|
||||||
|
const reportHTMLTemplates = ref<ReportHTMLTemplate[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isError = ref(false);
|
||||||
|
|
||||||
|
function getReportHTMLTemplates() {
|
||||||
|
axios
|
||||||
|
.get(`${baseUrl}/htmltemplates/`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
reportHTMLTemplates.value = data;
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addReportHTMLTemplate(payload: ReportHTMLTemplate) {
|
||||||
|
isLoading.value = true;
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/htmltemplates/`, payload)
|
||||||
|
.then(({ data }: { data: ReportHTMLTemplate }) => {
|
||||||
|
reportHTMLTemplates.value.push(data);
|
||||||
|
notifySuccess("HTML Template was added successfully");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function editReportHTMLTemplate(id: number, payload: ReportHTMLTemplate) {
|
||||||
|
isLoading.value = true;
|
||||||
|
axios
|
||||||
|
.put(`${baseUrl}/htmltemplates/${id}/`, payload)
|
||||||
|
.then(({ data }: { data: ReportHTMLTemplate }) => {
|
||||||
|
const index = reportHTMLTemplates.value.findIndex(
|
||||||
|
(template) => template.id === id,
|
||||||
|
);
|
||||||
|
reportHTMLTemplates.value[index] = data;
|
||||||
|
|
||||||
|
notifySuccess("HTML Template was edited successfully");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteReportHTMLTemplate(id: number) {
|
||||||
|
isLoading.value = true;
|
||||||
|
axios
|
||||||
|
.delete(`${baseUrl}/htmltemplates/${id}/`)
|
||||||
|
.then(() => {
|
||||||
|
reportHTMLTemplates.value = reportHTMLTemplates.value.filter(
|
||||||
|
(template) => template.id != id,
|
||||||
|
);
|
||||||
|
notifySuccess("The HTML template was successfully removed");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportHTMLTemplates,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
getReportHTMLTemplates,
|
||||||
|
addReportHTMLTemplate,
|
||||||
|
editReportHTMLTemplate,
|
||||||
|
deleteReportHTMLTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use if you want the state to be consistent across components
|
||||||
|
export const useSharedReportHTMLTemplates = useReportingHTMLTemplates();
|
||||||
|
|
||||||
|
// reporting data query endpoints
|
||||||
|
export interface useReportingDataQueries {
|
||||||
|
reportDataQueries: Ref<ReportDataQuery[]>;
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isError: Ref<boolean>;
|
||||||
|
getReportDataQueries: () => void;
|
||||||
|
addReportDataQuery: (payload: ReportDataQuery) => void;
|
||||||
|
editReportDataQuery: (id: number, payload: ReportDataQuery) => void;
|
||||||
|
deleteReportDataQuery: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportingDataQueries(): useReportingDataQueries {
|
||||||
|
const reportDataQueries = ref<ReportDataQuery[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isError = ref(false);
|
||||||
|
|
||||||
|
function getReportDataQueries() {
|
||||||
|
axios
|
||||||
|
.get(`${baseUrl}/dataqueries/`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
isLoading.value = true;
|
||||||
|
reportDataQueries.value = data;
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addReportDataQuery(payload: ReportDataQuery) {
|
||||||
|
axios
|
||||||
|
.post(`${baseUrl}/dataqueries/`, payload)
|
||||||
|
.then(({ data }: { data: ReportDataQuery }) => {
|
||||||
|
isLoading.value = true;
|
||||||
|
reportDataQueries.value.push(data);
|
||||||
|
notifySuccess("Data Query was added successfully");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function editReportDataQuery(id: number, payload: ReportDataQuery) {
|
||||||
|
axios
|
||||||
|
.put(`${baseUrl}/dataqueries/${id}/`, payload)
|
||||||
|
.then(({ data }: { data: ReportDataQuery }) => {
|
||||||
|
isLoading.value = true;
|
||||||
|
const index = reportDataQueries.value.findIndex(
|
||||||
|
(template) => template.id === id,
|
||||||
|
);
|
||||||
|
reportDataQueries.value[index] = data;
|
||||||
|
notifySuccess("Data Query was edited successfully");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteReportDataQuery(id: number) {
|
||||||
|
axios
|
||||||
|
.delete(`${baseUrl}/dataqueries/${id}/`)
|
||||||
|
.then(() => {
|
||||||
|
reportDataQueries.value = reportDataQueries.value.filter(
|
||||||
|
(template) => template.id != id,
|
||||||
|
);
|
||||||
|
notifySuccess("The Data Query was successfully removed");
|
||||||
|
})
|
||||||
|
.catch(() => (isError.value = true))
|
||||||
|
.finally(() => (isLoading.value = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportDataQueries,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
getReportDataQueries,
|
||||||
|
addReportDataQuery,
|
||||||
|
editReportDataQuery,
|
||||||
|
deleteReportDataQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use if you want the state to be consistent across components
|
||||||
|
export const useSharedReportDataQueries = useReportingDataQueries();
|
||||||
93
src/ee/reporting/components/AssetFileUpload.vue
Normal file
93
src/ee/reporting/components/AssetFileUpload.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
File Upload
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<div class="q-pa-md column items-start q-gutter-y-md">
|
||||||
|
<q-file
|
||||||
|
v-model="files"
|
||||||
|
label="Select files"
|
||||||
|
outlined
|
||||||
|
multiple
|
||||||
|
:clearable="!loading"
|
||||||
|
style="width: 400px"
|
||||||
|
>
|
||||||
|
<template #file="{ file }">
|
||||||
|
<q-chip class="full-width q-my-xs" square>
|
||||||
|
<q-avatar>
|
||||||
|
<q-icon name="insert_drive_file" />
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<div class="ellipsis relative-position">
|
||||||
|
{{ file.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-tooltip>
|
||||||
|
{{ file.name }}
|
||||||
|
</q-tooltip>
|
||||||
|
</q-chip>
|
||||||
|
</template>
|
||||||
|
</q-file>
|
||||||
|
</div>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-close-popup dense flat label="Cancel" />
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="Upload"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:loading="loading"
|
||||||
|
@click="upload"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// composition imports
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { uploadAssets } from "../api/reporting";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{ parentPath: string }>();
|
||||||
|
|
||||||
|
// setup quasar dialog
|
||||||
|
const { dialogRef, onDialogOK, onDialogHide } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const files = ref<File[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
let formData = new FormData();
|
||||||
|
files.value.forEach((file) => {
|
||||||
|
formData.append(file.name, file);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadAssets(formData, props.parentPath);
|
||||||
|
notifySuccess("Files uploaded successfully");
|
||||||
|
onDialogOK({ files: files.value, response: result });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
96
src/ee/reporting/components/DataQuerySelect.vue
Normal file
96
src/ee/reporting/components/DataQuerySelect.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card style="width: 400px">
|
||||||
|
<q-bar>
|
||||||
|
Data Query Select
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-card-section>
|
||||||
|
<tactical-dropdown
|
||||||
|
v-model="selectedQuery"
|
||||||
|
:options="queryOptions"
|
||||||
|
label="Data Queries"
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
:loading="loading"
|
||||||
|
@click="submit"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Select"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { useSharedReportDataQueries } from "../api/reporting";
|
||||||
|
import { notifyError } from "@/utils/notify";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const props = defineProps<{ dataSources?: any }>();
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const { reportDataQueries, getReportDataQueries } = useSharedReportDataQueries;
|
||||||
|
|
||||||
|
const selectedQuery = ref<string | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const queryOptions = computed(() => {
|
||||||
|
if (props.dataSources === undefined)
|
||||||
|
return reportDataQueries.value.map((query) => query.name);
|
||||||
|
else return Object.keys(props.dataSources);
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (selectedQuery.value === null)
|
||||||
|
notifyError("Select a query from the dropdown");
|
||||||
|
else {
|
||||||
|
let dataQuery;
|
||||||
|
if (props.dataSources === undefined) {
|
||||||
|
dataQuery = reportDataQueries.value.find(
|
||||||
|
(query) => query.name === selectedQuery.value,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dataQuery = {
|
||||||
|
id: 0,
|
||||||
|
name: selectedQuery.value,
|
||||||
|
json_query: props.dataSources[selectedQuery.value],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onDialogOK(dataQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.dataSources === undefined) {
|
||||||
|
getReportDataQueries();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
670
src/ee/reporting/components/EditorToolbar.vue
Normal file
670
src/ee/reporting/components/EditorToolbar.vue
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-bar>
|
||||||
|
<q-btn-dropdown
|
||||||
|
label="Formatting"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
auto-close
|
||||||
|
:ripple="false"
|
||||||
|
@hide="_editor.focus()"
|
||||||
|
>
|
||||||
|
<q-list dense>
|
||||||
|
<q-item clickable @click="insertHeader('#')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Heading 1</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertHeader('##')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Heading 2</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertHeader('###')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Heading 3</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertHeader('####')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Heading 4</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertHeader('#####')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Heading 5</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertHeader('######')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Heading 6</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
|
||||||
|
<q-btn-dropdown
|
||||||
|
label="Section"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
auto-close
|
||||||
|
:ripple="false"
|
||||||
|
@hide="_editor.focus()"
|
||||||
|
>
|
||||||
|
<q-list dense>
|
||||||
|
<q-item clickable @click="insertSection('section')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Section</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertSection('chapter')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Chapter</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertSection('header')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Header</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertSection('footer')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Footer</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertSection('nav')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Nav</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable @click="insertSection('div')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Div</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable @click="insertSection('article')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Article</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
|
||||||
|
<q-btn flat dense :ripple="false" icon="format_bold" @click="insertBold">
|
||||||
|
<q-tooltip :delay="500">Bold</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="format_italic"
|
||||||
|
@click="insertItalic"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Italic</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-separator vertical inset />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="format_list_numbered"
|
||||||
|
@click="insertNumberedList"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Numbered List</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="format_list_bulleted"
|
||||||
|
@click="insertBulletList"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Bullet List</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-separator vertical inset />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="format_quote"
|
||||||
|
@click="insertBlockQuote"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Block Quote</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-separator vertical inset />
|
||||||
|
<q-btn flat dense :ripple="false" icon="undo" @click="undo">
|
||||||
|
<q-tooltip :delay="500">Undo</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense :ripple="false" icon="redo" @click="redo">
|
||||||
|
<q-tooltip :delay="500">Redo</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-separator vertical inset />
|
||||||
|
<q-btn flat dense :ripple="false" icon="code" @click="insertCodeBlock">
|
||||||
|
<q-tooltip :delay="500">Code Block</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense :ripple="false" icon="link">
|
||||||
|
<q-tooltip :delay="500">Link</q-tooltip>
|
||||||
|
<q-menu>
|
||||||
|
<div class="no-wrap q-pa-md">
|
||||||
|
<div class="text-subtitle1">Create Link</div>
|
||||||
|
<q-input v-model="linkText" label="Text" type="text" />
|
||||||
|
|
||||||
|
<q-input v-model="linkUrl" label="Url" type="text" />
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
color="primary"
|
||||||
|
label="Insert Link"
|
||||||
|
class="full-width q-mt-sm"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
@click="insertLink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense :ripple="false" icon="image" @click="insertImage">
|
||||||
|
<q-tooltip :delay="500">Image</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense :ripple="false" icon="horizontal_rule" @click="insertHr">
|
||||||
|
<q-tooltip :delay="500">Horizontal Rule</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-separator vertical inset />
|
||||||
|
|
||||||
|
<!-- Jinja Block -->
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
label="{% %}"
|
||||||
|
no-caps
|
||||||
|
@click="insertJinjaBlock('block [name]', 'endblock')"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Jinja {% %} block</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
no-caps
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
label="{{ }}"
|
||||||
|
@click="insertJinjaData()"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Jinja template data</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
label="{% for "
|
||||||
|
no-caps
|
||||||
|
@click="insertJinjaBlock('for item in items', 'endfor')"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Jinja for loop</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
label="{% if"
|
||||||
|
no-caps
|
||||||
|
@click="insertJinjaBlock('if [condition]', 'endif')"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Jinja if condition</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-separator vertical inset />
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="mdi-database-plus-outline"
|
||||||
|
@click="openQueryAddDialog"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Add Data Query</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="mdi-database-arrow-down"
|
||||||
|
@click="insertDataQuery"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Insert Saved Data Query</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="mdi-database-edit"
|
||||||
|
@click="editDataQuery"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Edit Data Query</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
icon="mdi-table-large-plus"
|
||||||
|
@click="openTableMaker"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Table</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<!-- <q-btn flat dense :ripple="false" icon="add_chart" @click="openChartDialog">
|
||||||
|
<q-tooltip :delay="500">Add chart</q-tooltip>
|
||||||
|
</q-btn> -->
|
||||||
|
|
||||||
|
<slot name="buttons"></slot>
|
||||||
|
</q-bar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, toRaw, onMounted } from "vue";
|
||||||
|
import { useQuasar } from "quasar";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
|
// ui import
|
||||||
|
import ReportDataQueryForm from "./ReportDataQueryForm.vue";
|
||||||
|
import DataQuerySelect from "./DataQuerySelect.vue";
|
||||||
|
import ReportAssetSelect from "./ReportAssetSelect.vue";
|
||||||
|
// import ReportChartSelect from "./ReportChartSelect.vue";
|
||||||
|
import ReportTableMaker from "./ReportTableMaker.vue";
|
||||||
|
|
||||||
|
// utils
|
||||||
|
import { convertCamelCase } from "@/utils/format";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import { ReportDataQuery, ReportTemplateType } from "../types/reporting";
|
||||||
|
import { notifyWarning, notifySuccess } from "@/utils/notify";
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
variablesEditor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
templateType: ReportTemplateType;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const _editor = toRaw(props.editor);
|
||||||
|
const isMultiLineSelection = ref(false);
|
||||||
|
|
||||||
|
// link insert refs
|
||||||
|
const linkUrl = ref("");
|
||||||
|
const linkText = ref("");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// disable certain toolbar options if a multiline text selection is made
|
||||||
|
_editor.onDidChangeCursorSelection((evt) => {
|
||||||
|
isMultiLineSelection.value = monaco.Selection.spansMultipleLines(
|
||||||
|
evt.selection,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// toolbar actions
|
||||||
|
function insertHeader(header: string) {
|
||||||
|
if (props.templateType === "markdown") insertPrefix("#", header.length);
|
||||||
|
else insertWrap(`<h${header.length}>`, `</h${header.length}>`);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBold() {
|
||||||
|
if (props.templateType === "markdown") insertWrap("**", "**");
|
||||||
|
else insertWrap("<b>", "</b>");
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertItalic() {
|
||||||
|
if (props.templateType === "markdown") insertWrap("*", "*");
|
||||||
|
else insertWrap("<i>", "</i>");
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertNumberedList() {
|
||||||
|
if (props.templateType === "markdown") insertPrefix("1.");
|
||||||
|
else insert("<ol>\n\t<li></li>\n\t<li></li>\n</ol>", true);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBulletList() {
|
||||||
|
if (props.templateType === "markdown") insertPrefix("*");
|
||||||
|
else insert("<ul>\n\t<li></li>\n\t<li></li>\n</ul>", true);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBlockQuote() {
|
||||||
|
if (props.templateType === "markdown") insertPrefix(">");
|
||||||
|
else insertWrap("<blockquote>", "</blockquote>", true);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCodeBlock() {
|
||||||
|
if (props.templateType === "markdown") {
|
||||||
|
if (isMultiLineSelection.value) {
|
||||||
|
insertWrap("```\n", "\n```", true);
|
||||||
|
} else {
|
||||||
|
insertWrap("`", "`");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
insertWrap("<code>", "</code>");
|
||||||
|
}
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getDataSourcesInTemplate() {
|
||||||
|
let variablesJson = parse(props.variablesEditor.getValue()) || {};
|
||||||
|
|
||||||
|
if (!("data_sources" in variablesJson) || !variablesJson.data_sources)
|
||||||
|
return null;
|
||||||
|
else return variablesJson["data_sources"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveDataSourcesInTemplate(
|
||||||
|
dataQuery: ReportDataQuery,
|
||||||
|
convertNameToCamelCase = true,
|
||||||
|
) {
|
||||||
|
let variablesJson = parse(props.variablesEditor.getValue()) || {};
|
||||||
|
|
||||||
|
if (!("data_sources" in variablesJson) || !variablesJson.data_sources) {
|
||||||
|
variablesJson["data_sources"] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataQueryName = convertNameToCamelCase
|
||||||
|
? convertCamelCase(dataQuery.name)
|
||||||
|
: dataQuery.name;
|
||||||
|
variablesJson["data_sources"][dataQueryName] = dataQuery.json_query;
|
||||||
|
props.variablesEditor?.setValue(stringify(variablesJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQueryAddDialog() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportDataQueryForm,
|
||||||
|
}).onOk((dataQuery: ReportDataQuery) => {
|
||||||
|
_saveDataSourcesInTemplate(dataQuery);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertDataQuery() {
|
||||||
|
$q.dialog({
|
||||||
|
component: DataQuerySelect,
|
||||||
|
}).onOk((dataQuery: ReportDataQuery) => {
|
||||||
|
_saveDataSourcesInTemplate(dataQuery);
|
||||||
|
notifySuccess(`${dataQuery.name} was saved successfully in template`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editDataQuery() {
|
||||||
|
const dataSources = _getDataSourcesInTemplate();
|
||||||
|
|
||||||
|
if (!dataSources) {
|
||||||
|
notifyWarning("No data sources exist in template variables");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$q.dialog({
|
||||||
|
component: DataQuerySelect,
|
||||||
|
componentProps: {
|
||||||
|
dataSources,
|
||||||
|
},
|
||||||
|
}).onOk((dataQuery) => {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportDataQueryForm,
|
||||||
|
componentProps: {
|
||||||
|
dataQuery: dataQuery,
|
||||||
|
editInTemplate: true,
|
||||||
|
},
|
||||||
|
}).onOk((dataQuery: ReportDataQuery) => {
|
||||||
|
_saveDataSourcesInTemplate(dataQuery, false);
|
||||||
|
notifySuccess(`${dataQuery.name} was saved successfully in template`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// function openChartDialog() {
|
||||||
|
// $q.dialog({
|
||||||
|
// component: ReportChartSelect,
|
||||||
|
// }).onOk((data) => {
|
||||||
|
// let variablesJson = parse(props.variablesEditor.getValue()) || {};
|
||||||
|
// const optionsJson = parse(data.options);
|
||||||
|
|
||||||
|
// if (!("charts" in variablesJson) || !variablesJson.charts) {
|
||||||
|
// variablesJson["charts"] = {};
|
||||||
|
// }
|
||||||
|
|
||||||
|
// variablesJson["charts"][convertCamelCase(data.name)] = {
|
||||||
|
// chartType: data.chartType,
|
||||||
|
// outputType: data.outputType,
|
||||||
|
// options: optionsJson,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// props.variablesEditor?.setValue(stringify(variablesJson));
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
function insertLink() {
|
||||||
|
if (props.templateType === "markdown")
|
||||||
|
insert(`[${linkText.value}](${linkUrl.value})`);
|
||||||
|
else insert(`<a href="${linkUrl.value}">${linkText.value}</a>`);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertImage() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportAssetSelect,
|
||||||
|
componentProps: {
|
||||||
|
templateType: props.templateType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.onOk((text) => {
|
||||||
|
insert(text);
|
||||||
|
})
|
||||||
|
.onDismiss(() => _editor.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
_editor.trigger("toolbar", "redo", null);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
_editor.trigger("toolbar", "undo", null);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertHr() {
|
||||||
|
if (props.templateType === "markdown") insert("---", true);
|
||||||
|
else insert("<hr />", true);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTableMaker() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportTableMaker,
|
||||||
|
}).onOk((table) => {
|
||||||
|
insert(table, true);
|
||||||
|
_editor.focus();
|
||||||
|
});
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section =
|
||||||
|
| "article"
|
||||||
|
| "div"
|
||||||
|
| "section"
|
||||||
|
| "header"
|
||||||
|
| "footer"
|
||||||
|
| "nav"
|
||||||
|
| "chapter";
|
||||||
|
|
||||||
|
function insertSection(section: Section) {
|
||||||
|
if (props.templateType === "markdown") {
|
||||||
|
const tag = section.slice(0, 1).toUpperCase();
|
||||||
|
insertWrap(`~~${tag}~~\n`, `\n~~/${tag}~~`, true);
|
||||||
|
} else {
|
||||||
|
insertWrap(`<${section}>`, `</${section}>`, true);
|
||||||
|
}
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertJinjaBlock(open: string, end: string) {
|
||||||
|
insertWrap(`{% ${open} %}`, `{% ${end} %}`, true);
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertJinjaData() {
|
||||||
|
insertWrap("{{", "}}");
|
||||||
|
_editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// inserts text on a new line below the cursor position
|
||||||
|
function insert(text: string, moveToNewLine = false) {
|
||||||
|
const model = _editor.getModel();
|
||||||
|
const selections = _editor.getSelections();
|
||||||
|
if (!model || !selections) return;
|
||||||
|
|
||||||
|
let operations = [] as monaco.editor.IIdentifiedSingleEditOperation[];
|
||||||
|
for (let selection of selections) {
|
||||||
|
const end = selection.getEndPosition();
|
||||||
|
|
||||||
|
let editSelection = moveToNewLine
|
||||||
|
? monaco.Selection.fromPositions({
|
||||||
|
lineNumber: end.lineNumber,
|
||||||
|
column: model.getLineMaxColumn(end.lineNumber),
|
||||||
|
})
|
||||||
|
: selection;
|
||||||
|
|
||||||
|
const editText = moveToNewLine ? `\n${text}\n` : text;
|
||||||
|
operations.push({
|
||||||
|
text: editText,
|
||||||
|
range: editSelection,
|
||||||
|
forceMoveMarkers: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
model.pushEditOperations(selections, operations, (/*operations*/) => {
|
||||||
|
return selections;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// inserts a prefix before selected text
|
||||||
|
function insertPrefix(prefix: string, prefixCount = 1) {
|
||||||
|
const model = _editor.getModel();
|
||||||
|
const selections = _editor.getSelections();
|
||||||
|
if (!model || !selections) return;
|
||||||
|
|
||||||
|
let operations = [] as monaco.editor.IIdentifiedSingleEditOperation[];
|
||||||
|
let newSelections = [] as monaco.Selection[];
|
||||||
|
for (let selection of selections) {
|
||||||
|
const start = selection.getStartPosition();
|
||||||
|
const end = selection.getEndPosition();
|
||||||
|
|
||||||
|
let editSelection = monaco.Selection.fromPositions(
|
||||||
|
{ lineNumber: start.lineNumber, column: 0 },
|
||||||
|
{
|
||||||
|
lineNumber: end.lineNumber,
|
||||||
|
column: model.getLineMaxColumn(end.lineNumber),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let replacementText = [] as string[];
|
||||||
|
|
||||||
|
newSelections.push(editSelection);
|
||||||
|
|
||||||
|
// loop over line numbers
|
||||||
|
for (let i = start.lineNumber; i <= end.lineNumber; i++) {
|
||||||
|
let text = model?.getLineContent(i).trimStart();
|
||||||
|
|
||||||
|
// prefix and prefix character amount match so should toggle off prefix in editor
|
||||||
|
const re_toggle = new RegExp(`^\\${prefix}{${prefixCount}}\\s`);
|
||||||
|
const re_replace = new RegExp(`^\\${prefix}+\\s`);
|
||||||
|
|
||||||
|
if (text.match(re_toggle)) {
|
||||||
|
// remove prefix since it is present already (toggled off)
|
||||||
|
text = text.replace(prefix.repeat(prefixCount), "").trimStart();
|
||||||
|
} else {
|
||||||
|
// add prefix
|
||||||
|
text = `${prefix.repeat(prefixCount)} ${text
|
||||||
|
?.replace(re_replace, "")
|
||||||
|
.trimStart()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
replacementText.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
operations.push({
|
||||||
|
text: replacementText.join("\n"),
|
||||||
|
range: editSelection,
|
||||||
|
forceMoveMarkers: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
model.pushEditOperations(selections, operations, (/*operations*/) => {
|
||||||
|
return newSelections;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// wraps selected text beginning with a prefix and ending with a suffix
|
||||||
|
function insertWrap(prefix: string, suffix: string, includeWholeLine = false) {
|
||||||
|
const model = _editor.getModel();
|
||||||
|
const selections = _editor.getSelections();
|
||||||
|
if (!model || !selections) return;
|
||||||
|
|
||||||
|
let operations = [] as monaco.editor.IIdentifiedSingleEditOperation[];
|
||||||
|
for (let selection of selections) {
|
||||||
|
const start = selection.getStartPosition();
|
||||||
|
const end = selection.getEndPosition();
|
||||||
|
|
||||||
|
let editSelection = includeWholeLine
|
||||||
|
? monaco.Selection.fromPositions(
|
||||||
|
{ lineNumber: start.lineNumber, column: 0 },
|
||||||
|
{
|
||||||
|
lineNumber: end.lineNumber,
|
||||||
|
column: model.getLineMaxColumn(end.lineNumber),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: selection;
|
||||||
|
|
||||||
|
const text = `${prefix}${model.getValueInRange(editSelection)}${suffix}`;
|
||||||
|
|
||||||
|
operations.push({
|
||||||
|
text: text,
|
||||||
|
range: editSelection,
|
||||||
|
forceMoveMarkers: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
model.pushEditOperations(selections, operations, (operations) => {
|
||||||
|
return operations.map((operation) =>
|
||||||
|
monaco.Selection.fromRange(
|
||||||
|
operation.range,
|
||||||
|
monaco.SelectionDirection.LTR,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
134
src/ee/reporting/components/ReportAssetSelect.vue
Normal file
134
src/ee/reporting/components/ReportAssetSelect.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card style="width: 400px">
|
||||||
|
<q-bar>
|
||||||
|
Report Asset Select
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<q-card-section class="q-gutter-sm">
|
||||||
|
<q-radio dense v-model="imageType" val="link" label="Link" />
|
||||||
|
<q-radio dense v-model="imageType" val="asset" label="Report Asset" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="imageType === 'link'">
|
||||||
|
<q-input
|
||||||
|
v-model="linkText"
|
||||||
|
label="Text"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
class="q-pb-sm"
|
||||||
|
/>
|
||||||
|
<q-input v-model="linkUrl" label="Url" dense outlined class="q-pb-sm" />
|
||||||
|
<q-input v-model="output" label="Output" readonly dense />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section
|
||||||
|
v-if="imageType === 'asset'"
|
||||||
|
style="max-height: 50vh"
|
||||||
|
class="scroll"
|
||||||
|
>
|
||||||
|
<div v-if="tree.length === 0">
|
||||||
|
No Report Assets found. Go to Reporting Manager and use the Report
|
||||||
|
Assets button to upload
|
||||||
|
</div>
|
||||||
|
<q-tree
|
||||||
|
v-else
|
||||||
|
ref="qtree"
|
||||||
|
:nodes="tree"
|
||||||
|
v-model:selected="selected"
|
||||||
|
node-key="path"
|
||||||
|
label-key="name"
|
||||||
|
dense
|
||||||
|
default-expand-all
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-if="imageType === 'asset'">
|
||||||
|
<q-input
|
||||||
|
v-model="output"
|
||||||
|
label="Selected"
|
||||||
|
readonly
|
||||||
|
dense
|
||||||
|
class="q-pb-sm"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
@click="onDialogOK(output)"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Select"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from "vue";
|
||||||
|
import { type QTree, type QTreeNode, useDialogPluginComponent } from "quasar";
|
||||||
|
import { fetchAllReportAssets } from "../api/reporting";
|
||||||
|
|
||||||
|
import { ReportTemplateType } from "../types/reporting";
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{ templateType: ReportTemplateType }>();
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const tree = ref([] as QTreeNode<unknown>[]);
|
||||||
|
|
||||||
|
const imageType = ref("link");
|
||||||
|
const linkText = ref("");
|
||||||
|
const linkUrl = ref("");
|
||||||
|
const selected = ref("");
|
||||||
|
const output = ref("");
|
||||||
|
const qtree = ref<InstanceType<typeof QTree> | null>(null);
|
||||||
|
|
||||||
|
function formatImageLink(url: string, text: string) {
|
||||||
|
if (props.templateType === "markdown") {
|
||||||
|
return ``;
|
||||||
|
} else {
|
||||||
|
return `<img src="${url}" alt="${text}">`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([linkText, linkUrl, selected], ([newText, newLink, newSelected]) => {
|
||||||
|
if (imageType.value === "link")
|
||||||
|
output.value = formatImageLink(newLink, newText);
|
||||||
|
else if (imageType.value === "asset") {
|
||||||
|
if (newSelected) {
|
||||||
|
const asset: QTreeNode<unknown> = qtree.value?.getNodeByKey(newSelected);
|
||||||
|
output.value = formatImageLink(`asset://${asset.id}`, asset.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(imageType, () => {
|
||||||
|
output.value = "";
|
||||||
|
linkText.value = "";
|
||||||
|
linkUrl.value = "";
|
||||||
|
selected.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getAssets() {
|
||||||
|
tree.value = await fetchAllReportAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getAssets);
|
||||||
|
</script>
|
||||||
340
src/ee/reporting/components/ReportAssets.vue
Normal file
340
src/ee/reporting/components/ReportAssets.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" maximized @hide="onDialogHide">
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
Report Assets
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<FileBrowser
|
||||||
|
ref="fileBrowser"
|
||||||
|
:nodes="nodes"
|
||||||
|
:height="`${$q.screen.height - 32}px`"
|
||||||
|
:loading="isLoading"
|
||||||
|
@lazy-load="loadAssets"
|
||||||
|
>
|
||||||
|
<template #action-bar="{ selectedTreeNode, selectedTableNodes }">
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
icon="add"
|
||||||
|
label="Upload"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="uploadFiles(selectedTreeNode)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
label="New Folder"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="newFolder(selectedTreeNode)"
|
||||||
|
/>
|
||||||
|
<q-btn-dropdown
|
||||||
|
:disable="selectedTableNodes.length === 0"
|
||||||
|
class="q-ml-sm"
|
||||||
|
flat
|
||||||
|
outline
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
label="Bulk Actions"
|
||||||
|
>
|
||||||
|
<q-list>
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
dense
|
||||||
|
@click="deleteFiles(selectedTableNodes, selectedTreeNode)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="delete" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Delete</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #table-menu="{ item, selectedTreeNode }">
|
||||||
|
<q-menu context-menu>
|
||||||
|
<q-list dense style="min-width: 200px">
|
||||||
|
<q-item v-close-popup clickable @click="sendRename(item)">
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="edit" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Rename</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-close-popup clickable @click="downloadFile(item)">
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="cloud_download" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Download</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="deleteFiles([item], selectedTreeNode)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="delete" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Delete</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-item v-close-popup clickable>
|
||||||
|
<q-item-section>Close</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</template>
|
||||||
|
</FileBrowser>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// composition imports
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useFileBrowser } from "@/composables/filebrowser";
|
||||||
|
import {
|
||||||
|
fetchReportAssets,
|
||||||
|
renameReportAsset,
|
||||||
|
createAssetFolder,
|
||||||
|
deleteAssets,
|
||||||
|
downloadAsset,
|
||||||
|
} from "../api/reporting";
|
||||||
|
import { useQuasar, useDialogPluginComponent, exportFile } from "quasar";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import FileBrowser from "@/components/FileBrowser.vue";
|
||||||
|
import AssetFileUpload from "./AssetFileUpload.vue";
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import type {
|
||||||
|
LazyLoadCallbackParams,
|
||||||
|
FileSystemNodeTable,
|
||||||
|
QTreeFileNode,
|
||||||
|
} from "@/types/filebrowser";
|
||||||
|
import { UploadAssetsResponse } from "../types/reporting";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// setup quasar
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide /* onDialogOK */ } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
// setup filebrowser
|
||||||
|
const { createFileNode, createFolderNode, getFile } = useFileBrowser();
|
||||||
|
|
||||||
|
// data
|
||||||
|
const nodes = ref([
|
||||||
|
createFolderNode("Assets", "/", "storage", "primary"),
|
||||||
|
] as QTreeFileNode[]);
|
||||||
|
const fileBrowser = ref<InstanceType<typeof FileBrowser> | null>(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
async function loadAssets({ path, isDone, isFail }: LazyLoadCallbackParams) {
|
||||||
|
try {
|
||||||
|
const result = await fetchReportAssets(path);
|
||||||
|
isDone(parseNode(result));
|
||||||
|
} catch (e) {
|
||||||
|
isFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFiles(node: QTreeFileNode) {
|
||||||
|
$q.dialog({
|
||||||
|
component: AssetFileUpload,
|
||||||
|
componentProps: {
|
||||||
|
parentPath: node.path,
|
||||||
|
},
|
||||||
|
}).onOk(
|
||||||
|
({
|
||||||
|
files,
|
||||||
|
response,
|
||||||
|
}: {
|
||||||
|
files: File[];
|
||||||
|
response: UploadAssetsResponse;
|
||||||
|
}) => {
|
||||||
|
// the upload view returns an object with the old filename as the key and the
|
||||||
|
// new filename as the value in case there are name conflicts
|
||||||
|
files.forEach((file) => {
|
||||||
|
const path = response[file.name].filename;
|
||||||
|
const asset_id = response[file.name].id;
|
||||||
|
const name = getFile(path);
|
||||||
|
const fileNode = createFileNode(
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
file.size.toString(),
|
||||||
|
asset_id
|
||||||
|
);
|
||||||
|
node.children?.push(fileNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileBrowser.value?.reloadTable();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newFolder(node: QTreeFileNode) {
|
||||||
|
$q.dialog({
|
||||||
|
title: "Enter a folder name",
|
||||||
|
prompt: {
|
||||||
|
model: "",
|
||||||
|
isValid: (val) => val.length > 0,
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(async (data: string) => {
|
||||||
|
isLoading.value = true;
|
||||||
|
const folderName = data;
|
||||||
|
const folderPath = `${node.path}/${folderName}`;
|
||||||
|
try {
|
||||||
|
const newPath = await createAssetFolder(folderPath);
|
||||||
|
|
||||||
|
const folderNode = createFolderNode(getFile(newPath), newPath);
|
||||||
|
node.children?.push(folderNode);
|
||||||
|
|
||||||
|
fileBrowser.value?.reloadTable();
|
||||||
|
isLoading.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRename(node: FileSystemNodeTable) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Enter a new ${node.type} name`,
|
||||||
|
prompt: {
|
||||||
|
model: node.name,
|
||||||
|
isValid: (val) => val.length > 0,
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(async (data: string) => {
|
||||||
|
isLoading.value = true;
|
||||||
|
const oldPath = node.path;
|
||||||
|
const newName = data;
|
||||||
|
try {
|
||||||
|
const newPath = await renameReportAsset(oldPath, newName);
|
||||||
|
|
||||||
|
const treeNode = fileBrowser.value?.getNodeByKey(node.id);
|
||||||
|
|
||||||
|
if (treeNode === undefined) {
|
||||||
|
console.error("Node key not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
treeNode.label = getFile(newPath);
|
||||||
|
treeNode.path = newPath;
|
||||||
|
|
||||||
|
if (treeNode.type === "folder" && treeNode.children) {
|
||||||
|
updatePathOnChildNodes(treeNode.children, oldPath, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileBrowser.value?.reloadTable();
|
||||||
|
isLoading.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(node: FileSystemNodeTable) {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await downloadAsset(node.path);
|
||||||
|
if (result.type === "application/zip")
|
||||||
|
exportFile(`${node.name}.zip`, result);
|
||||||
|
else exportFile(node.name, result);
|
||||||
|
isLoading.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFiles(
|
||||||
|
nodes: FileSystemNodeTable[],
|
||||||
|
selectedTreeNode: QTreeFileNode
|
||||||
|
) {
|
||||||
|
$q.dialog({
|
||||||
|
title: "Are you sure?",
|
||||||
|
message: `You are about to delete ${
|
||||||
|
nodes.length > 1 ? nodes.length + " assets" : "an asset"
|
||||||
|
}. This action isn't reversible`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
const paths = nodes.map((node) => node.path);
|
||||||
|
await deleteAssets(paths);
|
||||||
|
|
||||||
|
selectedTreeNode.children = selectedTreeNode.children?.filter(
|
||||||
|
(node) => !paths.includes(node.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
fileBrowser.value?.reloadTable();
|
||||||
|
|
||||||
|
isLoading.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// recursive function to update path on child nodes
|
||||||
|
function updatePathOnChildNodes(
|
||||||
|
nodes: QTreeFileNode[],
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string
|
||||||
|
) {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
node.path = node.path.replace(oldPath, newPath);
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
updatePathOnChildNodes(node.children, oldPath, newPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// recursive function to parse file system output into Quasar tree nodes
|
||||||
|
function parseNode(nodes: QTreeFileNode[]): QTreeFileNode[] {
|
||||||
|
let parsedNodes: QTreeFileNode[] = [];
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
let tempNode: QTreeFileNode =
|
||||||
|
node.type === "folder"
|
||||||
|
? createFolderNode(node.name, node.path)
|
||||||
|
: createFileNode(node.name, node.path, node.size, node.asset_id);
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const parsedNode = parseNode(node.children);
|
||||||
|
if (tempNode.children) tempNode.children = parsedNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedNodes.push(tempNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsedNodes;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
121
src/ee/reporting/components/ReportChartSelect.vue
Normal file
121
src/ee/reporting/components/ReportChartSelect.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="unloadEditor" @show="loadEditor">
|
||||||
|
<q-card style="width: 600px">
|
||||||
|
<q-bar>
|
||||||
|
Add Chart
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input v-model="chartName" outlined dense label="Chart Name" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-select
|
||||||
|
v-model="chartType"
|
||||||
|
:options="chartOptions"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
label="Chart Type"
|
||||||
|
map-options
|
||||||
|
emit-value
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-option-group
|
||||||
|
v-model="outputType"
|
||||||
|
:options="outputOptions"
|
||||||
|
dense
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div
|
||||||
|
ref="chartEditor"
|
||||||
|
:style="{ height: `${$q.screen.height / 2}px` }"
|
||||||
|
></div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn @click="submit" dense flat label="Select" color="primary" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useDialogPluginComponent, useQuasar } from "quasar";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
|
|
||||||
|
// setup quasar
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const chartOptions = [
|
||||||
|
{ value: "bar", label: "Bar" },
|
||||||
|
{ value: "pie", label: "Pie" },
|
||||||
|
{ value: "line", label: "Line" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const outputOptions = [
|
||||||
|
{ value: "image", label: "Image" },
|
||||||
|
{ value: "html", label: "Html" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartName = ref("");
|
||||||
|
const chartType = ref("bar");
|
||||||
|
const outputType = ref("image");
|
||||||
|
const options = ref("");
|
||||||
|
|
||||||
|
const output = computed(() => ({
|
||||||
|
name: chartName.value,
|
||||||
|
chartType: chartType.value,
|
||||||
|
outputType: outputType.value,
|
||||||
|
options: options.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
onDialogOK(output.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartEditor = ref<HTMLElement | null>(null);
|
||||||
|
let editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
|
||||||
|
function loadEditor() {
|
||||||
|
var modelUri = monaco.Uri.parse("model://new"); // a made up unique URI for our model
|
||||||
|
var model = monaco.editor.createModel(options.value, "yaml", modelUri);
|
||||||
|
|
||||||
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
editor = monaco.editor.create(chartEditor.value!, {
|
||||||
|
model: model,
|
||||||
|
theme: theme,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
options.value = editor.getValue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadEditor() {
|
||||||
|
editor.getModel()?.dispose();
|
||||||
|
editor.dispose();
|
||||||
|
onDialogHide();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
151
src/ee/reporting/components/ReportDataQueryForm.vue
Normal file
151
src/ee/reporting/components/ReportDataQueryForm.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
maximized
|
||||||
|
@hide="onDialogHide"
|
||||||
|
@show="loadEditor"
|
||||||
|
@before-hide="cleanupEditors"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
{{ props.dataQuery ? "Edit Data Query" : "New Data Query" }}
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-input
|
||||||
|
v-model="state.name"
|
||||||
|
label="Data Query Name"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
style="width: 400px"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
</q-toolbar>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="queryEditor"
|
||||||
|
:style="{ height: `${$q.screen.height - 126}px` }"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-close-popup dense flat label="Cancel" />
|
||||||
|
<q-btn
|
||||||
|
:loading="isLoading"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Save"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { useDialogPluginComponent, extend, useQuasar } from "quasar";
|
||||||
|
import { useSharedReportDataQueries } from "../api/reporting";
|
||||||
|
import { until } from "@vueuse/shared";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import { type ReportDataQuery } from "../types/reporting";
|
||||||
|
import { notifyError } from "@/utils/notify";
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
dataQuery?: ReportDataQuery;
|
||||||
|
editInTemplate?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
// new data query logic
|
||||||
|
const state: ReportDataQuery = props.dataQuery
|
||||||
|
? reactive(extend({}, props.dataQuery))
|
||||||
|
: reactive({
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
json_query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const json_string = ref(JSON.stringify(state.json_query, null, 4));
|
||||||
|
|
||||||
|
const { isLoading, isError, addReportDataQuery, editReportDataQuery } =
|
||||||
|
useSharedReportDataQueries;
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
state.json_query = JSON.parse(json_string.value);
|
||||||
|
} catch (e) {
|
||||||
|
notifyError(`There was an error parsing the json: ${e}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.editInTemplate) {
|
||||||
|
props.dataQuery
|
||||||
|
? editReportDataQuery(state.id, state)
|
||||||
|
: addReportDataQuery(state);
|
||||||
|
|
||||||
|
await until(isLoading).not.toBeTruthy();
|
||||||
|
if (isError.value) return;
|
||||||
|
}
|
||||||
|
onDialogOK(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryEditor = ref<HTMLElement | null>(null);
|
||||||
|
let editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
|
||||||
|
async function loadEditor() {
|
||||||
|
const r = await axios.get("/reporting/queryschema/");
|
||||||
|
|
||||||
|
var modelUri = monaco.Uri.parse("model://new"); // a made up unique URI for our model
|
||||||
|
var model = monaco.editor.createModel(json_string.value, "json", modelUri);
|
||||||
|
|
||||||
|
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||||
|
validate: true,
|
||||||
|
schemas: [
|
||||||
|
{
|
||||||
|
uri: "schema://model-schema",
|
||||||
|
fileMatch: [modelUri.toString()],
|
||||||
|
schema: r.data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
editor = monaco.editor.create(queryEditor.value!, {
|
||||||
|
model: model,
|
||||||
|
theme: theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
json_string.value = editor.getValue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupEditors() {
|
||||||
|
editor.getModel()?.dispose();
|
||||||
|
editor.dispose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
193
src/ee/reporting/components/ReportDataQueryTable.vue
Normal file
193
src/ee/reporting/components/ReportDataQueryTable.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" maximized @hide="onDialogHide">
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
<q-btn
|
||||||
|
class="q-mr-sm"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
push
|
||||||
|
icon="refresh"
|
||||||
|
@click="getReportDataQueries"
|
||||||
|
/>Data Queries
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
:table-class="{
|
||||||
|
'table-bgcolor': !$q.dark.isActive,
|
||||||
|
'table-bgcolor-dark': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
|
||||||
|
class="tbl-sticky"
|
||||||
|
:rows="reportDataQueries"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="isLoading"
|
||||||
|
:pagination="{ rowsPerPage: 0, sortBy: 'favorite', descending: true }"
|
||||||
|
:filter="search"
|
||||||
|
row-key="id"
|
||||||
|
binary-state-sort
|
||||||
|
virtual-scroll
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
icon="add"
|
||||||
|
label="New"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="openNewDataQueryForm"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
<q-input
|
||||||
|
v-model="search"
|
||||||
|
style="width: 300px"
|
||||||
|
label="Search"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
class="q-pr-md q-pb-xs"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="search" color="primary" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr
|
||||||
|
:props="props"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@dblclick="openEditDataQuery(props.row)"
|
||||||
|
>
|
||||||
|
<!-- Context Menu -->
|
||||||
|
<q-menu context-menu>
|
||||||
|
<q-list dense style="min-width: 200px">
|
||||||
|
<q-item v-close-popup clickable @click="cloneQuery(props.row)">
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="content_copy" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Clone</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="openEditDataQuery(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="edit" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Edit</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="deleteDataQuery(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="delete" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Delete</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-item v-close-popup clickable>
|
||||||
|
<q-item-section>Close</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
|
||||||
|
<!-- rows -->
|
||||||
|
<td>{{ props.row.name }}</td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useQuasar, useDialogPluginComponent, type QTableColumn } from "quasar";
|
||||||
|
import { useSharedReportDataQueries } from "../api/reporting";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import ReportDataQueryForm from "./ReportDataQueryForm.vue";
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import type { ReportDataQuery } from "../types/reporting";
|
||||||
|
|
||||||
|
const columns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
field: "name",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// reports manager logic
|
||||||
|
const {
|
||||||
|
reportDataQueries,
|
||||||
|
isLoading,
|
||||||
|
getReportDataQueries,
|
||||||
|
deleteReportDataQuery,
|
||||||
|
} = useSharedReportDataQueries;
|
||||||
|
const search = ref("");
|
||||||
|
|
||||||
|
function openNewDataQueryForm() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportDataQueryForm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDataQuery(dataQuery: ReportDataQuery) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportDataQueryForm,
|
||||||
|
componentProps: {
|
||||||
|
dataQuery,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDataQuery(dataQuery: ReportDataQuery) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Delete Data Query: ${dataQuery.name}?`,
|
||||||
|
message:
|
||||||
|
"If this query is in use you will need to change it in every report template",
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(() => {
|
||||||
|
deleteReportDataQuery(dataQuery.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneQuery(dataQuery: ReportDataQuery) {
|
||||||
|
// TODO: fill out function
|
||||||
|
console.log(dataQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getReportDataQueries);
|
||||||
|
</script>
|
||||||
133
src/ee/reporting/components/ReportDependencyPrompt.vue
Normal file
133
src/ee/reporting/components/ReportDependencyPrompt.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card style="width: 400px">
|
||||||
|
<q-bar>
|
||||||
|
Select Report Dependencies
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<q-card-section v-for="(_, label) in dependencies" :key="label">
|
||||||
|
<tactical-dropdown
|
||||||
|
v-if="label === 'client'"
|
||||||
|
v-model="dependencies[label]"
|
||||||
|
:label="`${capitalize(label)}`"
|
||||||
|
:options="clientOptions"
|
||||||
|
outlined
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<tactical-dropdown
|
||||||
|
v-else-if="label === 'site'"
|
||||||
|
v-model="dependencies[label]"
|
||||||
|
:label="`${capitalize(label)}`"
|
||||||
|
:options="siteOptions"
|
||||||
|
outlined
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<tactical-dropdown
|
||||||
|
v-else-if="label === 'agent'"
|
||||||
|
v-model="dependencies[label]"
|
||||||
|
:label="`${capitalize(label)}`"
|
||||||
|
:options="agentOptions"
|
||||||
|
outlined
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-else
|
||||||
|
v-model="dependencies[label]"
|
||||||
|
:label="`${capitalize(label)}`"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-close-popup dense flat label="Cancel" />
|
||||||
|
<q-btn
|
||||||
|
:loading="loading"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Submit"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onBeforeMount } from "vue";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { notifyError } from "@/utils/notify";
|
||||||
|
import { capitalize } from "@/utils/format";
|
||||||
|
import { useAgentDropdown } from "@/composables/agents";
|
||||||
|
import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
dependsOn: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
// setup dropdown options
|
||||||
|
const { agentOptions, getAgentOptions } = useAgentDropdown();
|
||||||
|
const { clientOptions, getClientOptions } = useClientDropdown();
|
||||||
|
const { siteOptions, getSiteOptions } = useSiteDropdown();
|
||||||
|
|
||||||
|
// logic
|
||||||
|
const dependencies = reactive<{ [x: string]: string | number | null }>({});
|
||||||
|
props.dependsOn.forEach((dep) => (dependencies[dep] = null));
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
let valid = true;
|
||||||
|
props.dependsOn.forEach((dep) => {
|
||||||
|
if (!dependencies[dep]) valid = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (validate()) onDialogOK(dependencies);
|
||||||
|
else notifyError("All fields must have a value");
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
if (props.dependsOn.includes("client")) {
|
||||||
|
getClientOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.dependsOn.includes("site")) {
|
||||||
|
getSiteOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.dependsOn.includes("agent")) {
|
||||||
|
getAgentOptions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
136
src/ee/reporting/components/ReportHTMLTemplateForm.vue
Normal file
136
src/ee/reporting/components/ReportHTMLTemplateForm.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
maximized
|
||||||
|
@hide="onDialogHide"
|
||||||
|
@show="loadEditor"
|
||||||
|
@before-hide="cleanupEditors"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
New Base Template
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-input
|
||||||
|
v-model="state.name"
|
||||||
|
label="HTML Template Name"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
style="width: 400px"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
</q-toolbar>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="htmlEditor"
|
||||||
|
:style="{ height: `${$q.screen.height - 126}px` }"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-close-popup dense flat label="Cancel" />
|
||||||
|
<q-btn
|
||||||
|
:loading="isLoading"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Save"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { useDialogPluginComponent, extend, useQuasar } from "quasar";
|
||||||
|
import { useSharedReportHTMLTemplates } from "../api/reporting";
|
||||||
|
import { until } from "@vueuse/shared";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import { type ReportHTMLTemplate } from "../types/reporting";
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
template?: ReportHTMLTemplate;
|
||||||
|
cloneTemplate?: ReportHTMLTemplate;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const defaultTemplate = `<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
{{ css }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
\{% block content %\}\{% endblock %\}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
// new html template logic
|
||||||
|
const state: ReportHTMLTemplate = props.template
|
||||||
|
? reactive(extend({}, props.template))
|
||||||
|
: reactive({
|
||||||
|
id: 0,
|
||||||
|
name: props.cloneTemplate ? `Copy of ${props.cloneTemplate.name}` : "",
|
||||||
|
html: props.cloneTemplate ? props.cloneTemplate.html : defaultTemplate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isLoading, isError, addReportHTMLTemplate, editReportHTMLTemplate } =
|
||||||
|
useSharedReportHTMLTemplates;
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
props.template
|
||||||
|
? editReportHTMLTemplate(state.id, state)
|
||||||
|
: addReportHTMLTemplate(state);
|
||||||
|
|
||||||
|
// stops the dialog from closing when there is an error
|
||||||
|
await until(isLoading).not.toBeTruthy();
|
||||||
|
if (isError.value) return;
|
||||||
|
|
||||||
|
onDialogOK();
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlEditor = ref<HTMLElement | null>(null);
|
||||||
|
let editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
|
||||||
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
|
|
||||||
|
function loadEditor() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
editor = monaco.editor.create(htmlEditor.value!, {
|
||||||
|
language: "html",
|
||||||
|
value: state.html,
|
||||||
|
theme: theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
state.html = editor.getValue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupEditors() {
|
||||||
|
editor.dispose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
201
src/ee/reporting/components/ReportHTMLTemplateTable.vue
Normal file
201
src/ee/reporting/components/ReportHTMLTemplateTable.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" maximized @hide="onDialogHide">
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
<q-btn
|
||||||
|
class="q-mr-sm"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
push
|
||||||
|
icon="refresh"
|
||||||
|
@click="getReportHTMLTemplates"
|
||||||
|
/>Base Templates
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
:table-class="{
|
||||||
|
'table-bgcolor': !$q.dark.isActive,
|
||||||
|
'table-bgcolor-dark': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
|
||||||
|
class="tbl-sticky"
|
||||||
|
:rows="reportHTMLTemplates"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="isLoading"
|
||||||
|
:pagination="{ rowsPerPage: 0, sortBy: 'favorite', descending: true }"
|
||||||
|
:filter="search"
|
||||||
|
row-key="id"
|
||||||
|
binary-state-sort
|
||||||
|
virtual-scroll
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
icon="add"
|
||||||
|
label="New"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="openNewHTMLTemplateForm"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
<q-input
|
||||||
|
v-model="search"
|
||||||
|
style="width: 300px"
|
||||||
|
label="Search"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
class="q-pr-md q-pb-xs"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="search" color="primary" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr
|
||||||
|
:props="props"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@dblclick="openEditHTMLTemplate(props.row)"
|
||||||
|
>
|
||||||
|
<!-- Context Menu -->
|
||||||
|
<q-menu context-menu>
|
||||||
|
<q-list dense style="min-width: 200px">
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="openEditHTMLTemplate(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="edit" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Edit</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="cloneHTMLTemplate(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="content_copy" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Clone</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="deleteHTMLTemplate(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="delete" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Delete</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-item v-close-popup clickable>
|
||||||
|
<q-item-section>Close</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
|
||||||
|
<!-- rows -->
|
||||||
|
<td>{{ props.row.name }}</td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useQuasar, useDialogPluginComponent, type QTableColumn } from "quasar";
|
||||||
|
import { useSharedReportHTMLTemplates } from "../api/reporting";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import ReportHTMLTemplateForm from "./ReportHTMLTemplateForm.vue";
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import type { ReportHTMLTemplate } from "../types/reporting";
|
||||||
|
|
||||||
|
const columns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
field: "name",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// reports manager logic
|
||||||
|
const {
|
||||||
|
reportHTMLTemplates,
|
||||||
|
isLoading,
|
||||||
|
getReportHTMLTemplates,
|
||||||
|
deleteReportHTMLTemplate,
|
||||||
|
} = useSharedReportHTMLTemplates;
|
||||||
|
const search = ref("");
|
||||||
|
|
||||||
|
function openNewHTMLTemplateForm() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportHTMLTemplateForm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditHTMLTemplate(template: ReportHTMLTemplate) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportHTMLTemplateForm,
|
||||||
|
componentProps: {
|
||||||
|
template,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteHTMLTemplate(template: ReportHTMLTemplate) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Delete HTML Template: ${template.name}?`,
|
||||||
|
message:
|
||||||
|
"If this template is in use you will need to change it in every report template",
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(() => {
|
||||||
|
deleteReportHTMLTemplate(template.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneHTMLTemplate(template: ReportHTMLTemplate) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportHTMLTemplateForm,
|
||||||
|
componentProps: {
|
||||||
|
cloneTemplate: template,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getReportHTMLTemplates);
|
||||||
|
</script>
|
||||||
159
src/ee/reporting/components/ReportTableMaker.vue
Normal file
159
src/ee/reporting/components/ReportTableMaker.vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card style="width: 80vw">
|
||||||
|
<q-bar>
|
||||||
|
Insert Table
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-card-section>
|
||||||
|
<q-option-group
|
||||||
|
v-model="tableType"
|
||||||
|
:options="tableTypeOptions"
|
||||||
|
dense
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-if="tableType === 'variables'">
|
||||||
|
<q-select
|
||||||
|
v-model="source"
|
||||||
|
:options="arrayOptions"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
label="Data Source"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section style="max-height: 60vh" class="scroll">
|
||||||
|
<q-input v-model="output" filled type="textarea" autogrow />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-close-popup dense flat label="Cancel" />
|
||||||
|
<q-btn dense flat label="Insert" color="primary" @click="insert" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { useSharedReportTemplates } from "../api/reporting";
|
||||||
|
import { capitalize } from "@/utils/format";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const { variableAnalysis } = useSharedReportTemplates;
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const tableTypeOptions = [
|
||||||
|
{ value: "blank", label: "Blank" },
|
||||||
|
{ value: "variables", label: "From Variables" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const blankOutput = `<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
const tableType = ref<"blank" | "variables">("blank");
|
||||||
|
const source = ref("");
|
||||||
|
const output = ref(blankOutput);
|
||||||
|
|
||||||
|
// watch for source change and get list of columns
|
||||||
|
watch(source, (newSource) => {
|
||||||
|
let columns = [] as string[];
|
||||||
|
for (let key in variableAnalysis.value)
|
||||||
|
if (
|
||||||
|
variableAnalysis.value[key] !== "Object" &&
|
||||||
|
key.startsWith(newSource + "[0]")
|
||||||
|
)
|
||||||
|
columns.push(key.replace(newSource + "[0].", ""));
|
||||||
|
|
||||||
|
generateTable(columns);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(tableType, (newValue) => {
|
||||||
|
if (newValue === "blank") output.value = blankOutput;
|
||||||
|
});
|
||||||
|
|
||||||
|
// compute the arrayOptions
|
||||||
|
const arrayOptions = computed(() => {
|
||||||
|
let options = [];
|
||||||
|
for (let key in variableAnalysis.value)
|
||||||
|
if (variableAnalysis.value[key].toLowerCase().startsWith("array"))
|
||||||
|
options.push(key);
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
function capitalizeHeader(header: string) {
|
||||||
|
let words = header.split("__");
|
||||||
|
|
||||||
|
// get the last two words
|
||||||
|
if (words.length > 1) {
|
||||||
|
words = words.slice(-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnName = words.join("_");
|
||||||
|
|
||||||
|
return columnName
|
||||||
|
.split("_")
|
||||||
|
.map((word) => capitalize(word))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTable(columns: string[]) {
|
||||||
|
let headers = "";
|
||||||
|
let cells = "";
|
||||||
|
columns.forEach((column) => {
|
||||||
|
headers += `\t<th>${capitalizeHeader(column)}</th>\n`;
|
||||||
|
cells += `\t<td>{{ item.${column} }}</td>\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!headers) {
|
||||||
|
headers = "\t<th>Column Name</th>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cells) {
|
||||||
|
cells = "\t<td>{{ item }}</td>";
|
||||||
|
}
|
||||||
|
|
||||||
|
output.value = `<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
${headers}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in ${source.value} %}
|
||||||
|
<tr>
|
||||||
|
${cells}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insert() {
|
||||||
|
onDialogOK(output.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
734
src/ee/reporting/components/ReportTemplateForm.vue
Normal file
734
src/ee/reporting/components/ReportTemplateForm.vue
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
maximized
|
||||||
|
@hide="onDialogHide"
|
||||||
|
@show="initializeEditor"
|
||||||
|
@before-hide="cleanupEditors"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
New Report Template
|
||||||
|
<!-- <q-btn
|
||||||
|
icon="help"
|
||||||
|
round
|
||||||
|
flat
|
||||||
|
color="info"
|
||||||
|
@click="showHelp = !showHelp"
|
||||||
|
/> -->
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat icon="close" @click="openClosePrompt">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-input
|
||||||
|
v-model="state.name"
|
||||||
|
label="Report Name"
|
||||||
|
class="q-pr-sm"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
style="width: 425px"
|
||||||
|
:error="!isNameValid"
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
<q-select
|
||||||
|
v-model="state.template_html"
|
||||||
|
style="width: 250px"
|
||||||
|
class="q-pr-sm"
|
||||||
|
:options="HTMLTemplateOptions"
|
||||||
|
label="Base Templates"
|
||||||
|
map-options
|
||||||
|
emit-value
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-model="state.depends_on"
|
||||||
|
style="width: 250px"
|
||||||
|
class="q-pr-sm"
|
||||||
|
:options="dependsOnFilterOptions"
|
||||||
|
label="Template Dependencies"
|
||||||
|
multiple
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
use-input
|
||||||
|
input-debounce="0"
|
||||||
|
@new-value="createValue"
|
||||||
|
@filter="filterFn"
|
||||||
|
>
|
||||||
|
<template v-slot:selected>
|
||||||
|
<span v-if="state.depends_on && state.depends_on?.length > 0"
|
||||||
|
>{{ state.depends_on?.length }} Selected</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</q-select>
|
||||||
|
|
||||||
|
<q-option-group
|
||||||
|
v-model="previewFormat"
|
||||||
|
:options="formatOptions"
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
:disable="debug"
|
||||||
|
/>
|
||||||
|
<q-toggle v-model="debug" dense label="Debug" class="q-pl-sm" />
|
||||||
|
<q-space />
|
||||||
|
|
||||||
|
<q-tabs v-model="tab" dense shrink>
|
||||||
|
<q-tab
|
||||||
|
v-if="templateType === 'markdown'"
|
||||||
|
name="markdown"
|
||||||
|
label="Markdown"
|
||||||
|
:ripple="false"
|
||||||
|
/>
|
||||||
|
<q-tab
|
||||||
|
v-else-if="templateType === 'html'"
|
||||||
|
name="html"
|
||||||
|
label="Html"
|
||||||
|
:ripple="false"
|
||||||
|
/>
|
||||||
|
<q-tab v-else name="plaintext" label="Plain Text" :ripple="false" />
|
||||||
|
<q-tab
|
||||||
|
v-if="templateType !== 'plaintext'"
|
||||||
|
name="css"
|
||||||
|
label="CSS"
|
||||||
|
:ripple="false"
|
||||||
|
/>
|
||||||
|
<q-tab name="preview" label="Preview" :ripple="false" />
|
||||||
|
</q-tabs>
|
||||||
|
</q-toolbar>
|
||||||
|
|
||||||
|
<!-- main editor -->
|
||||||
|
<div v-show="tab !== 'preview'" class="q-px-sm">
|
||||||
|
<q-layout
|
||||||
|
view="lHh lpR lFf"
|
||||||
|
:style="{ height: `${$q.screen.height - 132}px` }"
|
||||||
|
container
|
||||||
|
>
|
||||||
|
<q-drawer
|
||||||
|
v-model="showVariablesDrawer"
|
||||||
|
:mini="drawerMiniState"
|
||||||
|
side="left"
|
||||||
|
bordered
|
||||||
|
:width="500"
|
||||||
|
:mini-width="40"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
icon="chevron_left"
|
||||||
|
color="dark"
|
||||||
|
class="absolute"
|
||||||
|
style="top: 15px; right: -17px"
|
||||||
|
@click="drawerMiniState = true"
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
<template v-slot:mini>
|
||||||
|
<div class="q-pt-sm">
|
||||||
|
<q-btn
|
||||||
|
class=""
|
||||||
|
icon="chevron_right"
|
||||||
|
color="dark"
|
||||||
|
@click="drawerMiniState = false"
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<VariablesSelector
|
||||||
|
:variables="state.template_variables"
|
||||||
|
:template="state.template_md"
|
||||||
|
:dependencies="dependencies"
|
||||||
|
:dependsOn="state.depends_on"
|
||||||
|
:base_template="state.template_html"
|
||||||
|
/>
|
||||||
|
</q-drawer>
|
||||||
|
|
||||||
|
<!-- <q-drawer
|
||||||
|
v-model="showHelp"
|
||||||
|
side="right"
|
||||||
|
:width="600"
|
||||||
|
overlay
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<ReportingHelpMenu section="template" />
|
||||||
|
</q-drawer> -->
|
||||||
|
|
||||||
|
<q-page-container>
|
||||||
|
<q-splitter
|
||||||
|
v-model="splitter"
|
||||||
|
emit-immediately
|
||||||
|
reverse
|
||||||
|
:limits="[3, 45]"
|
||||||
|
>
|
||||||
|
<template v-slot:before>
|
||||||
|
<EditorToolbar
|
||||||
|
v-if="
|
||||||
|
tab !== 'preview' &&
|
||||||
|
tab !== 'css' &&
|
||||||
|
editor &&
|
||||||
|
variablesEditor
|
||||||
|
"
|
||||||
|
:editor="editor"
|
||||||
|
:variablesEditor="variablesEditor"
|
||||||
|
:templateType="templateType"
|
||||||
|
>
|
||||||
|
<template v-slot:buttons>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
label="vars"
|
||||||
|
no-caps
|
||||||
|
@click="splitter > 3 ? (splitter = 3) : (splitter = 35)"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">{{
|
||||||
|
splitter >= 3 ? "Hide variables" : "Show variables"
|
||||||
|
}}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:ripple="false"
|
||||||
|
label="base"
|
||||||
|
no-caps
|
||||||
|
@click="openBaseTemplateForm"
|
||||||
|
>
|
||||||
|
<q-tooltip :delay="500">Add Base Template</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
</EditorToolbar>
|
||||||
|
<div
|
||||||
|
ref="editorDiv"
|
||||||
|
:style="{ height: `${$q.screen.height - 168}px` }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-bar>
|
||||||
|
<q-btn
|
||||||
|
v-if="splitter > 6"
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="chevron_right"
|
||||||
|
@click="splitter = 3"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="chevron_left"
|
||||||
|
@click="splitter = 35"
|
||||||
|
></q-btn>
|
||||||
|
|
||||||
|
<div v-if="splitter > 8" class="q-pl-xs text-subtitle">
|
||||||
|
Variables
|
||||||
|
</div>
|
||||||
|
</q-bar>
|
||||||
|
<div
|
||||||
|
ref="variablesDiv"
|
||||||
|
v-show="splitter > 8"
|
||||||
|
:style="{ height: `${$q.screen.height - 168}px` }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</q-splitter>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</div>
|
||||||
|
<!-- preview -->
|
||||||
|
<ReportTemplatePreview
|
||||||
|
v-if="tab == 'preview' && !isLoading"
|
||||||
|
:previewFormat="previewFormat"
|
||||||
|
:source="renderedPreview"
|
||||||
|
:debug="debug"
|
||||||
|
:variables="renderedVariables"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-inner-loading
|
||||||
|
v-if="tab == 'preview'"
|
||||||
|
:showing="isLoading"
|
||||||
|
label="Generating Report..."
|
||||||
|
label-class="text-teal"
|
||||||
|
label-style="font-size: 1.1em"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-card-actions v-if="tab !== 'preview'">
|
||||||
|
<q-toggle
|
||||||
|
v-if="reportTemplate"
|
||||||
|
v-model="autoSave"
|
||||||
|
label="Auto-save"
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
<span class="q-pl-sm" v-if="showSaved">Template Saved!</span>
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat label="Cancel" @click="openClosePrompt" />
|
||||||
|
<q-btn
|
||||||
|
v-if="reportTemplate"
|
||||||
|
:loading="isLoading"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Apply"
|
||||||
|
color="primary"
|
||||||
|
@click="applyChanges"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
:loading="isLoading"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Save"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, reactive, computed, watch, onBeforeMount, shallowRef } from "vue";
|
||||||
|
import { until, useDebounceFn, useTimeoutFn } from "@vueuse/shared";
|
||||||
|
import {
|
||||||
|
useQuasar,
|
||||||
|
useDialogPluginComponent,
|
||||||
|
extend,
|
||||||
|
type QSelectOption,
|
||||||
|
} from "quasar";
|
||||||
|
import {
|
||||||
|
useSharedReportTemplates,
|
||||||
|
useSharedReportHTMLTemplates,
|
||||||
|
} from "../api/reporting";
|
||||||
|
import { notifyError } from "@/utils/notify";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
|
import { parseDocument } from "yaml";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import EditorToolbar from "./EditorToolbar.vue";
|
||||||
|
import ReportTemplatePreview from "./ReportTemplatePreview.vue";
|
||||||
|
import ReportDependencyPrompt from "./ReportDependencyPrompt.vue";
|
||||||
|
import ReportHTMLTemplateForm from "./ReportHTMLTemplateForm.vue";
|
||||||
|
import VariablesSelector from "./VariablesSelector.vue";
|
||||||
|
//import ReportingHelpMenu from "./ReportingHelpMenu.vue";
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import type {
|
||||||
|
ReportTemplate,
|
||||||
|
ReportTemplateType,
|
||||||
|
ReportFormat,
|
||||||
|
ReportDependencies,
|
||||||
|
} from "../types/reporting";
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
templateType: ReportTemplateType;
|
||||||
|
reportTemplate?: ReportTemplate;
|
||||||
|
cloneTemplate?: ReportTemplate;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
// quasar setup
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// new report logic
|
||||||
|
const state: ReportTemplate = props.reportTemplate
|
||||||
|
? reactive(extend({}, props.reportTemplate))
|
||||||
|
: reactive({
|
||||||
|
id: 0,
|
||||||
|
name: props.cloneTemplate ? `Copy of ${props.cloneTemplate.name}` : "",
|
||||||
|
template_md: props.cloneTemplate ? props.cloneTemplate.template_md : "",
|
||||||
|
template_css: props.cloneTemplate ? props.cloneTemplate.template_css : "",
|
||||||
|
template_html: props.cloneTemplate
|
||||||
|
? props.cloneTemplate.template_html
|
||||||
|
: undefined,
|
||||||
|
type: props.templateType,
|
||||||
|
template_variables: props.cloneTemplate
|
||||||
|
? props.cloneTemplate.template_variables
|
||||||
|
: "",
|
||||||
|
depends_on: props.cloneTemplate ? props.cloneTemplate?.depends_on : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// are you sure? close prompt if work isn't saved
|
||||||
|
const edited = ref(false);
|
||||||
|
|
||||||
|
// watch variables and set the edited variable
|
||||||
|
watch(
|
||||||
|
state,
|
||||||
|
() => {
|
||||||
|
edited.value = true;
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function openClosePrompt() {
|
||||||
|
if (edited.value) {
|
||||||
|
$q.dialog({
|
||||||
|
title: "You have unsaved changes",
|
||||||
|
message: "Would you like to close?",
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(() => {
|
||||||
|
dialogRef.value?.hide();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dialogRef.value?.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// help menu
|
||||||
|
//const showHelp = ref(false);
|
||||||
|
|
||||||
|
// variables drawer menu state
|
||||||
|
const showVariablesDrawer = ref(true);
|
||||||
|
const drawerMiniState = ref(true);
|
||||||
|
|
||||||
|
// splitter
|
||||||
|
const splitter = ref(35);
|
||||||
|
|
||||||
|
const previewFormat = ref<ReportFormat>(
|
||||||
|
props.templateType === "html" || props.templateType === "markdown"
|
||||||
|
? "html"
|
||||||
|
: "plaintext",
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatOptions = [
|
||||||
|
{
|
||||||
|
label:
|
||||||
|
props.templateType === "html" || props.templateType === "markdown"
|
||||||
|
? "HTML"
|
||||||
|
: "Text",
|
||||||
|
value:
|
||||||
|
props.templateType === "html" || props.templateType === "markdown"
|
||||||
|
? "html"
|
||||||
|
: "plaintext",
|
||||||
|
},
|
||||||
|
{ label: "PDF", value: "pdf" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dependencies = ref<ReportDependencies>({});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => state.depends_on,
|
||||||
|
(newArray, oldArray) => {
|
||||||
|
if (newArray && oldArray) {
|
||||||
|
const removed = oldArray.filter((item) => newArray.indexOf(item) == -1);
|
||||||
|
removed.forEach((item) => delete dependencies.value[item]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// initial set of depends on options
|
||||||
|
const dependsOnOptions = ["client", "site", "agent"];
|
||||||
|
|
||||||
|
// will add any custom added depend_on options to the list
|
||||||
|
state.depends_on?.forEach((item) =>
|
||||||
|
!dependsOnOptions.includes(item) ? dependsOnOptions.push(item) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// the filtered list that the select uses
|
||||||
|
const dependsOnFilterOptions = ref(dependsOnOptions);
|
||||||
|
|
||||||
|
function createValue(
|
||||||
|
val: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
done: (val: any, mode: "add-unique" | "add" | "toggle" | undefined) => void,
|
||||||
|
) {
|
||||||
|
if (val.length > 0) {
|
||||||
|
if (!dependsOnOptions.includes(val)) {
|
||||||
|
dependsOnOptions.push(val);
|
||||||
|
}
|
||||||
|
done(val, "add-unique");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFn(val: string, update: (callback: () => void) => void) {
|
||||||
|
update(() => {
|
||||||
|
if (val === "") {
|
||||||
|
dependsOnFilterOptions.value = dependsOnOptions;
|
||||||
|
} else {
|
||||||
|
const needle = val.toLowerCase();
|
||||||
|
dependsOnFilterOptions.value = dependsOnOptions.filter(
|
||||||
|
(v) => v.toLowerCase().indexOf(needle) > -1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
renderedPreview,
|
||||||
|
renderedVariables,
|
||||||
|
addReportTemplate,
|
||||||
|
editReportTemplate,
|
||||||
|
runReportPreview,
|
||||||
|
runReportPreviewDebug,
|
||||||
|
getAllowedValues,
|
||||||
|
} = useSharedReportTemplates;
|
||||||
|
|
||||||
|
const { reportHTMLTemplates, getReportHTMLTemplates } =
|
||||||
|
useSharedReportHTMLTemplates;
|
||||||
|
|
||||||
|
const tab = ref(
|
||||||
|
props.templateType === "markdown"
|
||||||
|
? "markdown"
|
||||||
|
: props.templateType === "html"
|
||||||
|
? "html"
|
||||||
|
: "plaintext",
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
getReportHTMLTemplates();
|
||||||
|
|
||||||
|
if (state.depends_on?.length === 0) {
|
||||||
|
getAllowedValues({
|
||||||
|
variables: state.template_variables,
|
||||||
|
dependencies: dependencies.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const HTMLTemplateOptions = computed<QSelectOption<number>[]>(() =>
|
||||||
|
reportHTMLTemplates.value.map((template) => ({
|
||||||
|
label: template.name,
|
||||||
|
value: template.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const debug = ref(false);
|
||||||
|
|
||||||
|
watch(debug, (newValue) => {
|
||||||
|
if (newValue)
|
||||||
|
props.templateType === "html" || props.templateType === "markdown"
|
||||||
|
? (previewFormat.value = "html")
|
||||||
|
: (previewFormat.value = "plaintext");
|
||||||
|
});
|
||||||
|
|
||||||
|
function openBaseTemplateForm() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportHTMLTemplateForm,
|
||||||
|
}).onOk(() => getReportHTMLTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewReport() {
|
||||||
|
wrapDoubleQuotes();
|
||||||
|
let needsPrompt: string[] = [];
|
||||||
|
if (state.depends_on && state.depends_on.length > 0) {
|
||||||
|
needsPrompt = state.depends_on.filter((dep) => !dependencies.value[dep]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsPrompt.length > 0) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportDependencyPrompt,
|
||||||
|
componentProps: { dependsOn: needsPrompt },
|
||||||
|
})
|
||||||
|
.onOk((deps: ReportDependencies) => {
|
||||||
|
dependencies.value = { ...dependencies.value, ...deps };
|
||||||
|
})
|
||||||
|
.onDismiss(() => {
|
||||||
|
const request = {
|
||||||
|
...state,
|
||||||
|
format: previewFormat.value,
|
||||||
|
dependencies: dependencies.value,
|
||||||
|
debug: debug.value,
|
||||||
|
};
|
||||||
|
debug.value
|
||||||
|
? runReportPreviewDebug(request)
|
||||||
|
: runReportPreview(request);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const request = {
|
||||||
|
...state,
|
||||||
|
format: previewFormat.value,
|
||||||
|
dependencies: dependencies.value,
|
||||||
|
debug: debug.value,
|
||||||
|
};
|
||||||
|
debug.value ? runReportPreviewDebug(request) : runReportPreview(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// load preview when preview tab is selected
|
||||||
|
watch(tab, (newValue) => {
|
||||||
|
if (newValue === "preview") {
|
||||||
|
previewReport();
|
||||||
|
} else if (newValue === props.templateType) {
|
||||||
|
editor.value?.setModel(templateModel);
|
||||||
|
} else if (newValue === "css") {
|
||||||
|
splitter.value = 3;
|
||||||
|
editor.value?.setModel(cssModel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// load preview when preview format changes
|
||||||
|
watch(previewFormat, () => {
|
||||||
|
if (tab.value === "preview") {
|
||||||
|
previewReport();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// main editor
|
||||||
|
const editorDiv = ref<HTMLElement | null>(null);
|
||||||
|
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>();
|
||||||
|
|
||||||
|
// saves state for template
|
||||||
|
let templateModel: monaco.editor.ITextModel;
|
||||||
|
const templateUri = monaco.Uri.parse(`editor://${props.templateType}`);
|
||||||
|
|
||||||
|
// saves state for css
|
||||||
|
let cssModel: monaco.editor.ITextModel;
|
||||||
|
const cssUri = monaco.Uri.parse("editor://css");
|
||||||
|
|
||||||
|
// saves state for variables editor
|
||||||
|
const variablesDiv = ref<HTMLElement | null>(null);
|
||||||
|
const variablesEditor = shallowRef<monaco.editor.IStandaloneCodeEditor>();
|
||||||
|
let variablesModel: monaco.editor.ITextModel;
|
||||||
|
const variablesUri = monaco.Uri.parse("editor://variables");
|
||||||
|
|
||||||
|
function cleanupEditors() {
|
||||||
|
editor.value?.dispose();
|
||||||
|
variablesEditor.value?.dispose();
|
||||||
|
templateModel?.dispose();
|
||||||
|
cssModel?.dispose();
|
||||||
|
variablesModel?.dispose();
|
||||||
|
onDialogHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeEditor() {
|
||||||
|
templateModel = monaco.editor.createModel(
|
||||||
|
state.template_md,
|
||||||
|
props.templateType,
|
||||||
|
templateUri,
|
||||||
|
);
|
||||||
|
cssModel = monaco.editor.createModel(state.template_css, "css", cssUri);
|
||||||
|
|
||||||
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
editor.value = monaco.editor.create(editorDiv.value!, {
|
||||||
|
automaticLayout: true,
|
||||||
|
model: templateModel,
|
||||||
|
theme: theme,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
quickSuggestions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.value?.onDidChangeModelContent(() => {
|
||||||
|
const currentModel = editor.value?.getModel();
|
||||||
|
|
||||||
|
if (currentModel) {
|
||||||
|
if (currentModel?.uri === cssUri) {
|
||||||
|
state.template_css = currentModel.getValue();
|
||||||
|
} else {
|
||||||
|
state.template_md = currentModel.getValue();
|
||||||
|
}
|
||||||
|
autoSave.value && applyChanges();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
variablesModel = monaco.editor.createModel(
|
||||||
|
state.template_variables,
|
||||||
|
"yaml",
|
||||||
|
variablesUri,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
variablesEditor.value = monaco.editor.create(variablesDiv.value!, {
|
||||||
|
automaticLayout: true,
|
||||||
|
model: variablesModel,
|
||||||
|
theme: theme,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
variablesEditor.value?.onDidChangeModelContent(() => {
|
||||||
|
const currentModel = variablesEditor.value?.getModel();
|
||||||
|
|
||||||
|
if (currentModel) {
|
||||||
|
state.template_variables = currentModel.getValue();
|
||||||
|
autoSave.value && applyChanges();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure to put quotes around any variable values that have { or }
|
||||||
|
function wrapDoubleQuotes() {
|
||||||
|
const matchJsonCharacters = /([^:\s'"]+:\s*)([^'"]*[{}][^'"\n]*)/;
|
||||||
|
const editorValue = variablesEditor.value?.getValue();
|
||||||
|
if (editorValue && matchJsonCharacters.test(editorValue)) {
|
||||||
|
state.template_variables = editorValue
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(matchJsonCharacters, "$1'$2'"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
variablesEditor.value?.setValue(state.template_variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNameValid = ref(true);
|
||||||
|
function validate(dontNotify = false): boolean {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!state.template_md) {
|
||||||
|
dontNotify || notifyError("Template Text is required");
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.name) {
|
||||||
|
dontNotify || notifyError("Template Name is required");
|
||||||
|
isNameValid.value = false;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if yaml is valid
|
||||||
|
const doc = parseDocument(state.template_variables, { prettyErrors: true });
|
||||||
|
if (doc.errors.length > 0) {
|
||||||
|
dontNotify ||
|
||||||
|
notifyError("Error in variables: " + doc.errors[0].message, 5000);
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNameValid.value = true;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoSave = ref(props.reportTemplate ? true : false);
|
||||||
|
const showSaved = ref(false);
|
||||||
|
|
||||||
|
const applyChanges = useDebounceFn(() => {
|
||||||
|
isLoading.value = true;
|
||||||
|
if (validate(true)) {
|
||||||
|
wrapDoubleQuotes();
|
||||||
|
editReportTemplate(state.id, state, { dontNotify: true });
|
||||||
|
|
||||||
|
edited.value = false;
|
||||||
|
showSaved.value = true;
|
||||||
|
useTimeoutFn(() => (showSaved.value = false), 5000);
|
||||||
|
}
|
||||||
|
isLoading.value = false;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (validate()) {
|
||||||
|
wrapDoubleQuotes();
|
||||||
|
props.reportTemplate
|
||||||
|
? editReportTemplate(state.id, state)
|
||||||
|
: addReportTemplate(state);
|
||||||
|
|
||||||
|
// stops the dialog from closing when there is an error
|
||||||
|
await until(isLoading).not.toBeTruthy();
|
||||||
|
if (isError.value) return;
|
||||||
|
|
||||||
|
onDialogOK();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
85
src/ee/reporting/components/ReportTemplateImport.vue
Normal file
85
src/ee/reporting/components/ReportTemplateImport.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
Import Report Template
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-card-section>
|
||||||
|
<q-file
|
||||||
|
v-model="file"
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
label="Import File"
|
||||||
|
style="width: 400px"
|
||||||
|
accept=".json"
|
||||||
|
hint="Only accepts exported report template json files"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="overwriteOnNameConflict"
|
||||||
|
label="Overwrite if name exists"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions>
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat label="Cancel" />
|
||||||
|
<q-btn
|
||||||
|
:loading="isLoading"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Import"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { until } from "@vueuse/shared";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { useSharedReportTemplates } from "../api/reporting";
|
||||||
|
import { notifyError } from "@/utils/notify";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const { isLoading, isError, importReport } = useSharedReportTemplates;
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const file = ref<File | null>(null);
|
||||||
|
const overwriteOnNameConflict = ref(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (file.value) {
|
||||||
|
importReport({
|
||||||
|
overwrite: overwriteOnNameConflict.value,
|
||||||
|
template: await file.value.text(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// stops the dialog from closing when there is an error
|
||||||
|
await until(isLoading).not.toBeTruthy();
|
||||||
|
if (isError.value) return;
|
||||||
|
|
||||||
|
onDialogOK();
|
||||||
|
} else {
|
||||||
|
notifyError("File is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
119
src/ee/reporting/components/ReportTemplatePreview.vue
Normal file
119
src/ee/reporting/components/ReportTemplatePreview.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-splitter
|
||||||
|
v-model="horizontalSplitter"
|
||||||
|
horizontal
|
||||||
|
emit-immediately
|
||||||
|
unit="px"
|
||||||
|
:limits="[0, splitterHeight - 8]"
|
||||||
|
:style="{
|
||||||
|
'min-height': `${splitterHeight}px`,
|
||||||
|
height: `${splitterHeight}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-slot:before>
|
||||||
|
<iframe
|
||||||
|
:srcdoc="previewFormat !== 'pdf' ? source : undefined"
|
||||||
|
:src="previewFormat === 'pdf' ? source : undefined"
|
||||||
|
:style="{
|
||||||
|
'min-width': '100%',
|
||||||
|
'background-color': 'white',
|
||||||
|
height: `${horizontalSplitter - 6}px`,
|
||||||
|
}"
|
||||||
|
></iframe>
|
||||||
|
</template>
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-splitter v-if="debug" v-model="verticalSplitter">
|
||||||
|
<template v-slot:before>
|
||||||
|
<div class="q-pa-xs">
|
||||||
|
{{ previewFormat === "plaintext" ? "Text" : "HTML" }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="templateDiv"
|
||||||
|
:style="{ height: `${splitterHeight - horizontalSplitter - 33}px` }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:after>
|
||||||
|
<div class="q-pa-xs">Variables</div>
|
||||||
|
<div
|
||||||
|
id="variablesDiv"
|
||||||
|
:style="{ height: `${splitterHeight - horizontalSplitter - 33}px` }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</q-splitter>
|
||||||
|
<div v-else style="height: 0px"></div>
|
||||||
|
</template>
|
||||||
|
</q-splitter>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUnmounted, onMounted } from "vue";
|
||||||
|
import { useQuasar } from "quasar";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { ReportFormat } from "../types/reporting";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
previewFormat: ReportFormat;
|
||||||
|
source: string;
|
||||||
|
debug: boolean;
|
||||||
|
variables?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const splitterHeight = ref($q.screen.height - 82);
|
||||||
|
|
||||||
|
const horizontalSplitter = ref(
|
||||||
|
props.debug ? splitterHeight.value / 2 : splitterHeight.value - 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
const verticalSplitter = ref(props.debug ? 50 : 0);
|
||||||
|
|
||||||
|
// for debug editors in preview
|
||||||
|
if (props.debug) {
|
||||||
|
let templateEditor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
let variablesEditor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
|
||||||
|
|
||||||
|
templateEditor = monaco.editor.create(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
document.getElementById("templateDiv")!,
|
||||||
|
{
|
||||||
|
automaticLayout: true,
|
||||||
|
value: props.source || "",
|
||||||
|
theme: theme,
|
||||||
|
language: "html",
|
||||||
|
minimap: { enabled: false },
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
variablesEditor = monaco.editor.create(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
document.getElementById("variablesDiv")!,
|
||||||
|
{
|
||||||
|
automaticLayout: true,
|
||||||
|
value: props.variables || "",
|
||||||
|
language: "json",
|
||||||
|
theme: theme,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
templateEditor?.dispose();
|
||||||
|
variablesEditor?.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
64
src/ee/reporting/components/ReportingHelpMenu.vue
Normal file
64
src/ee/reporting/components/ReportingHelpMenu.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="q-px-sm">
|
||||||
|
<div class="text-h5">Report Template</div>
|
||||||
|
|
||||||
|
<div class="q-px-sm">
|
||||||
|
<div class="text-body1">Report Templates</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-h5">Base Template</div>
|
||||||
|
|
||||||
|
<div class="q-px-sm">
|
||||||
|
<div class="text-body1">Test</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-h5">Data Query</div>
|
||||||
|
|
||||||
|
<div class="q-px-sm">
|
||||||
|
<div class="text-body1">
|
||||||
|
Data Queries are used to save common database queries to use them in
|
||||||
|
templates. Behind the scenes, we are just creating a Django queryset.
|
||||||
|
The only difference is these querysets are restricted to only retrieve
|
||||||
|
data versus modifying data.
|
||||||
|
</div>
|
||||||
|
<div class="text-h6">Syntax</div>
|
||||||
|
<div class="q-px-sm">
|
||||||
|
<div class="text-body1">
|
||||||
|
When you create Data Queries in the Data Query Editor you use JSON.
|
||||||
|
You can also create Data Queries directly in the template variables
|
||||||
|
which uses yaml syntax.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-body1"></div>
|
||||||
|
|
||||||
|
<div class="text-h6">Structure</div>
|
||||||
|
<div class="q-px-sm">
|
||||||
|
<div class="text-body1">
|
||||||
|
Ctrl+Space in the query editor to auto-complete values
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl>
|
||||||
|
<dt>* model (*string)</dt>
|
||||||
|
<dd>
|
||||||
|
This is the only required field. This specifies the table to query.
|
||||||
|
</dd>
|
||||||
|
<dt>* filter (object)</dt>
|
||||||
|
<dd></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
section: "template" | "baseTemplate" | "dataQuery";
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
437
src/ee/reporting/components/ReportsManager.vue
Normal file
437
src/ee/reporting/components/ReportsManager.vue
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" maximized @hide="onDialogHide">
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
<q-btn
|
||||||
|
class="q-mr-sm"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
push
|
||||||
|
icon="refresh"
|
||||||
|
@click="getReportTemplates()"
|
||||||
|
/>Reports Manager
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
:table-class="{
|
||||||
|
'table-bgcolor': !$q.dark.isActive,
|
||||||
|
'table-bgcolor-dark': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
:style="{ 'max-height': `${$q.screen.height - 32}px` }"
|
||||||
|
class="tbl-sticky"
|
||||||
|
:rows="reportTemplates"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="isLoading"
|
||||||
|
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: false }"
|
||||||
|
:filter="search"
|
||||||
|
row-key="id"
|
||||||
|
binary-state-sort
|
||||||
|
virtual-scroll
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<q-btn-dropdown
|
||||||
|
class="q-ml-sm"
|
||||||
|
icon="add"
|
||||||
|
label="Template"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
>
|
||||||
|
<q-list dense>
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="openNewReportTemplateForm('markdown')"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="fa-brands fa-markdown" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Markdown Template</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="openNewReportTemplateForm('html')"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="fa-brands fa-html5" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>HTML Template</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="openNewReportTemplateForm('plaintext')"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="fa-solid fa-file-csv" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Plain Text Template</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup @click="importReportTemplate">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="fa-solid fa-file-import" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Import Report Template</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
label="Base Templates"
|
||||||
|
icon="fa-regular fa-file-code"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="openHTMLTemplates"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
label="Report Assets"
|
||||||
|
icon="fa-regular fa-folder-closed"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="openReportAssets"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
label="Data Queries"
|
||||||
|
icon="fa-solid fa-database"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="openDataQueries"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
label="Shared Templates"
|
||||||
|
icon="fa-solid fa-share"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="openSharedTemplates"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
<q-input
|
||||||
|
v-model="search"
|
||||||
|
style="width: 300px"
|
||||||
|
label="Search"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
class="q-pr-md q-pb-xs"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="search" color="primary" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr
|
||||||
|
:props="props"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@dblclick="openEditReportTemplateForm(props.row)"
|
||||||
|
>
|
||||||
|
<!-- Context Menu -->
|
||||||
|
<q-menu context-menu>
|
||||||
|
<q-list dense style="min-width: 200px">
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="openEditReportTemplateForm(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="edit" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Edit</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="cloneTemplate(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="content_copy" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Clone</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="
|
||||||
|
openReport(props.row.id, 'pdf', props.row.depends_on, {})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="mdi-file-pdf-box" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Open PDF Report</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="
|
||||||
|
openReport(
|
||||||
|
props.row.id,
|
||||||
|
props.row.type !== 'plaintext' ? 'html' : 'plaintext',
|
||||||
|
props.row.depends_on,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon
|
||||||
|
:name="
|
||||||
|
props.row.type !== 'plaintext' ? 'code' : 'description'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section
|
||||||
|
>Open
|
||||||
|
{{ props.row.type !== "plaintext" ? "HTML" : "Text" }}
|
||||||
|
Report</q-item-section
|
||||||
|
>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="downloadReport(props.row, 'pdf', {})"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="mdi-download" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Download PDF Report</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="
|
||||||
|
downloadReport(
|
||||||
|
props.row,
|
||||||
|
props.row.type !== 'plaintext' ? 'html' : 'plaintext',
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="mdi-download" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section
|
||||||
|
>Download
|
||||||
|
{{ props.row.type !== "plaintext" ? "HTML" : "Text" }}
|
||||||
|
Report</q-item-section
|
||||||
|
>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="exportReport(props.row.id)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="mdi-export" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Export</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-close-popup
|
||||||
|
clickable
|
||||||
|
@click="deleteTemplate(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="delete" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Delete</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item v-close-popup clickable>
|
||||||
|
<q-item-section>Close</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
|
||||||
|
<!-- rows -->
|
||||||
|
<td>{{ props.row.name }}</td>
|
||||||
|
<td>{{ props.row.type }}</td>
|
||||||
|
<td>
|
||||||
|
{{ props.row.depends_on.length > 0 ? props.row.depends_on : "" }}
|
||||||
|
</td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useQuasar, useDialogPluginComponent, type QTableColumn } from "quasar";
|
||||||
|
import { useSharedReportTemplates } from "../api/reporting";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import ReportTemplateForm from "./ReportTemplateForm.vue";
|
||||||
|
import ReportAssets from "./ReportAssets.vue";
|
||||||
|
import ReportHTMLTemplateTable from "./ReportHTMLTemplateTable.vue";
|
||||||
|
import ReportDataQueryTable from "./ReportDataQueryTable.vue";
|
||||||
|
import ReportTemplateImport from "./ReportTemplateImport.vue";
|
||||||
|
import SharedTemplatesImport from "./SharedTemplatesImport.vue";
|
||||||
|
|
||||||
|
// type imports
|
||||||
|
import type { ReportTemplate } from "../types/reporting";
|
||||||
|
|
||||||
|
const columns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
field: "name",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
label: "Template Type",
|
||||||
|
field: "type",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "depends_on",
|
||||||
|
label: "Template Dependencies",
|
||||||
|
field: "depends_on",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// reports manager logic
|
||||||
|
const {
|
||||||
|
reportTemplates,
|
||||||
|
isLoading,
|
||||||
|
getReportTemplates,
|
||||||
|
deleteReportTemplate,
|
||||||
|
openReport,
|
||||||
|
exportReport,
|
||||||
|
downloadReport,
|
||||||
|
} = useSharedReportTemplates;
|
||||||
|
|
||||||
|
onMounted(getReportTemplates);
|
||||||
|
const search = ref("");
|
||||||
|
|
||||||
|
function openNewReportTemplateForm(templateType: string) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportTemplateForm,
|
||||||
|
componentProps: {
|
||||||
|
templateType: templateType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditReportTemplateForm(template: ReportTemplate) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportTemplateForm,
|
||||||
|
componentProps: {
|
||||||
|
reportTemplate: template,
|
||||||
|
templateType: template.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReportAssets() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportAssets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDataQueries() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportDataQueryTable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHTMLTemplates() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportHTMLTemplateTable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTemplate(template: ReportTemplate) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Delete template: ${template.name}?`,
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(() => {
|
||||||
|
deleteReportTemplate(template.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneTemplate(template: ReportTemplate) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportTemplateForm,
|
||||||
|
componentProps: {
|
||||||
|
cloneTemplate: template,
|
||||||
|
templateType: template.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importReportTemplate() {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportTemplateImport,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSharedTemplates() {
|
||||||
|
$q.dialog({
|
||||||
|
component: SharedTemplatesImport,
|
||||||
|
}).onDismiss(() => getReportTemplates());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
153
src/ee/reporting/components/RunReportDialog.vue
Normal file
153
src/ee/reporting/components/RunReportDialog.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card style="width: 400px">
|
||||||
|
<q-bar>
|
||||||
|
{{ download ? "Download" : "Run" }} {{ capitalize(type) }} Report
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<q-card-section v-if="reportTemplates.length === 0">
|
||||||
|
There are no report templates that depend on {{ capitalize(type) }}. You
|
||||||
|
must select a dependency in the Report Template of type {{ type }} using
|
||||||
|
the dependencies dropdown.
|
||||||
|
</q-card-section>
|
||||||
|
<div v-else>
|
||||||
|
<q-card-section>
|
||||||
|
<tactical-dropdown
|
||||||
|
v-model="reportTemplate"
|
||||||
|
:options="reportTemplateOptions"
|
||||||
|
label="Report Template"
|
||||||
|
outlined
|
||||||
|
mapOptions
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-option-group
|
||||||
|
v-model="reportFormat"
|
||||||
|
:options="reportFormatOptions"
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-close-popup dense flat label="Cancel" />
|
||||||
|
<q-btn
|
||||||
|
:loading="isLoading"
|
||||||
|
:disable="!reportTemplate"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
label="Run Report"
|
||||||
|
color="primary"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, computed, onBeforeMount } from "vue";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { capitalize } from "@/utils/format";
|
||||||
|
import { useSharedReportTemplates } from "../api/reporting";
|
||||||
|
import { notifyError } from "@/utils/notify";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import { type ReportFormat } from "../types/reporting";
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string | number;
|
||||||
|
type: "client" | "site" | "agent";
|
||||||
|
download: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// quasar dialog setup
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const {
|
||||||
|
reportTemplates,
|
||||||
|
isLoading,
|
||||||
|
getReportTemplates,
|
||||||
|
openReport,
|
||||||
|
downloadReport,
|
||||||
|
} = useSharedReportTemplates;
|
||||||
|
|
||||||
|
// run report logic
|
||||||
|
const reportTemplate = ref<number | null>(null);
|
||||||
|
const reportFormat = ref<ReportFormat>("pdf");
|
||||||
|
|
||||||
|
const reportTemplateOptions = computed(() =>
|
||||||
|
reportTemplates.value.map((template) => ({
|
||||||
|
label: template.name,
|
||||||
|
value: template.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedTemplate = computed(() => {
|
||||||
|
return reportTemplates.value.find(
|
||||||
|
(template) => template.id === reportTemplate.value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportFormatOptions = computed(() => {
|
||||||
|
if (selectedTemplate.value) {
|
||||||
|
if (selectedTemplate.value.type !== "plaintext")
|
||||||
|
return [
|
||||||
|
{ label: "PDF", value: "pdf" },
|
||||||
|
{ label: "HTML", value: "html" },
|
||||||
|
];
|
||||||
|
else
|
||||||
|
return [
|
||||||
|
{ label: "PDF", value: "pdf" },
|
||||||
|
{ label: "Text", value: "plaintext" },
|
||||||
|
];
|
||||||
|
} else return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (reportTemplate.value === null) {
|
||||||
|
notifyError("Report Template is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTemplate.value && selectedTemplate.value.depends_on) {
|
||||||
|
if (!props.download)
|
||||||
|
openReport(
|
||||||
|
reportTemplate.value,
|
||||||
|
reportFormat.value,
|
||||||
|
selectedTemplate.value.depends_on,
|
||||||
|
{
|
||||||
|
[props.type]: props.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
else
|
||||||
|
downloadReport(selectedTemplate.value, reportFormat.value, {
|
||||||
|
[props.type]: props.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDialogOK();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => getReportTemplates([props.type]));
|
||||||
|
</script>
|
||||||
133
src/ee/reporting/components/SharedTemplatesImport.vue
Normal file
133
src/ee/reporting/components/SharedTemplatesImport.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" maximized @hide="onDialogHide">
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
Shared Templates
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
:table-class="{
|
||||||
|
'table-bgcolor': !$q.dark.isActive,
|
||||||
|
'table-bgcolor-dark': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
:style="{ 'max-height': `${$q.screen.height - 32}px` }"
|
||||||
|
class="tbl-sticky"
|
||||||
|
:rows="sharedTemplates"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="isLoading"
|
||||||
|
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: false }"
|
||||||
|
:filter="search"
|
||||||
|
selection="multiple"
|
||||||
|
v-model:selected="selected"
|
||||||
|
row-key="name"
|
||||||
|
binary-state-sort
|
||||||
|
virtual-scroll
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-sm"
|
||||||
|
label="Import"
|
||||||
|
icon="fa-solid fa-file-import"
|
||||||
|
no-caps
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:disable="selected.length === 0 || isLoading"
|
||||||
|
@click="importTemplates"
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-ml-sm"
|
||||||
|
dense
|
||||||
|
label="Overwrite if name conflicts"
|
||||||
|
v-model="overwrite"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
<q-input
|
||||||
|
v-model="search"
|
||||||
|
style="width: 300px"
|
||||||
|
label="Search"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
class="q-pr-md q-pb-xs"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="search" color="primary" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { until } from "@vueuse/shared";
|
||||||
|
import { useQuasar, useDialogPluginComponent, type QTableColumn } from "quasar";
|
||||||
|
import { useSharedReportTemplates } from "../api/reporting";
|
||||||
|
import { truncateText } from "@/utils/format";
|
||||||
|
|
||||||
|
const columns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
field: "name",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "url",
|
||||||
|
label: "Download Url",
|
||||||
|
field: "url",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
format: (val) => truncateText(val, 90),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// shared templates import logic
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
sharedTemplates,
|
||||||
|
importSharedTemplates,
|
||||||
|
getSharedTemplates,
|
||||||
|
} = useSharedReportTemplates;
|
||||||
|
|
||||||
|
const search = ref("");
|
||||||
|
const selected = ref([]);
|
||||||
|
const overwrite = ref(false);
|
||||||
|
|
||||||
|
async function importTemplates() {
|
||||||
|
importSharedTemplates({
|
||||||
|
templates: selected.value,
|
||||||
|
overwrite: overwrite.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// stops the dialog from closing when there is an error
|
||||||
|
await until(isLoading).not.toBeTruthy();
|
||||||
|
if (isError.value) return;
|
||||||
|
|
||||||
|
selected.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getSharedTemplates);
|
||||||
|
</script>
|
||||||
244
src/ee/reporting/components/VariablesSelector.vue
Normal file
244
src/ee/reporting/components/VariablesSelector.vue
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<q-list dense>
|
||||||
|
<q-item-label header
|
||||||
|
>Base Template Blocks
|
||||||
|
<span v-if="copiedBlock" class="float-right">Copied!</span></q-item-label
|
||||||
|
>
|
||||||
|
<q-item
|
||||||
|
v-for="block in templateBlocks"
|
||||||
|
:key="block"
|
||||||
|
:inset-level="block.warning ? 0 : 1"
|
||||||
|
>
|
||||||
|
<q-item-section avatar v-if="block.warning">
|
||||||
|
<q-icon name="warning" color="warning">
|
||||||
|
<q-tooltip
|
||||||
|
>Block not found in template. Click on the block to copy and paste
|
||||||
|
into template</q-tooltip
|
||||||
|
>
|
||||||
|
</q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span
|
||||||
|
class="cursor-pointer"
|
||||||
|
style="text-decoration-line: underline; font-size: smaller"
|
||||||
|
@click="copy(block.block, false, true)"
|
||||||
|
>
|
||||||
|
{{ block.block }}
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item-label header>
|
||||||
|
Variables <span v-if="copiedVariable" class="float-right">Copied!</span>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item
|
||||||
|
v-for="warning in [...dependencyWarnings, ...variableWarnings]"
|
||||||
|
:key="warning"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="warning" color="warning" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span style="font-size: smaller">{{ warning }}</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator
|
||||||
|
v-if="[...dependencyWarnings, ...variableWarnings].length > 0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-for="(type, prop) in variableAnalysis"
|
||||||
|
:key="prop"
|
||||||
|
@mouseover="mouseover = prop.toString()"
|
||||||
|
@mouseleave="mouseover = ''"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-badge color="primary" :label="type"></q-badge>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-label :lines="1">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer"
|
||||||
|
style="text-decoration-line: underline; font-size: smaller"
|
||||||
|
@click="copy(prop.toString(), type.toLowerCase() === 'array')"
|
||||||
|
>
|
||||||
|
{{ prop }}
|
||||||
|
</span>
|
||||||
|
<q-tooltip :delay="500">
|
||||||
|
{{ prop }}
|
||||||
|
</q-tooltip>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-section
|
||||||
|
v-if="
|
||||||
|
type.toLowerCase().substring(0, 5) === 'array' &&
|
||||||
|
mouseover === prop.toString()
|
||||||
|
"
|
||||||
|
side
|
||||||
|
>
|
||||||
|
<q-badge
|
||||||
|
class="cursor-pointer"
|
||||||
|
label="for loop"
|
||||||
|
@click="copy(prop.toString(), true)"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import type { ReportDependencies } from "../types/reporting";
|
||||||
|
import {
|
||||||
|
useSharedReportTemplates,
|
||||||
|
useSharedReportHTMLTemplates,
|
||||||
|
} from "../api/reporting";
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { copyToClipboard } from "quasar";
|
||||||
|
import { watchDebounced, until } from "@vueuse/core";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
variables: string;
|
||||||
|
template: string;
|
||||||
|
dependsOn?: string[];
|
||||||
|
base_template?: number;
|
||||||
|
dependencies?: ReportDependencies;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { getAllowedValues, variableAnalysis, isLoading } =
|
||||||
|
useSharedReportTemplates;
|
||||||
|
|
||||||
|
const { reportHTMLTemplates } = useSharedReportHTMLTemplates;
|
||||||
|
|
||||||
|
const copiedVariable = ref(false);
|
||||||
|
const copiedBlock = ref(false);
|
||||||
|
const templateBlocks = ref([] as { block: string; warning: boolean }[]);
|
||||||
|
const variableWarnings = ref([] as string[]);
|
||||||
|
const dependencyWarnings = ref([] as string[]);
|
||||||
|
const mouseover = ref("");
|
||||||
|
|
||||||
|
function copy(content: string, is_for = false, block = false) {
|
||||||
|
let text = "";
|
||||||
|
if (block) {
|
||||||
|
text = "{% block " + content + " %}{% endblock %}";
|
||||||
|
} else if (is_for) text = "{% for item in " + content + " %}{% endfor %}";
|
||||||
|
else text = "{{ " + content + " }}";
|
||||||
|
|
||||||
|
copyToClipboard(text).then(() => {
|
||||||
|
if (block) copiedBlock.value = true;
|
||||||
|
else copiedVariable.value = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (block) copiedBlock.value = false;
|
||||||
|
else copiedVariable.value = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVariables() {
|
||||||
|
variableWarnings.value = [];
|
||||||
|
|
||||||
|
// don't send variable analysis if client, site, or agent dependency isn't selected
|
||||||
|
if (props.dependsOn) {
|
||||||
|
for (let i = 0; i < props.dependsOn.length; i++) {
|
||||||
|
let dep = props.dependsOn[i];
|
||||||
|
if (dep === "client" || dep === "site" || dep === "agent") {
|
||||||
|
if (!props.dependencies?.[dep]) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllowedValues({
|
||||||
|
variables: props.variables,
|
||||||
|
dependencies: props?.dependencies,
|
||||||
|
});
|
||||||
|
|
||||||
|
await until(isLoading).not.toBeTruthy();
|
||||||
|
|
||||||
|
// check if any data queries returned empty results
|
||||||
|
for (let key in variableAnalysis.value) {
|
||||||
|
if (variableAnalysis.value[key].includes("0 Results")) {
|
||||||
|
variableWarnings.value.push(`Data Query: ${key} returned no results`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variableAnalysis.value[key].toLowerCase().substring(0, 5) === "array") {
|
||||||
|
variableAnalysis.value[key] = "Array";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch for variables changes
|
||||||
|
watchDebounced(
|
||||||
|
() => props.variables,
|
||||||
|
() => {
|
||||||
|
getVariables();
|
||||||
|
},
|
||||||
|
{ debounce: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// checks dependencies and adds warnings
|
||||||
|
function checkDependencies(
|
||||||
|
dependsOn: string[] | undefined,
|
||||||
|
dependencies: ReportDependencies | undefined
|
||||||
|
) {
|
||||||
|
dependencyWarnings.value = [];
|
||||||
|
// Check if dependencies aren't specified
|
||||||
|
dependsOn?.forEach((dep) => {
|
||||||
|
!dependencies?.[dep] &&
|
||||||
|
dependencyWarnings.value.push(
|
||||||
|
`Missing value for dependency: ${dep} . Open Preview to set values`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch for any dependency changes
|
||||||
|
watch(
|
||||||
|
[() => props.dependencies, () => props.dependsOn],
|
||||||
|
([dependencies, dependsOn]) => {
|
||||||
|
checkDependencies(dependsOn, dependencies);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// checks available blocks in base template and checks if they are used
|
||||||
|
function checkBaseTemplate(template: string, base_id: number | undefined) {
|
||||||
|
templateBlocks.value = [];
|
||||||
|
if (base_id) {
|
||||||
|
const base_template = reportHTMLTemplates.value.find(
|
||||||
|
(template) => template.id === base_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let regex = /\{% block ([A-Za-z0-9_ ]+) %\}/g,
|
||||||
|
match: string[] | null;
|
||||||
|
|
||||||
|
if (base_template)
|
||||||
|
while ((match = regex.exec(base_template?.html))) {
|
||||||
|
const full_match = match[0];
|
||||||
|
const block_name = match[1];
|
||||||
|
templateBlocks.value.push({
|
||||||
|
block: block_name,
|
||||||
|
warning: !template.includes(full_match),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watches for changes in base template and template
|
||||||
|
watch(
|
||||||
|
[() => props.base_template, () => props.template],
|
||||||
|
([newBase, newTemplate]) => {
|
||||||
|
checkBaseTemplate(newTemplate, newBase);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getVariables();
|
||||||
|
checkDependencies(props.dependsOn, props.dependencies);
|
||||||
|
checkBaseTemplate(props.template, props.base_template);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
73
src/ee/reporting/types/reporting.ts
Normal file
73
src/ee/reporting/types/reporting.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReportTemplateType = "markdown" | "html" | "plaintext";
|
||||||
|
|
||||||
|
export type ReportFormat = "pdf" | "html" | "plaintext";
|
||||||
|
|
||||||
|
export interface ReportDependencies {
|
||||||
|
client?: number;
|
||||||
|
site?: number;
|
||||||
|
agent?: string;
|
||||||
|
[x: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariableAnalysis {
|
||||||
|
[x: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
template_md: string;
|
||||||
|
template_css: string;
|
||||||
|
template_html?: number;
|
||||||
|
type: ReportTemplateType;
|
||||||
|
template_variables: string;
|
||||||
|
depends_on?: string[];
|
||||||
|
uuid: string;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportHTMLTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
html: string;
|
||||||
|
uuid: string;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
export interface ReportDataQuery {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
json_query: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadAssetsResponse {
|
||||||
|
[x: string]: { id: string; filename: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunReportPreviewRequest extends ReportTemplate {
|
||||||
|
format: ReportFormat;
|
||||||
|
dependencies?: ReportDependencies;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunReportRequest {
|
||||||
|
format: ReportFormat;
|
||||||
|
dependencies?: ReportDependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenReportParams {
|
||||||
|
id: number;
|
||||||
|
format: ReportFormat;
|
||||||
|
dependsOn: string[];
|
||||||
|
dependencies: ReportDependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedTemplate {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
81
src/ee/reporting/views/ReportView.vue
Normal file
81
src/ee/reporting/views/ReportView.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<q-inner-loading
|
||||||
|
:showing="isLoading"
|
||||||
|
label="Please wait..."
|
||||||
|
label-class="text-teal"
|
||||||
|
label-style="font-size: 1.1em"
|
||||||
|
/>
|
||||||
|
<iframe
|
||||||
|
:srcdoc="$route.query.format !== 'pdf' ? reportData : undefined"
|
||||||
|
:src="$route.query.format === 'pdf' ? reportData : undefined"
|
||||||
|
:style="{
|
||||||
|
'max-height': `${$q.screen.height}px`,
|
||||||
|
'min-height': `${$q.screen.height}px`,
|
||||||
|
'min-width': '100%',
|
||||||
|
'background-color': 'white',
|
||||||
|
}"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useQuasar } from "quasar";
|
||||||
|
import { useReportTemplates } from "../api/reporting";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import ReportDependencyPrompt from "../components/ReportDependencyPrompt.vue";
|
||||||
|
|
||||||
|
// type
|
||||||
|
import type { ReportFormat, ReportDependencies } from "../types/reporting";
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
id: number;
|
||||||
|
format: ReportFormat;
|
||||||
|
dependencies?: ReportDependencies;
|
||||||
|
dependsOn?: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// setup vue router
|
||||||
|
const $route = useRoute();
|
||||||
|
|
||||||
|
// setup quasar
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// logic
|
||||||
|
const dependsOn = props.dependsOn || [];
|
||||||
|
const dependencies = ref(Object.assign({}, props.dependencies));
|
||||||
|
|
||||||
|
const { reportData, isLoading, runReport, openReport } = useReportTemplates();
|
||||||
|
const needsPrompt = dependsOn.filter((dep) => !dependencies.value[dep]);
|
||||||
|
|
||||||
|
if (needsPrompt.length > 0) {
|
||||||
|
$q.dialog({
|
||||||
|
component: ReportDependencyPrompt,
|
||||||
|
componentProps: { dependsOn: needsPrompt },
|
||||||
|
})
|
||||||
|
.onOk((deps) => (dependencies.value = { ...dependencies.value, ...deps }))
|
||||||
|
.onDismiss(() => {
|
||||||
|
openReport(props.id, props.format, dependsOn, dependencies.value, false);
|
||||||
|
|
||||||
|
runReport(props.id, {
|
||||||
|
format: props.format,
|
||||||
|
dependencies: dependencies.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
runReport(props.id, {
|
||||||
|
format: props.format,
|
||||||
|
dependencies: dependencies.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
144
src/ee/sso/api/sso.ts
Normal file
144
src/ee/sso/api/sso.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { getCookie } from "@/ee/sso/utils/cookies";
|
||||||
|
import { getBaseUrl } from "@/boot/axios";
|
||||||
|
import { useStorage } from "@vueuse/core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SSOAccount,
|
||||||
|
SSOProvider,
|
||||||
|
SSOSettingsType,
|
||||||
|
} from "@/ee/sso/types/sso";
|
||||||
|
|
||||||
|
const baseUrl = "accounts";
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
provider: string;
|
||||||
|
process: string;
|
||||||
|
callback_url: string;
|
||||||
|
csrfmiddlewaretoken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCSRFToken() {
|
||||||
|
return getCookie("csrftoken");
|
||||||
|
}
|
||||||
|
|
||||||
|
// needed for sso provider redirect
|
||||||
|
function postForm(url: string, data: FormData) {
|
||||||
|
const f = document.createElement("form");
|
||||||
|
f.method = "POST";
|
||||||
|
f.action = url;
|
||||||
|
|
||||||
|
for (const key in data) {
|
||||||
|
const d = document.createElement("input");
|
||||||
|
d.type = "hidden";
|
||||||
|
d.name = key;
|
||||||
|
d.value = data[key];
|
||||||
|
f.appendChild(d);
|
||||||
|
}
|
||||||
|
document.body.appendChild(f);
|
||||||
|
f.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// sso providers
|
||||||
|
|
||||||
|
export async function fetchSSOProviders(): Promise<SSOProvider[]> {
|
||||||
|
const { data } = await axios.get(`${baseUrl}/ssoproviders/`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSSOProvider(payload: SSOProvider) {
|
||||||
|
const { data } = await axios.post(`${baseUrl}/ssoproviders/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editSSOProvider(id: number, payload: SSOProvider) {
|
||||||
|
const { data } = await axios.put(`${baseUrl}/ssoproviders/${id}/`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSSOProvider(id: number) {
|
||||||
|
const { data } = await axios.delete(`${baseUrl}/ssoproviders/${id}/`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSSOSettings(): Promise<SSOSettingsType> {
|
||||||
|
const { data } = await axios.get(`${baseUrl}/ssoproviders/settings/`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSSOSettings(settings: SSOSettingsType) {
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`${baseUrl}/ssoproviders/settings/`,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSSOProviderToken() {
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`${baseUrl}/ssoproviders/token/`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { "X-CSRFToken": getCSRFToken() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectSSOAccount(
|
||||||
|
provider: string,
|
||||||
|
account: string,
|
||||||
|
): Promise<SSOAccount> {
|
||||||
|
const { data } = await axios.delete(`${baseUrl}/ssoproviders/account/`, {
|
||||||
|
data: { provider, account },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allauth
|
||||||
|
const allauthBase = "_allauth/browser/v1";
|
||||||
|
|
||||||
|
export interface AllAuthResponse<T> {
|
||||||
|
data: T;
|
||||||
|
status: number;
|
||||||
|
meta?: {
|
||||||
|
is_autheticated: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSOProviderConfig {
|
||||||
|
client_id: string;
|
||||||
|
flows: string[];
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSOConfigResponse {
|
||||||
|
socialaccount: {
|
||||||
|
providers: SSOProviderConfig[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSSOConfig(): Promise<
|
||||||
|
AllAuthResponse<SSOConfigResponse>
|
||||||
|
> {
|
||||||
|
const { data } = await axios.get(`${allauthBase}/config/`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSSOProviderRedirect(id: string) {
|
||||||
|
//save provider to local storage
|
||||||
|
useStorage("provider_id", id);
|
||||||
|
postForm(`${getBaseUrl()}/${allauthBase}/auth/provider/redirect/`, {
|
||||||
|
provider: id,
|
||||||
|
process: "login",
|
||||||
|
callback_url: `${location.origin}/account/provider/callback`,
|
||||||
|
csrfmiddlewaretoken: getCSRFToken() || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
142
src/ee/sso/components/SSOAccountsTable.vue
Normal file
142
src/ee/sso/components/SSOAccountsTable.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card style="width: 60vw; max-width: 90vw; min-height: 40vh">
|
||||||
|
<q-bar>
|
||||||
|
Connected Social Accounts for {{ user.username }}
|
||||||
|
<q-space />
|
||||||
|
<q-btn v-close-popup dense flat icon="close">
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
:table-class="{
|
||||||
|
'table-bgcolor': !$q.dark.isActive,
|
||||||
|
'table-bgcolor-dark': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
|
||||||
|
class="tbl-sticky"
|
||||||
|
:rows="user.social_accounts"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="{ rowsPerPage: 0, sortBy: 'display', descending: true }"
|
||||||
|
row-key="id"
|
||||||
|
binary-state-sort
|
||||||
|
virtual-scroll
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
>
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr>
|
||||||
|
<!-- rows -->
|
||||||
|
<td>{{ props.row.display }}</td>
|
||||||
|
<td>{{ props.row.provider }}</td>
|
||||||
|
<td>{{ formatDate(props.row.last_login) }}</td>
|
||||||
|
<td>{{ formatDate(props.row.date_joined) }}</td>
|
||||||
|
<td>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
@click="removeSSOAccount(props.row)"
|
||||||
|
label="Disconnect"
|
||||||
|
color="negative"
|
||||||
|
></q-btn>
|
||||||
|
</td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useDialogPluginComponent, useQuasar, type QTableColumn } from "quasar";
|
||||||
|
import { disconnectSSOAccount } from "@/ee/sso/api/sso";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { formatDate } from "@/utils/format";
|
||||||
|
|
||||||
|
//types
|
||||||
|
import type { SSOAccount, SSOUser } from "../types/sso";
|
||||||
|
|
||||||
|
const columns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "display",
|
||||||
|
label: "Display Name",
|
||||||
|
field: "display",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "provider",
|
||||||
|
label: "Provider",
|
||||||
|
field: "provider",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last_login",
|
||||||
|
label: "Last Login",
|
||||||
|
field: "last_login",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "date_joined",
|
||||||
|
label: "Date Joined",
|
||||||
|
field: "date_joined",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
label: "",
|
||||||
|
field: "action",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
user: SSOUser;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
function removeSSOAccount(account: SSOAccount) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Disconnect social account: ${account.display}?`,
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await disconnectSSOAccount(account.provider, account.uid);
|
||||||
|
notifySuccess("Social account disconnected successfully");
|
||||||
|
if (
|
||||||
|
auth.username === props.user.username &&
|
||||||
|
auth.ssoLoginProvider === account.provider
|
||||||
|
) {
|
||||||
|
await auth.logout();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
onDialogHide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
160
src/ee/sso/components/SSOProvidersForm.vue
Normal file
160
src/ee/sso/components/SSOProvidersForm.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog persistent ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card class="q-dialog-plugin" style="width: 35vw; max-width: 35vw">
|
||||||
|
<q-bar>
|
||||||
|
{{ props.provider ? "Edit OIDC Provider" : "Add OIDC Provider" }}
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<!-- name -->
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
:readonly="!!props.provider"
|
||||||
|
:disable="!!props.provider"
|
||||||
|
label="Provider Name"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
v-model="localProvider.name"
|
||||||
|
:rules="[
|
||||||
|
(val) => !!val || '*Required',
|
||||||
|
(val) =>
|
||||||
|
/^[a-zA-Z0-9_-]+$/.test(val) ||
|
||||||
|
'Only letters, numbers, hyphens, and underscores are allowed',
|
||||||
|
]"
|
||||||
|
hint="A unique identifier for the SSO provider. Avoid spaces and special characters, as this will be part of the callback URL."
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- url -->
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
label="Issuer URL"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
v-model="localProvider.server_url"
|
||||||
|
:rules="[(val) => !!val || '*Required']"
|
||||||
|
hint="The OpenID Connect Issuer URL provided by the SSO provider. This is typically the base URL where the provider hosts their OIDC configuration."
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- client id -->
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
label="Client ID"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
v-model="localProvider.client_id"
|
||||||
|
:rules="[(val) => !!val || '*Required']"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- secret -->
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
v-model="localProvider.secret"
|
||||||
|
filled
|
||||||
|
:type="hideSecret ? 'password' : 'text'"
|
||||||
|
label="Secret"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:rules="[(val) => !!val || '*Required']"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="hideSecret ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="hideSecret = !hideSecret"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<tactical-dropdown
|
||||||
|
label="Default User Role"
|
||||||
|
:options="roleOptions"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
mapOptions
|
||||||
|
filled
|
||||||
|
v-model="localProvider.role"
|
||||||
|
hint="The role assigned to users upon first sign-in through this provider."
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="Submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { useDialogPluginComponent, extend } from "quasar";
|
||||||
|
import { editSSOProvider, addSSOProvider } from "@/ee/sso/api/sso";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { useRoleDropdown } from "@/composables/accounts";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { SSOProvider } from "@/ee/sso/types/sso";
|
||||||
|
|
||||||
|
// define emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
// define props
|
||||||
|
const props = defineProps<{ provider?: SSOProvider }>();
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const { roleOptions } = useRoleDropdown({ onMount: true });
|
||||||
|
|
||||||
|
const hideSecret = ref(true);
|
||||||
|
const localProvider: SSOProvider = props.provider
|
||||||
|
? reactive(extend({}, props.provider))
|
||||||
|
: reactive({
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
client_id: "",
|
||||||
|
secret: "",
|
||||||
|
server_url: "",
|
||||||
|
role: null,
|
||||||
|
} as SSOProvider);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
props.provider
|
||||||
|
? await editSSOProvider(localProvider.id, localProvider)
|
||||||
|
: await addSSOProvider(localProvider);
|
||||||
|
onDialogOK();
|
||||||
|
notifySuccess("SSO Provider was edited!");
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
293
src/ee/sso/components/SSOProvidersTable.vue
Normal file
293
src/ee/sso/components/SSOProvidersTable.vue
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="text-subtitle2">SSO Providers</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
color="grey-5"
|
||||||
|
icon="fas fa-plus"
|
||||||
|
text-color="black"
|
||||||
|
label="Add OIDC Provider"
|
||||||
|
@click="addSSOProvider"
|
||||||
|
:disable="!ssoSettings.sso_enabled"
|
||||||
|
>
|
||||||
|
<q-tooltip v-if="!ssoSettings.sso_enabled" class="text-caption"
|
||||||
|
>Enable SSO in the settings to allow adding a provider.</q-tooltip
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<q-separator />
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
:rows="providers"
|
||||||
|
:columns="columns"
|
||||||
|
:visible-columns="visibleColumns"
|
||||||
|
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: true }"
|
||||||
|
row-key="id"
|
||||||
|
binary-state-sort
|
||||||
|
hide-pagination
|
||||||
|
virtual-scroll
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
no-data-label="No OIDC Providers added yet"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
<template v-slot:top>
|
||||||
|
<q-btn
|
||||||
|
@click="openSSOSettings"
|
||||||
|
label="SSO Settings"
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- body slots -->
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr
|
||||||
|
:props="props"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@dblclick="editSSOProvider(props.row)"
|
||||||
|
>
|
||||||
|
<!-- context menu -->
|
||||||
|
<q-menu context-menu>
|
||||||
|
<q-list dense style="min-width: 200px">
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="editSSOProvider(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="edit" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Edit</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="deleteSSOProvider(props.row)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="delete" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Delete</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<!-- callback url -->
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="getCallbackURL(props.row.callback_url)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="description" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Copy Callback URL</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<!-- javascript origin url (used by google oauth) -->
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="getCallbackURL(props.row.javascript_origin_url)"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="description" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section
|
||||||
|
>Copy Authorized JavaScript origin</q-item-section
|
||||||
|
>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup>
|
||||||
|
<q-item-section>Close</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
<!-- name -->
|
||||||
|
<q-td>
|
||||||
|
{{ truncateText(props.row.name, 25) }}
|
||||||
|
<q-tooltip>{{ props.row.name }}</q-tooltip>
|
||||||
|
</q-td>
|
||||||
|
<!-- server_url -->
|
||||||
|
<q-td>
|
||||||
|
{{ truncateText(props.row.server_url, 20) }}
|
||||||
|
<q-tooltip>{{ props.row.server_url }}</q-tooltip>
|
||||||
|
</q-td>
|
||||||
|
<!-- pattern -->
|
||||||
|
<q-td>
|
||||||
|
{{ truncateText(props.row.client_id, 20) }}
|
||||||
|
<q-tooltip>{{ props.row.client_id }}</q-tooltip>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-icon
|
||||||
|
size="sm"
|
||||||
|
name="content_copy"
|
||||||
|
@click="getCallbackURL(props.row.callback_url)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Copy Callback URL to Clipboard</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { computed, ref, onMounted } from "vue";
|
||||||
|
import { useStore } from "vuex";
|
||||||
|
import { QTableColumn, useQuasar, copyToClipboard } from "quasar";
|
||||||
|
import {
|
||||||
|
fetchSSOProviders,
|
||||||
|
removeSSOProvider,
|
||||||
|
fetchSSOSettings,
|
||||||
|
} from "@/ee/sso/api/sso";
|
||||||
|
import { notifySuccess } from "@/utils/notify";
|
||||||
|
import { truncateText } from "@/utils/format";
|
||||||
|
|
||||||
|
// ui imports
|
||||||
|
import SSOProvidersForm from "@/ee/sso/components/SSOProvidersForm.vue";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import { type SSOProvider, SSOSettingsType } from "@/ee/sso/types/sso";
|
||||||
|
import SSOSettings from "@/ee/sso/components/SSOSettings.vue";
|
||||||
|
|
||||||
|
// setup quasar
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// setup vuew store
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const providers = ref([] as SSOProvider[]);
|
||||||
|
const ssoSettings = ref({} as SSOSettingsType);
|
||||||
|
|
||||||
|
const columns: QTableColumn[] = [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
field: "name",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server_url",
|
||||||
|
label: "Server Url",
|
||||||
|
field: "server_url",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "client_id",
|
||||||
|
label: "Client ID",
|
||||||
|
field: "client_id",
|
||||||
|
align: "left",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "callback_url",
|
||||||
|
label: "Callback URL",
|
||||||
|
field: "callback_url",
|
||||||
|
align: "left",
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "javascript_origin_url",
|
||||||
|
label: "Javascript Origin URL",
|
||||||
|
field: "javascript_origin_url",
|
||||||
|
align: "left",
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleColumns = computed(() => {
|
||||||
|
return columns
|
||||||
|
.map((column) => column.name)
|
||||||
|
.filter((name) => name !== "javascript_origin_url");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getSSOSettings() {
|
||||||
|
try {
|
||||||
|
ssoSettings.value = await fetchSSOSettings();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSSOProviders() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
providers.value = await fetchSSOProviders();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSSOProvider() {
|
||||||
|
$q.dialog({
|
||||||
|
component: SSOProvidersForm,
|
||||||
|
}).onOk(getSSOProviders);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSSOProvider(provider: SSOProvider) {
|
||||||
|
$q.dialog({
|
||||||
|
component: SSOProvidersForm,
|
||||||
|
componentProps: {
|
||||||
|
provider: provider,
|
||||||
|
},
|
||||||
|
}).onOk(getSSOProviders);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSSOProvider(provider: SSOProvider) {
|
||||||
|
$q.dialog({
|
||||||
|
title: `Delete SSO Provider: ${provider.name}?`,
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: "Delete", color: "negative" },
|
||||||
|
}).onOk(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await removeSSOProvider(provider.id);
|
||||||
|
await getSSOProviders();
|
||||||
|
notifySuccess(`SSO Provider: ${provider.name} was deleted!`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCallbackURL(url: string) {
|
||||||
|
copyToClipboard(url).then(() => {
|
||||||
|
notifySuccess("URL copied!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSSOSettings() {
|
||||||
|
$q.dialog({
|
||||||
|
component: SSOSettings,
|
||||||
|
}).onOk((updatedSSOSettings: SSOSettingsType) => {
|
||||||
|
store.commit(
|
||||||
|
"setBlockLocalUserLogon",
|
||||||
|
updatedSSOSettings.block_local_user_logon,
|
||||||
|
);
|
||||||
|
ssoSettings.value = { ...updatedSSOSettings };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await getSSOSettings();
|
||||||
|
await getSSOProviders();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
112
src/ee/sso/components/SSOSettings.vue
Normal file
112
src/ee/sso/components/SSOSettings.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||||
|
<q-card class="q-dialog-plugin" style="width: 50">
|
||||||
|
<q-bar>
|
||||||
|
SSO Settings
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<!-- disable sso-->
|
||||||
|
<q-card-section>
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
label="Enable SSO"
|
||||||
|
v-model="ssoSettings.sso_enabled"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- block local user logon -->
|
||||||
|
<q-card-section>
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
label="Block Local User Login"
|
||||||
|
v-model="ssoSettings.block_local_user_logon"
|
||||||
|
:disable="!ssoSettings.sso_enabled"
|
||||||
|
hint="When enabled, only users with SSO accounts can log in, with the exception of local superuser accounts."
|
||||||
|
>
|
||||||
|
<q-tooltip class="text-caption"
|
||||||
|
>When enabled, only users with SSO accounts can log in, with the
|
||||||
|
exception of local superuser accounts.</q-tooltip
|
||||||
|
>
|
||||||
|
</q-checkbox>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="Submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// composition imports
|
||||||
|
import { ref, watch, onMounted } from "vue";
|
||||||
|
import { useDialogPluginComponent } from "quasar";
|
||||||
|
import { notifySuccess, notifyWarning } from "@/utils/notify";
|
||||||
|
import { fetchSSOSettings, updateSSOSettings } from "@/ee/sso/api/sso";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import { SSOSettingsType } from "../types/sso";
|
||||||
|
|
||||||
|
// define emits
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
|
||||||
|
|
||||||
|
const ssoSettings = ref({} as SSOSettingsType);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function getSSOSettings() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
ssoSettings.value = await fetchSSOSettings();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await updateSSOSettings(ssoSettings.value);
|
||||||
|
notifySuccess("Settings updated successfully");
|
||||||
|
onDialogOK(ssoSettings.value);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 423) {
|
||||||
|
notifyWarning(e.response.data, 7000);
|
||||||
|
}
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await getSSOSettings();
|
||||||
|
// watcher to disable block local login if sso is disabled
|
||||||
|
watch(
|
||||||
|
() => ssoSettings.value.sso_enabled,
|
||||||
|
(newValue) => {
|
||||||
|
if (!newValue) {
|
||||||
|
ssoSettings.value.block_local_user_logon = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
33
src/ee/sso/types/sso.ts
Normal file
33
src/ee/sso/types/sso.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User } from "@/types/accounts";
|
||||||
|
export interface SSOProvider {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
provider_id: string;
|
||||||
|
client_id: string;
|
||||||
|
secret: string;
|
||||||
|
server_url: string;
|
||||||
|
role: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSOAccount {
|
||||||
|
uid: string;
|
||||||
|
display: string;
|
||||||
|
provider: string;
|
||||||
|
last_login: string;
|
||||||
|
date_joined: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSOUser extends User {
|
||||||
|
social_accounts: SSOAccount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSOSettingsType {
|
||||||
|
sso_enabled: boolean;
|
||||||
|
block_local_user_logon: boolean;
|
||||||
|
}
|
||||||
21
src/ee/sso/utils/cookies.ts
Normal file
21
src/ee/sso/utils/cookies.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2023-present Amidaware Inc.
|
||||||
|
This file is subject to the EE License Agreement.
|
||||||
|
For details, see: https://license.tacticalrmm.com/ee
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function getCookie(name: string) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== "") {
|
||||||
|
const cookies = document.cookie.split(";");
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
// Does this cookie string begin with the name we want?
|
||||||
|
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user