Compare commits

...

149 Commits

Author SHA1 Message Date
wh1te909
fc5195e817 update reqs 2024-03-21 18:19:11 +00:00
Dan
efd5c3dca1 Merge pull request #25 from dinger1986/dinger1986-add-coname-to-initialsetup
Update InitialSetup.vue
2024-03-20 17:08:03 -07:00
Dan
2f438feec2 not needed 2024-03-20 17:07:17 -07:00
Dan
07ae9dfddf make style consistent with the other q-inputs 2024-03-20 17:04:34 -07:00
dinger1986
b72a86e514 Update InitialSetup.vue 2024-03-20 21:21:36 +00:00
wh1te909
62f0414afa fix date filter fixes amidaware/tacticalrmm#1803 2024-03-20 05:35:16 +00:00
wh1te909
200a02b87b update ci and bump version 2024-03-15 19:57:05 +00:00
wh1te909
da5dbeaf0f update reqs 2024-03-15 19:49:24 +00:00
wh1te909
4b6d099f72 add icon for tooltip and don't show in hosted 2024-03-13 01:30:37 +00:00
wh1te909
842661ada6 update browser/node 2024-03-13 01:29:22 +00:00
wh1te909
f5148c87c8 no longer need disable auto login 2024-03-12 05:27:18 +00:00
wh1te909
16164c0bbc update apexcharts 2024-03-10 22:37:17 +00:00
wh1te909
f38ddb840b update reqs 2024-03-08 18:42:40 +00:00
wh1te909
f86fe26ffe fix wording 2024-03-08 18:42:32 +00:00
Dan
162360bf45 Merge pull request #21 from dinger1986/develop
Update TakeControl.vue
2024-03-02 12:43:27 -08:00
dinger1986
612aaa7880 Update TakeControl.vue 2024-02-26 21:51:29 +00:00
wh1te909
e91f3fe53d sync mesh users/perms with trmm amidaware/tacticalrmm#182 2024-02-23 21:30:27 +00:00
wh1te909
f0fe4d64bc same perms 2024-02-23 03:17:35 +00:00
Dan
07cc6aca6a Merge pull request #20 from conlan0/develop
Add Shutdown option to agent action menu
2024-02-22 13:50:14 -08:00
wh1te909
23bf81efbb fix js/typescript monaco support in editor 2024-02-22 20:50:03 +00:00
wh1te909
a55105e5ee format 2024-02-22 20:49:33 +00:00
Dan
5832a426bc Merge pull request #14 from NiceGuyIT/feature/cross-platform-scripting
[Feature] Add cross site scripting
2024-02-21 21:23:26 -08:00
conlan0
38dc709108 Add shutdown url 2024-02-21 21:22:37 -05:00
conlan0
5696d3359b Add Shutdown option 2024-02-21 21:19:20 -05:00
wh1te909
1b4fa84753 update reqs 2024-02-21 01:13:38 +00:00
wh1te909
2db4eeec05 bump version 2024-02-03 01:44:28 +00:00
Dan
fe5e8aa5fe Merge pull request #17 from JordanLukeJones/JordanLukeJones-OTP-1
Update LoginView.vue
2024-02-02 17:40:36 -08:00
Jordan Jones
13e35d24a2 Update LoginView.vue
Add's ability to auto-populate OTP on compatible devices
2024-02-02 09:29:19 +00:00
wh1te909
5e0fab88a3 bump version 2024-02-02 00:53:27 +00:00
wh1te909
bf8797264b feat: hide custom fields in summary tab only amidaware/tacticalrmm#1745 2024-01-28 03:22:54 +00:00
wh1te909
14bde967bd feat: add serial number to linux/mac amidaware/tacticalrmm#1683 2024-01-27 02:47:23 +00:00
wh1te909
596ce69789 feat: add from name to email amidaware/tacticalrmm#1726 2024-01-26 00:38:12 +00:00
wh1te909
c5491dcb73 feat: add time and ret code to script test closes amidaware/tacticalrmm#1713 2024-01-26 00:04:58 +00:00
wh1te909
3f6340f0a1 update reqs 2024-01-26 00:04:19 +00:00
wh1te909
351f0870a9 update reqs 2024-01-19 08:09:06 +00:00
Dan
f2638a4c5e Merge pull request #16 from silversword411/develop
Increase user preferences width
2024-01-18 23:14:41 -08:00
silversword411
2bd00d5ca0 Increase user preferences width 2024-01-19 05:31:47 +00:00
wh1te909
00a40dd450 forgot to include 80% 2024-01-17 19:19:00 +00:00
wh1te909
16fb75b56c bump version 2023-12-22 17:49:31 +00:00
wh1te909
094cf45ce3 add M3 2023-12-22 17:44:55 +00:00
wh1te909
d6984b3da9 use ticket instead of email 2023-12-22 17:44:36 +00:00
sadnub
53fc6f4cde fix folder view 2023-12-12 10:50:32 -05:00
wh1te909
49da10cf0b bump version 2023-12-01 18:53:26 +00:00
wh1te909
a3e10910bf wording 2023-12-01 18:52:01 +00:00
wh1te909
3ff9edc424 update reqs 2023-11-29 22:41:49 +00:00
sadnub
69414d4083 add unsaved changes to new scripts and close and cancel buttons 2023-11-29 10:24:13 -05:00
sadnub
e06b7a7775 add last_seen date to summary tab as a tooltip 2023-11-24 18:34:29 -05:00
sadnub
c006e4d922 make policy tasks sort ascending 2023-11-24 18:19:39 -05:00
sadnub
df6fe0863b change sort order for reports manager 2023-11-24 18:17:29 -05:00
sadnub
d55a29911c add trmm logo to community scripts in dropdown and improve img loading with es6 import 2023-11-24 17:47:07 -05:00
sadnub
d0e49d27fd add prompt for close if using esc in script manager 2023-11-24 17:02:45 -05:00
sadnub
1299bfc93e add trmm icon to builtin scripts 2023-11-24 16:19:33 -05:00
sadnub
be999646d4 make policy status width larger 2023-11-24 15:50:44 -05:00
sadnub
e57d32f122 add onboarding task in ui 2023-11-22 23:37:18 -05:00
wh1te909
08fa8da735 bump version 2023-11-23 00:03:10 +00:00
David Randall
fe8d88497f [Feature] Add cross site scripting 2023-11-12 15:37:55 -05:00
sadnub
4ab31a529e update to node 18 2023-11-11 13:51:48 -05:00
wh1te909
466725d5c2 rework uninstall perm amidaware/tacticalrmm#1673 2023-11-10 01:20:25 +00:00
wh1te909
908b337797 bump version 2023-11-07 17:24:22 +00:00
wh1te909
fea5258903 update deps 2023-11-07 17:23:25 +00:00
Dan
5521e4ea3e Merge pull request #13 from silversword411/develop
Increase name field to show 50chars
2023-11-06 13:07:24 -08:00
silversword411
6cc01596cb Increase name field to show 50chars 2023-11-06 12:14:35 -05:00
sadnub
0694538482 fix editors not closing properly on Escape key press 2023-11-04 23:56:22 -04:00
wh1te909
ac05ad40c0 bump version 2023-10-31 17:50:59 +00:00
wh1te909
239b0182fb add some icons 2023-10-31 07:12:54 +00:00
sadnub
4c57e5da4b put shared template scripts in ascending order 2023-10-29 15:47:27 -04:00
wh1te909
298d039028 remove ace 2023-10-29 19:33:04 +00:00
sadnub
021a066074 watch for language changes and update the snippet editor 2023-10-29 15:16:54 -04:00
sadnub
7dc2f5a658 watch for language changes and update the editor 2023-10-29 15:05:51 -04:00
sadnub
57bd8bafac exported templates now are readable with spaces 2023-10-29 10:55:07 -04:00
sadnub
7d5216aba9 prevent linux shebang banner opening and closing on script load 2023-10-29 10:31:51 -04:00
sadnub
076ab0c465 fix script body not loading in editor. Fix slow unload times for monaco editor 2023-10-29 10:24:16 -04:00
wh1te909
be37e89e16 update reqs 2023-10-29 09:08:02 +00:00
wh1te909
0bdc841084 change wording 2023-10-29 08:57:53 +00:00
wh1te909
96086d0b5d fix wording 2023-10-28 22:06:52 +00:00
wh1te909
9c8719e4bf bump version 2023-10-25 15:58:58 +00:00
wh1te909
d273dfa325 update reqs 2023-10-25 03:59:11 +00:00
Dan
38b74bf5dc Merge pull request #1 from sadnub/feat-reporting
Reporting Frontend Changes
2023-10-24 19:00:25 -07:00
wh1te909
ab5cd3b279 add header 2023-10-25 01:58:00 +00:00
wh1te909
55695c04a2 update wording 2023-10-25 01:52:59 +00:00
wh1te909
8c5ae39b10 remove console info 2023-10-25 01:16:37 +00:00
wh1te909
06d2fc4d16 update license 2023-10-24 05:40:40 +00:00
wh1te909
b392ca6d2f update reqs 2023-10-23 23:31:31 +00:00
wh1te909
8eb216eadd add reporting perms 2023-10-20 22:26:00 +00:00
wh1te909
e6300c47cd add import icon 2023-10-16 23:18:45 +00:00
sadnub
4743d3eeeb fix some typings 2023-10-15 17:37:43 -04:00
sadnub
ccecf0dc36 initial wip shared templates 2023-10-14 23:08:25 -04:00
sadnub
2720e1ea92 fix tab not selecting for markdown 2023-10-13 17:43:48 -04:00
sadnub
44d561f0a1 fix url when loading report dependencies 2023-10-09 17:38:07 -04:00
sadnub
482e092f49 add download report options in report manager and right-click context menus for site, client, and agent 2023-10-08 22:13:51 -04:00
sadnub
9d11a5bc4e show error message when parsing json in query editor 2023-10-08 14:49:56 -04:00
sadnub
80aa321b57 fix issue with report view white screen 2023-10-08 12:56:09 -04:00
sadnub
4f6bce3817 allow editing querys in templates and make insert data source add the query inline 2023-10-08 12:54:11 -04:00
sadnub
9ef7a0e4af fix rendering markdown in preview 2023-10-06 16:30:00 -04:00
sadnub
f5f984c6c5 fix open html menu item not working for markdown templates 2023-10-06 12:04:01 -04:00
sadnub
cb9488b01c add in plain text template type 2023-10-05 12:57:31 -04:00
sadnub
dba81c010a fix reports view when there are empty dependsOn or dependencies 2023-10-04 10:35:58 -04:00
sadnub
598f0ee7d6 add filterable dropdowns for dependency selection 2023-10-04 08:43:05 -04:00
sadnub
0226e54f96 add an bar above the variables editor and make opening and closing easier 2023-10-03 20:41:06 -04:00
wh1te909
b591b87f48 update reqs 2023-10-03 21:09:29 +00:00
sadnub
99e4607500 fix auto wrapping { or } in variables 2023-10-02 20:45:27 -04:00
sadnub
00b4896c3d fix quotes during import and moved the upload template button to dropdown 2023-10-02 19:37:08 -04:00
sadnub
817a37c241 fix dependencies validation bug when running reports and also replace the report url with dependencies 2023-10-02 18:58:00 -04:00
sadnub
01bcada53f fix dependencies 2023-10-02 18:57:41 -04:00
sadnub
2a370ea9b2 fix data queries not inserting when data_sources is null 2023-10-02 18:54:07 -04:00
sadnub
7ea828ffcf make nicer column names in tablemaker and make dialog bigger 2023-10-02 18:54:07 -04:00
sadnub
3a98d93bf4 add save prompt of unedited changes are detected on close 2023-10-02 18:54:07 -04:00
sadnub
73a72b7f1f fix random lockups when closing editors and other fixes 2023-10-02 18:54:07 -04:00
wh1te909
89545a99f3 handle dark/light mode 2023-10-02 18:54:07 -04:00
wh1te909
2f0bc3bd9b change query schema and handle dark mode 2023-10-02 18:54:07 -04:00
sadnub
8df4409866 fix package-lock 2023-10-02 18:54:05 -04:00
sadnub
1686a15839 vscode changes 2023-10-02 18:52:15 -04:00
sadnub
397413edfe popup 2023-10-02 18:52:15 -04:00
sadnub
5b293fa421 start help menu 2023-10-02 18:52:15 -04:00
sadnub
2335d90af6 start on table maker ui 2023-10-02 18:52:15 -04:00
sadnub
0778bee453 ui improvements 2023-10-02 18:52:15 -04:00
sadnub
2b3916a98a improve UI for variables sidebar 2023-10-02 18:52:15 -04:00
sadnub
1e90dfc556 variables introspection add 2023-10-02 18:52:15 -04:00
sadnub
07c001dc09 add debug mode for preview. import/export templates. More fixes and formatting 2023-10-02 18:52:15 -04:00
sadnub
91a29302d9 add autosave and some bug fixes 2023-10-02 18:52:15 -04:00
sadnub
dddd31741d wrap double quotes around {{}} tags automatically 2023-10-02 18:52:15 -04:00
sadnub
dbe24f1d09 charting stuff 2023-10-02 18:52:15 -04:00
sadnub
3fd44835fa update package-lock 2023-10-02 18:52:12 -04:00
sadnub
93217b495c more improvements 2023-10-02 18:50:23 -04:00
sadnub
cdbbddda7a Load reports manager as an integration 2023-10-02 18:49:11 -04:00
sadnub
00df9296bf fix report assets and add allow selecting in the template form 2023-10-02 18:49:11 -04:00
sadnub
cb301d34a6 minor fixes 2023-10-02 18:49:11 -04:00
sadnub
f7e6ebc69f data source select in template editor 2023-10-02 18:49:11 -04:00
sadnub
01a13f31f3 fix icon name 2023-10-02 18:49:11 -04:00
sadnub
e0c37faee8 fill out code functions for html template and report template 2023-10-02 18:49:11 -04:00
sadnub
f87f4970be fix url 2023-10-02 18:49:11 -04:00
sadnub
4d409ea1ae resolve query editor schema over http 2023-10-02 18:49:11 -04:00
sadnub
ee30314a3e reporting wip 2023-10-02 18:49:01 -04:00
wh1te909
0d87f5afee bump version 2023-10-01 17:36:39 +00:00
wh1te909
1b83c3c5d6 fix tooltip 2023-10-01 17:36:11 +00:00
wh1te909
34233fde2f bump version 2023-09-30 21:58:51 +00:00
wh1te909
e95dd5f6e7 use node 18 2023-09-30 21:57:37 +00:00
wh1te909
901e6986a0 update reqs 2023-09-30 21:56:24 +00:00
wh1te909
aa78929743 update reqs 2023-09-11 02:50:40 +00:00
wh1te909
1879977b83 bump version [skip ci] 2023-08-30 04:09:53 +00:00
wh1te909
b4de579a74 fix lint 2023-08-29 20:42:23 +00:00
wh1te909
23f15ff9e5 feat: add reset all check status amidaware/tacticalrmm#1615 2023-08-29 20:21:22 +00:00
wh1te909
498e038bbb update reqs 2023-08-29 20:19:35 +00:00
wh1te909
bb1f1c19cf wording 2023-08-21 21:30:21 +00:00
Dan
74e3aa4e46 Merge pull request #12 from silversword411/develop
Fixing main view removing blank space along bottom
2023-08-21 14:28:28 -07:00
silversword411
07a8e3ebcb Clarifying TRMM agent 2023-08-21 09:28:23 -04:00
silversword411
89966dd006 Fixing main view removing blank space along bottom 2023-08-18 02:39:43 -04:00
wh1te909
45ac82b1dd bump version 2023-08-14 06:39:31 +00:00
wh1te909
d94e5c7965 update reqs 2023-08-11 07:07:00 +00:00
72 changed files with 8326 additions and 7068 deletions

