mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
feat: SMTP support in alert destinations (#3976)
feat #1401 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a tabbed interface for selecting "Web Hook" and "Email" destination types in the Add Destination section. - Added a new tabbed interface for template types in the Add Template section, with conditional input fields based on selection. - Implemented a method to navigate to the email template creation page. - **Improvements** - Enhanced user experience with dynamic input fields for email addresses and validation in the Add Destination section. - Updated the save functionality to handle new properties for templates and destinations. - Improved email notification logic to use template titles for email subjects, ensuring better customization. - Added validation to ensure destination emails are associated with the correct organization. - Expanded data structure to include a new title property for templates. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Subhradeep Chakraborty <chakrabortysubhradeep556@gmail.com>
This commit is contained in:
co-authored by
Subhradeep Chakraborty
parent
ecd6367812
commit
5fa39d1803
@@ -32,4 +32,6 @@ pub struct Template {
|
||||
#[serde(rename = "type")]
|
||||
#[serde(default)]
|
||||
pub template_type: DestinationType,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
@@ -439,9 +439,23 @@ pub async fn send_notification(
|
||||
)
|
||||
.await;
|
||||
|
||||
let email_subject = if !dest.template.title.is_empty() {
|
||||
process_dest_template(
|
||||
&dest.template.title,
|
||||
alert,
|
||||
rows,
|
||||
&rows_tpl_val,
|
||||
rows_end_time,
|
||||
start_time,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
dest.template.name.clone()
|
||||
};
|
||||
|
||||
match dest.destination_type {
|
||||
DestinationType::Http => send_http_notification(dest, msg.clone()).await,
|
||||
DestinationType::Email => send_email_notification(&alert.name, dest, msg).await,
|
||||
DestinationType::Email => send_email_notification(&email_subject, dest, msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +521,7 @@ pub async fn send_http_notification(
|
||||
}
|
||||
|
||||
pub async fn send_email_notification(
|
||||
alert_name: &str,
|
||||
email_subject: &str,
|
||||
dest: &DestinationWithTemplate,
|
||||
msg: String,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
@@ -523,7 +537,7 @@ pub async fn send_email_notification(
|
||||
|
||||
let mut email = Message::builder()
|
||||
.from(cfg.smtp.smtp_from_email.parse()?)
|
||||
.subject(format!("Openobserve Alert - {}", alert_name));
|
||||
.subject(format!("Openobserve Alert - {}", email_subject));
|
||||
|
||||
for recipient in recipients {
|
||||
email = email.to(recipient.parse()?);
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{
|
||||
},
|
||||
utils::auth::{is_ofga_unsupported, remove_ownership, set_ownership},
|
||||
},
|
||||
service::db,
|
||||
service::db::{self, user},
|
||||
};
|
||||
|
||||
pub async fn save(
|
||||
@@ -50,6 +50,25 @@ pub async fn save(
|
||||
anyhow::anyhow!("Atleast one alert destination email is required"),
|
||||
));
|
||||
}
|
||||
for email in destination.emails.iter() {
|
||||
// Check if the email is part of the org
|
||||
match user::get(Some(org_id), email).await {
|
||||
Ok(user) => {
|
||||
if user.is_none() {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
anyhow::anyhow!("Destination email must be part of this org"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
anyhow::anyhow!("Destination email must be part of this org"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !config::get_config().smtp.smtp_enabled {
|
||||
return Err((
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<template>
|
||||
<q-page class="q-pa-none" style="min-height: inherit">
|
||||
<div>
|
||||
<div class="o2-input">
|
||||
<div class="row items-center no-wrap q-mx-md q-my-sm">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
@@ -43,7 +43,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
<q-separator />
|
||||
<div class="row q-col-gutter-sm q-pt-lg q-px-lg q-my-md">
|
||||
<div class="row q-col-gutter-sm q-px-lg q-my-md">
|
||||
<div class="col-12 q-pb-md">
|
||||
<app-tabs
|
||||
style="
|
||||
border: 1px solid #8a8a8a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
"
|
||||
:tabs="tabs"
|
||||
v-model:active-tab="formData.type"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="formData.type === 'email' && !getFormattedTemplates.length"
|
||||
class="flex items-center col-12 q-mb-md"
|
||||
>
|
||||
<div class="text-subtitle2 q-mr-sm">
|
||||
It looks like you haven't created any Email Templates yet.
|
||||
</div>
|
||||
<q-btn
|
||||
label="Create Email Template"
|
||||
size="sm"
|
||||
no-caps
|
||||
color="secondary"
|
||||
style="border-radius: 4px; font-size: 12px"
|
||||
@click="createEmailTemplate"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6 q-py-xs">
|
||||
<q-input
|
||||
data-test="add-destination-name-input"
|
||||
@@ -87,117 +115,143 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 q-py-xs">
|
||||
<q-input
|
||||
data-test="add-destination-url-input"
|
||||
v-model="formData.url"
|
||||
:label="t('alert_destinations.url') + ' *'"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
dense
|
||||
:rules="[(val: any) => !!val.trim() || 'Field is required!']"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6 q-py-xs destination-method-select">
|
||||
<q-select
|
||||
data-test="add-destination-method-select"
|
||||
v-model="formData.method"
|
||||
:label="t('alert_destinations.method') + ' *'"
|
||||
:options="apiMethods"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
:popup-content-style="{ textTransform: 'uppercase' }"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val: any) => !!val || 'Field is required!']"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 q-py-sm">
|
||||
<div class="text-bold q-py-xs" style="paddingleft: 10px">Headers</div>
|
||||
<div
|
||||
v-for="(header, index) in apiHeaders"
|
||||
:key="header.uuid"
|
||||
class="row q-col-gutter-sm q-pb-sm"
|
||||
>
|
||||
<div class="col-5 q-ml-none">
|
||||
<q-input
|
||||
:data-test="`add-destination-header-${header['key']}-key-input`"
|
||||
v-model="header.key"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
:placeholder="t('alert_destinations.api_header')"
|
||||
dense
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-5 q-ml-none">
|
||||
<q-input
|
||||
:data-test="`add-destination-header-${header['key']}-value-input`"
|
||||
v-model="header.value"
|
||||
:placeholder="t('alert_destinations.api_header_value')"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
dense
|
||||
isUpdatingDestination
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-2 q-ml-none">
|
||||
<q-btn
|
||||
:data-test="`add-destination-header-${header['key']}-delete-btn`"
|
||||
icon="delete"
|
||||
class="q-ml-xs iconHoverBtn"
|
||||
padding="sm"
|
||||
unelevated
|
||||
size="sm"
|
||||
round
|
||||
flat
|
||||
:title="t('alert_templates.edit')"
|
||||
@click="deleteApiHeader(header)"
|
||||
/>
|
||||
<q-btn
|
||||
data-test="add-destination-add-header-btn"
|
||||
v-if="index === apiHeaders.length - 1"
|
||||
icon="add"
|
||||
class="q-ml-xs iconHoverBtn"
|
||||
padding="sm"
|
||||
unelevated
|
||||
size="sm"
|
||||
round
|
||||
flat
|
||||
:title="t('alert_templates.edit')"
|
||||
@click="addApiHeader()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 q-py-sm">
|
||||
<div class="q-py-sm">
|
||||
<q-toggle
|
||||
data-test="add-destination-skip-tls-verify-toggle"
|
||||
class="q-mt-sm"
|
||||
v-model="formData.skip_tls_verify"
|
||||
:label="t('alert_destinations.skip_tls_verify')"
|
||||
|
||||
<template v-if="formData.type === 'http'">
|
||||
<div class="col-6 q-py-xs">
|
||||
<q-input
|
||||
data-test="add-destination-url-input"
|
||||
v-model="formData.url"
|
||||
:label="t('alert_destinations.url') + ' *'"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
dense
|
||||
:rules="[(val: any) => !!val.trim() || 'Field is required!']"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 q-py-xs destination-method-select">
|
||||
<q-select
|
||||
data-test="add-destination-method-select"
|
||||
v-model="formData.method"
|
||||
:label="t('alert_destinations.method') + ' *'"
|
||||
:options="apiMethods"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
:popup-content-style="{ textTransform: 'uppercase' }"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val: any) => !!val || 'Field is required!']"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 q-py-sm">
|
||||
<div class="text-bold q-py-xs" style="paddingleft: 10px">
|
||||
Headers
|
||||
</div>
|
||||
<div
|
||||
v-for="(header, index) in apiHeaders"
|
||||
:key="header.uuid"
|
||||
class="row q-col-gutter-sm q-pb-sm"
|
||||
>
|
||||
<div class="col-5 q-ml-none">
|
||||
<q-input
|
||||
:data-test="`add-destination-header-${header['key']}-key-input`"
|
||||
v-model="header.key"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
:placeholder="t('alert_destinations.api_header')"
|
||||
dense
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-5 q-ml-none">
|
||||
<q-input
|
||||
:data-test="`add-destination-header-${header['key']}-value-input`"
|
||||
v-model="header.value"
|
||||
:placeholder="t('alert_destinations.api_header_value')"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
dense
|
||||
isUpdatingDestination
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-2 q-ml-none">
|
||||
<q-btn
|
||||
:data-test="`add-destination-header-${header['key']}-delete-btn`"
|
||||
icon="delete"
|
||||
class="q-ml-xs iconHoverBtn"
|
||||
padding="sm"
|
||||
unelevated
|
||||
size="sm"
|
||||
round
|
||||
flat
|
||||
:title="t('alert_templates.edit')"
|
||||
@click="deleteApiHeader(header)"
|
||||
/>
|
||||
<q-btn
|
||||
data-test="add-destination-add-header-btn"
|
||||
v-if="index === apiHeaders.length - 1"
|
||||
icon="add"
|
||||
class="q-ml-xs iconHoverBtn"
|
||||
padding="sm"
|
||||
unelevated
|
||||
size="sm"
|
||||
round
|
||||
flat
|
||||
:title="t('alert_templates.edit')"
|
||||
@click="addApiHeader()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 q-py-sm">
|
||||
<div class="q-py-sm">
|
||||
<q-toggle
|
||||
data-test="add-destination-skip-tls-verify-toggle"
|
||||
class="q-mt-sm"
|
||||
v-model="formData.skip_tls_verify"
|
||||
:label="t('alert_destinations.skip_tls_verify')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="formData.type === 'email'">
|
||||
<q-input
|
||||
v-model="formData.emails"
|
||||
:label="t('reports.recipients') + ' *'"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
dense
|
||||
:rules="[
|
||||
(val: any) =>
|
||||
/^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(\s*[;,]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))*$/.test(
|
||||
val,
|
||||
) || 'Add valid emails!',
|
||||
]"
|
||||
tabindex="0"
|
||||
style="width: 100%"
|
||||
borderless
|
||||
:placeholder="t('user.inviteByEmail')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center q-mt-lg">
|
||||
@@ -231,19 +285,26 @@ import {
|
||||
onBeforeMount,
|
||||
onActivated,
|
||||
defineEmits,
|
||||
reactive,
|
||||
} from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import { useStore } from "vuex";
|
||||
import { useQuasar } from "quasar";
|
||||
import type { Template, DestinationData, Headers } from "@/ts/interfaces";
|
||||
import type {
|
||||
Template,
|
||||
DestinationData,
|
||||
Headers,
|
||||
DestinationPayload,
|
||||
} from "@/ts/interfaces";
|
||||
import { useRouter } from "vue-router";
|
||||
import { isValidResourceName } from "@/utils/zincutils";
|
||||
import AppTabs from "@/components/common/AppTabs.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
templates: Template[] | [];
|
||||
destination: DestinationData | null;
|
||||
destination: DestinationPayload | null;
|
||||
}>();
|
||||
const emit = defineEmits(["get:destinations", "cancel:hideform"]);
|
||||
const q = useQuasar();
|
||||
@@ -257,6 +318,8 @@ const formData: Ref<DestinationData> = ref({
|
||||
skip_tls_verify: false,
|
||||
template: "",
|
||||
headers: {},
|
||||
emails: "",
|
||||
type: "http",
|
||||
});
|
||||
const isUpdatingDestination = ref(false);
|
||||
|
||||
@@ -274,6 +337,32 @@ const apiHeaders: Ref<
|
||||
uuid: string;
|
||||
}[]
|
||||
> = ref([{ key: "", value: "", uuid: getUUID() }]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: "Web Hook",
|
||||
value: "http",
|
||||
style: {
|
||||
width: "fit-content",
|
||||
padding: "4px 14px",
|
||||
background: formData.value.type === "http" ? "#5960B2" : "",
|
||||
border: "none !important",
|
||||
color: formData.value.type === "http" ? "#ffffff !important" : "",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: "email",
|
||||
style: {
|
||||
width: "fit-content",
|
||||
padding: "4px 14px",
|
||||
background: formData.value.type === "email" ? "#5960B2" : "#ffffff",
|
||||
border: "none !important",
|
||||
color: formData.value.type === "email" ? "#ffffff !important" : "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
onActivated(() => setupDestinationData());
|
||||
onBeforeMount(() => {
|
||||
setupDestinationData();
|
||||
@@ -288,6 +377,9 @@ const setupDestinationData = () => {
|
||||
formData.value.skip_tls_verify = props.destination.skip_tls_verify;
|
||||
formData.value.template = props.destination.template;
|
||||
formData.value.headers = props.destination.headers;
|
||||
formData.value.emails = (props.destination.emails || []).join(", ");
|
||||
formData.value.type = props.destination.type || "http";
|
||||
|
||||
if (Object.keys(formData.value.headers).length) {
|
||||
apiHeaders.value = [];
|
||||
Object.entries(formData.value.headers).forEach(([key, value]) => {
|
||||
@@ -297,13 +389,22 @@ const setupDestinationData = () => {
|
||||
}
|
||||
};
|
||||
const getFormattedTemplates = computed(() =>
|
||||
props.templates.map((template: any) => template.name),
|
||||
props.templates
|
||||
.filter((template: any) => {
|
||||
if (formData.value.type === "email" && template.type === "email")
|
||||
return true;
|
||||
else if (formData.value.type !== "email") return true;
|
||||
})
|
||||
.map((template: any) => template.name),
|
||||
);
|
||||
|
||||
const isValidDestination = computed(
|
||||
() =>
|
||||
formData.value.name &&
|
||||
formData.value.url &&
|
||||
formData.value.method &&
|
||||
((formData.value.url &&
|
||||
formData.value.method &&
|
||||
formData.value.type === "http") ||
|
||||
(formData.value.type === "email" && formData.value.emails.length)) &&
|
||||
formData.value.template,
|
||||
);
|
||||
const saveDestination = () => {
|
||||
@@ -324,19 +425,29 @@ const saveDestination = () => {
|
||||
apiHeaders.value.forEach((header) => {
|
||||
if (header["key"] && header["value"]) headers[header.key] = header.value;
|
||||
});
|
||||
|
||||
const payload: any = {
|
||||
url: formData.value.url,
|
||||
method: formData.value.method,
|
||||
skip_tls_verify: formData.value.skip_tls_verify,
|
||||
template: formData.value.template,
|
||||
headers: headers,
|
||||
name: formData.value.name,
|
||||
};
|
||||
|
||||
if (formData.value.type === "email") {
|
||||
payload["type"] = "email";
|
||||
payload["emails"] = formData.value.emails
|
||||
.split(/[;,]/)
|
||||
.map((email: string) => email.trim());
|
||||
}
|
||||
|
||||
if (isUpdatingDestination.value) {
|
||||
destinationService
|
||||
.update({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
destination_name: formData.value.name,
|
||||
data: {
|
||||
url: formData.value.url,
|
||||
method: formData.value.method,
|
||||
skip_tls_verify: formData.value.skip_tls_verify,
|
||||
template: formData.value.template,
|
||||
headers: headers,
|
||||
name: formData.value.name,
|
||||
},
|
||||
data: payload,
|
||||
})
|
||||
.then(() => {
|
||||
dismiss();
|
||||
@@ -347,7 +458,7 @@ const saveDestination = () => {
|
||||
message: `Destination saved successfully.`,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch((err: any) => {
|
||||
dismiss();
|
||||
q.notify({
|
||||
type: "negative",
|
||||
@@ -359,14 +470,7 @@ const saveDestination = () => {
|
||||
.create({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
destination_name: formData.value.name,
|
||||
data: {
|
||||
url: formData.value.url,
|
||||
method: formData.value.method,
|
||||
skip_tls_verify: formData.value.skip_tls_verify,
|
||||
template: formData.value.template,
|
||||
headers: headers,
|
||||
name: formData.value.name,
|
||||
},
|
||||
data: payload,
|
||||
})
|
||||
.then(() => {
|
||||
dismiss();
|
||||
@@ -377,7 +481,7 @@ const saveDestination = () => {
|
||||
message: `Destination saved successfully.`,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch((err: any) => {
|
||||
dismiss();
|
||||
q.notify({
|
||||
type: "negative",
|
||||
@@ -397,6 +501,17 @@ const deleteApiHeader = (header: any) => {
|
||||
delete formData.value.headers[header.key];
|
||||
if (!apiHeaders.value.length) addApiHeader();
|
||||
};
|
||||
|
||||
const createEmailTemplate = () => {
|
||||
router.push({
|
||||
name: "alertTemplates",
|
||||
query: {
|
||||
action: "add",
|
||||
type: "email",
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
#editor {
|
||||
|
||||
@@ -46,11 +46,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<q-splitter
|
||||
v-model="splitterModel"
|
||||
unit="%"
|
||||
style="min-height: calc(100vh - 122px)"
|
||||
style="height: calc(100vh - 106px)"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<div class="row q-pa-md">
|
||||
<div class="col-12 q-pb-md q-pt-sm">
|
||||
<div class="col-12 q-pb-sm q-pt-sm o2-input">
|
||||
<q-input
|
||||
data-test="add-template-name-input"
|
||||
v-model="formData.name"
|
||||
@@ -74,29 +74,67 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 q-pb-md q-pt-xs">
|
||||
<div class="col-12 q-pb-md">
|
||||
<app-tabs
|
||||
style="
|
||||
border: 1px solid #8a8a8a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
"
|
||||
:tabs="tabs"
|
||||
v-model:active-tab="formData.type"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="formData.type === 'email'" class="col-12 q-pt-xs o2-input">
|
||||
<q-input
|
||||
data-test="add-template-email-title-input"
|
||||
v-model="formData.title"
|
||||
:label="t('alerts.title') + ' *'"
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
class="showLabelOnTop"
|
||||
stack-label
|
||||
outlined
|
||||
filled
|
||||
dense
|
||||
:rules="[(val: any) => !!val.trim() || 'Field is required!']"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 q-pb-md">
|
||||
<div
|
||||
class="q-pb-sm text-bold"
|
||||
data-test="add-template-body-input-title"
|
||||
>
|
||||
{{ t("alert_templates.body") + " *" }}
|
||||
</div>
|
||||
<query-editor
|
||||
editor-id="add-template-editor"
|
||||
class="monaco-editor q-mt-sm"
|
||||
language="json"
|
||||
ref="editorRef"
|
||||
v-model:query="formData.body"
|
||||
data-test="add-template-body-input"
|
||||
:label="t('alerts.sql')"
|
||||
:rules="[(val: any) => !!val || 'Field is required!']"
|
||||
/>
|
||||
<template v-if="formData.type === 'email'">
|
||||
<query-editor
|
||||
data-test="template-body-editor"
|
||||
ref="queryEditorRef"
|
||||
editor-id="template-body-editor"
|
||||
class="monaco-editor q-mb-md"
|
||||
language="markdown"
|
||||
v-model:query="formData.body"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<query-editor
|
||||
data-test="template-body-editor"
|
||||
ref="queryEditorRef"
|
||||
editor-id="template-body-editor"
|
||||
class="monaco-editor q-mb-md"
|
||||
language="json"
|
||||
v-model:query="formData.body"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="col-12 flex justify-center q-mt-lg">
|
||||
<div class="col-12 flex justify-center">
|
||||
<q-btn
|
||||
data-test="add-template-cancel-btn"
|
||||
v-close-popup="true"
|
||||
class="q-mb-md text-bold"
|
||||
class="text-bold"
|
||||
:label="t('alerts.cancel')"
|
||||
text-color="light-text"
|
||||
padding="sm md"
|
||||
@@ -106,7 +144,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<q-btn
|
||||
data-test="add-template-submit-btn"
|
||||
:label="t('alerts.save')"
|
||||
class="q-mb-md text-bold no-border q-ml-md"
|
||||
class="text-bold no-border q-ml-md"
|
||||
color="secondary"
|
||||
padding="sm xl"
|
||||
@click="saveTemplate"
|
||||
@@ -168,7 +206,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</q-page>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, defineProps, onBeforeMount, onActivated, defineEmits } from "vue";
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
defineProps,
|
||||
onBeforeMount,
|
||||
onActivated,
|
||||
defineEmits,
|
||||
watch,
|
||||
computed,
|
||||
} from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
@@ -178,6 +225,7 @@ import { copyToClipboard, useQuasar } from "quasar";
|
||||
import type { TemplateData, Template } from "@/ts/interfaces/index";
|
||||
import { useRouter } from "vue-router";
|
||||
import { isValidResourceName } from "@/utils/zincutils";
|
||||
import AppTabs from "@/components/common/AppTabs.vue";
|
||||
import QueryEditor from "@/components/QueryEditor.vue";
|
||||
|
||||
const props = defineProps<{ template: TemplateData | null }>();
|
||||
@@ -187,6 +235,8 @@ const splitterModel: Ref<number> = ref(75);
|
||||
const formData: Ref<Template> = ref({
|
||||
name: "",
|
||||
body: "",
|
||||
type: "http",
|
||||
title: "",
|
||||
});
|
||||
const store = useStore();
|
||||
const q = useQuasar();
|
||||
@@ -223,11 +273,46 @@ onActivated(() => setupTemplateData());
|
||||
onBeforeMount(() => {
|
||||
setupTemplateData();
|
||||
});
|
||||
|
||||
const baseTabStyle = {
|
||||
width: "fit-content",
|
||||
padding: "4px 14px",
|
||||
border: "none !important",
|
||||
};
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: "Web Hook",
|
||||
value: "http",
|
||||
style: {
|
||||
...baseTabStyle,
|
||||
background: formData.value.type === "http" ? "#5960B2" : "",
|
||||
color: formData.value.type === "http" ? "#ffffff !important" : "",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: "email",
|
||||
style: {
|
||||
...baseTabStyle,
|
||||
background: formData.value.type === "email" ? "#5960B2" : "#ffffff",
|
||||
color: formData.value.type === "email" ? "#ffffff !important" : "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const setupTemplateData = () => {
|
||||
const params = router.currentRoute.value.query;
|
||||
if (props.template) {
|
||||
isUpdatingTemplate.value = true;
|
||||
formData.value.name = props.template.name;
|
||||
formData.value.body = props.template.body;
|
||||
formData.value.type = props.template.type;
|
||||
formData.value.title = props.template.title;
|
||||
}
|
||||
|
||||
if (params.type) {
|
||||
formData.value.type = params.type as "email" | "http";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,7 +347,7 @@ const saveTemplate = () => {
|
||||
}
|
||||
|
||||
// Here checking is template body json valid
|
||||
if (!isTemplateBodyValid()) return;
|
||||
if (formData.value.type !== "email" && !isTemplateBodyValid()) return;
|
||||
|
||||
const dismiss = q.notify({
|
||||
spinner: true,
|
||||
@@ -278,6 +363,8 @@ const saveTemplate = () => {
|
||||
data: {
|
||||
name: formData.value.name.trim(),
|
||||
body: formData.value.body,
|
||||
type: formData.value.type,
|
||||
title: formData.value.title,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
@@ -305,6 +392,8 @@ const saveTemplate = () => {
|
||||
data: {
|
||||
name: formData.value.name.trim(),
|
||||
body: formData.value.body,
|
||||
type: formData.value.type,
|
||||
title: formData.value.title,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
@@ -341,6 +430,9 @@ const copyTemplateBody = (text: any) => {
|
||||
width: 100%;
|
||||
min-height: 310px !important;
|
||||
border-radius: 5px;
|
||||
// padding-bottom: 14px;
|
||||
resize: vertical;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.example-template-body {
|
||||
|
||||
@@ -165,7 +165,7 @@ import { useStore } from "vuex";
|
||||
import ConfirmDialog from "../ConfirmDialog.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import QTablePagination from "@/components/shared/grid/Pagination.vue";
|
||||
import type { DestinationData } from "@/ts/interfaces";
|
||||
import type { DestinationPayload } from "@/ts/interfaces";
|
||||
import type { Template } from "@/ts/interfaces/index";
|
||||
|
||||
import { outlinedDelete } from "@quasar/extras/material-icons-outlined";
|
||||
@@ -180,7 +180,7 @@ export default defineComponent({
|
||||
setup() {
|
||||
const qTable = ref();
|
||||
const store = useStore();
|
||||
const editingDestination: Ref<DestinationData | null> = ref(null);
|
||||
const editingDestination: Ref<DestinationPayload | null> = ref(null);
|
||||
const { t } = useI18n();
|
||||
const q = useQuasar();
|
||||
const columns: any = ref<QTableProps["columns"]>([
|
||||
@@ -220,8 +220,10 @@ export default defineComponent({
|
||||
style: "width: 110px",
|
||||
},
|
||||
]);
|
||||
const destinations: Ref<DestinationData[]> = ref([]);
|
||||
const templates: Ref<Template[]> = ref([{ name: "test", body: "" }]);
|
||||
const destinations: Ref<DestinationPayload[]> = ref([]);
|
||||
const templates: Ref<Template[]> = ref([
|
||||
{ name: "test", body: "", type: "http" },
|
||||
]);
|
||||
const confirmDelete: Ref<ConformDelete> = ref({
|
||||
visible: false,
|
||||
data: null,
|
||||
@@ -255,7 +257,7 @@ export default defineComponent({
|
||||
() => router.currentRoute.value.query.action,
|
||||
(action) => {
|
||||
if (!action) showDestinationEditor.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const getDestinations = () => {
|
||||
@@ -301,12 +303,12 @@ export default defineComponent({
|
||||
editDestination(null);
|
||||
if (router.currentRoute.value.query.action === "update")
|
||||
editDestination(
|
||||
getDestinationByName(router.currentRoute.value.query.name as string)
|
||||
getDestinationByName(router.currentRoute.value.query.name as string),
|
||||
);
|
||||
};
|
||||
const getDestinationByName = (name: string) => {
|
||||
return destinations.value.find(
|
||||
(destination) => destination.name === name
|
||||
(destination) => destination.name === name,
|
||||
);
|
||||
};
|
||||
const editDestination = (destination: any) => {
|
||||
@@ -342,13 +344,13 @@ export default defineComponent({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
destination_name: confirmDelete.value.data.name,
|
||||
})
|
||||
.then(() =>{
|
||||
.then(() => {
|
||||
q.notify({
|
||||
type: "positive",
|
||||
message: `Destination ${confirmDelete.value.data.name} deleted successfully`,
|
||||
timeout: 2000,
|
||||
});
|
||||
getDestinations()
|
||||
type: "positive",
|
||||
message: `Destination ${confirmDelete.value.data.name} deleted successfully`,
|
||||
timeout: 2000,
|
||||
});
|
||||
getDestinations();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.data.code === 409) {
|
||||
|
||||
@@ -216,13 +216,21 @@ const getTemplateByName = (name: string) => {
|
||||
const editTemplate = (template: any = null) => {
|
||||
resetEditingTemplate();
|
||||
toggleTemplateEditor();
|
||||
|
||||
const query: { [key: string]: string } = {
|
||||
action: template ? "update" : "add",
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
};
|
||||
|
||||
if (template) query.name = template.name;
|
||||
|
||||
if (router.currentRoute.value.query.type)
|
||||
query.type = router.currentRoute.value.query.type.toString() as string;
|
||||
|
||||
if (!template) {
|
||||
router.push({
|
||||
name: "alertTemplates",
|
||||
query: {
|
||||
action: "add",
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
},
|
||||
query,
|
||||
});
|
||||
} else {
|
||||
editingTemplate.value = { ...template };
|
||||
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
getUUID,
|
||||
generateTraceContext,
|
||||
} from "@/utils/zincutils";
|
||||
import { convertDateToTimestamp, getConsumableRelativeTime } from "@/utils/date";
|
||||
import {
|
||||
convertDateToTimestamp,
|
||||
getConsumableRelativeTime,
|
||||
} from "@/utils/date";
|
||||
import { byString } from "@/utils/json";
|
||||
import { logsErrorMessage } from "@/utils/common";
|
||||
import useSqlSuggestions from "@/composables/useSuggestions";
|
||||
@@ -167,7 +170,7 @@ const defaultObject = {
|
||||
colOrder: <any>{},
|
||||
colSizes: <any>{},
|
||||
},
|
||||
histogramInterval : <any> 0,
|
||||
histogramInterval: <any>0,
|
||||
transforms: <any>[],
|
||||
queryResults: <any>[],
|
||||
sortedQueryResults: <any>[],
|
||||
@@ -280,7 +283,8 @@ const useLogs = () => {
|
||||
parser = await sqlParser();
|
||||
};
|
||||
|
||||
searchObj.organizationIdentifier = store.state.selectedOrganization.identifier;
|
||||
searchObj.organizationIdentifier =
|
||||
store.state.selectedOrganization.identifier;
|
||||
const resetSearchObj = () => {
|
||||
// searchObj = reactive(Object.assign({}, defaultObject));
|
||||
searchObj.data.errorMsg = "No stream found in selected organization!";
|
||||
@@ -455,8 +459,6 @@ const useLogs = () => {
|
||||
searchObj.data.stream.streamType || "logs",
|
||||
true,
|
||||
).then((res) => {
|
||||
|
||||
|
||||
searchObj.loadingStream = false;
|
||||
return res;
|
||||
});
|
||||
@@ -1255,7 +1257,7 @@ const useLogs = () => {
|
||||
"Error while processing partition request.";
|
||||
if (err.response != undefined) {
|
||||
searchObj.data.errorMsg = err.response.data.error;
|
||||
if(err.response.data.hasOwnProperty("error_detail")){
|
||||
if (err.response.data.hasOwnProperty("error_detail")) {
|
||||
searchObj.data.errorDetail = err.response.data.error_detail;
|
||||
}
|
||||
if (err.response.data.hasOwnProperty("trace_id")) {
|
||||
@@ -1384,7 +1386,6 @@ const useLogs = () => {
|
||||
searchObj.data.queryResults.hasOwnProperty("aggs") &&
|
||||
searchObj.data.queryResults.aggs != null
|
||||
) {
|
||||
|
||||
}
|
||||
} else {
|
||||
searchObj.data.queryResults.total =
|
||||
@@ -1717,15 +1718,17 @@ const useLogs = () => {
|
||||
for (const partition of partitions) {
|
||||
searchObj.data.histogramQuery.query.start_time = partition[0];
|
||||
searchObj.data.histogramQuery.query.end_time = partition[1];
|
||||
//to improve the cancel query UI experience we add additional check here and further we need to remove it
|
||||
if (searchObj.data.isOperationCancelled) {
|
||||
//to improve the cancel query UI experience we add additional check here and further we need to remove it
|
||||
if (searchObj.data.isOperationCancelled) {
|
||||
searchObj.loadingHistogram = false;
|
||||
searchObj.data.isOperationCancelled = false;
|
||||
|
||||
|
||||
if (!searchObj.data.histogram?.xData?.length) {
|
||||
notificationMsg.value = "Search query was cancelled";
|
||||
searchObj.data.histogram.errorMsg = "Search query was cancelled";
|
||||
searchObj.data.histogram.errorDetail = "Search query was cancelled";
|
||||
searchObj.data.histogram.errorMsg =
|
||||
"Search query was cancelled";
|
||||
searchObj.data.histogram.errorDetail =
|
||||
"Search query was cancelled";
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1832,7 +1835,7 @@ const useLogs = () => {
|
||||
throw new Error("Invalid interval");
|
||||
}
|
||||
searchObj.data.histogramInterval = intervalMs;
|
||||
let date = new Date();
|
||||
const date = new Date();
|
||||
const startTimeDate = new Date(
|
||||
searchObj.data.customDownloadQueryObj.query.start_time / 1000,
|
||||
); // Convert microseconds to milliseconds
|
||||
@@ -2287,7 +2290,7 @@ const useLogs = () => {
|
||||
: "Error while processing histogram request.";
|
||||
if (err.response != undefined) {
|
||||
searchObj.data.errorMsg = err.response.data.error;
|
||||
if(err.response.data.hasOwnProperty("error_detail")){
|
||||
if (err.response.data.hasOwnProperty("error_detail")) {
|
||||
searchObj.data.errorDetail = err.response.data.error_detail;
|
||||
}
|
||||
if (err.response.data.hasOwnProperty("trace_id")) {
|
||||
@@ -2403,57 +2406,61 @@ const useLogs = () => {
|
||||
if (isTimestampASC(parsedSQL?.orderby) && partitions.length > 1) {
|
||||
partitions.reverse();
|
||||
}
|
||||
if(partitions[0][0] == queryReq.query.start_time && partitions[0][1] == queryReq.query.end_time){
|
||||
if (
|
||||
partitions[0][0] == queryReq.query.start_time &&
|
||||
partitions[0][1] == queryReq.query.end_time
|
||||
) {
|
||||
histogramResults = [];
|
||||
let date = new Date();
|
||||
const startDateTime = searchObj.data.customDownloadQueryObj.query.start_time / 1000;
|
||||
|
||||
const endDateTime = searchObj.data.customDownloadQueryObj.query.end_time / 1000 ;
|
||||
|
||||
const startDateTime =
|
||||
searchObj.data.customDownloadQueryObj.query.start_time / 1000;
|
||||
|
||||
const endDateTime =
|
||||
searchObj.data.customDownloadQueryObj.query.end_time / 1000;
|
||||
|
||||
const nowString = res.data.hits[0].zo_sql_key;
|
||||
const now = new Date(nowString);
|
||||
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const year = now.getFullYear();
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const year = now.getFullYear();
|
||||
|
||||
const dateToBePassed = `${day}-${month}-${year}`;
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
let minutes = String(now.getMinutes() ).padStart(2, "0");
|
||||
if(searchObj.data.histogramInterval / 1000 <= 60000 ){
|
||||
minutes = String(now.getMinutes() + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
const time = `${hours}:${minutes}`;
|
||||
const dateToBePassed = `${day}-${month}-${year}`;
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
let minutes = String(now.getMinutes()).padStart(2, "0");
|
||||
if (searchObj.data.histogramInterval / 1000 <= 60000) {
|
||||
minutes = String(now.getMinutes() + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
const currentTimeToBePassed = convertDateToTimestamp (
|
||||
dateToBePassed,
|
||||
time,
|
||||
'UTC'
|
||||
);
|
||||
for (
|
||||
let currentTime: any = currentTimeToBePassed.timestamp / 1000;
|
||||
currentTime < endDateTime;
|
||||
currentTime += searchObj.data.histogramInterval / 1000
|
||||
) {
|
||||
date = new Date(currentTime);
|
||||
histogramResults.push({
|
||||
zo_sql_key: date.toISOString().slice(0, 19),
|
||||
zo_sql_num: 0,
|
||||
});
|
||||
}
|
||||
for (
|
||||
let currentTime: any = currentTimeToBePassed.timestamp / 1000;
|
||||
currentTime > startDateTime;
|
||||
currentTime -= searchObj.data.histogramInterval / 1000
|
||||
) {
|
||||
date = new Date(currentTime);
|
||||
histogramResults.push({
|
||||
zo_sql_key: date.toISOString().slice(0, 19),
|
||||
zo_sql_num: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const time = `${hours}:${minutes}`;
|
||||
|
||||
const currentTimeToBePassed = convertDateToTimestamp(
|
||||
dateToBePassed,
|
||||
time,
|
||||
"UTC",
|
||||
);
|
||||
for (
|
||||
let currentTime: any = currentTimeToBePassed.timestamp / 1000;
|
||||
currentTime < endDateTime;
|
||||
currentTime += searchObj.data.histogramInterval / 1000
|
||||
) {
|
||||
date = new Date(currentTime);
|
||||
histogramResults.push({
|
||||
zo_sql_key: date.toISOString().slice(0, 19),
|
||||
zo_sql_num: 0,
|
||||
});
|
||||
}
|
||||
for (
|
||||
let currentTime: any = currentTimeToBePassed.timestamp / 1000;
|
||||
currentTime > startDateTime;
|
||||
currentTime -= searchObj.data.histogramInterval / 1000
|
||||
) {
|
||||
date = new Date(currentTime);
|
||||
histogramResults.push({
|
||||
zo_sql_key: date.toISOString().slice(0, 19),
|
||||
zo_sql_num: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
searchObj.data.queryResults.aggs.push(...res.data.hits);
|
||||
searchObj.data.queryResults.scan_size += res.data.scan_size;
|
||||
@@ -2463,21 +2470,24 @@ const useLogs = () => {
|
||||
const currentStartTime = queryReq.query.start_time;
|
||||
const currentEndTime = queryReq.query.end_time;
|
||||
let totalHits = 0;
|
||||
searchObj.data.queryResults.partitionDetail.partitions.map((item: any, index: any) => {
|
||||
if(item[0] == currentStartTime && item[1] == currentEndTime){
|
||||
totalHits = res.data.hits.reduce(
|
||||
(accumulator: number, currentValue: any) =>
|
||||
accumulator +
|
||||
Math.max(parseInt(currentValue.zo_sql_num, 10), 0),
|
||||
0,
|
||||
);
|
||||
searchObj.data.queryResults.partitionDetail.partitions.map(
|
||||
(item: any, index: any) => {
|
||||
if (item[0] == currentStartTime && item[1] == currentEndTime) {
|
||||
totalHits = res.data.hits.reduce(
|
||||
(accumulator: number, currentValue: any) =>
|
||||
accumulator +
|
||||
Math.max(parseInt(currentValue.zo_sql_num, 10), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
searchObj.data.queryResults.partitionDetail.partitionTotal[index] = totalHits;
|
||||
searchObj.data.queryResults.partitionDetail.partitionTotal[
|
||||
index
|
||||
] = totalHits;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
queryReq.query.start_time =
|
||||
searchObj.data.queryResults.partitionDetail.paginations[
|
||||
@@ -2964,7 +2974,7 @@ const useLogs = () => {
|
||||
|
||||
// Object.keys(recordwithMaxAttribute).forEach((key) => {
|
||||
for (const key of Object.keys(recordwithMaxAttribute)) {
|
||||
if(key == '_o2_id' || key == '_original'){
|
||||
if (key == "_o2_id" || key == "_original") {
|
||||
continue;
|
||||
}
|
||||
if (key == store.state.zoConfig.timestamp_column) {
|
||||
@@ -3916,8 +3926,6 @@ const useLogs = () => {
|
||||
);
|
||||
|
||||
if (streamData.schema != undefined) {
|
||||
|
||||
|
||||
searchObj.data.stream.selectedStreamFields.push(streamData.schema);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,7 +809,8 @@
|
||||
"crontitle": "Enable Cron",
|
||||
"lastTriggered":"Last Triggered At",
|
||||
"lastSatisfied":"Last Satisfied At",
|
||||
"owner": "Owner"
|
||||
"owner": "Owner",
|
||||
"title": "Title"
|
||||
},
|
||||
"alert_templates": {
|
||||
"header": "Templates",
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface Template {
|
||||
name: string;
|
||||
body: any;
|
||||
isDefault?: boolean;
|
||||
type: "http" | "email";
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Template object which is modified in frontend to display in table and form
|
||||
@@ -85,6 +87,19 @@ export interface Destination {
|
||||
skip_tls_verify: boolean;
|
||||
headers: Headers;
|
||||
template: string | Template;
|
||||
emails: string;
|
||||
type: "http" | "email";
|
||||
}
|
||||
|
||||
export interface DestinationPayload {
|
||||
name: string;
|
||||
url: string;
|
||||
method: string;
|
||||
skip_tls_verify: boolean;
|
||||
headers: Headers;
|
||||
template: string | Template;
|
||||
emails: string[];
|
||||
type: "http" | "email";
|
||||
}
|
||||
|
||||
// Destination object which is modified in frontend to display in table and form
|
||||
|
||||
Reference in New Issue
Block a user