mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
3344 lines
120 KiB
TypeScript
3344 lines
120 KiB
TypeScript
// 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,
|
||
};
|
||
}
|