View File

@@ -1,9 +1,9 @@
version: '3.4'
version: '3.7'
services:
app-dev:
container_name: trmm-app-dev
image: node:16-alpine
image: node:18-alpine
restart: always
command: /bin/sh -c "npm install --cache ~/.npm && npm run serve"
user: 1000:1000

View File

@@ -11,11 +11,11 @@ jobs:
name: Build web
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: "20.11.1"
- run: touch env-config.js
@@ -29,6 +29,6 @@ jobs:
run: tar -czvf trmm-web-${{github.ref_name}}.tar.gz dist/
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
files: trmm-web-${{github.ref_name}}.tar.gz

View File

@@ -13,7 +13,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
- run: npm install
- name: Run Prettier formatting

View File

@@ -9,13 +9,12 @@
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"typescript.tsdk": "node_modules/typescript/lib",
"files.watcherExclude": {
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/": true,
"/node_modules/**": true,
"**/env/": true,
"/env/**": true
}
}
},
"prettier.prettierPath": "./node_modules/prettier"
}

7560
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.101.25",
"version": "0.101.42",
"private": true,
"productName": "Tactical RMM",
"scripts": {
@@ -10,31 +10,37 @@
"format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore"
},
"dependencies": {
"@quasar/extras": "1.16.5",
"apexcharts": "3.41.1",
"axios": "1.4.0",
"dotenv": "16.3.1",
"@quasar/extras": "1.16.9",
"apexcharts": "3.48.0",
"axios": "1.6.8",
"dotenv": "16.4.5",
"pinia": "^2.1.7",
"qrcode.vue": "3.4.1",
"quasar": "2.12.3",
"vue": "3.3.4",
"vue3-ace-editor": "2.2.3",
"vue3-apexcharts": "1.4.4",
"quasar": "2.15.1",
"vue": "3.4.21",
"vue3-apexcharts": "1.5.2",
"vuedraggable": "4.1.0",
"vue-router": "4.2.4",
"vuex": "4.1.0"
"vue-router": "4.3.0",
"@vueuse/core": "10.9.0",
"@vueuse/shared": "10.9.0",
"monaco-editor": "0.47.0",
"vuex": "4.1.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.4.1"
},
"devDependencies": {
"@quasar/cli": "^2.2.1",
"@intlify/unplugin-vue-i18n": "^0.12.2",
"@quasar/app-vite": "^1.4.3",
"@types/node": "^20.4.8",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"autoprefixer": "10.4.14",
"eslint": "8.46.0",
"eslint-config-prettier": "9.0.0",
"@quasar/cli": "2.4.0",
"@intlify/unplugin-vue-i18n": "3.0.1",
"@quasar/app-vite": "1.8.0",
"@types/node": "20.11.30",
"@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.3.1",
"autoprefixer": "10.4.18",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-vue": "8.7.1",
"prettier": "3.0.1",
"typescript": "5.1.6"
"prettier": "3.2.5",
"typescript": "5.4.3"
}
}

