This commit is contained in:
sadnub
2024-04-02 11:46:05 -04:00
parent b03d7b370f
commit 0fbd3a59bd
62 changed files with 13143 additions and 2341 deletions

View File

@@ -3,10 +3,9 @@ version: '3.7'
services:
app-dev:
container_name: trmm-app-dev
image: node:18-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

9261
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,23 +7,27 @@
"serve": "quasar dev",
"build": "quasar build",
"lint": "eslint --ext .js,.ts,.vue ./",
"format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore"
"format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore",
"test:unit:ui": "vitest --ui",
"test": "echo \"See package.json => scripts for available tests.\" && exit 0",
"test:unit": "vitest",
"test:unit:ci": "vitest run"
},
"dependencies": {
"@quasar/extras": "1.16.11",
"@vueuse/core": "10.9.0",
"@vueuse/shared": "10.9.0",
"apexcharts": "3.48.0",
"axios": "1.6.8",
"dotenv": "16.4.5",
"monaco-editor": "0.47.0",
"pinia": "^2.1.7",
"qrcode.vue": "3.4.1",
"quasar": "2.15.2",
"vue": "3.4.21",
"vue-router": "4.3.0",
"vue3-apexcharts": "1.5.2",
"vuedraggable": "4.1.0",
"vue-router": "4.3.0",
"@vueuse/core": "10.9.0",
"@vueuse/shared": "10.9.0",
"monaco-editor": "0.47.0",
"vuex": "4.1.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
@@ -41,6 +45,9 @@
"eslint-config-prettier": "9.1.0",
"eslint-plugin-vue": "8.7.1",
"prettier": "3.2.5",
"typescript": "5.4.3"
"typescript": "5.4.3",
"@vue/test-utils": "^2.4.3",
"vitest": "^1.1.0",
"@vitest/ui": "^1.1.0"
}
}

View File

@@ -29,7 +29,7 @@ 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", "monaco", "integrations"],
boot: ["pinia", "axios", "monaco", "integrations"],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: ["app.sass"],

7
quasar.extensions.json Normal file
View File

@@ -0,0 +1,7 @@
{
"@quasar/testing-unit-vitest": {
"options": [
"ui"
]
}
}

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

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

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

@@ -0,0 +1,105 @@
import axios from "axios";
import type { URLAction, URLActionRunResponse } from "@/types/core/urlactions";
import type { AutomatedTask } from "@/types/tasks";
const baseUrl = "/core";
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 = {}) {
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;
}
export async function runURLAction(id: number): Promise<URLActionRunResponse> {
const { data } = await axios.post(`${baseUrl}/urlaction/${id}/run/`);
return data;
}
export async function fetchServerTasks(params = {}): Promise<AutomatedTask[]> {
const { data } = await axios.get(`${baseUrl}/servertasks/`, {
params: params,
});
return data;
}
export async function saveServerTask(action: AutomatedTask) {
const { data } = await axios.post(`${baseUrl}/servertasks/`, action);
return data;
}
export async function editServerTask(id: number, action: AutomatedTask) {
const { data } = await axios.put(`${baseUrl}/servertasks/${id}/`, action);
return data;
}
export async function removeServerTask(id: number) {
const { data } = await axios.delete(`${baseUrl}/servertasks/${id}/`);
return data;
}
export interface ServerScriptResponse {
output: string;
execution_time: number;
}
export async function runServerTask(id: number): Promise<ServerScriptResponse> {
const { data } = await axios.post(`${baseUrl}/servertasks/${id}/run/`);
return data;
}
export interface ServerScriptRunRequest {
timeout: number;
env_vars: string[];
args: string[];
}
export async function runServerScript(
id: number,
payload: ServerScriptRunRequest,
): Promise<string> {
const { data } = await axios.post(
`${baseUrl}/serverscript/${id}/run/`,
payload,
);
return data;
}
// 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

@@ -1,4 +1,5 @@
import axios from "axios";
import { useAuthStore } from "@/stores/auth";
import { Notify } from "quasar";
export const getBaseUrl = () => {
@@ -18,27 +19,22 @@ export function setErrorMessage(data, message) {
];
}
export default function ({ app, router, store }) {
export default function ({ app, router }) {
app.config.globalProperties.$axios = axios;
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}`;
}
// config.transformResponse = [
// function (data) {
// console.log(data);
// return data;
// },
// ];
return config;
},
function (err) {
return Promise.reject(err);
}
},
);
axios.interceptors.response.use(
@@ -101,6 +97,6 @@ export default function ({ app, router, store }) {
}
return Promise.reject({ ...error });
}
},
);
}

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

@@ -138,6 +138,10 @@
>
<q-item-section>Server Maintenance</q-item-section>
</q-item>
<!-- Run Serverside Script-->
<q-item clickable v-close-popup @click="showServerScriptRun">
<q-item-section>Run Server Script</q-item-section>
</q-item>
<!-- clear cache -->
<q-item clickable v-close-popup @click="clearCache">
<q-item-section>Clear Cache</q-item-section>
@@ -276,6 +280,7 @@ import DeploymentTable from "@/components/clients/DeploymentTable.vue";
import ServerMaintenance from "@/components/modals/core/ServerMaintenance.vue";
import CodeSign from "@/components/modals/coresettings/CodeSign.vue";
import PermissionsManager from "@/components/accounts/PermissionsManager.vue";
import RunServerScript from "@/components/modals/core/RunServerScript.vue";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { notifyWarning } from "@/utils/notify";
@@ -447,6 +452,11 @@ export default {
component: ReportsManager,
});
},
showServerScriptRun() {
this.$q.dialog({
component: RunServerScript,
});
},
},
};
</script>

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="dash_positive_color"
:color="dashPositiveColor"
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="dash_info_color"
:color="dashInfoColor"
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="dash_warning_color"
:color="dashWarningColor"
name="warning"
>
<q-tooltip>Warning</q-tooltip>
@@ -287,7 +287,7 @@
<q-icon
v-else
style="font-size: 1.3rem"
:color="dash_negative_color"
:color="dashNegativeColor"
name="error"
>
<q-tooltip>Error</q-tooltip>
@@ -342,7 +342,7 @@
</div>
</template>
<script>
<script setup>
// composition imports
import { ref, computed, watch, onMounted } from "vue";
import { useStore } from "vuex";
@@ -409,47 +409,44 @@ const columns = [
},
];
export default {
name: "AutomatedTasksTab",
setup() {
// setup vuex
const store = useStore();
const selectedAgent = computed(() => store.state.selectedRow);
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 vuex
const store = useStore();
const selectedAgent = computed(() => store.state.selectedRow);
const tabHeight = computed(() => store.state.tabHeight);
const agentPlatform = computed(() => store.state.agentPlatform);
const dashWarningColor = computed(() => store.state.dash_warning_color);
const dashNegativeColor = computed(() => store.state.dash_negative_color);
const dashPositiveColor = computed(() => store.state.dash_positive_color);
const dashInfoColor = computed(() => store.state.dash_info_color);
const formatDate = computed(() => store.getters.formatDate);
// setup quasar
const $q = useQuasar();
// setup quasar
const $q = useQuasar();
// automated tasks logic
const tasks = ref([]);
const loading = ref(false);
// automated tasks logic
const tasks = ref([]);
const loading = ref(false);
const pagination = ref({
const pagination = ref({
rowsPerPage: 0,
sortBy: "name",
descending: false,
});
});
async function getTasks() {
async function getTasks() {
loading.value = true;
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);
}
loading.value = false;
}
}
async function editTask(task, data) {
async function editTask(task, data) {
if (task.policy) return;
loading.value = true;
@@ -462,9 +459,9 @@ export default {
}
loading.value = false;
}
}
function deleteTask(task) {
function deleteTask(task) {
if (task.policy) return;
$q.dialog({
@@ -483,9 +480,9 @@ export default {
}
loading.value = false;
});
}
}
async function runWinTask(task) {
async function runWinTask(task) {
if (!task.enabled) {
notifyError("Task cannot be run when it's disabled. Enable it first.");
return;
@@ -495,88 +492,60 @@ 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) {
console.error(e);
}
loading.value = false;
}
}
function showAddTask() {
function showAddTask() {
$q.dialog({
component: AutomatedTaskForm,
componentProps: {
parent: { agent: selectedAgent.value },
type: "agent",
parent: selectedAgent.value,
plat: agentPlatform.value,
},
}).onOk(() => {
getTasks();
});
}
}
function showEditTask(task) {
function showEditTask(task) {
if (task.policy) return;
$q.dialog({
component: AutomatedTaskForm,
componentProps: {
task: task,
parent: { agent: selectedAgent.value },
type: "agent",
parent: selectedAgent.value,
plat: agentPlatform.value,
},
}).onOk(() => {
getTasks();
});
}
}
function showScriptOutput(script) {
function showScriptOutput(script) {
$q.dialog({
component: ScriptOutput,
componentProps: {
scriptInfo: script.task_result,
},
});
}
}
watch(selectedAgent, (newValue) => {
watch(selectedAgent, (newValue) => {
if (newValue) {
getTasks();
}
});
});
onMounted(() => {
onMounted(() => {
if (selectedAgent.value) getTasks();
});
return {
// reactive data
tasks,
loading,
pagination,
selectedAgent,
tabHeight,
agentPlatform,
dash_info_color,
dash_positive_color,
dash_warning_color,
dash_negative_color,
// non-reactive data
columns,
// methods
formatDate,
getTasks,
editTask,
runWinTask,
deleteTask,
showAddTask,
showEditTask,
showScriptOutput,
// helpers
truncateText,
};
},
};
});
</script>

View File

@@ -293,7 +293,8 @@ export default {
.dialog({
component: AutomatedTaskForm,
componentProps: {
parent: { policy: this.selectedPolicy },
parent: this.selectedPolicy,
type: "policy",
},
})
.onOk(this.getTasks);
@@ -304,7 +305,8 @@ export default {
component: AutomatedTaskForm,
componentProps: {
task: task,
parent: { policy: this.selectedPolicy },
parent: this.selectedPolicy,
type: "policy",
},
})
.onOk(this.getTasks);

View File

@@ -152,7 +152,8 @@ export default {
defaultTimeout,
defaultArgs,
defaultEnvVars,
} = useScriptDropdown(props.check ? props.check.script : undefined, {
} = useScriptDropdown({
script: props.check ? props.check.script : undefined,
onMount: true,
});

View File

@@ -0,0 +1,385 @@
<template>
<div>
<div class="row">
<div class="text-subtitle2">Server Tasks</div>
<q-space />
<q-btn
size="sm"
color="grey-5"
icon="fas fa-plus"
text-color="black"
label="Add Server Task"
@click="addServerTask"
/>
</div>
<q-separator />
<q-table
dense
:rows="tasks"
:columns="columns"
: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 Server Tasks added yet"
>
<template v-slot:header-cell-enabled="props">
<q-th auto-width :props="props">
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enabled</q-tooltip>
</q-icon>
</q-th>
</template>
<template v-slot:header-cell-smsalert="props">
<q-th auto-width :props="props">
<q-icon name="phone_android" size="1.5em">
<q-tooltip>SMS Alert</q-tooltip>
</q-icon>
</q-th>
</template>
<template v-slot:header-cell-emailalert="props">
<q-th auto-width :props="props">
<q-icon name="email" size="1.5em">
<q-tooltip>Email Alert</q-tooltip>
</q-icon>
</q-th>
</template>
<template v-slot:header-cell-dashboardalert="props">
<q-th auto-width :props="props">
<q-icon name="notifications" size="1.5em">
<q-tooltip>Dashboard Alert</q-tooltip>
</q-icon>
</q-th>
</template>
<template v-slot:header-cell-status="props">
<q-th auto-width :props="props"></q-th>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="editServerTask(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="editServerTask(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="deleteServerTask(props.row)"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator />
<q-item
clickable
v-close-popup
@click="executeServerTask(props.row)"
>
<q-item-section side>
<q-icon name="play_arrow" />
</q-item-section>
<q-item-section>Run Server Task</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- enabled -->
<q-td>
<q-checkbox
dense
v-model="props.row.enabled"
@update:model-value="
editTask(props.row.id, { enabled: props.row.enabled })
"
/>
</q-td>
<!-- text alert -->
<q-td>
<q-checkbox
dense
v-model="props.row.text_alert"
@update:model-value="
editTask(props.row.id, { text_alert: props.row.text_alert })
"
/>
</q-td>
<!-- email alert -->
<q-td>
<q-checkbox
dense
v-model="props.row.email_alert"
@update:model-value="
editTask(props.row.id, { email_alert: props.row.email_alert })
"
/>
</q-td>
<!-- dashboard alert -->
<q-td>
<q-checkbox
dense
v-model="props.row.dashboard_alert"
@update:model-value="
editTask(props.row.id, {
dashboard_alert: props.row.dashboard_alert,
})
"
/>
</q-td>
<!-- status icon -->
<q-td v-if="Object.keys(props.row.task_result).length === 0"></q-td>
<q-td v-else-if="props.row.task_result.status === 'passing'">
<q-icon
style="font-size: 1.3rem"
:color="dashPositiveColor"
name="check_circle"
>
<q-tooltip>Passing</q-tooltip>
</q-icon>
</q-td>
<q-td v-else-if="props.row.task_result.status === 'failing'">
<q-icon
v-if="props.row.alert_severity === 'info'"
style="font-size: 1.3rem"
:color="dashInfoColor"
name="info"
>
<q-tooltip>Informational</q-tooltip>
</q-icon>
<q-icon
v-else-if="props.row.alert_severity === 'warning'"
style="font-size: 1.3rem"
:color="dashWarningColor"
name="warning"
>
<q-tooltip>Warning</q-tooltip>
</q-icon>
<q-icon
v-else
style="font-size: 1.3rem"
:color="dashNegativeColor"
name="error"
>
<q-tooltip>Error</q-tooltip>
</q-icon>
</q-td>
<q-td v-else></q-td>
<!-- name -->
<q-td>{{ props.row.name }}</q-td>
<!-- schedule -->
<q-td>{{
props.row.crontab_schedule ? props.row.crontab_schedule : "Manual"
}}</q-td>
<!-- more info -->
<q-td
v-if="
props.row.task_result.retcode !== null ||
props.row.task_result.stdout ||
props.row.task_result.stderr
"
>
<span
style="cursor: pointer; text-decoration: underline"
class="text-primary"
@click="showScriptOutput(props.row)"
>output</span
>
</q-td>
<q-td v-else>Awaiting output</q-td>
<!-- last run -->
<q-td v-if="props.row.task_result.last_run">{{
formatDate(props.row.task_result.last_run)
}}</q-td>
<q-td v-else>Has not run yet</q-td>
</q-tr>
</template>
</q-table>
</div>
</template>
<script setup lang="ts">
// composition imports
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { QTableColumn, useQuasar } from "quasar";
import { fetchServerTasks, removeServerTask, runServerTask } from "@/api/core";
import { updateTask } from "@/api/tasks";
import { notifyError, notifySuccess } from "@/utils/notify";
// ui imports
import AutomatedTaskForm from "@/components/tasks/AutomatedTaskForm.vue";
import ScriptOutput from "@/components/checks/ScriptOutput.vue";
// types
import type { AutomatedTask } from "@/types/tasks";
// setup quasar
const $q = useQuasar();
const store = useStore();
const dashWarningColor = computed(() => store.state.dash_warning_color);
const dashNegativeColor = computed(() => store.state.dash_negative_color);
const dashPositiveColor = computed(() => store.state.dash_positive_color);
const dashInfoColor = computed(() => store.state.dash_info_color);
const formatDate = computed(() => store.getters.formatDate);
const tasks = ref([] as AutomatedTask[]);
const columns: QTableColumn[] = [
{ name: "enabled", align: "left", field: "enabled", label: "" },
{ name: "smsalert", field: "text_alert", align: "left", label: "" },
{ name: "emailalert", field: "email_alert", align: "left", label: "" },
{
name: "dashboardalert",
field: "dashboard_alert",
align: "left",
label: "",
},
{ name: "status", field: "status", align: "left", label: "" },
{
name: "name",
label: "Name",
field: "name",
align: "left",
sortable: true,
},
{
name: "crontab_schedule",
label: "Schedule",
field: "crontab_schedule",
align: "left",
sortable: true,
},
{
name: "moreinfo",
label: "More Info",
field: "more_info",
align: "left",
sortable: true,
},
{
name: "datetime",
label: "Last Run Time",
field: "last_run",
align: "left",
sortable: true,
},
];
async function getServerTasks() {
$q.loading.show();
try {
const result = await fetchServerTasks();
tasks.value = result;
} catch (e) {
console.error(e);
}
$q.loading.hide();
}
function addServerTask() {
$q.dialog({
component: AutomatedTaskForm,
componentProps: {
type: "server",
},
}).onOk(getServerTasks);
}
function editServerTask(task: AutomatedTask) {
$q.dialog({
component: AutomatedTaskForm,
componentProps: {
task: task,
type: "server",
},
}).onOk(getServerTasks);
}
async function executeServerTask(task: AutomatedTask) {
console.log(tasks.value);
try {
const result = await runServerTask(task.id);
console.log(result);
} catch (e) {
notifyError(`Unable to run task: ${e}`);
}
}
function showScriptOutput(task: AutomatedTask) {
$q.dialog({
component: ScriptOutput,
componentProps: {
scriptInfo: task.task_result,
},
});
}
function deleteServerTask(task: AutomatedTask) {
$q.dialog({
title: `Delete Server Task: ${task.name}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
$q.loading.show();
try {
await removeServerTask(task.id);
await getServerTasks();
notifySuccess(`Server Task: ${task.name} was deleted!`);
} catch (e) {
console.error(e);
}
$q.loading.hide();
});
}
interface EditTaskRequest {
text_alert?: boolean;
email_alert?: boolean;
dashboard_alert?: boolean;
enabled?: boolean;
}
async function editTask(task_id: number, data: EditTaskRequest) {
try {
const result = await updateTask(task_id, data);
notifySuccess(result);
} catch (e) {
console.error(e);
}
}
onMounted(getServerTasks);
</script>

