mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
1431 lines
53 KiB
Vue
1431 lines
53 KiB
Vue
<!-- Copyright 2026 OpenObserve Inc.
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
-->
|
|
|
|
<template>
|
|
<OPageLayout
|
|
class="overflow-hidden"
|
|
:title="destination ? t('alert_destinations.updateTitle') : t('alert_destinations.addTitle')"
|
|
title-data-test="add-destination-title"
|
|
:back="{
|
|
label: t('alert_destinations.header'),
|
|
onClick: () => emit('cancel:hideform'),
|
|
}"
|
|
bleed
|
|
>
|
|
<div class="bg-card-glass-bg flex-1 overflow-x-hidden overflow-y-auto py-2">
|
|
<div>
|
|
<OForm :form="form" id="add-destination-form" class="mt-2 mb-1 flex flex-col gap-2 px-3">
|
|
<!-- Destination Type Selection for Alerts (only show in create mode, not edit) -->
|
|
<div v-if="isAlerts && !destination" class="w-full pb-3">
|
|
<div class="mb-2 text-sm font-medium">
|
|
{{ t("alert_destinations.destination_type") }}
|
|
</div>
|
|
<PrebuiltDestinationSelector
|
|
:model-value="dtVal"
|
|
@update:model-value="setDestinationType"
|
|
:search-query="destinationSearchQuery"
|
|
data-test="prebuilt-destination-selector"
|
|
@select="selectDestinationType"
|
|
@update:search-query="destinationSearchQuery = $event"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Destination Type and Name Display for Edit Mode -->
|
|
<div v-if="isAlerts && destination && dtVal" class="w-full pb-3">
|
|
<div class="flex gap-3">
|
|
<!-- Destination Type (Read-only) -->
|
|
<div class="w-1/2">
|
|
<div class="mb-1 text-sm leading-tight font-medium">
|
|
{{ t("alert_destinations.destination_type") }}
|
|
</div>
|
|
<div
|
|
class="border-card-glass-border rounded-default flex items-center border p-2"
|
|
data-test="destination-type-readonly"
|
|
>
|
|
<OIcon :name="getDestinationTypeIcon(dtVal)" size="md" class="me-2" />
|
|
<span class="text-sm">{{ getDestinationTypeName(dtVal) }}</span>
|
|
<OTag type="readonlyFlag" value="readonly" class="ms-2">{{
|
|
t("alert_destinations.readonly")
|
|
}}</OTag>
|
|
</div>
|
|
</div>
|
|
<!-- Destination Name (Read-only) -->
|
|
<div class="w-1/2">
|
|
<OFormInput
|
|
data-test="add-destination-name-input"
|
|
name="name"
|
|
:label="t('alerts.name')"
|
|
required
|
|
readonly
|
|
disabled
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Prebuilt Destination Form (for alerts only) -->
|
|
<!-- Show for: create mode with destination_type selected OR edit mode for prebuilt destinations -->
|
|
<div
|
|
v-if="
|
|
isAlerts && (isPrebuiltDestination || (isUpdatingDestination && dtVal !== 'custom'))
|
|
"
|
|
class="w-full"
|
|
>
|
|
<SlackDestinationSetup
|
|
v-if="isNewSlackDestination"
|
|
:org-identifier="store.state.selectedOrganization.identifier"
|
|
:is-cloud="isCloudDeployment"
|
|
:is-enterprise="isEnterpriseDeployment"
|
|
@flow-change="handleSlackFlowChange"
|
|
@readiness-change="handleSlackReadinessChange"
|
|
/>
|
|
|
|
<!-- Name Field for Create Mode -->
|
|
<div v-if="!destination && !isNewSlackDestination" class="w-1/2 pb-3">
|
|
<OFormInput
|
|
data-test="add-destination-name-input"
|
|
name="name"
|
|
:label="t('alerts.name')"
|
|
required
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
|
|
<PrebuiltDestinationForm
|
|
v-if="dtVal && dtVal !== 'custom' && !isNewSlackDestination"
|
|
:destination-type="dtVal"
|
|
:hide-actions="true"
|
|
data-test="prebuilt-form"
|
|
/>
|
|
<div v-else-if="isUpdatingDestination" class="p-3 text-center">
|
|
<OSpinner size="md" data-test="add-destination-loading-indicator" />
|
|
<div class="text-text-muted mt-2">{{ t("alert_destinations.loadingData") }}</div>
|
|
</div>
|
|
|
|
<!-- Template selector for prebuilt destinations -->
|
|
<div
|
|
v-if="dtVal && dtVal !== 'custom' && showPrebuiltAdvancedSettings"
|
|
class="w-1/2 py-1"
|
|
>
|
|
<OFormSelect
|
|
data-test="add-destination-prebuilt-template-select"
|
|
name="template"
|
|
:label="t('alert_destinations.template')"
|
|
:options="prebuiltTemplateOptions"
|
|
labelKey="label"
|
|
valueKey="value"
|
|
tabindex="0"
|
|
/>
|
|
<div class="text-text-secondary mt-1 text-xs">
|
|
{{
|
|
t("alert_destinations.templateHelp", {
|
|
type: getDestinationTypeName(dtVal),
|
|
name: defaultPrebuiltTemplateName,
|
|
})
|
|
}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Additional Settings for Prebuilt Destinations -->
|
|
<div
|
|
v-if="showPrebuiltAdvancedSettings"
|
|
class="mt-3 w-full"
|
|
data-test="prebuilt-additional-settings"
|
|
>
|
|
<div class="py-1 font-bold">
|
|
{{ t("alert_destinations.additional_settings") }}
|
|
</div>
|
|
|
|
<!-- Custom Headers (hidden for email destinations) -->
|
|
<div v-if="dtVal !== 'email'" class="py-2">
|
|
<div class="pb-1 text-sm font-medium">
|
|
{{ t("alert_destinations.custom_headers") }}
|
|
</div>
|
|
<div v-for="(header, index) in apiHeaders" :key="index" class="flex gap-2 pb-2">
|
|
<div class="ms-0 w-5/12">
|
|
<OFormInput
|
|
:data-test="`add-destination-header-${header['key']}-key-input`"
|
|
:name="`apiHeaders[${index}].key`"
|
|
:placeholder="t('alert_destinations.api_header')"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
<div class="ms-0 w-5/12">
|
|
<OFormInput
|
|
:data-test="`add-destination-header-${header['key']}-value-input`"
|
|
:name="`apiHeaders[${index}].value`"
|
|
:placeholder="t('alert_destinations.api_header_value')"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
<div class="ms-0 w-1/6">
|
|
<OButton
|
|
:data-test="`add-destination-header-${header['key']}-delete-btn`"
|
|
class="ms-1"
|
|
variant="ghost"
|
|
size="icon-circle-sm"
|
|
:title="t('alert_templates.edit')"
|
|
@click="deleteApiHeader(index)"
|
|
>
|
|
<OIcon name="delete" size="sm" />
|
|
</OButton>
|
|
<OButton
|
|
data-test="add-destination-add-header-btn"
|
|
v-if="index === apiHeaders.length - 1"
|
|
class="ms-1"
|
|
variant="ghost"
|
|
size="icon-circle-sm"
|
|
:title="t('alert_templates.edit')"
|
|
@click="addApiHeader()"
|
|
>
|
|
<OIcon name="add" size="sm" />
|
|
</OButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Skip TLS Verify Toggle -->
|
|
<div class="py-2">
|
|
<OFormSwitch
|
|
data-test="add-destination-skip-tls-verify-toggle"
|
|
name="skip_tls_verify"
|
|
:label="t('alert_destinations.skip_tls_verify')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Test Result Display -->
|
|
<DestinationTestResult
|
|
v-if="lastTestResult"
|
|
:result="lastTestResult"
|
|
:is-loading="isTestInProgress"
|
|
:show-success-response-body="dtVal === 'slack'"
|
|
data-test="prebuilt-test-result"
|
|
@retry="handleTestDestination"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Tabs for non-alert destinations OR custom alert destinations -->
|
|
<div v-if="!isAlerts || (isAlerts && dtVal === 'custom')" class="w-full pb-3">
|
|
<div class="app-tabs-container me-2 h-9 w-fit">
|
|
<AppTabs
|
|
data-test="add-destination-tabs"
|
|
:tabs="tabs"
|
|
class="tabs-selection-container"
|
|
:active-tab="typeVal"
|
|
@update:active-tab="setType"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="typeVal === 'email' && !getFormattedTemplates.length"
|
|
class="mb-3 flex w-full items-center"
|
|
>
|
|
<div class="me-2 text-sm font-medium">
|
|
{{ t("alert_destinations.noEmailTemplates") }}
|
|
</div>
|
|
<OButton variant="outline" size="sm" @click="createEmailTemplate">{{
|
|
t("alert_destinations.createEmailTemplate")
|
|
}}</OButton>
|
|
</div>
|
|
<!-- Name + Template row for custom alert destinations -->
|
|
<div v-if="isAlerts && dtVal === 'custom'" class="flex w-full gap-3">
|
|
<div class="w-1/2 py-1">
|
|
<OFormInput
|
|
data-test="add-destination-name-input"
|
|
name="name"
|
|
:label="t('alerts.name')"
|
|
required
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
<div class="w-1/2 py-1">
|
|
<OFormSelect
|
|
data-test="add-destination-template-select"
|
|
name="template"
|
|
:label="t('alert_destinations.template')"
|
|
required
|
|
:options="getFormattedTemplates"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- Name field for non-alert destinations (pipelines) -->
|
|
<div v-if="!isAlerts" class="w-full py-1">
|
|
<OFormInput
|
|
data-test="add-destination-name-input"
|
|
name="name"
|
|
:label="t('alerts.name')"
|
|
required
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
|
|
<template
|
|
v-if="
|
|
(isAlerts && dtVal === 'custom' && typeVal === 'http') ||
|
|
(!isAlerts && typeVal === 'http')
|
|
"
|
|
>
|
|
<div class="flex w-full gap-3">
|
|
<div class="w-1/2 py-1">
|
|
<OFormInput
|
|
data-test="add-destination-url-input"
|
|
name="url"
|
|
:label="t('alert_destinations.url')"
|
|
required
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
<div class="py-1" :class="{ 'w-1/4': !isAlerts, 'w-1/2': isAlerts }">
|
|
<OFormSelect
|
|
data-test="add-destination-method-select"
|
|
name="method"
|
|
:label="t('alert_destinations.method')"
|
|
required
|
|
:options="apiMethods"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
<div v-if="!isAlerts" class="w-1/4 py-1">
|
|
<OFormSelect
|
|
data-test="add-destination-output-format-select"
|
|
name="output_format"
|
|
:label="t('alert_destinations.output_format')"
|
|
required
|
|
:options="outputFormats"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="w-full py-2">
|
|
<div class="py-1 font-bold">{{ t("alert_destinations.headers") }}</div>
|
|
<div v-for="(header, index) in apiHeaders" :key="index" class="flex gap-2 pb-2">
|
|
<div class="ms-0 w-5/12">
|
|
<OFormInput
|
|
:data-test="`add-destination-header-${header['key']}-key-input`"
|
|
:name="`apiHeaders[${index}].key`"
|
|
:placeholder="t('alert_destinations.api_header')"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
<div class="ms-0 w-5/12">
|
|
<OFormInput
|
|
:data-test="`add-destination-header-${header['key']}-value-input`"
|
|
:name="`apiHeaders[${index}].value`"
|
|
:placeholder="t('alert_destinations.api_header_value')"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
<div class="ms-0 w-1/6">
|
|
<OButton
|
|
:data-test="`add-destination-header-${header['key']}-delete-btn`"
|
|
class="ms-1"
|
|
variant="ghost"
|
|
size="icon-circle-sm"
|
|
:title="t('alert_templates.edit')"
|
|
@click="deleteApiHeader(index)"
|
|
>
|
|
<OIcon name="delete" size="sm" />
|
|
</OButton>
|
|
<OButton
|
|
data-test="add-destination-add-header-btn"
|
|
v-if="index === apiHeaders.length - 1"
|
|
class="ms-1"
|
|
variant="ghost"
|
|
size="icon-circle-sm"
|
|
:title="t('alert_templates.edit')"
|
|
@click="addApiHeader()"
|
|
>
|
|
<OIcon name="add" size="sm" />
|
|
</OButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="w-full py-2">
|
|
<OFormSwitch
|
|
data-test="add-destination-skip-tls-verify-toggle"
|
|
name="skip_tls_verify"
|
|
:label="t('alert_destinations.skip_tls_verify')"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template v-if="typeVal === 'email' && (!isAlerts || dtVal === 'custom')">
|
|
<!-- Recipients are organization users, picked from the list rather
|
|
than typed: a mistyped address in a free-text field failed
|
|
silently at delivery time, with nothing in the UI to show for
|
|
it. Someone who is not a user yet is created in place via the
|
|
action below the options. -->
|
|
<OFormSelect
|
|
data-test="add-destination-emails-select"
|
|
name="emails"
|
|
:label="t('reports.recipients')"
|
|
required
|
|
multiple
|
|
searchable
|
|
:options="orgUserOptions"
|
|
labelKey="label"
|
|
valueKey="value"
|
|
:loading="isLoadingOrgUsers"
|
|
tabindex="0"
|
|
>
|
|
<template #after-options>
|
|
<OButton
|
|
variant="ghost-primary"
|
|
size="sm"
|
|
class="w-full justify-start"
|
|
icon-left="person-add"
|
|
data-test="add-destination-create-user-btn"
|
|
@click="openCreateUser"
|
|
>
|
|
{{ t("user.add") }}
|
|
</OButton>
|
|
</template>
|
|
</OFormSelect>
|
|
</template>
|
|
</OForm>
|
|
</div>
|
|
<div
|
|
class="border-border-default flex w-full flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<!-- Left side: Test and Preview buttons (only for prebuilt destinations) -->
|
|
<div v-if="showPrebuiltFinalActions" class="flex flex-wrap items-center gap-2">
|
|
<OButton
|
|
data-test="destination-preview-button"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="showPreview"
|
|
icon-left="preview"
|
|
>
|
|
{{ t("alert_destinations.preview") }}
|
|
</OButton>
|
|
<OButton
|
|
data-test="destination-test-button"
|
|
:loading="isTestInProgress"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="handleTestDestination"
|
|
icon-left="send"
|
|
>
|
|
{{ t("alert_destinations.test") }}
|
|
</OButton>
|
|
</div>
|
|
<div v-else></div>
|
|
|
|
<!-- Right side: Cancel and Save buttons -->
|
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
<OButton
|
|
data-test="add-destination-cancel-btn"
|
|
v-close-popup="true"
|
|
variant="outline"
|
|
size="sm-action"
|
|
@click="$emit('cancel:hideform')"
|
|
>{{ t("alerts.cancel") }}</OButton
|
|
>
|
|
<OButton
|
|
v-if="!isNewSlackDestination || showPrebuiltAdvancedSettings"
|
|
data-test="add-destination-submit-btn"
|
|
variant="primary"
|
|
size="sm-action"
|
|
type="submit"
|
|
form="add-destination-form"
|
|
@click="onSaveClick"
|
|
>{{ t("alerts.save") }}</OButton
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Creating a recipient without leaving the destination form. Reuses the
|
|
IAM AddUser component in its drawer form (see its `container` prop) so
|
|
there is one user-creation form in the app, not two. -->
|
|
<AddUser
|
|
v-model:open="showCreateUser"
|
|
container="drawer"
|
|
:roles="createUserRoles"
|
|
:userRole="currentUserRole"
|
|
:isCloud="isCloudDeployment"
|
|
@updated="onUserCreated"
|
|
/>
|
|
|
|
<!-- Destination Preview Modal -->
|
|
<DestinationPreview
|
|
v-model="showPreviewModal"
|
|
:type="dtVal"
|
|
:template-content="previewContent"
|
|
data-test="destination-preview-modal"
|
|
/>
|
|
</OPageLayout>
|
|
</template>
|
|
<script lang="ts" setup>
|
|
import { saveDestinationMutation } from "@/services/alert_destination.queries";
|
|
import { useOrgId } from "@/composables/query/useOrgId";
|
|
import { useMutation } from "@tanstack/vue-query";
|
|
import { ref, computed, onBeforeMount, onActivated, watch } from "vue";
|
|
import type { PropType } from "vue";
|
|
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
|
import { useStore } from "vuex";
|
|
import OButton from "@/lib/core/Button/OButton.vue";
|
|
import OForm from "@/lib/forms/Form/OForm.vue";
|
|
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
|
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
|
|
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
|
|
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
|
import OTag from "@/lib/core/Badge/OTag.vue";
|
|
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
|
import type { Template, Headers, DestinationPayload } from "@/ts/interfaces";
|
|
import { useRouter } from "vue-router";
|
|
import AppTabs from "@/components/common/AppTabs.vue";
|
|
import config from "@/aws-exports";
|
|
import usersService from "@/services/users";
|
|
import AddUser from "@/components/iam/users/AddUser.vue";
|
|
import { useReo } from "@/services/reodotdev_analytics";
|
|
import {
|
|
usePrebuiltDestinations,
|
|
type SlackSetupMetadata,
|
|
} from "@/composables/usePrebuiltDestinations";
|
|
import { isPrebuiltType, detectPrebuiltTypeFromUrl } from "@/utils/prebuilt-templates";
|
|
import { isValidSlackWebhookUrl } from "@/utils/prebuilt-templates/slack";
|
|
import { DEFAULT_SLACK_APP_NAME } from "@/utils/slackManifest";
|
|
import PrebuiltDestinationForm from "./PrebuiltDestinationForm.vue";
|
|
import { prebuiltDestinationDefaults } from "./PrebuiltDestinationForm.schema";
|
|
import PrebuiltDestinationSelector from "./PrebuiltDestinationSelector.vue";
|
|
import DestinationTestResult from "./DestinationTestResult.vue";
|
|
import DestinationPreview from "./DestinationPreview.vue";
|
|
import SlackDestinationSetup from "./SlackDestinationSetup.vue";
|
|
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
|
import { toast } from "@/lib/feedback/Toast/useToast";
|
|
import { useOForm, type FormFieldPath } from "@/lib/forms/Form/useOForm";
|
|
import {
|
|
makeAddDestinationSchema,
|
|
addDestinationDefaults,
|
|
type AddDestinationForm,
|
|
} from "./AddDestination.schema";
|
|
|
|
const props = defineProps({
|
|
templates: {
|
|
type: Array as PropType<Template[]>,
|
|
default: () => [],
|
|
},
|
|
destination: {
|
|
type: Object as PropType<DestinationPayload | null>,
|
|
default: null,
|
|
},
|
|
isAlerts: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
});
|
|
const emit = defineEmits(["get:destinations", "cancel:hideform"]);
|
|
const apiMethods = ["get", "post", "put"];
|
|
const outputFormats = ["json", "ndjson"];
|
|
const store = useStore();
|
|
const { t } = useI18nTyped();
|
|
|
|
// ── Email recipients ──────────────────────────────────────────────────────
|
|
// Recipients are organization users, so the picker needs the org's user list.
|
|
|
|
const orgUsers = ref<{ label: I18nText; value: string }[]>([]);
|
|
const isLoadingOrgUsers = ref(false);
|
|
const showCreateUser = ref(false);
|
|
// Role choices for the create-user drawer, from the same endpoint the IAM
|
|
// users page uses — the org decides what roles exist, not this form.
|
|
const createUserRoles = ref<{ label: I18nText; value: string }[]>([]);
|
|
const currentUserRole = ref("admin");
|
|
|
|
const orgUserOptions = computed(() => orgUsers.value);
|
|
const isCloudDeployment = computed(() => config.isCloud === "true");
|
|
const isEnterpriseDeployment = computed(() => config.isEnterprise === "true");
|
|
|
|
// OSS has neither the OAuth backend nor the enterprise manifest flow, so webhook is the only option.
|
|
const defaultSlackSetupMethod = (): "oauth" | "manifest" | "webhook" => {
|
|
if (isCloudDeployment.value) return "oauth";
|
|
return isEnterpriseDeployment.value ? "manifest" : "webhook";
|
|
};
|
|
|
|
const fetchOrgUsers = async () => {
|
|
isLoadingOrgUsers.value = true;
|
|
try {
|
|
const res = await usersService.orgUsers(store.state.selectedOrganization.identifier);
|
|
const list = res.data?.data ?? res.data ?? [];
|
|
|
|
orgUsers.value = list
|
|
.filter((user: any) => user?.email)
|
|
.map((user: any) => ({ label: raw(user.email), value: user.email }));
|
|
|
|
// AddUser gates which roles the operator may grant on the CURRENT user's
|
|
// role; it comes off this same response, as it does on the IAM page.
|
|
const me = list.find(
|
|
(user: any) => user?.email?.toLowerCase() === store.state.userInfo?.email?.toLowerCase(),
|
|
);
|
|
if (me?.role) currentUserRole.value = String(me.role).toLowerCase();
|
|
} catch {
|
|
// A failed fetch leaves the picker empty rather than breaking the form. The
|
|
// required-field rule still stops an empty destination being saved.
|
|
orgUsers.value = [];
|
|
} finally {
|
|
isLoadingOrgUsers.value = false;
|
|
}
|
|
};
|
|
|
|
const fetchCreateUserRoles = async () => {
|
|
try {
|
|
const res = await usersService.getRoles(store.state.selectedOrganization.identifier);
|
|
createUserRoles.value = Array.isArray(res.data)
|
|
? res.data.map((role: any) => ({ label: raw(role.label ?? role), value: role.value ?? role }))
|
|
: [];
|
|
} catch {
|
|
createUserRoles.value = [];
|
|
}
|
|
};
|
|
|
|
const openCreateUser = () => {
|
|
showCreateUser.value = true;
|
|
};
|
|
|
|
/**
|
|
* Refresh the list after a user is created and select the new address, so the
|
|
* round-trip ends where the user was going: a recipient chosen.
|
|
*/
|
|
const onUserCreated = async (created?: any) => {
|
|
showCreateUser.value = false;
|
|
const known = new Set(orgUsers.value.map((option) => option.value));
|
|
|
|
await fetchOrgUsers();
|
|
|
|
const added = created?.email ?? orgUsers.value.find((option) => !known.has(option.value))?.value;
|
|
if (!added) return;
|
|
|
|
const current = (form.state.values.emails ?? []) as string[];
|
|
if (!current.includes(added)) {
|
|
form.setFieldValue("emails", [...current, added]);
|
|
}
|
|
};
|
|
|
|
const { track } = useReo();
|
|
|
|
// Single form for custom, pipeline and prebuilt destinations (credentials are
|
|
// `credentials.*` fields); saveDestination dispatches by type.
|
|
const addDestinationSchema = makeAddDestinationSchema(t, props.isAlerts);
|
|
const form = useOForm({
|
|
defaultValues: addDestinationDefaults(),
|
|
schema: addDestinationSchema,
|
|
onSubmit: (value: AddDestinationForm) => saveDestination(value),
|
|
});
|
|
|
|
// Single submit entry-point: prebuilt types save via handlePrebuiltSave, custom/
|
|
// pipeline via saveCustomDestination. Returned so OForm awaits the real save (the
|
|
// footer Save spinner spans it).
|
|
function saveDestination(value: AddDestinationForm): Promise<void> | undefined {
|
|
return isPrebuiltDestination.value ? handlePrebuiltSave(value) : saveCustomDestination(value);
|
|
}
|
|
|
|
// Reactive reads of the discriminators + the api-headers array.
|
|
const dtVal = form.useStore((s: any) => s.values.destination_type as string);
|
|
const typeVal = form.useStore((s: any) => s.values.type as string);
|
|
const apiHeaders = form.useStore(
|
|
(s: any) => (s.values.apiHeaders ?? []) as { key: string; value: string }[],
|
|
);
|
|
const slackSetupMethod = form.useStore(
|
|
(state: { values: AddDestinationForm }) => state.values.slack_setup_method,
|
|
);
|
|
const slackTeamId = form.useStore(
|
|
(state: { values: AddDestinationForm }) => state.values.slack_team_id,
|
|
);
|
|
const slackTeamName = form.useStore(
|
|
(state: { values: AddDestinationForm }) => state.values.slack_team_name,
|
|
);
|
|
const slackChannelId = form.useStore(
|
|
(state: { values: AddDestinationForm }) => state.values.slack_channel_id,
|
|
);
|
|
const slackManifestReady = ref(false);
|
|
const originalSlackWebhookUrl = ref("");
|
|
|
|
const isUpdatingDestination = ref(false);
|
|
|
|
const orgId = useOrgId();
|
|
// One mutation for both branches: the caller picks create vs update, the
|
|
// declaration owns the cache consequence.
|
|
const saveDestinationWrite = useMutation(() =>
|
|
saveDestinationMutation(orgId.value, () => isUpdatingDestination.value),
|
|
);
|
|
const router = useRouter();
|
|
|
|
// Prebuilt destinations composable
|
|
const {
|
|
availableTypes,
|
|
testDestination,
|
|
createDestination,
|
|
updateDestination,
|
|
generatePreview,
|
|
isTestInProgress,
|
|
lastTestResult,
|
|
clearTestResult,
|
|
detectPrebuiltType,
|
|
} = usePrebuiltDestinations();
|
|
|
|
// Prebuilt credentials sub-object (`credentials.*`), read reactively for the
|
|
// Preview/Test buttons.
|
|
const prebuiltCredentials = form.useStore(
|
|
(s: any) => (s.values.credentials ?? {}) as Record<string, any>,
|
|
);
|
|
const destinationSearchQuery = ref("");
|
|
const showPreviewModal = ref(false);
|
|
const previewContent = ref("");
|
|
|
|
watch(
|
|
prebuiltCredentials,
|
|
() => {
|
|
if (lastTestResult.value) clearTestResult();
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
const isNewSlackDestination = computed(
|
|
() => props.isAlerts && !props.destination && dtVal.value === "slack",
|
|
);
|
|
const isSlackOAuthFlow = computed(
|
|
() => isNewSlackDestination.value && slackSetupMethod.value === "oauth",
|
|
);
|
|
const isSlackManifestFlow = computed(
|
|
() => isNewSlackDestination.value && slackSetupMethod.value === "manifest",
|
|
);
|
|
const isSlackOAuthConnected = computed(() => {
|
|
const webhookUrl = prebuiltCredentials.value.webhookUrl;
|
|
const channel = prebuiltCredentials.value.channel;
|
|
return (
|
|
typeof webhookUrl === "string" &&
|
|
isValidSlackWebhookUrl(webhookUrl) &&
|
|
typeof channel === "string" &&
|
|
channel.trim().length > 0 &&
|
|
slackTeamId.value.trim().length > 0 &&
|
|
slackTeamName.value.trim().length > 0 &&
|
|
slackChannelId.value.trim().length > 0
|
|
);
|
|
});
|
|
const showPrebuiltAdvancedSettings = computed(() => {
|
|
if (isSlackOAuthFlow.value) return isSlackOAuthConnected.value;
|
|
if (isSlackManifestFlow.value) return slackManifestReady.value;
|
|
return true;
|
|
});
|
|
const showPrebuiltFinalActions = computed(
|
|
() =>
|
|
props.isAlerts &&
|
|
(isPrebuiltDestination.value || (isUpdatingDestination.value && dtVal.value !== "custom")) &&
|
|
showPrebuiltAdvancedSettings.value,
|
|
);
|
|
const handleSlackFlowChange = (): void => {
|
|
clearTestResult();
|
|
};
|
|
const handleSlackReadinessChange = (ready: boolean): void => {
|
|
slackManifestReady.value = ready;
|
|
};
|
|
|
|
const issuePathToName = (path: readonly PropertyKey[]): string =>
|
|
path.reduce<string>(
|
|
(name, segment) =>
|
|
typeof segment === "number"
|
|
? `${name}[${segment}]`
|
|
: name
|
|
? `${name}.${String(segment)}`
|
|
: String(segment),
|
|
"",
|
|
);
|
|
|
|
const formValues = form.useStore((state: { values: AddDestinationForm }) => state.values);
|
|
watch(
|
|
formValues,
|
|
() => {
|
|
const result = addDestinationSchema.safeParse(form.state.values);
|
|
const invalidFields = new Set(
|
|
result.success ? [] : result.error.issues.map((issue) => issuePathToName(issue.path)),
|
|
);
|
|
|
|
for (const field of Object.keys(
|
|
form.state.fieldMeta ?? {},
|
|
) as FormFieldPath<AddDestinationForm>[]) {
|
|
const meta = form.getFieldMeta(field);
|
|
if (meta && (meta.errors?.length ?? 0) > 0 && !invalidFields.has(field)) {
|
|
form.setFieldMeta(field, { ...meta, errorMap: {} });
|
|
}
|
|
}
|
|
|
|
if (result.success) {
|
|
const errorMap = (form.state.errorMap ?? {}) as Record<string, unknown>;
|
|
if (errorMap.onDynamic != null) {
|
|
form.setErrorMap({ onDynamic: undefined });
|
|
}
|
|
}
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
// When no destination-type card is chosen yet, `destination_type` is empty so the
|
|
// schema rejects on name/url — but those fields aren't mounted until a card is
|
|
// picked, so the errors have nowhere to render and the click looks dead. Toast a
|
|
// fill-required message to cover that no-type-selected state.
|
|
const onSaveClick = () => {
|
|
if (props.isAlerts && !props.destination && !dtVal.value) {
|
|
toast({
|
|
variant: "error",
|
|
message: t("common.fillRequiredFields"),
|
|
timeout: 1500,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Bridge helpers for the non-<input> discriminators.
|
|
const setDestinationType = (v: string) => form.setFieldValue("destination_type", v ?? "");
|
|
const setType = (v: string) => form.setFieldValue("type", v);
|
|
|
|
const tabs = computed(() => {
|
|
// In edit mode for custom destinations, only show the tab for the current type
|
|
if (isUpdatingDestination.value && dtVal.value === "custom") {
|
|
const currentType = typeVal.value;
|
|
|
|
// Only return the tab matching the current destination type
|
|
if (currentType === "http") {
|
|
return [{ label: t("alerts.webhook"), value: "http", icon: "webhook" }];
|
|
} else if (currentType === "email") {
|
|
return [{ label: t("alerts.email"), value: "email", icon: "mail" }];
|
|
}
|
|
}
|
|
|
|
// In create mode, show all tabs
|
|
const tabs = [
|
|
{ label: t("alerts.webhook"), value: "http", icon: "webhook" },
|
|
{ label: t("alerts.email"), value: "email", icon: "mail" },
|
|
];
|
|
|
|
return tabs;
|
|
});
|
|
|
|
// Check if current destination type is prebuilt
|
|
const isPrebuiltDestination = computed(() => {
|
|
return !!(dtVal.value && dtVal.value !== "custom");
|
|
});
|
|
|
|
// Helper methods for displaying destination type in edit mode
|
|
const getDestinationTypeName = (typeId: string) => {
|
|
const type = availableTypes.value.find((t) => t.id === typeId);
|
|
return type ? type.name : typeId;
|
|
};
|
|
|
|
const getDestinationTypeIcon = (typeId: string) => {
|
|
const iconMap: Record<string, string> = {
|
|
slack: "chat",
|
|
discord: "forum",
|
|
msteams: "groups",
|
|
email: "email",
|
|
pagerduty: "warning",
|
|
opsgenie: "notifications_active",
|
|
servicenow: "support_agent",
|
|
custom: "settings",
|
|
};
|
|
return iconMap[typeId] || "webhook";
|
|
};
|
|
|
|
onActivated(() => setupDestinationData());
|
|
onBeforeMount(async () => {
|
|
setupDestinationData();
|
|
await Promise.all([fetchOrgUsers(), fetchCreateUserRoles()]);
|
|
});
|
|
|
|
// Watch for destination prop changes (important for edit mode dialog)
|
|
watch(
|
|
() => props.destination,
|
|
(newDest) => {
|
|
if (newDest && newDest.name) {
|
|
// Only run setup when destination has actual data (name is a good indicator)
|
|
setupDestinationData();
|
|
}
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
const setupDestinationData = () => {
|
|
originalSlackWebhookUrl.value = "";
|
|
if (props.destination) {
|
|
isUpdatingDestination.value = true;
|
|
// Resolve the destination_type discriminator FIRST; setDestType only records
|
|
// the choice, which rides into the single form.reset(record) below.
|
|
let destType = "";
|
|
const setDestType = (v: string) => {
|
|
destType = v;
|
|
};
|
|
|
|
const destHeaders: Record<string, any> = props.destination.headers || {};
|
|
|
|
// Set destination_type for prebuilt destinations in edit mode
|
|
// Parse metadata if it's a string
|
|
let parsedMetadata: any = null;
|
|
if (props.destination.metadata) {
|
|
try {
|
|
parsedMetadata =
|
|
typeof props.destination.metadata === "string"
|
|
? JSON.parse(props.destination.metadata)
|
|
: props.destination.metadata;
|
|
} catch (e) {
|
|
console.error("Failed to parse destination metadata:", e);
|
|
}
|
|
}
|
|
|
|
// Priority 1: Check metadata.prebuilt_type (most reliable for prebuilt destinations)
|
|
if (parsedMetadata?.prebuilt_type && isPrebuiltType(parsedMetadata.prebuilt_type)) {
|
|
setDestType(parsedMetadata.prebuilt_type);
|
|
}
|
|
// Priority 2: Check if template starts with 'system-prebuilt-' AND destination structure matches
|
|
// (Must have emails array for email, or specific prebuilt URL patterns for HTTP)
|
|
else if (props.destination.template?.startsWith("system-prebuilt-")) {
|
|
const templateType = props.destination.template.replace("system-prebuilt-", "");
|
|
// Only treat as prebuilt if structure matches the type
|
|
if (
|
|
templateType === "email" &&
|
|
props.destination.type === "email" &&
|
|
props.destination.emails
|
|
) {
|
|
setDestType("email");
|
|
} else if (props.destination.type === "http" && props.destination.url) {
|
|
// Use URL-only detection here — detectPrebuiltType also checks the template
|
|
// name, which would always match since we're already inside the
|
|
// system-prebuilt-* branch. A custom destination can have any template,
|
|
// so the URL is the only reliable signal at this point.
|
|
const urlType = detectPrebuiltTypeFromUrl(props.destination.url);
|
|
if (urlType && isPrebuiltType(urlType)) {
|
|
setDestType(urlType);
|
|
} else {
|
|
// Has system template but URL doesn't match prebuilt patterns - it's custom
|
|
setDestType("custom");
|
|
}
|
|
} else {
|
|
setDestType("custom");
|
|
}
|
|
}
|
|
// Priority 3: Check if template starts with 'prebuilt_' (user templates)
|
|
// Also verify the URL matches the prebuilt type to avoid misclassifying custom
|
|
// destinations that happen to use a template with a prebuilt_ prefix.
|
|
else if (props.destination.template?.startsWith("prebuilt_")) {
|
|
const extractedType = props.destination.template.replace("prebuilt_", "");
|
|
if (isPrebuiltType(extractedType) && props.destination.url) {
|
|
const urlType = detectPrebuiltTypeFromUrl(props.destination.url);
|
|
setDestType(urlType === extractedType ? extractedType : "custom");
|
|
} else {
|
|
setDestType(isPrebuiltType(extractedType) ? extractedType : "custom");
|
|
}
|
|
}
|
|
// Priority 4: Check if template includes 'prebuilt' (legacy format)
|
|
// Also verify the URL matches the prebuilt type (same guard as Priority 3).
|
|
else if (props.destination.template?.includes("prebuilt")) {
|
|
const parts = props.destination.template.split("-");
|
|
const extractedType = parts[parts.length - 1];
|
|
if (isPrebuiltType(extractedType) && props.destination.url) {
|
|
const urlType = detectPrebuiltTypeFromUrl(props.destination.url);
|
|
setDestType(urlType === extractedType ? extractedType : "custom");
|
|
} else {
|
|
setDestType(isPrebuiltType(extractedType) ? extractedType : "custom");
|
|
}
|
|
}
|
|
// Priority 5: Fallback to URL-based detection (for destinations without metadata)
|
|
else if (props.destination.url) {
|
|
const detectedType = detectPrebuiltType(props.destination);
|
|
if (detectedType) {
|
|
setDestType(detectedType);
|
|
} else {
|
|
setDestType("custom");
|
|
}
|
|
}
|
|
// Priority 6: No indicators - this is a custom destination
|
|
else {
|
|
setDestType("custom");
|
|
}
|
|
|
|
// Edit-prefill: seed via a single form.reset(record), not a per-field
|
|
// setFieldValue loop — reset() also clears field meta, whereas a setFieldValue
|
|
// loop leaves every prefilled field marked dirty/touched with stale errors, and
|
|
// this runs again on every onActivated. Start from the defaults so every key is
|
|
// present, then overlay the record.
|
|
const hasSavedSlackOAuthMetadata =
|
|
destType === "slack" &&
|
|
parsedMetadata?.setup_method === "oauth" &&
|
|
typeof parsedMetadata.slack_team_id === "string" &&
|
|
parsedMetadata.slack_team_id.trim().length > 0 &&
|
|
typeof parsedMetadata.slack_team_name === "string" &&
|
|
parsedMetadata.slack_team_name.trim().length > 0 &&
|
|
typeof parsedMetadata.slack_channel_id === "string" &&
|
|
parsedMetadata.slack_channel_id.trim().length > 0;
|
|
const hasSavedSlackManifestMetadata =
|
|
destType === "slack" &&
|
|
parsedMetadata?.setup_method === "manifest" &&
|
|
typeof parsedMetadata.slack_app_name === "string" &&
|
|
parsedMetadata.slack_app_name.trim().length > 0;
|
|
|
|
const record: AddDestinationForm = {
|
|
...addDestinationDefaults(),
|
|
destination_type: destType,
|
|
name: props.destination.name,
|
|
url: props.destination.url ?? "",
|
|
method: props.destination.method ?? "post",
|
|
skip_tls_verify: props.destination.skip_tls_verify ?? false,
|
|
template: props.destination.template ?? "",
|
|
emails: props.destination?.emails ?? [],
|
|
type: props.destination.type || "http",
|
|
slack_setup_method: hasSavedSlackOAuthMetadata
|
|
? "oauth"
|
|
: hasSavedSlackManifestMetadata
|
|
? "manifest"
|
|
: "webhook",
|
|
slack_app_name: hasSavedSlackManifestMetadata
|
|
? parsedMetadata.slack_app_name.trim()
|
|
: DEFAULT_SLACK_APP_NAME,
|
|
slack_team_id: hasSavedSlackOAuthMetadata ? parsedMetadata.slack_team_id : "",
|
|
slack_team_name: hasSavedSlackOAuthMetadata ? parsedMetadata.slack_team_name : "",
|
|
slack_channel_id: hasSavedSlackOAuthMetadata ? parsedMetadata.slack_channel_id : "",
|
|
// Prebuilt credentials, typed to the active type's fields. Custom → {}.
|
|
credentials:
|
|
destType && destType !== "custom"
|
|
? prebuiltDestinationDefaults(destType, extractPrebuiltCredentials(destType))
|
|
: {},
|
|
};
|
|
|
|
// Only CUSTOM headers reach the UI array; system/prebuilt ones stay implicit.
|
|
// When there are none, the default apiHeaders row is kept.
|
|
if (Object.keys(destHeaders).length) {
|
|
const systemHeaders = ["Content-Type", "Authorization", "X-Routing-Key"];
|
|
const customHeadersOnly = Object.entries(destHeaders).filter(
|
|
([key]) => !systemHeaders.includes(key),
|
|
);
|
|
if (customHeadersOnly.length > 0) {
|
|
record.apiHeaders = customHeadersOnly.map(([key, value]) => ({
|
|
key,
|
|
value: value as string,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Only override the default when the saved destination carries one.
|
|
if (props.destination.output_format) {
|
|
record.output_format = props.destination.output_format;
|
|
}
|
|
|
|
if (destType === "slack") {
|
|
originalSlackWebhookUrl.value = (props.destination.url ?? "").trim();
|
|
}
|
|
|
|
// Template name is stored/displayed as-is (e.g. "prebuilt_slack") — the
|
|
// dropdown's first option carries that value, so edit mode matches automatically.
|
|
form.reset(record);
|
|
}
|
|
};
|
|
|
|
// Rebuild the credential sub-object for an existing prebuilt destination from its
|
|
// persisted shape (metadata `credential_*` vars, url/emails, and the auth headers
|
|
// where sensitive values live). Pure over props.destination — used to seed the
|
|
// single form.reset(record) in edit mode.
|
|
const extractPrebuiltCredentials = (typeId: string): Record<string, any> => {
|
|
const credentials: Record<string, any> = {};
|
|
if (!props.destination) return credentials;
|
|
|
|
// Step 1: Parse metadata and remove the credential_ prefix. `metadata` is not
|
|
// on the DestinationPayload type but is present on saved records at runtime.
|
|
const rawMetadata = props.destination.metadata;
|
|
if (rawMetadata) {
|
|
try {
|
|
const metadata = typeof rawMetadata === "string" ? JSON.parse(rawMetadata) : rawMetadata;
|
|
Object.entries(metadata).forEach(([key, value]) => {
|
|
if (key.startsWith("credential_")) {
|
|
credentials[key.replace("credential_", "")] = value;
|
|
} else if (key === "routing_key") {
|
|
// PagerDuty stores the integration key as the bare `routing_key`
|
|
// metadata variable (substituted into the request body).
|
|
credentials.integrationKey = value;
|
|
} else if (typeId === "pagerduty" && key === "severity") {
|
|
credentials.severity = value;
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to parse destination metadata:", e);
|
|
}
|
|
}
|
|
|
|
// Step 2: Restore sensitive fields from destination properties.
|
|
// webhookUrl is stored in the url field for webhook-based destinations.
|
|
if (props.destination.url) {
|
|
credentials.webhookUrl = props.destination.url;
|
|
}
|
|
// For ServiceNow, instanceUrl is the base URL.
|
|
if (typeId === "servicenow" && props.destination.url) {
|
|
credentials.instanceUrl = props.destination.url;
|
|
}
|
|
// For email destinations, recipients are in the emails field.
|
|
if (typeId === "email" && props.destination.emails) {
|
|
credentials.recipients = Array.isArray(props.destination.emails)
|
|
? props.destination.emails.join(", ")
|
|
: props.destination.emails;
|
|
}
|
|
// PagerDuty: integrationKey from routing_key metadata above; fall back to the
|
|
// X-Routing-Key header for older destinations.
|
|
if (
|
|
typeId === "pagerduty" &&
|
|
!credentials.integrationKey &&
|
|
props.destination.headers?.["X-Routing-Key"]
|
|
) {
|
|
credentials.integrationKey = props.destination.headers["X-Routing-Key"];
|
|
}
|
|
// Opsgenie: apiKey is in the Authorization header.
|
|
if (typeId === "opsgenie" && props.destination.headers?.["Authorization"]) {
|
|
const authHeader = props.destination.headers["Authorization"];
|
|
if (authHeader.startsWith("GenieKey ")) {
|
|
credentials.apiKey = authHeader.replace("GenieKey ", "");
|
|
}
|
|
}
|
|
// ServiceNow: username:password are in the Basic auth Authorization header.
|
|
if (typeId === "servicenow" && props.destination.headers?.["Authorization"]) {
|
|
const authHeader = props.destination.headers["Authorization"];
|
|
if (authHeader.startsWith("Basic ")) {
|
|
try {
|
|
const decoded = atob(authHeader.replace("Basic ", ""));
|
|
const colonIndex = decoded.indexOf(":");
|
|
if (colonIndex > 0) {
|
|
credentials.username = decoded.substring(0, colonIndex);
|
|
credentials.password = decoded.substring(colonIndex + 1);
|
|
}
|
|
} catch {
|
|
// Can't decode — leave credential fields blank.
|
|
}
|
|
}
|
|
}
|
|
// Non-sensitive fields (severity, priority, assignmentGroup, username, …) are
|
|
// restored from metadata via Step 1. Sensitive fields containing "password",
|
|
// "key", or "token" are NOT persisted to metadata for security.
|
|
return credentials;
|
|
};
|
|
|
|
const getFormattedTemplates = computed(() =>
|
|
props.templates
|
|
.filter((template) => {
|
|
if (typeVal.value === "email" && template.type === "email") return true;
|
|
else if (typeVal.value !== "email") return true;
|
|
return false;
|
|
})
|
|
.map((template) => template.name),
|
|
);
|
|
|
|
// The prebuilt template for this destination type, sourced from the API
|
|
// (isPrebuilt: true). Falls back to the constructed name if the API hasn't
|
|
// returned it yet (e.g. templates list still loading).
|
|
const templateNameFor = (type: string): string => {
|
|
if (!type || type === "custom") return "";
|
|
const expectedName = `prebuilt_${type}`;
|
|
const fromApi = props.templates.find((tpl) => tpl.isPrebuilt && tpl.name === expectedName);
|
|
return fromApi?.name ?? expectedName;
|
|
};
|
|
|
|
const defaultPrebuiltTemplateName = computed(() => templateNameFor(dtVal.value));
|
|
|
|
// Template choices for a prebuilt destination: the API-sourced prebuilt
|
|
// template for this type as the first (default) option, followed by any
|
|
// user-created custom templates of the matching kind (email vs http).
|
|
// Other prebuilt types are excluded to prevent cross-type mismatches.
|
|
const prebuiltTemplateOptions = computed(() => {
|
|
const isEmailType = dtVal.value === "email";
|
|
const matching = props.templates.filter((template) => {
|
|
if (template.isPrebuilt) return false;
|
|
if (isEmailType) return template.type === "email";
|
|
return template.type !== "email";
|
|
});
|
|
|
|
const options: { label: I18nText; value: string }[] = [];
|
|
|
|
if (defaultPrebuiltTemplateName.value) {
|
|
const defaultLabel = t("alert_destinations.templateDefaultOption", {
|
|
name: defaultPrebuiltTemplateName.value,
|
|
});
|
|
options.push({ label: raw(defaultLabel), value: defaultPrebuiltTemplateName.value });
|
|
}
|
|
|
|
matching.forEach((template) => {
|
|
options.push({ label: raw(template.name), value: template.name });
|
|
});
|
|
|
|
return options;
|
|
});
|
|
|
|
// Select destination type (prebuilt or custom) — bridges the card grid choice
|
|
// into the form and swaps the discriminated branch WITHOUT carrying stale
|
|
// inactive-branch values into the save.
|
|
const selectDestinationType = (type: string) => {
|
|
form.setFieldValue("destination_type", type);
|
|
clearTestResult();
|
|
slackManifestReady.value = false;
|
|
|
|
if (type === "custom") {
|
|
// Switch to custom mode — clear any prebuilt credentials.
|
|
form.setFieldValue("credentials", {});
|
|
form.setFieldValue("type", "http");
|
|
form.setFieldValue("url", "");
|
|
form.setFieldValue("template", "");
|
|
} else {
|
|
// Seed a fresh, per-type credential sub-object (defaults for that type) so
|
|
// switching types never carries stale credential keys into the save.
|
|
form.setFieldValue("credentials", prebuiltDestinationDefaults(type, {}));
|
|
form.setFieldValue("type", type === "email" ? "email" : "http");
|
|
form.setFieldValue("template", templateNameFor(type));
|
|
if (type === "slack") {
|
|
form.setFieldValue("slack_setup_method", defaultSlackSetupMethod());
|
|
form.setFieldValue("slack_app_name", DEFAULT_SLACK_APP_NAME);
|
|
form.setFieldValue("slack_team_id", "");
|
|
form.setFieldValue("slack_team_name", "");
|
|
form.setFieldValue("slack_channel_id", "");
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle prebuilt destination test
|
|
const handleTestDestination = async () => {
|
|
if (!isPrebuiltDestination.value) return;
|
|
|
|
try {
|
|
await testDestination(dtVal.value, prebuiltCredentials.value);
|
|
} catch {
|
|
// testDestination owns user-facing sanitized error state.
|
|
}
|
|
};
|
|
|
|
// Show template preview
|
|
const showPreview = async () => {
|
|
if (!isPrebuiltDestination.value) return;
|
|
|
|
try {
|
|
// Clear previous content
|
|
previewContent.value = "";
|
|
|
|
// Fetch and generate preview
|
|
const preview = await generatePreview(dtVal.value, prebuiltCredentials.value);
|
|
previewContent.value = preview;
|
|
|
|
// Only show modal after content is ready
|
|
showPreviewModal.value = true;
|
|
} catch {
|
|
toast({
|
|
variant: "error",
|
|
message: t("alert_destinations.previewError"),
|
|
});
|
|
}
|
|
};
|
|
|
|
// Save a prebuilt destination — invoked by saveDestination once the form's schema
|
|
// passes. name/template/apiHeaders/skip_tls_verify and `credentials` all come from
|
|
// the validated `value`.
|
|
const slackSetupMetadataFor = (value: AddDestinationForm): SlackSetupMetadata | undefined => {
|
|
if (value.destination_type !== "slack") return undefined;
|
|
const webhookUrl = value.credentials.webhookUrl;
|
|
if (
|
|
isUpdatingDestination.value &&
|
|
typeof webhookUrl === "string" &&
|
|
webhookUrl.trim() !== originalSlackWebhookUrl.value
|
|
) {
|
|
return { setup_method: "webhook" };
|
|
}
|
|
if (value.slack_setup_method === "oauth") {
|
|
return {
|
|
setup_method: "oauth",
|
|
slack_team_id: value.slack_team_id.trim(),
|
|
slack_team_name: value.slack_team_name.trim(),
|
|
slack_channel_id: value.slack_channel_id.trim(),
|
|
};
|
|
}
|
|
if (value.slack_setup_method === "manifest") {
|
|
return {
|
|
setup_method: "manifest",
|
|
slack_app_name: value.slack_app_name.trim(),
|
|
};
|
|
}
|
|
return { setup_method: "webhook" };
|
|
};
|
|
|
|
async function handlePrebuiltSave(value: AddDestinationForm) {
|
|
try {
|
|
// Scope credentials to the ACTIVE type's fields (dropping any keys left over
|
|
// from a previous type selection) and coerce them exactly as the schema does.
|
|
const credentials = prebuiltDestinationDefaults(
|
|
value.destination_type,
|
|
(value.credentials ?? {}) as Record<string, unknown>,
|
|
);
|
|
// Build custom headers object from the api-headers array-field
|
|
const customHeaders: Headers = {};
|
|
(value.apiHeaders || []).forEach((header) => {
|
|
if (header.key && header.value) customHeaders[header.key] = header.value;
|
|
});
|
|
|
|
const selectedTemplate = (value.template || "").trim();
|
|
const templateOverride = isUpdatingDestination.value
|
|
? selectedTemplate || undefined
|
|
: selectedTemplate && selectedTemplate !== templateNameFor(value.destination_type)
|
|
? selectedTemplate
|
|
: undefined;
|
|
|
|
if (isUpdatingDestination.value) {
|
|
// Update existing prebuilt destination
|
|
await updateDestination(
|
|
value.destination_type,
|
|
props.destination?.name ?? "", // original name
|
|
value.name, // potentially new name
|
|
credentials,
|
|
customHeaders, // custom headers
|
|
value.skip_tls_verify || false, // skipTlsVerify
|
|
templateOverride,
|
|
slackSetupMetadataFor(value),
|
|
);
|
|
} else {
|
|
// Create new prebuilt destination
|
|
await createDestination(
|
|
value.destination_type,
|
|
value.name,
|
|
credentials,
|
|
customHeaders, // custom headers
|
|
value.skip_tls_verify || false, // skipTlsVerify
|
|
templateOverride,
|
|
slackSetupMetadataFor(value),
|
|
);
|
|
}
|
|
|
|
// `createDestination`/`updateDestination` above come from usePrebuiltDestinations,
|
|
// which invalidates the scope itself.
|
|
emit("get:destinations");
|
|
emit("cancel:hideform");
|
|
} catch {
|
|
// The composable presents a sanitized error toast and rethrows so submission
|
|
// state settles without exposing credential-bearing request details.
|
|
}
|
|
}
|
|
|
|
// The custom/pipeline save path (dispatched from saveDestination for non-prebuilt
|
|
// types). OForm calls the submit only once the schema (incl. the type-keyed
|
|
// superRefine) passes — the schema, not a manual guard, gates the save. `value`
|
|
// is the validated payload source of truth; the payload is built with explicit
|
|
// keys so no schema-only field (e.g. `credentials`) leaks into the request.
|
|
function saveCustomDestination(value: AddDestinationForm) {
|
|
const dismiss = toast({
|
|
variant: "loading",
|
|
message: t("common.pleaseWait"),
|
|
timeout: 0,
|
|
});
|
|
const headers: Headers = {};
|
|
(value.apiHeaders || []).forEach((header) => {
|
|
if (header.key && header.value) headers[header.key] = header.value;
|
|
});
|
|
|
|
const payload: Partial<DestinationPayload> = {
|
|
url: value.url,
|
|
method: value.method,
|
|
skip_tls_verify: value.skip_tls_verify,
|
|
template: props.isAlerts ? value.template : "",
|
|
headers: headers,
|
|
name: value.name,
|
|
};
|
|
|
|
if (!props.isAlerts) {
|
|
payload["output_format"] = value.output_format;
|
|
}
|
|
|
|
if (value.type === "email") {
|
|
payload["type"] = "email";
|
|
payload["emails"] = value.emails ?? [];
|
|
}
|
|
|
|
if (isUpdatingDestination.value) {
|
|
track("Button Click", {
|
|
button: "Update Destination",
|
|
page: "Add Destination",
|
|
});
|
|
return saveDestinationWrite
|
|
.mutateAsync({ destination_name: value.name, data: payload })
|
|
.then(() => {
|
|
dismiss();
|
|
emit("get:destinations");
|
|
emit("cancel:hideform");
|
|
toast({
|
|
variant: "success",
|
|
message: t("alert_destinations.saved"),
|
|
});
|
|
})
|
|
.catch((err: any) => {
|
|
if (err.response?.status == 403) {
|
|
return;
|
|
}
|
|
dismiss();
|
|
toast({
|
|
variant: "error",
|
|
message: err.response?.data?.error || err.response?.data?.message,
|
|
});
|
|
});
|
|
} else {
|
|
track("Button Click", {
|
|
button: "Create Destination",
|
|
page: "Add Destination",
|
|
});
|
|
return saveDestinationWrite
|
|
.mutateAsync({ destination_name: value.name, data: payload })
|
|
.then(() => {
|
|
dismiss();
|
|
emit("get:destinations");
|
|
emit("cancel:hideform");
|
|
toast({
|
|
variant: "success",
|
|
message: t("alert_destinations.saved"),
|
|
});
|
|
})
|
|
.catch((err: any) => {
|
|
if (err.response?.status == 403) {
|
|
return;
|
|
}
|
|
dismiss();
|
|
toast({
|
|
variant: "error",
|
|
message: err.response?.data?.error || err.response?.data?.message,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add/remove operate on the apiHeaders array via push/removeFieldValue; the
|
|
// template v-for keys rows by index, and the delete button passes the row index.
|
|
const addApiHeader = (key = "", value = "") => {
|
|
(form as any).pushFieldValue("apiHeaders", { key, value });
|
|
};
|
|
const deleteApiHeader = (index: number) => {
|
|
(form as any).removeFieldValue("apiHeaders", index);
|
|
const rows = (form.getFieldValue("apiHeaders") ?? []) as {
|
|
key: string;
|
|
value: string;
|
|
}[];
|
|
// Always keep at least one (blank) row so the add button stays reachable.
|
|
if (!rows.length) (form as any).pushFieldValue("apiHeaders", { key: "", value: "" });
|
|
};
|
|
|
|
const createEmailTemplate = () => {
|
|
router.push({
|
|
name: "alertTemplates",
|
|
query: {
|
|
action: "add",
|
|
type: "email",
|
|
org_identifier: store.state.selectedOrganization.identifier,
|
|
},
|
|
});
|
|
};
|
|
</script>
|