View File

@@ -29,14 +29,14 @@ module.exports = configure(function (/* ctx */) {
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ["axios"],
boot: ["axios", "monaco", "integrations"],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: ["app.sass"],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
"ionicons-v4",
"mdi-v5",
"fontawesome-v6",
// 'eva-icons',
@@ -51,8 +51,8 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
target: {
browser: ["es2021"],
node: "node16",
browser: ["es2022"],
node: "node20",
},
vueRouterMode: "history", // available values: 'hash', 'history'

View File

@@ -191,6 +191,11 @@ export async function agentRebootNow(agent_id) {
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 = {}) {
const { data } = await axios.post(
`${baseUrl}/${agent_id}/meshcentral/recover/`,

View File

@@ -31,6 +31,11 @@ export async function resetCheck(id) {
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) {
const { data } = await axios.post(`${baseUrl}/${agent_id}/run/`);
return data;

BIN
src/assets/trmm_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -9,6 +9,15 @@ export const getBaseUrl = () => {
}
};
export function setErrorMessage(data, message) {
console.log(data);
return [
() => {
message;
},
];
}
export default function ({ app, router, store }) {
app.config.globalProperties.$axios = axios;
@@ -19,6 +28,12 @@ export default function ({ app, router, store }) {
if (token != null) {
config.headers.Authorization = `Token ${token}`;
}
// config.transformResponse = [
// function (data) {
// console.log(data);
// return data;
// },
// ];
return config;
},
function (err) {

10
src/boot/integrations.ts Normal file
View 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
View 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();
},
};
});

View File

@@ -170,7 +170,7 @@
overdueAlert(
'dashboard',
props.row,
props.row.overdue_dashboard_alert
props.row.overdue_dashboard_alert,
)
"
v-model="props.row.overdue_dashboard_alert"
@@ -431,8 +431,8 @@ export default {
return false;
else if (availability === "expired") {
let now = new Date();
let lastSeen = date.extractDate(row.last_seen, "MM DD YYYY HH:mm");
let diff = date.getDateDiff(now, lastSeen, "days");
let last_seen_unix = new Date(row.boot_time * 1000);
let diff = date.getDateDiff(now, last_seen_unix, "days");
if (diff < 30) return false;
}
}

View File

@@ -149,6 +149,49 @@
</q-list>
</q-menu>
</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 -->
<q-btn v-if="!hosted" size="md" dense no-caps flat label="Help">
<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 PermissionsManager from "@/components/accounts/PermissionsManager.vue";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { notifyWarning } from "@/utils/notify";
export default {
name: "FileBar",
mixins: [mixins],
@@ -396,6 +442,11 @@ export default {
component: DeploymentTable,
});
},
showReportsManager() {
this.$q.dialog({
component: ReportsManager,
});
},
},
};
</script>

View 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>

View File