View File

@@ -0,0 +1,101 @@
<template>
<q-dialog
ref="dialogRef"
@hide="onDialogHide"
maximized
@show="setupXTerm"
@before-hide="disconnect"
>
<q-card class="q-dialog-plugin">
<q-bar>
Tactical RMM Server Command Prompt - Careful! With great power comes
great responsibility!
<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
ref="xtermContainer"
:style="{ height: `${$q.screen.height - 34}px` }"
></div>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useDialogPluginComponent } from "quasar";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { useResizeObserver, useDebounceFn } from "@vueuse/core";
import { useCliWSConnection } from "@/websocket/websocket";
import "xterm/css/xterm.css";
// emits
defineEmits([...useDialogPluginComponent.emits]);
// setup quasar plugins
const { dialogRef, onDialogHide } = useDialogPluginComponent();
// set ws connection
const { data, send, close } = useCliWSConnection();
const xtermContainer = ref<HTMLElement | null>(null);
let term: Terminal;
const fit = new FitAddon();
// Setup Xterm
function setupXTerm() {
term = new Terminal({
convertEol: true,
fontFamily: "Menlo, Monaco, Courier New, monospace",
fontSize: 15,
fontWeight: 400,
cursorBlink: true,
});
term.options = {
theme: {
background: "#333",
},
};
term.loadAddon(fit);
term.open(xtermContainer.value!);
fit.fit();
term.onData((data) => {
send(JSON.stringify({ action: "trmmcli.input", data: { input: data } }));
});
useResizeObserver(dialogRef, () => {
resizeWindow();
});
interface WSTrmmCliOutput {
output: string;
messageId: string;
}
watch(data, (newValue) => {
if (newValue.action === "trmmcli.output") {
const incomingData = newValue.data as WSTrmmCliOutput;
term.write(incomingData.output);
}
});
}
const resizeWindow = useDebounceFn(() => {
fit.fit();
const dims = { cols: term.cols, rows: term.rows };
send(JSON.stringify({ action: "trmmcli.resize", data: dims }));
}, 300);
function disconnect() {
term.dispose();
close();
send(JSON.stringify({ action: "trmmcli.disconnect" }));
}
</script>

View File

@@ -262,7 +262,7 @@ export default {
// setup vuex store
const store = useStore();
const showCommunityScripts = computed(
() => store.state.showCommunityScripts
() => store.state.showCommunityScripts,
);
const shellOptions = computed(() => {
@@ -331,7 +331,7 @@ export default {
client.value = null;
site.value = null;
agents.value = [];
}
},
);
watch(
@@ -345,7 +345,7 @@ export default {
} else {
state.value.shell = "/bin/bash";
}
}
},
);
async function submit() {
@@ -388,8 +388,8 @@ export default {
script.category ||
!script.supported_platforms ||
script.supported_platforms.length === 0 ||
script.supported_platforms.includes(state.value.osType)
)
script.supported_platforms.includes(state.value.osType),
),
);
});
@@ -398,7 +398,7 @@ export default {
getAgentOptions();
getSiteOptions();
getClientOptions();
if (props.mode === "script") getScriptOptions(showCommunityScripts.value);
if (props.mode === "script") getScriptOptions();
});
return {

View File

@@ -41,7 +41,7 @@
<tactical-dropdown
:rules="[(val) => !!val || '*Required']"
v-model="state.script"
:options="filteredScriptOptions"
:options="filterByPlatformOptions(agent.plat)"
label="Select script"
outlined
mapOptions
@@ -182,23 +182,23 @@
</q-dialog>
</template>
<script>
<script setup lang="ts">
// composition imports
import { ref, watch, computed } from "vue";
import { ref, watch } from "vue";
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";
// types
import type { Agent } from "@/types/Agent";
// static data
const outputOptions = [
{ label: "Wait for Output", value: "wait" },
@@ -208,35 +208,35 @@ 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 {
// props
const props = defineProps<{
agent: Agent;
script?: number;
}>();
// setup quasar dialog plugin
const { dialogRef, onDialogHide } = useDialogPluginComponent();
// setup dropdowns
const {
script,
scriptOptions,
filterByPlatformOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
syntax,
link,
} = useScriptDropdown(props.script, {
} = useScriptDropdown({
script: props.script,
onMount: true,
filterByPlatform: props.agent.plat,
});
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
});
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
// main run script functionaity
const state = ref({
// main run script functionaity
const state = ref({
output: "wait",
emails: [],
emailMode: "default",
@@ -247,13 +247,13 @@ export default {
env_vars: defaultEnvVars,
timeout: defaultTimeout,
run_as_user: false,
});
});
const ret = ref(null);
const loading = ref(false);
const maximized = ref(false);
const ret = ref(null);
const loading = ref(false);
const maximized = ref(false);
async function sendScript() {
async function sendScript() {
ret.value = null;
loading.value = true;
@@ -263,55 +263,15 @@ export default {
onDialogHide();
notifySuccess(ret.value);
}
}
}
function openScriptURL() {
function openScriptURL() {
link.value ? openURL(link.value) : null;
}
}
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)
)
);
});
// watchers
watch(
// watchers
watch(
[() => state.value.output, () => state.value.emailMode],
() => (state.value.emails = [])
);
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,
};
},
};
() => (state.value.emails = []),
);
</script>

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>
@@ -157,45 +157,57 @@
</div>
<q-card-section>
<q-select
<q-option-group
v-model="template.action_type"
class="q-pb-sm"
:options="actionTypeOptions"
dense
inline
/>
<tactical-dropdown
v-if="template.action_type == 'script'"
class="q-mb-sm"
label="Failure action"
dense
options-dense
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>
v-model="failureScriptDropdown.script"
:options="failureScriptDropdown.scriptOptions"
mapOptions
filterable
/>
<tactical-dropdown
v-else-if="template.action_type == 'server'"
class="q-mb-sm"
label="Failure action"
outlined
clearable
v-model="failureScriptDropdown.script"
:options="failureScriptDropdown.serverScriptOptions"
mapOptions
filterable
/>
<tactical-dropdown
v-else
class="q-mb-sm"
label="Failure action"
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)"
filled
v-model="template.action_args"
v-model="failureScriptDropdown.defaultArgs"
use-input
use-chips
multiple
@@ -205,11 +217,12 @@
/>
<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)"
filled
v-model="template.action_env_vars"
v-model="failureScriptDropdown.defaultEnvVars"
use-input
use-chips
multiple
@@ -219,11 +232,12 @@
/>
<q-input
v-if="template.action_type !== 'rest'"
class="q-mb-sm"
label="Failure action timeout (seconds)"
outlined
type="number"
v-model.number="template.action_timeout"
v-model.number="failureScriptDropdown.defaultTimeout"
dense
:rules="[
(val) => !!val || 'Failure action timeout is required',
@@ -244,45 +258,57 @@
</div>
<q-card-section>
<q-select
<q-option-group
v-model="template.resolved_action_type"
class="q-pb-sm"
:options="actionTypeOptions"
dense
inline
/>
<tactical-dropdown
v-if="template.resolved_action_type === 'script'"
class="q-mb-sm"
label="Resolved Action"
dense
options-dense
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>
v-model="resolvedScriptDropdown.script"
:options="resolvedScriptDropdown.scriptOptions"
mapOptions
filterable
/>
<tactical-dropdown
v-else-if="template.resolved_action_type === 'server'"
class="q-mb-sm"
label="Resolved Action"
outlined
clearable
v-model="resolvedScriptDropdown.script"
:options="resolvedScriptDropdown.serverScriptOptions"
mapOptions
filterable
/>
<tactical-dropdown
v-else
class="q-mb-sm"
label="Resolved Action"
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)"
filled
v-model="template.resolved_action_args"
v-model="resolvedScriptDropdown.defaultArgs"
use-input
use-chips
multiple
@@ -292,11 +318,12 @@
/>
<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)"
filled
v-model="template.resolved_action_env_vars"
v-model="resolvedScriptDropdown.defaultEnvVars"
use-input
use-chips
multiple
@@ -306,11 +333,12 @@
/>
<q-input
v-if="template.resolved_action_type !== 'rest'"
class="q-mb-sm"
label="Resolved action timeout (seconds)"
outlined
type="number"
v-model.number="template.resolved_action_timeout"
v-model.number="resolvedScriptDropdown.defaultTimeout"
dense
:rules="[
(val) => !!val || 'Resolved action timeout is required',
@@ -688,18 +716,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,32 +740,71 @@
</q-dialog>
</template>
<script>
import mixins from "@/mixins/mixins";
import { mapGetters } from "vuex";
<script setup lang="ts">
import { ref, reactive, watch } from "vue";
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: {
// components
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
// types
import type { AlertTemplate, AlertSeverity } from "@/types/alerts";
// 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 failureScriptDropdown = reactive(
useScriptDropdown({ script: props.alertTemplate?.action, onMount: true }),
);
const resolvedScriptDropdown = reactive(
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: 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: [],
action_type: "script",
action: failureScriptDropdown.script,
action_rest: undefined,
action_args: failureScriptDropdown.defaultArgs,
action_env_vars: failureScriptDropdown.defaultEnvVars,
action_timeout: failureScriptDropdown.defaultTimeout,
resolved_action_type: "script",
resolved_action: resolvedScriptDropdown.script,
resolved_action_rest: undefined,
resolved_action_args: resolvedScriptDropdown.defaultArgs,
resolved_action_env_vars: resolvedScriptDropdown.defaultEnvVars,
resolved_action_timeout: resolvedScriptDropdown.defaultTimeout,
email_recipients: [] as string[],
email_from: "",
text_recipients: [],
text_recipients: [] as string[],
agent_email_on_resolved: false,
agent_text_on_resolved: false,
agent_always_email: null,
@@ -740,9 +812,9 @@ export default {
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_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,
@@ -750,9 +822,9 @@ export default {
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_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,
@@ -760,67 +832,64 @@ export default {
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,
() => {
failureScriptDropdown.reset();
template.action_rest = undefined;
template.action = undefined;
template.action_args = [];
template.action_env_vars = [];
template.action_timeout = 30;
},
scriptOptions: [],
severityOptions: [
);
watch(
() => template.resolved_action_type,
() => {
resolvedScriptDropdown.reset();
template.resolved_action_rest = undefined;
template.resolved_action = undefined;
template.resolved_action_args = [];
template.resolved_action_env_vars = [];
template.resolved_action_timeout = 30;
},
);
const 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,
},
};
},
computed: {
...mapGetters(["showCommunityScripts"]),
title() {
return this.editing ? "Edit Alert Template" : "Add Alert Template";
},
editing() {
return !!this.alertTemplate;
},
},
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({
];
const actionTypeOptions = [
{ label: "Script", value: "script" },
{ label: "Server", value: "server" },
{ label: "Rest Action", value: "rest" },
];
const stepper = ref<QStepper | null>(null);
function toggleAddEmail() {
$q.dialog({
title: "Add email",
prompt: {
model: "",
isValid: (val) => this.isValidEmail(val),
isValid: (val) => isValidEmail(val),
type: "email",
},
cancel: true,
ok: { label: "Add", color: "primary" },
persistent: false,
})
.onOk((data) => {
this.template.email_recipients.push(data);
}).onOk((data) => {
template.email_recipients.push(data);
});
},
toggleAddSMSNumber() {
this.$q
.dialog({
}
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>",
@@ -831,71 +900,60 @@ export default {
cancel: true,
ok: { label: "Add", color: "primary" },
persistent: false,
})
.onOk((data) => {
this.template.text_recipients.push(data);
}).onOk((data: string) => {
template.text_recipients.push(data);
});
},
removeEmail(email) {
const removed = this.template.email_recipients.filter((k) => k !== email);
this.template.email_recipients = removed;
},
removeSMSNumber(num) {
const removed = this.template.text_recipients.filter((k) => k !== num);
this.template.text_recipients = removed;
},
onSubmit() {
if (!this.template.name) {
this.notifyError("Name needs to be set");
}
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() {
if (!template.name) {
notifyError("Name needs to be set");
return;
}
this.$q.loading.show();
loading.value = true;
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();
});
// add properties from script dropdown composable before submitting
template.action = failureScriptDropdown.script;
template.action_args = failureScriptDropdown.defaultArgs;
template.action_env_vars = failureScriptDropdown.defaultEnvVars;
template.action_timeout = failureScriptDropdown.defaultTimeout;
template.resolved_action = resolvedScriptDropdown.script;
template.resolved_action_args = resolvedScriptDropdown.defaultArgs;
template.resolved_action_env_vars = resolvedScriptDropdown.defaultEnvVars;
template.resolved_action_timeout = resolvedScriptDropdown.defaultTimeout;
console.log(template);
if (props.alertTemplate) {
try {
await saveAlertTemplate(template.id, template);
notifySuccess("Alert Template edited!");
onDialogOK();
} catch {
} finally {
loading.value = false;
}
},
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))
);
// Copy alertTemplate prop locally
if (this.editing) Object.assign(this.template, this.alertTemplate);
},
};
} else {
try {
await addAlertTemplate(template);
notifySuccess("Alert Template edited!");
onDialogOK();
} catch {
} finally {
loading.value = false;
}
}
}
</script>

View File

