Files
openobserve/web/src/composables/useAlertForm.ts
T

3344 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2023 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/>.
import {
ref,
computed,
watch,
onMounted,
onUnmounted,
onBeforeMount,
nextTick,
type Ref,
} from "vue";
import { useI18nTyped, raw } from "@/types/i18n";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { cloneDeep, debounce, set } from "lodash-es";
import alertsService from "@/services/alerts";
import searchService from "@/services/search";
import anomalyDetectionService from "@/services/anomaly_detection";
import {
saveAnomalyConfigMutation,
triggerAnomalyTrainingMutation,
} from "@/services/anomaly_detection.queries";
import { useMutation } from "@tanstack/vue-query";
import { useOrgId } from "@/composables/query";
import analytics from "@/services/product_analytics";
import { useReo } from "@/services/reodotdev_analytics";
import useStreams from "@/composables/useStreams";
import useFunctions from "@/composables/useFunctions";
import useQuery from "@/composables/useQuery";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
import {
getUUID,
getTimezoneOffset,
smartDecodeVrlFunction,
isValidResourceName,
getTimezonesByOffset,
resolveBrowserTimezone,
} from "@/utils/zincutils";
import { convertDateToTimestamp } from "@/utils/date";
import { generateSqlQuery } from "@/utils/alerts/alertQueryBuilder";
import { isUnaryOperator } from "@/utils/alerts/conditionsFormatter";
import { modesWithContent } from "@/utils/alerts/alertCondition";
import {
validateInputs as validateInputsUtil,
validateSqlQuery as validateSqlQueryUtil,
saveAlertJson as saveAlertJsonUtil,
type ValidationContext,
type JsonValidationContext,
} from "@/utils/alerts/alertValidation";
import { type SqlErrorRange } from "@/utils/query/sqlDiagnostics";
import { maxParenDepth, SQL_PARSE_MAX_DEPTH } from "@/utils/query/sqlComplexity";
import {
getAlertPayload as getAlertPayloadUtil,
prepareAndSaveAlert as prepareAndSaveAlertUtil,
stripFormExtras,
type PayloadContext,
type PayloadFormData,
type SaveAlertContext,
} from "@/utils/alerts/alertPayload";
// Pure cron helpers — used by the cron save gate in runImperativeQueryChecks.
import { getCronIntervalDifferenceInSeconds, isAboveMinRefreshInterval } from "@/utils/queryUtils";
import {
getParser as getParserUtil,
addHavingClauseToQuery,
type SqlUtilsContext,
} from "@/utils/alerts/alertSqlUtils";
import {
updateGroup as updateGroupUtil,
removeConditionGroup as removeConditionGroupUtil,
transformFEToBE as transformFEToBEUtil,
retransformBEToFE as retransformBEToFEUtil,
detectConditionsVersion,
convertV0ToV2,
convertV1ToV2,
convertV1BEToV2,
ensureIds,
type TransformContext,
} from "@/utils/alerts/alertDataTransforms";
import { AlertFocusManager } from "@/utils/alerts/focusManager";
import { readAlertPrefill } from "@/utils/alerts/alertPrefillStorage";
import { hasDeliveryTarget } from "@/utils/alerts/deliveryTarget";
import { getAlertSource } from "@/utils/alerts/alertSourceRegistry";
import type { AlertPrefill, AlertPrefillWarning } from "@/ts/interfaces/alertPrefill";
import {
forecastModeFields,
parseForecastAlertPromql,
type ForecastAlert,
} from "@/utils/alerts/forecastAlert";
import { createAlertsContextProvider, contextRegistry } from "@/composables/contextProviders";
import { toast } from "@/lib/feedback/Toast/useToast";
import {
buildAnomalyFilterExpression,
operatorNeedsValue,
} from "@/utils/alerts/anomalyFilterOperators";
import { toDetectionFunctionSql } from "@/utils/alerts/anomalySqlBuilder";
import { useOForm } from "@/lib/forms/Form/useOForm";
import { makeAddAlertSchema, defaultAddAlertMeta } from "@/components/alerts/AddAlert.schema";
import {
anomalyBandWidthPrefill,
anomalyBudgetPerDay,
anomalyIntervalSeconds,
anomalyWindowShareErrors,
type AnomalyIntervalUnit,
type AnomalyStoredIntervals,
} from "@/components/anomaly_detection/steps/AnomalyDetectionConfig.schema";
// ─── Default Values ─────────────────────────────────────────────────────────
export const defaultAlertValue: any = () => {
return {
name: "",
stream_type: "logs",
stream_name: "",
is_real_time: "false",
composite_condition: {
expression: "",
warning_counts_as_firing: true,
stale_child_policy: "use_last_state",
},
children: [],
query_condition: {
conditions: {
filterType: "group",
logicalOperator: "AND",
groupId: "",
conditions: [],
},
sql: "",
promql: "",
type: "custom",
aggregation: {
group_by: [],
function: "avg",
having: {
column: "",
operator: ">=",
value: 1,
},
},
promql_condition: null,
// Per-SERIES alerting for PromQL (M-9). PromQL's counterpart to
// aggregation.multi_alert — a PromQL alert has no aggregation, so the
// flag cannot live there.
promql_multi_alert: false,
promql_rule_mode: false,
// Feature 5 (§6b.6). `null` until the SLO query mode is chosen: the
// backend enforces `query_type == slo` IFF this is present, so an empty
// object here would make every ordinary alert fail validation.
slo_condition: null,
vrl_function: null,
multi_time_range: [],
},
trigger_condition: {
period: 10,
operator: ">=",
frequency: 10,
cron: "",
threshold: 3,
silence: 10,
frequency_type: "minutes",
timezone: "UTC",
},
// Minutes while the form is open — the CANONICAL stored value (mirrors
// trigger_condition.frequency); `_ui.pendingPeriod` is the DISPLAY value,
// which may be in hours. getAlertPayload converts to seconds on save.
// 0 = fire immediately.
pending_period_sec: 0,
destinations: [],
// Enterprise-only: workflows linked to this alert (run when it fires).
workflows: [],
template: "",
context_attributes: [],
enabled: true,
description: "",
row_template: "",
row_template_type: "String",
lastTriggeredAt: 0,
createdAt: "",
updatedAt: "",
owner: "",
lastEditedBy: "",
folder_id: "",
creates_incident: false,
// Off by default: a recovery is a new outbound message class, so it is opted into.
notify_on_recovery: false,
recovery_destinations: [],
// Seconds the condition must stay clear before recovering. 0 = immediately.
keep_firing_for: 0,
// Feature 2 (PT-1/PT-6). `null` (not 0) is unset — 0 is not a valid
// priority id, and the payload layer drops null so pre-Feature-2 alerts
// serialize unchanged.
priority: null,
tags: [],
// Empty means "route from the identity dimensions"; the payload layer
// drops the key so alerts that never set a team serialize unchanged.
oncall_team: "",
};
};
// Anchored so "90s" is ninety SECONDS — the old parser read any non-"h" suffix as minutes.
const ANOMALY_INTERVAL_RE = /^(\d+)(s|m|h|d)$/;
/** Parses a stored interval string on the one s/m/h/d grammar; `parsed: false` falls back to the given default. */
export const parseAnomalyInterval = (
raw: unknown,
defaultValue: number,
defaultUnit: AnomalyIntervalUnit,
): { value: number; unit: AnomalyIntervalUnit; parsed: boolean } => {
const match = typeof raw === "string" ? ANOMALY_INTERVAL_RE.exec(raw.trim()) : null;
if (!match || Number(match[1]) <= 0)
return { value: defaultValue, unit: defaultUnit, parsed: false };
return { value: Number(match[1]), unit: match[2] as AnomalyIntervalUnit, parsed: true };
};
/** Largest s/m/h/d unit that renders the seconds count losslessly — the dirty check compares these values. */
export const anomalyWindowSecondsToParts = (
secs: number,
): { value: number; unit: AnomalyIntervalUnit } => {
if (secs % 86400 === 0) return { value: secs / 86400, unit: "d" };
if (secs % 3600 === 0) return { value: secs / 3600, unit: "h" };
if (secs % 60 === 0) return { value: secs / 60, unit: "m" };
return { value: secs, unit: "s" };
};
/** The three governing payload fields; an untouched stored field round-trips its raw wire value VERBATIM (N11). */
export const anomalyIntervalPayload = (
c: {
histogram_interval_value: number | string;
histogram_interval_unit: string;
schedule_interval_value: number | string;
schedule_interval_unit: string;
detection_window_value: number | string;
detection_window_unit: string;
},
stored: AnomalyStoredIntervals | null,
): { histogram_interval: string; schedule_interval: string; detection_window_seconds: number } => {
const untouched = (f: { value: number; unit: string }, v: unknown, u: unknown) =>
Number(v) === f.value && u === f.unit;
const histogram_interval =
stored !== null &&
typeof stored.histogram.raw === "string" &&
untouched(stored.histogram, c.histogram_interval_value, c.histogram_interval_unit)
? stored.histogram.raw
: `${c.histogram_interval_value}${c.histogram_interval_unit}`;
const schedule_interval =
stored !== null &&
typeof stored.schedule.raw === "string" &&
untouched(stored.schedule, c.schedule_interval_value, c.schedule_interval_unit)
? stored.schedule.raw
: `${c.schedule_interval_value}${c.schedule_interval_unit}`;
const detection_window_seconds =
stored !== null &&
typeof stored.window.raw === "number" &&
untouched(stored.window, c.detection_window_value, c.detection_window_unit)
? stored.window.raw
: (anomalyIntervalSeconds(
Number(c.detection_window_value),
String(c.detection_window_unit),
) ?? 0);
return { histogram_interval, schedule_interval, detection_window_seconds };
};
const numberOrNull = (v: unknown): number | null =>
v === "" || v === null || v === undefined ? null : Number(v);
/** Band width and delivery-policy fields; a blank input goes out as null, which the server reads as its default. */
export const anomalyBandPayload = (
c: {
band_width?: unknown;
alert_direction?: string | null;
alert_window_buckets?: unknown;
alert_window_fire_pct?: unknown;
alert_window_recover_pct?: unknown;
},
budgetMode: boolean,
) => ({
// The server rejects a band width beside a budget: the override would leave the budget controller inert.
band_width: budgetMode ? null : numberOrNull(c.band_width),
alert_direction: c.alert_direction ?? "both",
alert_window_buckets: numberOrNull(c.alert_window_buckets),
alert_window_fire_pct: numberOrNull(c.alert_window_fire_pct),
alert_window_recover_pct: numberOrNull(c.alert_window_recover_pct),
});
/**
* Mutually exclusive on the wire; in budget mode `threshold` is controller-derived, never sent.
* Band/percentile mode must send an explicit null (not omit the field): the update endpoint's
* `alert_budget_per_day` is a double-Option, so an absent field means "leave as-is" and a
* previously stored budget would never clear.
*/
export const anomalySensitivityPayload = (budgetPerDay: number | null, threshold: unknown) =>
budgetPerDay !== null
? { alert_budget_per_day: budgetPerDay }
: { threshold, alert_budget_per_day: null };
export const defaultAnomalyConfig = () => ({
name: "",
description: "",
stream_type: "logs",
stream_name: "",
query_mode: "filters" as "filters" | "custom_sql",
filters: [] as any[],
custom_sql: "",
detection_function: "count",
detection_function_field: "",
histogram_interval_value: 5,
histogram_interval_unit: "m" as AnomalyIntervalUnit,
schedule_interval_value: 1,
schedule_interval_unit: "h" as AnomalyIntervalUnit,
// 3h is the smallest round window meeting §4.3's recommendation (2×(1h+5m) + the absence allowance).
detection_window_value: 3,
detection_window_unit: "h" as AnomalyIntervalUnit,
training_window_days: 28,
retrain_interval_days: 7,
threshold: 97,
// Set only when the backend stored a budget; undefined/null = percentile mode.
alert_budget_per_day: undefined as number | undefined,
// Null is Auto: the trained k decides. A number overrides it live.
band_width: null as number | string | null,
alert_direction: "both" as "both" | "above" | "below",
alert_window_buckets: 1 as number | string | null,
alert_window_fire_pct: 100 as number | string | null,
// Null means recover at the fire share.
alert_window_recover_pct: null as number | string | null,
alert_enabled: true,
alert_destination_ids: [] as string[],
folder_id: "default",
status: undefined as string | undefined,
is_trained: false,
enabled: true,
last_error: undefined as string | undefined,
// Set only by the config API (§4.8); the UI keys the health badge on it, never on error-string prefixes.
notice_class: null as "window_floor" | "window_skip" | "retrain" | null,
last_detection_run: undefined as number | undefined,
next_run_at: undefined as number | undefined,
// Feature 2: anomaly configs carry the same triage metadata as alerts.
// `null` (not 0) is unset — 0 is not a valid priority id.
priority: null as number | null,
tags: [] as string[],
});
/** A saved alert's forecast fields, when its PromQL is a generated forecast query. */
export const formForecastOf = (alert: any): ForecastAlert | null =>
alert?.query_condition?.type === "promql"
? parseForecastAlertPromql(alert.query_condition.promql, alert.query_condition.promql_condition)
: null;
/** Seeds a form value from a PromQL prefill; a generated forecast query opens Forecast mode. */
export const applyPromqlPrefill = (data: any, prefill: AlertPrefill): any => {
data.query_condition.type = "promql";
data.query_condition.promql = prefill.promql ?? "";
if (prefill.promqlCondition) {
data.query_condition.promql_condition = { ...prefill.promqlCondition };
}
if (prefill.promqlMultiAlert !== undefined) {
data.query_condition.promql_multi_alert = prefill.promqlMultiAlert;
}
const forecast = formForecastOf(data);
data._ui = { ...data._ui, forecast };
if (forecast) {
Object.entries(forecastModeFields(forecast)).forEach(([path, value]) => set(data, path, value));
}
return data;
};
// ─── Composable ─────────────────────────────────────────────────────────────
export interface AlertFormProps {
modelValue: any;
isUpdated: boolean;
destinations: any[];
templates?: any[];
folderId?: string;
}
export interface AlertFormEmit {
(e: "update:list", folderId?: string): void;
(e: "cancel:hideform"): void;
(e: "refresh:destinations"): void;
(e: "refresh:templates"): void;
}
// The full value set held by the ONE OForm: the alert payload shape plus the
// form-only extras seeded by withFormExtras (logGroupBy / _ui / _meta). Typing
// the form generic with this makes `form.state.values.*` reads (the synchronous
// source of truth) typed instead of `unknown`.
export type AlertFormValues = PayloadFormData & {
logGroupBy: string[];
_ui: Record<string, unknown>;
_meta: Record<string, unknown>;
} & Record<string, unknown>;
export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
const store: any = useStore();
const { t } = useI18nTyped();
const router = useRouter();
const { track } = useReo();
const { getAllFunctions } = useFunctions();
const { getStreams, getStream } = useStreams(t);
const { buildQueryPayload } = useQuery();
// ── Core State ──────────────────────────────────────────────────────────
const beingUpdated = ref(false);
const addAlertForm: any = ref(null);
const disableColor: any = ref("");
// ── Headless OForm ────────────────────────────────────────────────────────
// AddAlert OWNS the ONE form for the whole wizard (topbar scalars + the
// descendant steps QueryConfig/AlertSettings bind their fields by nested
// `name=` into it). `formData` is a READ-VIEW of the form's values
// (form.useStore) — the SINGLE source of truth, no mirror. Every write goes
// through setF / resetForm; NEVER mutate the read-view directly (that bypasses
// TanStack).
/** The org's min evaluation frequency, in SECONDS (0 when unset — the schema
* then skips the floor rule rather than inventing one). */
const minAutoRefreshInterval = (): number =>
Number(store.state?.zoConfig?.min_auto_refresh_interval) || 0;
/** Split an alert's STORED frequency (always MINUTES) into the display unit +
* the number the user actually sees. Mirrors QueryConfig's
* `initialFrequencyMode` / sync-watch rule: >= 60 and a whole number of hours
* shows as hours. The display value lives under `_ui` (display-only, stripped
* from the payload); the stored minutes stay in trigger_condition.frequency. */
const frequencyDisplay = (
obj: any,
): { mode: "minutes" | "hours" | "cron"; checkEvery: number } => {
const mins = Number(obj?.trigger_condition?.frequency ?? 10);
if (obj?.trigger_condition?.frequency_type === "cron")
return { mode: "cron", checkEvery: mins };
const isHours = mins >= 60 && mins % 60 === 0;
return {
mode: isHours ? "hours" : "minutes",
checkEvery: isHours ? mins / 60 : mins,
};
};
/** Split the alert's STORED pending period (always MINUTES, mirroring
* `frequencyDisplay`) into the display unit + the number the user actually
* sees. AlertSettings.vue derives its own initial unit the same way — see
* its `initialPendingPeriodMode`, kept in sync by rule, not shared code
* (matching how `frequencyMode` and this helper stay independent). */
const pendingPeriodDisplay = (obj: any): { mode: "minutes" | "hours"; value: number } => {
const mins = Number(obj?.pending_period_sec ?? 0);
const isHours = mins >= 60 && mins % 60 === 0;
return {
mode: isHours ? "hours" : "minutes",
value: isHours ? mins / 60 : mins,
};
};
const buildDefaultForm = (): any => {
const base = defaultAlertValue();
return {
...base,
logGroupBy: [] as string[],
_ui: {
checkEvery: frequencyDisplay(base).checkEvery,
pendingPeriod: pendingPeriodDisplay(base).value,
},
_meta: defaultAddAlertMeta({
frequencyMode: frequencyDisplay(base).mode,
minAutoRefreshInterval: minAutoRefreshInterval(),
}),
};
};
// Add the form-only extras (logGroupBy for logs measure group-by; `_ui` the
// display-only state; `_meta` the QueryConfig discriminator block) to a plain
// alert object so `form.reset(...)` always seeds a schema-complete value set.
// QueryConfig then keeps `_meta` fresh via its own syncMeta watcher, and
// re-seeds `_ui.checkEvery` from the stored frequency at setup.
const withFormExtras = (obj: any): any => {
const groupBy: string[] = Array.isArray(obj?.query_condition?.aggregation?.group_by)
? obj.query_condition.aggregation.group_by.filter((g: string) => g)
: [];
const freq = frequencyDisplay(obj);
return {
...obj,
logGroupBy: groupBy,
_ui: obj?._ui ?? {
checkEvery: freq.checkEvery,
pendingPeriod: pendingPeriodDisplay(obj).value,
forecast: formForecastOf(obj),
},
_meta:
obj?._meta ??
defaultAddAlertMeta({
tab: obj?.query_condition?.type || "custom",
isRealTime: String(obj?.is_real_time ?? "false"),
isEventBased: (obj?.stream_type ?? "logs") !== "metrics",
aggregationEnabled: !!obj?.query_condition?.aggregation,
hasGroupBy: groupBy.length > 0,
hasConditions: !!obj?.query_condition?.conditions?.conditions?.length,
frequencyMode: freq.mode,
minAutoRefreshInterval: minAutoRefreshInterval(),
}),
};
};
// i18n-driven validation schema (messages resolve via `t` — see AddAlert.schema).
const addAlertSchema = makeAddAlertSchema(t);
const form = useOForm<AlertFormValues>({
defaultValues: buildDefaultForm() as AlertFormValues,
schema: addAlertSchema,
onSubmit: async () => {
await performSave();
},
});
// READ-VIEW of the single form. Reactive; replaced immutably on every change.
const formData: any = form.useStore((s: any) => s.values);
/** Write a single (possibly nested, dot/bracket-path) field into the ONE form. */
const setF = (path: string, value: any): void => form.setFieldValue(path as any, value);
/** Reset the whole form (edit-prefill / post-save reset) to a complete alert
* object, re-seeding the form-only extras (logGroupBy / _meta). */
const resetForm = (obj: any): void => form.reset(withFormExtras(obj));
const indexOptions = ref([]);
const schemaList = ref([]);
const streams: any = ref({});
const editorRef: any = ref(null);
const filteredColumns: any = ref([]);
const filteredStreams: Ref<string[]> = ref([]);
let editorobj: any = null;
const sqlAST: any = ref(null);
const selectedRelativeValue = ref("1");
// NOT i18n: "Minutes" here is DATA, not a label. It is the period *identifier*
// that loadPanelData compares against ("Hours"/"Days"/"Weeks") to convert a
// panel's relative range into minutes, and utils/date.ts keys off the very same
// literal (`periodLabel === "Minutes"`) when building the `period` URL param.
// Translating it would silently break that conversion.
const selectedRelativePeriod = ref("Minutes");
const relativePeriods: any = ref(["Minutes"]);
const triggerCols: any = ref([]);
const selectedDestinations = ref("slack");
const originalStreamFields: any = ref([]);
const isAggregationEnabled = ref(false);
const isEditorOpen = ref(false);
// ── Anomaly Detection State ─────────────────────────────────────────────
const anomalyConfig = ref(defaultAnomalyConfig());
// Captured ONCE from the edit-fetch response — never from anomalyConfig, which the form live-mutates (D4).
const anomalyStoredIntervals = ref<AnomalyStoredIntervals | null>(null);
const anomalyStep2Ref = ref<any>(null);
const showAnomalySummary = ref(true);
const anomalyEditMode = ref(false);
const anomalyRetraining = ref(false);
const anomalySaving = ref(false);
const anomalyOrgId = useOrgId();
const saveAnomalyConfig = useMutation(() =>
saveAnomalyConfigMutation(
anomalyOrgId.value,
() => router.currentRoute.value.params.anomaly_id as string | undefined,
),
);
const triggerAnomalyTraining = useMutation(() =>
triggerAnomalyTrainingMutation(anomalyOrgId.value),
);
const anomalyStatusVariant = computed<BadgeVariant>(() => {
switch (anomalyConfig.value.status) {
case "active":
return "success";
case "training":
return "primary";
case "failed":
return "error";
default:
return "default";
}
});
const anomalyFormatTs = (ts: number) => {
const d = new Date(ts / 1000);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const anomalyTriggerRetrain = async () => {
const anomalyId = router.currentRoute.value.params.anomaly_id as string | undefined;
if (!anomalyId) return;
anomalyRetraining.value = true;
try {
await triggerAnomalyTraining.mutateAsync(anomalyId);
toast({
variant: "success",
message: t("alerts.messages.trainingTriggered"),
});
} catch {
toast({
variant: "error",
message: t("alerts.messages.trainingTriggerFailed"),
});
} finally {
anomalyRetraining.value = false;
}
};
const isAnomalyMode = computed(() => formData.value.is_real_time === "anomaly");
const anomalyHistogramInterval = computed(
() =>
`${anomalyConfig.value.histogram_interval_value}${anomalyConfig.value.histogram_interval_unit}`,
);
const anomalyPreviewSql = computed(() => {
const c = anomalyConfig.value;
if (c.query_mode === "custom_sql") {
return c.custom_sql || t("alerts.messages.sqlPreviewPlaceholder");
}
const stream = c.stream_name || "<stream>";
const interval = anomalyHistogramInterval.value || "5m";
const fn = toDetectionFunctionSql(
c.detection_function,
c.detection_function_field || "<field>",
);
const filterLines = (c.filters || [])
.filter((f: any) => f.field && (operatorNeedsValue(f.operator) ? f.value : true))
.map((f: any) => ` AND ${buildAnomalyFilterExpression(f.field, f.operator, f.value)}`);
const where = filterLines.length
? [
"WHERE",
...filterLines.map((l: string, i: number) => (i === 0 ? l.replace(/^\s+AND /, " ") : l)),
].join("\n")
: "";
return [
`SELECT histogram(_timestamp, '${interval}') AS time_bucket,`,
` ${fn} AS value`,
`FROM ${stream}`,
where,
`GROUP BY time_bucket`,
`ORDER BY time_bucket`,
]
.filter(Boolean)
.join("\n");
});
const anomalySummarySectionStyle = computed(() => {
if (!showAnomalySummary.value) return { flex: "0 0 auto" };
return { flex: "1", minHeight: "9.375rem" };
});
// ── Expand / UI State ───────────────────────────────────────────────────
const expandState = ref({
alertSetup: true,
queryMode: true,
advancedSetup: true,
realTimeMode: true,
thresholds: true,
multiWindowSelection: false,
});
const triggerOperators: any = ref(["=", "!=", ">=", "<=", ">", "<", "Contains", "NotContains"]);
const showVrlFunction = ref(false);
const isFetchingStreams = ref(false);
const streamTypes = ["logs", "metrics", "traces"];
// `value` is LOAD-BEARING data ("String" / "Json" are the values persisted in
// `row_template_type` and compared against below) — only `label` is display
// text, so only `label` is translated.
const rowTemplateTypeOptions = [
{ label: t("alerts.advanced.templateTypeString"), value: "String" },
{ label: t("alerts.advanced.templateTypeJson"), value: "Json" },
];
const focusManager = new AlertFocusManager();
const streamFieldRef = ref(null);
const streamTypeFieldRef = ref(null);
const previewQuery = ref("");
const isUsingBackendSql = ref(false);
const sqlQueryErrorMsg = ref("");
// Editor squiggle ranges for server SQL-validation errors (shared with editors
// via provide/inject from AddAlert.vue).
const sqlErrorRanges = ref<SqlErrorRange[]>([]);
// SQL tab's Multi Alert value-column dropdown options — the query's own
// resolved output columns. Populated by PreviewAlert's `schema-updated`
// emit (AddAlert.vue's handleSqlSchemaUpdated), which reuses the
// /result_schema call PreviewAlert already makes every time the preview
// query itself fires (sql_simple_multi_alert_fe_prd.md §11.2/§11.3).
const sqlAggColumnOptions = ref<string[]>([]);
// Whether the user's own SQL carries a HAVING clause — same emit as above.
// Drives the QueryConfig warning that it runs before the Multi Alert's own
// "Alert if [column]" condition.
const sqlQueryHasHaving = ref(false);
const validateSqlQueryPromise = ref<Promise<unknown>>();
const addAlertFormRef = ref(null);
const viewSqlEditorDialog = ref(false);
const plotChart: any = ref(null);
const previewAlertRef: any = ref(null);
let parser: any = null;
const vrlFunctionError = ref("");
const showTimezoneWarning = ref(false);
const showJsonEditorDialog = ref(false);
const validationErrors = ref([]);
const isLoadingPrefill = ref(false);
/** Lossy transforms the source adapter performed, surfaced in the form. */
const prefillWarnings = ref<AlertPrefillWarning[]>([]);
const folderQuery = router.currentRoute.value.query.folder;
// Prefer the folder the caller (AlertList) hands us over the URL query: the
// folder tab's v-model is the authoritative current folder, while the URL
// query can be stale when the "New alert" dialog is opened from a tab.
const activeFolderId = ref<string>(
props.folderId || (Array.isArray(folderQuery) ? folderQuery[0] : folderQuery) || "default",
);
const alertType = ref(router.currentRoute.value.query.alert_type || "all");
// ── Wizard State (kept for anomaly flow) ────────────────────────────────
const wizardStep = ref(1);
const wizardStepper = ref(null);
const step1Ref = ref(null);
const step2Ref = ref(null);
const step3Ref = ref(null);
const step4Ref = ref(null);
const lastValidStep = ref(1);
// Topbar field refs (for focus-on-error only).
const streamTypeRef = ref<any>(null);
const streamNameRef = ref<any>(null);
const anomalyNameRef = ref<any>(null);
// ── V3 Tab State (for standard alerts) ──────────────────────────────────
const activeTab = ref("condition");
const tabErrors = ref<Record<string, boolean>>({
condition: false,
rules: false,
compare: false,
dedup: false,
advanced: false,
});
const chartCollapsed = ref(false);
// Preview DateTimePicker value — controls the time range for preview data
const previewDateTimeValue = ref({
tab: "relative",
relative: {
// NOT i18n: utils/date.ts reads `period.label` back as an identifier
// (`periodLabel === "Minutes"` → unit "m") when turning this object into
// query params, so the label is load-bearing data here, not display text.
period: { label: "Minutes", value: "Minutes" },
value: 15,
},
absolute: {
date: {
from: new Date().toLocaleDateString("en-ZA"),
to: new Date().toLocaleDateString("en-ZA"),
},
startTime: "00:00",
endTime: "23:59",
},
});
// ── Computed Properties ─────────────────────────────────────────────────
const editorData = ref("");
const prefixCode = ref("");
const suffixCode = ref("");
const streamFieldsMap = computed(() => {
const map: any = {};
originalStreamFields.value.forEach((field: any) => {
map[field.value] = field;
});
return map;
});
const showPreview = computed(() => {
return formData.value.stream_type && formData.value.stream_name;
});
// The `{name}` / `{timestamp}` braces are LITERAL template syntax shown to the
// user, so the locale values escape them for vue-i18n (`{'{'}` … `{'}'}`) and
// render byte-identical to the pre-i18n literals. Same keys as Advanced.vue,
// which owns the rendered placeholder.
const rowTemplatePlaceholder = computed(() => {
return formData.value.row_template_type === "Json"
? t("alerts.advanced.rowTemplatePlaceholderJson")
: t("alerts.advanced.rowTemplatePlaceholderString");
});
const decodedVrlFunction = computed(() => {
if (!formData.value.query_condition.vrl_function) {
return "";
}
return formData.value.query_condition.vrl_function;
});
const getSelectedTab = computed(() => {
return formData.value.query_condition.type || null;
});
const currentStepCaption = computed(() => {
const captions: Record<number, string> = {
1: t("alerts.stepCaptions.alertSetup"),
2: t("alerts.stepCaptions.conditions"),
3: t("alerts.stepCaptions.compareWithPast"),
4: t("alerts.stepCaptions.alertSettings"),
5: t("alerts.stepCaptions.deduplication"),
6: t("alerts.stepCaptions.advanced"),
};
return captions[wizardStep.value] || "";
});
const isLastStep = computed(() => {
if (formData.value.is_real_time === "anomaly") {
return wizardStep.value === 3;
}
return wizardStep.value === 6;
});
const getFormattedDestinations = computed(() => {
return props.destinations.map((destination: any) => {
return destination.name;
});
});
// Threshold mark line for chart
const thresholdMarkLine = computed(() => {
if (!formData.value.trigger_condition?.threshold) return null;
return {
data: [
{
yAxis: formData.value.trigger_condition.threshold,
label: { formatter: t("alerts.messages.thresholdMarkLine") },
},
],
lineStyle: { color: "#ff4444", type: "dashed", width: 2 },
symbol: "none",
};
});
// ── Parser ──────────────────────────────────────────────────────────────
const importSqlParser = async () => {
const useSqlParser: any = await import("@/composables/useParser");
const { sqlParser }: any = useSqlParser.default();
parser = await sqlParser();
};
const getParser = (sqlQuery: string) => {
const sqlUtilsContext: SqlUtilsContext = {
parser,
sqlQueryErrorMsg,
t,
};
return getParserUtil(sqlQuery, sqlUtilsContext);
};
// ── Stream Methods ──────────────────────────────────────────────────────
const updateEditorContent = async (stream_name: string) => {
triggerCols.value = [];
if (!stream_name) return;
if (editorData.value) {
editorData.value = editorData.value.replace(prefixCode.value, "").trim();
editorData.value = editorData.value.replace(suffixCode.value, "").trim();
}
if (!props.isUpdated) {
prefixCode.value = `select * from`;
suffixCode.value = "'" + formData.value.stream_name + "'";
}
const selected_stream: any = await getStream(stream_name, formData.value.stream_type, true);
selected_stream.schema.forEach(function (item: any) {
triggerCols.value.push(item.name);
});
};
const updateStreamFields = async (stream_name: any) => {
let streamCols: any = [];
const streamsData: any = await getStream(stream_name, formData.value.stream_type, true);
if (streamsData && Array.isArray(streamsData.schema)) {
streamCols = streamsData.schema.map((column: any) => ({
label: column.name,
value: column.name,
type: column.type,
}));
}
if (
streamsData?.settings?.defined_schema_fields &&
Array.isArray(streamsData.settings.defined_schema_fields) &&
streamsData.settings.defined_schema_fields.length > 0
) {
const definedFields = streamsData.settings.defined_schema_fields;
const timestampColumn = store.state.zoConfig?.timestamp_column || "_timestamp";
const allFieldsName = store.state.zoConfig?.all_fields_name;
streamCols = streamCols.filter((col: any) => {
if (col.value === timestampColumn || col.value === allFieldsName) {
return true;
}
return definedFields.includes(col.value);
});
}
originalStreamFields.value = [...streamCols];
filteredColumns.value = [...streamCols];
// In SQL mode, generate a starter query when the editor is still empty
if (
formData.value.query_condition.type === "sql" &&
!formData.value.query_condition.sql?.trim()
) {
setF("query_condition.sql", `SELECT * FROM "${stream_name}"`);
}
onInputUpdate("stream_name", stream_name);
};
// ── SQL → stream-name sync ──────────────────────────────────────────────
// When the user edits the SQL query and changes the stream name inside the
// FROM clause, update the stream-name dropdown to match.
// Guard flag prevents the auto-SQL-generation inside updateStreamFields from
// firing while we are already syncing (SQL is not empty at that point, so
// the guard in updateStreamFields would already prevent it, but this is an
// extra safety layer).
const isSyncingStreamFromSql = ref(false);
const debouncedSyncStreamFromSql = debounce(async (sql: string) => {
// An edit keeps its stream: the field is locked and the backend never updates it.
if (!sql || !parser || isSyncingStreamFromSql.value || beingUpdated.value) return;
// parse() is exponential in paren nesting depth — skip a pathologically
// nested query rather than freeze the tab. Losing this convenience sync
// is fine; the user can still pick the stream from the dropdown.
if (maxParenDepth(sql) > SQL_PARSE_MAX_DEPTH) return;
try {
const parsed = parser.parse(sql);
const fromStream = parsed?.ast?.from?.[0]?.table as string | undefined;
if (fromStream && fromStream !== formData.value.stream_name) {
isSyncingStreamFromSql.value = true;
try {
setF("stream_name", fromStream);
await updateStreamFields(fromStream);
} finally {
// updateStreamFields throws for a stream that does not exist; a flag
// left set would skip every later sync.
isSyncingStreamFromSql.value = false;
}
}
} catch {
// ignore parse errors while user is mid-typing
}
}, 600);
watch(
() => formData.value.query_condition.sql,
(sql) => {
if (formData.value.query_condition.type === "sql" && !isSyncingStreamFromSql.value) {
debouncedSyncStreamFromSql(sql || "");
}
},
);
const updateStreams = (resetStream = true) => {
if (resetStream) setF("stream_name", "");
// Read the synchronous source of truth (not the reactive read-view, which
// can lag one tick behind a setF from the OFormSelect change handler).
const streamType = form.state.values.stream_type;
const streamName = form.state.values.stream_name;
if (streams.value[streamType]) {
schemaList.value = streams.value[streamType];
indexOptions.value = streams.value[streamType].map((data: any) => {
return data.name;
});
return;
}
if (!streamType) return Promise.resolve();
isFetchingStreams.value = true;
return getStreams(streamType, false)
.then(async (res: any) => {
streams.value[streamType] = res.list;
schemaList.value = res.list;
indexOptions.value = res.list.map((data: any) => {
return data.name;
});
if (streamName) await updateStreamFields(streamName);
return Promise.resolve();
})
.catch(() => Promise.reject())
.finally(() => (isFetchingStreams.value = false));
};
const filterColumns = (options: any[], val: String, update: Function) => {
let filteredOptions: any[] = [];
if (val === "") {
update(() => {
filteredOptions = [...options];
});
return filteredOptions;
}
update(() => {
const value = val.toLowerCase();
filteredOptions = options.filter((column: any) => column.toLowerCase().indexOf(value) > -1);
});
return filteredOptions;
};
const filterStreams = (val: string, update: any) => {
filteredStreams.value = filterColumns(indexOptions.value, val, update);
};
// ── SQL Generation ──────────────────────────────────────────────────────
const generateSqlQueryLocal = () => {
return generateSqlQuery(
formData.value,
streamFieldsMap.value,
isAggregationEnabled.value,
store.state.zoConfig.timestamp_column || "_timestamp",
);
};
const generatedSqlQuery = ref("");
const allConditionsValid = (conditions: any): boolean => {
if (!conditions || typeof conditions !== "object") {
return false;
}
if (conditions.filterType === "condition") {
return !!(
conditions.column &&
(isUnaryOperator(conditions.operator) ||
(conditions.value !== undefined && conditions.value !== ""))
);
}
if (conditions.filterType === "group" && Array.isArray(conditions.conditions)) {
return conditions.conditions.every((cond: any) => allConditionsValid(cond));
}
return false;
};
const isAggregationValid = (): boolean => {
if (!isAggregationEnabled.value) {
return true;
}
const aggregation = formData.value.query_condition.aggregation;
if (!aggregation) {
return false;
}
if (aggregation.having) {
const { column, operator, value } = aggregation.having;
return !!(column && operator && value !== undefined && value !== "");
}
return true;
};
// Generation counter to discard stale async responses when the user switches
// tabs (e.g. builder → SQL) before the API call completes.
const generateRequestId = ref(0);
const generateSqlFromBackend = async () => {
const requestId = ++generateRequestId.value;
try {
// If no stream name or not custom mode, nothing to do
if (!formData.value.stream_name) {
return;
}
if (formData.value.query_condition.type !== "custom") {
return;
}
const hasConditions =
formData.value.query_condition?.conditions &&
Object.keys(formData.value.query_condition.conditions).length > 0;
const conditionsValid =
hasConditions && allConditionsValid(formData.value.query_condition.conditions);
const aggregationValid = isAggregationValid();
// If conditions are incomplete/missing, fall back to local SQL so the
// preview chart still loads with raw stream data (no WHERE clause).
if (!conditionsValid || !aggregationValid) {
const localSql = generateSqlQueryLocal();
generatedSqlQuery.value = localSql;
previewQuery.value = localSql;
isUsingBackendSql.value = false;
await nextTick();
previewAlertRef.value?.refreshData();
return;
}
const payload: any = {
stream_name: formData.value.stream_name,
stream_type: formData.value.stream_type || "logs",
query_condition: {
type: "custom",
conditions: {
version: 2,
conditions: formData.value.query_condition.conditions,
},
},
};
if (isAggregationEnabled.value && formData.value.query_condition.aggregation) {
const groupBy = formData.value.query_condition.aggregation.group_by || [];
const filteredGroupBy = groupBy.filter((field: string) => field && field.trim() !== "");
payload.query_condition.aggregation = {
...formData.value.query_condition.aggregation,
group_by: filteredGroupBy,
};
}
const response = await alertsService.generate_sql(
store.state.selectedOrganization.identifier,
payload,
);
// Discard if a newer request was started (e.g. user changed tabs or
// conditions before this response arrived).
if (requestId !== generateRequestId.value) return;
if (response.data && response.data.sql) {
generatedSqlQuery.value = response.data.sql;
previewQuery.value = response.data.sql;
isUsingBackendSql.value = true;
await nextTick();
previewAlertRef.value?.refreshData();
}
} catch (error) {
// Discard stale error fallback if a newer request has started.
if (requestId !== generateRequestId.value) return;
console.error("Error generating SQL from backend:", error);
const localSql = generateSqlQueryLocal();
generatedSqlQuery.value = localSql;
previewQuery.value = localSql;
isUsingBackendSql.value = false;
await nextTick();
previewAlertRef.value?.refreshData();
}
};
const debouncedGenerateSql = debounce(generateSqlFromBackend, 1000);
const previewAlert = async () => {
if (getSelectedTab.value === "custom") {
previewQuery.value = generateSqlQueryLocal();
} else if (getSelectedTab.value === "sql")
previewQuery.value = formData.value.query_condition.sql.trim();
else if (getSelectedTab.value === "promql")
previewQuery.value = formData.value.query_condition.promql.trim();
if (formData.value.is_real_time === "true") {
previewQuery.value = generateSqlQueryLocal();
}
await nextTick();
if (getSelectedTab.value !== "sql") {
previewAlertRef.value?.refreshData();
}
};
const debouncedPreviewAlert = debounce(previewAlert, 500);
const onInputUpdate = async (_name: string, _value: any) => {
if (formData.value.query_condition.type === "custom") {
debouncedGenerateSql();
} else if (showPreview.value) {
debouncedPreviewAlert();
}
};
// ── Validation ──────────────────────────────────────────────────────────
const getAlertPayload = () => {
const payloadContext: PayloadContext = {
store,
isAggregationEnabled,
getSelectedTab,
beingUpdated: beingUpdated.value,
};
// Read the synchronous source of truth (the form store), not the reactive
// read-view, so a value written by setF immediately before save is included.
if (form.state.values.is_real_time === "composite") {
const source = cloneDeep(form.state.values) as any;
const contextAttributes = Object.fromEntries(
(Array.isArray(source.context_attributes) ? source.context_attributes : [])
.filter((attribute: any) => attribute.key?.trim() && attribute.value?.trim())
.map((attribute: any) => [attribute.key, attribute.value]),
);
return {
...(source.id ? { id: source.id } : {}),
alert_type: "composite",
name: source.name,
description: raw(String(source.description ?? "").trim()),
enabled: source.enabled,
destinations: source.destinations ?? [],
template: source.template,
context_attributes: contextAttributes,
trigger_condition: {
silence: Number(source.trigger_condition?.silence ?? 0),
},
// Stored and evaluated for composite alerts server-side (see
// handle_composite_alert_trigger). Note the detail GET response
// doesn't return it yet on edit — see the fallback comment on the
// edit-prefill conversion above.
pending_period_sec: Math.round((Number(source.pending_period_sec) || 0) * 60),
owner: source.owner || undefined,
creates_incident: source.creates_incident ?? false,
workflows: source.workflows ?? [],
priority: source.priority ?? null,
tags: source.tags ?? [],
composite_condition: source.composite_condition,
};
}
const payload = getAlertPayloadUtil(form.state.values, payloadContext);
delete payload.composite_condition;
delete payload.children;
return payload;
};
const validateInputs = (input: any, notify: boolean = true) => {
const validationContext: ValidationContext = {
store,
t,
validateSqlQueryPromise,
sqlQueryErrorMsg,
vrlFunctionError,
buildQueryPayload,
getParser,
};
return validateInputsUtil(input, validationContext, notify);
};
const validateSqlQuery = async () => {
const validationContext: ValidationContext = {
store,
t,
validateSqlQueryPromise,
sqlQueryErrorMsg,
sqlErrorRanges,
vrlFunctionError,
buildQueryPayload,
getParser,
};
return validateSqlQueryUtil(formData.value, validationContext);
};
const validateConditionsAgainstUDS = () => {
if (formData.value.is_real_time === "composite") {
return { isValid: true, invalidFields: [] };
}
if (
!formData.value.stream_name ||
!formData.value.stream_type ||
!originalStreamFields.value ||
originalStreamFields.value.length === 0
) {
return { isValid: true, invalidFields: [] };
}
const isRealTime =
formData.value.is_real_time === "true" || formData.value.is_real_time === true;
const queryType = formData.value.query_condition?.type;
if (!isRealTime && (queryType === "sql" || queryType === "promql")) {
return { isValid: true, invalidFields: [] };
}
const allowedFieldNames = new Set(originalStreamFields.value.map((field: any) => field.value));
const invalidFields: string[] = [];
const checkConditionFields = (condition: any) => {
if (!condition) return;
if (
condition.filterType === "group" &&
condition.conditions &&
Array.isArray(condition.conditions)
) {
condition.conditions.forEach((item: any) => {
checkConditionFields(item);
});
} else if (condition.items && Array.isArray(condition.items)) {
condition.items.forEach((item: any) => {
checkConditionFields(item);
});
} else if (condition.column) {
if (
condition.column &&
condition.column !== "" &&
!allowedFieldNames.has(condition.column)
) {
if (!invalidFields.includes(condition.column)) {
invalidFields.push(condition.column);
}
}
}
};
if (isRealTime) {
if (formData.value.query_condition?.conditions) {
checkConditionFields(formData.value.query_condition.conditions);
}
} else {
if (formData.value.query_condition?.conditions && queryType === "custom") {
checkConditionFields(formData.value.query_condition.conditions);
}
if (
isAggregationEnabled.value &&
queryType === "custom" &&
formData.value.query_condition?.aggregation?.having?.column
) {
const havingColumn = formData.value.query_condition.aggregation.having.column;
if (havingColumn && havingColumn !== "" && !allowedFieldNames.has(havingColumn)) {
if (!invalidFields.includes(havingColumn)) {
invalidFields.push(havingColumn);
}
}
}
if (
isAggregationEnabled.value &&
queryType === "custom" &&
formData.value.query_condition?.aggregation?.group_by &&
Array.isArray(formData.value.query_condition.aggregation.group_by)
) {
formData.value.query_condition.aggregation.group_by.forEach((field: string) => {
if (field && field !== "" && !allowedFieldNames.has(field)) {
if (!invalidFields.includes(field)) {
invalidFields.push(field);
}
}
});
}
}
return {
isValid: invalidFields.length === 0,
invalidFields,
};
};
// Validate a specific step (used by wizard navigation).
// Always true by design: every step (topbar name/stream, QueryConfig,
// AlertSettings) is a DESCENDANT of the ONE AddAlert form, so their field
// rules run through the composed schema (AddAlert.schema.ts) on the real save
// path (handleSave → form.handleSubmit), and the non-schema query-text gates
// live in runImperativeQueryChecks. Kept as a function so wizard navigation
// keeps its gate seam.
const validateStep = async (_stepNumber: number) => {
return true;
};
const validateCurrentStep = async () => {
return await validateStep(wizardStep.value);
};
// Focus the first field showing a validation message. This used to query
// a legacy error class — one with no producer left in the app after the
// OForm migration, so it silently matched nothing and the "fix the highlighted
// fields" toast pointed at a field we never actually focused. OInput/OSelect
// render their message as [role="alert"] inside the field wrapper, so walk up
// from the message to the nearest ancestor holding the control.
//
// Scoped to the <form>: toasts are also role="alert" and would otherwise win.
// Invisible messages are skipped — a v-show'd tab has no offsetParent, and
// focusing a display:none control does nothing.
const focusVisibleError = (form: HTMLElement): boolean => {
const messages = Array.from(form.querySelectorAll<HTMLElement>('[role="alert"]')).filter(
(el) => el.offsetParent !== null && el.textContent?.trim(),
);
for (const message of messages) {
let node: HTMLElement | null = message.parentElement;
while (node && node !== form) {
// [data-inline-edit-trigger] is OInlineEdit's display-mode button: an
// inline-edited field has NO input in the DOM until it is opened, so
// without this the header name field would never be reachable here.
const control = node.querySelector<HTMLElement>(
'input:not([type="hidden"]), textarea, [role="combobox"], button[aria-haspopup], [data-inline-edit-trigger]',
);
if (control) {
control.focus();
control.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
node = node.parentElement;
}
}
return false;
};
const focusOnFirstError = () => {
nextTick(async () => {
const form = document.querySelector("form");
if (!form) return;
// The offending field is on the tab the user is already looking at.
if (focusVisibleError(form)) return;
// Nothing VISIBLE is erroring, but the form is still invalid — so the
// offending field lives on a tab the user isn't looking at. That pane is
// v-show'd off, so every message inside it has a null offsetParent and the
// scan above skips it: the toast fires, points at nothing, and the user is
// told to fix a field they cannot see. Bring the owning tab forward first,
// then focus for real (this is what the pre-migration save gate did by
// hardcoding `activeTab = "condition"` before focusing).
// Only hop to a tab that is REACHABLE in the current mode. Every pane stays
// in the DOM in both modes (v-show, not v-if) while `alertTabs` swaps the
// headers wholesale between anomaly and alert — so a stale message on a
// pane whose header isn't rendered would strand the user on a tab the
// toggle group cannot show or leave.
const stranded = Array.from(form.querySelectorAll<HTMLElement>('[role="alert"]')).find(
(el) => {
if (!el.textContent?.trim()) return false;
const key = el.closest<HTMLElement>("[data-tab-pane]")?.dataset.tabPane;
return !!key && !!form.querySelector(`[data-test="add-alert-tab-${key}"]`);
},
);
const pane = stranded?.closest<HTMLElement>("[data-tab-pane]");
const tab = pane?.dataset.tabPane;
if (!tab || tab === activeTab.value) return;
activeTab.value = tab;
await nextTick();
// Re-scan with the visibility filter intact: now that the pane is shown,
// its messages have an offsetParent. Anything still hidden (a collapsed
// section) is correctly skipped rather than focused into the void.
focusVisibleError(form);
});
};
// Focus a topbar select/input by its Vue component ref. A component that
// exposes its own focus() owns the decision (OInlineEdit has no input in the
// DOM until it opens, so "focus me" means "open the editor"); everything else
// falls back to the first input inside its root.
const focusTopbarField = (fieldRef: any) => {
nextTick(() => {
const el = fieldRef?.value?.$el as HTMLElement | null;
el?.scrollIntoView({ behavior: "smooth", block: "center" });
if (typeof fieldRef?.value?.focus === "function") {
fieldRef.value.focus();
return;
}
const input = el?.querySelector("input") as HTMLElement | null;
input?.focus();
});
};
// Schema-driven validity predicate (validation ONLY — never triggers the save;
// the real save path is handleSave → form.handleSubmit). The ONE composed
// schema owns name/stream + the step field rules, so this just runs the
// imperative query-text gates (re-homed from the descendant QueryConfig) and
// parses the current form values against the schema. Kept exported for
// programmatic callers (Rule ④ — same name/stream/§4 gating as before, now via
// the schema).
const validateAndFocus = async (): Promise<boolean> => {
if (isAnomalyMode.value) {
if (!anomalyConfig.value.name?.trim()) {
toast({
variant: "error",
message: t("alerts.messages.anomalyDetectionNameRequired"),
});
// One inline-edit control now serves both alert and anomaly names, so
// step1Ref is the anomaly name field too.
focusTopbarField(step1Ref);
return false;
}
return true;
}
if (!runImperativeQueryChecks()) return false;
const parsed = addAlertSchema.safeParse(form.state.values);
if (!parsed.success) {
// No hardcoded tab switch: focusOnFirstError now walks to the pane that
// actually owns the first error, so a topbar error (name/stream) no longer
// yanks the user onto the condition tab to look at a field that isn't there.
focusOnFirstError();
return false;
}
return true;
};
// Validate all tabs for V3 layout save
const validateAllTabs = async (): Promise<{
valid: boolean;
firstErrorTab: string | null;
}> => {
// Reset tab errors
tabErrors.value = {
condition: false,
rules: false,
compare: false,
dedup: false,
advanced: false,
};
// The per-step validate() calls that used to run here (condition tab /
// alert settings) are gone — those steps bind into the ONE form and the
// composed schema validates them on save (handleSave → form.handleSubmit).
return { valid: true, firstErrorTab: null };
};
// ── Condition Transforms ────────────────────────────────────────────────
// Build a mutable {query_condition:{conditions}} context off a CLONE of the
// form's current conditions tree. The transform utils mutate context.formData
// in place, so it must NOT be the readonly form read-view (formData.value) —
// writing to that silently fails (the form store values are readonly).
const conditionsTransformContext = (): TransformContext => ({
formData: {
query_condition: {
conditions: cloneDeep(
form.state.values.query_condition?.conditions ?? {
filterType: "group",
logicalOperator: "AND",
groupId: "",
conditions: [],
},
),
},
},
});
const updateGroup = (updatedGroup: any) => {
const ctx = conditionsTransformContext();
updateGroupUtil(updatedGroup, ctx);
setF(
"query_condition.conditions",
JSON.parse(JSON.stringify(ctx.formData.query_condition.conditions)),
);
};
const removeConditionGroup = (targetGroupId: string, currentGroup?: any) => {
const ctx = conditionsTransformContext();
removeConditionGroupUtil(targetGroupId, currentGroup, ctx);
setF(
"query_condition.conditions",
JSON.parse(JSON.stringify(ctx.formData.query_condition.conditions)),
);
};
const transformFEToBE = (node: any) => {
return transformFEToBEUtil(node);
};
const retransformBEToFE = (data: any) => {
return retransformBEToFEUtil(data);
};
// ── UI Update Methods ───────────────────────────────────────────────────
const updateActiveFolderId = (folderId: any) => {
activeFolderId.value = folderId.value;
};
const addVariable = () => {
setF("context_attributes", [
...(formData.value.context_attributes ?? []),
{ name: "", value: "", id: getUUID() },
]);
};
const removeVariable = (variable: any) => {
setF(
"context_attributes",
(formData.value.context_attributes ?? []).filter(
(_variable: any) => _variable.id !== variable.id,
),
);
};
const updateFunctionVisibility = () => {
if (!showVrlFunction.value && vrlFunctionError.value) {
validateSqlQueryPromise.value = Promise.resolve("");
vrlFunctionError.value = "";
}
};
const updateMultiTimeRange = (value: any) => {
if (value) {
setF("query_condition.multi_time_range", value);
}
};
const updateSilence = (value: any) => {
if (value) {
setF("trigger_condition.silence", value);
}
};
const updateExpandState = (value: any) => {
expandState.value = value;
};
const refreshDestinations = () => {
emit("refresh:destinations");
};
const refreshTemplates = () => {
emit("refresh:templates");
};
const updateDestinations = (destinations: any[]) => {
setF("destinations", destinations);
};
const updateRecoveryDestinations = (destinations: any[]) => {
setF("recovery_destinations", destinations);
};
const updateWorkflows = (workflows: any[]) => {
setF("workflows", workflows);
};
const updateTab = (tab: string) => {
setF("query_condition.type", tab);
};
// ── @update bridges from steps into the ONE form (Rule ② / ③) ─────────────
// The already-migrated descendants (QueryConfig/AlertSettings) bind their
// scalars directly by nested `name=`, so their update:* emits no-op in
// descendant mode; these handlers still route the out-of-form widget values
// (SQL/PromQL/VRL editors) and the bare wizard steps B (Advanced /
// CompareWithPast / Deduplication) into the form via setFieldValue.
const updateSqlQuery = (value: any) => setF("query_condition.sql", value);
const updatePromqlQuery = (value: any) => setF("query_condition.promql", value);
const updateVrlFunction = (value: any) => setF("query_condition.vrl_function", value);
const updateAggregation = (value: any) => setF("query_condition.aggregation", value);
const updatePromqlCondition = (value: any) => setF("query_condition.promql_condition", value);
const updateSloCondition = (value: any) => setF("query_condition.slo_condition", value);
const updateTriggerCondition = (value: any) => setF("trigger_condition", value);
const updateTemplate = (value: any) => setF("template", value);
const updateContextAttributes = (value: any) => setF("context_attributes", value);
const updateDescription = (value: any) => setF("description", value);
const updateRowTemplate = (value: any) => setF("row_template", value);
const updateRowTemplateType = (value: any) => setF("row_template_type", value);
const updateDeduplication = (value: any) => setF("deduplication", value);
const handleGoToSqlEditor = () => {
setF("query_condition.type", "sql");
if (isAnomalyMode.value) {
activeTab.value = "anomaly-config";
} else {
activeTab.value = "condition";
}
};
const clearMultiWindows = () => {
setF("query_condition.multi_time_range", []);
};
const handleEditorStateChanged = (isOpen: boolean) => {
isEditorOpen.value = isOpen;
};
const handleEditorClosed = () => {
if (previewAlertRef.value && typeof previewAlertRef.value.refreshData === "function") {
previewAlertRef.value.refreshData();
}
};
const openEditorDialog = () => {
viewSqlEditorDialog.value = true;
};
const openJsonEditor = () => {
showJsonEditorDialog.value = true;
};
const editorUpdate = (e: any) => {
setF("sql", e.target.value);
};
// ── Error Handling ──────────────────────────────────────────────────────
const HTTP_FORBIDDEN = 403;
const handleAlertError = (err: any) => {
if (err.response?.status !== HTTP_FORBIDDEN) {
toast({
variant: "error",
message: err.response?.data?.message || err.response?.data?.error || err.response?.data,
});
}
};
// ── JSON Editor Save ────────────────────────────────────────────────────
/** What the JSON editor DISPLAYS. `formData` is the whole form value set, so
* it carries the form-only keys (_ui/_meta/logGroupBy); pre-migration
* formData had none of them, so strip them to show the alert resource the
* user actually edits (Rule ④). cloneDeep first — formData is a readonly
* read-view and stripFormExtras mutates. */
const jsonEditorData = computed(() => stripFormExtras(cloneDeep(formData.value)));
const saveAlertJson = async (json: any) => {
const saveContext: SaveAlertContext = {
store,
t,
props,
emit,
router,
isAggregationEnabled,
activeFolderId: {
value: Array.isArray(activeFolderId.value) ? activeFolderId.value[0] : activeFolderId.value,
},
handleAlertError,
};
const prepareAndSaveAlertFunction = (data: any) => prepareAndSaveAlertUtil(data, saveContext);
const jsonValidationContext: JsonValidationContext = {
store,
t,
streams,
getStreams,
getParser,
// Bound here so the validation module keeps its 1-arg buildQueryPayload contract.
buildQueryPayload: (options: any) => buildQueryPayload(options, t),
prepareAndSaveAlert: prepareAndSaveAlertFunction,
};
// Seed the ONE form with the edited JSON (Rule ③ — `formData` is a readonly
// read-view, so the old `formData.value = payload` was a silent no-op).
// Matters on server-side rejection: the drawer is already closed, so without
// this the user's JSON edits are lost and can't be retried.
await saveAlertJsonUtil(
json,
props,
validationErrors,
showJsonEditorDialog,
resetForm,
jsonValidationContext,
);
};
// ── Prefill Import (any source surface) ─────────────────────────────────
/**
* Apply a prefill handed over by ANY surface — a dashboard panel, a logs
* search, a pattern set, or something added later. The shaping work already
* happened in the source's pure adapter (utils/alerts/prefill/*), so this
* only has to seed the form.
*
* Replaces the old `loadPanelDataIfPresent`, which read a JSON blob out of the
* URL and understood the panel shape only.
*/
const applyAlertPrefill = async () => {
const route = router.currentRoute.value;
if (!route.query.prefill) return;
const prefill = readAlertPrefill();
if (!prefill) return;
isLoadingPrefill.value = true;
try {
// Mutate a LOCAL working copy, then seed the ONE form with a single
// resetForm (Rule ③ — `formData` is a readonly read-view).
const data: any = cloneDeep(formData.value);
if (prefill.name) data.name = prefill.name;
data.stream_type = prefill.streamType || data.stream_type;
data.stream_name = prefill.streamName;
if (prefill.streamName) {
// Seed the form's stream fields first so updateStreams fetches the right
// schema (it reads them off the form); resetForm below re-seeds anyway.
setF("stream_type", data.stream_type);
setF("stream_name", data.stream_name);
await updateStreams(false);
}
if (prefill.queryType === "promql") {
applyPromqlPrefill(data, prefill);
} else if (prefill.queryType === "custom" && prefill.conditions) {
data.query_condition.type = "custom";
data.query_condition.conditions = cloneDeep(prefill.conditions);
} else {
data.query_condition.type = "sql";
let sql = prefill.sql ?? "";
// A raw-SQL threshold arrives as meta.sqlHaving because injecting it
// needs the SQL parser, which is async and lives here rather than in the
// (pure, synchronous) adapters.
const sqlHaving = prefill.meta?.sqlHaving as
{ column: string; operator: string; value: number } | undefined;
if (sql && sqlHaving?.column) {
if (!parser) await importSqlParser();
sql = addHavingClauseToQuery(
sql,
sqlHaving.column,
sqlHaving.operator,
sqlHaving.value,
parser,
);
}
data.query_condition.sql = sql;
}
if (prefill.aggregation) {
isAggregationEnabled.value = true;
data.query_condition.aggregation = cloneDeep(prefill.aggregation);
}
if (prefill.vrlFunction) {
showVrlFunction.value = true;
data.query_condition.vrl_function = prefill.vrlFunction;
}
if (prefill.periodMinutes) {
data.trigger_condition.period = prefill.periodMinutes;
}
if (prefill.frequencyMinutes) {
// The source's own schedule wins. The only floor is the org's minimum
// evaluation frequency, which is the rule the schema enforces on save;
// clamping to the form's DEFAULT instead (as this used to) silently
// overrode a curated alert that runs every minute with "every ten".
const floorMinutes = Math.max(1, Math.ceil(minAutoRefreshInterval() / 60));
data.trigger_condition.frequency = Math.max(floorMinutes, prefill.frequencyMinutes);
}
if (prefill.silenceMinutes !== undefined) {
data.trigger_condition.silence = prefill.silenceMinutes;
}
if (prefill.timezone) {
data.trigger_condition.timezone = prefill.timezone;
}
// A source that carries a real trigger says so explicitly, and is believed.
// Otherwise: with a threshold expressed inside the query (HAVING / promql
// condition), the trigger itself only needs "at least one row came back".
const hasQueryThreshold =
!!prefill.promqlCondition || !!prefill.meta?.sqlHaving || !!prefill.aggregation;
if (prefill.triggerThreshold !== undefined) {
data.trigger_condition.threshold = prefill.triggerThreshold;
if (prefill.triggerOperator) {
data.trigger_condition.operator = prefill.triggerOperator;
}
} else if (hasQueryThreshold) {
data.trigger_condition.threshold = 1;
data.trigger_condition.operator = ">=";
}
resetForm(data);
prefillWarnings.value = prefill.warnings ?? [];
toast({
variant: "success",
message: t(getAlertSource(prefill.source).toastKey, {
sourceLabel: prefill.sourceLabel,
}),
});
await nextTick();
if (previewAlertRef.value?.refreshData) {
previewAlertRef.value.refreshData();
}
} catch (error) {
console.error("Error applying alert prefill:", error);
toast({
variant: "error",
message: t("alerts.messages.failedToLoadPanelData"),
});
} finally {
isLoadingPrefill.value = false;
}
};
// ── Wizard Navigation (kept for anomaly) ────────────────────────────────
const goToStep2 = async () => {
wizardStep.value = 2;
};
const goToNextStep = async () => {
const isValid = await validateCurrentStep();
if (!isValid) {
return;
}
if (formData.value.is_real_time === "anomaly") {
if (wizardStep.value === 2 && anomalyStep2Ref.value) {
const valid = await anomalyStep2Ref.value.validate();
if (!valid) return;
}
if (wizardStep.value < 3) {
wizardStep.value = wizardStep.value + 1;
if (wizardStep.value > lastValidStep.value) {
lastValidStep.value = wizardStep.value;
}
}
return;
}
if (formData.value.is_real_time === "true") {
if (wizardStep.value === 2) {
wizardStep.value = 4;
} else if (wizardStep.value === 4) {
wizardStep.value = 6;
} else {
wizardStep.value = wizardStep.value + 1;
}
} else {
wizardStep.value = wizardStep.value + 1;
}
if (wizardStep.value > lastValidStep.value) {
lastValidStep.value = wizardStep.value;
}
};
const goToPreviousStep = () => {
if (formData.value.is_real_time === "anomaly") {
if (wizardStep.value > 1) wizardStep.value = wizardStep.value - 1;
return;
}
if (formData.value.is_real_time === "true") {
if (wizardStep.value === 6) {
wizardStep.value = 4;
} else if (wizardStep.value === 4) {
wizardStep.value = 2;
} else {
wizardStep.value = wizardStep.value - 1;
}
} else {
wizardStep.value = wizardStep.value - 1;
}
};
// ── Save Methods ────────────────────────────────────────────────────────
const saveAnomalyDetection = async () => {
if (!anomalyConfig.value.name?.trim()) {
toast({
variant: "error",
message: t("alerts.messages.anomalyNameRequired"),
});
return;
}
// These two gates must move the user to the tab holding the offending field.
// They used to set `wizardStep`, which the V2 stepper navigated by — the V3
// single-pane layout navigates by `activeTab` and passes a hardcoded
// wizard-step to the summary, so setting it here steered nothing and Save &
// Train just appeared dead. Toast as well: the invalid field is on a tab the
// user isn't looking at, so the highlight alone is invisible.
if (anomalyStep2Ref.value) {
const step2Valid = await anomalyStep2Ref.value.validate();
if (!step2Valid) {
activeTab.value = "anomaly-config";
toast({
variant: "error",
message: t("alerts.messages.fixHighlightedFields"),
});
return;
}
}
if (Object.values(anomalyWindowShareErrors(anomalyConfig.value)).some((e) => e !== null)) {
activeTab.value = "anomaly-alerting";
toast({
variant: "error",
message: t("alerts.messages.fixHighlightedFields"),
});
return;
}
anomalySaving.value = true;
const orgId = store.state.selectedOrganization.identifier;
const c = anomalyConfig.value;
if (c.query_mode === "custom_sql") {
if (!c.custom_sql?.trim()) {
toast({
variant: "error",
message: t("alerts.messages.customSqlRequired"),
});
wizardStep.value = 2;
anomalySaving.value = false;
return;
}
try {
await searchService.search({
org_identifier: orgId,
query: {
query: {
sql: c.custom_sql,
start_time: (Date.now() - 3600000) * 1000,
end_time: Date.now() * 1000,
from: 0,
size: 1,
},
},
page_type: c.stream_type,
});
} catch (sqlErr: any) {
const msg = sqlErr?.response?.data?.message || t("alerts.messages.invalidSqlQueryLower");
toast({
variant: "error",
message: t("alerts.validation.sqlValidationError", { error: msg }),
});
wizardStep.value = 2;
anomalySaving.value = false;
return;
}
}
try {
const budgetPerDay = anomalyBudgetPerDay(c);
const payload: any = {
alert_type: "anomaly_detection",
name: c.name,
description: c.description || undefined,
stream_name: c.stream_name,
stream_type: c.stream_type,
enabled: c.enabled ?? true,
folder_id: (activeFolderId.value as string) || "default",
alert_destinations:
c.alert_enabled && c.alert_destination_ids?.length ? c.alert_destination_ids : [],
// Feature 2. Priority is sent as an integer (the storage id); omitted
// entirely when unset so the payload matches a pre-Feature-2 config.
// Tags are always sent so clearing them actually clears — the backend
// treats an explicit empty list as "remove all".
...(c.priority === null || c.priority === undefined
? {}
: { priority: Number(c.priority) }),
tags: Array.isArray(c.tags) ? c.tags : [],
anomaly_config: {
query_mode: c.query_mode,
filters: c.query_mode === "filters" ? (c.filters ?? []) : null,
custom_sql: c.query_mode === "custom_sql" ? c.custom_sql : null,
detection_function: c.detection_function || "count(*)",
detection_function_field:
c.query_mode === "filters" && c.detection_function !== "count"
? c.detection_function_field || undefined
: undefined,
...anomalyIntervalPayload(c, anomalyStoredIntervals.value),
training_window_days: c.training_window_days,
retrain_interval_days: c.retrain_interval_days,
...anomalySensitivityPayload(budgetPerDay, c.threshold),
...anomalyBandPayload(c, budgetPerDay !== null),
alert_enabled: c.alert_enabled,
},
};
const routeAnomalyId = router.currentRoute.value.params.anomaly_id as string | undefined;
await saveAnomalyConfig.mutateAsync({
payload,
folderId: (activeFolderId.value as string) || "default",
});
toast({
variant: "success",
message: routeAnomalyId
? t("alerts.messages.anomalyConfigUpdated")
: t("alerts.anomalyCreated"),
});
emit("update:list", (activeFolderId.value as string) || "default");
} catch (err: any) {
toast({
variant: "error",
message: err?.response?.data?.message || t("alerts.messages.anomalyConfigSaveFailed"),
});
} finally {
anomalySaving.value = false;
}
};
// ── Save gate (Rule ③/④) ──────────────────────────────────────────────────
// The footer Save button (and Enter) drives `form.handleSubmit()`; the ONE
// composed schema now owns name/stream + the step field rules, so
// `useOForm({ onSubmit: performSave })` runs the actual save ONLY when the
// schema passes. handleSave adds the block-on-invalid + focus + toast that the
// old validateAndFocus() gave.
//
// Anomaly runs this too. It used to be excluded because the anomaly branch was
// a pure pass-through — the schema could never fail, so the block was dead code
// and saveAnomalyDetection's own toasts were the only feedback. Now that the
// branch enforces `name`, excluding anomaly would make a blank name fail
// SILENTLY: handleSubmit rejects, performSave never runs, and nothing tells the
// user why. The anomaly branch's ONLY rule is the blank name, so this can only
// fire for that.
const handleSave = async () => {
await form.handleSubmit();
if (!form.state.isValid) {
focusOnFirstError();
toast({
variant: "error",
message: t("alerts.messages.fixHighlightedFields"),
});
}
};
// ── Save-mode dialog ──────────────────────────────────────────────────────
// Only the selected query mode runs. When another runnable mode also holds
// content (or the selected one is empty while another is not), Save asks
// which mode the alert uses. Scheduled alerts only: Realtime runs Builder
// only, and anomaly/composite alerts have no query mode.
const queryModesWithContent = computed(() => {
const modes = modesWithContent({
sql: formData.value.query_condition?.sql,
promql: formData.value.query_condition?.promql,
conditions: formData.value.query_condition?.conditions,
streamType: formData.value.stream_type,
});
// An aggregation with no filters is still a Builder query, but only when
// Builder is selected: a new metrics alert's default avg is not content.
if (
(formData.value.query_condition?.type || "custom") === "custom" &&
isAggregationEnabled.value &&
!modes.includes("custom")
)
modes.unshift("custom");
return modes;
});
const saveModeChoices = computed(() => {
if (formData.value.is_real_time !== "false") return null;
const selected = formData.value.query_condition?.type || "custom";
const choices = (["custom", "sql", "promql"] as const).filter(
(mode) => mode === selected || queryModesWithContent.value.includes(mode),
);
return choices.length > 1 ? choices : null;
});
const saveModeDialogOpen = ref(false);
const saveModePick = ref<"custom" | "sql" | "promql">("custom");
// The user's last pick, so a save that fails validation does not ask again.
const confirmedSaveMode = ref<"custom" | "sql" | "promql" | null>(null);
// True while the dialog's own Save submits, so performSave does not ask again.
let savingPickedMode = false;
// Opens the dialog instead of saving. performSave calls it, so every submit
// asks: the footer Save button, and Enter in the name field (OInlineEdit
// calls form.requestSubmit(), which never goes through handleSave).
const askForSaveMode = () => {
if (savingPickedMode || !saveModeChoices.value) return false;
const selected = formData.value.query_condition?.type || "custom";
const selectedHasContent = queryModesWithContent.value.includes(selected);
if (selected === confirmedSaveMode.value && selectedHasContent) return false;
saveModePick.value = selected;
saveModeDialogOpen.value = true;
return true;
};
// The schema picks its rules from `_meta.tab`, which QueryConfig only syncs
// through watchers, so set it here too and let them settle before submitting.
// The guard stops a second click while the dialog plays its exit animation.
const saveWithPickedMode = async () => {
if (!saveModeDialogOpen.value) return;
saveModeDialogOpen.value = false;
confirmedSaveMode.value = saveModePick.value;
setF("query_condition.type", saveModePick.value);
setF("_meta.tab", saveModePick.value);
await nextTick();
savingPickedMode = true;
try {
await handleSave();
} finally {
savingPickedMode = false;
}
};
// Imperative pre-save gates RE-HOMED from QueryConfig.validate(), which returns
// true early in DESCENDANT mode — so its non-schema query-text gates (empty
// SQL / empty PromQL / aggregate-column toast) no longer fire. Re-home them
// here with the SAME messages so save stays blocked (Rule ④). The field rules
// (threshold / frequency / conditions / promql-condition / group_by / period /
// silence / destinations / name / stream) are covered by the composed schema.
const runImperativeQueryChecks = (): boolean => {
if (formData.value.is_real_time === "composite") return true;
// ── Cron gate (R4 RESTORE) ───────────────────────────────────────────────
// Pre-migration AlertSettings.validate() ran validateFrequency() first and
// returned {valid:false} on any cronJobError, which the orchestrator turned
// into a block + toast + switch to the condition tab. QueryConfig still
// RENDERS cronError inline but nothing gated save. Recomputed here from the
// form (not QueryConfig's local ref) so the gate holds regardless of which
// step is mounted. Messages are verbatim from validateFrequency.
// Minutes/hours mode needs no branch here: the ≥1 rule and the org-floor
// rule both live in the schema, keyed on `_ui.checkEvery`.
const tc = formData.value.trigger_condition || {};
if (tc.frequency_type === "cron") {
// Old validate() returned {valid:false, message:null} for a missing cron
// or timezone, and the orchestrator only toasted `if (message)` — so this
// case blocked + switched tab with NO toast, relying on QueryConfig's
// inline "Cron expression and timezone are required". Parity: no toast.
if (!tc.cron || !tc.timezone) {
activeTab.value = "condition";
return false;
}
try {
const intervalInSecs = getCronIntervalDifferenceInSeconds(tc.cron);
if (
typeof intervalInSecs === "number" &&
!isAboveMinRefreshInterval(intervalInSecs, store.state?.zoConfig)
) {
const minInterval = Number(store.state?.zoConfig?.min_auto_refresh_interval) || 1;
activeTab.value = "condition";
toast({
variant: "error",
message: t("alerts.queryConfig.frequencyGreaterThanSeconds", {
seconds: minInterval - 1,
}),
});
return false;
}
} catch {
activeTab.value = "condition";
toast({
variant: "error",
message: t("alerts.queryConfig.invalidCronExpression"),
});
return false;
}
}
const qc = formData.value.query_condition || {};
const tab = qc.type || "custom";
if (tab === "sql") {
if (!qc.sql || String(qc.sql).trim() === "") {
activeTab.value = "condition";
toast({
variant: "error",
message: t("alerts.messages.sqlQueryEmpty"),
});
return false;
}
if (sqlQueryErrorMsg.value && sqlQueryErrorMsg.value.trim() !== "") {
activeTab.value = "condition";
toast({
variant: "error",
message: t("alerts.messages.fixSqlErrorBeforeSaving"),
});
return false;
}
} else if (tab === "promql") {
if (!qc.promql || String(qc.promql).trim() === "") {
activeTab.value = "condition";
toast({
variant: "error",
message: t("alerts.messages.promqlQueryEmpty"),
});
return false;
}
}
// NOTE: the custom/measure "Column is required when using an aggregate
// function." gate USED to live here as a toast-only check. It moved into the
// composed schema (QueryConfig.schema.ts, the isCustom && isMeasure block)
// so the name-bound <OFormSelect> actually renders the error — an imperative
// toast can never paint a field, which is why the red highlight main drew
// (via QueryConfig's `columnSelectError` ref) went missing. Save stays
// blocked: a schema issue fails handleSubmit before onSubmit runs, so this
// function is never even reached in that state.
return true;
};
let callAlert: Promise<{ data: any }>;
// Advisories that ride along with a 200 — an alert that can only ever page
// the catch-all team, or one whose groups span teams. Shown beside the
// success toast and never in place of it: the save did happen, and an
// operator who meant it is entitled to keep it.
const showSaveWarnings = (res: { data?: { warnings?: string[] } }) => {
for (const message of res?.data?.warnings ?? []) {
toast({ variant: "warning", message: raw(message), timeout: 10000 });
}
};
// Post-schema scheduled/realtime save. Runs ONLY after the composed schema
// passes (via handleSubmit); preserves the imperative gates + the payload
// assembly byte-for-byte (Rule ④ payload parity). The payload is built from
// `form.state.values` — the synchronous source of truth (formData is its
// reactive read-view and can lag by a tick after a just-written setF).
const onSubmit = async () => {
if (!runImperativeQueryChecks()) return false;
if (
formData.value.is_real_time == "false" &&
formData.value.query_condition.type == "sql" &&
!getParser(formData.value.query_condition.sql)
) {
activeTab.value = "condition";
toast({
variant: "error",
message: t("alerts.messages.selectAllColumnsNotAllowed"),
timeout: 1500,
});
return false;
}
if (
formData.value.is_real_time == "false" &&
formData.value.trigger_condition.frequency_type == "cron"
) {
const now = new Date();
const day = String(now.getDate()).padStart(2, "0");
const month = String(now.getMonth() + 1).padStart(2, "0");
const year = now.getFullYear();
const date = `${day}-${month}-${year}`;
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const time = `${hours}:${minutes}`;
// Resolve any lingering "Browser Time (<zone>)" label (e.g. an existing
// alert opened from an older release) to a plain IANA zone before deriving
// the offset — otherwise convertDateToTimestamp returns NaN and tz_offset
// serializes to null in the saved payload.
const resolvedTimezone = resolveBrowserTimezone(
formData.value?.trigger_condition?.timezone ?? "",
);
setF("trigger_condition.timezone", resolvedTimezone);
const convertedDateTime = convertDateToTimestamp(date, time, resolvedTimezone);
setF("tz_offset", convertedDateTime.offset);
}
// Validate UDS
const udsValidation = validateConditionsAgainstUDS();
if (!udsValidation.isValid) {
const invalidCount = udsValidation.invalidFields.length;
let message = "";
// Three DISTINCT sentence shapes (singular / short list / truncated list),
// not one pluralised message — vue-i18n plural forms can't reproduce the
// third branch's extra params, so the hand-rolled branching stays and each
// shape gets its own key.
if (invalidCount === 1) {
message = t("alerts.messages.udsFieldNotAvailable", {
field: udsValidation.invalidFields[0],
});
} else if (invalidCount <= 3) {
message = t("alerts.messages.udsFieldsNotAvailable", {
fields: udsValidation.invalidFields.map((f: string) => `"${f}"`).join(", "),
});
} else {
const firstThree = udsValidation.invalidFields
.slice(0, 3)
.map((f: string) => `"${f}"`)
.join(", ");
const remaining = invalidCount - 3;
message = t("alerts.messages.udsFieldsNotAvailableTruncated", {
count: invalidCount,
fields: firstThree,
remaining,
});
}
toast({
variant: "error",
message: raw(message),
timeout: 6000,
});
activeTab.value = "condition";
return false;
}
const payload = getAlertPayload();
// Read before the save: resetForm in .then clears the form the payload came from.
const sendsNowhere = !hasDeliveryTarget(payload);
const dismiss = toast({
variant: "loading",
message: t("common.pleaseWait"),
timeout: 0,
});
if (formData.value.is_real_time == "false" && formData.value.query_condition.type == "sql") {
try {
await validateSqlQueryPromise.value;
} catch (error) {
dismiss();
toast({
variant: "error",
message: t("alerts.messages.sqlValidationRequestFailed"),
timeout: 1500,
});
console.error("Error while validating sql query", error);
return false;
}
}
if (formData.value.is_real_time !== "composite") {
// VERSION HANDLING - wrap conditions with version field for backend
payload.query_condition.conditions = {
version: 2,
conditions: form.state.values.query_condition.conditions,
};
}
if (beingUpdated.value) {
payload.folder_id = router.currentRoute.value.query.folder || "default";
callAlert = alertsService.update_by_alert_id(
store.state.selectedOrganization.identifier,
payload,
activeFolderId.value,
);
// Hold the settled promise so onSubmit (and therefore the form's
// isSubmitting) spans the whole request — otherwise the Save button
// re-enables in the same tick and repeat clicks fire duplicate saves.
const request = callAlert
.then((res: { data: any }) => {
resetForm(defaultAlertValue());
emit("update:list", activeFolderId.value);
addAlertForm.value?.resetValidation();
dismiss();
toast({
variant: "success",
message: sendsNowhere
? t("alerts.messages.alertUpdatedNoDestination")
: t("alerts.messages.alertUpdated"),
});
showSaveWarnings(res);
})
.catch((err: any) => {
dismiss();
handleAlertError(err);
});
track("Button Click", {
button: "Update Alert",
page: "Alerts",
});
analytics.track("Button Click", {
button: "Update Alert",
user_org: store.state.selectedOrganization.identifier,
user_id: store.state.userInfo.email,
stream_name: formData.value.stream_name,
alert_name: formData.value.name,
page: "Add/Update Alert",
});
return request;
} else {
payload.folder_id = activeFolderId.value;
callAlert = alertsService.create_by_alert_id(
store.state.selectedOrganization.identifier,
payload,
activeFolderId.value,
);
// Same as the update branch: returned below so isSubmitting spans the request.
const request = callAlert
.then((res: { data: any }) => {
resetForm(defaultAlertValue());
emit("update:list", activeFolderId.value);
addAlertForm.value?.resetValidation();
dismiss();
toast({
variant: "success",
message: sendsNowhere
? t("alerts.messages.alertSavedNoDestination")
: t("alerts.messages.alertSaved"),
});
showSaveWarnings(res);
})
.catch((err: any) => {
dismiss();
handleAlertError(err);
});
track("Button Click", {
button: "Create Alert",
page: "Alerts",
});
analytics.track("Button Click", {
button: "Save Alert",
user_org: store.state.selectedOrganization.identifier,
user_id: store.state.userInfo.email,
stream_name: formData.value.stream_name,
alert_name: formData.value.name,
page: "Add/Update Alert",
});
return request;
}
};
// useOForm's onSubmit handler (wired at form creation). Runs after the schema
// passes. Anomaly bypasses the alert form/payload entirely (its own OForm +
// saveAnomalyDetection). The anomaly schema branch is pass-through, so this is
// still reached in anomaly mode and delegates correctly.
const performSave = async () => {
if (isAnomalyMode.value) {
await saveAnomalyDetection();
return;
}
if (askForSaveMode()) return;
await onSubmit();
};
// ── Data Initialization (replaces created() hook) ───────────────────────
const initializeFormData = async () => {
// Build the full alert object in a LOCAL var, apply every transform, then
// seed the ONE form with a single form.reset (Rule ③ — NOT a per-field
// setFieldValue loop, NOT a mirror). The form-only extras (logGroupBy /
// _meta) are added by resetForm().
const data: any = { ...defaultAlertValue(), ...cloneDeep(props.modelValue) };
const route = router.currentRoute.value;
const hasPrefill = !!route.query.prefill;
if (!props.isUpdated) {
data.is_real_time = alertType.value === "realTime" ? true : false;
}
if (data.alert_type === "composite") data.is_real_time = "composite";
data.is_real_time = data.is_real_time.toString();
if (store.state?.zoConfig?.min_auto_refresh_interval)
data.trigger_condition.frequency = Math.max(
10,
Math.ceil(store.state?.zoConfig?.min_auto_refresh_interval / 60 || 10),
);
beingUpdated.value = props.isUpdated;
// Edit prefill (modelValue carries a saved alert).
let pendingTimezoneOffset: number | null = null;
if (props.modelValue && props.modelValue.name != undefined && props.modelValue.name != "") {
beingUpdated.value = true;
disableColor.value = "grey-5";
// Replace the working object with the saved alert (mirrors the old
// `formData.value = cloneDeep(modelValue)` full swap).
Object.keys(data).forEach((k) => delete data[k]);
Object.assign(data, cloneDeep(props.modelValue));
// The swap above replaces every key with the raw GET response, which for
// a composite carries `alert_type` but not `is_real_time`. Re-derive the
// composite flag so the composite form (not the scheduled form) renders.
if (data.alert_type === "composite") data.is_real_time = "composite";
// Guard the enterprise workflows link: the edited alert is expected to
// carry `workflows` (v2 GET returns it, serde-defaulted to []), but if a
// partially-populated row is ever passed, default it so an edit-save can't
// silently wipe existing links. Must run AFTER the swap above, which
// replaces every key on `data`.
if (!Array.isArray(data.workflows)) data.workflows = [];
// Same guard: the full swap above drops any key the GET omitted, and an undefined list
// would make the edit-save wipe the override.
if (!Array.isArray(data.recovery_destinations)) data.recovery_destinations = [];
// BE stores seconds; the form field displays minutes (mirrors the
// frequency field's display unit). Falls back to 0 for any alert type
// where the field is absent from the GET response (older cached
// response shape, etc.) rather than showing NaN.
const pendingMinutes = (Number(data.pending_period_sec) || 0) / 60;
data.pending_period_sec =
data.query_condition?.type === "promql" && data.query_condition.promql_rule_mode
? pendingMinutes
: Math.round(pendingMinutes);
isAggregationEnabled.value = !!data.query_condition?.aggregation;
// The saved type is the user's earlier answer; ask again only on a switch.
confirmedSaveMode.value = data.query_condition?.type ?? null;
if (data.query_condition?.promql_condition) {
if (!data.query_condition.promql_condition.column) {
data.query_condition.promql_condition.column = "value";
}
if (!data.query_condition.promql_condition.operator) {
data.query_condition.promql_condition.operator = ">=";
}
if (
data.query_condition.promql_condition.value === undefined ||
data.query_condition.promql_condition.value === null
) {
data.query_condition.promql_condition.value = 1;
}
}
lastValidStep.value = 6;
if (data.is_real_time !== "composite" && !data.trigger_condition?.timezone) {
if (data.tz_offset === 0) {
data.trigger_condition.timezone = "UTC";
} else {
// Resolved async AFTER the form.reset below → setF in the .then.
pendingTimezoneOffset = data.tz_offset;
}
} else if (data.is_real_time !== "composite") {
// Heal legacy alerts (e.g. created on older releases) that persisted a
// "Browser Time (<zone>)" label — resolve it to a plain IANA zone so the
// picker shows a valid value and the save path computes a real offset.
data.trigger_condition.timezone = resolveBrowserTimezone(data.trigger_condition.timezone);
}
if (data.query_condition?.vrl_function) {
showVrlFunction.value = true;
data.query_condition.vrl_function = smartDecodeVrlFunction(
data.query_condition.vrl_function,
);
}
}
data.is_real_time = data.is_real_time.toString();
// Convert context_attributes from object to array format
if (
data.context_attributes &&
typeof data.context_attributes === "object" &&
!Array.isArray(data.context_attributes)
) {
data.context_attributes = Object.keys(data.context_attributes).map((attr) => ({
key: attr,
value: data.context_attributes[attr],
id: getUUID(),
}));
} else if (!data.context_attributes) {
data.context_attributes = [];
}
// VERSION DETECTION AND CONVERSION
if (data.is_real_time === "composite") {
data.query_condition ??= defaultAlertValue().query_condition;
data.stream_type ??= "";
data.stream_name ??= "";
// The composite GET response may carry `null` for optional fields; the
// form inputs expect strings/arrays, so normalize them here.
data.description ??= "";
data.template ??= "";
data.tags ??= [];
data.composite_condition ??= {
expression: "",
warning_counts_as_firing: true,
stale_child_policy: "use_last_state",
};
data.children ??= [];
} else if (
data.query_condition.conditions?.version === "2" ||
data.query_condition.conditions?.version === 2
) {
data.query_condition.conditions = ensureIds(data.query_condition.conditions.conditions);
} else if (
data.query_condition.conditions &&
!Array.isArray(data.query_condition.conditions) &&
Object.keys(data.query_condition.conditions).length != 0
) {
const version = detectConditionsVersion(data.query_condition.conditions);
if (version === 0) {
data.query_condition.conditions = ensureIds(convertV0ToV2(data.query_condition.conditions));
} else if (version === 1) {
if (data.query_condition.conditions.and || data.query_condition.conditions.or) {
data.query_condition.conditions = ensureIds(
convertV1BEToV2(data.query_condition.conditions),
);
} else if (data.query_condition.conditions.label && data.query_condition.conditions.items) {
data.query_condition.conditions = ensureIds(
convertV1ToV2(data.query_condition.conditions),
);
}
} else {
data.query_condition.conditions = ensureIds(data.query_condition.conditions);
}
} else if (
Array.isArray(data.query_condition.conditions) &&
data.query_condition.conditions.length > 0
) {
data.query_condition.conditions = ensureIds(convertV0ToV2(data.query_condition.conditions));
} else if (
data.query_condition.conditions == null ||
data.query_condition.conditions == undefined ||
data.query_condition.conditions.length == 0 ||
Object.keys(data.query_condition.conditions).length == 0
) {
data.query_condition.conditions = {
filterType: "group",
logicalOperator: "AND",
conditions: [],
groupId: getUUID(),
};
}
// Seed the ONE form (single source of truth) with the fully-transformed obj.
resetForm(data);
// Resolve the timezone from the stored offset (edit prefill) after the reset.
if (pendingTimezoneOffset != null) {
getTimezonesByOffset(pendingTimezoneOffset).then((res: any) => {
if (res.length > 1) showTimezoneWarning.value = true;
setF("trigger_condition.timezone", res[0]);
});
}
// A prefill from any source surface writes into the now-seeded form.
if (hasPrefill) {
setF("query_condition.type", "");
await applyAlertPrefill();
}
if (data.is_real_time !== "composite") {
updateStreams(false)?.then(() => {
updateEditorContent(formData.value.stream_name);
});
}
};
// ── Watchers ────────────────────────────────────────────────────────────
// Sync shared fields from alert formData into anomaly config
watch(
() => [formData.value.name, formData.value.stream_type, formData.value.stream_name],
([name, streamType, streamName]) => {
anomalyConfig.value.name = name as string;
anomalyConfig.value.stream_type = (streamType as string) || "logs";
anomalyConfig.value.stream_name = streamName as string;
},
);
watch(
() => activeFolderId.value,
(folderId) => {
anomalyConfig.value.folder_id = (folderId as string) || "default";
},
);
// When switching to anomaly mode, seed anomalyConfig
watch(
() => formData.value.is_real_time,
(val) => {
if (val === "anomaly") {
anomalyConfig.value.name = formData.value.name || "";
anomalyConfig.value.stream_type = formData.value.stream_type || "logs";
anomalyConfig.value.stream_name = formData.value.stream_name || "";
anomalyConfig.value.folder_id = (activeFolderId.value as string) || "default";
if (wizardStep.value > 3) wizardStep.value = 1;
}
},
);
watch(
triggerCols.value,
() => {
filteredColumns.value = [...triggerCols.value];
},
{ immediate: true },
);
// Watch for SQL query changes
watch(
() => formData.value.query_condition?.sql,
(newValue) => {
if (getSelectedTab.value === "sql") {
previewQuery.value = newValue ? newValue.trim() : "";
}
},
);
// Watch for PromQL query changes
watch(
() => formData.value.query_condition?.promql,
(newValue) => {
if (getSelectedTab.value === "promql") {
previewQuery.value = newValue ? newValue.trim() : "";
}
},
);
// Watch for tab changes
watch(
() => formData.value.query_condition?.type,
(newType) => {
if (newType === "sql") {
previewQuery.value = formData.value.query_condition?.sql
? formData.value.query_condition.sql.trim()
: "";
isUsingBackendSql.value = false;
} else if (newType === "promql") {
previewQuery.value = formData.value.query_condition?.promql
? formData.value.query_condition.promql.trim()
: "";
isUsingBackendSql.value = false;
if (
!formData.value.query_condition.promql_condition &&
!formData.value.query_condition.promql_rule_mode
) {
setF("query_condition.promql_condition", {
column: "value",
operator: ">=",
value: 1,
});
}
} else if (newType === "custom") {
previewQuery.value = "";
isUsingBackendSql.value = false;
debouncedGenerateSql();
}
},
);
// Watch for condition/stream/aggregation changes to regenerate SQL
watch(
() => [
formData.value.query_condition?.conditions,
formData.value.stream_name,
formData.value.query_condition?.aggregation,
isAggregationEnabled.value,
],
() => {
if (formData.value.query_condition?.type === "custom") {
debouncedGenerateSql();
}
},
{ deep: true },
);
// Watch for stream changes to update context provider
watch(
() => [formData.value.stream_name, formData.value.stream_type],
([newStreamName, newStreamType]) => {
const alertsProvider = createAlertsContextProvider(
formData,
store,
props.isUpdated,
newStreamName,
newStreamType,
);
contextRegistry.register("alerts", alertsProvider);
},
);
// Watch for step4Ref to register focus manager fields
watch(
step4Ref,
(newVal) => {
if (newVal) {
nextTick(() => {
if ((newVal as any).periodFieldRef) {
focusManager.registerField("period", {
ref: (newVal as any).periodFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-alerting";
} else {
activeTab.value = "condition";
}
},
});
}
if ((newVal as any).thresholdFieldRef) {
focusManager.registerField("threshold", {
ref: (newVal as any).thresholdFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-alerting";
} else {
activeTab.value = "condition";
}
},
});
}
if ((newVal as any).silenceFieldRef) {
focusManager.registerField("silence", {
ref: (newVal as any).silenceFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-alerting";
} else {
activeTab.value = "condition";
}
},
});
}
if ((newVal as any).destinationsFieldRef) {
focusManager.registerField("destinations", {
ref: (newVal as any).destinationsFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-alerting";
} else {
activeTab.value = "condition";
}
},
});
}
if ((newVal as any).pendingPeriodFieldRef) {
focusManager.registerField("pending_period", {
ref: (newVal as any).pendingPeriodFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-alerting";
} else {
activeTab.value = "condition";
}
},
});
}
});
}
},
{ immediate: true },
);
// Watch for step2Ref to register query field
watch(
step2Ref,
(newVal) => {
if (newVal) {
nextTick(() => {
const queryType = formData.value.query_condition?.type || "custom";
if (queryType === "custom" && (newVal as any).customPreviewRef) {
focusManager.registerField("query", {
ref: (newVal as any).customPreviewRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-config";
} else {
activeTab.value = "condition";
}
},
});
} else if (
(queryType === "sql" || queryType === "promql") &&
(newVal as any).sqlPromqlPreviewRef
) {
focusManager.registerField("query", {
ref: (newVal as any).sqlPromqlPreviewRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-config";
} else {
activeTab.value = "condition";
}
},
});
}
});
}
},
{ immediate: true },
);
// Watch for step3Ref to register multiwindow field
watch(
step3Ref,
(newVal) => {
if (newVal && (newVal as any).multiWindowContainerRef) {
nextTick(() => {
focusManager.registerField("multiwindow", {
ref: (newVal as any).multiWindowContainerRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-alerting";
} else {
activeTab.value = "compare";
}
},
});
});
}
},
{ immediate: true },
);
// Watch for query type changes to re-register correct ref
watch(
() => formData.value.query_condition?.type,
(newType) => {
if (step2Ref.value && newType) {
nextTick(() => {
if (newType === "custom" && (step2Ref.value as any).customPreviewRef) {
focusManager.registerField("query", {
ref: (step2Ref.value as any).customPreviewRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-config";
} else {
activeTab.value = "condition";
}
},
});
} else if (
(newType === "sql" || newType === "promql") &&
(step2Ref.value as any).sqlPromqlPreviewRef
) {
focusManager.registerField("query", {
ref: (step2Ref.value as any).sqlPromqlPreviewRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
activeTab.value = "anomaly-config";
} else {
activeTab.value = "condition";
}
},
});
}
});
}
},
);
// ── Lifecycle ───────────────────────────────────────────────────────────
onBeforeMount(async () => {
await importSqlParser();
await getAllFunctions();
});
onMounted(async () => {
// Initialize form data (replaces created() hook)
await initializeFormData();
// Pre-set anomaly mode
if (router.currentRoute.value.name === "addAnomalyDetection") {
setF("is_real_time", "anomaly");
}
// Load anomaly detection config when editing
const routeAnomalyId = router.currentRoute.value.params.anomaly_id as string | undefined;
if (routeAnomalyId) {
try {
const res = await anomalyDetectionService.get(
store.state.selectedOrganization.identifier,
routeAnomalyId,
);
const data = res.data;
const histInterval = parseAnomalyInterval(data.histogram_interval, 5, "m");
const sched = parseAnomalyInterval(data.schedule_interval, 1, "h");
const winSecs =
typeof data.detection_window_seconds === "number" && data.detection_window_seconds > 0
? data.detection_window_seconds
: null;
const win = anomalyWindowSecondsToParts(
winSecs ?? anomalyIntervalSeconds(sched.value, sched.unit) ?? 3600,
);
anomalyStoredIntervals.value = {
histogram: {
raw: typeof data.histogram_interval === "string" ? data.histogram_interval : null,
...histInterval,
},
schedule: {
raw: typeof data.schedule_interval === "string" ? data.schedule_interval : null,
...sched,
},
window: { raw: winSecs, value: win.value, unit: win.unit, parsed: winSecs !== null },
};
const rawDestIds =
data.alert_destinations ?? data.alert_destination_ids ?? data.alert_destination_id;
const destIds: string[] = Array.isArray(rawDestIds)
? rawDestIds
: rawDestIds
? [rawDestIds]
: [];
const rawFn: string = data.detection_function || "count";
const fnMatch = rawFn.match(/^(\w+)\(([^)]*)\)$/);
const parsedFn = fnMatch ? fnMatch[1] : rawFn;
const parsedField =
data.detection_function_field || (fnMatch && fnMatch[2] !== "*" ? fnMatch[2] : "");
anomalyConfig.value = {
...defaultAnomalyConfig(),
...data,
detection_function: parsedFn,
detection_function_field: parsedField,
threshold: data.threshold ?? data.percentile ?? 97,
band_width: anomalyBandWidthPrefill(data),
filters: Array.isArray(data.filters) ? data.filters : [],
histogram_interval_value: histInterval.value,
histogram_interval_unit: histInterval.unit,
schedule_interval_value: sched.value,
schedule_interval_unit: sched.unit,
detection_window_value: win.value,
detection_window_unit: win.unit,
alert_destination_ids: destIds,
};
setF("is_real_time", "anomaly");
setF("name", data.name);
setF("stream_name", data.stream_name);
setF("stream_type", data.stream_type);
if (data.folder_id) activeFolderId.value = data.folder_id;
anomalyEditMode.value = true;
lastValidStep.value = 6;
// The stream is only known NOW. initializeFormData already ran its
// updateStreams, but at that point stream_type was empty, so it
// returned early — leaving the stream select with no options and the
// stream's columns unloaded. The form value was set, yet nothing was
// selectable and every field picker downstream was empty.
if (data.stream_type) await updateStreams(false);
} catch {
toast({
variant: "error",
message: t("alerts.messages.anomalyConfigLoadFailed"),
});
}
}
// Set up alerts context provider
const alertsProvider = createAlertsContextProvider(
formData,
store,
props.isUpdated,
formData.value.stream_name,
formData.value.stream_type,
);
contextRegistry.register("alerts", alertsProvider);
contextRegistry.setActive("alerts");
// Register focus manager fields
await nextTick();
focusManager.registerField("streamType", {
ref: streamTypeFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
if (wizardStep.value !== 1) wizardStep.value = 1;
}
// For V3 layout, streamType is always visible in top bar
},
});
focusManager.registerField("stream", {
ref: streamFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
if (wizardStep.value !== 1) wizardStep.value = 1;
}
},
});
focusManager.registerField("alertType", {
ref: streamTypeFieldRef,
onBeforeFocus: () => {
if (isAnomalyMode.value) {
if (wizardStep.value !== 1) wizardStep.value = 1;
}
},
});
});
onUnmounted(() => {
contextRegistry.unregister("alerts");
contextRegistry.setActive("");
focusManager.clear();
});
// ── Return ──────────────────────────────────────────────────────────────
return {
// Dependencies
t,
store,
router,
track,
// Core state
beingUpdated,
addAlertForm,
disableColor,
formData,
indexOptions,
schemaList,
streams,
editorRef,
filteredColumns,
filteredStreams,
editorobj,
sqlAST,
selectedRelativeValue,
selectedRelativePeriod,
relativePeriods,
triggerCols,
selectedDestinations,
originalStreamFields,
isAggregationEnabled,
isEditorOpen,
// Anomaly state
anomalyConfig,
anomalyStoredIntervals,
anomalyStep2Ref,
showAnomalySummary,
anomalyEditMode,
anomalyRetraining,
anomalySaving,
anomalyStatusVariant,
anomalyFormatTs,
anomalyTriggerRetrain,
isAnomalyMode,
anomalyPreviewSql,
anomalySummarySectionStyle,
// UI state
expandState,
triggerOperators,
showVrlFunction,
isFetchingStreams,
streamTypes,
rowTemplateTypeOptions,
focusManager,
streamFieldRef,
streamTypeFieldRef,
previewQuery,
isUsingBackendSql,
sqlQueryErrorMsg,
sqlErrorRanges,
sqlAggColumnOptions,
sqlQueryHasHaving,
validateSqlQueryPromise,
addAlertFormRef,
viewSqlEditorDialog,
plotChart,
previewAlertRef,
vrlFunctionError,
showTimezoneWarning,
showJsonEditorDialog,
validationErrors,
isLoadingPrefill,
prefillWarnings,
activeFolderId,
alertType,
// Wizard state (anomaly)
wizardStep,
wizardStepper,
step1Ref,
step2Ref,
step3Ref,
step4Ref,
lastValidStep,
streamTypeRef,
streamNameRef,
anomalyNameRef,
currentStepCaption,
isLastStep,
goToStep2,
goToNextStep,
goToPreviousStep,
// V3 tab state
activeTab,
tabErrors,
chartCollapsed,
previewDateTimeValue,
thresholdMarkLine,
// Computed
editorData,
prefixCode,
suffixCode,
streamFieldsMap,
showPreview,
rowTemplatePlaceholder,
decodedVrlFunction,
getSelectedTab,
getFormattedDestinations,
generatedSqlQuery,
// Form (Rule ③ owner) + write helpers
form,
setF,
resetForm,
// Methods
editorUpdate,
updateEditorContent,
updateStreamFields,
updateStreams,
filterStreams,
generateSqlQuery: generateSqlQueryLocal,
onInputUpdate,
getAlertPayload,
updateSqlQuery,
updatePromqlQuery,
updateVrlFunction,
updateAggregation,
updatePromqlCondition,
updateSloCondition,
updateTriggerCondition,
updateTemplate,
updateContextAttributes,
updateDescription,
updateRowTemplate,
updateRowTemplateType,
updateDeduplication,
validateInputs,
validateSqlQuery,
validateConditionsAgainstUDS,
validateStep,
validateAllTabs,
validateAndFocus,
updateGroup,
removeConditionGroup,
transformFEToBE,
retransformBEToFE,
updateActiveFolderId,
addVariable,
removeVariable,
updateFunctionVisibility,
updateMultiTimeRange,
updateSilence,
updateExpandState,
refreshDestinations,
refreshTemplates,
updateDestinations,
updateRecoveryDestinations,
updateWorkflows,
updateTab,
handleGoToSqlEditor,
clearMultiWindows,
handleEditorStateChanged,
handleEditorClosed,
openEditorDialog,
openJsonEditor,
jsonEditorData,
saveAlertJson,
applyAlertPrefill,
handleSave,
queryModesWithContent,
saveModeChoices,
saveModeDialogOpen,
saveModePick,
saveWithPickedMode,
onSubmit,
saveAnomalyDetection,
previewAlert,
focusOnFirstError,
handleAlertError,
getParser,
initializeFormData,
// Constants/utils
info: "info",
getTimezoneOffset,
isValidResourceName,
convertDateToTimestamp,
getTimezonesByOffset,
};
}