@@ -27,6 +27,21 @@
</div>
</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>
<q-separator />
<q-card-section class="row">
@@ -70,10 +85,6 @@
v-model="localRole.can_uninstall_agents"
label="Uninstall Agents"
/>
<q-checkbox
v-model="localRole.can_ping_agents"
label="Ping Agents"
/>
<q-checkbox
v-model="localRole.can_update_agents"
label="Update Agents"
@@ -96,7 +107,7 @@
/>
<q-checkbox
v-model="localRole.can_reboot_agents"
label="Reboot Agents"
label="Shutdown / Reboot Agents"
/>
<q-checkbox
v-model="localRole.can_send_wol"
@@ -432,7 +443,6 @@ export default {
can_uninstall_agents: false,
can_update_agents: false,
can_edit_agent: false,
can_ping_agents: false,
can_manage_procs: false,
can_view_eventlogs: false,
can_send_cmd: false,
@@ -501,6 +511,9 @@ export default {
can_manage_roles: false,
can_view_clients: [],
can_view_sites: [],
// reporting perms
can_view_reports: false,
can_manage_reports: false,
});
const loading = ref(false);
@@ -528,7 +541,7 @@ export default {
role.value[key] = newValue;
}
});
}
},
);
return {

View File

@@ -176,6 +176,13 @@
</q-menu>
</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-section side>
<q-icon size="xs" name="policy" />
@@ -183,6 +190,24 @@
<q-item-section>Assign Automation Policy</q-item-section>
</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-section side>
<q-icon size="xs" name="fas fa-first-aid" />
@@ -213,6 +238,7 @@ import { fetchURLActions, runURLAction } from "@/api/core";
import {
editAgent,
agentRebootNow,
agentShutdown,
sendAgentPing,
removeAgent,
runRemoteBackground,
@@ -232,9 +258,13 @@ import RebootLater from "@/components/modals/agents/RebootLater.vue";
import EditAgent from "@/components/modals/agents/EditAgent.vue";
import SendCommand from "@/components/modals/agents/SendCommand.vue";
import RunScript from "@/components/modals/agents/RunScript.vue";
import IntegrationsContextMenu from "@/components/ui/IntegrationsContextMenu.vue";
export default {
name: "AgentActionMenu",
components: {
IntegrationsContextMenu,
},
props: {
agent: !Object,
},
@@ -276,7 +306,7 @@ export default {
if (urlActions.value.length === 0) {
notifyWarning(
"No URL Actions configured. Go to Settings > Global Settings > URL Actions"
"No URL Actions configured. Go to Settings > Global Settings > URL Actions",
);
return;
}
@@ -342,7 +372,7 @@ export default {
notifySuccess(
`Maintenance mode was ${
agent.maintenance_mode ? "disabled" : "enabled"
} on ${agent.hostname}`
} on ${agent.hostname}`,
);
store.commit("setRefreshSummaryTab", true);
refreshDashboard();
@@ -415,6 +445,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) {
$q.dialog({
component: PolicyAdd,
@@ -483,7 +539,7 @@ export default {
notifySuccess(data);
refreshDashboard(
false /* clearTreeSelected */,
true /* clearSubTable */
true /* clearSubTable */,
);
} catch (e) {
console.error(e);
@@ -512,6 +568,7 @@ export default {
runChecks,
showRebootLaterModal,
rebootNow,
shutdown,
showPolicyAdd,
showAgentRecovery,
pingAgent,

View File

@@ -119,6 +119,16 @@
no-caps
icon="play_arrow"
@click="runChecks"
class="q-mr-md"
/>
<q-btn
label="Reset All Checks Status"
dense
flat
push
no-caps
icon="restart_alt"
@click="resetAllChecks"
/>
</template>
@@ -415,6 +425,7 @@ import {
updateCheck,
removeCheck,
resetCheck,
resetAllChecksStatus,
runAgentChecks,
} from "@/api/checks";
import { fetchAgentChecks } from "@/api/agents";
@@ -572,7 +583,7 @@ export default {
notifySuccess(result);
refreshDashboard(
false /* clearTreeSelected */,
false /* clearSubTable */
false /* clearSubTable */,
);
} catch (e) {
console.error(e);
@@ -580,6 +591,26 @@ export default {
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) {
$q.dialog({
component: EventLogCheckOutput,
@@ -674,6 +705,7 @@ export default {
formatDate,
getAlertSeverity,
runChecks,
resetAllChecks,
// dialogs
showScriptOutput,

View File

@@ -34,7 +34,7 @@
:color="dash_warning_color"
class="q-mr-sm"
>
<q-tooltip>Agent offline</q-tooltip>
<q-tooltip>{{ store.getters.formatDate(summary.last_seen) }}</q-tooltip>
</q-icon>
<q-icon
v-else
@@ -43,7 +43,7 @@
:color="dash_positive_color"
class="q-mr-sm"
>
<q-tooltip>Agent online</q-tooltip>
<q-tooltip>{{ store.getters.formatDate(summary.last_seen) }}</q-tooltip>
</q-icon>
<b>{{ summary.hostname }}</b>
<span v-if="summary.maintenance_mode">
@@ -267,7 +267,11 @@ export default {
const loading = ref(false);
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(() => {
@@ -280,7 +284,7 @@ export default {
function diskBarColor(percent) {
if (percent < 80) {
return dash_positive_color.value;
} else if (percent > 80 && percent < 95) {
} else if (percent >= 80 && percent < 95) {
return dash_warning_color.value;
} else {
return dash_negative_color.value;
@@ -311,11 +315,11 @@ export default {
const ret = [];
for (const customField of summary.value.custom_fields) {
const definition = customFieldsDefinitions.value.find(
(def) => def.id === customField.field
(def) => def.id === customField.field,
);
if (
definition &&
!definition.hide_in_ui &&
!definition.hide_in_summary &&
customField.value?.length > 0
) {
ret.push({
@@ -381,6 +385,7 @@ export default {
dash_negative_color,
serial_number,
cpu,
store,
// methods
getSummary,

View File

@@ -254,7 +254,7 @@ export default {
pagination: {
rowsPerPage: 0,
sortBy: "name",
descending: true,
descending: false,
},
};
},
@@ -321,7 +321,7 @@ export default {
runTask(task) {
if (!task.enabled) {
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;
}

View File

@@ -1,6 +1,6 @@
<template>
<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>
{{ title.slice(0, 27) }}
<q-space />

View File

@@ -137,7 +137,7 @@
<q-radio
v-model="goarch"
:val="GOARCH_ARM64"
label="Apple Silicon (M1, M2)"
label="Apple Silicon (M1, M2, M3)"
v-show="agentOS === 'darwin'"
/>
<q-radio

View File

@@ -142,6 +142,11 @@
v-model="localField.hide_in_ui"
color="green"
/>
<q-toggle
label="Hide in Summary Tab"
v-model="localField.hide_in_summary"
color="green"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
@@ -172,6 +177,7 @@ export default {
default_value_bool: false,
default_values_multiple: [],
hide_in_ui: false,
hide_in_summary: false,
},
modelOptions: [
{ label: "Client", value: "client" },

View File

@@ -57,6 +57,10 @@
<q-td>
<q-icon v-if="props.row.hide_in_ui" name="check" />
</q-td>
<!-- hide in summary tab -->
<q-td>
<q-icon v-if="props.row.hide_in_summary" name="check" />
</q-td>
<!-- default value -->
<q-td v-if="props.row.type === 'checkbox'">
{{ props.row.default_value_bool }}
@@ -123,6 +127,13 @@ export default {
align: "left",
sortable: true,
},
{
name: "hide_in_summary",
label: "Hide in Summary Tab",
field: "hide_in_summary",
align: "left",
sortable: true,
},
{
name: "default_value",
label: "Default Value",

View File

@@ -71,7 +71,7 @@
icon="info"
@click="
openURL(
'https://quasar.dev/quasar-utils/date-utils#format-for-display'
'https://quasar.dev/quasar-utils/date-utils#format-for-display',
)
"
>
@@ -216,7 +216,7 @@
<div class="text-subtitle2">SMTP Settings</div>
<q-separator />
<q-card-section class="row">
<div class="col-2">From:</div>
<div class="col-2">From email:</div>
<div class="col-4"></div>
<q-input
outlined
@@ -226,6 +226,16 @@
:rules="[(val) => isValidEmail(val) || 'Invalid email']"
/>
</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">
<div class="col-2">Host:</div>
<div class="col-4"></div>
@@ -379,7 +389,7 @@
<q-tab-panel name="meshcentral">
<div class="text-subtitle2">MeshCentral Settings</div>
<q-separator />
<q-card-section class="row">
<q-card-section class="row" v-if="!hosted">
<div class="col-4">Username:</div>
<div class="col-2"></div>
<q-input
@@ -395,7 +405,7 @@
]"
/>
</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-2"></div>
<q-input
@@ -405,7 +415,7 @@
class="col-6"
/>
</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-2"></div>
<q-input
@@ -415,7 +425,7 @@
class="col-6"
/>
</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-2"></div>
<q-input
@@ -425,17 +435,45 @@
class="col-6"
/>
</q-card-section>
<q-card-section class="row">
<div class="col-4">
Disable Auto Login for Remote Control and Remote background:
<q-card-section class="row" v-if="!hosted">
<div class="col-4 flex items-center">
Sync MeshCentral Users/Permissions with TRMM:
</div>
<div class="col-2"></div>
<q-checkbox
dense
v-model="settings.mesh_disable_auto_login"
v-model="settings.sync_mesh_with_trmm"
class="col-6"
/>
</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 name="customfields">
<CustomFields />
@@ -635,6 +673,11 @@ export default {
],
};
},
computed: {
hosted() {
return this.$store.state.hosted;
},
},
methods: {
openURL(url) {
openURL(url);
@@ -711,13 +754,13 @@ export default {
},
removeEmail(email) {
const removed = this.settings.email_alert_recipients.filter(
(k) => k !== email
(k) => k !== email,
);
this.settings.email_alert_recipients = removed;
},
removeSMSNumber(num) {
const removed = this.settings.sms_alert_recipients.filter(
(k) => k !== num
(k) => k !== num,
);
this.settings.sms_alert_recipients = removed;
},

View File

@@ -1,6 +1,6 @@
<template>
<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">
<template v-slot:before>
<q-tabs dense v-model="tab" vertical class="text-primary">
@@ -201,7 +201,7 @@
icon="info"
@click="
openURL(
'https://quasar.dev/quasar-utils/date-utils#format-for-display'
'https://quasar.dev/quasar-utils/date-utils#format-for-display',
)
"
>
@@ -315,7 +315,7 @@ export default {
this.$axios.get("/core/urlaction/").then((r) => {
if (r.data.length === 0) {
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;
}

View File

@@ -1,15 +1,14 @@
<template>
<q-dialog
ref="dialogRef"
maximized
no-esc-dismiss
@hide="onDialogHide"
persistent
@keydown.esc.stop="onDialogHide"
:maximized="maximized"
@show="loadEditor"
@before-hide="unloadEditor"
@keydown.esc.stop="closeEditor"
>
<q-card
class="q-dialog-plugin"
:style="maximized ? '' : 'width: 90vw; max-width: 90vw'"
>
<q-card class="q-dialog-plugin">
<q-bar>
<span class="q-pr-sm">{{ title }}</span>
<q-btn
@@ -23,230 +22,194 @@
@click="generateScriptOpenAI"
/>
<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" @click="closeEditor">
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-form @submit="submitForm">
<q-banner
v-if="missingShebang"
dense
inline-actions
class="text-black bg-warning"
<q-banner
v-if="script.script_body && missingShebang"
dense
inline-actions
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>
<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: `${maximized ? '82vh' : '64vh'}` }"
>
<div class="q-gutter-sm q-pr-sm">
<q-input
filled
dense
:readonly="readonly"
v-model="formScript.name"
label="Name"
:rules="[(val) => !!val || '*Required']"
hide-bottom-space
/>
<q-input
filled
dense
:readonly="readonly"
v-model="formScript.description"
label="Description"
/>
<q-select
:readonly="readonly"
options-dense
filled
dense
v-model="formScript.shell"
:options="shellOptions"
emit-value
map-options
label="Shell Type"
/>
<tactical-dropdown
v-model="formScript.supported_platforms"
:options="agentPlatformOptions"
label="Supported Platforms (All supported if blank)"
clearable
mapOptions
filled
multiple
:readonly="readonly"
/>
<tactical-dropdown
filled
v-model="formScript.category"
:options="categories"
use-input
clearable
new-value-mode="add-unique"
filterable
label="Category"
:readonly="readonly"
hide-bottom-space
/>
<tactical-dropdown
v-model="formScript.args"
label="Script Arguments (press Enter after typing each argument)"
filled
use-input
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
:readonly="readonly"
/>
<tactical-dropdown
v-model="formScript.env_vars"
:label="envVarsLabel"
filled
use-input
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
:readonly="readonly"
/>
<q-input
type="number"
filled
dense
:readonly="readonly"
v-model.number="formScript.default_timeout"
label="Timeout (seconds)"
:rules="[(val) => val >= 5 || 'Minimum is 5']"
hide-bottom-space
/>
<q-checkbox
v-model="formScript.run_as_user"
label="Run As User (Windows only)"
>
<q-tooltip
>Setting this value on the script model will always override
any 'Run As User' checkboxes in the UI and force this script
to always be 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.
</q-tooltip>
</q-checkbox>
<q-input
label="Syntax"
type="textarea"
style="height: 150px; overflow-y: auto; resize: none"
v-model="formScript.syntax"
dense
filled
:readonly="readonly"
/>
</div>
</q-scroll-area>
<v-ace-editor
v-model:value="formScript.script_body"
class="col-8"
:lang="lang"
:theme="$q.dark.isActive ? 'tomorrow_night_eighties' : 'tomorrow'"
:style="{ height: `${maximized ? '82vh' : '64vh'}` }"
wrap
:printMargin="false"
:options="{ fontSize: '14px' }"
/>
</div>
<q-card-actions>
<tactical-dropdown
style="width: 350px"
dense
:loading="agentLoading"
filled
v-model="agent"
:options="agentOptions"
label="Agent to run test script on"
mapOptions
filterable
>
<template v-slot:after>
<q-btn
size="md"
color="primary"
dense
flat
label="Test Script"
:disable="
!agent ||
!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>
<div class="q-gutter-sm q-pr-sm">
<q-input
filled
dense
:readonly="readonly"
v-model="script.name"
label="Name"
:rules="[(val) => !!val || '*Required']"
hide-bottom-space
/>
<q-input
filled
dense
:readonly="readonly"
v-model="script.description"
label="Description"
/>
<q-select
:readonly="readonly"
options-dense
filled
dense
v-model="script.shell"
:options="shellOptions"
emit-value
map-options
label="Shell Type"
/>
<tactical-dropdown
v-model="script.supported_platforms"
:options="agentPlatformOptions"
label="Supported Platforms (All supported if blank)"
clearable
mapOptions
filled
multiple
:readonly="readonly"
/>
<tactical-dropdown
filled
v-model="script.category"
:options="categories"
use-input
clearable
new-value-mode="add-unique"
filterable
label="Category"
:readonly="readonly"
hide-bottom-space
/>
<tactical-dropdown
v-model="script.args"
label="Script Arguments (press Enter after typing each argument)"
filled
use-input
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
:readonly="readonly"
/>
<tactical-dropdown
v-model="script.env_vars"
:label="envVarsLabel"
filled
use-input
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
:readonly="readonly"
/>
<q-input
type="number"
filled
dense
:readonly="readonly"
v-model.number="script.default_timeout"
label="Timeout (seconds)"
:rules="[(val) => val >= 5 || 'Minimum is 5']"
hide-bottom-space
/>
<q-checkbox
v-model="script.run_as_user"
label="Run As User (Windows only)"
>
<q-tooltip
>Setting this value on the script model will always override any
'Run As User' checkboxes in the UI and force this script to
always be run in the context of the logged in user. If no user
is logged in, the script will run as SYSTEM.
</q-tooltip>
</q-checkbox>
<q-input
label="Syntax"
type="textarea"
style="height: 150px; overflow-y: auto; resize: none"
v-model="script.syntax"
dense
filled
:readonly="readonly"
/>
</div>
</q-scroll-area>
<div
ref="scriptEditor"
class="col-8 q-mb-none q-pb-none"
:style="{ height: `${$q.screen.height - 106}px` }"
></div>
</div>
<q-card-actions>
<tactical-dropdown
style="width: 350px"
dense
:loading="agentLoading"
filled
v-model="agent"
:options="agentOptions"
label="Agent to run test script on"
mapOptions
filterable
>
<template v-slot:after>
<q-btn
size="md"
color="primary"
dense
flat
label="Test Script"
:disable="
!agent || !script.script_body || !script.default_timeout
"
@click="openTestScriptModal"
/>
</template>
</tactical-dropdown>
<q-space />
<q-btn dense flat label="Cancel" @click="closeEditor" />
<q-btn
v-if="!readonly"
:loading="loading"
dense
flat
label="Save"
color="primary"
@click="submit"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script>
<script setup lang="ts">
// composable imports
import { ref, computed, onMounted } from "vue";
import { ref, reactive, watch, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { useQuasar, useDialogPluginComponent } from "quasar";
import { saveScript, editScript, downloadScript } from "@/api/scripts";
@@ -257,190 +220,251 @@ import { notifySuccess } from "@/utils/notify";
// ui imports
import TestScriptModal from "@/components/scripts/TestScriptModal.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 "ace-builds/src-noconflict/mode-powershell";
import "ace-builds/src-noconflict/mode-python";
import "ace-builds/src-noconflict/mode-batchfile";
import "ace-builds/src-noconflict/mode-sh";
import "ace-builds/src-noconflict/theme-tomorrow_night_eighties";
import "ace-builds/src-noconflict/theme-tomorrow";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.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 jsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
// 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
import { shellOptions } from "@/composables/scripts";
import { envVarsLabel } from "@/constants/constants";
export default {
name: "ScriptFormModal",
emits: [...useDialogPluginComponent.emits],
components: {
TacticalDropdown,
VAceEditor,
// props
const props = withDefaults(
defineProps<{
script?: Script;
categories?: string[];
readonly: boolean;
clone?: boolean;
}>(),
{
clone: false,
readonly: false,
},
props: {
script: Object,
categories: !Array,
readonly: {
type: Boolean,
default: false,
);
// emits
defineEmits([...useDialogPluginComponent.emits]);
// 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();
// 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.includes("#!");
} 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() {
$q.dialog({
component: TestScriptModal,
componentProps: {
script: { ...script },
agent: agent.value,
},
clone: {
type: Boolean,
default: false,
},
},
setup(props) {
// setup quasar plugins
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
const $q = useQuasar();
});
}
// setup store
const store = useStore();
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
const scriptEditor = ref<HTMLElement | null>(null);
let editor: monaco.editor.IStandaloneCodeEditor;
// setup agent dropdown
const { agent, agentOptions, getAgentOptions } = useAgentDropdown();
function loadEditor() {
var model = monaco.editor.createModel(script.script_body, lang.value);
// script form logic
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: [],
});
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
if (props.clone) script.value.name = `(Copy) ${script.value.name}`;
const maximized = ref(false);
const loading = ref(false);
const agentLoading = ref(false);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editor = monaco.editor.create(scriptEditor.value!, {
readOnly: props.readonly,
automaticLayout: true,
model: model,
theme: theme,
});
const missingShebang = computed(() => {
if (script.value.shell === "shell" || script.value.shell === "python") {
return !script.value.script_body.includes("#!");
} else {
return false;
}
});
editor.onDidChangeModelContent(() => {
script.script_body = editor.getValue();
});
const title = computed(() => {
if (props.script) {
return props.readonly
? `Viewing ${script.value.name}`
: props.clone
? `Copying ${script.value.name}`
: `Editing ${script.value.name}`;
} else {
return "Adding new script";
}
});
// get code if editing or cloning script
if (props.script)
downloadScript(script.id, { with_snippets: props.readonly }).then((r) => {
script.script_body = r.code;
editor.setValue(r.code);
// convert highlighter language to match what ace expects
const lang = computed(() => {
if (script.value.shell === "cmd") return "batchfile";
else if (script.value.shell === "powershell") return "powershell";
else if (script.value.shell === "python") return "python";
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;
}
// need to add this in the download function otherwise the above will trigger an edit
watch(
() => script.script_body,
() => {
edited.value = true;
},
);
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 {
// reactive data
formScript: script.value,
maximized,
loading,
agentOptions,
agent,
agentLoading,
lang,
missingShebang,
// watch for changes in language
watch(lang, () => {
monaco.editor.setModelLanguage(model, lang.value);
});
}
// non-reactive data
shellOptions,
agentPlatformOptions,
envVarsLabel,
function unloadEditor() {
editor.getModel()?.dispose();
editor.dispose();
onDialogHide();
}
//computed
title,
openAIEnabled,
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.script_body = completion;
});
}
//methods
submitForm,
openTestScriptModal,
generateScriptOpenAI,
// add are you sure prompt to unsaved script
const edited = ref(false);
// quasar dialog plugin
dialogRef,
onDialogHide,
};
},
};
function closeEditor() {
if (edited.value)
$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>

View File

@@ -175,6 +175,28 @@
>
<q-tooltip> Shell </q-tooltip>
</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
class="q-pl-xs text-weight-bold"
@@ -463,6 +485,22 @@
>
<q-tooltip> Shell </q-tooltip>
</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>
<!-- supported platforms -->
<q-td key="supported_platforms" :props="props">
@@ -488,6 +526,12 @@
:props="props"
: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) }}
<q-tooltip
v-if="props.row.name.length >= 50"
@@ -550,6 +594,8 @@ import ScriptFormModal from "@/components/scripts/ScriptFormModal.vue";
import ScriptSnippets from "@/components/scripts/ScriptSnippets.vue";
import TacticalTable from "@/components/ui/TacticalTable.vue";
import trmmLogo from "@/assets/trmm_256.png";
// static data
const columns = [
{
@@ -620,7 +666,7 @@ export default {
// setup vuex store
const store = useStore();
const showCommunityScripts = computed(
() => store.state.showCommunityScripts
() => store.state.showCommunityScripts,
);
// setup quasar plugins
@@ -721,7 +767,7 @@ export default {
return showCommunityScripts.value
? scripts.value.filter((i) => !i.hidden)
: scripts.value.filter(
(i) => i.script_type !== "builtin" && !i.hidden
(i) => i.script_type !== "builtin" && !i.hidden,
);
}
});
@@ -884,6 +930,7 @@ export default {
loading,
showCommunityScripts,
showHiddenScripts,
trmmLogo,
// computed
visibleScripts,

View File

@@ -1,15 +1,12 @@
<template>
<q-dialog
ref="dialogRef"
maximized
@hide="onDialogHide"
persistent
@keydown.esc="onDialogHide"
:maximized="maximized"
@show="loadEditor"
@before-hide="unloadEditor"
>
<q-card
class="q-dialog-plugin"
:style="maximized ? '' : 'width: 70vw; max-width: 90vw'"
>
<q-card class="q-dialog-plugin">
<q-bar>
<span class="q-pr-sm">{{ title }}</span>
<q-btn
@@ -23,90 +20,62 @@
@click="generateScriptOpenAI"
/>
<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-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-form @submit="submitForm">
<div class="row">
<q-input
:rules="[(val) => !!val || '*Required']"
class="q-pa-sm col-4"
v-model="formSnippet.name"
label="Name"
filled
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' }"
<div class="row">
<q-input
:rules="[(val: string) => !!val || '*Required']"
class="q-pa-sm col-4"
v-model="snippet.name"
label="Name"
filled
dense
/>
<q-card-actions align="right">
<q-btn dense flat label="Cancel" v-close-popup />
<q-btn
:loading="loading"
dense
flat
label="Save"
color="primary"
type="submit"
/>
</q-card-actions>
</q-form>
<q-select
v-model="snippet.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="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-dialog>
</template>
<script>
<script setup lang="ts">
// composable imports
import { ref, computed } from "vue";
import { ref, watch, reactive, computed } from "vue";
import { useStore } from "vuex";
import { useQuasar } from "quasar";
import { generateScript } from "@/api/core";
@@ -115,117 +84,152 @@ import { saveScriptSnippet, editScriptSnippet } from "@/api/scripts";
import { notifySuccess } from "@/utils/notify";
// ui imports
import { VAceEditor } from "vue3-ace-editor";
import * as monaco from "monaco-editor";
// imports for ace editor
import "ace-builds/src-noconflict/mode-powershell";
import "ace-builds/src-noconflict/mode-python";
import "ace-builds/src-noconflict/mode-batchfile";
import "ace-builds/src-noconflict/mode-sh";
import "ace-builds/src-noconflict/theme-tomorrow_night_eighties";
import "ace-builds/src-noconflict/theme-tomorrow";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.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 jsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
// 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
import { shellOptions } from "@/composables/scripts";
export default {
name: "ScriptFormModal",
emits: [...useDialogPluginComponent.emits],
components: {
VAceEditor,
},
props: {
snippet: Object,
},
setup(props) {
// setup quasar plugins
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// props
const props = defineProps<{ snippet?: ScriptSnippet }>();
// setup quasar
const $q = useQuasar();
// emits
defineEmits([...useDialogPluginComponent.emits]);
// setup store
const store = useStore();
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
// quasar dialog setup
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// snippet form logic
const snippet = props.snippet
? ref(Object.assign({}, props.snippet))
: ref({ name: "", code: "", shell: "powershell" });
const maximized = ref(false);
const loading = ref(false);
// setup quasar
const $q = useQuasar();
const title = computed(() => {
if (props.snippet) {
return `Editing ${snippet.value.name}`;
} else {
return "Adding New Script Snippet";
}
// setup store
const store = useStore();
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
// 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,
});
// 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,
};
},
};
snippet.code = completion;
});
}
</script>

View File

@@ -124,6 +124,22 @@
>
<q-tooltip> Shell </q-tooltip>
</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>
<!-- name -->
<q-td>{{ props.row.name }}</q-td>

View File

@@ -8,8 +8,25 @@
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section class="scroll" style="max-height: 70vh; height: 70vh">
<pre v-if="ret">{{ ret }}</pre>
<q-card-section style="height: 70vh" 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">
Standard Output
<q-separator />
<pre>{{ ret.stdout }}</pre>
</div>
<div v-if="ret.stderr">
Standard Error
<q-separator />
<pre>{{ ret.stderr }}</pre>
</div>
<q-inner-loading :showing="loading" />
</q-card-section>
</q-card>
@@ -34,7 +51,12 @@ export default {
const { dialogRef, onDialogHide } = useDialogPluginComponent();
// main run script functionality
const ret = ref(null);
const ret = ref({
execution_time: "",
retcode: "",
stdout: "",
stderr: "",
});
const loading = ref(false);
async function runTestScript() {

View File

@@ -87,181 +87,183 @@
:done="step > 2"
:error="!isValidStep2"
>
<q-form @submit.prevent="addAction">
<div class="row q-pa-sm q-gutter-x-xs items-center">
<div class="text-subtitle2 col-12">Action Type:</div>
<q-option-group
class="col-12"
inline
v-model="actionType"
:options="[
{ label: 'Script', value: 'script' },
{ label: 'Command', value: 'cmd' },
]"
/>
<div class="scroll" style="max-height: 60vh">
<q-form @submit.prevent="addAction">
<div class="row q-pa-sm q-gutter-x-xs items-center">
<div class="text-subtitle2 col-12">Action Type:</div>
<q-option-group
class="col-12"
inline
v-model="actionType"
:options="[
{ label: 'Script', value: 'script' },
{ label: 'Command', value: 'cmd' },
]"
/>
<tactical-dropdown
v-if="actionType === 'script'"
class="col-3"
label="Select script"
v-model="script"
:options="scriptOptions"
filled
mapOptions
filterable
/>
<tactical-dropdown
v-if="actionType === 'script'"
class="col-3"
label="Select script"
v-model="script"
:options="scriptOptions"
filled
mapOptions
filterable
/>
<q-select
v-if="actionType === 'script'"
class="col-3"
dense
label="Script Arguments (press Enter after typing each argument)"
filled
v-model="defaultArgs"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
/>
<q-select
v-if="actionType === 'script'"
class="col-3"
dense
label="Script Arguments (press Enter after typing each argument)"
filled
v-model="defaultArgs"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
/>
<q-select
v-if="actionType === 'script'"
class="col-3"
dense
:label="envVarsLabel"
filled
v-model="defaultEnvVars"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
/>
<q-select
v-if="actionType === 'script'"
class="col-3"
dense
:label="envVarsLabel"
filled
v-model="defaultEnvVars"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
/>
<q-input
v-if="actionType === 'script'"
class="col-2"
filled
dense
v-model.number="defaultTimeout"
type="number"
label="Timeout (seconds)"
/>
<q-input
v-if="actionType === 'script'"
class="col-2"
filled
dense
v-model.number="defaultTimeout"
type="number"
label="Timeout (seconds)"
/>
<q-input
v-if="actionType === 'cmd'"
label="Command"
v-model="command"
<q-input
v-if="actionType === 'cmd'"
label="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
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"
/>
>
<q-tooltip>Continue task if an action fails</q-tooltip>
</q-checkbox>
</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
>
<q-tooltip>Continue task if an action fails</q-tooltip>
</q-checkbox>
</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" />
&nbsp; {{ 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" />
&nbsp;
<div class="q-pt-sm" style="height: 150px">
<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
size="sm"
:name="
element.shell === 'cmd'
? 'mdi-microsoft-windows'
: 'mdi-powershell'
"
color="primary"
class="handle"
style="cursor: move"
name="drag_handle"
/>
{{ 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>
</q-item-section>
<q-item-section v-if="element.type === 'script'">
<q-item-label>
<q-icon size="sm" name="description" color="primary" />
&nbsp; {{ 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" />
&nbsp;
<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>
</q-step>
@@ -283,7 +285,7 @@
<q-card-section
v-if="
['runonce', 'daily', 'weekly', 'monthly'].includes(
state.task_type
state.task_type,
)
"
class="row"
@@ -314,6 +316,22 @@
/>
</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 -->
<q-card-section v-if="state.task_type === 'daily'" class="row">
<!-- daily interval -->
@@ -579,7 +597,8 @@
<q-card-section
v-if="
state.task_type !== 'checkfailure' &&
state.task_type !== 'manual'
state.task_type !== 'manual' &&
state.task_type !== 'onboarding'
"
class="row"
>
@@ -617,7 +636,7 @@
(val) =>
convertPeriodToSeconds(val) >=
convertPeriodToSeconds(
state.task_repetition_interval
state.task_repetition_interval,
) ||
'Repetition duration must be greater than repetition interval',
]"
@@ -712,7 +731,7 @@
@click="
validateStep(
step === 1 ? $refs.taskGeneralForm : undefined,
$refs.stepper
$refs.stepper,
)
"
color="primary"
@@ -769,6 +788,7 @@ const taskTypeOptions = [
{ label: "Monthly", value: "monthly" },
{ label: "Run Once", value: "runonce" },
{ label: "On check failure", value: "checkfailure" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Manual", value: "manual" },
];
@@ -933,7 +953,7 @@ export default {
task.value.actions.push({
type: "script",
name: scriptOptions.value.find(
(option) => option.value === script.value
(option) => option.value === script.value,
).label,
script: script.value,
timeout: defaultTimeout.value,
@@ -1019,13 +1039,13 @@ export default {
// remove milliseconds and Z to work with native date input
task.value.run_time_date = formatDateInputField(
task.value.run_time_date,
true
true,
);
if (task.value.expire_date)
task.value.expire_date = formatDateInputField(
task.value.expire_date,
true
true,
);
// set task type if monthlydow is being used
@@ -1069,7 +1089,7 @@ export default {
task.value.monthly_weeks_of_month = [];
task.value.task_instance_policy = 0;
task.value.expire_date = null;
}
},
);
// check the collector box when editing task and custom field is set

View 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>

View File

@@ -25,13 +25,21 @@
:key="mapOptions ? scope.opt.value : scope.opt"
>
<q-item-section>
<q-item-label
v-html="mapOptions ? scope.opt.label : scope.opt"
></q-item-label>
<q-item-label v-html="mapOptions ? scope.opt.label : scope.opt" />
</q-item-section>
<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 v-if="filtered && mapOptions && scope.opt.cat" side>{{
scope.opt.cat
}}</q-item-section>
</q-item>
<q-item-label
v-if="scope.opt.category"
@@ -80,7 +88,7 @@ export default {
if (!props.mapOptions)
filteredOptions.value = props.options.filter(
(v) => v.toLowerCase().indexOf(needle) > -1
(v) => v.toLowerCase().indexOf(needle) > -1,
);
else
filteredOptions.value = props.options.filter((v) => {

View 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,
};
}

View File

@@ -18,7 +18,7 @@ export function useScriptDropdown(setScript = null, { onMount = false } = {}) {
// specify parameters to filter out community scripts
async function getScriptOptions(showCommunityScripts = false) {
scriptOptions.value = Object.freeze(
formatScriptOptions(await fetchScripts({ showCommunityScripts }))
formatScriptOptions(await fetchScripts({ showCommunityScripts })),
);
}
@@ -26,7 +26,7 @@ export function useScriptDropdown(setScript = null, { onMount = false } = {}) {
watch([script, scriptOptions], () => {
if (script.value && scriptOptions.value.length > 0) {
const tmpScript = scriptOptions.value.find(
(i) => i.value === script.value
(i) => i.value === script.value,
);
defaultTimeout.value = tmpScript.timeout;
defaultArgs.value = tmpScript.args;
@@ -65,4 +65,6 @@ export const shellOptions = [
{ label: "Batch", value: "cmd" },
{ label: "Python", value: "python" },
{ label: "Shell", value: "shell" },
{ label: "Nushell", value: "nushell" },
{ label: "Deno", value: "deno" },
];

View File

@@ -4,7 +4,7 @@ export const GOARCH_ARM64 = "arm64";
export const GOARCH_ARM32 = "arm";
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 =
"Environment vars (press Enter after typing each key=value pair)";

30
src/ee/LICENSE.md Normal file
View 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 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.

View 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();

View 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>

View 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>

View 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>

View 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 `![${text}](${url})`;
} 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;
}

View 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>

View File

@@ -25,8 +25,8 @@
If you have downgraded or cancelled your sponsorship, please delete
your token from the Code Signing modal and refresh to get rid of this
banner.<br /><br />
For any issues or to renew your sponsorship please email
support@amidaware.com<br /><br
For any issues or to renew your sponsorship please open a ticket at
support.amidaware.com<br /><br
/></span>
<q-btn
color="dark"
@@ -74,7 +74,7 @@
:color="dash_negative_color"
text-color="black"
icon="warning"
>Certificate expires in {{ daysUntilCertExpires }} days</q-chip
>SSL certificate expires in {{ daysUntilCertExpires }} days</q-chip
>
</q-toolbar-title>
<!-- temp dark mode toggle -->
@@ -288,7 +288,7 @@ export default {
if (!token.value) {
console.log(
"Access token is null or invalid, not setting up WebSocket"
"Access token is null or invalid, not setting up WebSocket",
);
return;
}
@@ -325,10 +325,13 @@ export default {
const poll = ref(null);
function livePoll() {
poll.value = setInterval(() => {
store.dispatch("checkVer");
store.dispatch("getDashInfo", false);
}, 60 * 4 * 1000);
poll.value = setInterval(
() => {
store.dispatch("checkVer");
store.dispatch("getDashInfo", false);
},
60 * 4 * 1000,
);
}
const updateAvailable = computed(() => {

View File

@@ -67,7 +67,7 @@ const routes = [
name: "SessionExpired",
component: () => import("@/views/SessionExpired.vue"),
},
{ path: "/:catchAll(.*)*", component: () => import("@/views/NotFound.vue") },
{ path: "/:catchAll(.*)", component: () => import("@/views/NotFound.vue") },
];
export default routes;

1
src/types/agents.ts Normal file
View File

@@ -0,0 +1 @@
export type AgentPlatformType = "windows" | "linux" | "darwin";

26
src/types/filebrowser.ts Normal file
View File

@@ -0,0 +1,26 @@
// type imports
import { type QTreeNode } from "quasar";
export interface LazyLoadCallbackParams {
path: string;
isDone(nodes: QTreeFileNode[]): void;
isFail(): void;
}
export interface FileSystemNodeTable {
id: string;
name: string;
path: string;
type: "folder" | "file";
asset_id?: string;
size?: string;
}
export interface QTreeFileNode extends QTreeNode<unknown> {
id: string;
path: string;
type: "folder" | "file";
size?: string;
asset_id?: string;
children?: QTreeFileNode[];
}

26
src/types/scripts.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { AgentPlatformType } from "@/types/agents";
export type ScriptShellType = "powershell" | "cmd" | "shell" | "python" | "nushell" | "deno";
export interface Script {
id?: number;
name: string;
description?: string;
shell: ScriptShellType;
default_timeout: number;
category?: string;
syntax?: string;
args: string[];
run_as_user: boolean;
env_vars: string[];
script_body: string;
supported_platforms?: AgentPlatformType[];
}
export interface ScriptSnippet {
id?: number;
name: string;
code: string;
shell: ScriptShellType;
desc?: string;
}

View File

@@ -1,5 +1,6 @@
import { date } from "quasar";
import { validateTimePeriod } from "@/utils/validation";
import trmmLogo from "@/assets/trmm_256.png";
// dropdown options formatting
export function removeExtraOptionCategories(array) {
@@ -24,7 +25,7 @@ function _formatOptions(
flat = false,
allowDuplicates = true,
appendToOptionObject = {},
}
},
) {
if (!flat)
// returns array of options in object format [{label: label, value: 1}]
@@ -64,6 +65,7 @@ export function formatScriptOptions(data) {
data.forEach((script) => {
if (script.category === cat) {
tmp.push({
img_right: script.script_type === "builtin" ? trmmLogo : undefined,
label: script.name,
value: script.id,
timeout: script.default_timeout,
@@ -100,7 +102,7 @@ export function formatScriptOptions(data) {
export function formatAgentOptions(
data,
flat = false,
value_field = "agent_id"
value_field = "agent_id",
) {
if (flat) {
// returns just agent hostnames in array
@@ -185,7 +187,7 @@ export function formatSiteOptions(data, flat = false) {
label: "name",
flat: flat,
appendToOptionObject: { cat: client.name },
})
}),
);
});
@@ -361,7 +363,7 @@ export function convertToBitArray(number) {
bitArray.push(1);
} else {
bitArray.push(
parseInt(binary.slice(i), 2) - parseInt(binary.slice(i + 1), 2)
parseInt(binary.slice(i), 2) - parseInt(binary.slice(i + 1), 2),
);
}
}
@@ -377,3 +379,12 @@ export function convertFromBitArray(array) {
}
return result;
}
export function convertCamelCase(str) {
return str
.replace(/[^a-zA-Z0-9]+/g, " ")
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index == 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, "");
}

View File

@@ -13,7 +13,11 @@
>
<q-spinner size="40px" color="primary" />
</div>
<div v-else class="q-pa-sm q-gutter-sm scroll" style="height: 85vh">
<div
v-else
class="q-pa-sm q-gutter-sm scroll"
style="height: 85vh; overflow: initial"
>
<q-list dense class="rounded-borders">
<q-item
clickable
@@ -163,7 +167,7 @@
runURLAction(
props.node.id,
action.id,
props.node.children ? 'client' : 'site'
props.node.children ? 'client' : 'site',
)
"
>
@@ -185,6 +189,29 @@
<q-item-section>Run Checks</q-item-section>
</q-item>
<q-item
clickable
v-if="
(props.node.children &&
$integrations?.clientMenuIntegrations?.length >
0) ||
(!props.node.children &&
$integrations?.siteMenuIntegrations.length > 0)
"
>
<q-item-section side>
<q-icon 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="props.node.children ? 'client' : 'site'"
:id="props.node.id"
/>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
@@ -425,6 +452,7 @@ import SitesForm from "@/components/clients/SitesForm.vue";
import DeleteClient from "@/components/clients/DeleteClient.vue";
import InstallAgent from "@/components/modals/agents/InstallAgent.vue";
import AlertTemplateAdd from "@/components/modals/alerts/AlertTemplateAdd.vue";
import IntegrationsContextMenu from "@/components/ui/IntegrationsContextMenu.vue";
import { removeClient, removeSite } from "@/api/clients";
@@ -435,6 +463,7 @@ export default {
AgentTable,
SubTableTabs,
InstallAgent,
IntegrationsContextMenu,
},
// allow child components to refresh table
provide() {
@@ -791,7 +820,7 @@ export default {
this.$axios.get("/core/urlaction/").then((r) => {
if (r.data.length === 0) {
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;
}

View File

@@ -53,6 +53,26 @@
:options="allTimezones"
/>
</q-card-section>
<q-card-section>
<div>
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>
<q-input dense outlined v-model="companyname"> </q-input>
</q-card-section>
<q-card-actions align="center">
<q-btn
label="Finish"
@@ -86,6 +106,7 @@ export default {
allTimezones: [],
timezone: null,
arch: "64",
companyname: "",
};
},
methods: {
@@ -95,6 +116,7 @@ export default {
client: this.client,
site: this.site,
timezone: this.timezone,
companyname: this.companyname,
initialsetup: true,
};
this.$axios

View File

@@ -63,6 +63,7 @@
autofocus
outlined
v-model="credentials.twofactor"
autocomplete="one-time-code"
:rules="[
(val) =>
(val && val.length > 0) || 'This field is required',

View File

@@ -2,7 +2,7 @@
<div>
<q-bar>
<span class="text-caption">
Agent Status:
TRMM Agent Status:
<q-badge :color="statusColor" :label="status" />
</span>
<q-space />
@@ -90,7 +90,7 @@ export default {
control.value = data.control;
status.value = data.status;
useMeta({
title: `${data.hostname} - ${data.client} - ${data.site} | Remote Background`,
title: `${data.hostname} - ${data.client} - ${data.site} | Take Control`,
});
} catch (e) {
console.error(e);