Files
openobserve/web/src/components/alerts/steps/QueryConfig.vue
T
61aebff552 fix: refactor!: remove SQL mode toggle in favor of auto-detection (#12483)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 04:43:00 +00:00

2654 lines
99 KiB
Vue

<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="step-query-config" :class="store.state.theme === 'dark' ? 'dark-mode' : 'light-mode'">
<div class="step-content card-container">
<!-- Section header -->
<div class="section-header">
<div class="section-header-accent" />
<span class="section-header-title">{{ t('alerts.queryConfig.sectionTitle') }}</span>
</div>
<div class="tw:px-3 tw:py-2 tw:min-w-0 tw:w-full tw:box-border">
<!-- Query Mode Tabs (hidden for real-time alerts) -->
<div v-if="shouldShowTabs" class="tw:mb-2 tw:flex tw:items-center tw:justify-between">
<OToggleGroup
:model-value="localTab"
@update:model-value="updateTab($event as string)"
data-test="step2-query-tabs"
>
<OToggleGroupItem
v-for="tab in tabOptions"
:key="tab.value"
:value="tab.value"
:data-test="`query-mode-${tab.value}`"
size="sm"
>
<template #icon-left>
<OIcon v-if="tab.value === 'custom'" name="build" size="sm" />
<OIcon v-else-if="tab.value === 'sql'" name="database" size="sm" />
<OIcon v-else-if="tab.value === 'promql'" name="show-chart" size="sm" />
</template>
{{ tab.label }}
</OToggleGroupItem>
</OToggleGroup>
<!-- Open Full Editor (SQL/PromQL tabs) -->
<OButton
v-if="localTab !== 'custom'"
data-test="step2-view-editor-btn"
variant="outline"
size="sm"
@click="viewSqlEditor = true"
>
{{ t('alerts.queryConfig.openFullEditor') }}
</OButton>
</div>
<!-- Custom Query Builder -->
<template v-if="localTab === 'custom'">
<div>
<!-- Section 1: Alert condition sentence — scheduled only -->
<div v-if="isRealTime === 'false'" class="alert-condition-rows">
<!-- LOGS/TRACES -->
<template v-if="isEventBased">
<!-- Alert if row -->
<div class="alert-condition-row" data-test="alert-if-row-logs">
<span class="condition-label">Alert if *</span>
<div class="tw:flex tw:flex-nowrap tw:items-center tw:gap-2">
<div class="tw:min-w-[130px] tw:max-w-[180px]">
<OSelect
v-model="selectedFunction"
:options="logFunctionOptions"
labelKey="label"
valueKey="value"
@update:model-value="onLogFunctionChange"
/>
<OTooltip :content="logFunctionOptions.find((o: any) => o.value === selectedFunction)?.tooltip || ''" :delay="400" />
</div>
<!-- "of [field]" shown for measure modes -->
<template v-if="selectedFunction !== 'total_events'">
<span class="condition-text">of</span>
<OSelect
v-model="logMeasureColumn"
:options="numericColumns"
searchable
:placeholder="t('alerts.placeholders.selectColumn')"
:class="['tw:min-w-[140px] tw:max-w-[200px]', columnSelectError ? 'column-select-error' : '']"
@update:model-value="columnSelectError = false; onLogMeasureColumnChange($event)"
/>
</template>
<!-- COUNT mode -->
<template v-if="selectedFunction === 'total_events'">
<OSelect
v-model="triggerOperator"
:options="numericOperators"
class="tw:min-w-[70px] tw:max-w-[120px]"
data-test="alert-trigger-operator-select"
:searchable="false"
@update:model-value="onTriggerOperatorChange"
/>
<OInput
v-model="triggerThreshold"
type="number"
data-test="alert-trigger-threshold-input"
@blur="restoreDefaultThreshold"
class="tw:min-w-[60px] tw:max-w-[80px]"
min="1"
:error="!!triggerThresholdError"
:error-message="triggerThresholdError"
@update:model-value="triggerThresholdError = ''; onTriggerThresholdChange($event)"
/>
<span v-if="streamName" class="condition-text">matching {{ streamType === 'traces' ? 'traces' : 'logs' }} found</span>
</template>
<!-- MEASURE mode -->
<template v-else>
<span class="condition-text">is</span>
<OSelect
v-model="conditionOperator"
:options="numericOperators"
:searchable="false"
class="tw:min-w-[70px] tw:max-w-[120px]"
data-test="alert-condition-operator-select"
@update:model-value="onConditionOperatorChange"
/>
<OInput
v-model="conditionValue"
type="number"
:placeholder="t('alerts.placeholders.value')"
class="tw:min-w-[80px] tw:max-w-[120px]"
:error="!!conditionValueError"
:error-message="conditionValueError"
@update:model-value="conditionValueError = ''; onConditionValueChange($event)"
/>
</template>
</div>
</div>
<!-- group by row (hidden for count mode) -->
<div v-if="selectedFunction !== 'total_events'" class="alert-condition-row" data-test="alert-group-by-row">
<span class="condition-label tw:font-bold">
{{ t('alerts.groupBy') }}
<OTooltip :content="t('alerts.queryConfig.groupByTooltip')" :delay="300" side="top" />
</span>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-wrap">
<template
v-for="(group, index) in logGroupBy"
:key="index"
>
<div class="tw:flex tw:items-center tw:gap-1">
<OSelect
v-model="logGroupBy[index]"
:options="columns"
searchable
:placeholder="t('alerts.placeholders.selectColumn')"
class="alert-v3-select"
style="min-width: 120px; max-width: 180px;"
:data-test="`alert-group-by-select-${index}`"
@update:model-value="onLogGroupByChange"
/>
<OButton
variant="ghost"
size="icon-circle-sm"
class="tw:text-gray-400 hover:tw:text-red-500"
@click="deleteLogGroupByColumn(index)"
>
<OIcon name="close" size="sm" />
</OButton>
</div>
</template>
<OButton
variant="ghost-primary"
size="icon-circle-sm"
data-test="alert-group-by-add-btn"
@click="addLogGroupByColumn"
>
<OIcon name="add" size="sm" />
<OTooltip :content="t('alerts.queryConfig.addGroupByField')" />
</OButton>
</div>
</div>
<!-- no. of groups row — visible only when group-by fields are added -->
<div v-if="selectedFunction !== 'total_events' && hasLogGroupByFields" class="alert-condition-row" data-test="alert-having-groups-row">
<span class="condition-label tw:font-bold">
{{ t('alerts.queryConfig.havingGroups') }}
<OTooltip :content="t('alerts.queryConfig.havingGroupsTooltip')" :delay="300" side="top" />
</span>
<div class="tw:flex tw:flex-nowrap tw:items-center tw:gap-2">
<OSelect
v-model="triggerOperator"
:options="numericOperators"
:searchable="false"
class="tw:min-w-[70px] tw:max-w-[120px]"
@update:model-value="onTriggerOperatorChange"
/>
<OInput
v-model="triggerThreshold"
type="number"
class="tw:min-w-[60px] tw:max-w-[80px]"
min="1"
@update:model-value="triggerThresholdError = ''; onTriggerThresholdChange($event)"
@blur="restoreDefaultThreshold"
/>
</div>
</div>
</template>
<!-- METRICS -->
<template v-else>
<!-- Alert if row -->
<div class="alert-condition-row">
<span class="condition-label">Alert if *</span>
<div class="tw:flex tw:flex-nowrap tw:items-center tw:gap-2">
<div class="tw:min-w-[130px] tw:max-w-[180px]">
<OSelect
v-model="selectedFunction"
:options="logFunctionOptions"
labelKey="label"
valueKey="value"
class="alert-v3-select"
@update:model-value="onMetricFunctionChange"
/>
<OTooltip :content="logFunctionOptions.find((o: any) => o.value === selectedFunction)?.tooltip || ''" :delay="400" />
</div>
<!-- "of [field]" hidden for count mode -->
<template v-if="selectedFunction !== 'total_events'">
<span class="condition-text">of</span>
<div style="position: relative; display: inline-flex;">
<OSelect
v-model="inputData.aggregation.having.column"
:options="columns"
searchable
:placeholder="t('alerts.placeholders.selectColumn')"
:readonly="inputData.aggregation.having.column === 'value' && columns.some((c: any) => (typeof c === 'string' ? c : c.value) === 'value')"
:disable="inputData.aggregation.having.column === 'value' && columns.some((c: any) => (typeof c === 'string' ? c : c.value) === 'value')"
@update:model-value="columnSelectError = false; emitAggregationUpdate()"
class="alert-v3-select"
:class="columnSelectError ? 'column-select-error' : ''"
style="min-width: 140px; max-width: 200px;"
/>
<OTooltip v-if="inputData.aggregation.having.column === 'value' && columns.some((c: any) => (typeof c === 'string' ? c : c.value) === 'value')" content="Metrics streams store their measurement in the &quot;value&quot; field by default" :delay="300" side="bottom" />
</div>
<span class="condition-text">is</span>
</template>
<!-- Count mode for metrics -->
<template v-if="selectedFunction === 'total_events'">
<OSelect
v-model="triggerOperator"
:options="numericOperators"
:searchable="false"
class="alert-v3-select"
style="min-width: 70px; max-width: 120px;"
@update:model-value="onTriggerOperatorChange"
/>
<OInput
v-model="triggerThreshold"
type="number"
class="alert-v3-input"
style="min-width: 80px; max-width: 120px;"
:error="!!triggerThresholdError"
:error-message="triggerThresholdError"
@update:model-value="triggerThresholdError = ''; onTriggerThresholdChange($event)"
/>
<span class="condition-text">matching metrics found</span>
</template>
<!-- Measure mode for metrics -->
<template v-else>
<OSelect
v-model="conditionOperator"
:options="numericOperators"
:searchable="false"
class="alert-v3-select"
style="min-width: 70px; max-width: 120px;"
@update:model-value="onConditionOperatorChange"
/>
<OInput
v-model="conditionValue"
type="number"
:placeholder="t('alerts.placeholders.value')"
class="alert-v3-input"
style="min-width: 80px; max-width: 120px;"
:error="!!conditionValueError"
:error-message="conditionValueError"
@update:model-value="conditionValueError = ''; onConditionValueChange($event)"
/>
</template>
</div>
</div>
<!-- group by row — hidden for count mode -->
<div v-if="inputData.aggregation && selectedFunction !== 'total_events'" class="alert-condition-row">
<span class="condition-label tw:font-bold">
{{ t('alerts.groupBy') }}
<OTooltip :content="t('alerts.queryConfig.groupByTooltip')" :delay="300" side="top" />
</span>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-wrap">
<template
v-for="(group, index) in inputData.aggregation.group_by"
:key="index"
>
<div class="tw:flex tw:items-center tw:gap-1">
<OSelect
v-model="inputData.aggregation.group_by[index]"
:options="columns"
searchable
:placeholder="t('alerts.placeholders.selectColumn')"
class="alert-v3-select"
style="min-width: 120px; max-width: 180px;"
@update:model-value="emitAggregationUpdate"
/>
<OButton
variant="ghost"
size="icon-circle-sm"
class="tw:text-gray-400 hover:tw:text-red-500"
@click="deleteGroupByColumn(index)"
>
<OIcon name="close" size="sm" />
</OButton>
</div>
</template>
<OButton
variant="ghost-primary"
size="icon-circle-sm"
@click="addGroupByColumn"
>
<OIcon name="add" size="sm" />
<OTooltip :content="t('alerts.queryConfig.addGroupByField')" />
</OButton>
</div>
</div>
<!-- no. of groups row — visible only when group-by fields are added -->
<div v-if="selectedFunction !== 'total_events' && hasMetricGroupByFields" class="alert-condition-row">
<span class="condition-label tw:font-bold">
{{ t('alerts.queryConfig.havingGroups') }}
<OTooltip :content="t('alerts.queryConfig.havingGroupsTooltip')" :delay="300" side="top" />
</span>
<div class="tw:flex tw:flex-nowrap tw:items-center tw:gap-2">
<OSelect
v-model="triggerOperator"
:options="numericOperators"
:searchable="false"
class="tw:min-w-[70px] tw:max-w-[120px]"
@update:model-value="onTriggerOperatorChange"
/>
<OInput
v-model="triggerThreshold"
type="number"
class="tw:min-w-[60px] tw:max-w-[80px]"
min="1"
@update:model-value="triggerThresholdError = ''; onTriggerThresholdChange($event)"
@blur="restoreDefaultThreshold"
/>
</div>
</div>
</template>
<!-- Check every row -->
<div class="alert-condition-row tw:!tw:items-start">
<span class="condition-label" style="line-height: 28px;">
Check every *
<OTooltip content="How often to check this alert condition" :delay="300" side="top" />
</span>
<div class="tw:flex tw:flex-col tw:gap-1">
<div class="tw:flex tw:items-center tw:gap-2">
<!-- Minutes/hours mode: number input -->
<template v-if="frequencyMode !== 'cron'">
<OInput
v-model="checkEveryFrequency"
type="number"
class="alert-v3-input"
style="min-width: 100px; max-width: 100px;"
min="1"
:error="!!checkEveryFrequencyError"
:error-message="checkEveryFrequencyError"
@update:model-value="checkEveryFrequencyError = ''; onCheckEveryChange($event)"
@blur="restoreDefaultFrequency"
/>
</template>
<!-- Cron mode: expression input + timezone -->
<template v-else>
<OInput
v-model="cronExpression"
class="alert-v3-input"
placeholder="0 */10 * * * *"
style="min-width: 100px; max-width: 100px;"
@update:model-value="onCronExpressionChange"
/>
</template>
<!-- Unit dropdown: minutes / hours / cron -->
<OSelect
:model-value="frequencyMode"
:options="frequencyUnitOptions"
labelKey="label"
valueKey="value"
:searchable="false"
class="alert-v3-select frequency-unit-select"
style="min-width: 80px; max-width: 100px;"
@update:model-value="onFrequencyUnitChange"
/>
<!-- Timezone (only for cron, inline) -->
<template v-if="frequencyMode === 'cron'">
<span class="tw:inline-block" style="min-width: 150px; max-width: 150px;">
<OSelect
v-model="cronTimezone"
:options="filteredTimezones"
searchable
placeholder="timezone"
class="alert-v3-select"
style="min-width: 150px; max-width: 150px;"
@update:model-value="onCronTimezoneChange"
/>
<OTooltip
v-if="cronTimezone"
:content="cronTimezone"
:delay="300"
side="bottom"
/>
</span>
</template>
<span class="condition-text">on these</span>
<div
class="tw:flex tw:items-center tw:gap-1 tw:cursor-pointer tw:select-none filters-inline-toggle tw:px-2 tw:py-0.5 tw:rounded-md tw:transition-colors"
:class="store.state.theme === 'dark'
? 'tw:bg-gray-700/60 hover:tw:bg-gray-600/70'
: 'tw:bg-gray-100 hover:tw:bg-gray-200'"
@click="toggleFilters"
>
<OIcon
name="filter-alt"
size="xs"
:class="filterCount > 0
? 'tw:text-[var(--q-primary)]'
: (store.state.theme === 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-500')"
/>
<span class="tw:text-xs tw:font-semibold"
:class="filterCount > 0
? 'tw:text-[var(--q-primary)]'
: (store.state.theme === 'dark' ? 'tw:text-gray-300' : 'tw:text-gray-600')">
filters
</span>
<span v-if="filterCount > 0"
class="tw:text-[11px] tw:px-1.5 tw:py-0 tw:rounded-full tw:font-bold tw:leading-5"
:class="store.state.theme === 'dark' ? 'tw:bg-blue-800 tw:text-blue-200' : 'tw:bg-blue-100 tw:text-blue-700'">
{{ filterCount }}
</span>
<OIcon
:name="showFilters ? 'expand-more' : 'chevron-right'"
size="sm"
:class="store.state.theme === 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-500'"
/>
<!-- Review your SQL query hint -->
<span v-if="generatedSqlQuery && !showFilters"
class="tw:text-xs tw:italic tw:ml-1 tw:whitespace-nowrap sql-query-hint"
:class="store.state.theme === 'dark' ? 'tw:text-gray-500' : 'tw:text-gray-400'">
view the alert query
<OTooltip :delay="200" side="bottom">
<template #content>
<pre class="hljs tw:text-xs tw:m-0 tw:whitespace-pre-wrap tw:font-mono tw:p-2 tw:rounded" v-html="highlightedSqlQuery" />
</template>
</OTooltip>
</span>
</div>
</div>
<!-- Cron description + error -->
<div v-if="frequencyMode === 'cron' && cronDescription && !cronError" class="tw:text-[11px] tw:ml-0 tw:italic"
:class="store.state.theme === 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-500'">
{{ cronDescription }}
</div>
<div v-if="frequencyMode === 'cron' && cronError" class="tw:text-red-500 tw:text-[11px] tw:ml-0">
{{ cronError }}
</div>
</div>
</div>
</div>
<!-- Filters section — scheduled -->
<div v-if="isRealTime === 'false'" ref="filtersSectionRef" class="tw:mt-1 tw:px-3">
<div v-show="showFilters" ref="customPreviewRef">
<FilterGroup
:stream-fields="columns"
:stream-fields-map="streamFieldsMap"
:show-sql-preview="false"
:sql-query="generatedSqlQuery"
:group="inputData.conditions"
:depth="0"
module="alerts"
@add-condition="updateGroup"
@add-group="updateGroup"
@remove-group="removeConditionGroup"
@input:update="onInputUpdate"
/>
</div>
</div>
<!-- Realtime — no threshold sentence, just filters always visible -->
<div v-else class="tw:mb-1 tw:px-3">
<div class="tw:flex tw:items-center tw:gap-2 tw:py-1">
<div
class="tw:flex tw:items-center tw:gap-1 tw:cursor-pointer tw:select-none filters-inline-toggle tw:px-2 tw:py-0.5 tw:rounded-md tw:transition-colors"
:class="store.state.theme === 'dark'
? 'tw:bg-gray-700/60 hover:tw:bg-gray-600/70'
: 'tw:bg-gray-100 hover:tw:bg-gray-200'"
@click="toggleFilters"
>
<OIcon
name="filter-alt"
size="xs"
:class="filterCount > 0
? 'tw:text-[var(--q-primary)]'
: (store.state.theme === 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-500')"
/>
<span class="tw:text-xs tw:font-semibold"
:class="filterCount > 0
? 'tw:text-[var(--q-primary)]'
: (store.state.theme === 'dark' ? 'tw:text-gray-300' : 'tw:text-gray-600')">
filters
</span>
<span v-if="filterCount > 0"
class="tw:text-[11px] tw:px-1.5 tw:py-0 tw:rounded-full tw:font-bold tw:leading-5"
:class="store.state.theme === 'dark' ? 'tw:bg-blue-800 tw:text-blue-200' : 'tw:bg-blue-100 tw:text-blue-700'">
{{ filterCount }}
</span>
<OIcon
:name="showFilters ? 'expand-more' : 'chevron-right'"
size="sm"
:class="store.state.theme === 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-500'"
/>
<!-- Review your SQL query hint -->
<span v-if="generatedSqlQuery && !showFilters"
class="tw:text-xs tw:italic tw:ml-1 tw:whitespace-nowrap sql-query-hint"
:class="store.state.theme === 'dark' ? 'tw:text-gray-500' : 'tw:text-gray-400'">
view the alert query
<OTooltip :delay="200" side="bottom">
<template #content>
<pre class="hljs tw:text-xs tw:m-0 tw:whitespace-pre-wrap tw:font-mono tw:p-2 tw:rounded" v-html="highlightedSqlQuery" />
</template>
</OTooltip>
</span>
</div>
</div>
<div v-show="showFilters" ref="customPreviewRef">
<FilterGroup
:stream-fields="columns"
:stream-fields-map="streamFieldsMap"
:show-sql-preview="false"
:sql-query="generatedSqlQuery"
:group="inputData.conditions"
:depth="0"
module="alerts"
@add-condition="updateGroup"
@add-group="updateGroup"
@remove-group="removeConditionGroup"
@input:update="onInputUpdate"
/>
</div>
</div>
</div>
</template>
<!-- SQL/PromQL Inline Editor Mode -->
<template v-else>
<div class="tw:w-full tw:flex tw:flex-col tw:gap-2 tw:overflow-hidden">
<!-- Editor area — position:relative shell owns the size; inner absolute never leaks -->
<div style="position: relative; height: 320px;">
<div style="position: absolute; inset: 0; display: flex; overflow: hidden;">
<!-- SQL/PromQL pane — with its own header -->
<div style="display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden;"
:style="{ width: showVrl && localTab === 'sql' ? '50%' : '100%' }">
<div class="inline-editor-header tw:flex tw:items-center tw:justify-between"
:class="store.state.theme === 'dark' ? 'inline-editor-header--dark' : 'inline-editor-header--light'">
<div class="tw:flex tw:items-center tw:gap-2">
<div class="pane-accent-bar pane-accent-bar--primary" />
<span class="inline-editor-title">{{ localTab === 'sql' ? 'SQL Editor' : 'PromQL Editor' }}</span>
</div>
<!-- fx toggle shown here only when VRL is not yet enabled -->
<OSwitch
v-if="localTab === 'sql' && !showVrl"
v-model="showVrl"
>
<OTooltip content="Show VRL editor" :delay="300" />
</OSwitch>
</div>
<div style="position: relative; flex: 1; min-height: 0;">
<div style="position: absolute; inset: 0;">
<UnifiedQueryEditor
ref="inlineQueryEditorRef"
data-test-prefix="alert-inline-sql"
:languages="localTab === 'promql' ? ['promql'] : ['sql']"
:default-language="localTab"
:query="localTab === 'sql' ? localSqlQuery : localPromqlQuery"
editor-height="100%"
:disable-ai="!streamName"
:keywords="autoCompleteKeywords"
:suggestions="autoCompleteSuggestions"
@focus="queryEditorPlaceholderFlag = false"
@blur="onBlurInlineSqlEditor"
@update:query="handleInlineQueryUpdate"
/>
</div>
</div>
</div>
<!-- VRL pane — with its own header, side-by-side with SQL pane -->
<div v-if="showVrl && localTab === 'sql'"
style="display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; width: 50%;"
:style="{ borderLeft: store.state.theme === 'dark' ? '1px solid #2d3748' : '1px solid #e5e7eb' }">
<div class="inline-editor-header tw:flex tw:items-center tw:justify-between"
:class="store.state.theme === 'dark' ? 'inline-editor-header--dark' : 'inline-editor-header--light'">
<div class="tw:flex tw:items-center tw:gap-2">
<div class="pane-accent-bar pane-accent-bar--secondary" />
<span class="inline-editor-title">VRL Editor</span>
</div>
<div class="tw:flex tw:items-center tw:gap-1">
<OSelect
v-model="selectedSavedFunctionName"
:options="functionsList"
labelKey="name"
valueKey="name"
clearable
class="mini-select alert-v3-select"
style="width: 130px;"
:placeholder="t('alerts.placeholders.savedFunctions')"
@update:model-value="(name: any) => { const fn = functionsList.find((f: any) => f.name === name); if (fn) vrlFunctionContent = fn.function || fn.body || ''; }"
>
<template #empty>
<span>{{ t('alerts.queryConfig.noFunctions') }}</span>
</template>
</OSelect>
<OSwitch v-model="showVrl">
<OTooltip content="Hide VRL editor" :delay="300" />
</OSwitch>
</div>
</div>
<div style="position: relative; flex: 1; min-height: 0;">
<div style="position: absolute; inset: 0;">
<UnifiedQueryEditor
data-test-prefix="alert-inline-vrl"
:languages="['vrl']"
default-language="vrl"
:query="vrlFunctionContent"
editor-height="100%"
:disable-ai="false"
:hide-nl-toggle="false"
@focus="vrlEditorPlaceholderFlag = false"
@blur="onBlurInlineVrlEditor"
@update:query="(v) => { vrlFunctionContent = v; handleVrlFunctionUpdate(v); }"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Status bar — outside overflow:hidden so borders render correctly -->
<div
v-if="localTab !== 'promql'"
class="inline-sql-status-bar"
:class="[inlineStatusState, store.state.theme === 'dark' ? 'sql-status-bar--dark' : 'sql-status-bar--light']"
>
<div class="sql-status-bar__inner">
<template v-if="inlineStatusState === 'sql-status-bar--error'">
<OIcon name="error-outline" size="xs" style="flex-shrink:0;" />
<span class="sql-status-bar__msg">{{ sqlQueryErrorMsg }}</span>
<OTooltip side="top" style="font-size:11px;white-space:pre-wrap;word-break:break-word;">{{ sqlQueryErrorMsg }}</OTooltip>
</template>
<template v-else-if="inlineStatusState === 'sql-status-bar--hint'">
<OIcon name="edit" size="xs" style="flex-shrink:0;opacity:0.6;" />
<span>{{ t('alerts.queryConfig.writeQueryHint') }}</span>
</template>
<template v-else-if="inlineStatusState === 'sql-status-bar--idle'">
<OIcon name="check-circle-outline" size="xs" style="flex-shrink:0;opacity:0.7;" />
<span>{{ t('alerts.queryConfig.sqlEditorHint') }}</span>
</template>
</div>
</div>
<!-- SQL/PromQL condition rows (scheduled only): Check every + Alert if in one block -->
<div v-if="isRealTime === 'false'" class="alert-condition-rows tw:mt-2 tw:px-1">
<!-- Check every -->
<div class="alert-condition-row tw:!tw:items-start">
<span class="condition-label sql-promql-label" style="line-height: 28px;">
Check every *
<OTooltip content="How often to check this alert condition" :delay="300" side="top" />
</span>
<div class="tw:flex tw:flex-col tw:gap-1">
<div class="tw:flex tw:items-center tw:gap-2">
<template v-if="frequencyMode !== 'cron'">
<OInput
v-model="checkEveryFrequency"
type="number"
class="alert-v3-input"
style="min-width: 100px; max-width: 100px;"
min="1"
:error="!!checkEveryFrequencyError"
:error-message="checkEveryFrequencyError"
@update:model-value="checkEveryFrequencyError = ''; onCheckEveryChange($event)"
@blur="restoreDefaultFrequency"
/>
</template>
<template v-else>
<OInput
v-model="cronExpression"
class="alert-v3-input"
placeholder="0 */10 * * * *"
style="min-width: 100px; max-width: 100px;"
@update:model-value="onCronExpressionChange"
/>
</template>
<OSelect
:model-value="frequencyMode"
:options="frequencyUnitOptions"
labelKey="label"
valueKey="value"
:searchable="false"
class="alert-v3-select frequency-unit-select"
style="min-width: 80px; max-width: 100px;"
@update:model-value="onFrequencyUnitChange"
/>
<template v-if="frequencyMode === 'cron'">
<span class="tw:inline-block" style="min-width: 150px; max-width: 150px;">
<OSelect
v-model="cronTimezone"
:options="filteredTimezones"
searchable
placeholder="timezone"
class="alert-v3-select"
style="min-width: 150px; max-width: 150px;"
@update:model-value="onCronTimezoneChange"
/>
<OTooltip
v-if="cronTimezone"
:content="cronTimezone"
:delay="300"
side="bottom"
/>
</span>
</template>
</div>
<div v-if="frequencyMode === 'cron' && cronDescription && !cronError" class="tw:text-[11px] tw:italic"
:class="store.state.theme === 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-500'">
{{ cronDescription }}
</div>
<div v-if="frequencyMode === 'cron' && cronError" class="tw:text-red-500 tw:text-[11px]">
{{ cronError }}
</div>
</div>
</div>
<!-- SQL: Alert if No. of events -->
<div v-if="localTab === 'sql'" class="alert-condition-row">
<span class="condition-label sql-promql-label">Alert if No. of events *</span>
<div class="tw:flex tw:items-center tw:gap-2">
<OSelect
v-model="triggerOperator"
:options="numericOperators"
:searchable="false"
class="alert-v3-select"
data-test="alert-trigger-operator-select"
style="min-width: 70px; max-width: 120px;"
@update:model-value="onTriggerOperatorChange"
/>
<OInput
v-model="triggerThreshold"
type="number"
class="alert-v3-input"
style="min-width: 60px; max-width: 80px;"
min="1"
:error="!!triggerThresholdError"
:error-message="triggerThresholdError"
@update:model-value="triggerThresholdError = ''; onTriggerThresholdChange($event)"
@blur="restoreDefaultThreshold"
/>
</div>
</div>
<!-- PromQL: Alert if the value is + Having series -->
<template v-if="localTab === 'promql' && promqlCondition">
<div class="alert-condition-row">
<span class="condition-label sql-promql-label">Alert if the value is *
<OTooltip content="Alert when the PromQL expression evaluates to this condition for a time series. Example: &gt;= 100 triggers when the result is 100 or more." :delay="300" side="top" />
</span>
<div class="tw:flex tw:items-center tw:gap-2">
<OSelect
v-model="promqlCondition.operator"
:options="numericOperators"
:searchable="false"
class="alert-v3-select"
data-test="alert-threshold-operator-select"
style="min-width: 70px; max-width: 120px;"
:error="!!promqlOperatorError"
:error-message="promqlOperatorError"
@update:model-value="promqlOperatorError = ''; emitPromqlConditionUpdate()"
/>
<OInput
v-model.number="promqlCondition.value"
type="number"
class="alert-v3-input"
data-test="alert-threshold-value-input"
style="min-width: 60px; max-width: 120px;"
:debounce="300"
:error="!!promqlValueError"
:error-message="promqlValueError"
@update:model-value="promqlValueError = ''; emitPromqlConditionUpdate()"
/>
</div>
</div>
<div class="alert-condition-row">
<span class="condition-label sql-promql-label">Having series *
<OTooltip content="Minimum number of time series that must satisfy the condition above to trigger the alert." :delay="300" side="top" />
</span>
<div class="tw:flex tw:items-center tw:gap-2">
<OSelect
v-model="triggerOperator"
:options="numericOperators"
:searchable="false"
class="alert-v3-select"
style="min-width: 70px; max-width: 120px;"
@update:model-value="onTriggerOperatorChange"
/>
<OInput
v-model="triggerThreshold"
type="number"
class="alert-v3-input"
style="min-width: 60px; max-width: 80px;"
min="1"
:error="!!triggerThresholdError"
:error-message="triggerThresholdError"
@update:model-value="triggerThresholdError = ''; onTriggerThresholdChange($event)"
@blur="restoreDefaultThreshold"
/>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- Query Editor Dialog -->
<QueryEditorDialog
v-model="viewSqlEditor"
:tab="localTab"
:sqlQuery="localSqlQuery"
:promqlQuery="localPromqlQuery"
:vrlFunction="vrlFunctionContent"
:streamName="streamName"
:streamType="streamType"
:columns="columns"
:period="inputData.period"
:multiTimeRange="inputData.multi_time_range"
:savedFunctions="functionsList"
:sqlQueryErrorMsg="sqlQueryErrorMsg"
@update:sqlQuery="updateSqlQuery"
@update:promqlQuery="updatePromqlQuery"
@update:vrlFunction="handleVrlFunctionUpdate"
@validate-sql="handleValidateSql"
/>
<!-- Multi-Window Confirmation Dialog -->
<CustomConfirmDialog
v-model="showMultiWindowDialog"
title="Clear Multi-Windows?"
:message="`Multi-windows are configured. To enable ${pendingTab === 'custom' ? 'Custom' : 'PromQL'} mode, we need to clear them. Do you want to proceed?`"
@confirm="handleConfirmClearMultiWindows"
@cancel="handleCancelClearMultiWindows"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, type PropType, defineAsyncComponent, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { b64EncodeUnicode, getUUID, convertMinutesToCron, getCronIntervalDifferenceInSeconds, isAboveMinRefreshInterval, describeCron, getImageURL } from "@/utils/zincutils";
import hljs from "highlight.js/lib/core";
import sql from "highlight.js/lib/languages/sql";
hljs.registerLanguage("sql", sql);
import useSqlSuggestions from "@/composables/useSuggestions";
import FilterGroup from "@/components/alerts/FilterGroup.vue";
import QueryEditorDialog from "@/components/alerts/QueryEditorDialog.vue";
import CustomConfirmDialog from "@/components/alerts/CustomConfirmDialog.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
const QueryEditor = defineAsyncComponent(
() => import("@/components/CodeQueryEditor.vue")
);
const UnifiedQueryEditor = defineAsyncComponent(
() => import("@/components/QueryEditor.vue")
);
export default defineComponent({
name: "Step2QueryConfig",
components: {
FilterGroup,
QueryEditor,
QueryEditorDialog,
CustomConfirmDialog,
UnifiedQueryEditor,
OButton,
OToggleGroup,
OToggleGroupItem,
OInput,
OSelect,
OSwitch,
OTooltip,
OIcon,
},
props: {
tab: {
type: String,
default: "custom",
},
multiTimeRange: {
type: Array as PropType<any[]>,
default: () => [],
},
columns: {
type: Array as PropType<any[]>,
default: () => [],
},
streamFieldsMap: {
type: Object as PropType<any>,
default: () => ({}),
},
generatedSqlQuery: {
type: String,
default: "",
},
inputData: {
type: Object as PropType<any>,
required: true,
},
streamType: {
type: String,
default: "",
},
isRealTime: {
type: String,
default: "false",
},
sqlQuery: {
type: String,
default: "",
},
promqlQuery: {
type: String,
default: "",
},
vrlFunction: {
type: String,
default: "",
},
streamName: {
type: String,
default: "",
},
sqlQueryErrorMsg: {
type: String,
default: "",
},
isAggregationEnabled: {
type: Boolean,
default: false,
},
beingUpdated: {
type: Boolean,
default: false,
},
promqlCondition: {
type: Object as PropType<any>,
default: null,
},
triggerCondition: {
type: Object as PropType<any>,
default: null,
},
},
emits: ["update:tab", "update-group", "remove-group", "input:update", "update:sqlQuery", "update:promqlQuery", "update:vrlFunction", "validate-sql", "clear-multi-windows", "editor-closed", "editor-state-changed", "update:isAggregationEnabled", "update:aggregation", "update:promqlCondition", "update:triggerCondition"],
setup(props, { emit }) {
const { t } = useI18n();
const store = useStore();
const localTab = ref(props.tab);
const columnSelectError = ref(false);
const viewSqlEditor = ref(false);
const showMultiWindowDialog = ref(false);
// Field-level error refs (replaces Quasar :rules validation)
const triggerThresholdError = ref('');
const conditionValueError = ref('');
const checkEveryFrequencyError = ref('');
const promqlOperatorError = ref('');
const promqlValueError = ref('');
const pendingTab = ref<string | null>(null);
// Field refs for focus manager
const customPreviewRef = ref(null);
const sqlPromqlPreviewRef = ref(null);
// Local query values
const localSqlQuery = ref(props.sqlQuery);
const localPromqlQuery = ref(props.promqlQuery);
const vrlFunctionContent = ref(props.vrlFunction);
const selectedSavedFunctionName = ref<string | null>(null);
// Aggregation state
const localIsAggregationEnabled = ref(props.isAggregationEnabled);
// Expandable section toggles — auto-expand filters if editing an alert with existing conditions
const hasExistingFilters = props.inputData.conditions?.conditions?.some(
(c: any) => c.filterType === 'condition' && c.column && c.column.trim() !== ''
);
const showFilters = ref(true);
const showVrl = ref(!!(props.vrlFunction?.trim()));
const filtersSectionRef = ref<HTMLElement | null>(null);
const inlineQueryEditorRef = ref<any>(null);
// ── Inline editor autocomplete ──────────────────────────────────────────
const {
autoCompleteData,
autoCompleteKeywords,
autoCompleteSuggestions,
getSuggestions,
updateFieldKeywords,
} = useSqlSuggestions();
// Rebuild field keywords whenever columns prop changes
watch(
() => props.columns,
(cols) => {
if (cols?.length) {
const fields = (cols as any[]).map((c: any) => ({
name: typeof c === 'string' ? c : (c.value ?? c.label ?? c),
type: c.type ?? 'Utf8',
}));
updateFieldKeywords(fields);
}
},
{ immediate: true, deep: true }
);
// Called on every keystroke in the inline SQL/PromQL editor — updates
// the query and feeds autocomplete context (same pattern as QueryEditorDialog)
const handleInlineQueryUpdate = (newQuery: string) => {
if (localTab.value === 'sql') {
updateSqlQuery(newQuery);
} else {
updatePromqlQuery(newQuery);
}
autoCompleteData.value.query = newQuery;
autoCompleteData.value.cursorIndex = inlineQueryEditorRef.value?.getCursorIndex?.() ?? 0;
autoCompleteData.value.org = store.state.selectedOrganization.identifier;
autoCompleteData.value.streamType = props.streamType;
autoCompleteData.value.streamName = props.streamName;
autoCompleteData.value.popup.open = inlineQueryEditorRef.value?.triggerAutoComplete;
getSuggestions();
};
// Inline editor status bar state
const inlineStatusState = computed(() => {
if (props.sqlQueryErrorMsg?.trim()) return 'sql-status-bar--error';
const query = localTab.value === 'sql' ? localSqlQuery.value : localPromqlQuery.value;
if (!query?.trim()) return 'sql-status-bar--hint';
return 'sql-status-bar--idle';
});
// Placeholder flags for inline editors (show image when empty + not focused)
const queryEditorPlaceholderFlag = ref(true);
const vrlEditorPlaceholderFlag = ref(true);
const onBlurInlineSqlEditor = () => {
queryEditorPlaceholderFlag.value = localTab.value === 'sql'
? localSqlQuery.value === ''
: localPromqlQuery.value === '';
// Validate SQL on blur (same chain as QueryEditorDialog)
if (localTab.value === 'sql') {
emit("validate-sql");
}
};
const onBlurInlineVrlEditor = () => {
vrlEditorPlaceholderFlag.value = vrlFunctionContent.value === '';
};
const toggleFilters = () => {
showFilters.value = !showFilters.value;
};
// Stream-type-driven: logs/traces are event-based, metrics are aggregation-based
const isEventBased = computed(() => {
return props.streamType !== 'metrics';
});
// Set aggregation state based on stream type on mount
if (!isEventBased.value) {
if (!props.isAggregationEnabled) {
// total_events selected — no aggregation, same path as logs
localIsAggregationEnabled.value = false;
} else {
// Metrics: aggregation-enabled
localIsAggregationEnabled.value = true;
// Initialize aggregation object if missing
if (!props.inputData.aggregation) {
props.inputData.aggregation = {
group_by: [],
function: "avg",
having: { column: "", operator: ">=", value: "" },
};
}
// Default column to "value" only if the stream actually has a "value" field
if (!props.inputData.aggregation?.having?.column) {
const hasValueField = props.columns.some((c: any) => (typeof c === 'string' ? c : c.value) === 'value');
if (hasValueField && props.inputData.aggregation?.having) {
props.inputData.aggregation.having.column = 'value';
}
}
}
} else {
// Logs/Traces: event-based, no aggregation
localIsAggregationEnabled.value = false;
}
// Log function options — count (default) and measure functions
const logFunctionOptions = [
{ label: 'total events', value: 'total_events', tooltip: 'Count the total number of log events matching your filters (COUNT(*))' },
{ label: 'count', value: 'count', tooltip: 'Count non-null values of a specific field (COUNT(field))' },
{ label: 'avg', value: 'avg', tooltip: 'Average value of a numeric field' },
{ label: 'min', value: 'min', tooltip: 'Minimum value of a numeric field' },
{ label: 'max', value: 'max', tooltip: 'Maximum value of a numeric field' },
{ label: 'sum', value: 'sum', tooltip: 'Sum of values of a numeric field' },
{ label: 'median', value: 'median', tooltip: 'Median value of a numeric field' },
{ label: 'p50', value: 'p50', tooltip: '50th percentile of a numeric field' },
{ label: 'p75', value: 'p75', tooltip: '75th percentile of a numeric field' },
{ label: 'p90', value: 'p90', tooltip: '90th percentile of a numeric field' },
{ label: 'p95', value: 'p95', tooltip: '95th percentile of a numeric field' },
{ label: 'p99', value: 'p99', tooltip: '99th percentile of a numeric field' },
];
// Numeric-only operators (no Contains/NotContains for thresholds)
const numericOperators = ["=", "!=", ">=", ">", "<=", "<"];
// Selected function — defaults to 'total_events' when no aggregation, 'avg' otherwise
const selectedFunction = ref(
isEventBased.value
? (props.isAggregationEnabled && props.inputData.aggregation?.function
? props.inputData.aggregation.function
: 'total_events')
: (!props.isAggregationEnabled
? 'total_events'
: (props.inputData.aggregation?.function || 'avg'))
);
// Log-specific: measure column and group-by
const logMeasureColumn = ref(
props.inputData.aggregation?.having?.column || ''
);
const logGroupBy = ref<string[]>(
props.inputData.aggregation?.group_by?.filter((g: string) => g)?.length
? [...props.inputData.aggregation.group_by.filter((g: string) => g)]
: []
);
// Condition operator and value
// Logs/Traces: maps to trigger_condition (event count threshold)
// Metrics: maps to aggregation.having (aggregated value threshold)
const conditionOperator = ref(
isEventBased.value
? (props.triggerCondition?.operator || '>=')
: (props.inputData.aggregation?.having?.operator || '>=')
);
const conditionValue = ref(
isEventBased.value
? (props.triggerCondition?.threshold || '')
: (props.inputData.aggregation?.having?.value || '')
);
// Watch stream type changes to restore saved state or set new-alert defaults.
// Fires when stream type prop changes (e.g. async load on edit, or user switching stream type).
watch(isEventBased, (eventBased, oldEventBased) => {
if (eventBased) {
// PromQL is only available for metrics — switch to builder when
// moving to logs/traces so the user isn't stuck on an invalid tab.
if (localTab.value === "promql") {
updateTab("custom");
}
// Switched to logs/traces — restore saved function or default to total_events.
// When coming from metrics the aggregation defaults don't apply to log fields.
if (props.isAggregationEnabled && props.inputData.aggregation?.function && oldEventBased !== false) {
selectedFunction.value = props.inputData.aggregation.function;
localIsAggregationEnabled.value = true;
} else {
selectedFunction.value = 'total_events';
localIsAggregationEnabled.value = false;
emit("update:isAggregationEnabled", false);
}
} else {
// Switched to metrics
if (props.isAggregationEnabled && props.inputData.aggregation?.function) {
// Edit mode: restore saved aggregation function
selectedFunction.value = props.inputData.aggregation.function;
localIsAggregationEnabled.value = true;
} else if (!props.isAggregationEnabled && props.beingUpdated) {
// Edit mode: alert was saved with total_events (aggregation: null)
selectedFunction.value = 'total_events';
localIsAggregationEnabled.value = false;
} else {
// New alert switching to metrics — default to avg + init aggregation
selectedFunction.value = 'avg';
localIsAggregationEnabled.value = true;
emit("update:isAggregationEnabled", true);
if (!props.inputData.aggregation) {
props.inputData.aggregation = {
group_by: [],
function: "avg",
having: { column: "", operator: ">=", value: "" },
};
} else {
props.inputData.aggregation.function = 'avg';
if (!props.inputData.aggregation.having?.column) {
const hasValueField = props.columns.some((c: any) => (typeof c === 'string' ? c : c.value) === 'value');
if (hasValueField) {
props.inputData.aggregation.having.column = 'value';
}
}
}
emitAggregationUpdate();
}
}
});
// Trigger threshold — only meaningful when group-by fields exist.
// If loading an alert with no group-by, reset to defaults so stale values don't show.
const hasInitialGroupBy =
(props.inputData.aggregation?.group_by || []).filter((g: string) => g?.trim()).length > 0;
const triggerOperator = ref(hasInitialGroupBy ? (props.triggerCondition?.operator || '>=') : '>=');
const triggerThreshold = ref(hasInitialGroupBy ? (props.triggerCondition?.threshold || 1) : 1);
// Sync reset to parent so saving also persists the corrected value
if (!hasInitialGroupBy && props.triggerCondition) {
props.triggerCondition.threshold = 1;
props.triggerCondition.operator = '>=';
emit("update:triggerCondition", { ...props.triggerCondition });
}
// Whether log/trace group-by has at least one non-empty field
const hasLogGroupByFields = computed(
() => logGroupBy.value.filter((f: string) => f?.trim()).length > 0
);
// Whether metric group-by has at least one non-empty field
const hasMetricGroupByFields = computed(
() => (props.inputData.aggregation?.group_by || []).filter((f: string) => f?.trim()).length > 0
);
const onTriggerOperatorChange = (value: string) => {
triggerOperator.value = value;
if (props.triggerCondition) {
props.triggerCondition.operator = value;
emit("update:triggerCondition", { ...props.triggerCondition });
}
};
const onTriggerThresholdChange = (value: any) => {
isUserTriggerChange.value = true;
triggerThreshold.value = value === '' || value === null || value === undefined ? null : Number(value);
if (props.triggerCondition) {
props.triggerCondition.threshold = triggerThreshold.value;
emit("update:triggerCondition", { ...props.triggerCondition });
}
};
const restoreDefaultThreshold = () => {
if (triggerThreshold.value === null || triggerThreshold.value === '' || triggerThreshold.value === undefined || Number.isNaN(Number(triggerThreshold.value))) {
triggerThreshold.value = 3;
if (props.triggerCondition) {
props.triggerCondition.threshold = 3;
emit("update:triggerCondition", { ...props.triggerCondition });
}
}
};
// Check every — evaluation frequency
const checkEveryFrequency = ref(props.triggerCondition?.frequency || 10);
// Determine initial unit: if frequency_type is 'cron' use cron, else check if frequency >= 60 for hours
const initFrequencyUnit = (): 'minutes' | 'hours' | 'cron' => {
if (props.triggerCondition?.frequency_type === 'cron') return 'cron';
const freq = props.triggerCondition?.frequency || 10;
// If frequency is >= 60 and divisible by 60, show as hours
if (freq >= 60 && freq % 60 === 0) return 'hours';
return 'minutes';
};
const frequencyMode = ref<'minutes' | 'hours' | 'cron'>(initFrequencyUnit());
const isUserTriggerChange = ref(false);
const cronExpression = ref(props.triggerCondition?.cron || '');
const cronTimezone = ref(props.triggerCondition?.timezone || '');
const cronError = ref('');
const cronDescription = computed(() => describeCron(cronExpression.value, cronTimezone.value));
const filteredTimezones = ref<string[]>([]);
// Initialize timezone
const initTimezones = () => {
try {
if (!cronTimezone.value) {
cronTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// @ts-ignore
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
// @ts-ignore
filteredTimezones.value = Intl.supportedValuesOf("timeZone");
} else {
filteredTimezones.value = [cronTimezone.value];
}
} catch {
cronTimezone.value = cronTimezone.value || 'UTC';
filteredTimezones.value = ['UTC'];
}
};
initTimezones();
const validateCron = () => {
cronError.value = '';
if (frequencyMode.value !== 'cron') return;
if (!cronExpression.value || !cronTimezone.value) {
cronError.value = 'Cron expression and timezone are required';
return;
}
try {
const intervalInSecs = getCronIntervalDifferenceInSeconds(cronExpression.value);
if (typeof intervalInSecs === 'number' && !isAboveMinRefreshInterval(intervalInSecs, store.state?.zoConfig)) {
const minInterval = Number(store.state?.zoConfig?.min_auto_refresh_interval) || 1;
cronError.value = `Frequency should be greater than ${minInterval - 1} seconds.`;
}
} catch {
cronError.value = 'Invalid cron expression';
}
};
const frequencyUnitOptions = [
{ label: 'Minutes', value: 'minutes' },
{ label: 'Hours', value: 'hours' },
{ label: 'Cron', value: 'cron' },
];
const onFrequencyUnitChange = (unit: string) => {
const prevMode = frequencyMode.value;
isUserTriggerChange.value = true;
frequencyMode.value = unit as 'minutes' | 'hours' | 'cron';
if (!props.triggerCondition) return;
if (unit === 'cron') {
props.triggerCondition.frequency_type = 'cron';
// Auto-convert current frequency to cron if no expression yet
if (!cronExpression.value) {
let mins = Number(checkEveryFrequency.value);
if (prevMode === 'hours') mins = mins * 60;
if (mins > 0) {
cronExpression.value = convertMinutesToCron(mins);
props.triggerCondition.cron = cronExpression.value;
}
if (!cronTimezone.value) {
cronTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
props.triggerCondition.timezone = cronTimezone.value;
}
validateCron();
} else {
props.triggerCondition.frequency_type = 'minutes';
// Convert between minutes and hours
if (unit === 'hours' && prevMode === 'minutes') {
// Converting minutes to hours: round up
const hrs = Math.max(1, Math.round(Number(checkEveryFrequency.value) / 60));
checkEveryFrequency.value = hrs;
props.triggerCondition.frequency = hrs * 60;
} else if (unit === 'minutes' && prevMode === 'hours') {
// Converting hours to minutes
const mins = Number(checkEveryFrequency.value) * 60;
checkEveryFrequency.value = mins;
props.triggerCondition.frequency = mins;
} else if (unit === 'minutes' && prevMode === 'cron') {
// Coming back from cron, restore sensible default
checkEveryFrequency.value = props.triggerCondition.frequency || 10;
} else if (unit === 'hours' && prevMode === 'cron') {
const mins = props.triggerCondition.frequency || 60;
checkEveryFrequency.value = Math.max(1, Math.round(mins / 60));
props.triggerCondition.frequency = checkEveryFrequency.value * 60;
}
}
emit("update:triggerCondition", { ...props.triggerCondition });
};
const onCronExpressionChange = (value: any) => {
isUserTriggerChange.value = true;
cronExpression.value = value;
if (props.triggerCondition) {
props.triggerCondition.cron = value;
validateCron();
emit("update:triggerCondition", { ...props.triggerCondition });
}
};
const onCronTimezoneChange = (value: any) => {
isUserTriggerChange.value = true;
cronTimezone.value = value;
if (props.triggerCondition) {
props.triggerCondition.timezone = value;
validateCron();
emit("update:triggerCondition", { ...props.triggerCondition });
}
};
const onCheckEveryChange = (value: any) => {
isUserTriggerChange.value = true;
const parsed = value === '' || value === null || value === undefined ? null : Number(value);
checkEveryFrequency.value = parsed;
if (props.triggerCondition) {
// Store as minutes internally (hours mode: multiply by 60)
const mins = parsed != null ? (frequencyMode.value === 'hours' ? parsed * 60 : parsed) : null;
props.triggerCondition.frequency = mins;
emit("update:triggerCondition", { ...props.triggerCondition });
}
};
const restoreDefaultFrequency = () => {
if (checkEveryFrequency.value === null || checkEveryFrequency.value === '' || checkEveryFrequency.value === undefined || Number.isNaN(Number(checkEveryFrequency.value))) {
const defaultVal = frequencyMode.value === 'hours' ? 1 : 10;
checkEveryFrequency.value = defaultVal;
if (props.triggerCondition) {
props.triggerCondition.frequency = frequencyMode.value === 'hours' ? 60 : 10;
emit("update:triggerCondition", { ...props.triggerCondition });
}
}
};
// Count of active filter conditions
const filterCount = computed(() => {
const conditions = props.inputData.conditions?.conditions;
if (!conditions || !Array.isArray(conditions)) return 0;
return conditions.filter((c: any) => c.filterType === 'condition' && c.column).length;
});
// Ensure at least one empty filter condition exists on mount (filters are always visible now)
// Empty condition is now provided by default form data in useAlertForm.ts
// Aggregation functions (kept for legacy references)
const aggFunctions = ["count", "min", "max", "avg", "sum", "median", "p50", "p75", "p90", "p95", "p99"];
// Trigger operators
const triggerOperators = ["=", "!=", ">=", ">", "<=", "<", "Contains", "NotContains"];
// Helper to get numeric columns based on selected function
const getNumericColumns = (cols: any[]) => {
if (selectedFunction.value === 'count' || selectedFunction.value === 'total_events') {
return [...cols];
}
return cols.filter((column: any) => {
if (typeof column === "string") return false;
if (!column.type) return false;
return !/(?:utf8|string)/i.test(column.type);
});
};
// Computed column lists — OSelect handles built-in filtering, no @filter needed
const numericColumns = computed(() => getNumericColumns(props.columns));
// Get saved VRL functions from store
const functionsList = computed(() => store.state.organizationData.functions || []);
// Compute tab options based on stream type and alert type
const tabOptions = computed(() => {
// For real-time alerts, only show Builder (no tabs needed)
if (props.isRealTime === "true") {
return [
{
label: t('alerts.queryBuilder'),
value: "custom",
},
];
}
// For metrics, show all three tabs: Builder, SQL, PromQL
if (props.streamType === "metrics") {
return [
{
label: t('alerts.queryBuilder'),
value: "custom",
},
{
label: "SQL",
value: "sql",
},
{
label: "PromQL",
value: "promql",
},
];
}
// For logs and traces, show only Builder and SQL
return [
{
label: "Builder",
value: "custom",
},
{
label: "SQL",
value: "sql",
},
];
});
// Hide tabs completely for real-time alerts (only one option)
const shouldShowTabs = computed(() => {
return props.isRealTime === "false";
});
const updateTab = (tab: string) => {
const hasComparisonWindow = props.multiTimeRange?.length > 0;
// Check if switching to custom or promql while multi-windows are present
if ((tab === 'custom' || tab === 'promql') && hasComparisonWindow) {
// Show confirmation dialog
pendingTab.value = tab;
showMultiWindowDialog.value = true;
return;
}
// When switching to custom mode, check if there's only one empty condition and remove it
// This ensures generate_sql API is called
if (tab === 'custom' && props.inputData.conditions) {
removeSingleEmptyCondition(props.inputData.conditions);
}
// No multi-windows, proceed with tab change
localTab.value = tab;
emit("update:tab", tab);
};
// Helper function to remove a single empty condition if that's the only condition present
const removeSingleEmptyCondition = (conditionsObj: any) => {
if (!conditionsObj || !conditionsObj.conditions || !Array.isArray(conditionsObj.conditions)) {
return;
}
// Only proceed if there's exactly one condition at the top level
if (conditionsObj.conditions.length === 1) {
const singleItem = conditionsObj.conditions[0];
// Check if it's a condition (not a group) with empty column AND empty value
if (singleItem.filterType === 'condition') {
const hasColumn = singleItem.column && singleItem.column.trim() !== '';
const hasValue = singleItem.value !== undefined && singleItem.value !== '' && singleItem.value !== null;
// If both column and value are empty, remove this condition
if (!hasColumn && !hasValue) {
conditionsObj.conditions = [];
}
}
}
};
const handleConfirmClearMultiWindows = () => {
// Clear multi-windows
emit("clear-multi-windows");
// Wait for next tick to ensure multi-windows are cleared, then switch tab
nextTick(() => {
if (pendingTab.value) {
localTab.value = pendingTab.value;
emit("update:tab", pendingTab.value);
pendingTab.value = null;
}
showMultiWindowDialog.value = false;
});
};
const handleCancelClearMultiWindows = () => {
pendingTab.value = null;
showMultiWindowDialog.value = false;
};
const updateGroup = (data: any) => {
emit("update-group", data);
};
const removeConditionGroup = (data: any) => {
emit("remove-group", data);
};
const onInputUpdate = (name: string, field: any) => {
emit("input:update", name, field);
};
const sqlOrPromqlQuery = computed(() => {
return localTab.value === 'sql' ? props.sqlQuery : props.promqlQuery;
});
const updateSqlQuery = (value: string) => {
localSqlQuery.value = value;
emit("update:sqlQuery", value);
};
const updatePromqlQuery = (value: string) => {
localPromqlQuery.value = value;
emit("update:promqlQuery", value);
};
// Handler for VRL function updates from QueryEditorDialog
// The dialog now emits plain text VRL (encoding happens once at save time)
const handleVrlFunctionUpdate = (vrlValue: string) => {
emit("update:vrlFunction", vrlValue);
};
// Handler for SQL validation from QueryEditorDialog
const handleValidateSql = () => {
emit("validate-sql");
};
// Emit trigger condition update (for non-aggregation mode)
const emitTriggerUpdate = () => {
if (props.triggerCondition) {
emit("update:triggerCondition", { ...props.triggerCondition });
}
};
// When metric function dropdown changes — handles count vs aggregation modes
const onMetricFunctionChange = (value: string) => {
if (value === 'total_events') {
// total_events (COUNT(*)) — no aggregation, trigger threshold shown inline
localIsAggregationEnabled.value = false;
emit("update:isAggregationEnabled", false);
} else {
// Measure mode — aggregation, always reset "having groups" to >= 1 on switch
localIsAggregationEnabled.value = true;
triggerThreshold.value = 1;
triggerOperator.value = '>=';
if (props.triggerCondition) {
props.triggerCondition.threshold = 1;
props.triggerCondition.operator = '>=';
emit("update:triggerCondition", { ...props.triggerCondition });
}
emit("update:isAggregationEnabled", true);
onFunctionChange(value);
}
};
// When function dropdown changes (metrics only — always aggregation)
const onFunctionChange = (value: string) => {
localIsAggregationEnabled.value = true;
emit("update:isAggregationEnabled", true);
// Initialize aggregation object if needed
if (!props.inputData.aggregation) {
props.inputData.aggregation = {
group_by: [],
function: value,
having: {
column: "",
operator: conditionOperator.value || ">=",
value: conditionValue.value || "",
},
};
} else {
props.inputData.aggregation.function = value;
props.inputData.aggregation.having.operator = conditionOperator.value;
props.inputData.aggregation.having.value = conditionValue.value;
}
emitAggregationUpdate();
};
// When log function mode changes (count / unique_count / avg / etc.)
const onLogFunctionChange = (value: string) => {
if (value === 'total_events') {
// total_events (COUNT(*)) — no aggregation, trigger threshold shown inline
localIsAggregationEnabled.value = false;
emit("update:isAggregationEnabled", false);
logMeasureColumn.value = '';
// Restore default threshold for count mode
triggerThreshold.value = 3;
triggerOperator.value = '>=';
if (props.triggerCondition) {
props.triggerCondition.threshold = 3;
props.triggerCondition.operator = '>=';
emit("update:triggerCondition", { ...props.triggerCondition });
}
} else {
// Measure mode — uses aggregation, always reset "having groups" to >= 1 on switch
localIsAggregationEnabled.value = true;
triggerThreshold.value = 1;
triggerOperator.value = '>=';
if (props.triggerCondition) {
props.triggerCondition.threshold = 1;
props.triggerCondition.operator = '>=';
emit("update:triggerCondition", { ...props.triggerCondition });
}
emit("update:isAggregationEnabled", true);
const aggFunction = value;
if (!props.inputData.aggregation) {
props.inputData.aggregation = {
group_by: logGroupBy.value.length ? [...logGroupBy.value] : [],
function: aggFunction,
having: {
column: logMeasureColumn.value || "",
operator: conditionOperator.value || ">=",
value: conditionValue.value || "",
},
};
} else {
props.inputData.aggregation.function = aggFunction;
props.inputData.aggregation.having.operator = conditionOperator.value;
props.inputData.aggregation.having.value = conditionValue.value;
props.inputData.aggregation.having.column = logMeasureColumn.value || "";
props.inputData.aggregation.group_by = logGroupBy.value.length ? [...logGroupBy.value] : [];
}
emitAggregationUpdate();
}
};
// When log measure column changes (for unique count / measure modes)
const onLogMeasureColumnChange = (value: string) => {
if (props.inputData.aggregation) {
props.inputData.aggregation.having.column = value;
emitAggregationUpdate();
}
};
// Log group-by management
const addLogGroupByColumn = () => {
logGroupBy.value.push("");
};
const deleteLogGroupByColumn = (index: number) => {
logGroupBy.value.splice(index, 1);
onLogGroupByChange();
// Reset threshold to 1 when last group-by field is removed
if (logGroupBy.value.filter((f: string) => f?.trim()).length === 0) {
triggerThreshold.value = 1;
triggerOperator.value = '>=';
if (props.triggerCondition) {
props.triggerCondition.threshold = 1;
props.triggerCondition.operator = '>=';
emit("update:triggerCondition", { ...props.triggerCondition });
}
}
};
const onLogGroupByChange = () => {
if (props.inputData.aggregation) {
props.inputData.aggregation.group_by = [...logGroupBy.value];
emitAggregationUpdate();
}
};
// When condition operator changes
const onConditionOperatorChange = (value: string) => {
if (isEventBased.value && selectedFunction.value === 'total_events') {
// Logs count mode: maps to trigger_condition
if (props.triggerCondition) {
props.triggerCondition.operator = value;
emitTriggerUpdate();
}
} else {
// Metrics or logs measure/unique count: maps to aggregation.having
if (props.inputData.aggregation) {
props.inputData.aggregation.having.operator = value;
emitAggregationUpdate();
}
}
};
// When condition value changes
const onConditionValueChange = (value: any) => {
const parsed = value === '' || value === null || value === undefined ? null : Number(value);
if (isEventBased.value && selectedFunction.value === 'total_events') {
// Logs count mode: maps to trigger_condition
if (props.triggerCondition) {
isUserTriggerChange.value = true;
props.triggerCondition.threshold = parsed;
emitTriggerUpdate();
}
} else {
// Metrics or logs measure/unique count: maps to aggregation.having
if (props.inputData.aggregation) {
props.inputData.aggregation.having.value = parsed;
emitAggregationUpdate();
}
}
};
// Legacy toggle kept for compatibility — not used in new UI
const toggleAggregation = () => {
emit("update:isAggregationEnabled", localIsAggregationEnabled.value);
};
// Add group by column
const addGroupByColumn = () => {
if (props.inputData.aggregation) {
props.inputData.aggregation.group_by.push("");
emitAggregationUpdate();
}
};
// Delete group by column
const deleteGroupByColumn = (index: string | number) => {
const idx = typeof index === 'string' ? parseInt(index) : index;
if (props.inputData.aggregation) {
props.inputData.aggregation.group_by.splice(idx, 1);
emitAggregationUpdate();
// Reset threshold to 1 when last group-by field is removed
const remaining = (props.inputData.aggregation.group_by || []).filter((f: string) => f?.trim()).length;
if (remaining === 0) {
triggerThreshold.value = 1;
triggerOperator.value = '>=';
if (props.triggerCondition) {
props.triggerCondition.threshold = 1;
props.triggerCondition.operator = '>=';
emit("update:triggerCondition", { ...props.triggerCondition });
}
}
}
};
// Emit aggregation update
const emitAggregationUpdate = () => {
emit("update:aggregation", props.inputData.aggregation);
};
// Emit PromQL condition update
const emitPromqlConditionUpdate = () => {
emit("update:promqlCondition", props.promqlCondition);
};
// Watch for SQL editor dialog state changes
// Sync local state when parent props update (e.g. after loading an existing alert async)
watch(
() => props.tab,
(newTab) => {
if (newTab && newTab !== localTab.value) {
localTab.value = newTab;
}
}
);
watch(
() => props.sqlQuery,
(newVal) => {
if (newVal !== undefined && newVal !== localSqlQuery.value) {
localSqlQuery.value = newVal;
}
}
);
watch(
() => props.promqlQuery,
(newVal) => {
if (newVal !== undefined && newVal !== localPromqlQuery.value) {
localPromqlQuery.value = newVal;
}
}
);
watch(
() => props.vrlFunction,
(newVal) => {
if (newVal !== undefined && newVal !== vrlFunctionContent.value) {
vrlFunctionContent.value = newVal;
}
// Auto-enable VRL pane when a function is present
if (newVal?.trim()) {
showVrl.value = true;
}
}
);
watch(viewSqlEditor, (newValue, oldValue) => {
// Emit state change whenever it changes
emit("editor-state-changed", newValue);
// When dialog closes (goes from true to false), emit event to refresh preview
if (oldValue === true && newValue === false) {
emit("editor-closed");
}
});
// Sync aggregation state when columns prop changes (async stream load)
watch(
() => props.columns,
(newCols) => {
if (!isEventBased.value && props.inputData.aggregation?.having) {
const currentCol = props.inputData.aggregation.having.column;
const colExistsInNewStream = currentCol
? newCols.some((c: any) => (typeof c === 'string' ? c : c.value) === currentCol)
: false;
if (currentCol && !colExistsInNewStream) {
// Stream changed — previously selected column is no longer valid, clear it
// and auto-set to "value" only if the new stream has it
const hasValue = newCols.some((c: any) => (typeof c === 'string' ? c : c.value) === 'value');
props.inputData.aggregation.having.column = hasValue ? 'value' : '';
emitAggregationUpdate();
} else if (!currentCol) {
// Column not set yet — auto-set to "value" if new stream has it
const hasValue = newCols.some((c: any) => (typeof c === 'string' ? c : c.value) === 'value');
if (hasValue) {
props.inputData.aggregation.having.column = 'value';
emitAggregationUpdate();
}
}
}
}
);
// Watch for isAggregationEnabled prop changes (for loading saved alerts)
watch(
() => props.isAggregationEnabled,
(newVal) => {
localIsAggregationEnabled.value = newVal;
// Sync all aggregation-dependent UI state when aggregation becomes enabled during load
// (e.g. editing a logs/traces alert that was saved with avg/sum/etc.)
if (newVal && props.inputData.aggregation) {
const agg = props.inputData.aggregation;
if (agg.function) {
selectedFunction.value = agg.function;
}
if (agg.having?.column) {
logMeasureColumn.value = agg.having.column;
}
if (agg.having?.operator) {
conditionOperator.value = agg.having.operator;
}
if (agg.having?.value !== undefined && agg.having?.value !== null) {
conditionValue.value = agg.having.value;
}
if (agg.group_by?.length) {
logGroupBy.value = [...agg.group_by.filter((g: string) => g?.trim())];
}
}
}
);
// Sync from aggregation data when it changes (e.g. loading saved metric alert)
watch(
() => props.inputData.aggregation,
(agg) => {
if (isEventBased.value) return; // Only for metrics
if (agg?.having) {
conditionOperator.value = agg.having.operator || '>=';
conditionValue.value = agg.having.value || '';
}
if (agg?.function) {
selectedFunction.value = agg.function;
}
},
{ deep: true, immediate: false }
);
// Sync from trigger_condition when it changes (e.g. loading saved alert)
watch(
() => props.triggerCondition,
(tc) => {
if (!tc) return;
// Skip frequency mode sync when the change was triggered by user interaction
if (isUserTriggerChange.value) {
isUserTriggerChange.value = false;
return;
}
// In measure mode with empty group-by, always reset to >= 1 (field is disabled, user can't edit it)
const inMeasureMode = selectedFunction.value !== 'total_events';
const groupByEmpty = isEventBased.value
? !hasLogGroupByFields.value
: !hasMetricGroupByFields.value;
if (inMeasureMode && groupByEmpty) {
triggerOperator.value = '>=';
triggerThreshold.value = 1;
if (tc.operator !== '>=' || tc.threshold !== 1) {
tc.operator = '>=';
tc.threshold = 1;
emit("update:triggerCondition", { ...tc });
}
} else {
triggerOperator.value = tc.operator ?? '>=';
triggerThreshold.value = tc.threshold ?? 3;
}
const freq = tc.frequency ?? 10;
if (tc.frequency_type === 'cron') {
frequencyMode.value = 'cron';
checkEveryFrequency.value = freq;
} else if (freq >= 60 && freq % 60 === 0) {
frequencyMode.value = 'hours';
checkEveryFrequency.value = freq / 60;
} else {
frequencyMode.value = 'minutes';
checkEveryFrequency.value = freq;
}
cronExpression.value = tc.cron || '';
cronTimezone.value = tc.timezone || '';
},
{ deep: true, immediate: false }
);
// When function switches to measure mode, reset threshold to >= 1 if group-by is empty
watch(selectedFunction, (value) => {
if (value === 'total_events') return;
const groupByEmpty = isEventBased.value
? !hasLogGroupByFields.value
: !hasMetricGroupByFields.value;
if (groupByEmpty) {
triggerThreshold.value = 1;
triggerOperator.value = '>=';
if (props.triggerCondition) {
props.triggerCondition.threshold = 1;
props.triggerCondition.operator = '>=';
emit("update:triggerCondition", { ...props.triggerCondition });
}
}
});
// Validation function for Step 2
const validate = async () => {
// Custom mode: Check if conditions have empty columns or values
if (localTab.value === 'custom') {
// Aggregation column required when a measure function is selected
if (selectedFunction.value !== 'total_events') {
const col = isEventBased.value
? logMeasureColumn.value
: props.inputData?.aggregation?.having?.column;
if (!col || col.trim() === '') {
columnSelectError.value = true;
toast({ variant: "error", message: 'Column is required when using an aggregate function.' });
return false;
}
}
columnSelectError.value = false;
return await validateCustomMode();
}
// SQL mode
if (localTab.value === 'sql') {
const sqlQuery = props.sqlQuery;
if (!sqlQuery || sqlQuery.trim() === '') {
toast({ variant: "error", message: 'SQL query cannot be empty.' });
await nextTick();
inlineQueryEditorRef.value?.focus?.();
return false;
}
if (props.sqlQueryErrorMsg && props.sqlQueryErrorMsg.trim() !== '') {
toast({ variant: "error", message: 'Please fix the SQL error before saving.' });
await nextTick();
inlineQueryEditorRef.value?.focus?.();
return false;
}
return true;
}
// PromQL mode
if (localTab.value === 'promql') {
const promqlQuery = localPromqlQuery.value;
if (!promqlQuery || promqlQuery.trim() === '') {
toast({ variant: "error", message: 'PromQL query cannot be empty.' });
await nextTick();
inlineQueryEditorRef.value?.focus?.();
return false;
}
return true;
}
return true;
};
// Validate custom mode conditions
const validateCustomMode = async () => {
const conditions = props.inputData.conditions;
// If no conditions added at all, allow navigation
if (!conditions || !conditions.conditions || conditions.conditions.length === 0) {
return true;
}
let isValid = true;
// Validate triggerThreshold (count mode and having-groups rows)
if (selectedFunction.value === 'total_events' || hasLogGroupByFields.value || hasMetricGroupByFields.value) {
triggerThresholdError.value = !triggerThreshold.value ? 'Required' : '';
if (triggerThresholdError.value) isValid = false;
}
// Validate conditionValue (measure mode)
if (selectedFunction.value !== 'total_events') {
conditionValueError.value = !conditionValue.value ? 'Field is required!' : '';
if (conditionValueError.value) isValid = false;
}
// Validate checkEveryFrequency (when not in cron mode)
if (frequencyMode.value !== 'cron') {
checkEveryFrequencyError.value = !checkEveryFrequency.value ? 'Required' : '';
if (checkEveryFrequencyError.value) isValid = false;
}
if (!isValid) {
await nextTick();
const firstErrorField = document.querySelector('[data-o2-error] input, [data-o2-error] textarea') as HTMLElement;
if (firstErrorField) {
firstErrorField.focus();
}
}
return isValid;
};
// Validate SQL mode
const validateSqlMode = () => {
const sqlQuery = props.sqlQuery;
// Check if SQL query is empty
if (!sqlQuery || sqlQuery.trim() === '') {
return false;
}
// Check if there's a backend validation error
if (props.sqlQueryErrorMsg && props.sqlQueryErrorMsg.trim() !== '') {
return false;
}
return true;
};
const highlightedSqlQuery = computed(() => {
if (!props.generatedSqlQuery) return "";
return hljs.highlight(props.generatedSqlQuery, { language: "sql" }).value;
});
return {
t,
store,
highlightedSqlQuery,
localTab,
tabOptions,
shouldShowTabs,
updateTab,
updateGroup,
removeConditionGroup,
onInputUpdate,
sqlOrPromqlQuery,
viewSqlEditor,
localSqlQuery,
localPromqlQuery,
vrlFunctionContent,
selectedSavedFunctionName,
updateSqlQuery,
updatePromqlQuery,
handleVrlFunctionUpdate,
handleValidateSql,
functionsList,
validate,
// Field refs for focus manager
customPreviewRef,
sqlPromqlPreviewRef,
// Multi-window dialog
showMultiWindowDialog,
pendingTab,
handleConfirmClearMultiWindows,
handleCancelClearMultiWindows,
// Aggregation & inline condition
localIsAggregationEnabled,
aggFunctions,
triggerOperators,
numericColumns,
toggleAggregation,
addGroupByColumn,
deleteGroupByColumn,
emitAggregationUpdate,
emitPromqlConditionUpdate,
// V3.1 inline condition
isEventBased,
logFunctionOptions,
numericOperators,
selectedFunction,
conditionOperator,
conditionValue,
triggerOperator,
triggerThreshold,
onTriggerOperatorChange,
onTriggerThresholdChange,
restoreDefaultThreshold,
hasLogGroupByFields,
hasMetricGroupByFields,
checkEveryFrequency,
onCheckEveryChange,
restoreDefaultFrequency,
frequencyMode,
cronExpression,
cronTimezone,
cronError,
cronDescription,
filteredTimezones,
onFrequencyUnitChange,
frequencyUnitOptions,
onCronExpressionChange,
onCronTimezoneChange,
logMeasureColumn,
columnSelectError,
logGroupBy,
onFunctionChange,
onMetricFunctionChange,
onLogFunctionChange,
onLogMeasureColumnChange,
addLogGroupByColumn,
deleteLogGroupByColumn,
onLogGroupByChange,
onConditionOperatorChange,
onConditionValueChange,
showFilters,
toggleFilters,
filtersSectionRef,
showVrl,
filterCount,
emitTriggerUpdate,
getImageURL,
queryEditorPlaceholderFlag,
vrlEditorPlaceholderFlag,
onBlurInlineSqlEditor,
onBlurInlineVrlEditor,
inlineStatusState,
inlineQueryEditorRef,
autoCompleteKeywords,
autoCompleteSuggestions,
handleInlineQueryUpdate,
// Field error state (for inline :error/:error-message)
triggerThresholdError,
conditionValueError,
checkEveryFrequencyError,
promqlOperatorError,
promqlValueError,
};
},
});
</script>
<style scoped lang="scss">
.step-query-config {
width: 100%;
min-width: 0;
height: 100%;
margin: 0 auto;
overflow: auto;
.step-content {
border-radius: 8px;
min-height: 100%;
width: 100%;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
}
&.dark-mode {
.step-content {
background-color: #212121;
border: 1px solid #343434;
}
.section-header {
border-bottom: 1px solid #343434;
}
.section-header-title {
color: #e0e0e0;
}
.section-header-accent {
background: var(--q-primary);
}
}
&.light-mode {
.step-content {
background-color: #ffffff;
border: 1px solid #e6e6e6;
}
.section-header {
border-bottom: 1px solid #eeeeee;
}
.section-header-title {
color: #374151;
}
.section-header-accent {
background: var(--q-primary);
}
}
}
.section-header {
display: flex;
align-items: center;
gap: 0;
padding: 10px 12px;
}
.section-header-accent {
width: 3px;
height: 16px;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
}
.section-header-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
}
.preview-box {
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
.preview-header {
border-bottom: 1px solid;
flex-shrink: 0;
}
.preview-title {
font-weight: 600;
font-size: 14px;
}
.preview-content {
flex: 1;
overflow: auto;
min-height: 0;
}
.preview-code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
&.dark-mode-preview {
background-color: #181a1b;
border: 1px solid #343434;
.preview-header {
background-color: #212121;
border-bottom-color: #343434;
}
.preview-title {
color: #ffffff;
}
.preview-code {
color: #e0e0e0;
}
}
&.light-mode-preview {
background-color: #f5f5f5;
border: 1px solid #e6e6e6;
.preview-header {
background-color: #ffffff;
border-bottom-color: #e6e6e6;
}
.preview-title {
color: #3d3d3d;
}
.preview-code {
color: #3d3d3d;
}
}
}
.inline-editor-pane {
border-radius: 8px;
overflow: hidden;
&--light { border: 1px solid #e5e7eb; }
&--dark { border: 1px solid #2d3748; }
}
.inline-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
flex-shrink: 0;
height: 36px;
&--light {
background-color: #f3f4f6;
border-bottom: 1px solid #e5e7eb;
}
&--dark {
background-color: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid #2d3748;
}
}
.inline-editor-title {
font-size: 12px;
font-weight: 600;
}
.inline-sql-status-bar {
position: relative;
height: 22px;
flex-shrink: 0;
font-size: 13px;
font-weight: 500;
cursor: default;
.sql-status-bar__inner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
overflow: hidden;
}
.sql-status-bar__msg {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
&.sql-status-bar--hint { background: #f3f4f6; color: #6b7280; }
&.sql-status-bar--idle { background: #f3f4f6; color: #6b7280; }
&.sql-status-bar--error { background: rgba(239, 68, 68, 0.08); color: #ef4444; cursor: pointer; }
&.sql-status-bar--light {
border-left: 1px solid #e5e7eb;
border-right: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
&.sql-status-bar--dark {
border-left: 1px solid #2d3748;
border-right: 1px solid #2d3748;
border-bottom: 1px solid #2d3748;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
&.sql-status-bar--hint,
&.sql-status-bar--idle { background: rgba(255, 255, 255, 0.04); color: #d1d5db; }
}
}
.pane-accent-bar {
width: 3px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
&--primary { background: var(--q-primary); }
&--secondary { background: var(--q-secondary); }
}
.condition-label-hint {
font-weight: 500;
opacity: 0.7;
font-size: 12px;
}
.sql-query-hint {
cursor: help;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
// Override Quasar tooltip background for SQL preview
:global(.sql-preview-tooltip) {
background: #1e1e1e !important;
padding: 0 !important;
border-radius: 6px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
.hljs {
background: #1e1e1e !important;
border-radius: 6px;
}
}
.editor-dialog-card {
background-color: var(--q-dark-page);
}
.editor-text-title {
font-weight: 600;
font-size: 16px;
}
.no-output-before-run-query {
border-radius: 8px;
}
.dark-mode {
.no-output-before-run-query {
background-color: #1a1a1a;
}
}
.light-mode {
.no-output-before-run-query {
background-color: #f9fafb;
}
}
// Condition rows layout
.alert-condition-rows {
display: flex;
flex-direction: column;
gap: 0;
}
.alert-condition-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
}
.condition-label {
font-weight: 700;
font-size: 13px;
white-space: nowrap;
min-width: 90px;
flex-shrink: 0;
}
.condition-text {
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.set-threshold-btn {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 12px;
font-weight: 600;
padding: 2px 10px;
border-radius: 999px;
border: 1px dashed color-mix(in srgb, var(--q-primary) 50%, transparent);
color: var(--q-primary);
cursor: pointer;
user-select: none;
opacity: 0.65;
transition: opacity 0.15s ease, background 0.15s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, var(--q-primary) 10%, transparent);
}
}
.light-mode {
.condition-label {
color: rgba(0, 0, 0, 0.8);
}
.condition-text {
color: rgba(0, 0, 0, 0.7);
}
}
.dark-mode {
.condition-label {
color: rgba(255, 255, 255, 0.9);
}
.condition-text {
color: rgba(255, 255, 255, 0.75);
}
}
.ai-hover-btn {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(236, 72, 153, 0.15) 100%) !important;
transition: background 0.3s ease, box-shadow 0.3s ease;
&:hover {
background: linear-gradient(135deg, #8B5CF6 0%, #EC4899 100%) !important;
box-shadow: 0 0.25rem 0.75rem 0 rgba(139, 92, 246, 0.35);
}
&.ai-btn-active {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(236, 72, 153, 0.15) 100%) !important;
}
}
.dark-mode-chat-container {
background-color: #1f2937;
border-left: 1px solid #374151;
}
.light-mode-chat-container {
background-color: #ffffff;
border-left: 1px solid #e5e7eb;
}
// Fixed label width for SQL/PromQL condition rows so inputs align
.sql-promql-label {
min-width: 160px;
width: 160px;
}
// Column select error state (aggregation measure column)
.alert-v3-select.column-select-error {
:deep(.q-field__control) {
border: 1px solid #ef5350 !important;
border-radius: 4px !important;
background: rgba(239, 83, 80, 0.05) !important;
}
}
</style>