456 lines
19 KiB
Vue
456 lines
19 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>
|
|
<div
|
|
class="step-alert-conditions rounded-default bg-surface-overlay border-border-default mx-auto w-full border"
|
|
>
|
|
<!-- Section header -->
|
|
<div class="border-border-default flex items-center border-b px-3 py-2.5">
|
|
<div class="rounded-default bg-theme-accent mr-2 h-4 w-0.75 shrink-0" />
|
|
<span class="text-compact text-text-heading font-semibold tracking-[0.01em]">{{
|
|
t("alerts.alertSettings.sectionTitle")
|
|
}}</span>
|
|
</div>
|
|
|
|
<!-- The AddAlert orchestrator owns the ONE <OForm> and provides
|
|
FORM_CONTEXT_KEY. The OForm* fields below inject that form and bind by
|
|
nested `name=` (trigger_condition.*, destinations, creates_incident); the
|
|
composed schema in AddAlert.schema.ts validates them on save. -->
|
|
<div class="px-3 py-2">
|
|
<div>
|
|
<!-- For Real-Time Alerts -->
|
|
<template v-if="isRealTime === 'true'">
|
|
<!-- Silence Notification (Cooldown) -->
|
|
<div class="mb-4 flex items-start justify-start pb-3">
|
|
<div class="text-text-heading flex h-7 w-47.5 items-center font-semibold">
|
|
{{ t("alerts.silenceNotification") + " *" }}
|
|
<OIcon name="info" size="sm" class="ml-1 cursor-pointer" />
|
|
<OTooltip :content="t('alerts.alertSettings.cooldownTooltip')" side="right" />
|
|
</div>
|
|
<div class="mr-2 flex w-fit flex-col gap-1">
|
|
<div class="flex items-center">
|
|
<div class="w-21.75">
|
|
<OFormInput
|
|
name="trigger_condition.silence"
|
|
type="number"
|
|
min="0"
|
|
data-test="alert-settings-silence-duration-input"
|
|
>
|
|
<!-- Message rendered below at pair width — see silenceError. -->
|
|
<template #error />
|
|
</OFormInput>
|
|
</div>
|
|
<div
|
|
class="bg-input-addon-bg text-input-addon-text text-compact flex h-8.5 min-w-22.5 items-center justify-center"
|
|
>
|
|
{{ t("alerts.minutes") }}
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="silenceError"
|
|
class="text-input-error-text text-xs whitespace-nowrap"
|
|
data-test="alert-settings-silence-error"
|
|
role="alert"
|
|
>
|
|
{{ silenceError }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Destinations -->
|
|
<div class="mb-4 flex items-start pb-4">
|
|
<div class="text-text-heading flex h-7 w-47.5 items-center font-semibold">
|
|
{{ t("alerts.destination") + " *" }}
|
|
<OIcon name="info" size="sm" class="ml-1 cursor-pointer" />
|
|
<OTooltip :content="t('alerts.alertSettings.destinationsTooltip')" side="right" />
|
|
</div>
|
|
<!-- Combined destinations + (enterprise) workflows picker. It owns its
|
|
refresh / add buttons and keeps the original data-test hooks; the
|
|
label above and the error below stay here. Deliberately NOT
|
|
name=-bound: one control writes two form fields, so both go up
|
|
through the parent's setFieldValue via the events below. -->
|
|
<div class="flex flex-col">
|
|
<AlertTargetsSelect
|
|
:destinations="destinations"
|
|
:workflows="workflows"
|
|
:destination-options="formattedDestinations"
|
|
:workflow-options="workflowOptions"
|
|
:workflows-enabled="workflowsEnabled"
|
|
:error="!!destinationsError"
|
|
@update:destinations="$emit('update:destinations', $event)"
|
|
@update:workflows="$emit('update:workflows', $event)"
|
|
@refresh="refreshTargets"
|
|
@create-destination="routeToCreateDestination"
|
|
@create-workflow="routeToCreateWorkflow"
|
|
/>
|
|
<div v-if="destinationsError" class="text-red-8 text-2xs pt-1 leading-3">
|
|
{{ destinationsError }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- For Scheduled Alerts -->
|
|
<template v-else>
|
|
<!-- Period -->
|
|
<div ref="periodFieldRef" class="mr-2 mb-4! flex items-start">
|
|
<div class="text-text-heading flex h-7 w-47.5 items-center font-semibold">
|
|
{{ t("alerts.period") + " *" }}
|
|
<OIcon name="info" size="sm" class="ml-1 cursor-pointer" />
|
|
<OTooltip :content="t('alerts.alertSettings.periodTooltip')" side="right" />
|
|
</div>
|
|
<div class="mr-2 flex w-fit flex-col gap-1">
|
|
<div class="flex items-center">
|
|
<div class="w-21.75">
|
|
<OFormInput
|
|
name="trigger_condition.period"
|
|
type="number"
|
|
min="1"
|
|
:debounce="300"
|
|
data-test="alert-settings-period-input"
|
|
@update:model-value="handlePeriodChange"
|
|
>
|
|
<!-- Message rendered below at pair width — see periodError. -->
|
|
<template #error />
|
|
</OFormInput>
|
|
</div>
|
|
<div
|
|
class="bg-input-addon-bg text-input-addon-text text-compact flex h-8.5 min-w-22.5 items-center justify-center"
|
|
>
|
|
{{ t("alerts.minutes") }}
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="periodError"
|
|
class="text-input-error-text text-xs whitespace-nowrap"
|
|
data-test="alert-settings-period-error"
|
|
role="alert"
|
|
>
|
|
{{ periodError }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Silence Notification (Cooldown) for Scheduled Alerts -->
|
|
<div ref="silenceFieldRef" class="mr-2 mb-4! flex items-start">
|
|
<div class="text-text-heading flex h-7 w-47.5 items-center font-semibold">
|
|
{{ t("alerts.silenceNotification") + " *" }}
|
|
<OIcon name="info" size="sm" class="ml-1 cursor-pointer" />
|
|
<OTooltip :content="t('alerts.alertSettings.cooldownTooltip')" side="right" />
|
|
</div>
|
|
<div class="mr-2 flex w-fit flex-col gap-1">
|
|
<div class="flex items-center">
|
|
<div class="w-21.75">
|
|
<OFormInput
|
|
name="trigger_condition.silence"
|
|
type="number"
|
|
min="0"
|
|
:debounce="300"
|
|
data-test="alert-settings-silence-duration-input"
|
|
>
|
|
<!-- Message rendered below at pair width — see silenceError. -->
|
|
<template #error />
|
|
</OFormInput>
|
|
</div>
|
|
<div
|
|
class="bg-input-addon-bg text-input-addon-text text-compact flex h-8.5 min-w-22.5 items-center justify-center"
|
|
>
|
|
{{ t("alerts.minutes") }}
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="silenceError"
|
|
class="text-input-error-text text-xs whitespace-nowrap"
|
|
data-test="alert-settings-silence-error"
|
|
role="alert"
|
|
>
|
|
{{ silenceError }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Destinations -->
|
|
<div ref="destinationsFieldRef" class="mr-2 mb-4! flex items-start">
|
|
<div class="text-text-heading flex h-7 w-47.5 items-center font-semibold">
|
|
{{ t("alerts.destination") + " *" }}
|
|
<OIcon name="info" size="sm" class="ml-1 cursor-pointer" />
|
|
<OTooltip :content="t('alerts.alertSettings.destinationsTooltip')" side="right" />
|
|
</div>
|
|
<!-- Combined destinations + (enterprise) workflows picker. It owns its
|
|
refresh / add buttons and keeps the original data-test hooks; the
|
|
label above and the error below stay here. Deliberately NOT
|
|
name=-bound: one control writes two form fields, so both go up
|
|
through the parent's setFieldValue via the events below. -->
|
|
<div class="flex flex-col">
|
|
<AlertTargetsSelect
|
|
:destinations="destinations"
|
|
:workflows="workflows"
|
|
:destination-options="formattedDestinations"
|
|
:workflow-options="workflowOptions"
|
|
:workflows-enabled="workflowsEnabled"
|
|
:error="!!destinationsError"
|
|
@update:destinations="$emit('update:destinations', $event)"
|
|
@update:workflows="$emit('update:workflows', $event)"
|
|
@refresh="refreshTargets"
|
|
@create-destination="routeToCreateDestination"
|
|
@create-workflow="routeToCreateWorkflow"
|
|
/>
|
|
<div v-if="destinationsError" class="text-red-8 text-2xs pt-1 leading-3">
|
|
{{ destinationsError }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Creates Incident toggle — shown for all alert types -->
|
|
<div class="mb-4! flex items-start">
|
|
<div class="text-text-heading flex h-7 w-47.5 items-center font-semibold">
|
|
{{ t("alerts.alertSettings.createsIncident") }}
|
|
<OIcon name="info" size="sm" class="ml-1 cursor-pointer" />
|
|
<OTooltip :content="t('alerts.alertSettings.createsIncidentTooltip')" side="right" />
|
|
</div>
|
|
<OFormSwitch name="creates_incident" data-test="alert-creates-incident-toggle" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { computed, defineComponent, inject, onMounted, ref, type PropType } from "vue";
|
|
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
|
import { useStore } from "vuex";
|
|
import { useRouter } from "vue-router";
|
|
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
|
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
|
|
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
|
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
|
import AlertTargetsSelect from "@/components/alerts/AlertTargetsSelect.vue";
|
|
import workflowService from "@/services/workflows";
|
|
import config from "@/aws-exports";
|
|
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
|
|
import { firstFieldError } from "@/lib/forms/Form/fieldError";
|
|
import { convertMinutesToCron } from "@/utils/zincutils";
|
|
|
|
export default defineComponent({
|
|
name: "Step3AlertConditions",
|
|
components: {
|
|
OFormInput,
|
|
OFormSwitch,
|
|
OTooltip,
|
|
OIcon,
|
|
AlertTargetsSelect,
|
|
},
|
|
props: {
|
|
formData: {
|
|
type: Object as PropType<any>,
|
|
required: true,
|
|
},
|
|
isRealTime: {
|
|
type: String,
|
|
default: "false",
|
|
},
|
|
// Passed by the parent but not consumed here (kept to avoid attr fallthrough).
|
|
columns: {
|
|
type: Array as PropType<any[]>,
|
|
default: () => [],
|
|
},
|
|
isAggregationEnabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
destinations: {
|
|
type: Array as PropType<any[]>,
|
|
default: () => [],
|
|
},
|
|
// Enterprise-only: workflow ids linked to this alert. Read-view off the ONE
|
|
// form (AddAlert passes `formData.workflows`); writes go back up through
|
|
// `update:workflows` → the parent's setFieldValue, never mutated here.
|
|
workflows: {
|
|
type: Array as PropType<any[]>,
|
|
default: () => [],
|
|
},
|
|
formattedDestinations: {
|
|
type: Array as PropType<any[]>,
|
|
default: () => [],
|
|
},
|
|
},
|
|
emits: [
|
|
"update:trigger",
|
|
"update:aggregation",
|
|
"update:isAggregationEnabled",
|
|
"update:destinations",
|
|
"refresh:destinations",
|
|
"update:workflows",
|
|
"update:promqlCondition",
|
|
],
|
|
setup(props, { emit }) {
|
|
const { t } = useI18nTyped();
|
|
const store = useStore();
|
|
const router = useRouter();
|
|
|
|
// Field refs consumed by the parent's AlertFocusManager (registered off the
|
|
// step ref). Scheduled-only.
|
|
const periodFieldRef = ref<any>(null);
|
|
const silenceFieldRef = ref<any>(null);
|
|
const destinationsFieldRef = ref<any>(null);
|
|
|
|
// Period / silence are composite "number + Minutes addon" fields: a 5.4rem
|
|
// OFormInput glued to a unit block. OFormInput renders its message INSIDE
|
|
// that narrow width, wrapping it into a ragged column and growing the field,
|
|
// which pushes the addon out of line. Empty #error slot suppresses the inline
|
|
// text (the field keeps its red border) and we render the message in a
|
|
// full-width sibling below the pair. Reads the same R3-timed field errors
|
|
// OFormInput would have surfaced — single source of truth, wider display.
|
|
const form: any = inject(FORM_CONTEXT_KEY, null);
|
|
const fieldError = (path: string) =>
|
|
form
|
|
? form.useStore((s: any) => firstFieldError(s.fieldMeta?.[path]?.errors ?? []))
|
|
: computed(() => undefined);
|
|
const periodError = fieldError("trigger_condition.period");
|
|
const silenceError = fieldError("trigger_condition.silence");
|
|
// Destinations is NOT an OFormSelect any more (AlertTargetsSelect below is a
|
|
// plain controlled component covering destinations + workflows), so its
|
|
// schema error has no wrapper to render it — surface it the same way period
|
|
// and silence do. The rule is "at least one destination OR workflow" and is
|
|
// keyed on `destinations` in AddAlert.schema.ts, so it lands on this path.
|
|
const destinationsError = fieldError("destinations");
|
|
|
|
// ── Workflows (enterprise/cloud only) ────────────────────────────────────
|
|
// Options are self-fetched here rather than threaded down the whole alert-form
|
|
// chain like destinations. In OSS the group is never built, no list is
|
|
// fetched, and — since `workflows` stays [] — the "destination OR workflow"
|
|
// rule reduces to the original "destination required".
|
|
// "Are workflows available here" — the only thing this flag has ever gated
|
|
// in this component (the picker's Workflows group + the list fetch below).
|
|
// It was build-only; it now also respects the backend /config flag
|
|
// `workflows_enabled`, via the same shared gate the sidebar and routes use.
|
|
// Renamed from `workflowsEnabled` because it no longer means "enterprise build":
|
|
// on an enterprise deployment with workflows switched off this is false.
|
|
const workflowsEnabled = computed(
|
|
() =>
|
|
(config.isEnterprise === "true" || config.isCloud === "true") &&
|
|
store.state.zoConfig?.workflows_enabled === true,
|
|
);
|
|
const workflowOptions = ref<{ label: I18nText; value: string }[]>([]);
|
|
const fetchWorkflows = async () => {
|
|
if (!workflowsEnabled.value) return;
|
|
try {
|
|
const res = await workflowService.listWorkflows(
|
|
store.state.selectedOrganization.identifier,
|
|
);
|
|
const list = Array.isArray(res.data) ? res.data : (res.data?.list ?? []);
|
|
workflowOptions.value = list.map((wf: any) => ({
|
|
// Workflow names come from the server — never translated copy.
|
|
label: raw(wf.name),
|
|
value: wf.id,
|
|
}));
|
|
} catch {
|
|
workflowOptions.value = [];
|
|
}
|
|
};
|
|
onMounted(fetchWorkflows);
|
|
|
|
// The combined field's single refresh reloads both lists.
|
|
const refreshTargets = () => {
|
|
emit("refresh:destinations");
|
|
fetchWorkflows();
|
|
};
|
|
|
|
const routeToCreateWorkflow = () => {
|
|
const url = router.resolve({
|
|
name: "createWorkflow",
|
|
query: {
|
|
trigger: "alert_fired",
|
|
org_identifier: store.state.selectedOrganization.identifier,
|
|
},
|
|
}).href;
|
|
window.open(url, "_blank");
|
|
};
|
|
|
|
const getBrowserTimezone = (): string => {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
} catch {
|
|
return "UTC";
|
|
}
|
|
};
|
|
|
|
// Period typed → cross-step CASCADE (period drives frequency / cron /
|
|
// timezone / silence). The ancestor AddAlert listens to @update:trigger
|
|
// (updateTriggerCondition → setFieldValue) and writes the whole
|
|
// trigger_condition into the ONE form, so the visible silence field
|
|
// auto-fills. The period field value itself is already written into the form
|
|
// by its own OFormInput binding; it rides on the emit so the parent write
|
|
// does not revert it.
|
|
const handlePeriodChange = (val: unknown) => {
|
|
const periodValue = Number(val);
|
|
// Spread the FRESH form value, not `props.formData.trigger_condition`.
|
|
// The prop is a `form.useStore` read-view that only refreshes on the next
|
|
// render, and the parent's @update:trigger handler is a WHOLE-OBJECT
|
|
// `setFieldValue("trigger_condition", …)` — so spreading the stale prop
|
|
// round-trips a pre-write snapshot and silently clobbers any field written
|
|
// earlier in the same tick.
|
|
const currentTrigger =
|
|
form?.getFieldValue?.("trigger_condition") ?? props.formData.trigger_condition;
|
|
const nextTrigger: Record<string, any> = {
|
|
...currentTrigger,
|
|
period: val,
|
|
};
|
|
if (periodValue && periodValue > 0) {
|
|
const minFrequency = Math.ceil(store.state?.zoConfig?.min_auto_refresh_interval / 60) || 10;
|
|
if (periodValue >= minFrequency) nextTrigger.frequency = periodValue;
|
|
nextTrigger.cron = convertMinutesToCron(periodValue);
|
|
if (!nextTrigger.timezone) nextTrigger.timezone = getBrowserTimezone();
|
|
nextTrigger.silence = periodValue;
|
|
}
|
|
emit("update:trigger", nextTrigger);
|
|
};
|
|
|
|
const routeToCreateDestination = () => {
|
|
const url = router.resolve({
|
|
name: "alertDestinations",
|
|
query: {
|
|
action: "add",
|
|
org_identifier: store.state.selectedOrganization.identifier,
|
|
},
|
|
}).href;
|
|
window.open(url, "_blank");
|
|
};
|
|
|
|
return {
|
|
t,
|
|
store,
|
|
handlePeriodChange,
|
|
routeToCreateDestination,
|
|
// Field refs for the parent focus manager
|
|
periodFieldRef,
|
|
silenceFieldRef,
|
|
destinationsFieldRef,
|
|
periodError,
|
|
silenceError,
|
|
destinationsError,
|
|
workflowsEnabled,
|
|
workflowOptions,
|
|
fetchWorkflows,
|
|
refreshTargets,
|
|
routeToCreateWorkflow,
|
|
};
|
|
},
|
|
});
|
|
</script>
|