Compare commits

...

363 Commits

Author SHA1 Message Date
wh1te909
144949e9f4 bump version (again) 2025-02-03 20:35:58 +00:00
wh1te909
d0a655f570 bump version 2025-02-03 20:22:16 +00:00
wh1te909
a9e14e4cb4 fix clipboard perms amidaware/tacticalrmm#1134 2025-02-01 20:14:05 +00:00
wh1te909
f7b52e506d bump version 2024-11-20 19:39:45 +00:00
wh1te909
4932997498 Merge pull request #28 from sadnub/sso
feat: single sign-on https://github.com/amidaware/tacticalrmm/issues/508
2024-11-20 11:33:39 -08:00
wh1te909
09ecc36bcd wording 2024-11-19 23:15:17 +00:00
wh1te909
4d8abbaa12 make the modal persistent 2024-11-19 23:14:45 +00:00
wh1te909
9f143a7e05 restore missing livepoll function that got missed during rework 2024-11-15 23:55:05 +00:00
wh1te909
e51efaa9e2 typo 2024-11-15 20:52:15 +00:00
wh1te909
64edab9df4 fix lint 2024-11-08 21:25:02 +00:00
wh1te909
85f408ae94 increase chunksize limit 2024-11-08 21:01:08 +00:00
wh1te909
26ed91cad9 update reqs 2024-11-07 22:08:10 +00:00
wh1te909
bb828b5996 add client/site columns to alerts table 2024-11-07 21:06:36 +00:00
wh1te909
2583e9ac9e make user admin modal wider 2024-11-06 20:45:42 +00:00
wh1te909
acaced7122 clear the provider id on logout 2024-11-05 20:26:22 +00:00
wh1te909
795ba12f3a handle 423 2024-11-04 23:50:02 +00:00
wh1te909
face099460 urls now sent by backend and add javascript origin url 2024-11-04 22:35:07 +00:00
wh1te909
2690e9daef add descriptive wording 2024-11-04 20:33:19 +00:00
wh1te909
ec5ef65911 don't allow SSO reset from UI 2024-11-01 18:29:49 +00:00
wh1te909
237b097684 return error from backend instead if local login disabled rather than not displaying at all in UI 2024-11-01 18:29:19 +00:00
wh1te909
6f6d98fae2 set provider icon from api 2024-11-01 17:52:59 +00:00
wh1te909
583f57f2af add sso 2024-11-01 17:49:09 +00:00
wh1te909
4270fd0d19 fix logic 2024-10-31 21:14:44 +00:00
wh1te909
02eeea50e3 rename type to avoid naming conflict with component 2024-10-31 20:46:33 +00:00
sadnub
54207d1c0f disable certain UI elements if block_local_user_local is enabled 2024-10-29 11:19:07 -04:00
wh1te909
16b9bf1529 fix run on server missing for posix 2024-10-29 11:19:07 -04:00
wh1te909
1adeadd48e fix run on server missing for posix 2024-10-28 21:26:14 +00:00
wh1te909
fada3c2ed7 also add column for copying callback url 2024-10-25 19:26:20 +00:00
wh1te909
c1cd6114de add disable and hint to sso form 2024-10-25 19:25:36 +00:00
wh1te909
79d02060ef style sso login 2024-10-25 19:24:17 +00:00
wh1te909
3ce67b0701 remove debug 2024-10-25 19:21:43 +00:00
sadnub
8aab840633 change secret field to password and allow toggling visibility 2024-10-24 04:34:19 +00:00
sadnub
0ce8da44c1 add sso user column in user table and fix disconnecting sso accounts 2024-10-24 04:34:19 +00:00
sadnub
856a3b8b96 allow dispay full name in UI if present 2024-10-24 04:34:19 +00:00
sadnub
0e59f580c3 auto redirect to sso login on sso signup 2024-10-24 04:34:19 +00:00
sadnub
d0cf72bbd2 fix 403 on sso provider signup and other tweaks to UI. Setting to disable SSO 2024-10-24 04:34:19 +00:00
sadnub
65096e6b88 implement role assignment on sso user signups and log ip for sso logins 2024-10-24 04:34:19 +00:00
sadnub
c31ed666b5 added user session tracking, social accoutn tracking, and implemented local user logon blocking 2024-10-24 04:34:19 +00:00
sadnub
09e39ef6da rollback axios not redirecting on 401 errors for certain urls 2024-10-24 04:34:19 +00:00
sadnub
75a9ef88d1 implement session auth login logic and cleanup views 2024-10-24 04:34:19 +00:00
sadnub
0eb81662d3 move sso auth to auth store 2024-10-24 04:34:19 +00:00
sadnub
541134a88f sso init 2024-10-24 04:34:19 +00:00
wh1te909
ee8aada530 bump version 2024-10-23 02:08:33 +00:00
wh1te909
fa2ef65103 update lint 2024-10-23 01:16:18 +00:00
wh1te909
d73991cb0a update node 2024-10-23 01:14:36 +00:00
wh1te909
a8e5203b58 add total cpu/ram and fix race condition with polling interval for process manager closes amidaware/tacticalrmm#2037 2024-10-22 07:05:43 +00:00
wh1te909
bdf7cd7bf4 update reqs 2024-10-21 21:03:12 +00:00
wh1te909
c3bd551b3a wording 2024-10-21 21:02:58 +00:00
wh1te909
e045485d8c don't limit file extensions 2024-10-18 18:09:13 +00:00
wh1te909
fa0992c49f add win task name tooltip for debugging closes amidaware/tacticalrmm#1886 2024-10-18 00:01:38 +00:00
wh1te909
21ea5a1981 add mon type icon to agents table closes amidaware/tacticalrmm#1966 2024-10-17 23:34:44 +00:00
wh1te909
a53a3b3343 add syntax hover icon to bulk script closes amidaware/tacticalrmm#1946 2024-10-17 23:28:56 +00:00
wh1te909
ddb7c82575 change icon 2024-10-17 22:52:11 +00:00
wh1te909
fbb221fcac add run on server option to run script endpoint amidaware/tacticalrmm#1923 2024-10-17 20:40:46 +00:00
wh1te909
0d832ba833 update reqs 2024-10-17 20:40:10 +00:00
wh1te909
870d70b4f2 show more detail in checks tab closes amidaware/tacticalrmm#2014 2024-10-15 08:36:43 +00:00
wh1te909
33dbeb5552 update reqs 2024-10-13 19:51:26 +00:00
wh1te909
9457bf2bc5 hide global keystore content and add perms closes amidaware/tacticalrmm#1984 2024-10-06 05:59:51 +00:00
wh1te909
797b27af13 add refresh button to policy status closes amidaware/tacticalrmm#2010 2024-10-06 03:51:02 +00:00
wh1te909
f6bbe3ecd8 add saving output of bulk script to custom field and agent note amidaware/tacticalrmm#1845 2024-10-06 01:15:24 +00:00
wh1te909
f0c603d36f update reqs 2024-09-30 17:53:56 +00:00
wh1te909
f87c6b2a10 update reqs 2024-09-30 08:22:45 +00:00
wh1te909
4186b1cbf2 update reqs 2024-09-04 09:54:51 +00:00
wh1te909
dce732ec3c bump version 2024-08-05 17:51:08 +00:00
wh1te909
0c744eded6 show custom field id when hovering amidaware/tacticalrmm#1943 2024-07-29 19:40:53 +00:00
wh1te909
4c1a231811 show script id when hovering over the name in script manager table closes amidaware/tacticalrmm#1943 2024-07-29 04:10:24 +00:00
wh1te909
c53179892c fix pinia state 2024-07-28 22:40:55 +00:00
wh1te909
1f5af9ba2d sort urlactions by name closes amidaware/tacticalrmm#1795 2024-07-26 18:53:21 +00:00
wh1te909
45e2690a81 bump versions 2024-07-12 08:33:14 +00:00
wh1te909
2ff504db09 update quasar 2024-07-12 08:29:36 +00:00
wh1te909
0c89e58d8c add copy to clipboard button for command/script output 2024-07-12 08:23:49 +00:00
wh1te909
e1dc75e2d8 make dialog a bit wider for webhooks and move buttons to the left 2024-07-12 08:18:09 +00:00
wh1te909
6dd027c994 bump dev build 2024-07-08 20:16:33 +00:00
wh1te909
263c1f1d75 update reqs 2024-07-08 20:15:53 +00:00
wh1te909
52ee688259 make sure server scripts start with shebang 2024-07-08 19:01:21 +00:00
wh1te909
ce4c3a74b5 fix lint 2024-07-05 21:28:39 +00:00
wh1te909
72e493bef0 add global option for handling info/warning notifications amidaware/tacticalrmm#1834 2024-07-05 21:20:48 +00:00
wh1te909
14903c888c add content type by default 2024-07-05 21:19:32 +00:00
wh1te909
f2bdf0e9f1 remove validation as we can still have notifications only without actions 2024-07-02 03:20:17 +00:00
wh1te909
02871fdd66 update reqs 2024-07-01 19:48:27 +00:00
wh1te909
fd9f1bca8e don't show in hosted 2024-07-01 19:47:08 +00:00
wh1te909
739181a7ec bump version 2024-06-28 23:07:55 +00:00
Dan
a0c406251f Merge pull request #18 from sadnub/urlaction-rework
Serverside cli, tasks, and scripts.  Plus REST URL Actions
2024-06-28 13:28:58 -07:00
wh1te909
c420e063bd update reqs 2024-06-28 20:00:48 +00:00
wh1te909
24ba9fb598 fix truncate length 2024-06-24 06:49:59 +00:00
wh1te909
c872764541 change wording and add some ghetto form validation 2024-06-19 20:42:58 +00:00
wh1te909
3e52924859 move role to scripts section 2024-06-19 20:41:31 +00:00
wh1te909
51cc895d12 update reqs 2024-06-15 01:02:36 +00:00
wh1te909
0a1f33fede make post the default webhook type 2024-06-14 23:43:17 +00:00
wh1te909
b539df007b disable instead of hide if server scripts are disabled 2024-06-14 23:42:56 +00:00
wh1te909
1a6cb090fe fix 2fa not clearing and add error handling 2024-06-12 23:32:40 +00:00
wh1te909
5e2602c3dc make descriptions textfields 2024-06-12 21:55:45 +00:00
wh1te909
8a388db603 change title 2024-06-12 06:05:42 +00:00
wh1te909
e0018871b1 rename fields 2024-06-10 17:59:54 +00:00
wh1te909
be508d9c9d fix lint 2024-06-08 09:20:55 +00:00
wh1te909
e670e67ef5 Merge branch 'develop' into urlaction-rework 2024-06-08 09:15:32 +00:00
wh1te909
32dae6e181 use v2 urls 2024-06-08 08:53:20 +00:00
wh1te909
0f0a7ed119 wrong var 2024-06-07 19:02:44 +00:00
wh1te909
e407a8c59e update reqs 2024-06-04 22:53:53 +00:00
sadnub
6c4d95ebfd add loading indicator 2024-05-30 11:42:24 -04:00
sadnub
7e54f2456e fix icon 2024-05-30 11:33:49 -04:00
sadnub
2419179877 add request body to debug 2024-05-30 00:49:15 -04:00
wh1te909
58a120e5c8 grammar 2024-05-28 08:10:31 +00:00
wh1te909
19315b6174 use same var names as before rework 2024-05-24 20:22:11 +00:00
wh1te909
b014f9afd9 more scripts/terminal rework 2024-05-23 00:52:46 +00:00
wh1te909
794e128504 format 2024-05-22 23:15:04 +00:00
wh1te909
de25074861 add server scripts/terminal enabled status to vuex 2024-05-22 22:20:34 +00:00
wh1te909
4da70dd23a don't show in hosted 2024-05-22 22:19:33 +00:00
wh1te909
b840ee542a notify if no web terminal perms 2024-05-22 20:02:29 +00:00
wh1te909
a51939df32 rework web terminal to open in new window 2024-05-22 18:46:33 +00:00
wh1te909
c3098f023a remove server tasks/script run 2024-05-22 08:38:27 +00:00
wh1te909
857a744c74 update reqs 2024-05-16 20:46:05 +00:00
sadnub
62fd3a207c filter url action dropdowns by web type 2024-05-07 21:38:58 -04:00
sadnub
ae3acfbc98 fix a couple issues with the urls opening 2024-04-29 00:49:30 -04:00
sadnub
2bf32aeab5 fix opening automated tasks form and make it so only web actions are in client/site/agent dropdowns 2024-04-29 00:15:23 -04:00
sadnub
3642407de8 start removing tasks rework 2024-04-27 12:24:18 -04:00
sadnub
f9333c5ffd fix instance not passing to backend 2024-04-23 00:52:56 -04:00
sadnub
b2fb45fe16 truncate text in url action table 2024-04-22 23:59:30 -04:00
sadnub
1864a4ea77 fix custom field dropdown and script dropdown not properly setting name 2024-04-22 23:47:15 -04:00
sadnub
c6e34dd900 modify the text on url action modal buttons and make Test easier to find 2024-04-22 23:23:50 -04:00
sadnub
589b36d074 fix format options to not add cat field. Fixes only categories getting applied to certain dropdowns 2024-04-22 23:20:29 -04:00
wh1te909
575ef6fec7 fix alert template bugs 2024-04-20 05:33:28 +00:00
wh1te909
dd5c009d89 fix xterm css import 2024-04-19 22:52:25 +00:00
wh1te909
3fa26a6b25 update reqs and handle new xterm packages 2024-04-19 22:42:02 +00:00
wh1te909
1d14f5a8b6 update reqs 2024-04-19 21:47:35 +00:00
sadnub
7f5d5db0ef add test button to web hooks form 2024-04-18 22:10:12 -04:00
sadnub
592909d890 fix totp setup view 2024-04-18 14:11:34 -04:00
sadnub
5113f42781 missed a couple things for server tasks 2024-04-18 12:54:31 -04:00
sadnub
60ddf07be9 Hide Server Tasks in settings 2024-04-18 12:51:54 -04:00
sadnub
c8f1b1b247 Add new roles to frontend 2024-04-18 12:51:54 -04:00
sadnub
31f2807295 fix script dropdowns and default script values updating 2024-04-18 12:51:54 -04:00
wh1te909
08edca4fbf fix webhook editor bug 2024-04-17 04:07:19 +00:00
sadnub
cb2a740beb package.lock 2024-04-16 22:06:23 -04:00
wh1te909
e0f6f4f563 fix webhooks labels 2024-04-16 22:04:44 -04:00
sadnub
34652110ca Fix a typo in the frontend and change a field name in alert template form 2024-04-16 22:04:44 -04:00
sadnub
d4d4bda519 fix vuex in the cmd placeholder computed function and cleanup the scriptDropdowns and actually filter by platform everywhere 2024-04-16 22:04:44 -04:00
sadnub
e83463a3cc Split up global settings rest/web actions in th eUI 2024-04-16 22:04:44 -04:00
sadnub
33216fd197 remove testing example files and packages 2024-04-16 22:04:41 -04:00
sadnub
b332332f79 actually show the server task run results instantly 2024-04-16 22:03:56 -04:00
sadnub
ff81f7a9d0 add message for server task and remove console.log line 2024-04-16 22:03:56 -04:00
sadnub
b8379c4508 removed unused imports in bulk action 2024-04-16 22:03:56 -04:00
sadnub
0fbd3a59bd init 2024-04-16 22:03:54 -04:00
wh1te909
b03d7b370f fix label 2024-04-11 22:49:51 +00:00
wh1te909
789a8b0cf0 bump version 2024-04-09 00:00:58 +00:00
wh1te909
c9dd02ace3 update reqs 2024-04-03 04:42:25 +00:00
wh1te909
ad5906c7b6 fix date filter (again) fixes amidaware/tacticalrmm#1803 2024-03-31 09:30:23 +00:00
wh1te909
afc40fcbe3 bump version 2024-03-25 17:38:12 +00:00
wh1te909
185f50787b wording 2024-03-23 19:29:12 +00:00
Dan
6c33676f73 Merge pull request #26 from dinger1986/dinger1986-add-confirm-and-info-for-enable-sync
Dinger1986 add confirm and info for enable sync
2024-03-23 12:12:44 -07:00
Dan
0290002444 update wording 2024-03-23 12:11:44 -07:00
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
64575c5f7d Update EditCoreSettings.vue 2024-03-20 21:40:50 +00:00
dinger1986
e0fa339644 Update EditCoreSettings.vue 2024-03-20 21:32:18 +00: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
wh1te909
e0c1b3199a update reqs 2023-08-06 23:01:46 +00:00
sadnub
fdbbdf7394 make it so the script manager doesn't close if escape is pressed on script editor 2023-08-05 09:54:14 -04:00
wh1te909
346670e8ea bump version 2023-07-04 18:49:28 +00:00
wh1te909
e030efaecf bump dev ver 2023-07-03 23:32:40 +00:00
wh1te909
b8a4f9fe74 update reqs 2023-07-03 23:32:09 +00:00
wh1te909
f963b51d70 update quasar 2023-07-01 16:48:03 +00:00
wh1te909
feacb19cf9 bump dev version 2023-06-30 20:30:52 +00:00
wh1te909
7ce2c1e969 node 18 2023-06-30 20:26:30 +00:00
wh1te909
d1defcef4a update reqs 2023-06-30 20:26:21 +00:00
Dan
e674b4fa5d Merge pull request #9 from silversword411/develop
Adding customizable columns to script manager table
2023-06-07 11:59:29 -07:00
sadnub
b08a5a6c2d fix light mode colors 2023-06-06 16:17:09 -04:00
sadnub
9fa1d7209f fix duplicate columns on script manager close and open 2023-06-06 15:42:06 -04:00
Dan
2adfccfa1d Merge pull request #10 from dinger1986/develop
Update InitialSetup.vue
2023-06-06 11:51:01 -07:00
silversword411
04766efcd0 tactical-table and script manager table for column selection 2023-06-06 12:38:53 -04:00
dinger1986
4babb937f6 Update InitialSetup.vue 2023-06-01 21:37:06 +00:00
wh1te909
69403def2a bump version 2023-05-30 22:11:11 +00:00
wh1te909
3fdd8272f6 bump version 2023-05-29 20:36:02 +00:00
wh1te909
339227bedc add cert expiring soon indicator closes amidaware/tacticalrmm#722 2023-05-27 23:31:07 +00:00
wh1te909
17c7c95cc1 group processors for cleaner UI closes amidaware/tacticalrmm#583 2023-05-27 20:05:53 +00:00
wh1te909
a3ceb5e81b add serial number to summary tab closes amidaware/tacticalrmm#389 2023-05-27 19:48:19 +00:00
wh1te909
679d8cab77 add wake-on-lan amidaware/tacticalrmm#1180 2023-05-27 00:38:34 +00:00
wh1te909
c4c1474e09 add serial number to search amidaware/tacticalrmm#1355 2023-05-26 22:54:46 +00:00
wh1te909
82677b0b82 make cmd placeholder text customizable closes #5 2023-05-26 22:18:01 +00:00
wh1te909
b78af07f11 allow customizing dashboard colors closes amidaware/tacticalrmm#1514 2023-05-25 22:31:16 +00:00
wh1te909
24acef19c5 formatting 2023-05-25 22:30:05 +00:00
wh1te909
fee6edb39e update reqs 2023-05-25 22:21:34 +00:00
wh1te909
89e7db905d fix client sorting fixes amidaware/tacticalrmm#1439 2023-05-25 21:27:45 +00:00
wh1te909
827e81dcda don't retry null token fixes amidaware/tacticalrmm#1199 2023-05-17 07:03:13 +00:00
wh1te909
6ea3a053f2 update reqs 2023-05-17 07:00:38 +00:00
sadnub
88d297f7c6 Change the default color of anchor tags to make them look better in dark mode 2023-05-13 00:00:36 -04:00
sadnub
6c57d3e6b1 Make the alert icon menu height fixed 2023-05-12 23:59:18 -04:00
wh1te909
0113fbc761 hide openai until next release 2023-05-09 21:06:40 +00:00
wh1te909
95df8c1889 update reqs 2023-05-07 02:16:19 +00:00
sadnub
819a364207 Merge pull request #8 from sadnub/develop
open ai integration
2023-04-10 19:06:18 -04:00
sadnub
ed2b07fb0b change wording on default prompt 2023-04-10 19:04:01 -04:00
sadnub
64ed5e8740 open ai integration 2023-04-09 22:36:20 -04:00
wh1te909
cdeaa3d9c4 bump version 2023-04-09 03:28:00 +00:00
wh1te909
8c6ac164ba bump dev ver 2023-04-07 22:46:24 +00:00
wh1te909
dc68b16ff2 format and align the icon 2023-04-07 21:52:53 +00:00
Dan
a4f15fd05a Merge pull request #6 from jpros/custom-fields-in-summary
Add custom fields to summary view
2023-04-07 13:51:50 -07:00
wh1te909
176675abd8 update reqs 2023-04-07 20:50:21 +00:00
João Paulo Ros
73dc278ac4 Hide custom fields with no value 2023-04-04 20:36:53 -07:00
wh1te909
d6b443296b update reqs 2023-04-04 06:34:11 +00:00
Dan
f3c718d29c Merge pull request #7 from jpros/custom-fields-search
Add custom fields to search in the dashboard
2023-04-03 22:46:20 -07:00
João Paulo Ros
5955af08c7 Hide custom fields that are not supposed to appear in the UI. 2023-03-31 15:22:10 -07:00
João Paulo Ros
dec1ccc98a Add Search from query parameter 2023-03-30 15:40:47 -07:00
João Paulo Ros
a78780b837 Add custom fields to summary view 2023-03-30 14:38:55 -07:00
João Paulo Ros
beff8eb10e Add custom fields to search in dashboard 2023-03-30 12:37:26 -07:00
wh1te909
c2f21b70dd bump version 2023-03-22 16:59:35 +00:00
wh1te909
520145e0e3 bump dev ver 2023-03-21 18:49:28 +00:00
wh1te909
6a132187a2 fix phantom column fixes amidaware/tacticalrmm#1264 2023-03-20 14:08:58 +00:00
wh1te909
a63a9ccd76 update reqs 2023-03-20 01:28:54 +00:00
wh1te909
ff1eb791db feat: increase size of notes text box closes amidaware/tacticalrmm#1407 2023-03-10 05:31:50 +00:00
wh1te909
13bd88b979 feat: bulk run checks by client or site amidaware/tacticalrmm@7d017f9494 2023-03-10 00:22:12 +00:00
wh1te909
5b0c244920 update reqs 2023-03-10 00:20:51 +00:00
wh1te909
0318a17cac update reqs 2023-03-05 20:49:03 +00:00
wh1te909
75296ed8ee bump version 2023-01-18 20:04:26 +00:00
wh1te909
09bee45b2f update reqs and dev version 2023-01-16 23:03:30 +00:00
wh1te909
3573c48872 formatting 2023-01-16 08:38:34 +00:00
wh1te909
784841c221 syntax typo 2023-01-16 08:37:15 +00:00
wh1te909
ed788a1861 update reqs 2023-01-16 08:35:07 +00:00
wh1te909
bd6b08505a bump version 2022-12-21 18:44:21 +00:00
150 changed files with 16805 additions and 11551 deletions

