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:
Omkar Kesarkhane
2024-10-01 12:38:09 +05:30
committed by GitHub
co-authored by Subhradeep Chakraborty
parent ecd6367812
commit 5fa39d1803
10 changed files with 524 additions and 248 deletions
+2
View File
@@ -32,4 +32,6 @@ pub struct Template {
#[serde(rename = "type")]
#[serde(default)]
pub template_type: DestinationType,
#[serde(default)]
pub title: String,
}
+17 -3
View File
@@ -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()?);
+20 -1
View File
@@ -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,
+249 -134
View File
@@ -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 {
+110 -18
View File
@@ -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 {
+15 -13
View File
@@ -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) {
+12 -4
View File
@@ -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 };
+82 -74
View File
@@ -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);
}
}
+2 -1
View File
@@ -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",
+15
View File
@@ -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