@@ -0,0 +1,161 @@
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card class="dialog-plugin" style="min-width: 60vw">
<q-bar>
Run a script on Server
<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>
<tactical-dropdown
:rules="[(val: number) => !!val || '*Required']"
v-model="script"
:options="serverScriptOptions"
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>
<tactical-dropdown
v-model="state.args"
label="Script Arguments (press Enter after typing each argument)"
filled
use-input
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
/>
</q-card-section>
<q-card-section>
<tactical-dropdown
v-model="state.env_vars"
:label="envVarsLabel"
filled
use-input
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
/>
</q-card-section>
<q-card-section>
<q-input
v-model.number="state.timeout"
dense
outlined
type="number"
style="max-width: 150px"
label="Timeout (seconds)"
stack-label
:rules="[
(val) => !!val || '*Required',
(val) => val >= 5 || 'Minimum is 5 seconds',
]"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn label="Cancel" v-close-popup />
<q-btn
@click="runScript"
:loading="loading"
:disabled="loading"
label="Run"
color="primary"
/>
</q-card-actions>
<q-card-section
v-if="ret !== null"
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
style="max-height: 50vh"
>
<pre>{{ ret }}</pre>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { ref, reactive } from "vue";
import { useDialogPluginComponent, openURL } from "quasar";
import { useScriptDropdown } from "@/composables/scripts";
import { runServerScript } from "@/api/core";
import { envVarsLabel } from "@/constants/constants";
import { formatScriptSyntax } from "@/utils/format";
import { notifyError } from "@/utils/notify";
//ui imports
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
// emits
defineEmits([...useDialogPluginComponent.emits]);
// setup quasar dialog plugin
const { dialogRef, onDialogHide } = useDialogPluginComponent();
// setup dropdowns
const {
script,
serverScriptOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
syntax,
link,
} = useScriptDropdown({
onMount: true,
});
// main run script functionaity
const state = reactive({
args: defaultArgs,
env_vars: defaultEnvVars,
timeout: defaultTimeout,
});
const ret = ref<string | null>(null);
const loading = ref(false);
async function runScript() {
if (!script.value) {
notifyError("A script must be selected");
return;
}
ret.value = null;
loading.value = true;
try {
ret.value = await runServerScript(script.value, state);
} catch (e) {
console.error(e);
}
loading.value = false;
}
function openScriptURL() {
link.value ? openURL(link.value) : null;
}
</script>

View File

@@ -12,6 +12,7 @@
<q-tab name="urlactions" label="URL Actions" />
<q-tab name="retention" label="Retention" />
<q-tab name="apikeys" label="API Keys" />
<q-tab name="tasks" label="Server Tasks" />
<!-- <q-tab name="openai" label="Open AI" /> -->
</q-tabs>
</template>
@@ -603,6 +604,9 @@
</q-input>
</q-card-section>
</q-tab-panel> -->
<q-tab-panel name="tasks">
<ServerTasksTable />
</q-tab-panel>
</q-tab-panels>
</q-scroll-area>
<q-card-section class="row items-center">
@@ -647,6 +651,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 ServerTasksTable from "@/components/core/ServerTasksTable.vue";
export default {
name: "EditCoreSettings",
@@ -656,6 +661,7 @@ export default {
KeyStoreTable,
URLActionsTable,
APIKeysTable,
ServerTasksTable,
},
mixins: [mixins],
data() {

View File

@@ -1,14 +1,20 @@
<template>
<q-dialog ref="dialog" @hide="onHide">
<q-dialog
ref="dialogRef"
@hide="onDialogHide"
@show="loadEditor"
@before-hide="cleanupEditors"
>
<q-card class="q-dialog-plugin" style="width: 60vw">
<q-bar>
{{ title }}
{{ props.action ? "Edit URL Action" : "Add URL Action" }}
<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
@@ -30,6 +36,16 @@
/>
</q-card-section>
<!-- url action type -->
<q-card-section>
<q-option-group
v-model="localAction.action_type"
:options="URLActionOptions"
color="primary"
inline
/>
</q-card-section>
<!-- pattern -->
<q-card-section>
<q-input
@@ -41,89 +57,168 @@
/>
</q-card-section>
<q-card-section v-if="localAction.action_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="localAction.action_type === 'rest'">
<q-toolbar>
<q-space />
<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 flat label="Cancel" v-close-popup />
<q-btn flat label="Submit" color="primary" type="submit" />
<q-btn flat label="Submit" color="primary" @click="submit" />
</q-card-actions>
</q-form>
</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 } from "@/types/core/urlactions";
export default {
name: "URLActionsForm",
emits: ["hide", "ok", "cancel"],
mixins: [mixins],
props: { action: Object },
data() {
return {
localAction: {
import * as monaco from "monaco-editor";
// define emits
defineEmits([...useDialogPluginComponent.emits]);
// define props
const props = defineProps<{ action?: URLAction }>();
// setup quasar
const $q = useQuasar();
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// static data
const URLActionOptions = [
{ value: "web", label: "Web" },
{ value: "rest", label: "REST" },
];
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: "",
},
};
},
computed: {
title() {
return this.editing ? "Edit URL Action" : "Add URL Action";
},
editing() {
return !!this.action;
},
},
methods: {
submit() {
this.$q.loading.show();
action_type: "web",
rest_body: "{\n \n}",
rest_method: "get",
rest_headers: "{\n \n}",
} as URLAction);
let data = {
...this.localAction,
};
const disableBodyTab = computed(() =>
["get", "delete"].includes(localAction.rest_method),
);
const tab = ref(disableBodyTab.value ? "headers" : "body");
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();
});
watch(
() => localAction.rest_method,
() => {
disableBodyTab.value ? (tab.value = "headers") : undefined;
},
);
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,
);
// watch tab change and change model
watch(tab, (newValue) => {
if (newValue === "body") {
editor.setModel(modelBody);
} else if (newValue === "headers") {
editor.setModel(modelHeaders);
}
},
show() {
this.$refs.dialog.show();
},
hide() {
this.$refs.dialog.hide();
},
onHide() {
this.$emit("hide");
},
onOk() {
this.$emit("ok");
this.hide();
},
},
mounted() {
// If pk prop is set that means we are editing
if (this.action) Object.assign(this.localAction, this.action);
},
};
});
function loadEditor() {
const theme = $q.dark.isActive ? "vs-dark" : "vs-light";
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editor = monaco.editor.create(editorDiv.value!, {
model: modelBody,
theme: theme,
automaticLayout: true,
minimap: { enabled: false },
quickSuggestions: false,
});
editor.onDidChangeModelContent(() => {
const currentModel = editor.getModel();
if (currentModel) {
if (currentModel?.uri === modelBodyUri) {
localAction.rest_body = currentModel.getValue();
} else {
localAction.rest_headers = currentModel.getValue();
}
}
});
}
function cleanupEditors() {
modelBody.dispose();
modelHeaders.dispose();
editor.dispose();
}
</script>

View File

@@ -9,7 +9,7 @@
icon="fas fa-plus"
text-color="black"
label="Add URL Action"
@click="addAction"
@click="addURLAction"
/>
</div>
<q-separator />
@@ -17,7 +17,7 @@
dense
:rows="actions"
:columns="columns"
v-model:pagination="pagination"
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: true }"
row-key="id"
binary-state-sort
hide-pagination
@@ -30,18 +30,22 @@
<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>
@@ -63,6 +67,10 @@
<q-td>
{{ props.row.desc }}
</q-td>
<!-- action type -->
<q-td>
{{ props.row.action_type }}
</q-td>
<!-- pattern -->
<q-td>
{{ props.row.pattern }}
@@ -73,22 +81,25 @@
</div>
</template>
<script>
import URLActionsForm from "@/components/modals/coresettings/URLActionsForm.vue";
import mixins from "@/mixins/mixins";
<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";
export default {
name: "URLActionTable",
mixins: [mixins],
data() {
return {
actions: [],
pagination: {
rowsPerPage: 0,
sortBy: "name",
descending: true,
},
columns: [
// ui imports
import URLActionsForm from "@/components/modals/coresettings/URLActionsForm.vue";
// types
import { type URLAction } from "@/types/core/urlactions";
// setup quasar
const $q = useQuasar();
const actions = ref([]);
const columns: QTableColumn[] = [
{
name: "name",
label: "Name",
@@ -96,6 +107,13 @@ export default {
align: "left",
sortable: true,
},
{
name: "action_type",
label: "Type",
field: "action_type",
align: "left",
sortable: true,
},
{
name: "desc",
label: "Description",
@@ -110,68 +128,52 @@ export default {
align: "left",
sortable: true,
},
],
};
},
methods: {
getURLActions() {
this.$q.loading.show();
];
this.$axios
.get("/core/urlaction/")
.then((r) => {
this.$q.loading.hide();
this.actions = r.data;
})
.catch(() => {
this.$q.loading.hide();
});
},
addAction() {
this.$q
.dialog({
async function getURLActions() {
$q.loading.show();
try {
const result = await fetchURLActions();
actions.value = result;
} catch (e) {
console.error(e);
}
$q.loading.hide();
}
function addURLAction() {
$q.dialog({
component: URLActionsForm,
})
.onOk(() => {
this.getURLActions();
});
},
editAction(action) {
this.$q
.dialog({
}).onOk(getURLActions);
}
function editURLAction(action: URLAction) {
$q.dialog({
component: URLActionsForm,
componentProps: {
action: action,
},
})
.onOk(() => {
this.getURLActions();
});
},
deleteAction(action) {
this.$q
.dialog({
}).onOk(getURLActions);
}
function deleteURLAction(action: URLAction) {
$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();
}).onOk(async () => {
$q.loading.show();
try {
await removeURLAction(action.id);
await getURLActions();
notifySuccess(`URL Action: ${action.name} was deleted!`);
} catch (e) {
console.error(e);
}
$q.loading.hide();
});
});
},
},
mounted() {
this.getURLActions();
},
};
}
onMounted(getURLActions);
</script>

View File