View File

@@ -1,12 +1,11 @@
version: '3.4'
version: '3.7'
services:
app-dev:
container_name: trmm-app-dev
image: node:16-alpine
image: node:20-alpine
restart: always
command: /bin/sh -c "npm install --cache ~/.npm && npm run serve"
user: 1000:1000
command: /bin/sh -c "npm install --cache ~/.npm && npm i -g @quasar/cli && npm run serve"
working_dir: /workspace/web
volumes:
- ..:/workspace:cached

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: 16
node-version: "20.18.0"
- run: touch env-config.js
@@ -29,7 +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

@@ -9,11 +9,11 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: "20.18.0"
- run: npm install
- name: Run Prettier formatting

View File

@@ -5,7 +5,7 @@
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",

18
.vscode/settings.json vendored
View File

@@ -4,25 +4,17 @@
"editor.formatOnSave": true,
"[vue][javascript][typescript][javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": [
"source.fixAll.eslint"
],
"editor.codeActionsOnSave": ["source.fixAll.eslint"]
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"vue"
],
"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,
}
"/env/**": true
},
}
"prettier.prettierPath": "./node_modules/prettier"
}

View File

@@ -1,24 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title><%= productName %></title>
<head>
<title>
<%= productName %>
</title>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>" />
<link rel="icon" type="image/ico" href="favicon.ico" />
<script src="/env-config.js"></script>
</head>
<body>
<!-- quasar:entry-point -->
</body>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link rel="icon" type="image/ico" href="favicon.ico" />
<script src="/env-config.js"></script>
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

13101
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.10-dev",
"version": "0.101.52",
"private": true,
"productName": "Tactical RMM",
"scripts": {
@@ -10,31 +10,38 @@
"format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore"
},
"dependencies": {
"@quasar/extras": "1.15.8",
"apexcharts": "3.36.3",
"axios": "0.27.2",
"dotenv": "16.0.3",
"qrcode.vue": "3.3.3",
"quasar": "2.11.1",
"vue": "3.2.45",
"vue3-ace-editor": "2.2.2",
"vue3-apexcharts": "1.4.1",
"@quasar/extras": "1.16.13",
"@vueuse/core": "11.2.0",
"@vueuse/integrations": "11.2.0",
"@vueuse/shared": "11.2.0",
"apexcharts": "3.54.1",
"axios": "1.7.7",
"dotenv": "16.4.5",
"monaco-editor": "0.50.0",
"pinia": "2.2.6",
"qrcode": "1.5.4",
"quasar": "2.17.2",
"vue": "3.5.12",
"vue-router": "4.4.5",
"vue3-apexcharts": "1.7.0",
"vuedraggable": "4.1.0",
"vue-router": "4.1.6",
"vuex": "4.1.0"
"vuex": "4.1.0",
"@xterm/xterm": "5.5.0",
"@xterm/addon-fit": "0.10.0",
"yaml": "2.6.0"
},
"devDependencies": {
"@quasar/cli": "^1.3.2",
"@intlify/vite-plugin-vue-i18n": "^6.0.3",
"@quasar/app-vite": "^1.1.3",
"@types/node": "^18.11.17",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"autoprefixer": "10.4.13",
"eslint": "8.30.0",
"eslint-config-prettier": "8.5.0",
"@intlify/unplugin-vue-i18n": "4.0.0",
"@quasar/app-vite": "1.10.2",
"@quasar/cli": "2.4.1",
"@types/node": "22.7.5",
"@typescript-eslint/eslint-plugin": "7.16.0",
"@typescript-eslint/parser": "7.16.0",
"autoprefixer": "10.4.20",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-vue": "8.7.1",
"prettier": "2.8.1",
"typescript": "4.9.4"
"prettier": "3.3.3",
"typescript": "5.6.2"
}
}
}

View File

@@ -4,18 +4,18 @@
module.exports = {
plugins: [
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
require("autoprefixer")({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
})
"last 4 Chrome versions",
"last 4 Firefox versions",
"last 4 Edge versions",
"last 4 Safari versions",
"last 4 Android versions",
"last 4 ChromeAndroid versions",
"last 4 FirefoxAndroid versions",
"last 4 iOS versions",
],
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
@@ -23,5 +23,5 @@ module.exports = {
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
]
}
],
};

View File

