// 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 .
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 } from "lodash-es";
import alertsService from "@/services/alerts";
import searchService from "@/services/search";
import anomalyDetectionService from "@/services/anomaly_detection";
import segment from "@/services/segment_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,
b64DecodeUnicode,
smartDecodeVrlFunction,
isValidResourceName,
getTimezonesByOffset,
resolveBrowserTimezone,
} from "@/utils/zincutils";
import { convertDateToTimestamp } from "@/utils/date";
import { generateSqlQuery } from "@/utils/alerts/alertQueryBuilder";
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 {
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 { 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 config from "@/aws-exports";
import { useOForm } from "@/lib/forms/Form/useOForm";
import { makeAddAlertSchema, defaultAddAlertMeta } from "@/components/alerts/AddAlert.schema";
// ─── Default Values ─────────────────────────────────────────────────────────
export const defaultAlertValue: any = () => {
return {
name: "",
stream_type: "logs",
stream_name: "",
is_real_time: "false",
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,
vrl_function: null,
multi_time_range: [],
},
trigger_condition: {
period: 10,
operator: ">=",
frequency: 10,
cron: "",
threshold: 3,
silence: 10,
frequency_type: "minutes",
timezone: "UTC",
},
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,
// 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: [],
};
};
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 "m" | "h",
schedule_interval_value: 1,
schedule_interval_unit: "h" as "m" | "h",
detection_window_value: 1,
detection_window_unit: "h" as "m" | "h",
training_window_days: 14,
retrain_interval_days: 7,
threshold: 100,
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,
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[],
});
// ─── Composable ─────────────────────────────────────────────────────────────
export interface AlertFormProps {
modelValue: any;
isUpdated: boolean;
destinations: any[];
templates?: any[];
}
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;
_meta: Record;
} & Record;
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,
};
};
const buildDefaultForm = (): any => {
const base = defaultAlertValue();
return {
...base,
logGroupBy: [] as string[],
_ui: { checkEvery: frequencyDisplay(base).checkEvery },
_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 },
_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).
// Workflows are enterprise/cloud-only; where they exist an alert may be
// delivered to a destination OR a workflow, which relaxes "destinations ≥ 1"
// into "at least one of the two". In OSS this stays false and the rule (and
// its message) is byte-identical to before.
// Also respects the backend /config flag: on an enterprise build with
// workflows switched OFF the picker has no Workflows group, so relaxing the
// rule would surface "destination or workflow required" for a choice the user
// cannot make. Falls back to the strict "destination required" rule, which is
// the same rule OSS gets. (Built once in setup — if /config has not landed
// yet this is the stricter of the two, which is the safe direction.)
const addAlertSchema = makeAddAlertSchema(
t,
(config.isEnterprise === "true" || config.isCloud === "true") &&
store.state.zoConfig?.workflows_enabled === true,
);
const form = useOForm({
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 = 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());
const anomalyStep2Ref = ref(null);
const showAnomalySummary = ref(true);
const anomalyEditMode = ref(false);
const anomalyRetraining = ref(false);
const anomalySaving = ref(false);
const anomalyStatusVariant = computed(() => {
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 anomalyDetectionService.triggerTraining(
store.state.selectedOrganization.identifier,
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 anomalyScheduleInterval = computed(
() =>
`${anomalyConfig.value.schedule_interval_value}${anomalyConfig.value.schedule_interval_unit}`,
);
const anomalyDetectionWindowSeconds = computed(() => {
const mult = anomalyConfig.value.detection_window_unit === "h" ? 3600 : 60;
return anomalyConfig.value.detection_window_value * mult;
});
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 || "";
const interval = anomalyHistogramInterval.value || "5m";
const fn = toDetectionFunctionSql(
c.detection_function,
c.detection_function_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")
: "";
const autoSeasonality = c.training_window_days >= 7 ? "week" : "day";
const seasonalSelect =
autoSeasonality === "week"
? ",\n date_part('hour', to_timestamp(_timestamp / 1000000)) AS hour,\n date_part('dow', to_timestamp(_timestamp / 1000000)) AS dow"
: ",\n date_part('hour', to_timestamp(_timestamp / 1000000)) AS hour";
const seasonalGroup = autoSeasonality === "week" ? ", hour, dow" : ", hour";
return [
`SELECT histogram(_timestamp, '${interval}') AS time_bucket,`,
` ${fn} AS value${seasonalSelect}`,
`FROM ${stream}`,
where,
`GROUP BY time_bucket${seasonalGroup}`,
`ORDER BY time_bucket`,
]
.filter(Boolean)
.join("\n");
});
const anomalySummarySectionStyle = computed(() => {
if (!showAnomalySummary.value) return { flex: "0 0 auto" };
return { flex: "1", minHeight: "150px" };
});
// ── 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([]);
const validateSqlQueryPromise = ref>();
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 isLoadingPanelData = ref(false);
const folderQuery = router.currentRoute.value.query.folder;
const activeFolderId = ref(
(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(null);
const streamNameRef = ref(null);
const anomalyNameRef = ref(null);
// ── V3 Tab State (for standard alerts) ──────────────────────────────────
const activeTab = ref("condition");
const tabErrors = ref>({
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 = {
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,
};
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) => {
if (!sql || !parser || isSyncingStreamFromSql.value) 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;
setF("stream_name", fromStream);
await updateStreamFields(fromStream);
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 && 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.
return getAlertPayloadUtil(form.state.values, payloadContext);
};
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.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