@@ -4,7 +4,7 @@
<q-bar>
{{
task
? `Editing Automated Task: ${task.name}`
? `Editing Automated Task: ${localTask.name}`
: "Adding Automated Task"
}}
<q-space />
@@ -29,32 +29,32 @@
:rules="[(val) => !!val || '*Required']"
filled
dense
v-model="state.name"
v-model="localTask.name"
label="Descriptive name of task"
hide-bottom-space
/>
</q-card-section>
<q-card-section>
<q-card-section v-if="type !== 'server'">
<q-checkbox
dense
label="Collector Task"
v-model="collector"
class="q-pb-sm"
@update:model-value="
state.custom_field = null;
state.collector_all_output = false;
localTask.custom_field = null;
localTask.collector_all_output = false;
"
/>
<tactical-dropdown
v-if="collector"
:rules="[(val) => !!val || '*Required']"
v-model="state.custom_field"
:rules="[(val: number) => !!val || '*Required']"
v-model="localTask.custom_field"
:options="customFieldOptions"
label="Custom Field to update"
filled
mapOptions
:hint="
state.collector_all_output
localTask.collector_all_output
? 'All script output will be saved to custom field selected'
: 'The last line of script output will be saved to custom field selected'
"
@@ -64,18 +64,18 @@
v-if="collector"
dense
label="Save all output"
v-model="state.collector_all_output"
v-model="localTask.collector_all_output"
class="q-py-sm"
/>
</q-card-section>
<q-card-section>
<tactical-dropdown
v-model="state.alert_severity"
v-model="localTask.alert_severity"
:options="severityOptions"
label="Alert Severity"
filled
mapOptions
:rules="[(val) => !!val || '*Required']"
:rules="[(val: string) => !!val || '*Required']"
/>
</q-card-section>
</q-form>
@@ -106,7 +106,13 @@
class="col-3"
label="Select script"
v-model="script"
:options="scriptOptions"
:options="
type === 'policy'
? scriptOptions
: type === 'server'
? filterByPlatformOptions('linux')
: filterByPlatformOptions(plat)
"
filled
mapOptions
filterable
@@ -170,7 +176,7 @@
label="Timeout (seconds)"
/>
<q-option-group
v-if="actionType === 'cmd'"
v-if="actionType === 'cmd' && type !== 'server'"
class="col-2 q-pl-sm"
inline
v-model="shell"
@@ -195,7 +201,7 @@
<q-checkbox
class="float-right"
label="Continue on Errors"
v-model="state.continue_on_error"
v-model="localTask.continue_on_error"
dense
>
<q-tooltip>Continue task if an action fails</q-tooltip>
@@ -206,7 +212,7 @@
class="q-list"
handle=".handle"
ghost-class="ghost"
v-model="state.actions"
v-model="localTask.actions"
item-key="index"
>
<template v-slot:item="{ index, element }">
@@ -238,6 +244,7 @@
<q-icon size="sm" name="terminal" color="primary" />
&nbsp;
<q-icon
v-if="type !== 'server'"
size="sm"
:name="
element.shell === 'cmd'
@@ -246,6 +253,12 @@
"
color="primary"
/>
<q-icon
v-else
size="sm"
name="mdi-bash"
color="primary"
/>
{{ element.command }}
</q-item-label>
<q-item-label caption>
@@ -270,14 +283,26 @@
<q-step :name="3" title="Choose Schedule" :error="!isValidStep3">
<div class="scroll" style="height: 60vh; max-height: 60vh">
<q-form @submit.prevent ref="taskDetailForm">
<q-card-section>
<q-option-group
v-model="state.task_type"
label="Task run type"
:options="taskTypeOptions"
<!-- form for server and linux agents -->
<q-card-section v-if="type === 'server'">
<q-input
v-model="localTask.crontab_schedule"
label="Crontab Schedule"
dense
inline
@update:model-value="$refs.taskDetailForm.resetValidation()"
hint="The schedule portion of a crontab entry. i.e. * * * 1 *. Leave blank for manual"
/>
</q-card-section>
<!-- form for policy and windows agents-->
<div v-else>
<q-card-section>
<q-option-group
v-model="localTask.task_type"
label="Task run type"
:options="agentTaskTypeOptions"
dense
inline
@update:model-value="taskDetailForm?.resetValidation()"
/>
</q-card-section>
@@ -285,7 +310,7 @@
<q-card-section
v-if="
['runonce', 'daily', 'weekly', 'monthly'].includes(
state.task_type,
localTask.task_type,
)
"
class="row"
@@ -298,7 +323,7 @@
label="Start time"
stack-label
filled
v-model="state.run_time_date"
v-model="localTask.run_time_date"
hint="Agent timezone will be used"
:rules="[(val) => !!val || '*Required']"
/>
@@ -311,29 +336,32 @@
stack-label
label="Expires on"
filled
v-model="state.expire_date"
v-model="localTask.expire_date"
hint="Agent timezone will be used"
/>
</q-card-section>
<q-card-section
v-if="
state.task_type === 'onboarding' ||
state.task_type === 'runonce'
localTask.task_type === 'onboarding' ||
localTask.task_type === 'runonce'
"
class="row"
>
<span v-if="state.task_type === 'onboarding'"
<span v-if="localTask.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'"
<span v-else-if="localTask.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">
<q-card-section
v-if="localTask.task_type === 'daily'"
class="row"
>
<!-- daily interval -->
<q-input
:rules="[
@@ -345,7 +373,7 @@
dense
type="number"
label="Run every"
v-model.number="state.daily_interval"
v-model.number="localTask.daily_interval"
filled
class="col-6 q-pa-sm"
>
@@ -357,7 +385,10 @@
</q-card-section>
<!-- weekly options -->
<q-card-section v-if="state.task_type === 'weekly'" class="row">
<q-card-section
v-if="localTask.task_type === 'weekly'"
class="row"
>
<!-- weekly interval -->
<q-input
:rules="[
@@ -369,7 +400,7 @@
class="col-6 q-pa-sm"
dense
label="Run every"
v-model="state.weekly_interval"
v-model="localTask.weekly_interval"
filled
>
<template v-slot:append>
@@ -383,18 +414,23 @@
<!-- day of week input -->
Run on Days:
<q-option-group
:rules="[(val) => val.length > 0 || '*Required']"
:rules="[
(val: number[]) => val.length > 0 || '*Required',
]"
inline
dense
:options="dayOfWeekOptions"
type="checkbox"
v-model="state.run_time_bit_weekdays"
v-model="localTask.run_time_bit_weekdays"
/>
</div>
</q-card-section>
<!-- monthly options -->
<q-card-section v-if="state.task_type === 'monthly'" class="row">
<q-card-section
v-if="localTask.task_type === 'monthly'"
class="row"
>
<!-- type of monthly schedule -->
<q-option-group
class="col-12 q-pa-sm"
@@ -413,7 +449,7 @@
filled
dense
options-dense
v-model="state.monthly_months_of_year"
v-model="localTask.monthly_months_of_year"
:options="monthOptions"
label="Run on Months"
multiple
@@ -464,7 +500,7 @@
filled
dense
options-dense
v-model="state.monthly_days_of_month"
v-model="localTask.monthly_days_of_month"
:options="dayOfMonthOptions"
label="Run on Days"
multiple
@@ -517,7 +553,7 @@
filled
dense
options-dense
v-model="state.monthly_weeks_of_month"
v-model="localTask.monthly_weeks_of_month"
:options="weekOptions"
label="Run on weeks"
multiple
@@ -550,7 +586,7 @@
filled
dense
options-dense
v-model="state.run_time_bit_weekdays"
v-model="localTask.run_time_bit_weekdays"
:options="dayOfWeekOptions"
label="Run on days"
multiple
@@ -596,9 +632,9 @@
<q-card-section
v-if="
state.task_type !== 'checkfailure' &&
state.task_type !== 'manual' &&
state.task_type !== 'onboarding'
localTask.task_type !== 'checkfailure' &&
localTask.task_type !== 'manual' &&
localTask.task_type !== 'onboarding'
"
class="row"
>
@@ -608,7 +644,7 @@
dense
label="Repeat task every"
filled
v-model="state.task_repetition_interval"
v-model="localTask.task_repetition_interval"
placeholder="e.g. 30m (30 minutes) or 1h (1 hour)"
lazy-rules
:rules="[
@@ -620,33 +656,34 @@
/>
<q-input
:disable="!state.task_repetition_interval"
:disable="!localTask.task_repetition_interval"
class="col-6 q-pa-sm"
dense
label="Task repeat duration"
filled
v-model="state.task_repetition_duration"
v-model="localTask.task_repetition_duration"
placeholder="e.g. 6h (6 hours) or 1d (1 day)"
lazy-rules
:rules="[
(val) =>
(val: string) =>
validateTimePeriod(val) ||
'Valid values are 1-3 digits followed by (D|d|H|h|M|m|S|s)',
(val) => (state.task_repetition_interval ? !!val : true), // field is required if repetition interval is set
(val) =>
(val: string) =>
localTask.task_repetition_interval ? !!val : true, // field is required if repetition interval is set
(val: string) =>
convertPeriodToSeconds(val) >=
convertPeriodToSeconds(
state.task_repetition_interval,
localTask?.task_repetition_interval,
) ||
'Repetition duration must be greater than repetition interval',
]"
/>
<q-checkbox
:disable="!state.task_repetition_interval"
:disable="!localTask.task_repetition_interval"
class="col-6 q-pa-sm"
dense
v-model="state.stop_task_at_duration_end"
v-model="localTask.stop_task_at_duration_end"
label="Stop all tasks at the end of duration"
/>
<div class="col-6"></div>
@@ -656,7 +693,7 @@
dense
label="Random task delay"
filled
v-model="state.random_task_delay"
v-model="localTask.random_task_delay"
placeholder="e.g. 2m (2 minutes) or 1h (1 hour)"
lazy-rules
:rules="[
@@ -668,20 +705,20 @@
/>
<div class="col-6"></div>
<q-checkbox
:disable="!state.expire_date"
:disable="!localTask.expire_date"
class="col-6 q-pa-sm"
dense
v-model="state.remove_if_not_scheduled"
v-model="localTask.remove_if_not_scheduled"
label="Delete task if not scheduled for 30 days"
>
<q-tooltip>Must set an expire date</q-tooltip>
</q-checkbox>
<div class="col-6"></div>
<q-checkbox
:disable="state.task_type === 'runonce'"
:disable="localTask.task_type === 'runonce'"
class="col-6 q-pa-sm"
dense
v-model="state.run_asap_after_missed"
v-model="localTask.run_asap_after_missed"
label="Run task ASAP after a scheduled start is missed"
/>
@@ -691,7 +728,7 @@
class="col-6 q-pa-sm"
label="Task instance policy"
:options="taskInstancePolicyOptions"
v-model="state.task_instance_policy"
v-model="localTask.task_instance_policy"
filled
mapOptions
/>
@@ -699,13 +736,13 @@
<!-- check failure options -->
<q-card-section
v-else-if="state.task_type === 'checkfailure'"
v-else-if="localTask.task_type === 'checkfailure'"
class="row"
>
<tactical-dropdown
class="col-6 q-pa-sm"
:rules="[(val) => !!val || '*Required']"
v-model="state.assigned_check"
:rules="[(val: number) => !!val || '*Required']"
v-model="localTask.assigned_check"
filled
:options="checkOptions"
label="Select Check"
@@ -713,6 +750,7 @@
filterable
/>
</q-card-section>
</div>
</q-form>
</div>
</q-step>
@@ -722,18 +760,13 @@
<q-btn
v-if="step > 1"
label="Back"
@click="$refs.stepper.previous()"
@click="stepper?.previous()"
color="primary"
flat
/>
<q-btn
v-if="step < 3"
@click="
validateStep(
step === 1 ? $refs.taskGeneralForm : undefined,
$refs.stepper,
)
"
@click="validateStep(step === 1 ? taskGeneralForm : null, stepper)"
color="primary"
label="Next"
flat
@@ -742,7 +775,7 @@
v-else
:label="task ? 'Edit Task' : 'Add Task'"
color="primary"
@click="validateStep($refs.taskDetailForm, $refs.stepper)"
@click="validateStep(taskDetailForm, stepper)"
:loading="loading"
flat
dense
@@ -753,10 +786,10 @@
</q-dialog>
</template>
<script>
<script setup lang="ts">
// composition imports
import { ref, watch, onMounted } from "vue";
import { useDialogPluginComponent } from "quasar";
import { ref, watch, onMounted, reactive } from "vue";
import { QForm, QStepper, useDialogPluginComponent } from "quasar";
import draggable from "vuedraggable";
import { saveTask, updateTask } from "@/api/tasks";
import { useScriptDropdown } from "@/composables/scripts";
@@ -770,11 +803,21 @@ import {
convertToBitArray,
convertFromBitArray,
formatDateInputField,
copyObjectWithoutKeys,
} from "@/utils/format";
// ui imports
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
// type
import type {
AutomatedTask,
AutomatedTaskAction,
AutomatedTaskForDB,
AutomatedTaskCommandActionShellType,
} from "@/types/tasks";
import type { AgentPlatformType } from "@/types/agents";
// static data
const severityOptions = [
{ label: "Informational", value: "info" },
@@ -782,7 +825,7 @@ const severityOptions = [
{ label: "Error", value: "error" },
];
const taskTypeOptions = [
const agentTaskTypeOptions = [
{ label: "Daily", value: "daily" },
{ label: "Weekly", value: "weekly" },
{ label: "Monthly", value: "monthly" },
@@ -843,98 +886,108 @@ const taskInstancePolicyOptions = [
{ label: "Stop Existing", value: 3 },
];
export default {
components: { TacticalDropdown, draggable },
name: "AddAutomatedTask",
emits: [...useDialogPluginComponent.emits],
props: {
parent: Object, // parent policy or agent for task
task: Object, // only for editing
},
setup(props) {
// setup quasar dialog
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// emits
defineEmits([...useDialogPluginComponent.emits]);
// setup dropdowns
const {
// props
const props = defineProps<{
parent?: number | string; // parent policy or agent for task
type: "agent" | "policy" | "server";
task?: AutomatedTaskForDB; // only for editing
plat?: AgentPlatformType; // filters scripts options base on plat
}>();
// setup quasar dialog
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
// setup dropdowns
const {
script,
scriptOptions,
filterByPlatformOptions,
defaultTimeout,
defaultArgs,
defaultEnvVars,
} = useScriptDropdown(undefined, {
scriptName,
} = useScriptDropdown({
onMount: true,
});
});
// set defaultTimeout to 30
defaultTimeout.value = 30;
// set defaultTimeout to 30
defaultTimeout.value = 30;
const { checkOptions, getCheckOptions } = useCheckDropdown();
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
const { checkOptions, getCheckOptions } = useCheckDropdown();
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
// add task logic
const task = props.task
? ref(Object.assign({}, props.task))
: ref({
...props.parent,
actions: [],
assigned_check: null,
custom_field: null,
name: null,
expire_date: null,
// add task logic
const localTask: AutomatedTask = props.task
? reactive(Object.assign({}, processTaskDatafromDB(props.task)))
: (reactive({
id: 0,
policy: props.type === "policy" ? props.parent : undefined,
agent: props.type === "agent" ? props.parent : undefined,
server_task: props.type === "server",
crontab_schedule: "",
actions: [] as AutomatedTaskAction[],
assigned_check: undefined,
custom_field: undefined,
name: "",
expire_date: undefined,
run_time_date: formatDateInputField(Date.now()),
run_time_bit_weekdays: [],
run_time_bit_weekdays: [] as number[],
weekly_interval: 1,
daily_interval: 1,
monthly_months_of_year: [],
monthly_days_of_month: [],
monthly_weeks_of_month: [],
monthly_months_of_year: [] as number[],
monthly_days_of_month: [] as number[],
monthly_weeks_of_month: [] as number[],
task_instance_policy: 0,
task_repetition_interval: null,
task_repetition_duration: null,
task_repetition_interval: undefined,
task_repetition_duration: undefined,
stop_task_at_duration_end: false,
random_task_delay: null,
random_task_delay: undefined,
remove_if_not_scheduled: false,
run_asap_after_missed: true,
task_type: "daily",
alert_severity: "info",
collector_all_output: false,
continue_on_error: true,
});
}) as AutomatedTask);
const actionType = ref("script");
const command = ref("");
const shell = ref("cmd");
const monthlyType = ref("days");
const collector = ref(false);
const loading = ref(false);
const actionType = ref("script");
const command = ref("");
const shell = ref<AutomatedTaskCommandActionShellType>(
props.type !== "server" ? "cmd" : "bash",
);
const monthlyType = ref("days");
const collector = ref(false);
const loading = ref(false);
// before-options check boxes that will select all options
// before-options check boxes that will select all options
// if all months is selected or cleared it will either clear the monthly_months_of_year array or add all options to it.
const allMonthsCheckbox = ref(false);
function toggleMonths() {
task.value.monthly_months_of_year = allMonthsCheckbox.value
// if all months is selected or cleared it will either clear the monthly_months_of_year array or add all options to it.
const allMonthsCheckbox = ref(false);
function toggleMonths() {
localTask.monthly_months_of_year = allMonthsCheckbox.value
? monthOptions.map((month) => month.value)
: [];
}
}
const allMonthDaysCheckbox = ref(false);
function toggleMonthDays() {
task.value.monthly_days_of_month = allMonthDaysCheckbox.value
const allMonthDaysCheckbox = ref(false);
function toggleMonthDays() {
localTask.monthly_days_of_month = allMonthDaysCheckbox.value
? dayOfMonthOptions.map((day) => day.value)
: [];
}
}
const allWeekDaysCheckbox = ref(false);
function toggleWeekDays() {
task.value.run_time_bit_weekdays = allWeekDaysCheckbox.value
const allWeekDaysCheckbox = ref(false);
function toggleWeekDays() {
localTask.run_time_bit_weekdays = allWeekDaysCheckbox.value
? dayOfWeekOptions.map((day) => day.value)
: [];
}
}
// function for adding script and commands to be run from task
function addAction() {
// function for adding script and commands to be run from task
function addAction() {
if (
actionType.value === "script" &&
(!script.value || !defaultTimeout.value)
@@ -950,18 +1003,17 @@ export default {
}
if (actionType.value === "script") {
task.value.actions.push({
if (script.value)
localTask.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,
env_vars: defaultEnvVars.value,
});
} else if (actionType.value === "cmd") {
task.value.actions.push({
localTask.actions.push({
type: "cmd",
command: command.value,
shell: shell.value,
@@ -970,140 +1022,158 @@ export default {
}
// clear fields after add
script.value = null;
script.value = undefined;
defaultArgs.value = [];
defaultEnvVars.value = [];
defaultTimeout.value = 30;
command.value = "";
}
}
function removeAction(index) {
task.value.actions.splice(index, 1);
}
// runs whenever task data is saved
function processTaskDataforDB(taskData) {
// copy data
let data = Object.assign({}, taskData);
function removeAction(index: number) {
localTask.actions.splice(index, 1);
}
// runs whenever task data is saved
function processTaskDataforDB(taskData: AutomatedTask): AutomatedTaskForDB {
// converts fields from arrays to integers
data.run_time_bit_weekdays =
const run_time_bit_weekdays =
taskData.run_time_bit_weekdays.length > 0
? convertFromBitArray(taskData.run_time_bit_weekdays)
: null;
: undefined;
data.monthly_months_of_year =
const monthly_months_of_year =
taskData.monthly_months_of_year.length > 0
? convertFromBitArray(taskData.monthly_months_of_year)
: null;
: undefined;
data.monthly_days_of_month =
const monthly_days_of_month =
taskData.monthly_days_of_month.length > 0
? convertFromBitArray(taskData.monthly_days_of_month)
: null;
: undefined;
data.monthly_weeks_of_month =
const monthly_weeks_of_month =
taskData.monthly_weeks_of_month.length > 0
? convertFromBitArray(taskData.monthly_weeks_of_month)
: null;
: undefined;
let data = {
...taskData,
...{
run_time_bit_weekdays,
monthly_months_of_year,
monthly_days_of_month,
monthly_weeks_of_month,
},
} as AutomatedTaskForDB;
// Add Z back to run_time_date and expires_date
data.run_time_date += "Z";
if (!taskData.server_task) data.run_time_date += "Z";
if (taskData.expire_date) data.expire_date += "Z";
// change task type if monthly day of week is set
if (task.value.task_type === "monthly" && monthlyType.value === "weeks") {
if (taskData.task_type === "monthly" && monthlyType.value === "weeks") {
data.task_type = "monthlydow";
}
return data;
}
}
// runs when editing a task to convert values to be compatible with quasar
function processTaskDatafromDB(task: AutomatedTaskForDB) {
const convertedTask = copyObjectWithoutKeys(task, [
"run_time_bit_weekdays",
"monthly_months_of_year",
"monthly_days_of_month",
"monthly_weeks_of_month",
]) as AutomatedTask;
// runs when editing a task to convert values to be compatible with quasar
function processTaskDatafromDB() {
// converts fields from integers to arrays
task.value.run_time_bit_weekdays = task.value.run_time_bit_weekdays
? convertToBitArray(task.value.run_time_bit_weekdays)
convertedTask.run_time_bit_weekdays = task.run_time_bit_weekdays
? convertToBitArray(task.run_time_bit_weekdays)
: [];
task.value.monthly_months_of_year = task.value.monthly_months_of_year
? convertToBitArray(task.value.monthly_months_of_year)
convertedTask.monthly_months_of_year = task.monthly_months_of_year
? convertToBitArray(task.monthly_months_of_year)
: [];
task.value.monthly_days_of_month = task.value.monthly_days_of_month
? convertToBitArray(task.value.monthly_days_of_month)
convertedTask.monthly_days_of_month = task.monthly_days_of_month
? convertToBitArray(task.monthly_days_of_month)
: [];
task.value.monthly_weeks_of_month = task.value.monthly_weeks_of_month
? convertToBitArray(task.value.monthly_weeks_of_month)
convertedTask.monthly_weeks_of_month = task.monthly_weeks_of_month
? convertToBitArray(task.monthly_weeks_of_month)
: [];
// remove milliseconds and Z to work with native date input
task.value.run_time_date = formatDateInputField(
task.value.run_time_date,
if (!task.server_task)
convertedTask.run_time_date = formatDateInputField(
convertedTask.run_time_date,
true,
);
if (task.value.expire_date)
task.value.expire_date = formatDateInputField(
task.value.expire_date,
if (convertedTask.expire_date)
convertedTask.expire_date = formatDateInputField(
convertedTask.expire_date,
true,
);
// set task type if monthlydow is being used
if (task.value.task_type === "monthlydow") {
task.value.task_type = "monthly";
if (task.task_type === "monthlydow") {
convertedTask.task_type = "monthly";
monthlyType.value = "weeks";
}
}
async function submit() {
return convertedTask;
}
async function submit() {
loading.value = true;
try {
const result = props.task
? await updateTask(task.value.id, processTaskDataforDB(task.value))
: await saveTask(processTaskDataforDB(task.value));
? await updateTask(localTask.id, processTaskDataforDB(localTask))
: await saveTask(processTaskDataforDB(localTask));
notifySuccess(result);
onDialogOK();
} catch (e) {
console.error(e);
}
loading.value = false;
}
}
// format task data to match what quasar expects if editing
if (props.task) processTaskDatafromDB();
watch(
() => task.value.task_type,
watch(
() => localTask.task_type,
() => {
task.value.assigned_check = null;
task.value.run_time_bit_weekdays = [];
task.value.remove_if_not_scheduled = false;
task.value.task_repetition_interval = null;
task.value.task_repetition_duration = null;
task.value.stop_task_at_duration_end = false;
task.value.random_task_delay = null;
task.value.weekly_interval = 1;
task.value.daily_interval = 1;
task.value.monthly_months_of_year = [];
task.value.monthly_days_of_month = [];
task.value.monthly_weeks_of_month = [];
task.value.task_instance_policy = 0;
task.value.expire_date = null;
localTask.assigned_check = undefined;
localTask.run_time_bit_weekdays = [];
localTask.remove_if_not_scheduled = false;
localTask.task_repetition_interval = undefined;
localTask.task_repetition_duration = undefined;
localTask.stop_task_at_duration_end = false;
localTask.random_task_delay = undefined;
localTask.weekly_interval = 1;
localTask.daily_interval = 1;
localTask.monthly_months_of_year = [];
localTask.monthly_days_of_month = [];
localTask.monthly_weeks_of_month = [];
localTask.task_instance_policy = 0;
localTask.expire_date = undefined;
},
);
);
// check the collector box when editing task and custom field is set
if (props.task && props.task.custom_field) collector.value = true;
// check the collector box when editing task and custom field is set
if (props.task && props.task.custom_field) collector.value = true;
// stepper logic
const step = ref(1);
const isValidStep1 = ref(true);
const isValidStep2 = ref(true);
const isValidStep3 = ref(true);
// stepper logic
const taskGeneralForm = ref<InstanceType<typeof QForm> | null>(null);
const taskDetailForm = ref<InstanceType<typeof QForm> | null>(null);
const stepper = ref<InstanceType<typeof QStepper> | null>(null);
const step = ref(1);
const isValidStep1 = ref(true);
const isValidStep2 = ref(true);
const isValidStep3 = ref(true);
function validateStep(form: QForm | null, stepper: QStepper | null) {
if (!stepper) return;
function validateStep(form, stepper) {
if (step.value === 2) {
if (task.value.actions.length > 0) {
if (localTask.actions.length > 0) {
isValidStep2.value = true;
stepper.next();
return;
@@ -1113,7 +1183,7 @@ export default {
// steps 1 or 3
} else {
form.validate().then((result) => {
form?.validate().then((result: boolean) => {
if (step.value === 1) {
isValidStep1.value = result;
if (result) stepper.next();
@@ -1123,63 +1193,11 @@ export default {
}
});
}
}
}
onMounted(() => {
getCheckOptions(props.parent);
});
return {
// reactive data
state: task,
script,
defaultTimeout,
defaultArgs,
defaultEnvVars,
actionType,
command,
shell,
allMonthsCheckbox,
allMonthDaysCheckbox,
allWeekDaysCheckbox,
collector,
monthlyType,
loading,
step,
isValidStep1,
isValidStep2,
isValidStep3,
scriptOptions,
checkOptions,
customFieldOptions,
// non-reactive data
validateTimePeriod,
convertPeriodToSeconds,
severityOptions,
dayOfWeekOptions,
dayOfMonthOptions,
weekOptions,
monthOptions,
taskTypeOptions,
taskInstancePolicyOptions,
envVarsLabel,
// methods
submit,
validateStep,
addAction,
removeAction,
toggleMonths,
toggleMonthDays,
toggleWeekDays,
// quasar dialog
dialogRef,
onDialogHide,
};
},
};
onMounted(() => {
if (props.type !== "server") getCheckOptions({ [props.type]: props.parent });
});
</script>
<style scoped>

View File

@@ -1,5 +1,5 @@
import { computed, ref } from "vue";
import { useStore } from "vuex";
import { ref } from "vue";
import { useDashboardStore } from "@/stores/dashboard";
import { fetchAgents } from "@/api/agents";
import { formatAgentOptions } from "@/utils/format";
@@ -13,7 +13,7 @@ export function useAgentDropdown() {
async function getAgentOptions(flat = false) {
agentOptions.value = formatAgentOptions(
await fetchAgents({ detail: false }),
flat
flat,
);
}
@@ -29,12 +29,11 @@ export function useAgentDropdown() {
}
export function cmdPlaceholder(shell) {
const store = useStore();
const placeholders = computed(() => store.state.run_cmd_placeholder_text);
const store = useDashboardStore();
if (shell === "cmd") return placeholders.value.cmd;
else if (shell === "powershell") return placeholders.value.powershell;
else return placeholders.value.shell;
if (shell === "cmd") return store.runCmdPlaceholders.cmd;
else if (shell === "powershell") return store.runCmdPlaceholders.powershell;
else return store.runCmdPlaceholders.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,
};
}

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

@@ -0,0 +1,73 @@
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 UseCustomFieldDropdownParams {
onMount?: boolean;
}
export function useCustomFieldDropdown(opts: UseCustomFieldDropdownParams) {
const customFieldOptions = ref([] as CustomField[]);
// 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 URLAction[]);
// type can be "client", "site", or "agent"
async function getURLActionOptions(flat = false) {
const params = {};
urlActionOptions.value = formatURLActionOptions(
await fetchURLActions(params),
flat,
);
}
const restActionOptions = computed(() =>
urlActionOptions.value.filter((action) => action.action_type === "rest"),
);
if (opts.onMount) onMounted(getURLActionOptions);
return {
urlActionOptions,
restActionOptions,
//methods
getURLActionOptions,
};
}

View File

@@ -1,70 +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" },
{ label: "Nushell", value: "nushell" },
{ label: "Deno", value: "deno" },
];

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

@@ -0,0 +1,134 @@
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
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 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 })),
) 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.name;
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"),
),
),
);
const filterByPlatformOptions = (plat: AgentPlatformType | undefined) => {
if (!plat) {
return scriptOptions.value;
}
return removeExtraOptionCategories(
scriptOptions.value.filter(
(script) =>
script.category ||
!script.supported_platforms ||
script.supported_platforms.length === 0 ||
script.supported_platforms.includes(plat),
),
);
};
function reset() {
defaultTimeout.value = 30;
defaultArgs.value = [];
defaultEnvVars.value = [];
script.value = undefined;
syntax.value = "";
link.value = "";
}
if (opts.onMount)
onMounted(() => getScriptOptions(showCommunityScripts.value));
return {
//data
script,
defaultTimeout,
defaultArgs,
defaultEnvVars,
scriptName,
syntax,
link,
scriptOptions, // unfiltered options
serverScriptOptions, //only scripts that can run on server
//methods
getScriptOptions,
filterByPlatformOptions,
reset,
};
}
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

@@ -84,7 +84,14 @@
checked-icon="nights_stay"
unchecked-icon="wb_sunny"
/>
<q-btn
label=">_"
dense
flat
@click="openTrmmCli"
class="q-mr-sm"
style="font-size: 16px"
/>
<!-- Devices Chip -->
<q-chip class="cursor-pointer">
<q-avatar size="md" icon="devices" color="primary" />
@@ -148,7 +155,7 @@
<AlertsIcon />
<q-btn-dropdown flat no-caps stretch :label="user">
<q-btn-dropdown flat no-caps stretch :label="username || ''">
<q-list>
<q-item
clickable
@@ -200,29 +207,38 @@
</q-page-container>
</q-layout>
</template>
<script>
<script setup lang="ts">
// composition imports
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { computed, onMounted } from "vue";
import { useQuasar } from "quasar";
import { useStore } from "vuex";
import axios from "axios";
import { getWSUrl } from "@/websocket/channels";
import { useDashboardStore } from "@/stores/dashboard";
import { useAuthStore } from "@/stores/auth";
import { storeToRefs } from "pinia";
import { resetTwoFactor } from "@/api/accounts";
import { notifySuccess } from "@/utils/notify";
import axios from "axios";
// ui imports
import AlertsIcon from "@/components/AlertsIcon.vue";
import UserPreferences from "@/components/modals/coresettings/UserPreferences.vue";
import ResetPass from "@/components/accounts/ResetPass.vue";
import TRMMCommandPrompt from "@/components/core/TRMMCommandPrompt.vue";
export default {
name: "MainLayout",
components: { AlertsIcon },
setup() {
const store = useStore();
const $q = useQuasar();
const store = useStore();
const $q = useQuasar();
const darkMode = computed({
const {
serverCount,
serverOfflineCount,
workstationCount,
workstationOfflineCount,
daysUntilCertExpires,
} = storeToRefs(useDashboardStore());
const { username } = storeToRefs(useAuthStore());
const darkMode = computed({
get: () => {
return $q.dark.isActive;
},
@@ -230,36 +246,41 @@ export default {
axios.patch("/accounts/users/ui/", { dark_mode: value });
$q.dark.set(value);
},
});
});
const currentTRMMVersion = computed(() => store.state.currentTRMMVersion);
const latestTRMMVersion = computed(() => store.state.latestTRMMVersion);
const needRefresh = computed(() => store.state.needrefresh);
const user = computed(() => store.state.username);
const hosted = computed(() => store.state.hosted);
const tokenExpired = computed(() => store.state.tokenExpired);
const dash_warning_color = computed(() => store.state.dash_warning_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const currentTRMMVersion = computed(() => store.state.currentTRMMVersion);
const latestTRMMVersion = computed(() => store.state.latestTRMMVersion);
const needRefresh = computed(() => store.state.needrefresh);
const hosted = computed(() => store.state.hosted);
const tokenExpired = computed(() => store.state.tokenExpired);
const dash_warning_color = computed(() => store.state.dash_warning_color);
const dash_negative_color = computed(() => store.state.dash_negative_color);
const latestReleaseURL = computed(() => {
const latestReleaseURL = computed(() => {
return latestTRMMVersion.value
? `https://github.com/amidaware/tacticalrmm/releases/tag/v${latestTRMMVersion.value}`
: "";
});
});
function showUserPreferences() {
function showUserPreferences() {
$q.dialog({
component: UserPreferences,
}).onOk(() => store.dispatch("getDashInfo"));
}
}
function resetPassword() {
function resetPassword() {
$q.dialog({
component: ResetPass,
});
}
}
function reset2FA() {
function openTrmmCli() {
$q.dialog({
component: TRMMCommandPrompt,
});
}
function reset2FA() {
$q.dialog({
title: "Reset 2FA",
message: "Are you sure you would like to reset your 2FA token?",
@@ -271,70 +292,9 @@ export default {
notifySuccess(ret, 3000);
} catch {}
});
}
}
const serverCount = ref(0);
const serverOfflineCount = ref(0);
const workstationCount = ref(0);
const workstationOfflineCount = ref(0);
const daysUntilCertExpires = ref(100);
const ws = ref(null);
function setupWS() {
// moved computed token inside the function since it is not refreshing
// when ws is closed causing ws to connect with expired token
const token = computed(() => store.state.token);
if (!token.value) {
console.log(
"Access token is null or invalid, not setting up WebSocket",
);
return;
}
console.log("Starting websocket");
let url = getWSUrl("dashinfo", token.value);
ws.value = new WebSocket(url);
ws.value.onopen = () => {
console.log("Connected to ws");
};
ws.value.onmessage = (e) => {
const data = JSON.parse(e.data);
serverCount.value = data.total_server_count;
serverOfflineCount.value = data.total_server_offline_count;
workstationCount.value = data.total_workstation_count;
workstationOfflineCount.value = data.total_workstation_offline_count;
daysUntilCertExpires.value = data.days_until_cert_expires;
};
ws.value.onclose = (e) => {
try {
console.log(`Closed code: ${e.code}`);
console.log("Retrying websocket connection...");
setTimeout(() => {
setupWS();
}, 3 * 1000);
} catch (e) {
console.log("Websocket connection closed");
}
};
ws.value.onerror = () => {
console.log("There was an error");
ws.value.onclose();
};
}
const poll = ref(null);
function livePoll() {
poll.value = setInterval(
() => {
store.dispatch("checkVer");
store.dispatch("getDashInfo", false);
},
60 * 4 * 1000,
);
}
const updateAvailable = computed(() => {
const updateAvailable = computed(() => {
if (
latestTRMMVersion.value === "error" ||
hosted.value ||
@@ -342,45 +302,10 @@ export default {
)
return false;
return currentTRMMVersion.value !== latestTRMMVersion.value;
});
});
onMounted(() => {
setupWS();
onMounted(() => {
store.dispatch("getDashInfo");
store.dispatch("checkVer");
livePoll();
});
onBeforeUnmount(() => {
ws.value.close();
clearInterval(poll.value);
});
return {
// reactive data
serverCount,
serverOfflineCount,
workstationCount,
workstationOfflineCount,
daysUntilCertExpires,
latestReleaseURL,
currentTRMMVersion,
latestTRMMVersion,
user,
needRefresh,
darkMode,
hosted,
tokenExpired,
dash_warning_color,
dash_negative_color,
// methods
showUserPreferences,
resetPassword,
reset2FA,
updateAvailable,
};
},
};
});
</script>

View File

@@ -4,6 +4,8 @@ import {
createWebHistory,
createWebHashHistory,
} from "vue-router";
import { useAuthStore } from "@/stores/auth";
import routes from "./routes";
// useful for importing router outside of vue components
@@ -13,7 +15,7 @@ export const router = new createRouter({
history: createWebHistory(process.env.VUE_ROUTER_BASE),
});
export default function ({ store }) {
export default function (/* { store } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: process.env.VUE_ROUTER_MODE === "history"
@@ -24,13 +26,15 @@ export default function ({ store }) {
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
history: createHistory(
process.env.MODE === "ssr" ? void 0 : process.env.VUE_ROUTER_BASE
process.env.MODE === "ssr" ? void 0 : process.env.VUE_ROUTER_BASE,
),
});
Router.beforeEach((to, from, next) => {
const auth = useAuthStore();
if (to.meta.requireAuth) {
if (!store.getters.loggedIn) {
if (!auth.loggedIn) {
next({
name: "Login",
});
@@ -38,7 +42,7 @@ export default function ({ store }) {
next();
}
} else if (to.meta.requiresVisitor) {
if (store.getters.loggedIn) {
if (auth.loggedIn) {
next({
name: "Dashboard",
});

View File

@@ -7,8 +7,6 @@ export default function () {
const Store = new createStore({
state() {
return {
username: localStorage.getItem("user_name") || null,
token: localStorage.getItem("access_token") || null,
tree: [],
agents: [],
treeReady: false,
@@ -49,9 +47,6 @@ export default function () {
clientTreeSplitterModel(state) {
return state.clientTreeSplitter;
},
loggedIn(state) {
return state.token !== null;
},
selectedAgentId(state) {
return state.selectedRow;
},
@@ -76,14 +71,6 @@ export default function () {
setAgentPlatform(state, agentPlatform) {
state.agentPlatform = agentPlatform;
},
retrieveToken(state, { token, username }) {
state.token = token;
state.username = username;
},
destroyCommit(state) {
state.token = null;
state.username = null;
},
loadTree(state, treebar) {
state.tree = treebar;
state.treeReady = true;
@@ -213,7 +200,7 @@ export default function () {
}
try {
const { data } = await axios.get(
`/agents/${localParams ? localParams : ""}`
`/agents/${localParams ? localParams : ""}`,
);
commit("setAgents", data);
} catch (e) {
@@ -232,7 +219,7 @@ export default function () {
LoadingBar.setDefaults({ color: data.loading_bar_color });
commit(
"setClearSearchWhenSwitching",
data.clear_search_when_switching
data.clear_search_when_switching,
);
commit("SET_DEFAULT_AGENT_TBL_TAB", data.default_agent_tbl_tab);
commit("SET_CLIENT_TREE_SORT", data.client_tree_sort);
@@ -307,15 +294,15 @@ export default function () {
}
const sorted = output.sort((a, b) =>
a.label.localeCompare(b.label)
a.label.localeCompare(b.label),
);
if (state.clientTreeSort === "alphafail") {
// move failing clients to the top
const failing = sorted.filter(
(i) => i.color === "negative" || i.color === "warning"
(i) => i.color === "negative" || i.color === "warning",
);
const ok = sorted.filter(
(i) => i.color !== "negative" && i.color !== "warning"
(i) => i.color !== "negative" && i.color !== "warning",
);
const sortedByFailing = [...failing, ...ok];
commit("loadTree", sortedByFailing);
@@ -349,37 +336,6 @@ export default function () {
localStorage.removeItem("rmmver");
location.reload();
},
retrieveToken(context, credentials) {
return new Promise((resolve) => {
axios.post("/login/", credentials).then((response) => {
const token = response.data.token;
const username = credentials.username;
localStorage.setItem("access_token", token);
localStorage.setItem("user_name", username);
context.commit("retrieveToken", { token, username });
resolve(response);
});
});
},
destroyToken(context) {
if (context.getters.loggedIn) {
return new Promise((resolve) => {
axios
.post("/logout/")
.then((response) => {
localStorage.removeItem("access_token");
localStorage.removeItem("user_name");
context.commit("destroyCommit");
resolve(response);
})
.catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("user_name");
context.commit("destroyCommit");
});
});
}
},
},
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";

61
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,61 @@
import { defineStore } from "pinia";
import { useStorage } from "@vueuse/core";
import axios from "axios";
interface CheckCredentialsRequest {
username: string;
password: string;
}
interface LoginRequest {
username: string;
password: string;
twofactor: string;
}
interface CheckCredentialsResponse {
token: string;
username: string;
totp?: boolean;
}
export const useAuthStore = defineStore("auth", {
state: () => ({
username: useStorage("username", null),
token: useStorage("token", null),
}),
getters: {
loggedIn: (state) => {
return state.token !== null;
},
},
actions: {
async checkCredentials(
credentials: CheckCredentialsRequest,
): Promise<CheckCredentialsResponse> {
const { data } = await axios.post("/checkcreds/", credentials);
if (!data.totp) {
this.token = data.token;
this.username = data.username;
}
return data;
},
async login(credentials: LoginRequest) {
const { data } = await axios.post("/login/", credentials);
this.username = data.username;
this.token = data.token;
return data;
},
async logout() {
if (this.token !== null) {
try {
await axios.post("/logout/");
} catch {}
}
this.token = null;
this.username = null;
},
},
});

44
src/stores/dashboard.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineStore } from "pinia";
import { ref, watch } from "vue";
import { useDashWSConnection } from "@/websocket/websocket";
export interface WSAgentCount {
total_server_count: number;
total_server_offline_count: number;
total_workstation_count: number;
total_workstation_offline_count: number;
days_until_cert_expires: number;
}
export const useDashboardStore = defineStore("dashboard", () => {
// updated by dashboard.agentcount event
const serverCount = ref(0);
const serverOfflineCount = ref(0);
const workstationCount = ref(0);
const workstationOfflineCount = ref(0);
const daysUntilCertExpires = ref(180);
const { data } = useDashWSConnection();
// watch for data ws data
watch(data, (newValue) => {
if (newValue.action === "dashboard.agentcount") {
const incomingData = newValue.data as WSAgentCount;
serverCount.value = incomingData.total_server_count;
serverOfflineCount.value = incomingData.total_server_offline_count;
workstationCount.value = incomingData.total_workstation_count;
workstationOfflineCount.value =
incomingData.total_workstation_offline_count;
daysUntilCertExpires.value = incomingData.days_until_cert_expires;
}
});
return {
serverCount,
serverOfflineCount,
workstationCount,
workstationOfflineCount,
daysUntilCertExpires,
};
});

4
src/types/accounts.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface User {
id: number;
username: string;
}

View File

@@ -1 +1,12 @@
export type AgentPlatformType = "windows" | "linux" | "darwin";
export type AgentTab = "mixed" | "server" | "workstation";
export interface Agent {
id: number;
agent_id: string;
hostname: string;
client: string;
site: string;
plat: AgentPlatformType;
monitoring_type: AgentTab;
}

49
src/types/alerts.ts Normal file
View File

@@ -0,0 +1,49 @@
export type AlertSeverity = "error" | "warning" | "info";
export type ActionType = "script" | "server" | "rest";
export interface AlertTemplate {
id: number;
name: string;
is_active: boolean;
action_type: ActionType;
action?: number;
action_rest?: number;
action_args: string[];
action_env_vars: string[];
action_timeout: number;
resolved_action_type: ActionType;
resolved_action?: number;
resolved_action_rest?: number;
resolved_action_args: string[];
resolved_action_env_vars: string[];
resolved_action_timeout: number;
email_recipients: string[];
email_from: string;
text_recipients: string[];
agent_email_on_resolved: boolean;
agent_text_on_resolved: boolean;
agent_always_email: boolean | null;
agent_always_text: boolean | null;
agent_always_alert: boolean | null;
agent_periodic_alert_days: number;
agent_script_actions: boolean;
check_email_alert_severity: AlertSeverity[];
check_text_alert_severity: AlertSeverity[];
check_dashboard_alert_severity: AlertSeverity[];
check_email_on_resolved: boolean;
check_text_on_resolved: boolean;
check_always_email: boolean | null;
check_always_text: boolean | null;
check_always_alert: boolean | null;
check_periodic_alert_days: number;
check_script_actions: boolean;
task_email_alert_severity: AlertSeverity[];
task_text_alert_severity: AlertSeverity[];
task_dashboard_alert_severity: AlertSeverity[];
task_email_on_resolved: boolean;
task_text_on_resolved: boolean;
task_always_email: boolean | null;
task_always_text: boolean | null;
task_always_alert: boolean | null;
task_periodic_alert_days: number;
task_script_actions: boolean;
}

3
src/types/automation.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface Policy {
id: number;
}

3
src/types/checks.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface Check {
id: number;
}

15
src/types/clients.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface Client {
id: number;
name: string;
}
export interface ClientWithSites {
id: number;
name: string;
sites: Site[];
}
export interface Site {
id: number;
name: string;
}

View File

@@ -0,0 +1,12 @@
export interface CustomField {
id: number;
model: "agent" | "client" | "site";
name: string;
type: string;
required: boolean;
default_value: string | boolean | number | string[];
}
export interface CustomFieldValue {
[x: string]: string | boolean | number | string[];
}

View File

@@ -0,0 +1,17 @@
export type ActionType = "web" | "rest";
export interface URLAction {
id: number;
name: string;
desc?: string;
action_type: ActionType;
pattern: string;
rest_method: string;
rest_body: string;
rest_headers: string;
}
export interface URLActionRunResponse {
url: string;
result: string;
}

View File

@@ -15,6 +15,11 @@ export interface Script {
env_vars: string[];
script_body: string;
supported_platforms?: AgentPlatformType[];
guid?: string;
script_type: "userdefined" | "builtin";
favorite: boolean;
hidden: boolean;
filename?: string;
}
export interface ScriptSnippet {

134
src/types/tasks.ts Normal file
View File

@@ -0,0 +1,134 @@
import { type CustomField } from "@/types/core/customfields";
import { type AlertSeverity } from "@/types/alerts";
export interface TaskResult {
task: number;
agent?: number;
retcode: number;
stdout: string;
stderr: string;
execution_time: number;
last_run: string;
status: string;
sync_status: string;
}
export type AutomatedTaskCommandActionShellType = "powershell" | "cmd" | "bash";
export interface AutomatedTaskScriptAction {
type: "script";
name: string;
script: number;
timeout: number;
script_args?: string[];
env_vars?: string[];
}
export interface AutomatedTaskCommandAction {
type: "cmd";
command: string;
timeout: number;
shell: AutomatedTaskCommandActionShellType;
}
export type AutomatedTaskAction =
| AutomatedTaskCommandAction
| AutomatedTaskScriptAction;
export type AgentTaskType =
| "daily"
| "weekly"
| "monthly"
| "runonce"
| "checkfailure"
| "onboarding"
| "manual"
| "monthlydow";
export type ServerTaskType = "daily" | "weekly" | "monthly" | "runonce";
export interface AutomatedTaskBase {
id: number;
custom_field?: CustomField;
actions: AutomatedTaskAction[];
assigned_check?: number;
name: string;
collector_all_output: boolean;
continue_on_error: boolean;
alert_severity: AlertSeverity;
email_alert?: boolean;
text_alert?: boolean;
dashboard_alert?: boolean;
win_task_name?: string;
run_time_date: string;
expire_date?: string;
daily_interval?: number;
weekly_interval?: number;
task_repetition_duration?: string;
task_repetition_interval?: string;
stop_task_at_duration_end?: boolean;
random_task_delay?: string;
remove_if_not_scheduled?: boolean;
run_asap_after_missed?: boolean;
task_instance_policy?: number;
crontab_schedule?: string;
task_result?: TaskResult;
}
export interface AutomatedTaskForUIBase extends AutomatedTaskBase {
run_time_bit_weekdays: number[];
monthly_days_of_month: number[];
monthly_months_of_year: number[];
monthly_weeks_of_month: number[];
}
export interface AutomatedTaskPolicy extends AutomatedTaskForUIBase {
policy: number;
task_type: AgentTaskType;
server_task: false;
}
export interface AutomatedTaskAgent extends AutomatedTaskForUIBase {
agent: number;
task_type: AgentTaskType;
server_task: false;
}
export interface AutomatedTaskServer extends AutomatedTaskForUIBase {
task_type: ServerTaskType;
server_task: true;
}
export type AutomatedTask =
| AutomatedTaskAgent
| AutomatedTaskPolicy
| AutomatedTaskServer;
export interface AutomatedTaskForDBBase extends AutomatedTaskBase {
run_time_bit_weekdays: number;
monthly_days_of_month: number;
monthly_months_of_year: number;
monthly_weeks_of_month: number;
}
export interface AutomatedTaskPolicyForDB extends AutomatedTaskForDBBase {
policy: number;
task_type: AgentTaskType;
server_task: false;
}
export interface AutomatedTaskAgentForDB extends AutomatedTaskForDBBase {
agent: number;
task_type: AgentTaskType;
server_task: false;
}
export interface AutomatedTaskServerForDB extends AutomatedTaskForDBBase {
task_type: ServerTaskType;
server_task: true;
}
export type AutomatedTaskForDB =
| AutomatedTaskAgentForDB
| AutomatedTaskPolicyForDB
| AutomatedTaskServerForDB;

10
src/types/typings.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module "*.png" {
const content: string;
export default content;
}
declare module "*?worker" {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content: any;
export default content;
}

View File

@@ -1,390 +0,0 @@
import { date } from "quasar";
import { validateTimePeriod } from "@/utils/validation";
import trmmLogo from "@/assets/trmm_256.png";
// dropdown options formatting
export function removeExtraOptionCategories(array) {
let tmp = [];
// loop through options and if two categories are next to each other remove the top one
for (let i = 0; i < array.length; i++) {
if (i === array.length - 1) {
// check if last item is not a category and add it
if (!array[i].category) tmp.push(array[i]);
} else if (!(array[i].category && array[i + 1].category)) {
tmp.push(array[i]);
}
}
return tmp;
}
function _formatOptions(
data,
{
label,
value = "id",
flat = false,
allowDuplicates = true,
appendToOptionObject = {},
},
) {
if (!flat)
// returns array of options in object format [{label: label, value: 1}]
return data.map((i) => ({
label: i[label],
value: i[value],
...appendToOptionObject,
}));
// returns options as an array of strings ["label", "label1"]
else if (!allowDuplicates) return data.map((i) => i[label]);
else {
const options = [];
data.forEach((i) => {
if (!options.includes(i[label])) options.push(i[label]);
});
return options;
}
}
export function formatScriptOptions(data) {
let options = [];
let categories = [];
let create_unassigned = false;
data.forEach((script) => {
if (!!script.category && !categories.includes(script.category)) {
categories.push(script.category);
} else if (!script.category) {
create_unassigned = true;
}
});
if (create_unassigned) categories.push("Unassigned");
categories.sort().forEach((cat) => {
options.push({ category: cat });
let tmp = [];
data.forEach((script) => {
if (script.category === cat) {
tmp.push({
img_right: script.script_type === "builtin" ? trmmLogo : undefined,
label: script.name,
value: script.id,
timeout: script.default_timeout,
args: script.args,
env_vars: script.env_vars,
filename: script.filename,
syntax: script.syntax,
script_type: script.script_type,
shell: script.shell,
supported_platforms: script.supported_platforms,
});
} else if (cat === "Unassigned" && !script.category) {
tmp.push({
label: script.name,
value: script.id,
timeout: script.default_timeout,
args: script.args,
env_vars: script.env_vars,
filename: script.filename,
syntax: script.syntax,
script_type: script.script_type,
shell: script.shell,
supported_platforms: script.supported_platforms,
});
}
});
const sorted = tmp.sort((a, b) => a.label.localeCompare(b.label));
options.push(...sorted);
});
return options;
}
export function formatAgentOptions(
data,
flat = false,
value_field = "agent_id",
) {
if (flat) {
// returns just agent hostnames in array
return _formatOptions(data, {
label: "hostname",
value: value_field,
flat: true,
allowDuplicates: false,
});
} else {
// returns options with categories in object format
let options = [];
const agents = data.map((agent) => ({
label: agent.hostname,
value: agent[value_field],
cat: `${agent.client} > ${agent.site}`,
}));
let categories = [];
agents.forEach((option) => {
if (!categories.includes(option.cat)) {
categories.push(option.cat);
}
});
categories.sort().forEach((cat) => {
options.push({ category: cat });
let tmp = [];
agents.forEach((agent) => {
if (agent.cat === cat) {
tmp.push(agent);
}
});
const sorted = tmp.sort((a, b) => a.label.localeCompare(b.label));
options.push(...sorted);
});
return options;
}
}
export function formatCustomFieldOptions(data, flat = false) {
if (flat) {
return _formatOptions(data, { label: "name", flat: true });
} else {
const categories = ["Client", "Site", "Agent"];
const options = [];
categories.forEach((cat) => {
options.push({ category: cat });
const tmp = [];
data.forEach((custom_field) => {
if (custom_field.model === cat.toLowerCase()) {
tmp.push({
label: custom_field.name,
value: custom_field.id,
cat: cat,
});
}
});
const sorted = tmp.sort((a, b) => a.label.localeCompare(b.label));
options.push(...sorted);
});
return options;
}
}
export function formatClientOptions(data, flat = false) {
return _formatOptions(data, { label: "name", flat: flat });
}
export function formatSiteOptions(data, flat = false) {
const options = [];
data.forEach((client) => {
options.push({ category: client.name });
options.push(
..._formatOptions(client.sites, {
label: "name",
flat: flat,
appendToOptionObject: { cat: client.name },
}),
);
});
return options;
}
export function formatUserOptions(data, flat = false) {
return _formatOptions(data, { label: "username", flat: flat });
}
export function formatCheckOptions(data, flat = false) {
return _formatOptions(data, { label: "readable_desc", flat: flat });
}
export function formatCustomFields(fields, values) {
let tempArray = [];
for (let field of fields) {
if (field.type === "multiple") {
tempArray.push({ multiple_value: values[field.name], field: field.id });
} else if (field.type === "checkbox") {
tempArray.push({ bool_value: values[field.name], field: field.id });
} else {
tempArray.push({ string_value: values[field.name], field: field.id });
}
}
return tempArray;
}
export function formatScriptSyntax(syntax) {
let temp = syntax;
temp = temp.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
temp = temp
.replaceAll("&lt;", '<span style="color:#d4d4d4">&lt;</span>')
.replaceAll("&gt;", '<span style="color:#d4d4d4">&gt;</span>');
temp = temp
.replaceAll("[", '<span style="color:#ffd70a">[</span>')
.replaceAll("]", '<span style="color:#ffd70a">]</span>');
temp = temp
.replaceAll("(", '<span style="color:#87cefa">(</span>')
.replaceAll(")", '<span style="color:#87cefa">)</span>');
temp = temp
.replaceAll("{", '<span style="color:#c586b6">{</span>')
.replaceAll("}", '<span style="color:#c586b6">}</span>');
temp = temp.replaceAll("\n", "<br />");
return temp;
}
// date formatting
export function getTimeLapse(unixtime) {
if (date.inferDateFormat(unixtime) === "string") {
unixtime = date.formatDate(unixtime, "X");
}
var previous = unixtime * 1000;
var current = new Date();
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
var msPerMonth = msPerDay * 30;
var msPerYear = msPerDay * 365;
var elapsed = current - previous;
if (elapsed < msPerMinute) {
return Math.round(elapsed / 1000) + " seconds ago";
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + " minutes ago";
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + " hours ago";
} else if (elapsed < msPerMonth) {
return Math.round(elapsed / msPerDay) + " days ago";
} else if (elapsed < msPerYear) {
return Math.round(elapsed / msPerMonth) + " months ago";
} else {
return Math.round(elapsed / msPerYear) + " years ago";
}
}
export function formatDate(dateString, format = "MMM-DD-YYYY HH:mm") {
if (!dateString) return "";
return date.formatDate(dateString, format);
}
export function getNextAgentUpdateTime() {
const d = new Date();
let ret;
if (d.getMinutes() <= 35) {
ret = d.setMinutes(35);
} else {
ret = date.addToDate(d, { hours: 1 });
ret.setMinutes(35);
}
const a = date.formatDate(ret, "MMM D, YYYY");
const b = date.formatDate(ret, "h:mm A");
return `${a} at ${b}`;
}
// converts a date with timezone to local for html native datetime fields -> YYYY-MM-DD HH:mm:ss
export function formatDateInputField(isoDateString, noTimezone = false) {
if (noTimezone) {
isoDateString = isoDateString.replace("Z", "");
}
return date.formatDate(isoDateString, "YYYY-MM-DDTHH:mm");
}
// converts a local date string "YYYY-MM-DDTHH:mm:ss" to an iso date string with the local timezone
export function formatDateStringwithTimezone(localDateString) {
return date.formatDate(localDateString, "YYYY-MM-DDTHH:mm:ssZ");
}
// string formatting
export function capitalize(string) {
return string[0].toUpperCase() + string.substring(1);
}
export function formatTableColumnText(text) {
let string = "";
// split at underscore if exists
const words = text.split("_");
words.forEach((word) => (string = string + " " + capitalize(word)));
return string.trim();
}
export function truncateText(txt, chars) {
if (!txt) return;
return txt.length >= chars ? txt.substring(0, chars) + "..." : txt;
}
export function bytes2Human(bytes) {
if (bytes == 0) return "0B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
export function convertMemoryToPercent(percent, memory) {
const mb = memory * 1024;
return Math.ceil((percent * mb) / 100).toLocaleString();
}
// convert time period(str) to seconds(int) (3h -> 10800) used for comparing time intervals
export function convertPeriodToSeconds(period) {
if (!validateTimePeriod(period)) {
console.error("Time Period is invalid");
return NaN;
}
if (period.toUpperCase().includes("S"))
// remove last letter from string and return since already in seconds
return parseInt(period.slice(0, -1));
else if (period.toUpperCase().includes("M"))
// remove last letter from string and multiple by 60 to get seconds
return parseInt(period.slice(0, -1)) * 60;
else if (period.toUpperCase().includes("H"))
// remove last letter from string and multiple by 60 twice to get seconds
return parseInt(period.slice(0, -1)) * 60 * 60;
else if (period.toUpperCase().includes("D"))
// remove last letter from string and multiply by 24 and 60 twice to get seconds
return parseInt(period.slice(0, -1)) * 24 * 60 * 60;
}
// takes an integer and converts it to an array in binary format. i.e: 13 -> [8, 4, 1]
// Needed to work with multi-select fields in tasks form
export function convertToBitArray(number) {
let bitArray = [];
let binary = number.toString(2);
for (let i = 0; i < binary.length; ++i) {
if (binary[i] !== "0") {
// last binary digit
if (binary.slice(i).length === 1) {
bitArray.push(1);
} else {
bitArray.push(
parseInt(binary.slice(i), 2) - parseInt(binary.slice(i + 1), 2),
);
}
}
}
return bitArray;
}
// takes an array of integers and adds them together
export function convertFromBitArray(array) {
let result = 0;
for (let i = 0; i < array.length; i++) {
result += array[i];
}
return result;
}
export function convertCamelCase(str) {
return str
.replace(/[^a-zA-Z0-9]+/g, " ")
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index == 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, "");
}

472
src/utils/format.ts Normal file
View File

@@ -0,0 +1,472 @@
import { date } from "quasar";
import { validateTimePeriod } from "@/utils/validation";
import trmmLogo from "@/assets/trmm_256.png";
import type { Script } from "@/types/scripts";
import type { Agent } from "@/types/agents";
import type { Client, ClientWithSites } from "@/types/clients";
import type { User } from "@/types/accounts";
import type { Check } from "@/types/checks";
import { CustomField, CustomFieldValue } from "@/types/core/customfields";
import { URLAction } from "@/types/core/urlactions";
// dropdown options formatting
export interface SelectOptionCategory {
category: string;
}
export interface OptionWithoutCategory {
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any;
}
export type Option = SelectOptionCategory | OptionWithoutCategory | string;
export function removeExtraOptionCategories(array: Option[]) {
const tmp: Option[] = [];
for (let i = 0; i < array.length; i++) {
const currentOption = array[i];
const nextOption = array[i + 1];
// Determine if current and next options are categories
const isCurrentCategory =
typeof currentOption === "object" && "category" in currentOption;
const isNextCategory =
typeof nextOption === "object" && "category" in nextOption;
if (i === array.length - 1) {
// Always add the last item if it's not a category
if (!isCurrentCategory) {
tmp.push(currentOption);
}
} else if (!(isCurrentCategory && isNextCategory)) {
// Add the current option if it's not followed by a category option
tmp.push(currentOption);
}
}
return tmp;
}
interface FormatOptionsParams {
label: string; // Key to use for the label
value?: string; // Key to use for the value, defaults to "id"
flat?: boolean; // Whether to return a flat array of strings
allowDuplicates?: boolean; // Whether to allow duplicate labels
// eslint-disable-next-line @typescript-eslint/no-explicit-any
appendToOptionObject?: { [key: string]: any }; // Additional properties to append to each option object
copyPropertiesList?: string[]; // List of properties to copy from the original objects
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function _formatOptions<T extends { [key: string]: any }>(
data: T[],
{
label,
value = "id",
flat = false,
allowDuplicates = true,
appendToOptionObject = {},
copyPropertiesList = [],
}: FormatOptionsParams,
): Option[] | string[] {
if (!flat) {
return data.map((item) => {
const option: Partial<Option> = {
label: item[label],
value: item[value],
...appendToOptionObject,
};
copyPropertiesList.forEach((prop) => {
if (Object.hasOwn(item, prop)) {
option[prop] = item[prop];
}
});
return option as Option;
});
} else {
const labels = data.map((item) => item[label]);
return allowDuplicates ? labels : [...new Set(labels)];
}
}
export function formatScriptOptions(data: Script[]): Option[] {
const categoryMap = new Map<string, Script[]>();
let hasUnassigned = false;
data.forEach((script) => {
const category = script.category || "Unassigned";
if (!script.category) hasUnassigned = true;
if (!categoryMap.has(category)) {
categoryMap.set(category, []);
}
categoryMap.get(category)!.push(script);
});
const categories = Array.from(categoryMap.keys());
if (hasUnassigned) {
// Ensure "Unassigned" is the last category
const index = categories.indexOf("Unassigned");
categories.splice(index, 1);
categories.push("Unassigned");
}
categories.sort(); // Sort categories alphabetically
const options: Option[] = [];
categories.forEach((cat) => {
options.push({ category: cat, label: cat, value: cat }); // Assuming you want the category itself as an option
const scripts = categoryMap
.get(cat)!
.sort((a, b) => a.name.localeCompare(b.name));
scripts.forEach((script) => {
const option: Option = {
img_right: script.script_type === "builtin" ? trmmLogo : undefined,
label: script.name,
value: script.id,
default_timeout: script.default_timeout,
args: script.args,
env_vars: script.env_vars,
filename: script.filename,
syntax: script.syntax,
script_type: script.script_type,
shell: script.shell,
supported_platforms: script.supported_platforms,
};
options.push(option);
});
});
return options;
}
export function formatAgentOptions(
data: Agent[],
flat = false,
value_field: keyof Agent = "agent_id",
): Option[] | string[] {
if (flat) {
// Returns just agent hostnames in an array
return _formatOptions(data, {
label: "hostname",
value: value_field as string,
flat: true,
allowDuplicates: false,
});
} else {
// Returns options with categories in object format
const options: Option[] = [];
const agents = data.map((agent) => ({
label: agent.hostname,
value: agent[value_field] as string,
cat: `${agent.client} > ${agent.site}`,
}));
const categories = [...new Set(agents.map((agent) => agent.cat))].sort();
categories.forEach((cat) => {
options.push({ category: cat, label: cat, value: cat });
const agentsInCategory = agents.filter((agent) => agent.cat === cat);
const sortedAgents = agentsInCategory.sort((a, b) =>
a.label.localeCompare(b.label),
);
options.push(
...sortedAgents.map(({ label, value }) => ({ label, value })),
);
});
return options;
}
}
export function formatCustomFieldOptions(
data: CustomField[],
flat = false,
): Option[] {
if (flat) {
// For a flat list, simply format the options based on the "name" property
return _formatOptions(data, { label: "name", flat: true });
} else {
// Predefined categories for organizing the custom fields
const categories = ["Client", "Site", "Agent"];
const options: Option[] = [];
categories.forEach((cat) => {
// Add a category header as an option
options.push({ category: cat, label: cat, value: cat }); // Assuming you want the category itself as an option
// Filter and map the custom fields that match the current category
const matchingFields = data
.filter((custom_field) => custom_field.model === cat.toLowerCase())
.map((custom_field) => ({
label: custom_field.name,
value: custom_field.id,
category: cat, // Include the category in each option for clarity or further processing
}));
// Sort the filtered custom fields by their labels and add them to the options
const sortedFields = matchingFields.sort((a, b) =>
a.label.localeCompare(b.label),
);
options.push(...sortedFields);
});
return options;
}
}
export function formatClientOptions(data: Client[], flat = false) {
return _formatOptions(data, { label: "name", flat: flat });
}
export function formatSiteOptions(data: ClientWithSites[], flat = false) {
const options = [] as Option[];
data.forEach((client) => {
options.push({ category: client.name });
options.push(
..._formatOptions(client.sites, {
label: "name",
flat: flat,
appendToOptionObject: { cat: client.name },
}),
);
});
return options;
}
export function formatUserOptions(data: User[], flat = false) {
return _formatOptions(data, { label: "username", flat: flat });
}
export function formatCheckOptions(data: Check[], flat = false) {
return _formatOptions(data, { label: "readable_desc", flat: flat });
}
export function formatURLActionOptions(data: URLAction[], flat = false) {
return _formatOptions(data, {
label: "name",
flat: flat,
copyPropertiesList: ["action_type"],
});
}
export function formatCustomFields(
fields: CustomField[],
values: CustomFieldValue,
) {
const tempArray = [];
for (const field of fields) {
if (field.type === "multiple") {
tempArray.push({ multiple_value: values[field.name], field: field.id });
} else if (field.type === "checkbox") {
tempArray.push({ bool_value: values[field.name], field: field.id });
} else {
tempArray.push({ string_value: values[field.name], field: field.id });
}
}
return tempArray;
}
export function formatScriptSyntax(syntax: string) {
let temp = syntax;
temp = temp.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
temp = temp
.replaceAll("&lt;", '<span style="color:#d4d4d4">&lt;</span>')
.replaceAll("&gt;", '<span style="color:#d4d4d4">&gt;</span>');
temp = temp
.replaceAll("[", '<span style="color:#ffd70a">[</span>')
.replaceAll("]", '<span style="color:#ffd70a">]</span>');
temp = temp
.replaceAll("(", '<span style="color:#87cefa">(</span>')
.replaceAll(")", '<span style="color:#87cefa">)</span>');
temp = temp
.replaceAll("{", '<span style="color:#c586b6">{</span>')
.replaceAll("}", '<span style="color:#c586b6">}</span>');
temp = temp.replaceAll("\n", "<br />");
return temp;
}
// date formatting
export function getTimeLapse(unixtime: number) {
if (date.inferDateFormat(unixtime) === "string") {
unixtime = parseInt(date.formatDate(unixtime, "X"));
}
const previous = unixtime * 1000;
const current = Date.now();
const msPerMinute = 60 * 1000;
const msPerHour = msPerMinute * 60;
const msPerDay = msPerHour * 24;
const msPerMonth = msPerDay * 30;
const msPerYear = msPerDay * 365;
const elapsed = current - previous;
if (elapsed < msPerMinute) {
return Math.round(elapsed / 1000) + " seconds ago";
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + " minutes ago";
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + " hours ago";
} else if (elapsed < msPerMonth) {
return Math.round(elapsed / msPerDay) + " days ago";
} else if (elapsed < msPerYear) {
return Math.round(elapsed / msPerMonth) + " months ago";
} else {
return Math.round(elapsed / msPerYear) + " years ago";
}
}
export function formatDate(
dateString: string | number | Date,
format = "MMM-DD-YYYY HH:mm",
) {
if (!dateString) return "";
return date.formatDate(dateString, format);
}
export function getNextAgentUpdateTime() {
const d = new Date();
let ret;
if (d.getMinutes() <= 35) {
ret = d.setMinutes(35);
} else {
ret = date.addToDate(d, { hours: 1 });
ret.setMinutes(35);
}
const a = date.formatDate(ret, "MMM D, YYYY");
const b = date.formatDate(ret, "h:mm A");
return `${a} at ${b}`;
}
// converts a date with timezone to local for html native datetime fields -> YYYY-MM-DD HH:mm:ss
export function formatDateInputField(
isoDateString: string | number,
noTimezone = false,
) {
if (noTimezone && typeof isoDateString === "string") {
isoDateString = isoDateString.replace("Z", "");
}
return date.formatDate(isoDateString, "YYYY-MM-DDTHH:mm");
}
// converts a local date string "YYYY-MM-DDTHH:mm:ss" to an iso date string with the local timezone
export function formatDateStringwithTimezone(localDateString: string) {
return date.formatDate(localDateString, "YYYY-MM-DDTHH:mm:ssZ");
}
// string formatting
export function capitalize(string: string) {
return string[0].toUpperCase() + string.substring(1);
}
export function formatTableColumnText(text: string) {
let string = "";
// split at underscore if exists
const words = text.split("_");
words.forEach((word) => (string = string + " " + capitalize(word)));
return string.trim();
}
export function truncateText(txt: string, chars: number) {
if (!txt) return;
return txt.length >= chars ? txt.substring(0, chars) + "..." : txt;
}
export function bytes2Human(bytes: number) {
if (bytes == 0) return "0B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
export function convertMemoryToPercent(percent: number, memory: number) {
const mb = memory * 1024;
return Math.ceil((percent * mb) / 100).toLocaleString();
}
// convert time period(str) to seconds(int) (3h -> 10800) used for comparing time intervals
export function convertPeriodToSeconds(period: string) {
if (!validateTimePeriod(period)) {
console.error("Time Period is invalid");
return 0;
}
if (period.toUpperCase().includes("S"))
// remove last letter from string and return since already in seconds
return parseInt(period.slice(0, -1));
else if (period.toUpperCase().includes("M"))
// remove last letter from string and multiple by 60 to get seconds
return parseInt(period.slice(0, -1)) * 60;
else if (period.toUpperCase().includes("H"))
// remove last letter from string and multiple by 60 twice to get seconds
return parseInt(period.slice(0, -1)) * 60 * 60;
else if (period.toUpperCase().includes("D"))
// remove last letter from string and multiply by 24 and 60 twice to get seconds
return parseInt(period.slice(0, -1)) * 24 * 60 * 60;
return 0;
}
// takes an integer and converts it to an array in binary format. i.e: 13 -> [8, 4, 1]
// Needed to work with multi-select fields in tasks form
export function convertToBitArray(number: number) {
const bitArray = [];
const binary = number.toString(2);
for (let i = 0; i < binary.length; ++i) {
if (binary[i] !== "0") {
// last binary digit
if (binary.slice(i).length === 1) {
bitArray.push(1);
} else {
bitArray.push(
parseInt(binary.slice(i), 2) - parseInt(binary.slice(i + 1), 2),
);
}
}
}
return bitArray;
}
// takes an array of integers and adds them together
export function convertFromBitArray(array: number[]) {
let result = 0;
for (let i = 0; i < array.length; i++) {
result += array[i];
}
return result;
}
export function convertCamelCase(str: string) {
return str
.replace(/[^a-zA-Z0-9]+/g, " ")
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index == 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, "");
}
// This will take an object and make a clone of it without including some of the keys
export function copyObjectWithoutKeys<
T extends Record<string, unknown>,
K extends keyof T,
>(objToCopy: T, keysToExclude: Array<K>): Omit<T, K> {
const result: Partial<T> = {};
Object.keys(objToCopy).forEach((key) => {
if (!keysToExclude.includes(key as K)) {
// Use an intermediate variable with a more permissive type
const safeKey: keyof T = key as keyof T;
result[safeKey] = objToCopy[safeKey];
}
});
return result as Omit<T, K>;
}

View File

@@ -1,6 +1,6 @@
import { Notify } from "quasar";
export function notifySuccess(msg, timeout = 2000) {
export function notifySuccess(msg: string, timeout = 2000) {
Notify.create({
type: "positive",
message: msg,
@@ -8,7 +8,7 @@ export function notifySuccess(msg, timeout = 2000) {
});
}
export function notifyError(msg, timeout = 2000) {
export function notifyError(msg: string, timeout = 2000) {
Notify.create({
type: "negative",
message: msg,
@@ -16,7 +16,7 @@ export function notifyError(msg, timeout = 2000) {
});
}
export function notifyWarning(msg, timeout = 2000) {
export function notifyWarning(msg: string, timeout = 2000) {
Notify.create({
type: "warning",
message: msg,
@@ -24,7 +24,7 @@ export function notifyWarning(msg, timeout = 2000) {
});
}
export function notifyInfo(msg, timeout = 2000) {
export function notifyInfo(msg: string, timeout = 2000) {
Notify.create({
type: "info",
message: msg,

View File

@@ -1,6 +1,10 @@
import { Notify } from "quasar";
export function isValidThreshold(warning, error, diskcheck = false) {
export function isValidThreshold(
warning: number,
error: number,
diskcheck = false,
) {
if (warning === 0 && error === 0) {
Notify.create({
type: "negative",
@@ -31,7 +35,7 @@ export function isValidThreshold(warning, error, diskcheck = false) {
return true;
}
export function validateEventID(val) {
export function validateEventID(val: number | "*") {
if (val === null || val.toString().replace(/\s/g, "") === "") {
return false;
} else if (val === "*") {
@@ -44,10 +48,20 @@ export function validateEventID(val) {
}
// validate script return code
export function validateRetcode(val, done) {
// function is used for quasar's q-select on-new-value function
export function validateRetcode(
val: string,
done: (item?: unknown, mode?: "add" | "add-unique" | "toggle") => void,
) {
/^\d+$/.test(val) ? done(val) : done();
}
export function validateTimePeriod(val) {
export function validateTimePeriod(val: string) {
return /^\d{1,3}(H|h|M|m|S|s|d|D)$/.test(val);
}
export function isValidEmail(val: string) {
const email =
/^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return email.test(val);
}

View File

@@ -11,7 +11,7 @@
</div>
</q-card-section>
<q-card-section>
<q-form @submit.prevent="checkCreds" class="q-gutter-md">
<q-form ref="form" @submit.prevent="checkCreds" class="q-gutter-md">
<q-input
filled
v-model="credentials.username"
@@ -24,7 +24,7 @@
<q-input
v-model="credentials.password"
filled
:type="isPwd ? 'password' : 'text'"
:type="showPassword ? 'password' : 'text'"
label="Password"
lazy-rules
:rules="[
@@ -33,9 +33,9 @@
>
<template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
@@ -53,7 +53,7 @@
<!-- 2 factor modal -->
<q-dialog persistent v-model="prompt">
<q-card style="min-width: 400px">
<q-form @submit.prevent="onSubmit">
<q-form ref="formToken" @submit.prevent="onSubmit">
<q-card-section class="text-center text-h6"
>Two-Factor Token</q-card-section
>
@@ -62,8 +62,8 @@
<q-input
autofocus
outlined
v-model="credentials.twofactor"
autocomplete="one-time-code"
v-model="twofactor"
:rules="[
(val) =>
(val && val.length > 0) || 'This field is required',
@@ -83,53 +83,51 @@
</q-layout>
</template>
<script>
import mixins from "@/mixins/mixins";
<script setup lang="ts">
import { ref, reactive } from "vue";
import { type QForm, useQuasar } from "quasar";
import { useAuthStore } from "@/stores/auth";
import { useRouter } from "vue-router";
export default {
name: "LoginView",
mixins: [mixins],
data() {
return {
credentials: {},
prompt: false,
isPwd: true,
};
},
// setup quasar
const $q = useQuasar();
$q.dark.set(true);
methods: {
checkCreds() {
this.$axios.post("/checkcreds/", this.credentials).then((r) => {
if (r.data.totp === "totp not set") {
// sign in to setup two factor temporarily
const token = r.data.token;
const username = r.data.username;
localStorage.setItem("access_token", token);
localStorage.setItem("user_name", username);
this.$store.commit("retrieveToken", { token, username });
this.$router.push({ name: "TOTPSetup" });
// setup auth store
const auth = useAuthStore();
// setup router
const router = useRouter();
const form = ref<QForm | null>(null);
const formToken = ref<QForm | null>(null);
// login logic
const credentials = reactive({ username: "", password: "" });
const twofactor = ref("");
const prompt = ref(false);
const showPassword = ref(true);
async function checkCreds() {
const { totp } = await auth.checkCredentials(credentials);
if (!totp) {
router.push({ name: "TOTPSetup" });
} else {
this.prompt = true;
prompt.value = true;
}
});
},
onSubmit() {
this.$store
.dispatch("retrieveToken", this.credentials)
.then(() => {
this.credentials = {};
this.$router.push({ name: "Dashboard" });
})
.catch(() => {
this.credentials = {};
this.prompt = false;
});
},
},
mounted() {
this.$q.dark.set(true);
},
};
}
async function onSubmit() {
try {
await auth.login({ ...credentials, twofactor: twofactor.value });
router.push({ name: "Dashboard" });
} finally {
form.value?.reset();
formToken.value?.reset();
prompt.value = false;
}
}
</script>
<style>

View File

@@ -5,11 +5,19 @@
</div>
</template>
<script>
export default {
name: "SessionExpired",
mounted() {
this.$store.dispatch("destroyToken");
},
};
<script setup lang="ts">
import { onMounted } from "vue";
import { useAuthStore } from "@/stores/auth";
import { useDashWSConnection } from "@/websocket/websocket";
// setup store
const auth = useAuthStore();
// setup websocket
const { close } = useDashWSConnection();
onMounted(async () => {
await auth.logout();
close();
});
</script>

View File

@@ -1,11 +0,0 @@
import { getBaseUrl } from "@/boot/axios";
export function getWSUrl(path, token) {
const url = getBaseUrl().split("://")[1];
const proto =
process.env.NODE_ENV === "production" || process.env.DOCKER_BUILD
? "wss"
: "ws";
return `${proto}://${url}/ws/${path}/?access_token=${token}`;
}

View File

@@ -0,0 +1,81 @@
import { ref, watch } from "vue";
import { UseWebSocketReturn, useWebSocket } from "@vueuse/core";
import { getBaseUrl } from "@/boot/axios";
import { useAuthStore } from "@/stores/auth";
export function getWSUrl(path: string, token: string | null) {
const url = getBaseUrl().split("://")[1];
const proto =
process.env.NODE_ENV === "production" || process.env.DOCKER_BUILD
? "wss"
: "ws";
return `${proto}://${url}/ws/${path}/?access_token=${token}`;
}
interface WSReturn {
action: string;
data: unknown;
}
let WSConnection: UseWebSocketReturn<string> | undefined = undefined;
export function useDashWSConnection() {
const auth = useAuthStore();
if (WSConnection === undefined) {
const url = getWSUrl("dashinfo", auth.token);
WSConnection = useWebSocket(url, {
autoReconnect: true,
});
}
const { status, data, send, open, close } = WSConnection;
const parsedData = ref<WSReturn>({ action: "", data: {} });
watch(data, (newValue) => {
if (newValue) parsedData.value = JSON.parse(newValue);
});
function closeConnection() {
WSConnection = undefined;
close();
}
return {
status,
data: parsedData,
send,
open,
close: closeConnection,
};
}
let WSCliConnection: UseWebSocketReturn<string> | undefined = undefined;
export function useCliWSConnection() {
const auth = useAuthStore();
if (WSCliConnection === undefined) {
const url = getWSUrl("trmmcli", auth.token);
WSCliConnection = useWebSocket(url);
}
const { status, data, send, open, close } = WSCliConnection;
const parsedData = ref<WSReturn>({ action: "", data: {} });
watch(data, (newValue) => {
if (newValue) parsedData.value = JSON.parse(newValue);
});
function closeConnection() {
WSCliConnection = undefined;
close();
}
return {
status,
data: parsedData,
send,
open,
close: closeConnection,
};
}

View File

@@ -0,0 +1,38 @@
import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-vitest';
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ExampleComponent from './demo/ExampleComponent.vue';
installQuasarPlugin();
describe('example Component', () => {
it('should mount component with todos', async () => {
const wrapper = mount(ExampleComponent, {
props: {
title: 'Hello',
meta: {
totalCount: 4,
},
todos: [
{ id: 1, content: 'Hallo' },
{ id: 2, content: 'Hoi' },
],
},
});
expect(wrapper.vm.clickCount).toBe(0);
await wrapper.find('.q-item').trigger('click');
expect(wrapper.vm.clickCount).toBe(1);
});
it('should mount component without todos', () => {
const wrapper = mount(ExampleComponent, {
props: {
title: 'Hello',
meta: {
totalCount: 4,
},
},
});
expect(wrapper.findAll('.q-item')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,13 @@
import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-vitest';
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import LayoutComponent from './demo/LayoutComponent.vue';
installQuasarPlugin();
describe('layout example', () => {
it('should mount component properly', () => {
const wrapper = mount(LayoutComponent);
expect(wrapper.exists()).to.be.true;
});
});

View File

@@ -0,0 +1,19 @@
import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-vitest';
import { mount } from '@vue/test-utils';
import { Notify } from 'quasar';
import { describe, expect, it, vi } from 'vitest';
import NotifyComponent from './demo/NotifyComponent.vue';
installQuasarPlugin({ plugins: { Notify } });
describe('notify example', () => {
it('should call notify on click', async () => {
expect(NotifyComponent).toBeTruthy();
const wrapper = mount(NotifyComponent);
const spy = vi.spyOn(Notify, 'create');
expect(spy).not.toHaveBeenCalled();
await wrapper.trigger('click');
expect(spy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
<template>
<div>
<p>{{ title }}</p>
<q-list>
<q-item v-for="todo in todos" :key="todo.id" clickable @click="increment">
{{ todo.id }} - {{ todo.content }}
</q-item>
</q-list>
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
<p>Active: {{ active ? 'yes' : 'no' }}</p>
<p>Clicks on todos: {{ clickCount }}</p>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
interface Todo {
id: number;
content: string;
}
interface Meta {
totalCount: number;
}
const props = withDefaults(
defineProps<{
title: string;
todos?: Todo[];
meta: Meta;
active?: boolean;
}>(),
{
todos: () => [],
},
);
const clickCount = ref(0);
function increment() {
clickCount.value += 1;
return clickCount.value;
}
const todoCount = computed(() => props.todos.length);
</script>

View File

@@ -0,0 +1,12 @@
<template>
<q-header>
<q-toolbar>
<q-toolbar-title>Header</q-toolbar-title>
</q-toolbar>
</q-header>
<q-footer>
<q-toolbar>
<q-toolbar-title>Footer</q-toolbar-title>
</q-toolbar>
</q-footer>
</template>

View File

@@ -0,0 +1,11 @@
<template>
<q-btn @click="onClick"> Click me! </q-btn>
</template>
<script lang="ts" setup>
import { Notify } from 'quasar';
function onClick() {
Notify.create('Hello there!');
}
</script>

View File

@@ -0,0 +1 @@
// This file will be run before each test file

27
vitest.config.mts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { quasar, transformAssetUrls } from '@quasar/vite-plugin';
import tsconfigPaths from 'vite-tsconfig-paths';
// https://vitejs.dev/config/
export default defineConfig({
test: {
environment: 'happy-dom',
setupFiles: 'test/vitest/setup-file.ts',
include: [
// Matches vitest tests in any subfolder of 'src' or into 'test/vitest/__tests__'
// Matches all files with extension 'js', 'jsx', 'ts' and 'tsx'
'src/**/*.vitest.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'test/vitest/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
],
},
plugins: [
vue({
template: { transformAssetUrls },
}),
quasar({
sassVariables: 'src/quasar-variables.scss',
}),
tsconfigPaths(),
],
});