@@ -8,6 +8,7 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const { mergeConfig } = require("vite");
const { configure } = require("quasar/wrappers");
const path = require("path");
require("dotenv").config();
@@ -29,15 +30,15 @@ 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: ["pinia", "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',
"mdi-v5",
"ionicons-v4",
"mdi-v7",
"fontawesome-v6",
// 'eva-icons',
// 'themify',
@@ -51,8 +52,8 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
target: {
browser: ["es2021"],
node: "node16",
browser: ["es2022"],
node: "node20",
},
vueRouterMode: "history", // available values: 'hash', 'history'
@@ -78,9 +79,22 @@ module.exports = configure(function (/* ctx */) {
// polyfillModulePreload: true,
distDir: "dist/",
// extendViteConf (viteConf) {},
/* eslint-disable quotes */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
extendViteConf(viteConf, { isServer, isClient }) {
viteConf.build = mergeConfig(viteConf.build, {
chunkSizeWarningLimit: 1600,
rollupOptions: {
output: {
entryFileNames: `[hash].js`,
chunkFileNames: `[hash].js`,
assetFileNames: `[hash].[ext]`,
},
},
});
},
/* eslint-enable quotes */
// viteVuePluginOptions: {},
// vitePlugins: []
},

View File

@@ -12,6 +12,9 @@ export default {
body
overflow-y: hidden
a
color: #1976D2
.tbl-sticky
thead tr th
position: sticky

View File

@@ -31,6 +31,34 @@ export async function resetTwoFactor() {
}
}
// sessions api
export async function fetchUserSessions(id) {
try {
const { data } = await axios.get(`${baseUrl}/users/${id}/sessions/`);
return data;
} catch (e) {
console.error(e);
}
}
export async function deleteAllUserSessions(id) {
try {
const { data } = await axios.delete(`${baseUrl}/users/${id}/sessions/`);
return data;
} catch (e) {
console.error(e);
}
}
export async function deleteUserSession(id) {
try {
const { data } = await axios.delete(`${baseUrl}/sessions/${id}/`);
return data;
} catch (e) {
console.error(e);
}
}
// role api function
export async function fetchRoles(params = {}) {
try {

View File

@@ -34,7 +34,7 @@ export function openAgentWindow(agent_id) {
export function runRemoteBackground(agent_id, agentPlatform) {
const url = router.resolve(
`/remotebackground/${agent_id}?agentPlatform=${agentPlatform}`
`/remotebackground/${agent_id}?agentPlatform=${agentPlatform}`,
).href;
openURL(url, null, {
popup: true,
@@ -129,7 +129,7 @@ export async function refreshAgentWMI(agent_id) {
export async function runScript(agent_id, payload) {
const { data } = await axios.post(
`${baseUrl}/${agent_id}/runscript/`,
payload
payload,
);
return data;
}
@@ -153,7 +153,7 @@ export async function fetchAgentProcesses(agent_id, params = {}) {
export async function killAgentProcess(agent_id, pid, params = {}) {
const { data } = await axios.delete(
`${baseUrl}/${agent_id}/processes/${pid}/`,
{ params: params }
{ params: params },
);
return data;
}
@@ -162,7 +162,7 @@ export async function fetchAgentEventLog(agent_id, logType, days, params = {}) {
try {
const { data } = await axios.get(
`${baseUrl}/${agent_id}/eventlog/${logType}/${days}/`,
{ params: params }
{ params: params },
);
return data;
} catch (e) {
@@ -191,10 +191,15 @@ 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/`,
{ params: params }
{ params: params },
);
return data;
}
@@ -232,3 +237,8 @@ export async function removeAgentNote(pk) {
const { data } = await axios.delete(`${baseUrl}/notes/${pk}/`);
return data;
}
export async function wakeUpWOL(agent_id) {
const { data } = await axios.post(`${baseUrl}/${agent_id}/wol/`);
return data;
}

13
src/api/alerts.ts Normal file
View File

@@ -0,0 +1,13 @@
import axios from "axios";
import type { AlertTemplate } from "@/types/alerts";
export async function saveAlertTemplate(id: number, payload: AlertTemplate) {
const { data } = await axios.put(`alerts/templates/${id}/`, payload);
return data;
}
export async function addAlertTemplate(payload: AlertTemplate) {
const { data } = await axios.post("alerts/templates/", payload);
return data;
}

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;

View File

@@ -1,40 +0,0 @@
import axios from "axios";
import { openURL } from "quasar";
const baseUrl = "/core";
export async function fetchCustomFields(params = {}) {
try {
const { data } = await axios.get(`${baseUrl}/customfields/`, {
params: params,
});
return data;
} catch (e) {
console.error(e);
}
}
export async function fetchDashboardInfo(params = {}) {
const { data } = await axios.get(`${baseUrl}/dashinfo/`, { params: params });
return data;
}
export async function fetchURLActions(params = {}) {
try {
const { data } = await axios.get(`${baseUrl}/urlaction/`, {
params: params,
});
return data;
} catch (e) {
console.error(e);
}
}
export async function runURLAction(payload) {
try {
const { data } = await axios.patch(`${baseUrl}/urlaction/run/`, payload);
openURL(data);
} catch (e) {
console.error(e);
}
}

104
src/api/core.ts Normal file
View File

@@ -0,0 +1,104 @@
import axios from "axios";
import { openURL } from "quasar";
import { router } from "@/router";
import type {
URLAction,
TestRunURLActionRequest,
TestRunURLActionResponse,
} from "@/types/core/urlactions";
import type { CoreSetting } from "@/types/core/settings";
const baseUrl = "/core";
export async function fetchCoreSettings(params = {}): Promise<CoreSetting> {
const { data } = await axios.get("/core/settings/", { params: params });
return data;
}
export async function fetchDashboardInfo(params = {}) {
const { data } = await axios.get(`${baseUrl}/dashinfo/`, { params: params });
return data;
}
export async function fetchCustomFields(params = {}) {
try {
const { data } = await axios.get(`${baseUrl}/customfields/`, {
params: params,
});
return data;
} catch (e) {
console.error(e);
}
}
export async function fetchURLActions(params = {}): Promise<URLAction[]> {
const { data } = await axios.get(`${baseUrl}/urlaction/`, {
params: params,
});
return data;
}
export async function saveURLAction(action: URLAction) {
const { data } = await axios.post(`${baseUrl}/urlaction/`, action);
return data;
}
export async function editURLAction(id: number, action: URLAction) {
const { data } = await axios.put(`${baseUrl}/urlaction/${id}/`, action);
return data;
}
export async function removeURLAction(id: number) {
const { data } = await axios.delete(`${baseUrl}/urlaction/${id}/`);
return data;
}
interface RunURLActionRequest {
agent_id?: string;
client?: number;
site?: number;
action: number;
}
export async function runURLAction(payload: RunURLActionRequest) {
const { data } = await axios.patch(`${baseUrl}/urlaction/run/`, payload);
openURL(data);
}
export async function runTestURLAction(
payload: TestRunURLActionRequest,
): Promise<TestRunURLActionResponse> {
const { data } = await axios.post(`${baseUrl}/urlaction/run/test/`, payload);
return data;
}
export async function checkWebTermPerms(): Promise<{
message: string;
status: number;
}> {
const ret = await axios.post(`${baseUrl}/webtermperms/`);
return { message: ret.data, status: ret.status };
}
export function openWebTerminal(): void {
const url: string = router.resolve("/webterm").href;
openURL(url, undefined, {
popup: true,
scrollbars: false,
location: false,
status: false,
toolbar: false,
menubar: false,
width: 1280,
height: 720,
});
}
// TODO: Build out type for openai payload
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function generateScript(payload: any) {
const { data } = await axios.post(`${baseUrl}/openai/generate/`, payload);
return data;
}

View File

@@ -13,6 +13,11 @@ export async function testScript(agent_id, payload) {
return data;
}
export async function testScriptOnServer(payload) {
const { data } = await axios.post("core/serverscript/test/", payload);
return data;
}
export async function saveScript(payload) {
const { data } = await axios.post(`${baseUrl}/`, payload);
return data;
@@ -56,7 +61,7 @@ export async function fetchScriptSnippet(id, params = {}) {
export async function editScriptSnippet(payload) {
const { data } = await axios.put(
`${baseUrl}/snippets/${payload.id}/`,
payload
payload,
);
return data;
}

BIN
src/assets/trmm_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import { useAuthStore } from "@/stores/auth";
import { Notify } from "quasar";
export const getBaseUrl = () => {
@@ -9,13 +10,24 @@ export const getBaseUrl = () => {
}
};
export default function ({ app, router, store }) {
export function setErrorMessage(data, message) {
console.log(data);
return [
() => {
message;
},
];
}
export default function ({ app, router }) {
app.config.globalProperties.$axios = axios;
axios.defaults.withCredentials = true;
axios.interceptors.request.use(
function (config) {
const auth = useAuthStore();
config.baseURL = getBaseUrl();
const token = store.state.token;
const token = auth.token;
if (token != null) {
config.headers.Authorization = `Token ${token}`;
}
@@ -23,7 +35,7 @@ export default function ({ app, router, store }) {
},
function (err) {
return Promise.reject(err);
}
},
);
axios.interceptors.response.use(
@@ -54,12 +66,20 @@ export default function ({ app, router, store }) {
// perms
else if (error.response.status === 403) {
// don't notify user if method is GET
if (error.config.method === "get" || error.config.method === "patch")
if (
error.config.method === "get" ||
error.config.method === "patch" ||
error.config.url === "accounts/ssoproviders/token/"
)
return Promise.reject({ ...error });
text = error.response.data.detail;
}
// catch all for other 400 error messages
else if (error.response.status >= 400 && error.response.status < 500) {
else if (
error.response.status >= 400 &&
error.response.status < 500 &&
error.response.status !== 423
) {
if (error.config.responseType === "blob") {
text = (await error.response.data.text()).replace(/^"|"$/g, "");
} else if (error.response.data.non_field_errors) {
@@ -74,7 +94,7 @@ export default function ({ app, router, store }) {
}
}
if (text || error.response) {
if ((text || error.response) && error.response.status !== 423) {
Notify.create({
color: "negative",
message: text ? text : "",
@@ -86,6 +106,6 @@ export default function ({ app, router, store }) {
}
return Promise.reject({ ...error });
}
},
);
}

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();
},
};
});

11
src/boot/pinia.ts Normal file
View File

@@ -0,0 +1,11 @@
import { boot } from "quasar/wrappers";
import { createPinia } from "pinia";
export default boot(({ app }) => {
const pinia = createPinia();
app.use(pinia);
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
});

View File

@@ -1,157 +1,202 @@
<template>
<div style="width: 900px; max-width: 90vw">
<q-card>
<q-bar>
<q-card style="width: 65vw; max-width: 70vw; min-height: 50vh">
<q-bar>
<q-btn
ref="refresh"
@click="getUsers"
class="q-mr-sm"
dense
flat
push
icon="refresh"
/>User Administration
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<div class="q-pa-md">
<div class="q-gutter-sm">
<q-btn
ref="refresh"
@click="getUsers"
class="q-mr-sm"
ref="new"
label="New"
dense
flat
push
icon="refresh"
/>User Administration
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<div class="q-pa-md">
<div class="q-gutter-sm">
<q-btn
ref="new"
label="New"
dense
flat
push
unelevated
no-caps
icon="add"
@click="showAddUserModal"
/>
</div>
<q-table
dense
:rows="users"
:columns="columns"
v-model:pagination="pagination"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
>
<!-- header slots -->
<template v-slot:header-cell-is_active="props">
<q-th :props="props" auto-width>
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enable User</q-tooltip>
</q-icon>
</q-th>
</template>
<!-- No data Slot -->
<template v-slot:no-data>
<div class="full-width row flex-center q-gutter-sm">
<span v-if="users.length === 0">No Users</span>
</div>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="showEditUserModal(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="showEditUserModal(props.row)"
>
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteUser(props.row)"
:disable="props.row.username === logged_in_user"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="ResetPassword(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Password</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="reset2FA(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Two-Factor Auth</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- enabled checkbox -->
<q-td>
<q-checkbox
dense
@update:model-value="toggleEnabled(props.row)"
v-model="props.row.is_active"
:disable="props.row.username === logged_in_user"
/>
</q-td>
<q-td>{{ props.row.username }}</q-td>
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
<q-td>{{ props.row.email }}</q-td>
<q-td v-if="props.row.last_login">{{
formatDate(props.row.last_login)
}}</q-td>
<q-td v-else>Never</q-td>
<q-td>{{ props.row.last_login_ip }}</q-td>
</q-tr>
</template>
</q-table>
unelevated
no-caps
icon="add"
@click="showAddUserModal"
/>
</div>
</q-card>
</div>
<q-table
dense
:rows="users"
:columns="columns"
v-model:pagination="pagination"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
>
<!-- header slots -->
<template v-slot:header-cell-is_active="props">
<q-th :props="props" auto-width>
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enable User</q-tooltip>
</q-icon>
</q-th>
</template>
<template v-slot:header-cell-sso="props">
<q-th :props="props" auto-width></q-th>
</template>
<!-- No data Slot -->
<template v-slot:no-data>
<div class="full-width row flex-center q-gutter-sm">
<span v-if="users.length === 0">No Users</span>
</div>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="showEditUserModal(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="showEditUserModal(props.row)"
>
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteUser(props.row)"
:disable="props.row.username === logged_in_user"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="ResetPassword(props.row)"
id="context-reset"
:disable="props.row.social_accounts.length !== 0"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Password</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="reset2FA(props.row)"
id="context-reset"
:disable="props.row.social_accounts.length !== 0"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Two-Factor Auth</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="showSSOAccounts(props.row)"
id="context-reset"
:disable="props.row.social_accounts.length === 0"
>
<q-item-section side>
<q-icon name="groups" />
</q-item-section>
<q-item-section>Show Connected SSO Accounts</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="showSessions(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="groups" />
</q-item-section>
<q-item-section>Show Active Sessions</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- enabled checkbox -->
<q-td>
<q-checkbox
dense
@update:model-value="toggleEnabled(props.row)"
v-model="props.row.is_active"
:disable="props.row.username === logged_in_user"
/>
</q-td>
<q-td>
<q-chip
v-if="props.row.social_accounts.length > 0"
color="primary"
dense
>SSO</q-chip
>
</q-td>
<q-td>{{ props.row.username }}</q-td>
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
<q-td>{{ props.row.email }}</q-td>
<q-td v-if="props.row.last_login">{{
formatDate(props.row.last_login)
}}</q-td>
<q-td v-else>Never</q-td>
<q-td>{{ props.row.last_login_ip }}</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-card>
</template>
<script>
import mixins from "@/mixins/mixins";
import { computed } from "vue";
import { mapState, useStore } from "vuex";
import { useStore } from "vuex";
import { useQuasar } from "quasar";
import { mapState as piniaMapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import UserForm from "@/components/modals/admin/UserForm.vue";
import UserResetPasswordForm from "@/components/modals/admin/UserResetPasswordForm.vue";
import SSOAccountsTable from "@/ee/sso/components/SSOAccountsTable.vue";
import UserSessionsTable from "@/components/accounts/UserSessionsTable.vue";
export default {
name: "AdminManager",
@@ -161,8 +206,30 @@ export default {
const store = useStore();
const formatDate = computed(() => store.getters.formatDate);
const $q = useQuasar();
function showSSOAccounts(user) {
$q.dialog({
component: SSOAccountsTable,
componentProps: {
user,
},
});
}
async function showSessions(user) {
$q.dialog({
component: UserSessionsTable,
componentProps: {
user,
},
});
}
return {
formatDate,
showSSOAccounts,
showSessions,
};
},
data() {
@@ -175,6 +242,13 @@ export default {
field: "is_active",
align: "left",
},
{
name: "sso",
label: "",
field: "sso",
align: "left",
sortable: true,
},
{
name: "username",
label: "Username",
@@ -316,7 +390,7 @@ export default {
},
},
computed: {
...mapState({
...piniaMapState(useAuthStore, {
logged_in_user: (state) => state.username,
}),
},

View File

@@ -46,6 +46,9 @@
<template v-slot:header-cell-plat="props">
<q-th auto-width :props="props"></q-th>
</template>
<template v-slot:header-cell-mon-type="props">
<q-th auto-width :props="props"></q-th>
</template>
<template v-slot:header-cell-checks-status="props">
<q-th :props="props">
<q-icon name="fas fa-check-double" size="1.2em">
@@ -170,7 +173,7 @@
overdueAlert(
'dashboard',
props.row,
props.row.overdue_dashboard_alert
props.row.overdue_dashboard_alert,
)
"
v-model="props.row.overdue_dashboard_alert"
@@ -206,12 +209,26 @@
</q-icon>
</q-td>
<q-td key="mon-type" :props="props">
<q-icon
v-if="props.row.monitoring_type === 'server'"
name="dns"
size="sm"
color="primary"
>
<q-tooltip>Server</q-tooltip>
</q-icon>
<q-icon v-else name="computer" size="sm" color="primary">
<q-tooltip>Workstation</q-tooltip>
</q-icon>
</q-td>
<q-td key="checks-status" :props="props">
<q-icon
v-if="props.row.maintenance_mode"
name="construction"
size="1.2em"
color="green"
:color="dash_positive_color"
>
<q-tooltip>Maintenance Mode Enabled</q-tooltip>
</q-icon>
@@ -219,7 +236,7 @@
v-else-if="props.row.checks.failing > 0"
name="fas fa-check-double"
size="1.2em"
color="negative"
:color="dash_negative_color"
>
<q-tooltip>Checks failing</q-tooltip>
</q-icon>
@@ -227,7 +244,7 @@
v-else-if="props.row.checks.warning > 0"
name="fas fa-check-double"
size="1.2em"
color="warning"
:color="dash_warning_color"
>
<q-tooltip>Checks warning</q-tooltip>
</q-icon>
@@ -235,7 +252,7 @@
v-else-if="props.row.checks.info > 0"
name="fas fa-check-double"
size="1.2em"
color="info"
:color="dash_info_color"
>
<q-tooltip>Checks info</q-tooltip>
</q-icon>
@@ -243,7 +260,7 @@
v-else
name="fas fa-check-double"
size="1.2em"
color="positive"
:color="dash_positive_color"
>
<q-tooltip>Checks passing</q-tooltip>
</q-icon>
@@ -279,7 +296,7 @@
@click="showPendingActionsModal(props.row)"
name="far fa-clock"
size="1.4em"
color="warning"
:color="dash_warning_color"
class="cursor-pointer"
>
<q-tooltip
@@ -303,7 +320,7 @@
v-if="props.row.status === 'overdue'"
name="fas fa-signal"
size="1.2em"
color="negative"
:color="dash_negative_color"
>
<q-tooltip>Agent overdue</q-tooltip>
</q-icon>
@@ -311,11 +328,16 @@
v-else-if="props.row.status === 'offline'"
name="fas fa-signal"
size="1.2em"
color="warning"
:color="dash_warning_color"
>
<q-tooltip>Agent offline</q-tooltip>
</q-icon>
<q-icon v-else name="fas fa-signal" size="1.2em" color="positive">
<q-icon
v-else
name="fas fa-signal"
size="1.2em"
:color="dash_positive_color"
>
<q-tooltip>Agent online</q-tooltip>
</q-icon>
</q-td>
@@ -373,17 +395,13 @@ export default {
"local_ips",
"make_model",
"physical_disks",
"custom_fields",
"serial_number",
];
// quasar filter only does visible columns so this is a hack to add hidden columns we want to filter
for (const elem of hiddenFields) {
if (!cols.find((o) => o.name === elem)) {
cols.push({
name: elem,
field: elem,
});
}
}
// originally I was modifying cols directly but this led to phantom colum so doing it this way now
// https://github.com/amidaware/tacticalrmm/issues/1264
const allColumns = [...cols, ...hiddenFields.map((field) => ({ field }))];
const lowerTerms = terms ? terms.toLowerCase() : "";
let advancedFilter = false;
@@ -430,15 +448,19 @@ 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 = new Date(row.last_seen);
let diff = date.getDateDiff(now, last_seen, "days");
if (diff < 30) return false;
}
}
// Normal text filter
return cols.some((col) => {
const val = cellValue(col, row) + "";
return allColumns.some((col) => {
let valObj = cellValue(col, row);
if (Array.isArray(valObj)) {
valObj = valObj.map((item) => (item.value ? item.value : item));
}
const val = valObj + "";
const haystack =
val === "undefined" || val === "null" ? "" : val.toLowerCase();
return haystack.indexOf(search) !== -1;
@@ -489,7 +511,9 @@ export default {
const data = {
[db_field]: !alert_action,
};
const alertColor = !alert_action ? "positive" : "info";
const alertColor = !alert_action
? this.dash_positive_color
: this.dash_info_color;
this.$axios.put(`/agents/${agent.agent_id}/`, data).then(() => {
this.$q.notify({
color: alertColor,
@@ -533,7 +557,13 @@ export default {
},
},
computed: {
...mapState(["tableHeight"]),
...mapState([
"tableHeight",
"dash_info_color",
"dash_positive_color",
"dash_negative_color",
"dash_warning_color",
]),
agentDblClickAction() {
return this.$store.state.agentDblClickAction;
},

View File

@@ -3,7 +3,7 @@
<q-badge v-if="alertsCount > 0" :color="badgeColor" floating transparent>{{
alertsCountText()
}}</q-badge>
<q-menu style="max-height: 30vh">
<q-menu :style="{ 'max-height': `${$q.screen.height - 100}px` }">
<q-list separator>
<q-item v-if="alertsCount === 0">No New Alerts</q-item>
<q-item v-for="alert in topAlerts" :key="alert.id">
@@ -59,6 +59,7 @@
</template>
<script>
import { mapState } from "vuex";
import mixins from "@/mixins/mixins";
import AlertsOverview from "@/components/modals/alerts/AlertsOverview.vue";
import { getTimeLapse } from "@/utils/format";
@@ -75,19 +76,21 @@ export default {
return {
alertsCount: 0,
topAlerts: [],
errorColor: "red",
warningColor: "orange",
infoColor: "blue",
poll: null,
};
},
computed: {
...mapState([
"dash_info_color",
"dash_warning_color",
"dash_negative_color",
]),
badgeColor() {
const severities = this.topAlerts.map((alert) => alert.severity);
if (severities.includes("error")) return this.errorColor;
else if (severities.includes("warning")) return this.warningColor;
else return this.infoColor;
if (severities.includes("error")) return this.dash_negative_color;
else if (severities.includes("warning")) return this.dash_warning_color;
else return this.dash_info_color;
},
},
methods: {
@@ -159,9 +162,9 @@ export default {
});
},
alertIconColor(severity) {
if (severity === "error") return this.errorColor;
else if (severity === "warning") return this.warningColor;
else return this.infoColor;
if (severity === "error") return this.dash_negative_color;
else if (severity === "warning") return this.dash_warning_color;
else return this.dash_info_color;
},
alertsCountText() {
if (this.alertsCount > 99) return "99+";

View File

@@ -278,7 +278,7 @@ export default {
},
{
name: "resolved_action_name",
label: "Resolve Action",
label: "Resolved Action",
field: "resolved_action_name",
align: "left",
},
@@ -326,7 +326,7 @@ export default {
this.refresh();
this.$q.loading.hide();
this.notifySuccess(
`Alert template ${template.name} was deleted!`
`Alert template ${template.name} was deleted!`,
);
})
.catch(() => {

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,11 @@
/>
<q-checkbox
v-model="localRole.can_reboot_agents"
label="Reboot Agents"
label="Shutdown / Reboot Agents"
/>
<q-checkbox
v-model="localRole.can_send_wol"
label="Wake-Up (WoL) Agents"
/>
<q-checkbox
v-model="localRole.can_install_agents"
@@ -136,6 +151,14 @@
v-model="localRole.can_edit_core_settings"
label="Edit Global Settings"
/>
<q-checkbox
v-model="localRole.can_view_global_keystore"
label="View Global Key Store"
/>
<q-checkbox
v-model="localRole.can_edit_global_keystore"
label="Edit Global Key Store"
/>
<q-checkbox
v-model="localRole.can_do_server_maint"
label="Do Server Maintenance"
@@ -164,6 +187,11 @@
v-model="localRole.can_manage_customfields"
label="Edit Custom Fields"
/>
<q-checkbox
v-if="!hosted"
v-model="localRole.can_use_webterm"
label="Use TRMM Server Web Terminal"
/>
</div>
</q-card-section>
@@ -313,6 +341,11 @@
v-model="localRole.can_manage_scripts"
label="Manage Scripts"
/>
<q-checkbox
v-if="!hosted"
v-model="localRole.can_run_server_scripts"
label="Run Scripts on TRMM Server"
/>
</div>
</q-card-section>
@@ -394,7 +427,8 @@
<script>
// composition imports
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useStore } from "vuex";
import { useDialogPluginComponent } from "quasar";
import { saveRole, editRole } from "@/api/accounts";
import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
@@ -412,6 +446,10 @@ export default {
// quasar setup
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// store
const store = useStore();
const hosted = computed(() => store.state.hosted);
// dropdown setup
const { clientOptions } = useClientDropdown(true);
const { siteOptions } = useSiteDropdown(true);
@@ -428,7 +466,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,
@@ -437,8 +474,8 @@ export default {
can_run_scripts: false,
can_run_bulk: false,
can_manage_winsvcs: false,
can_recover_agents: false,
can_list_agent_history: false,
can_send_wol: false,
// software perms
can_list_software: false,
can_manage_software: false,
@@ -448,6 +485,8 @@ export default {
// settings perms
can_view_core_settings: false,
can_edit_core_settings: false,
can_view_global_keystore: false,
can_edit_global_keystore: false,
can_do_server_maint: false,
can_code_sign: false,
can_run_urlactions: false,
@@ -497,6 +536,12 @@ export default {
can_manage_roles: false,
can_view_clients: [],
can_view_sites: [],
// server scripts and web terminal
can_run_server_scripts: false,
can_use_webterm: false,
// reporting perms
can_view_reports: false,
can_manage_reports: false,
});
const loading = ref(false);
@@ -524,7 +569,7 @@ export default {
role.value[key] = newValue;
}
});
}
},
);
return {
@@ -533,6 +578,7 @@ export default {
loading,
clientOptions,
siteOptions,
hosted,
onSubmit,

View File

@@ -0,0 +1,151 @@
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card style="width: 60vw; max-width: 90vw; min-height: 40vh">
<q-bar>
User Sessions for {{ user.username }}
<q-space />
<q-btn v-close-popup dense flat icon="close">
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-table
dense
:table-class="{
'table-bgcolor': !$q.dark.isActive,
'table-bgcolor-dark': $q.dark.isActive,
}"
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
class="tbl-sticky"
:rows="sessions"
:columns="columns"
:loading="loading"
:pagination="{ rowsPerPage: 0, sortBy: 'display', descending: true }"
row-key="id"
binary-state-sort
virtual-scroll
:rows-per-page-options="[0]"
>
<template #top>
<q-space />
<q-btn
label="Remove All Sessions"
@click="removeAllSessions"
size="sm"
color="negative"
/>
</template>
<template #body="props">
<q-tr>
<!-- rows -->
<td>{{ formatDate(props.row.created) }}</td>
<td>{{ formatDate(props.row.expiry) }}</td>
<td>
<q-btn
size="sm"
@click="removeSession(props.row)"
label="Disconnect"
color="negative"
></q-btn>
</td>
</q-tr>
</template>
</q-table>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { onMounted, ref } from "vue";
import { useDialogPluginComponent, useQuasar, type QTableColumn } from "quasar";
import { notifySuccess } from "@/utils/notify";
import { formatDate } from "@/utils/format";
import {
fetchUserSessions,
deleteAllUserSessions,
deleteUserSession,
} from "@/api/accounts";
//types
import type { SSOUser } from "@/ee/sso/types/sso";
import type { AuthToken } from "@/types/accounts";
const columns: QTableColumn[] = [
{
name: "created",
label: "Created",
field: "created",
align: "left",
sortable: true,
},
{
name: "expiry",
label: "Expires",
field: "expiry",
align: "left",
sortable: true,
},
{
name: "action",
label: "",
field: "action",
align: "left",
sortable: true,
},
];
// emits
defineEmits([...useDialogPluginComponent.emits]);
// props
const props = defineProps<{
user: SSOUser;
}>();
const { dialogRef, onDialogHide } = useDialogPluginComponent();
const $q = useQuasar();
const sessions = ref([] as AuthToken[]);
const loading = ref(false);
function removeSession(token: AuthToken) {
$q.dialog({
title: `Disconnect session for ${token.user}?`,
message: "This user will be signed out immediately.",
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await deleteUserSession(token.digest);
notifySuccess("Login session deleted successfully");
} finally {
loading.value = false;
await getSessions();
}
});
}
function removeAllSessions() {
$q.dialog({
title: `Disconnect all sessions for ${props.user.username}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await deleteAllUserSessions(props.user.id);
notifySuccess("Login sessions deleted successfully");
} finally {
loading.value = false;
onDialogHide();
}
});
}
async function getSessions() {
sessions.value = await fetchUserSessions(props.user.id);
}
onMounted(getSessions);
</script>

View File

@@ -146,6 +146,13 @@
<q-item-section>Run Checks</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="wakeUp(agent)">
<q-item-section side>
<q-icon size="xs" name="offline_bolt" />
</q-item-section>
<q-item-section>Wake-Up (WoL)</q-item-section>
</q-item>
<q-item clickable>
<q-item-section side>
<q-icon size="xs" name="power_settings_new" />
@@ -169,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" />
@@ -176,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" />
@@ -206,10 +238,12 @@ import { fetchURLActions, runURLAction } from "@/api/core";
import {
editAgent,
agentRebootNow,
agentShutdown,
sendAgentPing,
removeAgent,
runRemoteBackground,
runTakeControl,
wakeUpWOL,
} from "@/api/agents";
import { runAgentUpdateScan, runAgentUpdateInstall } from "@/api/winupdates";
import { runAgentChecks } from "@/api/checks";
@@ -224,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,
},
@@ -264,16 +302,21 @@ export default {
async function getURLActions() {
menuLoading.value = true;
try {
urlActions.value = await fetchURLActions();
urlActions.value = (await fetchURLActions())
.filter((action) => action.action_type === "web")
.sort((a, b) => a.name.localeCompare(b.name));
if (urlActions.value.length === 0) {
notifyWarning(
"No URL Actions configured. Go to Settings > Global Settings > URL Actions"
"No URL Actions configured. Go to Settings > Global Settings > URL Actions",
);
return;
}
} catch (e) {}
menuLoading.value = true;
} catch (e) {
console.error(e);
} finally {
menuLoading.value = false;
}
}
function showSendCommand(agent) {
@@ -334,7 +377,7 @@ export default {
notifySuccess(
`Maintenance mode was ${
agent.maintenance_mode ? "disabled" : "enabled"
} on ${agent.hostname}`
} on ${agent.hostname}`,
);
store.commit("setRefreshSummaryTab", true);
refreshDashboard();
@@ -370,6 +413,15 @@ export default {
}
}
async function wakeUp(agent) {
try {
const data = await wakeUpWOL(agent.agent_id);
notifySuccess(data);
} catch (e) {
console.error(e);
}
}
function showRebootLaterModal(agent) {
$q.dialog({
component: RebootLater,
@@ -398,6 +450,32 @@ export default {
});
}
function shutdown(agent) {
$q.dialog({
title:
'Please type <code style="color:red">yes</code> in the box below to confirm shutdown.',
prompt: {
model: "",
type: "text",
isValid: (val) => val === "yes",
},
cancel: true,
ok: { label: "Shutdown", color: "negative" },
persistent: true,
html: true,
}).onOk(async () => {
$q.loading.show();
try {
await agentShutdown(agent.agent_id);
notifySuccess(`${agent.hostname} will now be shutdown`);
$q.loading.hide();
} catch (e) {
$q.loading.hide();
console.error(e);
}
});
}
function showPolicyAdd(agent) {
$q.dialog({
component: PolicyAdd,
@@ -466,7 +544,7 @@ export default {
notifySuccess(data);
refreshDashboard(
false /* clearTreeSelected */,
true /* clearSubTable */
true /* clearSubTable */,
);
} catch (e) {
console.error(e);
@@ -495,9 +573,11 @@ export default {
runChecks,
showRebootLaterModal,
rebootNow,
shutdown,
showPolicyAdd,
showAgentRecovery,
pingAgent,
wakeUp,
};
},
};

View File

@@ -261,7 +261,7 @@
<q-td v-else-if="props.row.task_result.status === 'passing'">
<q-icon
style="font-size: 1.3rem"
color="positive"
:color="dash_positive_color"
name="check_circle"
>
<q-tooltip>Passing</q-tooltip>
@@ -271,7 +271,7 @@
<q-icon
v-if="props.row.alert_severity === 'info'"
style="font-size: 1.3rem"
color="info"
:color="dash_info_color"
name="info"
>
<q-tooltip>Informational</q-tooltip>
@@ -279,7 +279,7 @@
<q-icon
v-else-if="props.row.alert_severity === 'warning'"
style="font-size: 1.3rem"
color="warning"
:color="dash_warning_color"
name="warning"
>
<q-tooltip>Warning</q-tooltip>
@@ -287,7 +287,7 @@
<q-icon
v-else
style="font-size: 1.3rem"
color="negative"
:color="dash_negative_color"
name="error"
>
<q-tooltip>Error</q-tooltip>
@@ -295,7 +295,12 @@
</q-td>
<q-td v-else></q-td>
<!-- name -->
<q-td>{{ props.row.name }}</q-td>
<q-td
>{{ props.row.name
}}<q-tooltip v-if="props.row?.win_task_name" :delay="700">{{
props.row.win_task_name
}}</q-tooltip></q-td
>
<!-- sync status -->
<q-td v-if="props.row.task_result.sync_status === 'notsynced'"
>Will sync on next agent checkin</q-td
@@ -418,6 +423,10 @@ export default {
const tabHeight = computed(() => store.state.tabHeight);
const agentPlatform = computed(() => store.state.agentPlatform);
const formatDate = computed(() => store.getters.formatDate);
const dash_info_color = computed(() => store.state.dash_info_color);
const dash_positive_color = computed(() => store.state.dash_positive_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const dash_warning_color = computed(() => store.state.dash_warning_color);
// setup quasar
const $q = useQuasar();
@@ -437,7 +446,7 @@ export default {
try {
const result = await fetchAgentTasks(selectedAgent.value);
tasks.value = result.filter(
(task) => task.sync_status !== "pendingdeletion"
(task) => task.sync_status !== "pendingdeletion",
);
} catch (e) {
console.error(e);
@@ -491,7 +500,7 @@ export default {
try {
const result = await runTask(
task.id,
task.policy ? { agent_id: selectedAgent.value } : {}
task.policy ? { agent_id: selectedAgent.value } : {},
);
notifySuccess(result);
} catch (e) {
@@ -552,6 +561,10 @@ export default {
selectedAgent,
tabHeight,
agentPlatform,
dash_info_color,
dash_positive_color,
dash_warning_color,
dash_negative_color,
// non-reactive data
columns,

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>
@@ -301,7 +311,7 @@
<q-td v-else-if="props.row.check_result.status === 'passing'">
<q-icon
style="font-size: 1.3rem"
color="positive"
:color="dash_positive_color"
name="check_circle"
>
<q-tooltip>Passing</q-tooltip>
@@ -311,7 +321,7 @@
<q-icon
v-if="getAlertSeverity(props.row) === 'info'"
style="font-size: 1.3rem"
color="info"
:color="dash_info_color"
name="info"
>
<q-tooltip>Informational</q-tooltip>
@@ -319,7 +329,7 @@
<q-icon
v-else-if="getAlertSeverity(props.row) === 'warning'"
style="font-size: 1.3rem"
color="warning"
:color="dash_warning_color"
name="warning"
>
<q-tooltip>Warning</q-tooltip>
@@ -327,7 +337,7 @@
<q-icon
v-else
style="font-size: 1.3rem"
color="negative"
:color="dash_negative_color"
name="error"
>
<q-tooltip>Error</q-tooltip>
@@ -360,7 +370,13 @@
style="cursor: pointer; text-decoration: underline"
class="text-primary"
@click="showPingInfo(props.row)"
>Last Output</span
>{{
grep(props.row.check_result.more_info, [
"transmitted",
"received",
"packet loss",
])
}}</span
>
<span
v-else-if="
@@ -369,7 +385,7 @@
style="cursor: pointer; text-decoration: underline"
class="text-primary"
@click="showScriptOutput(props.row.check_result)"
>Last Output</span
>{{ processOutput(props.row.check_result) }}</span
>
<span
v-else-if="
@@ -382,7 +398,9 @@
>
<span
v-else-if="
props.row.check_type === 'diskspace' ||
['diskspace', 'cpuload', 'memory'].includes(
props.row.check_type,
) ||
(props.row.check_type === 'winsvc' && props.row.check_result.id)
"
>{{ props.row.check_result.more_info }}</span
@@ -415,6 +433,7 @@ import {
updateCheck,
removeCheck,
resetCheck,
resetAllChecksStatus,
runAgentChecks,
} from "@/api/checks";
import { fetchAgentChecks } from "@/api/agents";
@@ -479,6 +498,10 @@ export default {
const tabHeight = computed(() => store.state.tabHeight);
const agentPlatform = computed(() => store.state.agentPlatform);
const formatDate = computed(() => store.getters.formatDate);
const dash_info_color = computed(() => store.state.dash_info_color);
const dash_positive_color = computed(() => store.state.dash_positive_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const dash_warning_color = computed(() => store.state.dash_warning_color);
// setup quasar
const $q = useQuasar();
@@ -495,6 +518,40 @@ export default {
descending: false,
});
// TODO this will break when we add translations
function grep(text, stringsToMatch) {
try {
const lines = text.split("\n");
const matched = [];
for (const line of lines) {
if (stringsToMatch.every((str) => line.includes(str))) {
matched.push(line);
}
}
return matched.length > 0 ? matched.join("\n") : "Last Output";
} catch (e) {
console.error(e);
return "Last Output";
}
}
function processOutput(result) {
try {
if (result.stdout && result.stdout.trim() !== "") {
return result.stdout.substring(0, 60);
} else if (result.stderr && result.stderr.trim() !== "") {
return result.stderr.substring(0, 60);
} else {
return "Last Output";
}
} catch (e) {
console.error(e);
return "Last Output";
}
}
function getAlertSeverity(check) {
if (check.check_result.alert_severity) {
return check.check_result.alert_severity;
@@ -568,7 +625,7 @@ export default {
notifySuccess(result);
refreshDashboard(
false /* clearTreeSelected */,
false /* clearSubTable */
false /* clearSubTable */,
);
} catch (e) {
console.error(e);
@@ -576,6 +633,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,
@@ -631,6 +708,7 @@ export default {
componentProps: {
check: check,
parent: !check ? { agent: selectedAgent.value } : undefined,
plat: type === "script" ? agentPlatform.value : undefined,
},
}).onOk(getChecks);
}
@@ -653,6 +731,10 @@ export default {
tabHeight,
selectedAgent,
agentPlatform,
dash_info_color,
dash_positive_color,
dash_warning_color,
dash_negative_color,
// non-reactive data
columns,
@@ -666,6 +748,9 @@ export default {
formatDate,
getAlertSeverity,
runChecks,
resetAllChecks,
grep,
processOutput,
// dialogs
showScriptOutput,

View File

@@ -166,7 +166,7 @@ export default {
type: "textarea",
isValid: (val) => !!val,
},
style: "width: 30vw; max-width: 50vw;",
style: "width: 90vw; max-width: 90vw",
ok: { label: "Add" },
cancel: true,
}).onOk(async () => {
@@ -193,7 +193,7 @@ export default {
type: "textarea",
isValid: (val) => !!val,
},
style: "width: 30vw; max-width: 50vw;",
style: "width: 90vw; max-width: 90vw",
ok: { label: "Save" },
cancel: true,
}).onOk(async (data) => {

View File

@@ -18,6 +18,33 @@
icon="refresh"
@click="refreshSummary"
/>
<q-icon
v-if="summary.status === 'overdue'"
name="fas fa-signal"
size="1.2em"
:color="dash_negative_color"
class="q-mr-sm"
>
<q-tooltip>Agent overdue</q-tooltip>
</q-icon>
<q-icon
v-else-if="summary.status === 'offline'"
name="fas fa-signal"
size="1.2em"
:color="dash_warning_color"
class="q-mr-sm"
>
<q-tooltip>{{ store.getters.formatDate(summary.last_seen) }}</q-tooltip>
</q-icon>
<q-icon
v-else
name="fas fa-signal"
size="1.2em"
:color="dash_positive_color"
class="q-mr-sm"
>
<q-tooltip>{{ store.getters.formatDate(summary.last_seen) }}</q-tooltip>
</q-icon>
<b>{{ summary.hostname }}</b>
<span v-if="summary.maintenance_mode">
&bull; <q-badge color="green"> Maintenance Mode </q-badge>
@@ -60,7 +87,7 @@
</q-item-section>
<q-item-section>{{ summary.make_model }}</q-item-section>
</q-item>
<q-item v-for="(cpu, i) in summary.cpu_model" :key="cpu + i">
<q-item>
<q-item-section avatar>
<q-icon name="fas fa-microchip" />
</q-item-section>
@@ -87,6 +114,13 @@
</q-item-section>
<q-item-section>{{ summary.graphics }}</q-item-section>
</q-item>
<!-- serial -->
<q-item v-if="serial_number">
<q-item-section avatar>
<q-icon name="fa-solid fa-barcode" />
</q-item-section>
<q-item-section>{{ serial_number }}</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<q-icon name="fas fa-globe-americas" />
@@ -110,7 +144,7 @@
size="lg"
square
icon="done"
color="green"
:color="dash_positive_color"
text-color="white"
/>
<small>{{ summary.checks.passing }} checks passing</small>
@@ -120,7 +154,7 @@
size="lg"
square
icon="cancel"
color="red"
:color="dash_negative_color"
text-color="white"
/>
<small>{{ summary.checks.failing }} checks failing</small>
@@ -130,7 +164,7 @@
size="lg"
square
icon="warning"
color="warning"
:color="dash_warning_color"
text-color="white"
/>
<small>{{ summary.checks.warning }} checks warning</small>
@@ -140,7 +174,7 @@
size="lg"
square
icon="info"
color="info"
:color="dash_info_color"
text-color="white"
/>
<small>{{ summary.checks.info }} checks info</small>
@@ -158,6 +192,20 @@
>
</div>
<div v-else>No checks</div>
<span
v-if="customFields.length > 0"
class="text-subtitle2 text-bold block q-mt-xl"
>Custom Fields</span
>
<q-list dense>
<q-item v-for="(field, i) in customFields" :key="field + i">
<q-item-section thumbnail>
<q-icon name="fas fa-user" size="xs" />
</q-item-section>
<q-item-section>{{ field.name }}: {{ field.value }}</q-item-section>
</q-item>
</q-list>
</div>
<div class="col-1"></div>
<!-- right -->
@@ -193,6 +241,7 @@ import {
openAgentWindow,
} from "@/api/agents";
import { notifySuccess } from "@/utils/notify";
import { fetchCustomFields } from "@/api/core";
// ui imports
import AgentActionMenu from "@/components/agents/AgentActionMenu.vue";
@@ -207,18 +256,38 @@ export default {
const store = useStore();
const selectedAgent = computed(() => store.state.selectedRow);
const refreshSummaryTab = computed(() => store.state.refreshSummaryTab);
const dash_info_color = computed(() => store.state.dash_info_color);
const dash_positive_color = computed(() => store.state.dash_positive_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const dash_warning_color = computed(() => store.state.dash_warning_color);
// summary tab logic
const summary = ref(null);
const customFieldsDefinitions = ref(null);
const loading = ref(false);
const serial_number = computed(() => {
if (summary.value.plat === "windows") {
return summary.value.wmi_detail.bios?.[0]?.[0]?.SerialNumber;
} else {
return summary.value.wmi_detail.serialnumber;
}
});
const cpu = computed(() => {
if (summary.value.cpu_model?.length > 1) {
return `${summary.value.cpu_model.length}x ${summary.value.cpu_model[0]}`;
}
return summary.value.cpu_model[0];
});
function diskBarColor(percent) {
if (percent < 80) {
return "positive";
} else if (percent > 80 && percent < 95) {
return "warning";
return dash_positive_color.value;
} else if (percent >= 80 && percent < 95) {
return dash_warning_color.value;
} else {
return "negative";
return dash_negative_color.value;
}
}
@@ -236,9 +305,37 @@ export default {
return ret;
});
const customFields = computed(() => {
if (!summary.value.custom_fields) {
return [];
}
if (!customFieldsDefinitions.value) {
return [];
}
const ret = [];
for (const customField of summary.value.custom_fields) {
const definition = customFieldsDefinitions.value.find(
(def) => def.id === customField.field,
);
if (
definition &&
!definition.hide_in_summary &&
customField.value?.length > 0
) {
ret.push({
name: definition.name,
value: customField.value,
});
}
}
return ret;
});
async function getSummary() {
loading.value = true;
summary.value = await fetchAgent(selectedAgent.value);
customFieldsDefinitions.value = await fetchCustomFields();
store.commit("setRefreshSummaryTab", false);
store.commit("setAgentPlatform", summary.value.plat);
loading.value = false;
@@ -246,6 +343,7 @@ export default {
async function refreshSummary() {
loading.value = true;
summary.value = await fetchAgent(selectedAgent.value);
try {
const result = await refreshAgentWMI(selectedAgent.value);
await getSummary();
@@ -277,9 +375,17 @@ export default {
return {
// reactive data
summary,
customFields,
loading,
selectedAgent,
disks,
dash_info_color,
dash_positive_color,
dash_warning_color,
dash_negative_color,
serial_number,
cpu,
store,
// methods
getSummary,

View File

@@ -128,7 +128,7 @@
<q-icon
v-else-if="props.row.action === 'ignore'"
name="fas fa-check"
color="negative"
:color="dash_negative_color"
>
<q-tooltip>Ignore</q-tooltip>
</q-icon>
@@ -144,7 +144,7 @@
<q-icon
v-if="props.row.installed"
name="fas fa-check"
color="positive"
:color="dash_positive_color"
>
<q-tooltip>Installed</q-tooltip>
</q-icon>
@@ -158,11 +158,15 @@
<q-icon
v-else-if="props.row.action == 'ignore'"
name="fas fa-ban"
color="negative"
:color="dash_negative_color"
>
<q-tooltip>Ignored</q-tooltip>
</q-icon>
<q-icon v-else name="fas fa-exclamation" color="warning">
<q-icon
v-else
name="fas fa-exclamation"
:color="dash_warning_color"
>
<q-tooltip>Missing</q-tooltip>
</q-icon>
</q-td>
@@ -251,6 +255,9 @@ export default {
const tabHeight = computed(() => store.state.tabHeight);
const agentPlatform = computed(() => store.state.agentPlatform);
const formatDate = computed(() => store.getters.formatDate);
const dash_positive_color = computed(() => store.state.dash_positive_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const dash_warning_color = computed(() => store.state.dash_warning_color);
// setup quasar
const $q = useQuasar();
@@ -348,6 +355,9 @@ export default {
selectedAgent,
tabHeight,
agentPlatform,
dash_positive_color,
dash_warning_color,
dash_negative_color,
// non-reactive data
columns,

View File

@@ -8,16 +8,16 @@
v
}}</q-badge>
<q-btn
v-if="!!v"
size="sm"
class="q-ml-xs"
flat
round
icon="content_copy"
@click="copyValueToClip(v)"
>
<q-tooltip>Copy to Clipboard</q-tooltip>
</q-btn>
v-if="!!v"
size="sm"
class="q-ml-xs"
flat
round
icon="content_copy"
@click="copyValueToClip(v)"
>
<q-tooltip>Copy to Clipboard</q-tooltip>
</q-btn>
</div>
</div>
<q-separator v-if="info.length > 1" />
@@ -42,10 +42,9 @@ export default {
const tabHeight = computed(() => store.state.tabHeight);
function copyValueToClip(val) {
copyToClipboard(val)
.then(() => {
notifySuccess("Copied to clipboard");
})
copyToClipboard(val).then(() => {
notifySuccess("Copied to clipboard");
});
}
return {

View File

@@ -17,70 +17,85 @@
:loading="loading"
>
<template v-slot:top>
<q-btn
v-if="isPolling"
dense
flat
push
@click="stopPoll"
icon="stop"
label="Stop Live Refresh"
/>
<q-btn
v-else
dense
flat
push
@click="startPoll"
icon="play_arrow"
label="Resume Live Refresh"
/>
<q-space />
<div class="q-pa-md q-gutter-sm">
<div class="q-gutter-md flex flex-center items-center">
<q-btn
:disable="pollInterval === 1"
v-if="isPolling"
dense
@click="pollIntervalChanged('subtract')"
flat
push
icon="remove"
size="sm"
color="grey"
@click="stopPoll"
icon="stop"
label="Stop Live Refresh"
/>
<q-btn
v-else
dense
flat
push
icon="add"
size="sm"
color="grey"
@click="pollIntervalChanged('add')"
@click="startPoll"
icon="play_arrow"
label="Resume Live Refresh"
/>
</div>
<div class="text-overline">
<q-badge
align="middle"
size="sm"
class="text-h6"
color="blue"
:label="pollInterval"
/>
Refresh interval (seconds)
</div>
<q-space />
<q-input v-model="filter" outlined label="Search" dense clearable>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
<!-- file download doesn't work so disabling -->
<export-table-btn
v-show="false"
class="q-ml-sm"
:columns="columns"
:data="processes"
/>
<div class="flex flex-center q-ml-md">
<q-icon name="fas fa-microchip" class="q-mr-xs" />
<div class="text-caption q-mr-sm">
CPU Usage:
<span class="text-body1 text-weight-medium"
>{{ totalCpuUsage }}%</span
>
</div>
<q-icon name="fas fa-memory" class="q-mr-xs" />
<div class="text-caption">
RAM Usage:
<span class="text-body1 text-weight-medium"
>{{ bytes2Human(totalRamUsage) }}/{{ total_ram }} GB</span
>
</div>
</div>
<q-space />
<div class="q-pa-md q-gutter-sm">
<q-btn
:disable="pollInterval === 1"
dense
@click="pollIntervalChanged('subtract')"
push
icon="remove"
size="sm"
color="grey"
/>
<q-btn
dense
push
icon="add"
size="sm"
color="grey"
@click="pollIntervalChanged('add')"
/>
</div>
<div class="text-overline">
<q-badge
align="middle"
size="sm"
class="text-h6"
color="blue"
:label="pollInterval"
/>
Refresh interval (seconds)
</div>
<q-space />
<q-input v-model="filter" outlined label="Search" dense clearable>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
</template>
<template v-slot:body="props">
<q-tr :props="props" class="cursor-pointer">
@@ -121,9 +136,6 @@ import {
import { bytes2Human } from "@/utils/format";
import { notifySuccess } from "@/utils/notify";
// ui imports
import ExportTableBtn from "@/components/ui/ExportTableBtn.vue";
const columns = [
{
name: "name",
@@ -164,7 +176,6 @@ const columns = [
];
export default {
components: { ExportTableBtn },
name: "ProcessManager",
props: {
agent_id: !String,
@@ -175,52 +186,71 @@ export default {
const poll = ref(null);
const isPolling = computed(() => !!poll.value);
async function startPoll() {
await getProcesses();
if (processes.value.length > 0) {
refreshProcesses();
}
function startPoll() {
stopPoll();
getProcesses();
poll.value = setInterval(() => {
getProcesses();
}, pollInterval.value * 1000);
}
function stopPoll() {
clearInterval(poll.value);
poll.value = null;
if (poll.value) {
clearInterval(poll.value);
poll.value = null;
}
}
function pollIntervalChanged(action) {
if (action === "subtract" && pollInterval.value <= 1) {
stopPoll();
startPoll();
return;
}
if (action === "add") {
pollInterval.value++;
} else {
} else if (action === "subtract" && pollInterval.value > 1) {
pollInterval.value--;
}
stopPoll();
startPoll();
if (isPolling.value) {
startPoll();
}
}
// process manager logic
const processes = ref([]);
const filter = ref("");
const memory = ref(null);
const total_ram = ref(0);
const loading = ref(false);
const totalCpuUsage = computed(() => {
if (!Array.isArray(processes.value) || processes.value.length === 0) {
return "0.00";
}
const total = processes.value.reduce((acc, proc) => {
const cpuPercent = parseFloat(proc.cpu_percent);
if (isNaN(cpuPercent)) {
return acc;
}
return acc + cpuPercent;
}, 0);
return total.toFixed(2);
});
const totalRamUsage = computed(() => {
return processes.value.reduce((acc, proc) => acc + proc.membytes, 0);
});
async function getProcesses() {
loading.value = true;
processes.value = await fetchAgentProcesses(props.agent_id);
try {
processes.value = await fetchAgentProcesses(props.agent_id);
} catch (error) {
console.error(error);
}
loading.value = false;
}
function refreshProcesses() {
poll.value = setInterval(() => {
getProcesses(props.agent_id);
}, pollInterval.value * 1000);
}
async function killProcess(pid) {
loading.value = true;
let result = "";
@@ -235,11 +265,8 @@ export default {
// lifecycle hooks
onMounted(async () => {
memory.value = await fetchAgent(props.agent_id).total_ram;
await getProcesses();
if (processes.value.length > 0) {
refreshProcesses();
}
total_ram.value = (await fetchAgent(props.agent_id)).total_ram;
startPoll();
});
onBeforeUnmount(() => clearInterval(poll.value));
@@ -248,10 +275,12 @@ export default {
// reactive data
processes,
filter,
memory,
total_ram,
isPolling,
pollInterval,
loading,
totalCpuUsage,
totalRamUsage,
// non-reactive data
columns,

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

@@ -217,6 +217,7 @@
</template>
<script>
import { mapState } from "vuex";
import mixins from "@/mixins/mixins";
import PolicyStatus from "@/components/automation/modals/PolicyStatus.vue";
import DiskSpaceCheck from "@/components/checks/DiskSpaceCheck.vue";
@@ -268,6 +269,9 @@ export default {
if (newValue !== oldValue) this.getChecks();
},
},
computed: {
...mapState(["dash_positive_color", "dash_warning_color"]),
},
methods: {
getChecks() {
this.$q.loading.show();
@@ -295,7 +299,9 @@ export default {
data.check_alert = true;
const act = !action ? "enabled" : "disabled";
const color = !action ? "positive" : "warning";
const color = !action
? this.dash_positive_color
: this.dash_warning_color;
this.$axios
.put(`/checks/${id}/`, data)
.then(() => {

View File

@@ -1,7 +1,16 @@
<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>
<q-btn
ref="refresh"
@click="refresh"
class="q-mr-sm"
dense
flat
push
icon="refresh"
/>
{{ title.slice(0, 27) }}
<q-space />
<q-btn dense flat icon="close" v-close-popup>
@@ -41,7 +50,7 @@
<q-td v-if="props.row.status === 'passing'">
<q-icon
style="font-size: 1.3rem"
color="positive"
:color="dash_positive_color"
name="check_circle"
>
<q-tooltip>Passing</q-tooltip>
@@ -51,7 +60,7 @@
<q-icon
v-if="props.row.alert_severity === 'info'"
style="font-size: 1.3rem"
color="info"
:color="dash_info_color"
name="info"
>
<q-tooltip>Informational</q-tooltip>
@@ -59,7 +68,7 @@
<q-icon
v-else-if="props.row.alert_severity === 'warning'"
style="font-size: 1.3rem"
color="warning"
:color="dash_warning_color"
name="warning"
>
<q-tooltip>Warning</q-tooltip>
@@ -67,7 +76,7 @@
<q-icon
v-else
style="font-size: 1.3rem"
color="negative"
:color="dash_negative_color"
name="error"
>
<q-tooltip>Error</q-tooltip>
@@ -148,7 +157,7 @@
<script>
import { computed } from "vue";
import { useStore } from "vuex";
import { useStore, mapState } from "vuex";
import ScriptOutput from "@/components/checks/ScriptOutput.vue";
import EventLogCheckOutput from "@/components/checks/EventLogCheckOutput.vue";
@@ -220,6 +229,12 @@ export default {
};
},
computed: {
...mapState([
"dash_info_color",
"dash_positive_color",
"dash_negative_color",
"dash_warning_color",
]),
title() {
return !!this.item.readable_desc
? this.item.readable_desc + " Status"
@@ -275,6 +290,13 @@ export default {
},
});
},
refresh() {
if (this.type === "task") {
this.getTaskData();
} else {
this.getCheckData();
}
},
show() {
this.$refs.dialog.show();
},

View File

@@ -8,7 +8,7 @@
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section v-if="scriptOptions.length === 0">
<q-card-section v-if="filterByPlatformOptions.length === 0">
<p>You need to upload a script first</p>
<p>Settings -> Script Manager</p>
</q-card-section>
@@ -19,7 +19,7 @@
:rules="[(val) => !!val || '*Required']"
outlined
v-model="state.script"
:options="scriptOptions"
:options="filterByPlatformOptions"
label="Select script"
mapOptions
:disable="!!check"
@@ -128,7 +128,7 @@ import { useDialogPluginComponent } from "quasar";
import { useCheckModal } from "@/composables/checks";
import { useScriptDropdown } from "@/composables/scripts";
import { validateRetcode } from "@/utils/validation";
import { envVarsLabel } from "@/constants/constants"
import { envVarsLabel } from "@/constants/constants";
// ui imports
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
@@ -140,16 +140,24 @@ export default {
props: {
check: Object,
parent: Object, // {agent: agent.agent_id} or {policy: policy.id}
plat: String,
},
setup(props) {
// setup quasar dialog
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// setup script dropdown
const { script, scriptOptions, defaultTimeout, defaultArgs, defaultEnvVars } =
useScriptDropdown(props.check ? props.check.script : undefined, {
onMount: true,
});
const {
script,
filterByPlatformOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
} = useScriptDropdown({
script: props.check ? props.check.script : undefined,
plat: props.plat,
onMount: true,
});
// check logic
const { state, loading, submit, failOptions, severityOptions } =
@@ -176,7 +184,7 @@ export default {
// non-reactive data
failOptions,
scriptOptions,
filterByPlatformOptions,
severityOptions,
envVarsLabel,

View File

@@ -20,12 +20,18 @@
</div>
<br />
<div v-if="scriptInfo.stdout">
Standard Output
<script-output-copy-clip
label="Standard Output"
:data="scriptInfo.stdout"
/>
<q-separator />
<pre>{{ scriptInfo.stdout }}</pre>
</div>
<div v-if="scriptInfo.stderr">
Standard Error
<script-output-copy-clip
label="Standard Error"
:data="scriptInfo.stderr"
/>
<q-separator />
<pre>{{ scriptInfo.stderr }}</pre>
</div>
@@ -43,8 +49,13 @@ import { computed } from "vue";
import { useStore } from "vuex";
import { useDialogPluginComponent } from "quasar";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
export default {
name: "ScriptOutput",
components: {
ScriptOutputCopyClip,
},
emits: [...useDialogPluginComponent.emits],
props: { scriptInfo: !Object },
setup() {

View File

@@ -304,6 +304,9 @@ export default {
// setup vuex
const store = useStore();
const formatDate = computed(() => store.getters.formatDate);
const dash_positive_color = computed(() => store.state.dash_positive_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const dash_warning_color = computed(() => store.state.dash_warning_color);
// setup dropdowns
const { clientOptions, getClientOptions } = useClientDropdown();
@@ -381,12 +384,18 @@ export default {
}
function formatActionColor(action) {
if (action === "add") return "success";
else if (action === "agent_install") return "success";
else if (action === "modify") return "warning";
else if (action === "delete") return "negative";
else if (action === "failed_login") return "negative";
else return "primary";
switch (action.toLowerCase()) {
case "modify":
return dash_warning_color.value;
case "add":
case "agent_install":
return dash_positive_color.value;
case "delete":
case "failed_login":
return dash_negative_color.value;
default:
return "primary";
}
}
// watchers

View File

@@ -68,25 +68,25 @@
/>
<q-radio
v-model="logLevelFilter"
color="cyan"
:color="dash_info_color"
val="info"
label="Info"
/>
<q-radio
v-model="logLevelFilter"
color="red"
:color="dash_negative_color"
val="critical"
label="Critical"
/>
<q-radio
v-model="logLevelFilter"
color="red"
:color="dash_negative_color"
val="error"
label="Error"
/>
<q-radio
v-model="logLevelFilter"
color="yellow"
:color="dash_warning_color"
val="warning"
label="Warning"
/>
@@ -109,7 +109,7 @@
<template v-slot:top-row>
<q-tr v-if="Array.isArray(debugLog) && debugLog.length === 1000">
<q-td colspan="100%">
<q-icon name="warning" color="warning" />
<q-icon name="warning" :color="dash_warning_color" />
Results are limited to 1000 rows.
</q-td>
</q-tr>
@@ -203,6 +203,10 @@ export default {
const store = useStore();
const formatDate = computed(() => store.getters.formatDate);
const dash_info_color = computed(() => store.state.dash_info_color);
const dash_positive_color = computed(() => store.state.dash_positive_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const dash_warning_color = computed(() => store.state.dash_warning_color);
// setup dropdowns
const { agentOptions, getAgentOptions } = useAgentDropdown();
@@ -261,6 +265,10 @@ export default {
agentOptions,
loading,
filter,
dash_info_color,
dash_positive_color,
dash_warning_color,
dash_negative_color,
// non-reactive data
columns,

View File

@@ -116,7 +116,8 @@
</template>
<script>
import { mapState } from "vuex";
import { mapState as piniaMapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import mixins from "@/mixins/mixins";
export default {
@@ -145,7 +146,7 @@ export default {
title() {
return this.user ? "Edit User" : "Add User";
},
...mapState({
...piniaMapState(useAuthStore, {
logged_in_user: (state) => state.username,
}),
},

View File

@@ -89,7 +89,8 @@
<p class="text-italic">
Note: the auth token above will be valid for {{ info.expires }} hours.
</p>
<q-btn v-if="info.plat === 'windows'"
<q-btn
v-if="info.plat === 'windows'"
type="a"
:href="info.data.url"
color="primary"

View File

@@ -83,12 +83,29 @@
<tactical-dropdown
:rules="[(val) => !!val || '*Required']"
v-model="state.script"
:options="filteredScriptOptions"
:options="filterByPlatformOptions"
label="Select Script"
outlined
mapOptions
filterable
/>
>
<template v-slot:after>
<q-btn
size="sm"
round
dense
flat
icon="info"
@click="openScriptURL"
>
<q-tooltip
v-if="syntax"
class="bg-white text-primary text-body1"
v-html="formatScriptSyntax(syntax)"
/>
</q-btn>
</template>
</tactical-dropdown>
</q-card-section>
<q-card-section v-if="mode === 'script'" class="q-pt-none">
<tactical-dropdown
@@ -153,6 +170,39 @@
</q-checkbox>
</q-card-section>
<q-card-section v-if="mode === 'script'" class="q-pt-none">
<div class="q-gutter-sm">
<q-checkbox
label="Save results to Custom Field"
v-model="collector"
@update:model-value="
state.custom_field = null;
state.collector_all_output = false;
"
/>
<q-checkbox
v-model="state.save_to_agent_note"
label="Save results to Agent Note"
/>
</div>
</q-card-section>
<q-card-section v-if="mode === 'script' && collector">
<tactical-dropdown
:rules="[(val) => !!val || '*Required']"
outlined
v-model="state.custom_field"
:options="customFieldOptions"
label="Select custom field"
mapOptions
filterable
/>
<q-checkbox
v-model="state.collector_all_output"
label="Save all output"
/>
</q-card-section>
<q-card-section v-if="mode === 'script' || mode === 'command'">
<q-input
v-model.number="state.timeout"
@@ -210,16 +260,23 @@
<script>
// composition imports
import { ref, computed, watch, onMounted } from "vue";
import { useStore } from "vuex";
import { useDialogPluginComponent } from "quasar";
import {
ref,
reactive,
computed,
watch,
onMounted,
defineComponent,
} from "vue";
import { useDialogPluginComponent, openURL } from "quasar";
import { useScriptDropdown } from "@/composables/scripts";
import { useAgentDropdown } from "@/composables/agents";
import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
import { useCustomFieldDropdown } from "@/composables/core";
import { runBulkAction } from "@/api/agents";
import { notifySuccess } from "@/utils/notify";
import { formatScriptSyntax } from "@/utils/format";
import { cmdPlaceholder } from "@/composables/agents";
import { removeExtraOptionCategories } from "@/utils/format";
import { envVarsLabel, runAsUserToolTip } from "@/constants/constants";
// ui imports
@@ -251,7 +308,7 @@ const patchModeOptions = [
{ label: "Install", value: "install" },
];
export default {
export default defineComponent({
name: "BulkAction",
components: { TacticalDropdown },
emits: [...useDialogPluginComponent.emits],
@@ -259,14 +316,8 @@ export default {
mode: !String,
},
setup(props) {
// setup vuex store
const store = useStore();
const showCommunityScripts = computed(
() => store.state.showCommunityScripts
);
const shellOptions = computed(() => {
if (state.value.osType === "windows") {
if (state.osType === "windows") {
return [
{ label: "CMD", value: "cmd" },
{ label: "Powershell", value: "powershell" },
@@ -293,18 +344,26 @@ export default {
// dropdown setup
const {
script,
scriptOptions,
plat,
filterByPlatformOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
syntax,
link,
getScriptOptions,
} = useScriptDropdown();
const { agents, agentOptions, getAgentOptions } = useAgentDropdown();
const { site, siteOptions, getSiteOptions } = useSiteDropdown();
const { client, clientOptions, getClientOptions } = useClientDropdown();
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
function openScriptURL() {
link.value ? openURL(link.value) : null;
}
// bulk action logic
const state = ref({
const state = reactive({
mode: props.mode,
target: "client",
monType: "all",
@@ -312,6 +371,9 @@ export default {
cmd: "",
shell: "cmd",
custom_shell: null,
custom_field: null,
collector_all_output: false,
save_to_agent_note: false,
patchMode: "scan",
offlineAgents: false,
client,
@@ -324,35 +386,42 @@ export default {
run_as_user: false,
});
const loading = ref(false);
const collector = ref(false);
watch(
() => state.value.target,
() => state.target,
() => {
client.value = null;
site.value = null;
agents.value = [];
}
},
);
plat.value = state.osType;
watch(
() => state.value.osType,
() => state.osType,
(newValue) => {
state.value.custom_shell = null;
state.value.run_as_user = false;
state.custom_shell = null;
state.run_as_user = false;
if (newValue === "windows") {
state.value.shell = "cmd";
state.shell = "cmd";
} else {
state.value.shell = "/bin/bash";
state.shell = "/bin/bash";
}
}
// set plat to filter script options
if (newValue === "all") plat.value = undefined;
else plat.value = newValue;
},
);
async function submit() {
loading.value = true;
try {
const data = await runBulkAction(state.value);
const data = await runBulkAction(state);
notifySuccess(data);
onDialogHide();
} catch (e) {}
@@ -362,9 +431,7 @@ export default {
const supportsRunAsUser = () => {
const modes = ["script", "command"];
return (
state.value.osType === "windows" && modes.includes(state.value.mode)
);
return state.osType === "windows" && modes.includes(state.mode);
};
// set modal title and caption
@@ -372,25 +439,10 @@ export default {
return props.mode === "command"
? "Run Bulk Command"
: props.mode === "script"
? "Run Bulk Script"
: props.mode === "patch"
? "Bulk Patch Management"
: "";
});
const filteredScriptOptions = computed(() => {
if (props.mode !== "script") return [];
if (state.value.osType === "all") return scriptOptions.value;
return removeExtraOptionCategories(
scriptOptions.value.filter(
(script) =>
script.category ||
!script.supported_platforms ||
script.supported_platforms.length === 0 ||
script.supported_platforms.includes(state.value.osType)
)
);
? "Run Bulk Script"
: props.mode === "patch"
? "Bulk Patch Management"
: "";
});
// component lifecycle hooks
@@ -398,7 +450,7 @@ export default {
getAgentOptions();
getSiteOptions();
getClientOptions();
if (props.mode === "script") getScriptOptions(showCommunityScripts.value);
if (props.mode === "script") getScriptOptions();
});
return {
@@ -406,8 +458,10 @@ export default {
state,
agentOptions,
clientOptions,
collector,
customFieldOptions,
siteOptions,
filteredScriptOptions,
filterByPlatformOptions,
loading,
shellOptions,
filteredOsTypeOptions,
@@ -419,6 +473,7 @@ export default {
patchModeOptions,
runAsUserToolTip,
envVarsLabel,
syntax,
//computed
modalTitle,
@@ -427,11 +482,13 @@ export default {
submit,
cmdPlaceholder,
supportsRunAsUser,
openScriptURL,
formatScriptSyntax,
// quasar dialog plugin
dialogRef,
onDialogHide,
};
},
};
});
</script>

View File

@@ -94,7 +94,7 @@
class="q-pr-sm"
name="fas fa-signal"
size="1.2em"
color="warning"
:color="dash_warning_color"
/>
Mark an agent as
<span class="text-weight-bold">offline</span> if it has
@@ -120,7 +120,7 @@
class="q-pr-sm"
name="fas fa-signal"
size="1.2em"
color="negative"
:color="dash_negative_color"
/>
Mark an agent as
<span class="text-weight-bold">overdue</span> if it has
@@ -373,6 +373,7 @@
</template>
<script>
import { mapState } from "vuex";
import { useDialogPluginComponent } from "quasar";
import mixins from "@/mixins/mixins";
import PatchPolicyForm from "@/components/modals/agents/PatchPolicyForm.vue";
@@ -549,6 +550,9 @@ export default {
return result.trimEnd(",");
},
},
computed: {
...mapState(["dash_warning_color", "dash_negative_color"]),
},
mounted() {
// Get custom fields
this.getCustomFields("agent").then((r) => {

View File

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

View File

@@ -39,9 +39,9 @@
<q-form @submit.prevent="sendScript">
<q-card-section>
<tactical-dropdown
:rules="[(val) => !!val || '*Required']"
:rules="[(val: number) => !!val || '*Required']"
v-model="state.script"
:options="filteredScriptOptions"
:options="filterByPlatformOptions"
label="Select script"
outlined
mapOptions
@@ -89,7 +89,7 @@
new-value-mode="add"
/>
</q-card-section>
<q-card-section>
<q-card-section v-if="!state.run_on_server">
<q-option-group
v-model="state.output"
:options="outputOptions"
@@ -130,7 +130,7 @@
</q-card-section>
<q-card-section v-if="state.output === 'collector'">
<tactical-dropdown
:rules="[(val) => !!val || '*Required']"
:rules="[(val: number) => !!val || '*Required']"
outlined
v-model="state.custom_field"
:options="customFieldOptions"
@@ -140,10 +140,30 @@
/>
<q-checkbox v-model="state.save_all_output" label="Save all output" />
</q-card-section>
<q-card-section v-if="agent.plat === 'windows'">
<q-checkbox v-model="state.run_as_user" label="Run As User">
<q-card-section>
<q-checkbox
v-if="agent.plat === 'windows' && !state.run_on_server"
v-model="state.run_as_user"
label="Run As User"
>
<q-tooltip>{{ runAsUserToolTip }}</q-tooltip>
</q-checkbox>
<q-checkbox
v-if="!hosted"
:disable="!server_scripts_enabled"
v-model="state.run_on_server"
label="Run On Server"
@update:model-value="ret = null"
>
<q-tooltip v-if="!server_scripts_enabled"
>Enable server side scripts globally to activate this
feature.</q-tooltip
>
<q-tooltip v-else
>Run the script on the Tactical RMM server in the context of this
agent.</q-tooltip
>
</q-checkbox>
</q-card-section>
<q-card-section>
<q-input
@@ -175,29 +195,70 @@
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
style="max-height: 50vh"
>
<pre>{{ ret }}</pre>
<script-output-copy-clip
v-if="!state.run_on_server"
label="Output"
:data="ret"
/>
<q-separator />
<pre v-if="!state.run_on_server">{{ ret }}</pre>
<q-card-section v-if="state.run_on_server" class="scroll">
<div>
Run Time:
<code>{{ ret.execution_time }} seconds</code>
<br />Return Code:
<code>{{ ret.retcode }}</code>
<br />
</div>
<br />
<div v-if="ret.stdout">
<script-output-copy-clip
label="Standard Output"
:data="ret.stdout"
/>
<q-separator />
<pre>{{ ret.stdout }}</pre>
</div>
<div v-if="ret.stderr">
<script-output-copy-clip
label="Standard Error"
:data="ret.stderr"
/>
<q-separator />
<pre>{{ ret.stderr }}</pre>
</div>
</q-card-section>
</q-card-section>
</q-form>
</q-card>
</q-dialog>
</template>
<script>
<script setup lang="ts">
// composition imports
import { ref, watch, computed } from "vue";
import { computed, ref, watch } from "vue";
import { useStore } from "vuex";
import { useDialogPluginComponent, openURL } from "quasar";
import { useScriptDropdown } from "@/composables/scripts";
import { useCustomFieldDropdown } from "@/composables/core";
import { runScript } from "@/api/agents";
import { notifySuccess } from "@/utils/notify";
import { envVarsLabel, runAsUserToolTip } from "@/constants/constants";
import {
formatScriptSyntax,
removeExtraOptionCategories,
} from "@/utils/format";
import { formatScriptSyntax } from "@/utils/format";
//ui imports
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
// types
import type { Agent } from "@/types/agents";
// store
const store = useStore();
const hosted = computed(() => store.state.hosted);
const server_scripts_enabled = computed(
() => store.state.server_scripts_enabled,
);
// static data
const outputOptions = [
@@ -208,103 +269,72 @@ const outputOptions = [
{ label: "Save results to Agent Notes", value: "note" },
];
export default {
name: "RunScript",
emits: [...useDialogPluginComponent.emits],
components: { TacticalDropdown },
props: {
agent: !Object,
script: Number,
},
setup(props) {
// setup quasar dialog plugin
const { dialogRef, onDialogHide } = useDialogPluginComponent();
// emits
defineEmits([...useDialogPluginComponent.emits]);
// setup dropdowns
const { script, scriptOptions, defaultTimeout, defaultArgs, defaultEnvVars, syntax, link } =
useScriptDropdown(props.script, {
onMount: true,
filterByPlatform: props.agent.plat,
});
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
// props
const props = defineProps<{
agent: Agent;
script?: number;
}>();
// main run script functionaity
const state = ref({
output: "wait",
emails: [],
emailMode: "default",
custom_field: null,
save_all_output: false,
script,
args: defaultArgs,
env_vars: defaultEnvVars,
timeout: defaultTimeout,
run_as_user: false,
});
// setup quasar dialog plugin
const { dialogRef, onDialogHide } = useDialogPluginComponent();
const ret = ref(null);
const loading = ref(false);
const maximized = ref(false);
// setup dropdowns
const {
script,
filterByPlatformOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
syntax,
link,
} = useScriptDropdown({
script: props.script,
plat: props.agent.plat,
onMount: true,
});
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
async function sendScript() {
ret.value = null;
loading.value = true;
// main run script functionaity
const state = ref({
output: "wait",
emails: [],
emailMode: "default",
custom_field: null,
save_all_output: false,
script,
args: defaultArgs,
env_vars: defaultEnvVars,
timeout: defaultTimeout,
run_as_user: false,
run_on_server: false,
});
ret.value = await runScript(props.agent.agent_id, state.value);
loading.value = false;
if (state.value.output === "forget") {
onDialogHide();
notifySuccess(ret.value);
}
}
const ret = ref(null);
const loading = ref(false);
const maximized = ref(false);
function openScriptURL() {
link.value ? openURL(link.value) : null;
}
async function sendScript() {
ret.value = null;
loading.value = true;
const filteredScriptOptions = computed(() => {
return removeExtraOptionCategories(
scriptOptions.value.filter(
(script) =>
script.category ||
!script.supported_platforms ||
script.supported_platforms.length === 0 ||
script.supported_platforms.includes(props.agent.plat)
)
);
});
ret.value = await runScript(props.agent.agent_id, state.value);
loading.value = false;
if (state.value.output === "forget") {
onDialogHide();
if (ret.value) notifySuccess(ret.value);
}
}
// watchers
watch(
[() => state.value.output, () => state.value.emailMode],
() => (state.value.emails = [])
);
function openScriptURL() {
link.value ? openURL(link.value) : null;
}
return {
// reactive data
state,
loading,
filteredScriptOptions,
link,
syntax,
ret,
maximized,
customFieldOptions,
// non-reactive data
outputOptions,
runAsUserToolTip,
envVarsLabel,
//methods
formatScriptSyntax,
sendScript,
openScriptURL,
// quasar dialog plugin
dialogRef,
onDialogHide,
};
},
};
// watchers
watch(
[() => state.value.output, () => state.value.emailMode],
() => (state.value.emails = []),
);
</script>

View File

@@ -104,6 +104,9 @@
type="submit"
/>
</q-card-actions>
<q-card-section v-if="ret !== null"
><script-output-copy-clip label="Output" :data="ret" /> <q-separator
/></q-card-section>
<q-card-section
v-if="ret !== null"
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
@@ -124,8 +127,13 @@ import { sendAgentCommand } from "@/api/agents";
import { cmdPlaceholder } from "@/composables/agents";
import { runAsUserToolTip } from "@/constants/constants";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
export default {
name: "SendCommand",
components: {
ScriptOutputCopyClip,
},
emits: [...useDialogPluginComponent.emits],
props: {
agent: !Object,

View File

@@ -1,8 +1,8 @@
<template>
<q-dialog ref="dialog" @hide="onHide">
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card style="width: 90vw; max-width: 90vw">
<q-bar>
{{ title }}
{{ alertTemplate ? "Edit Alert Template" : "Add Alert Template" }}
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
@@ -150,50 +150,62 @@
<span style="text-decoration: underline; cursor: help"
>Alert Failure Settings
<q-tooltip>
The selected script will run when an alert is triggered. This
script will run on any online agent.
The selected action will run when an alert is triggered.
</q-tooltip>
</span>
</div>
<q-card-section>
<q-select
class="q-mb-sm"
label="Failure action"
<q-option-group
v-model="template.action_type"
class="q-pb-sm"
:options="actionTypeOptions"
dense
options-dense
inline
/>
<tactical-dropdown
v-if="template.action_type == 'script'"
class="q-mb-sm"
label="Failure script"
outlined
clearable
v-model="template.action"
:options="scriptOptions"
map-options
emit-value
@update:model-value="setScriptDefaults('failure')"
>
<template v-slot:option="scope">
<q-item
v-if="!scope.opt.category"
v-bind="scope.itemProps"
class="q-pl-lg"
>
<q-item-section>
<q-item-label v-html="scope.opt.label"></q-item-label>
</q-item-section>
</q-item>
<q-item-label
v-if="scope.opt.category"
v-bind="scope.itemProps"
header
class="q-pa-sm"
>{{ scope.opt.category }}</q-item-label
>
</template>
</q-select>
mapOptions
filterable
:rules="[(val) => !!val || '*Required']"
/>
<tactical-dropdown
v-else-if="template.action_type == 'server'"
class="q-mb-sm"
label="Failure script"
outlined
clearable
v-model="template.action"
:options="serverScriptOptions"
mapOptions
filterable
/>
<tactical-dropdown
v-else
class="q-mb-sm"
label="Failure Web Hook"
outlined
clearable
v-model="template.action_rest"
:options="restActionOptions"
mapOptions
filterable
/>
<q-select
v-if="template.action_type !== 'rest'"
class="q-mb-sm"
dense
label="Failure action arguments (press Enter after typing each argument)"
label="Failure script arguments (press Enter after typing each argument)"
filled
v-model="template.action_args"
use-input
@@ -205,9 +217,10 @@
/>
<q-select
v-if="template.action_type !== 'rest'"
class="q-mb-sm"
dense
label="Failure action environment vars (press Enter after typing each key=value pair)"
label="Failure script environment vars (press Enter after typing each key=value pair)"
filled
v-model="template.action_env_vars"
use-input
@@ -219,16 +232,15 @@
/>
<q-input
v-if="template.action_type !== 'rest'"
class="q-mb-sm"
label="Failure action timeout (seconds)"
label="Failure script timeout (seconds)"
outlined
type="number"
v-model.number="template.action_timeout"
dense
:rules="[
(val) => !!val || 'Failure action timeout is required',
(val) => val > 0 || 'Timeout must be greater than 0',
(val) => val <= 60 || 'Timeout must be 60 or less',
(val) => !!val || 'Failure script timeout is required',
]"
/>
</q-card-section>
@@ -237,50 +249,61 @@
<span style="text-decoration: underline; cursor: help"
>Alert Resolved Settings
<q-tooltip>
The selected script will run when an alert is resolved. This
script will run on any online agent.
The selected action will run when an alert is resolved.
</q-tooltip>
</span>
</div>
<q-card-section>
<q-select
class="q-mb-sm"
label="Resolved Action"
<q-option-group
v-model="template.resolved_action_type"
class="q-pb-sm"
:options="actionTypeOptions"
dense
options-dense
inline
/>
<tactical-dropdown
v-if="template.resolved_action_type === 'script'"
class="q-mb-sm"
label="Resolved Script"
outlined
clearable
v-model="template.resolved_action"
:options="scriptOptions"
map-options
emit-value
@update:model-value="setScriptDefaults('resolved')"
>
<template v-slot:option="scope">
<q-item
v-if="!scope.opt.category"
v-bind="scope.itemProps"
class="q-pl-lg"
>
<q-item-section>
<q-item-label v-html="scope.opt.label"></q-item-label>
</q-item-section>
</q-item>
<q-item-label
v-if="scope.opt.category"
v-bind="scope.itemProps"
header
class="q-pa-sm"
>{{ scope.opt.category }}</q-item-label
>
</template>
</q-select>
mapOptions
filterable
/>
<tactical-dropdown
v-else-if="template.resolved_action_type === 'server'"
class="q-mb-sm"
label="Resolved Script"
outlined
clearable
v-model="template.resolved_action"
:options="serverScriptOptions"
mapOptions
filterable
/>
<tactical-dropdown
v-else
class="q-mb-sm"
label="Resolved Web Hook"
outlined
clearable
v-model="template.resolved_action_rest"
:options="restActionOptions"
mapOptions
filterable
/>
<q-select
v-if="template.resolved_action_type !== 'rest'"
class="q-mb-sm"
dense
label="Resolved action arguments (press Enter after typing each argument)"
label="Resolved script arguments (press Enter after typing each argument)"
filled
v-model="template.resolved_action_args"
use-input
@@ -292,6 +315,7 @@
/>
<q-select
v-if="template.resolved_action_type !== 'rest'"
class="q-mb-sm"
dense
label="Resolved action environment vars (press Enter after typing each key=value pair)"
@@ -306,16 +330,15 @@
/>
<q-input
v-if="template.resolved_action_type !== 'rest'"
class="q-mb-sm"
label="Resolved action timeout (seconds)"
label="Resolved script timeout (seconds)"
outlined
type="number"
v-model.number="template.resolved_action_timeout"
dense
:rules="[
(val) => !!val || 'Resolved action timeout is required',
(val) => val > 0 || 'Timeout must be greater than 0',
(val) => val <= 60 || 'Timeout must be 60 or less',
(val) => !!val || 'Resolved script timeout is required',
]"
/>
</q-card-section>
@@ -324,7 +347,7 @@
<span style="text-decoration: underline; cursor: help"
>Run actions only on
<q-tooltip>
The selected script will only run on the following types of
The selected action will only run on the following types of
alerts
</q-tooltip>
</span>
@@ -674,7 +697,7 @@
left-label
/>
<q-toggle
v-model="template.check_text_on_resolved"
v-model="template.task_text_on_resolved"
label="Text"
color="green"
left-label
@@ -688,18 +711,23 @@
v-if="step > 1"
flat
color="primary"
@click="$refs.stepper.previous()"
@click="stepper?.previous()"
label="Back"
class="q-mr-xs"
/>
<q-btn
v-if="step < 5"
@click="$refs.stepper.next()"
@click="stepper?.next()"
color="primary"
label="Next"
/>
<q-space />
<q-btn @click="onSubmit" color="primary" label="Submit" />
<q-btn
@click="onSubmit"
color="primary"
label="Submit"
:loading="loading"
/>
</q-stepper-navigation>
</template>
</q-stepper>
@@ -707,195 +735,279 @@
</q-dialog>
</template>
<script>
import mixins from "@/mixins/mixins";
import { mapGetters } from "vuex";
<script setup lang="ts">
import { computed, ref, reactive, watch, nextTick } from "vue";
import { useStore } from "vuex";
import { useQuasar, useDialogPluginComponent, type QStepper } from "quasar";
import { useScriptDropdown } from "@/composables/scripts";
import { useURLActionDropdown } from "@/composables/core";
import { notifyError, notifySuccess } from "@/utils/notify";
import { addAlertTemplate, saveAlertTemplate } from "@/api/alerts";
import { isValidEmail } from "@/utils/validation";
export default {
name: "AlertTemplateForm",
emits: ["hide", "ok", "cancel"],
mixins: [mixins],
props: { alertTemplate: Object },
data() {
return {
step: 1,
template: {
name: "",
is_active: true,
action: null,
action_args: [],
action_env_vars: [],
action_timeout: 15,
resolved_action: null,
resolved_action_args: [],
resolved_action_env_vars: [],
resolved_action_timeout: 15,
email_recipients: [],
email_from: "",
text_recipients: [],
agent_email_on_resolved: false,
agent_text_on_resolved: false,
agent_always_email: null,
agent_always_text: null,
agent_always_alert: null,
agent_periodic_alert_days: 0,
agent_script_actions: true,
check_email_alert_severity: [],
check_text_alert_severity: [],
check_dashboard_alert_severity: [],
check_email_on_resolved: false,
check_text_on_resolved: false,
check_always_email: null,
check_always_text: null,
check_always_alert: null,
check_periodic_alert_days: 0,
check_script_actions: true,
task_email_alert_severity: [],
task_text_alert_severity: [],
task_dashboard_alert_severity: [],
task_email_on_resolved: false,
task_text_on_resolved: false,
task_always_email: null,
task_always_text: null,
task_always_alert: null,
task_periodic_alert_days: 0,
task_script_actions: true,
},
scriptOptions: [],
severityOptions: [
{ label: "Error", value: "error" },
{ label: "Warning", value: "warning" },
{ label: "Informational", value: "info" },
],
thumbStyle: {
right: "2px",
borderRadius: "5px",
backgroundColor: "#027be3",
width: "5px",
opacity: 0.75,
},
};
// components
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
// types
import type { AlertTemplate, AlertSeverity } from "@/types/alerts";
// store
const store = useStore();
const hosted = computed(() => store.state.hosted);
const server_scripts_enabled = computed(
() => store.state.server_scripts_enabled,
);
// props
const props = defineProps<{
alertTemplate?: AlertTemplate;
}>();
// emits
defineEmits([...useDialogPluginComponent.emits]);
// setup quasar plugins
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
const $q = useQuasar();
const step = ref(1);
// setup script dropdowns
const {
script: failureAction,
defaultArgs: failureArgs,
defaultEnvVars: failureEnvVars,
defaultTimeout: failureTimeout,
serverScriptOptions,
scriptOptions,
} = useScriptDropdown({ script: props.alertTemplate?.action, onMount: true });
const {
script: resolvedAction,
defaultArgs: resolvedArgs,
defaultEnvVars: resolvedEnvVars,
defaultTimeout: resolvedTimeout,
} = useScriptDropdown({
script: props.alertTemplate?.resolved_action,
onMount: true,
});
// setup custom field dropdown
const { restActionOptions } = useURLActionDropdown({ onMount: true });
// alert template form logic
const template: AlertTemplate = props.alertTemplate
? reactive(Object.assign({}, { ...props.alertTemplate }))
: reactive({
id: 0,
name: "",
is_active: true,
action_type: "script",
action: failureAction,
action_rest: undefined,
action_args: failureArgs,
action_env_vars: failureEnvVars,
action_timeout: failureTimeout,
resolved_action_type: "script",
resolved_action: resolvedAction,
resolved_action_rest: undefined,
resolved_action_args: resolvedArgs,
resolved_action_env_vars: resolvedEnvVars,
resolved_action_timeout: resolvedTimeout,
email_recipients: [] as string[],
email_from: "",
text_recipients: [] as string[],
agent_email_on_resolved: false,
agent_text_on_resolved: false,
agent_always_email: null,
agent_always_text: null,
agent_always_alert: null,
agent_periodic_alert_days: 0,
agent_script_actions: true,
check_email_alert_severity: [] as AlertSeverity[],
check_text_alert_severity: [] as AlertSeverity[],
check_dashboard_alert_severity: [] as AlertSeverity[],
check_email_on_resolved: false,
check_text_on_resolved: false,
check_always_email: null,
check_always_text: null,
check_always_alert: null,
check_periodic_alert_days: 0,
check_script_actions: true,
task_email_alert_severity: [] as AlertSeverity[],
task_text_alert_severity: [] as AlertSeverity[],
task_dashboard_alert_severity: [] as AlertSeverity[],
task_email_on_resolved: false,
task_text_on_resolved: false,
task_always_email: null,
task_always_text: null,
task_always_alert: null,
task_periodic_alert_days: 0,
task_script_actions: true,
});
// reset selected script if action type is changed
watch(
() => template.action_type,
() => {
template.action_rest = undefined;
template.action = undefined;
template.action_args = [];
template.action_env_vars = [];
template.action_timeout = 30;
},
computed: {
...mapGetters(["showCommunityScripts"]),
title() {
return this.editing ? "Edit Alert Template" : "Add Alert Template";
},
editing() {
return !!this.alertTemplate;
},
);
watch(
() => template.resolved_action_type,
() => {
template.resolved_action_rest = undefined;
template.resolved_action = undefined;
template.resolved_action_args = [];
template.resolved_action_env_vars = [];
template.resolved_action_timeout = 30;
},
methods: {
setScriptDefaults(type) {
if (type === "failure") {
const script = this.scriptOptions.find(
(i) => i.value === this.template.action
);
this.template.action_args = script.args;
this.template.action_env_vars = script.env_vars;
} else if (type === "resolved") {
const script = this.scriptOptions.find(
(i) => i.value === this.template.resolved_action
);
this.template.resolved_action_args = script.args;
this.template.resolved_action_env_vars = script.env_vars;
}
},
toggleAddEmail() {
this.$q
.dialog({
title: "Add email",
prompt: {
model: "",
isValid: (val) => this.isValidEmail(val),
type: "email",
},
cancel: true,
ok: { label: "Add", color: "primary" },
persistent: false,
})
.onOk((data) => {
this.template.email_recipients.push(data);
);
// sync selected script to scriptdropdown
// only add watchers if editting template
if (props.alertTemplate) {
watch(
() => template.action,
(newValue) => {
if (newValue) {
failureAction.value = newValue;
// wait for the script change to happen
nextTick(() => {
template.action_args = failureArgs.value;
template.action_env_vars = failureEnvVars.value;
template.action_timeout = failureTimeout.value;
});
}
},
toggleAddSMSNumber() {
this.$q
.dialog({
title: "Add number",
message:
"Use E.164 format: must have the <b>+</b> symbol and <span class='text-red'>country code</span>, followed by the <span class='text-green'>phone number</span> e.g. <b>+<span class='text-red'>1</span><span class='text-green'>2131231234</span></b>",
prompt: {
model: "",
},
html: true,
cancel: true,
ok: { label: "Add", color: "primary" },
persistent: false,
})
.onOk((data) => {
this.template.text_recipients.push(data);
);
watch(
() => template.resolved_action,
(newValue) => {
if (newValue) {
resolvedAction.value = newValue;
// wait for the script change to happen
nextTick(() => {
template.resolved_action_args = resolvedArgs.value;
template.resolved_action_env_vars = resolvedEnvVars.value;
template.resolved_action_timeout = resolvedTimeout.value;
});
},
removeEmail(email) {
const removed = this.template.email_recipients.filter((k) => k !== email);
this.template.email_recipients = removed;
},
removeSMSNumber(num) {
const removed = this.template.text_recipients.filter((k) => k !== num);
this.template.text_recipients = removed;
},
onSubmit() {
if (!this.template.name) {
this.notifyError("Name needs to be set");
return;
}
this.$q.loading.show();
if (this.editing) {
this.$axios
.put(`alerts/templates/${this.template.id}/`, this.template)
.then(() => {
this.$q.loading.hide();
this.onOk();
this.notifySuccess("Alert Template edited!");
})
.catch(() => {
this.$q.loading.hide();
});
} else {
this.$axios
.post("alerts/templates/", this.template)
.then(() => {
this.$q.loading.hide();
this.onOk();
this.notifySuccess("Alert Template was added!");
})
.catch(() => {
this.$q.loading.hide();
});
}
},
show() {
this.$refs.dialog.show();
},
hide() {
this.$refs.dialog.hide();
},
onHide() {
this.$emit("hide");
},
onOk() {
this.$emit("ok");
this.hide();
},
},
mounted() {
this.getScriptOptions(this.showCommunityScripts).then(
(options) => (this.scriptOptions = Object.freeze(options))
);
}
const severityOptions = [
{ label: "Error", value: "error" },
{ label: "Warning", value: "warning" },
{ label: "Informational", value: "info" },
];
const staticActionTypeOptions = [
{ label: "Send a Web Hook", value: "rest" },
{ label: "Run script on Agent", value: "script" },
{ label: "Run script on TRMM Server", value: "server" },
];
const actionTypeOptions = computed(() => {
// don't show for hosted at all
if (hosted.value) {
return staticActionTypeOptions.filter(
(option) => option.value !== "server",
);
// Copy alertTemplate prop locally
if (this.editing) Object.assign(this.template, this.alertTemplate);
},
};
}
// disable the server script radio button if feature is disabled globally
const modifiedOptions = staticActionTypeOptions.map((option) => {
if (!server_scripts_enabled.value && option.value === "server") {
return { ...option, disable: true };
}
return option;
});
return modifiedOptions;
});
const stepper = ref<QStepper | null>(null);
function toggleAddEmail() {
$q.dialog({
title: "Add email",
prompt: {
model: "",
isValid: (val) => isValidEmail(val),
type: "email",
},
cancel: true,
ok: { label: "Add", color: "primary" },
persistent: false,
}).onOk((data) => {
template.email_recipients.push(data);
});
}
function toggleAddSMSNumber() {
$q.dialog({
title: "Add number",
message:
"Use E.164 format: must have the <b>+</b> symbol and <span class='text-red'>country code</span>, followed by the <span class='text-green'>phone number</span> e.g. <b>+<span class='text-red'>1</span><span class='text-green'>2131231234</span></b>",
prompt: {
model: "",
},
html: true,
cancel: true,
ok: { label: "Add", color: "primary" },
persistent: false,
}).onOk((data: string) => {
template.text_recipients.push(data);
});
}
function removeEmail(email: string) {
const removed = template.email_recipients.filter((k) => k !== email);
template.email_recipients = removed;
}
function removeSMSNumber(num: string) {
const removed = template.text_recipients.filter((k) => k !== num);
template.text_recipients = removed;
}
const loading = ref(false);
async function onSubmit() {
// TODO rework this ghetto form validation
if (!template.name) {
notifyError("Name needs to be set");
return;
}
loading.value = true;
if (props.alertTemplate) {
try {
await saveAlertTemplate(template.id, template);
notifySuccess("Alert Template edited!");
onDialogOK();
} catch {
} finally {
loading.value = false;
}
} else {
try {
await addAlertTemplate(template);
notifySuccess("Alert Template edited!");
onDialogOK();
} catch {
} finally {
loading.value = false;
}
}
}
</script>

View File

@@ -191,24 +191,6 @@
}}</q-badge>
</q-td>
</template>
<template v-slot:body-cell-alert_time="props">
<q-td :props="props">
{{ formatDate(props.value) }}
</q-td>
</template>
<template v-slot:body-cell-resolve_on="props">
<q-td :props="props">
{{ formatDate(props.value) }}
</q-td>
</template>
<template v-slot:body-cell-snoozed_until="props">
<q-td :props="props">
{{ formatDate(props.value) }}
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
@@ -265,6 +247,21 @@ export default {
field: "alert_time",
align: "left",
sortable: true,
format: (a) => this.formatDate(a),
},
{
name: "client",
label: "Client",
field: "client",
align: "left",
sortable: true,
},
{
name: "site",
label: "Site",
field: "site",
align: "left",
sortable: true,
},
{
name: "hostname",
@@ -296,11 +293,12 @@ export default {
sortable: true,
},
{
name: "resolve_on",
name: "resolved_on",
label: "Resolved On",
field: "resolve_on",
field: "resolved_on",
align: "left",
sortable: true,
format: (a) => this.formatDate(a),
},
{
name: "snoozed_until",
@@ -308,6 +306,7 @@ export default {
field: "snoozed_until",
align: "left",
sortable: true,
format: (a) => this.formatDate(a),
},
{ name: "actions", label: "Actions", align: "left" },
],
@@ -328,7 +327,7 @@ export default {
return this.columns.map((column) => {
if (column.name === "snoozed_until") {
if (this.includeSnoozed) return column.name;
} else if (column.name === "resolve_on") {
} else if (column.name === "resolved_on") {
if (this.includeResolved) return column.name;
} else {
return column.name;
@@ -340,7 +339,7 @@ export default {
getClients() {
this.$axios.get("/clients/").then((r) => {
this.clientsOptions = Object.freeze(
r.data.map((client) => ({ label: client.name, value: client.id }))
r.data.map((client) => ({ label: client.name, value: client.id })),
);
});
},

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

@@ -48,6 +48,7 @@
<!-- name -->
<q-td>
{{ props.row.name }}
<q-tooltip :delay="600">ID: {{ props.row.id }}</q-tooltip>
</q-td>
<!-- type -->
<q-td>
@@ -57,6 +58,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 +128,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

@@ -10,8 +10,11 @@
<q-tab name="customfields" label="Custom Fields" />
<q-tab name="keystore" label="Key Store" />
<q-tab name="urlactions" label="URL Actions" />
<q-tab name="webhooks" label="Web Hooks" />
<q-tab name="retention" label="Retention" />
<q-tab name="apikeys" label="API Keys" />
<q-tab name="sso" label="Single Sign-On (SSO)" />
<!-- <q-tab name="openai" label="Open AI" /> -->
</q-tabs>
</template>
<template v-slot:after>
@@ -40,6 +43,51 @@
<q-tooltip> Runs at 35mins past every hour </q-tooltip>
</q-checkbox>
</q-card-section>
<q-card-section v-if="!hosted" class="row">
<q-checkbox
v-model="settings.enable_server_scripts"
label="Enable server side scripts"
>
<q-tooltip
>Allow running scripts on TRMM server for alert
failure/resolve actions</q-tooltip
>
</q-checkbox>
<q-btn
size="sm"
round
dense
flat
icon="warning"
@click="
openURL(
'https://docs.tacticalrmm.com/functions/permissions/#permissions-with-extra-security-implications',
)
"
>
</q-btn>
</q-card-section>
<q-card-section v-if="!hosted" class="row">
<q-checkbox
v-model="settings.enable_server_webterminal"
label="Enable web terminal"
>
<q-tooltip>Enable the web terminal</q-tooltip>
</q-checkbox>
<q-btn
size="sm"
roundenable_server_webterminal
dense
flat
icon="warning"
@click="
openURL(
'https://docs.tacticalrmm.com/functions/permissions/#permissions-with-extra-security-implications',
)
"
>
</q-btn>
</q-card-section>
<q-card-section class="row">
<div class="col-4">Default agent timezone:</div>
<div class="col-2"></div>
@@ -70,7 +118,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',
)
"
>
@@ -124,6 +172,24 @@
class="col-6"
/>
</q-card-section>
<q-card-section class="row">
<div class="col-4 flex items-center">
Receive notifications on:
</div>
<div class="col-2"></div>
<q-checkbox
dense
v-model="settings.notify_on_info_alerts"
class="col-3"
label="Informational Alerts"
/>
<q-checkbox
dense
v-model="settings.notify_on_warning_alerts"
class="col-3"
label="Warning Alerts"
/>
</q-card-section>
<q-card-section class="row">
<div class="col-4">Agent Debug Level:</div>
<div class="col-2"></div>
@@ -215,7 +281,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
@@ -225,6 +291,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>
@@ -378,7 +454,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
@@ -394,7 +470,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
@@ -404,7 +480,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
@@ -414,7 +490,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
@@ -424,29 +500,81 @@
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 Mesh Perms with TRMM:
<q-icon
right
name="ion-information-circle-outline"
size="sm"
class="cursor-pointer"
>
<q-tooltip class="text-caption">
It is recommended to keep this option enabled;
otherwise, all TRMM users will have full permissions in
MeshCentral regardless of their permissions in TRMM.
</q-tooltip>
</q-icon>
</div>
<div class="col-2"></div>
<q-checkbox
dense
v-model="settings.mesh_disable_auto_login"
:model-value="settings.sync_mesh_with_trmm"
@update:model-value="confirmSyncChange"
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>
<!-- custom fields -->
<q-tab-panel name="customfields">
<CustomFields />
</q-tab-panel>
<!-- key store -->
<q-tab-panel name="keystore">
<KeyStoreTable />
</q-tab-panel>
<!-- url actions -->
<q-tab-panel name="urlactions">
<URLActionsTable />
<URLActionsTable type="web" />
</q-tab-panel>
<!-- web hooks -->
<q-tab-panel name="webhooks">
<URLActionsTable type="rest" />
</q-tab-panel>
<!-- retention -->
<q-tab-panel name="retention">
<q-card-section class="row">
<div class="col-4">Check History (days):</div>
@@ -508,6 +636,54 @@
<q-tab-panel name="apikeys">
<APIKeysTable />
</q-tab-panel>
<!-- sso integration -->
<q-tab-panel name="sso">
<SSOProvidersTable />
</q-tab-panel>
<!-- Open AI -->
<!-- <q-tab-panel name="openai">
<div class="text-subtitle2">Open AI</div>
<q-separator />
<q-card-section class="row">
<div class="col-4">API Key:</div>
<div class="col-2"></div>
<q-input
dense
outlined
v-model="settings.open_ai_token"
class="col-6"
/>
</q-card-section>
<q-card-section class="row">
<div class="col-4">Open AI Model:</div>
<div class="col-2"></div>
<q-input
dense
outlined
v-model="settings.open_ai_model"
class="col-6"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="info"
size="sm"
@click="
openURL(
'https://platform.openai.com/docs/models/overview'
)
"
>
<q-tooltip>Click to see available options</q-tooltip>
</q-btn>
</template>
</q-input>
</q-card-section>
</q-tab-panel> -->
</q-tab-panels>
</q-scroll-area>
<q-card-section class="row items-center">
@@ -515,7 +691,8 @@
v-show="
tab !== 'customfields' &&
tab !== 'keystore' &&
tab !== 'urlactions'
tab !== 'urlactions' &&
tab !== 'sso'
"
label="Save"
color="primary"
@@ -552,6 +729,7 @@ import CustomFields from "@/components/modals/coresettings/CustomFields.vue";
import KeyStoreTable from "@/components/modals/coresettings/KeyStoreTable.vue";
import URLActionsTable from "@/components/modals/coresettings/URLActionsTable.vue";
import APIKeysTable from "@/components/core/APIKeysTable.vue";
import SSOProvidersTable from "@/ee/sso/components/SSOProvidersTable.vue";
export default {
name: "EditCoreSettings",
@@ -561,6 +739,7 @@ export default {
KeyStoreTable,
URLActionsTable,
APIKeysTable,
SSOProvidersTable,
},
mixins: [mixins],
data() {
@@ -591,6 +770,18 @@ export default {
],
};
},
computed: {
hosted() {
return this.$store.state.hosted;
},
},
watch: {
tab(newTab, oldTab) {
if (oldTab === "sso") {
this.getCoreSettings();
}
},
},
methods: {
openURL(url) {
openURL(url);
@@ -625,6 +816,19 @@ export default {
}));
});
},
confirmSyncChange(newValue) {
this.$q
.dialog({
title: "Are you sure?",
message:
"This operation may take several minutes to complete in the background and can be very CPU/disk intensive, depending on your hardware and number of agents. Please allow time for the sync to fully complete.",
ok: { label: "Yes", color: "primary" },
cancel: { label: "No", color: "negative" },
})
.onOk(() => {
this.settings.sync_mesh_with_trmm = newValue;
});
},
showResetPatchPolicy() {
this.$q.dialog({
component: ResetPatchPolicy,
@@ -667,13 +871,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;
},
@@ -714,6 +918,7 @@ export default {
});
} else {
this.$emit("close");
this.$store.dispatch("getDashInfo", false);
this.notifySuccess("Settings were edited!");
}
})

View File

@@ -27,8 +27,16 @@
outlined
dense
v-model="localKey.value"
:type="isPwd ? 'password' : 'text'"
:rules="[(val) => !!val || '*Required']"
/>
><template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
</q-card-section>
<q-card-actions align="right">
@@ -50,6 +58,7 @@ export default {
props: { globalKey: Object },
data() {
return {
isPwd: true,
localKey: {
name: "",
value: "",

View File

@@ -3,6 +3,15 @@
<div class="row">
<div class="text-subtitle2">Global Key Store</div>
<q-space />
<q-btn
size="sm"
color="grey-5"
text-color="black"
class="q-mr-sm"
:label="isPwd ? 'Show values' : 'Hide values'"
:icon="isPwd ? 'visibility_off' : 'visibility'"
@click="isPwd = !isPwd"
/>
<q-btn
size="sm"
color="grey-5"
@@ -61,7 +70,7 @@
</q-td>
<!-- value -->
<q-td>
{{ props.row.value }}
{{ isPwd ? "****" : props.row.value }}
</q-td>
</q-tr>
</template>
@@ -79,6 +88,7 @@ export default {
data() {
return {
keystore: [],
isPwd: true,
pagination: {
rowsPerPage: 0,
sortBy: "name",

View File

@@ -0,0 +1,160 @@
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card class="q-dialog-plugin" style="width: 80vw">
<q-bar>
Testing {{ urlAction.name }}
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section>
<q-option-group
v-model="runAgainst"
:options="runAgainstOptions"
inline
dense
/>
</q-card-section>
<q-card-section v-if="runAgainst === 'agent'">
<tactical-dropdown
v-model="agent"
:options="agentOptions"
label="Agents"
mapOptions
filterable
dense
filled
/>
</q-card-section>
<q-card-section v-else-if="runAgainst === 'site'">
<tactical-dropdown
v-model="site"
:options="siteOptions"
label="Sites"
mapOptions
filterable
dense
filled
/>
</q-card-section>
<q-card-section v-else-if="runAgainst === 'client'">
<tactical-dropdown
v-model="client"
:options="clientOptions"
label="Client"
mapOptions
filterable
dense
filled
/>
</q-card-section>
<q-card-section style="height: 60vh" class="scroll">
<div>
URL:
<code>{{ return_url }}</code>
</div>
<br />
<div>
Body
<q-separator />
<code>{{ return_request }}</code>
</div>
<br />
<div>
Response
<q-separator />
<code>{{ return_result }}</code>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" v-close-popup />
<q-btn
:loading="loading"
flat
label="Run"
color="primary"
@click="submit"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { ref, reactive, computed } from "vue";
import { useDialogPluginComponent } from "quasar";
import { useAgentDropdown } from "@/composables/agents";
import { useSiteDropdown, useClientDropdown } from "@/composables/clients";
import { runTestURLAction } from "@/api/core";
import { URLAction } from "@/types/core/urlactions";
// ui imports
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
// define emits
defineEmits([...useDialogPluginComponent.emits]);
// define props
const props = defineProps<{ urlAction: URLAction }>();
// setup quasar
const { dialogRef, onDialogHide } = useDialogPluginComponent();
// setup dropdowns
const { agent, agentOptions } = useAgentDropdown({ onMount: true });
const { client, clientOptions } = useClientDropdown(true);
const { site, siteOptions } = useSiteDropdown(true);
const runAgainst = ref<"agent" | "site" | "client" | "none">("none");
const runAgainstOptions = [
{ label: "Agent", value: "agent" },
{ label: "Site", value: "site" },
{ label: "Client", value: "client" },
{ label: "None", value: "none" },
];
const loading = ref(false);
const runAgainstID = computed(() => {
if (runAgainst.value === "agent") return agent.value;
else if (runAgainst.value === "site") return site.value;
else if (runAgainst.value === "client") return client.value;
else return 0;
});
const state = reactive({
pattern: props.urlAction.pattern,
rest_body: props.urlAction.rest_body,
rest_headers: props.urlAction.rest_headers,
rest_method: props.urlAction.rest_method,
run_instance_type: runAgainst,
run_instance_id: runAgainstID,
});
const return_url = ref("");
const return_result = ref("");
const return_request = ref("");
async function submit() {
loading.value = true;
try {
const { url, result, body } = await runTestURLAction(state);
return_result.value = result;
return_url.value = url;
return_request.value = body;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
</script>

View File

@@ -1,14 +1,31 @@
<template>
<q-dialog ref="dialog" @hide="onHide">
<q-card class="q-dialog-plugin" style="width: 60vw">
<q-dialog
ref="dialogRef"
@hide="onDialogHide"
@show="loadEditor"
@before-hide="cleanupEditors"
>
<q-card
class="q-dialog-plugin"
:style="`width: ${props.type === 'web' ? 50 : 60}vw; max-width: ${props.type === 'web' ? 60 : 70}vw`"
>
<q-bar>
{{ title }}
{{
props.action
? props.type === "web"
? "Edit URL Action"
: "Edit Web Hook"
: props.type === "web"
? "Add URL Action"
: "Add Web Hook"
}}
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-form @submit="submit">
<div style="max-height: 80vh" class="scroll">
<!-- name -->
<q-card-section>
<q-input
@@ -26,6 +43,8 @@
label="Description"
outlined
dense
type="textarea"
rows="2"
v-model="localAction.desc"
/>
</q-card-section>
@@ -41,89 +60,186 @@
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn flat label="Submit" color="primary" type="submit" />
</q-card-actions>
</q-form>
<q-card-section v-if="type === 'rest'">
<q-select
v-model="localAction.rest_method"
label="Method"
:options="URLActionMethods"
outlined
dense
map-options
emit-value
/>
</q-card-section>
<q-card-section v-show="type === 'rest'">
<q-toolbar>
<q-tabs v-model="tab" dense shrink>
<q-tab
name="body"
label="Request Body"
:ripple="false"
:disable="disableBodyTab"
/>
<q-tab name="headers" label="Request Headers" :ripple="false" />
</q-tabs>
</q-toolbar>
<div ref="editorDiv" :style="{ height: '30vh' }"></div>
</q-card-section>
</div>
<q-card-actions align="right">
<q-btn
v-if="type === 'rest'"
flat
label="Test"
color="primary"
@click="testWebHook"
/>
<q-btn flat label="Cancel" v-close-popup />
<q-btn flat label="Submit" color="primary" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script>
import mixins from "@/mixins/mixins";
<script setup lang="ts">
// composition imports
import { ref, computed, reactive, watch } from "vue";
import { useDialogPluginComponent, useQuasar, extend } from "quasar";
import { editURLAction, saveURLAction } from "@/api/core";
import { notifySuccess } from "@/utils/notify";
import { URLAction, URLActionType } from "@/types/core/urlactions";
export default {
name: "URLActionsForm",
emits: ["hide", "ok", "cancel"],
mixins: [mixins],
props: { action: Object },
data() {
return {
localAction: {
name: "",
desc: "",
pattern: "",
},
};
},
computed: {
title() {
return this.editing ? "Edit URL Action" : "Add URL Action";
},
editing() {
return !!this.action;
},
},
methods: {
submit() {
this.$q.loading.show();
// ui imports
import TestURLAction from "@/components/modals/coresettings/TestURLAction.vue";
let data = {
...this.localAction,
};
import * as monaco from "monaco-editor";
if (this.editing) {
this.$axios
.put(`/core/urlaction/${data.id}/`, data)
.then(() => {
this.$q.loading.hide();
this.onOk();
this.notifySuccess("Url Action was edited!");
})
.catch(() => {
this.$q.loading.hide();
});
} else {
this.$axios
.post("/core/urlaction/", data)
.then(() => {
this.$q.loading.hide();
this.onOk();
this.notifySuccess("URL Action was added!");
})
.catch(() => {
this.$q.loading.hide();
});
}
},
show() {
this.$refs.dialog.show();
},
hide() {
this.$refs.dialog.hide();
},
onHide() {
this.$emit("hide");
},
onOk() {
this.$emit("ok");
this.hide();
},
// define emits
defineEmits([...useDialogPluginComponent.emits]);
// define props
const props = defineProps<{ type: URLActionType; action?: URLAction }>();
// setup quasar
const $q = useQuasar();
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// static data
const URLActionMethods = [
{ value: "get", label: "GET" },
{ value: "post", label: "POST" },
{ value: "put", label: "PUT" },
{ value: "delete", label: "DELETE" },
{ value: "patch", label: "PATCH" },
];
const localAction: URLAction = props.action
? reactive(extend({}, props.action))
: reactive({
name: "",
desc: "",
pattern: "",
action_type: props.type,
rest_body: "{\n \n}",
rest_method: "post",
rest_headers: `{\n "Content-Type": "application/json"\n}`, // eslint-disable-line
} as URLAction);
const disableBodyTab = computed(() =>
["get", "delete"].includes(localAction.rest_method),
);
const tab = ref(disableBodyTab.value ? "headers" : "body");
watch(
() => localAction.rest_method,
() => {
disableBodyTab.value ? (tab.value = "headers") : undefined;
},
mounted() {
// If pk prop is set that means we are editing
if (this.action) Object.assign(this.localAction, this.action);
},
};
);
async function submit() {
$q.loading.show();
try {
props.action
? await editURLAction(localAction.id, localAction)
: await saveURLAction(localAction);
onDialogOK();
notifySuccess("Url Action was edited!");
} catch (e) {}
$q.loading.hide();
}
const editorDiv = ref<HTMLElement | null>(null);
let editor: monaco.editor.IStandaloneCodeEditor;
var modelBodyUri = monaco.Uri.parse("model://body"); // a made up unique URI for our model
var modelHeadersUri = monaco.Uri.parse("model://headers"); // a made up unique URI for our model
var modelBody = monaco.editor.createModel(
localAction.rest_body,
"json",
modelBodyUri,
);
var modelHeaders = monaco.editor.createModel(
localAction.rest_headers,
"json",
modelHeadersUri,
);
function testWebHook() {
$q.dialog({
component: TestURLAction,
componentProps: {
urlAction: localAction,
},
});
}
// watch tab change and change model
watch(tab, (newValue, oldValue) => {
if (oldValue === "body") {
localAction.rest_body = editor.getValue();
} else if (oldValue === "headers") {
localAction.rest_headers = editor.getValue();
}
if (newValue === "body") {
editor.setModel(modelBody);
editor.setValue(localAction.rest_body);
} else if (newValue === "headers") {
editor.setModel(modelHeaders);
editor.setValue(localAction.rest_headers);
}
});
function loadEditor() {
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
if (!editorDiv.value) return;
editor = monaco.editor.create(editorDiv.value, {
model: tab.value === "body" ? modelBody : modelHeaders,
theme: theme,
automaticLayout: true,
minimap: { enabled: false },
quickSuggestions: false,
});
editor.onDidChangeModelContent(() => {
if (tab.value === "body") {
localAction.rest_body = editor.getValue();
} else if (tab.value === "headers") {
localAction.rest_headers = editor.getValue();
}
});
}
function cleanupEditors() {
modelBody.dispose();
modelHeaders.dispose();
editor.dispose();
}
</script>

View File

@@ -1,15 +1,21 @@
<template>
<div>
<div class="row">
<div class="text-subtitle2">URL Actions</div>
<div class="text-subtitle2">
{{
props.type === "web"
? "URL Actions"
: "Web Hooks for Alert Failure/Resolved Actions"
}}
</div>
<q-space />
<q-btn
size="sm"
color="grey-5"
icon="fas fa-plus"
text-color="black"
label="Add URL Action"
@click="addAction"
:label="`Add ${props.type === 'web' ? 'URL Action' : 'Web Hook'}`"
@click="addURLAction"
/>
</div>
<q-separator />
@@ -17,31 +23,36 @@
dense
:rows="actions"
:columns="columns"
v-model:pagination="pagination"
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: true }"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
:rows-per-page-options="[0]"
no-data-label="No URL Actions added yet"
:no-data-label="`No ${props.type === 'web' ? 'URL Actions' : 'Web Hooks'} added yet`"
:loading="loading"
>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="editAction(props.row)"
@dblclick="editURLAction(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item clickable v-close-popup @click="editAction(props.row)">
<q-item clickable v-close-popup @click="editURLAction(props.row)">
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="deleteAction(props.row)">
<q-item
clickable
v-close-popup
@click="deleteURLAction(props.row)"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
@@ -57,15 +68,15 @@
</q-menu>
<!-- name -->
<q-td>
{{ props.row.name }}
{{ truncateText(props.row.name, 30) }}
</q-td>
<!-- desc -->
<q-td>
{{ props.row.desc }}
{{ truncateText(props.row.desc, 20) }}
</q-td>
<!-- pattern -->
<q-td>
{{ props.row.pattern }}
{{ truncateText(props.row.pattern, 20) }}
</q-td>
</q-tr>
</template>
@@ -73,105 +84,103 @@
</div>
</template>
<script>
<script setup lang="ts">
// composition imports
import { ref, onMounted } from "vue";
import { QTableColumn, useQuasar } from "quasar";
import { fetchURLActions, removeURLAction } from "@/api/core";
import { notifySuccess } from "@/utils/notify";
import { truncateText } from "@/utils/format";
// ui imports
import URLActionsForm from "@/components/modals/coresettings/URLActionsForm.vue";
import mixins from "@/mixins/mixins";
export default {
name: "URLActionTable",
mixins: [mixins],
data() {
return {
actions: [],
pagination: {
rowsPerPage: 0,
sortBy: "name",
descending: true,
},
columns: [
{
name: "name",
label: "Name",
field: "name",
align: "left",
sortable: true,
},
{
name: "desc",
label: "Description",
field: "desc",
align: "left",
sortable: true,
},
{
name: "pattern",
label: "Pattern",
field: "pattern",
align: "left",
sortable: true,
},
],
};
},
methods: {
getURLActions() {
this.$q.loading.show();
// types
import { type URLActionType, type URLAction } from "@/types/core/urlactions";
this.$axios
.get("/core/urlaction/")
.then((r) => {
this.$q.loading.hide();
this.actions = r.data;
})
.catch(() => {
this.$q.loading.hide();
});
},
addAction() {
this.$q
.dialog({
component: URLActionsForm,
})
.onOk(() => {
this.getURLActions();
});
},
editAction(action) {
this.$q
.dialog({
component: URLActionsForm,
componentProps: {
action: action,
},
})
.onOk(() => {
this.getURLActions();
});
},
deleteAction(action) {
this.$q
.dialog({
title: `Delete URL Action: ${action.name}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
})
.onOk(() => {
this.$q.loading.show();
this.$axios
.delete(`/core/urlaction/${action.id}/`)
.then(() => {
this.getURLActions();
this.$q.loading.hide();
this.notifySuccess(`URL Action: ${action.name} was deleted!`);
})
.catch(() => {
this.$q.loading.hide();
});
});
},
// define props
const props = defineProps<{ type: URLActionType }>();
// setup quasar
const $q = useQuasar();
const loading = ref(false);
const actions = ref([] as URLAction[]);
const columns: QTableColumn[] = [
{
name: "name",
label: "Name",
field: "name",
align: "left",
sortable: true,
},
mounted() {
this.getURLActions();
{
name: "desc",
label: "Description",
field: "desc",
align: "left",
sortable: true,
},
};
{
name: "pattern",
label: "URL Pattern",
field: "pattern",
align: "left",
sortable: true,
},
];
async function getURLActions() {
$q.loading.show();
try {
const result = await fetchURLActions();
actions.value = result.filter(
(action) => action.action_type === props.type,
);
} catch (e) {
console.error(e);
}
$q.loading.hide();
}
function addURLAction() {
$q.dialog({
component: URLActionsForm,
componentProps: {
type: props.type,
},
}).onOk(getURLActions);
}
function editURLAction(action: URLAction) {
$q.dialog({
component: URLActionsForm,
componentProps: {
type: props.type,
action: action,
},
}).onOk(getURLActions);
}
function deleteURLAction(action: URLAction) {
$q.dialog({
title: `Delete URL Action: ${action.name}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await removeURLAction(action.id);
await getURLActions();
notifySuccess(`URL Action: ${action.name} was deleted!`);
} catch (e) {
console.error(e);
}
loading.value = false;
});
}
onMounted(getURLActions);
</script>

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">
@@ -82,6 +82,98 @@
class="col-4"
/>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Dashboard Info Color:</div>
<div class="col-2"></div>
<q-input
outlined
dense
v-model="dash_info_color"
class="col-8"
>
<template v-slot:after>
<q-btn
round
dense
flat
size="sm"
icon="info"
@click="openURL(quasar_color_url)"
>
<q-tooltip>Click to see color options</q-tooltip>
</q-btn>
</template>
</q-input>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Dashboard Positive Color:</div>
<div class="col-2"></div>
<q-input
outlined
dense
v-model="dash_positive_color"
class="col-8"
>
<template v-slot:after>
<q-btn
round
dense
flat
size="sm"
icon="info"
@click="openURL(quasar_color_url)"
>
<q-tooltip>Click to see color options</q-tooltip>
</q-btn>
</template>
</q-input>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Dashboard Negative Color:</div>
<div class="col-2"></div>
<q-input
outlined
dense
v-model="dash_negative_color"
class="col-8"
>
<template v-slot:after>
<q-btn
round
dense
flat
size="sm"
icon="info"
@click="openURL(quasar_color_url)"
>
<q-tooltip>Click to see color options</q-tooltip>
</q-btn>
</template>
</q-input>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Dashboard Warning Color:</div>
<div class="col-2"></div>
<q-input
outlined
dense
v-model="dash_warning_color"
class="col-8"
>
<template v-slot:after>
<q-btn
round
dense
flat
size="sm"
icon="info"
@click="openURL(quasar_color_url)"
>
<q-tooltip>Click to see color options</q-tooltip>
</q-btn>
</template>
</q-input>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Client Sort:</div>
<div class="col-2"></div>
@@ -109,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',
)
"
>
@@ -156,9 +248,14 @@ export default {
tab: "ui",
splitterModel: 20,
loading_bar_color: "",
dash_info_color: "",
dash_positive_color: "",
dash_negative_color: "",
dash_warning_color: "",
urlActions: [],
clear_search_when_switching: true,
date_format: "",
quasar_color_url: "https://quasar.dev/style/color-palette",
clientTreeSortOptions: [
{
label: "Sort alphabetically, moving failing clients to the top",
@@ -216,16 +313,19 @@ export default {
},
getURLActions() {
this.$axios.get("/core/urlaction/").then((r) => {
if (r.data.length === 0) {
this.urlActions = r.data
.filter((action) => action.action_type === "web")
.sort((a, b) => a.name.localeCompare(b.name))
.map((action) => ({
label: action.name,
value: action.id,
}));
if (this.urlActions.length === 0) {
this.notifyWarning(
"No URL Actions configured. Go to Settings > Global Settings > URL Actions"
"No URL Actions configured. Go to Settings > Global Settings > URL Actions",
);
return;
}
this.urlActions = r.data.map((action) => ({
label: action.name,
value: action.id,
}));
});
},
getUserPrefs() {
@@ -235,6 +335,10 @@ export default {
this.defaultAgentTblTab = r.data.default_agent_tbl_tab;
this.clientTreeSort = r.data.client_tree_sort;
this.loading_bar_color = r.data.loading_bar_color;
this.dash_info_color = r.data.dash_info_color;
this.dash_positive_color = r.data.dash_positive_color;
this.dash_negative_color = r.data.dash_negative_color;
this.dash_warning_color = r.data.dash_warning_color;
this.clear_search_when_switching = r.data.clear_search_when_switching;
this.date_format = r.data.date_format;
});
@@ -253,6 +357,10 @@ export default {
default_agent_tbl_tab: this.defaultAgentTblTab,
client_tree_sort: this.clientTreeSort,
loading_bar_color: this.loading_bar_color,
dash_info_color: this.dash_info_color,
dash_positive_color: this.dash_positive_color,
dash_negative_color: this.dash_negative_color,
dash_warning_color: this.dash_warning_color,
clear_search_when_switching: this.clear_search_when_switching,
date_format: this.date_format,
};

View File

@@ -1,68 +1,66 @@
<template>
<q-dialog
ref="dialogRef"
maximized
no-esc-dismiss
@hide="onDialogHide"
persistent
@keydown.esc="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>
{{ title }}
<span class="q-pr-sm">{{ title }}</span>
<q-btn
v-if="!script && openAIEnabled"
size="xs"
:disable="loading"
dense
label="Generate Script"
color="primary"
no-caps
@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">
<div class="col-4 q-gutter-sm q-pr-sm">
<div class="q-gutter-sm q-pr-sm">
<q-input
filled
dense
:readonly="readonly"
v-model="formScript.name"
v-model="script.name"
label="Name"
:rules="[(val) => !!val || '*Required']"
hide-bottom-space
@@ -71,22 +69,24 @@
filled
dense
:readonly="readonly"
v-model="formScript.description"
v-model="script.description"
label="Description"
type="textarea"
rows="2"
/>
<q-select
:readonly="readonly"
options-dense
filled
dense
v-model="formScript.shell"
v-model="script.shell"
:options="shellOptions"
emit-value
map-options
label="Shell Type"
/>
<tactical-dropdown
v-model="formScript.supported_platforms"
v-model="script.supported_platforms"
:options="agentPlatformOptions"
label="Supported Platforms (All supported if blank)"
clearable
@@ -97,7 +97,7 @@
/>
<tactical-dropdown
filled
v-model="formScript.category"
v-model="script.category"
:options="categories"
use-input
clearable
@@ -108,7 +108,7 @@
hide-bottom-space
/>
<tactical-dropdown
v-model="formScript.args"
v-model="script.args"
label="Script Arguments (press Enter after typing each argument)"
filled
use-input
@@ -119,7 +119,7 @@
:readonly="readonly"
/>
<tactical-dropdown
v-model="formScript.env_vars"
v-model="script.env_vars"
:label="envVarsLabel"
filled
use-input
@@ -134,261 +134,365 @@
filled
dense
:readonly="readonly"
v-model.number="formScript.default_timeout"
v-model.number="script.default_timeout"
label="Timeout (seconds)"
:rules="[(val) => val >= 5 || 'Minimum is 5']"
hide-bottom-space
/>
<q-checkbox
v-model="formScript.run_as_user"
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 not run and an error will be
returned.
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="formScript.syntax"
v-model="script.syntax"
dense
filled
:readonly="readonly"
/>
</div>
<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 ? '87vh' : '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>
</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: 450px"
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('agent')"
/>
<q-btn
v-if="!hosted"
size="md"
color="secondary"
dense
flat
label="Test on Server"
:disable="
!script.script_body ||
!script.default_timeout ||
!server_scripts_enabled
"
@click="openTestScriptModal('server')"
/>
</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";
import { useAgentDropdown, agentPlatformOptions } from "@/composables/agents";
import { notifySuccess } from "@/utils/notify";
import { generateScript } from "@/api/core";
import { notifyError, 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,
},
clone: {
type: Boolean,
default: false,
},
},
setup(props) {
// setup quasar plugins
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
const $q = useQuasar();
);
// setup agent dropdown
const { agent, agentOptions, getAgentOptions } = useAgentDropdown();
// emits
defineEmits([...useDialogPluginComponent.emits]);
// 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: [],
});
// setup quasar plugins
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
const $q = useQuasar();
if (props.clone) script.value.name = `(Copy) ${script.value.name}`;
const maximized = ref(false);
const loading = ref(false);
const agentLoading = ref(false);
// setup store
const store = useStore();
const openAIEnabled = computed(() => store.state.openAIIntegrationEnabled);
const missingShebang = computed(() => {
if (script.value.shell === "shell" || script.value.shell === "python") {
return !script.value.script_body.includes("#!");
} else {
return false;
}
// setup agent dropdown
const { agent, agentOptions, getAgentOptions } = useAgentDropdown();
const hosted = computed(() => store.state.hosted);
const server_scripts_enabled = computed(
() => store.state.server_scripts_enabled,
);
// script form logic
const script: Script = props.script
? reactive(Object.assign({}, { ...props.script, script_body: "" }))
: reactive({
name: "",
shell: "powershell",
default_timeout: 90,
args: [],
script_body: "",
run_as_user: false,
env_vars: [],
});
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";
}
});
if (props.clone) script.name = `(Copy) ${script.name}`;
const loading = ref(false);
const agentLoading = ref(false);
// 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 "";
});
const missingShebang = computed(() => {
if (script.shell === "shell" || script.shell === "python") {
return !script.script_body.startsWith("#!");
} else {
return false;
}
});
// 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;
}
);
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";
}
});
async function submitForm() {
loading.value = true;
let result = "";
try {
// edit existing script
if (props.script && !props.clone) {
result = await editScript(script.value);
// 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 "";
}
});
// add or save cloned script
} else {
result = await saveScript(script.value);
}
async function submit() {
loading.value = true;
let result = "";
try {
// edit existing script
if (props.script && !props.clone) {
result = await editScript(script);
onDialogOK();
notifySuccess(result);
} catch (e) {
console.error(e);
}
loading.value = false;
// add or save cloned script
} else {
result = await saveScript(script);
}
function openTestScriptModal() {
$q.dialog({
component: TestScriptModal,
componentProps: {
script: { ...script.value },
agent: agent.value,
onDialogOK();
notifySuccess(result);
} catch (e) {
console.error(e);
}
loading.value = false;
}
function openTestScriptModal(ctx: string) {
if (ctx === "server" && !script.script_body.startsWith("#!")) {
notifyError(
"A shebang is required at the top of the script to specify the interpreter's path. Please ensure your script begins with a shebang line.",
7000,
);
return;
}
$q.dialog({
component: TestScriptModal,
componentProps: {
script: { ...script },
agent: agent.value,
ctx: ctx,
},
});
}
const scriptEditor = ref<HTMLElement | null>(null);
let editor: monaco.editor.IStandaloneCodeEditor;
function loadEditor() {
var model = monaco.editor.createModel(script.script_body, 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(scriptEditor.value!, {
readOnly: props.readonly,
automaticLayout: true,
model: model,
theme: theme,
});
editor.onDidChangeModelContent(() => {
script.script_body = editor.getValue();
});
// 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);
// need to add this in the download function otherwise the above will trigger an edit
watch(
() => script.script_body,
() => {
edited.value = true;
},
});
}
// 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,
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,
// 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"
@@ -286,15 +308,10 @@
</template>
</q-tree>
</div>
<q-table
<tactical-table
v-if="tableView"
dense
:table-class="{
'table-bgcolor': !$q.dark.isActive,
'table-bgcolor-dark': $q.dark.isActive,
}"
:style="{ 'max-height': `${$q.screen.height - 182}px` }"
class="tbl-sticky"
:rows="visibleScripts"
:columns="columns"
:loading="loading"
@@ -304,6 +321,7 @@
binary-state-sort
virtual-scroll
:rows-per-page-options="[0]"
column-select
>
<template v-slot:header-cell-favorite="props">
<q-th :props="props" auto-width>
@@ -425,7 +443,7 @@
</q-list>
</q-menu>
<!-- favorite -->
<q-td>
<q-td key="favorite" :props="props">
<q-icon
v-if="props.row.favorite"
color="yellow-8"
@@ -434,7 +452,7 @@
/>
</q-td>
<!-- shell icon -->
<q-td>
<q-td key="shell" :props="props">
<q-icon
v-if="props.row.shell === 'powershell'"
name="mdi-powershell"
@@ -467,9 +485,25 @@
>
<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>
<q-td key="supported_platforms" :props="props">
<q-badge
v-if="
!props.row.supported_platforms ||
@@ -487,7 +521,17 @@
>
</q-td>
<!-- name -->
<q-td :style="{ color: props.row.hidden ? 'grey' : '' }">
<q-td
key="name"
: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"
@@ -495,9 +539,10 @@
>
{{ props.row.name }}
</q-tooltip>
<q-tooltip :delay="600">ID: {{ props.row.id }}</q-tooltip>
</q-td>
<!-- args -->
<q-td>
<q-td key="args" :props="props">
<span v-if="props.row.args.length > 0">
{{ truncateText(props.row.args.toString(), 30) }}
<q-tooltip
@@ -509,8 +554,8 @@
</span>
</q-td>
<q-td>{{ props.row.category }}</q-td>
<q-td>
<q-td key="category" :props="props">{{ props.row.category }}</q-td>
<q-td key="desc" :props="props">
{{ truncateText(props.row.description, 30) }}
<q-tooltip
v-if="props.row.description.length >= 30"
@@ -518,10 +563,13 @@
>{{ props.row.description }}</q-tooltip
>
</q-td>
<q-td>{{ props.row.default_timeout }}</q-td>
<q-td key="default_timeout" :props="props">{{
props.row.default_timeout
}}</q-td>
<q-td></q-td>
</q-tr>
</template>
</q-table>
</tactical-table>
</q-card>
</q-dialog>
</template>
@@ -545,12 +593,15 @@ import { notifySuccess } from "@/utils/notify";
import ScriptUploadModal from "@/components/scripts/ScriptUploadModal.vue";
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 = [
{
name: "favorite",
label: "",
label: "Favorites",
field: "favorite",
align: "left",
sortable: true,
@@ -608,12 +659,15 @@ const columns = [
export default {
name: "ScriptManager",
components: {
TacticalTable,
},
emits: [...useDialogPluginComponent.emits],
setup() {
// setup vuex store
const store = useStore();
const showCommunityScripts = computed(
() => store.state.showCommunityScripts
() => store.state.showCommunityScripts,
);
// setup quasar plugins
@@ -714,7 +768,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,
);
}
});
@@ -877,6 +931,7 @@ export default {
loading,
showCommunityScripts,
showHiddenScripts,
trmmLogo,
// computed
visibleScripts,

View File

@@ -0,0 +1,26 @@
<template>
<div class="row q-gutter-sm items-center">
<div class="col-auto">{{ label }}</div>
<div class="col-auto">
<q-btn dense flat size="md" icon="content_copy" @click="copyText">
<q-tooltip>Copy to Clipboard</q-tooltip>
</q-btn>
</div>
</div>
</template>
<script setup lang="ts">
import { copyOutput } from "@/utils/helpers";
const props = defineProps({
label: String,
data: {
type: String,
required: true,
},
});
const copyText = () => {
copyOutput(props.data);
};
</script>

View File

@@ -1,192 +1,235 @@
<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>
{{ title }}
<span class="q-pr-sm">{{ title }}</span>
<q-btn
v-if="!snippet && openAIEnabled"
:disable="loading"
dense
size="xs"
label="Generate Script"
color="primary"
no-caps
@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";
import { useDialogPluginComponent } from "quasar";
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 }>();
// 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);
// emits
defineEmits([...useDialogPluginComponent.emits]);
const title = computed(() => {
if (props.snippet) {
return `Editing ${snippet.value.name}`;
} else {
return "Adding New Script Snippet";
}
// quasar dialog setup
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// setup quasar
const $q = useQuasar();
// 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;
}
return {
// reactive data
formSnippet: snippet.value,
maximized,
lang,
loading,
// non-reactive data
shellOptions,
//computed
title,
//methods
submitForm,
// 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

@@ -42,15 +42,7 @@
</q-card-section>
<q-card-section>
<q-file
label="Script Upload"
v-model="file"
hint="Supported file types: .ps1, .bat, .py, .sh"
filled
dense
counter
accept=".ps1, .bat, .py, .sh"
>
<q-file label="Script Upload" v-model="file" filled dense counter>
<template v-slot:prepend>
<q-icon name="attach_file" />
</template>

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">
<script-output-copy-clip label="Standard Output" :data="ret.stdout" />
<q-separator />
<pre>{{ ret.stdout }}</pre>
</div>
<div v-if="ret.stderr">
<script-output-copy-clip label="Standard Error" :data="ret.stderr" />
<q-separator />
<pre>{{ ret.stderr }}</pre>
</div>
<q-inner-loading :showing="loading" />
</q-card-section>
</q-card>
@@ -19,22 +36,32 @@
<script>
// composition imports
import { ref, onMounted } from "vue";
import { testScript } from "@/api/scripts";
import { testScript, testScriptOnServer } from "@/api/scripts";
import { useDialogPluginComponent } from "quasar";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
export default {
name: "TestScriptModal",
components: {
ScriptOutputCopyClip,
},
emits: [...useDialogPluginComponent.emits],
props: {
script: !Object,
agent: !String,
ctx: !String,
},
setup(props) {
// setup quasar dialog plugin
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() {
@@ -48,7 +75,11 @@ export default {
env_vars: props.script.env_vars,
};
try {
ret.value = await testScript(props.agent, data);
if (props.ctx === "server") {
ret.value = await testScriptOnServer(data);
} else {
ret.value = await testScript(props.agent, data);
}
} catch (e) {
console.error(e);
}

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"
@@ -736,7 +755,7 @@
<script>
// composition imports
import { ref, watch, onMounted } from "vue";
import { ref, watch, onMounted, defineComponent } from "vue";
import { useDialogPluginComponent } from "quasar";
import draggable from "vuedraggable";
import { saveTask, updateTask } from "@/api/tasks";
@@ -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" },
];
@@ -823,7 +843,7 @@ const taskInstancePolicyOptions = [
{ label: "Stop Existing", value: 3 },
];
export default {
export default defineComponent({
components: { TacticalDropdown, draggable },
name: "AddAutomatedTask",
emits: [...useDialogPluginComponent.emits],
@@ -836,15 +856,21 @@ export default {
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// setup dropdowns
const { script, scriptOptions, defaultTimeout, defaultArgs, defaultEnvVars } =
useScriptDropdown(undefined, {
onMount: true,
});
const {
script,
scriptName,
scriptOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
} = useScriptDropdown({
onMount: true,
});
// set defaultTimeout to 30
defaultTimeout.value = 30;
const { checkOptions, getCheckOptions } = useCheckDropdown();
const { checkOptions, getCheckOptions } = useCheckDropdown(props.parent);
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
// add task logic
@@ -927,9 +953,7 @@ export default {
if (actionType.value === "script") {
task.value.actions.push({
type: "script",
name: scriptOptions.value.find(
(option) => option.value === script.value
).label,
name: scriptName.value,
script: script.value,
timeout: defaultTimeout.value,
script_args: defaultArgs.value,
@@ -1014,13 +1038,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
@@ -1064,7 +1088,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
@@ -1154,7 +1178,7 @@ export default {
onDialogHide,
};
},
};
});
</script>
<style scoped>

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,107 @@
<template>
<q-table
:columns="localColumns"
:visible-columns="visibleColumns"
:table-class="{
'table-bgcolor': !$q.dark.isActive,
'table-bgcolor-dark': $q.dark.isActive,
'column-bgcolor-dark': $q.dark.isActive && columnSelect,
'column-bgcolor': !$q.dark.isActive && columnSelect,
'sticky-header-right-column': columnSelect,
'tbl-sticky': !columnSelect,
}"
v-bind="$attrs"
>
<template v-for="(_, slot) in $slots" v-slot:[slot]="scope">
<slot :name="slot" v-bind="scope || {}" />
</template>
<template v-slot:header-cell-columnSelect="props">
<q-th :props="props" auto-width>
<q-btn dense flat icon="more_horiz">
<q-menu>
<q-option-group
v-model="visibleColumns"
:options="columnOptions"
type="checkbox"
/>
</q-menu>
</q-btn>
</q-th>
</template>
</q-table>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
inheritAttrs: false,
});
</script>
<script setup lang="ts">
import { ref } from "vue";
import { type QTableColumn } from "quasar";
const props = withDefaults(
defineProps<{
columns: QTableColumn[];
columnSelect?: boolean;
excludeColumns?: string[];
}>(),
{ columnSelect: false, excludeColumns: () => ["columnSelect"] }
);
// save a non-reactive copy of columns to modify
const localColumns: QTableColumn[] = Object.assign([], props.columns);
if (props.columnSelect)
localColumns.push({
name: "columnSelect",
label: "Column Select",
field: "columnSelect",
});
const visibleColumns = ref(localColumns.map((column) => column.name));
const columnOptions = ref(
localColumns
.filter((column) => !props.excludeColumns.includes(column.name))
.map((column) => ({ label: column.label, value: column.name }))
);
</script>
<style lang="sass">
.column-bgcolor-dark
td:last-child
/* bg color is important for td; just specify one */
background-color: #1d1d1d
.column-bgcolor
td:last-child
/* bg color is important for td; just specify one */
background-color: #ffffff
.sticky-header-right-column
tr th
position: sticky
/* higher than z-index for td below */
z-index: 2
/* this will be the loading indicator */
thead tr:last-child th
/* height of all previous header rows */
top: 48px
/* highest z-index */
z-index: 3
thead tr:last-child th
top: 0
z-index: 1
tr:last-child th:last-child
/* highest z-index */
z-index: 3
td:last-child
z-index: 1
td:last-child, th:last-child
position: sticky
right: 0
/* prevent scrolling behind sticky top row on focus */
tbody
/* height of all previous header rows */
scroll-margin-top: 48px
</style>

View File

@@ -1,5 +1,5 @@
import { ref, onMounted } from "vue";
import { fetchUsers } from "@/api/accounts";
import { fetchUsers, fetchRoles } from "@/api/accounts";
import { formatUserOptions } from "@/utils/format";
export function useUserDropdown(onMount = false) {
@@ -44,3 +44,26 @@ export function useUserDropdown(onMount = false) {
getDynamicUserOptions,
};
}
export function useRoleDropdown(opts = {}) {
const roleOptions = ref([]);
async function getRoleOptions() {
const roles = await fetchRoles();
roleOptions.value = roles.map((role) => ({
value: role.id,
label: role.name,
}));
}
if (opts.onMount) {
onMounted(getRoleOptions);
}
return {
//data
roleOptions,
//methods
getRoleOptions,
};
}

View File

@@ -1,9 +1,10 @@
import { ref } from "vue";
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { fetchAgents } from "@/api/agents";
import { formatAgentOptions } from "@/utils/format";
// agent dropdown
export function useAgentDropdown() {
export function useAgentDropdown(opts = {}) {
const agent = ref(null);
const agents = ref([]);
const agentOptions = ref([]);
@@ -12,10 +13,14 @@ export function useAgentDropdown() {
async function getAgentOptions(flat = false) {
agentOptions.value = formatAgentOptions(
await fetchAgents({ detail: false }),
flat
flat,
);
}
if (opts.onMount) {
onMounted(getAgentOptions);
}
return {
//data
agent,
@@ -28,10 +33,12 @@ export function useAgentDropdown() {
}
export function cmdPlaceholder(shell) {
if (shell === "cmd") return "rmdir /S /Q C:\\Windows\\System32";
else if (shell === "powershell")
return "Remove-Item -Recurse -Force C:\\Windows\\System32";
else return "rm -rf --no-preserve-root /";
const store = useStore();
const placeholders = computed(() => store.state.run_cmd_placeholder_text);
if (shell === "cmd") return placeholders.value.cmd;
else if (shell === "powershell") return placeholders.value.powershell;
else return placeholders.value.shell;
}
export const agentPlatformOptions = [

View File

@@ -1,28 +0,0 @@
import { ref, onMounted } from "vue";
import { fetchCustomFields } from "@/api/core";
import { formatCustomFieldOptions } from "@/utils/format";
export function useCustomFieldDropdown({ onMount = false }) {
const customFieldOptions = ref([]);
// type can be "client", "site", or "agent"
async function getCustomFieldOptions(model = null, flat = false) {
const params = {};
if (model) params[model] = model;
customFieldOptions.value = formatCustomFieldOptions(
await fetchCustomFields(params),
flat
);
}
if (onMount) onMounted(getCustomFieldOptions);
return {
//data
customFieldOptions,
//methods
getCustomFieldOptions,
};
}

88
src/composables/core.ts Normal file
View File

@@ -0,0 +1,88 @@
import { ref, computed, onMounted } from "vue";
import { fetchCustomFields, fetchURLActions } from "@/api/core";
import {
formatCustomFieldOptions,
formatURLActionOptions,
} from "@/utils/format";
import type { CustomField } from "@/types/core/customfields";
import type { URLAction } from "@/types/core/urlactions";
export interface URLActionOption extends URLAction {
value: number;
label: string;
}
export interface CustomFieldOption extends CustomField {
value: number;
label: string;
}
export interface UseCustomFieldDropdownParams {
onMount?: boolean;
}
export function useCustomFieldDropdown(opts: UseCustomFieldDropdownParams) {
const customFieldOptions = ref([] as CustomFieldOption[]);
// type can be "client", "site", or "agent"
async function getCustomFieldOptions(model = null, flat = false) {
const params = {};
if (model) params[model] = model;
customFieldOptions.value = formatCustomFieldOptions(
await fetchCustomFields(params),
flat,
);
}
const restActionOptions = computed(() =>
customFieldOptions.value.filter((option) => option.type === "rest"),
);
if (opts.onMount) onMounted(getCustomFieldOptions);
return {
customFieldOptions,
restActionOptions,
//methods
getCustomFieldOptions,
};
}
export interface UseURLActionDropdownParams {
onMount?: boolean;
}
export function useURLActionDropdown(opts: UseURLActionDropdownParams) {
const urlActionOptions = ref([] as URLActionOption[]);
// type can be "client", "site", or "agent"
async function getURLActionOptions(flat = false) {
const params = {};
urlActionOptions.value = formatURLActionOptions(
await fetchURLActions(params),
flat,
);
}
const webActionOptions = computed(() =>
urlActionOptions.value.filter((action) => action.action_type === "web"),
);
const restActionOptions = computed(() =>
urlActionOptions.value.filter((action) => action.action_type === "rest"),
);
if (opts?.onMount) onMounted(getURLActionOptions);
return {
urlActionOptions,
restActionOptions,
webActionOptions,
//methods
getURLActionOptions,
};
}

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

@@ -1,68 +0,0 @@
import { ref, watch, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { fetchScripts } from "@/api/scripts";
import { formatScriptOptions } from "@/utils/format";
// script dropdown
export function useScriptDropdown(setScript = null, { onMount = false } = {}) {
const scriptOptions = ref([]);
const defaultTimeout = ref(30);
const defaultArgs = ref([]);
const defaultEnvVars = ref([]);
const script = ref(setScript);
const syntax = ref("");
const link = ref("");
const baseUrl =
"https://github.com/amidaware/community-scripts/blob/main/scripts/";
// specify parameters to filter out community scripts
async function getScriptOptions(showCommunityScripts = false) {
scriptOptions.value = Object.freeze(
formatScriptOptions(await fetchScripts({ showCommunityScripts }))
);
}
// watch scriptPk for changes and update the default timeout and args
watch([script, scriptOptions], () => {
if (script.value && scriptOptions.value.length > 0) {
const tmpScript = scriptOptions.value.find(
(i) => i.value === script.value
);
defaultTimeout.value = tmpScript.timeout;
defaultArgs.value = tmpScript.args;
defaultEnvVars.value = tmpScript.env_vars,
syntax.value = tmpScript.syntax;
link.value =
tmpScript.script_type === "builtin"
? `${baseUrl}${tmpScript.filename}`
: null;
}
});
// vuex show community scripts
const store = useStore();
const showCommunityScripts = computed(() => store.state.showCommunityScripts);
if (onMount) onMounted(() => getScriptOptions(showCommunityScripts.value));
return {
//data
script,
scriptOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
syntax,
link,
//methods
getScriptOptions,
};
}
export const shellOptions = [
{ label: "Powershell", value: "powershell" },
{ label: "Batch", value: "cmd" },
{ label: "Python", value: "python" },
{ label: "Shell", value: "shell" },
];

141
src/composables/scripts.ts Normal file
View File

@@ -0,0 +1,141 @@
import { ref, watch, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { fetchScripts } from "@/api/scripts";
import {
formatScriptOptions,
removeExtraOptionCategories,
} from "@/utils/format";
import type { Script } from "@/types/scripts";
import { AgentPlatformType } from "@/types/agents";
export interface ScriptOption extends Script {
label: string;
value: number;
}
export interface useScriptDropdownParams {
script?: number; // set a selected script on init
plat?: AgentPlatformType; // set a platform for filterByPlatform
onMount?: boolean; // loads script options on mount
}
// script dropdown
export function useScriptDropdown(opts?: useScriptDropdownParams) {
const scriptOptions = ref([] as ScriptOption[]);
const defaultTimeout = ref(30);
const defaultArgs = ref([] as string[]);
const defaultEnvVars = ref([] as string[]);
const script = ref(opts?.script);
const scriptName = ref("");
const syntax = ref<string | undefined>("");
const link = ref<string | undefined>("");
const plat = ref<AgentPlatformType | undefined>(opts?.plat);
const baseUrl =
"https://github.com/amidaware/community-scripts/blob/main/scripts/";
// specify parameters to filter out community scripts
async function getScriptOptions() {
scriptOptions.value = Object.freeze(
formatScriptOptions(
await fetchScripts({
showCommunityScripts: showCommunityScripts.value,
}),
),
) as ScriptOption[];
}
// watch scriptPk for changes and update the default timeout and args
watch([script, scriptOptions], () => {
if (script.value && scriptOptions.value.length > 0) {
const tmpScript = scriptOptions.value.find(
(i) => i.value === script.value,
);
if (tmpScript) {
defaultTimeout.value = tmpScript.default_timeout;
defaultArgs.value = tmpScript.args;
defaultEnvVars.value = tmpScript.env_vars;
syntax.value = tmpScript.syntax;
scriptName.value = tmpScript.label;
link.value =
tmpScript.script_type === "builtin"
? `${baseUrl}${tmpScript.filename}`
: undefined;
}
}
});
// vuex show community scripts
const store = useStore();
const showCommunityScripts = computed(() => store.state.showCommunityScripts);
// filter for only getting server tasks
const serverScriptOptions = computed(
() =>
removeExtraOptionCategories(
scriptOptions.value.filter(
(script) =>
script.category ||
!script.supported_platforms ||
script.supported_platforms.length === 0 ||
script.supported_platforms.includes("linux"),
),
) as ScriptOption[],
);
const filterByPlatformOptions = computed(() => {
if (!plat.value) {
return scriptOptions.value;
} else {
return removeExtraOptionCategories(
scriptOptions.value.filter(
(script) =>
script.category ||
!script.supported_platforms ||
script.supported_platforms.length === 0 ||
script.supported_platforms.includes(plat.value!),
),
) as ScriptOption[];
}
});
function reset() {
defaultTimeout.value = 30;
defaultArgs.value = [];
defaultEnvVars.value = [];
script.value = undefined;
syntax.value = "";
link.value = "";
}
if (opts?.onMount) onMounted(() => getScriptOptions());
return {
//data
script,
defaultTimeout,
defaultArgs,
defaultEnvVars,
scriptName,
syntax,
link,
plat,
scriptOptions, // unfiltered options
serverScriptOptions, // only scripts that can run on server
filterByPlatformOptions, // use the returned plat to change options
//methods
getScriptOptions,
reset, // resets dropdown selection state
};
}
export const shellOptions = [
{ label: "Powershell", value: "powershell" },
{ label: "Batch", value: "cmd" },
{ label: "Python", value: "python" },
{ label: "Shell", value: "shell" },
{ label: "Nushell", value: "nushell" },
{ label: "Deno", value: "deno" },
];

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)"
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 SSO (Single Sign-On), Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference.
## License Grant
Subject to the terms of this Agreement and upon the Licensee's possession of a valid and authorized sponsorship token issued by Amidaware, Amidaware hereby grants to the Licensee a limited, non-exclusive, non-transferable, and revocable right and license to use the Software.
## Restrictions
The Licensee acknowledges and agrees that, notwithstanding any other provision of this Agreement:
a) The Licensee shall not copy, merge, publish, distribute, sublicense, or sell the Software or any derivative thereof;
b) The Licensee shall not, in any manner, circumvent, bypass, or tamper with the license key functionality embedded in the Software, nor shall the Licensee remove, alter, or obscure any features that are protected by such license keys. For the avoidance of doubt, the Software's code contains protective measures that enable specific EE features, and the Licensee is strictly prohibited from modifying or removing any licensing code with the intent to enable or unlock these EE features without proper authorization.
## Termination
1. **Breach**: If the Licensee breaches any term of this Agreement, Amidaware reserves the right to terminate this Agreement and the Licensee's rights granted hereunder immediately, without prior notice.
2. **Legal Action**: Any breach of this Agreement may result in Amidaware pursuing legal action against the Licensee. The Licensee acknowledges and agrees that, upon any breach, Amidaware may seek remedies, including injunctive relief, damages, and legal fees, and will be entitled to prosecute the Licensee to the full extent of the law.
3. **Effects of Termination**: Upon termination, the Licensee shall immediately cease all use of the Software and delete all copies of the Software from its systems and confirm such deletion in writing to Amidaware.
## Updates & Amendments
1. **Software Updates**: Amidaware may, from time to time, release updates or upgrades to the Software. These updates or upgrades might be subject to additional terms presented to you at the time of download or installation.
2. **Amendments to this Agreement**: Amidaware reserves the right to modify the terms of this Agreement at any given time. Any such modifications will be effective immediately upon posting on Amidaware's official website or by direct communication to the Licensee. The continued use of the Software after such modifications will constitute the Licensee's acceptance of the revised terms.

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>

Some files were not shown because too many files have changed in this diff Show More