-
{{ $t('xpack.alert.keepSecret') }}
@@ -281,6 +306,7 @@
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { ElInput, ElMessageBox } from 'element-plus';
import i18n from '@/lang';
+import { MsgError } from '@/utils/message';
import {
CUSTOM_WEBHOOK_VARIABLES,
CUSTOM_WEBHOOK_LIMITS,
@@ -294,6 +320,7 @@ import {
contentTypeForBodyType,
createCustomWebhookFormField,
createCustomWebhookHeader,
+ formatCustomWebhookJsonTemplate,
insertCustomWebhookVariable,
isCustomWebhookSecretHeader,
isCustomWebhookPresetPristine,
@@ -426,6 +453,33 @@ const updateBodyTemplate = (template: string) => {
emitValue(updateCustomWebhookBodyTemplate(props.modelValue, template));
};
+const formatJsonBody = () => {
+ if (props.modelValue.body.type !== 'json') return;
+ const result = formatCustomWebhookJsonTemplate(props.modelValue.body.template);
+ if (!result.success) {
+ MsgError(
+ i18n.global.t(
+ result.error === 'bodyTooLarge'
+ ? 'xpack.alert.jsonFormatTooLarge'
+ : 'xpack.alert.customWebhookValidation.jsonInvalid',
+ ),
+ );
+ return;
+ }
+ if (result.template === props.modelValue.body.template) return;
+ updateBodyTemplate(result.template);
+ void nextTick(() => {
+ bodyTemplateInputRef.value?.focus();
+ const textarea = bodyTemplateInputRef.value?.textarea;
+ if (textarea) {
+ textarea.setSelectionRange(0, 0);
+ textarea.scrollTop = 0;
+ textarea.scrollLeft = 0;
+ }
+ bodyTemplateFocused.value = true;
+ });
+};
+
const addFormField = () => {
emitValue({
...props.modelValue,
@@ -571,7 +625,6 @@ const errorFor = (field: string): string => {
gap: 8px;
}
-.secret-editor__actions,
.header-card__footer,
.header-card__secret-actions {
display: flex;
@@ -580,6 +633,22 @@ const errorFor = (field: string): string => {
gap: 8px;
}
+.webhook-url-item :deep(.el-form-item__label),
+.body-template-item :deep(.el-form-item__label) {
+ width: 100%;
+ height: auto;
+ min-height: 22px;
+}
+
+.body-template-editor :deep(.el-textarea__inner) {
+ padding: 12px;
+ font-family: var(--el-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
+ font-size: 13px;
+ line-height: 1.6;
+ tab-size: 2;
+ background-color: var(--el-fill-color-extra-light);
+}
+
.key-value-list,
.header-list {
display: flex;
@@ -664,7 +733,7 @@ const errorFor = (field: string): string => {
min-height: 44px;
}
- .secret-editor__actions > .el-button,
+ .webhook-url-item :deep(.el-form-item__label .el-button),
.key-value-row > .el-button {
min-height: 44px;
}
diff --git a/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts b/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts
index a36a5ab57..74a294477 100644
--- a/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts
+++ b/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts
@@ -13,6 +13,41 @@ export type CustomWebhookPreset = 'genericJson' | 'slack' | 'discord' | 'teamsWo
export type CustomWebhookBodyType = 'json' | 'form' | 'text';
export type CustomWebhookSecretAction = 'keep' | 'replace' | 'clear';
+export interface CustomWebhookTestState {
+ attempt: number;
+ pending: boolean;
+ passed: boolean;
+}
+
+export const createCustomWebhookTestState = (): CustomWebhookTestState => ({
+ attempt: 0,
+ pending: false,
+ passed: false,
+});
+
+export const invalidateCustomWebhookTest = (state: CustomWebhookTestState): void => {
+ state.attempt += 1;
+ state.pending = false;
+ state.passed = false;
+};
+
+export const beginCustomWebhookTest = (state: CustomWebhookTestState): number => {
+ invalidateCustomWebhookTest(state);
+ state.pending = true;
+ return state.attempt;
+};
+
+export const completeCustomWebhookTest = (
+ state: CustomWebhookTestState,
+ attempt: number,
+ success: boolean,
+): boolean => {
+ if (attempt !== state.attempt || !state.pending) return false;
+ state.pending = false;
+ state.passed = success;
+ return true;
+};
+
export interface CustomWebhookSecretView {
configured: boolean;
masked?: string;
@@ -466,6 +501,51 @@ const isJsonTemplateValid = (value: string): boolean => {
}
};
+export type CustomWebhookJsonFormatResult =
+ { success: true; template: string } | { success: false; error: 'jsonInvalid' | 'bodyTooLarge' };
+
+export const formatCustomWebhookJsonTemplate = (template: string): CustomWebhookJsonFormatResult => {
+ if (!isJsonTemplateValid(template)) return { success: false, error: 'jsonInvalid' };
+
+ const tokens = template.matchAll(/"(?:\\.|[^"\\])*"|[{}[\],:]|[^\s{}[\],:]+/g);
+ const parts: string[] = [];
+ const encoder = new TextEncoder();
+ let remaining = CUSTOM_WEBHOOK_LIMITS.body;
+ let depth = 0;
+ let previous = '';
+
+ const append = (value: string): boolean => {
+ if (value.length > remaining) return false;
+ const size = encoder.encode(value).length;
+ if (size > remaining) return false;
+ remaining -= size;
+ parts.push(value);
+ return true;
+ };
+ const appendIndent = (): boolean => {
+ const size = 1 + depth * 2;
+ if (size > remaining) return false;
+ remaining -= size;
+ parts.push('\n' + ' '.repeat(depth));
+ return true;
+ };
+
+ for (const [token] of tokens) {
+ if (token === '}' || token === ']') {
+ depth -= 1;
+ if (previous !== '{' && previous !== '[' && !appendIndent()) {
+ return { success: false, error: 'bodyTooLarge' };
+ }
+ } else if (previous === '{' || previous === '[' || previous === ',') {
+ if (!appendIndent()) return { success: false, error: 'bodyTooLarge' };
+ }
+ if (!append(token === ':' ? ': ' : token)) return { success: false, error: 'bodyTooLarge' };
+ if (token === '{' || token === '[') depth += 1;
+ previous = token;
+ }
+ return { success: true, template: parts.join('') };
+};
+
const hasTemplateJsonKey = (value: string): boolean => {
try {
const visit = (node: unknown): boolean => {
diff --git a/frontend/src/views/setting/alert/setting/drawer/index.vue b/frontend/src/views/setting/alert/setting/drawer/index.vue
index 40b6bdd9a..fab9e4668 100644
--- a/frontend/src/views/setting/alert/setting/drawer/index.vue
+++ b/frontend/src/views/setting/alert/setting/drawer/index.vue
@@ -166,8 +166,12 @@ import { Alert } from '@/api/interface/alert';
import CustomWebhookForm from './custom-webhook-form.vue';
import {
CustomWebhookValidationIssue,
+ beginCustomWebhookTest,
+ completeCustomWebhookTest,
+ createCustomWebhookTestState,
createDefaultCustomWebhookDraft,
hydrateCustomWebhookDraft,
+ invalidateCustomWebhookTest,
serializeCustomWebhookDraft,
validateCustomWebhookDraft,
} from './custom-webhook';
@@ -235,7 +239,8 @@ const defaultEmailForm = {
const drawerVisible = ref(false);
const loading = ref(false);
-const testLoading = ref(false);
+const customWebhookTestState = reactive(createCustomWebhookTestState());
+const testLoading = computed(() => customWebhookTestState.pending);
const isEdit = ref(false);
const isOK = ref(false);
const emailRevision = ref(0);
@@ -273,14 +278,11 @@ const form = reactive({
});
const customWebhookValidationIssues = ref([]);
-const customWebhookRevision = ref(0);
-const testedCustomWebhookRevision = ref(null);
-const customWebhookTestPassed = computed(
- () =>
- testedCustomWebhookRevision.value !== null && testedCustomWebhookRevision.value === customWebhookRevision.value,
-);
+const customWebhookTestPassed = computed(() => customWebhookTestState.passed);
const customWebhookSaveAllowed = computed(
- () => customWebhookTestPassed.value || (form.status === 'Disable' && form.customWebhook.url.action === 'clear'),
+ () =>
+ !testLoading.value &&
+ (customWebhookTestPassed.value || (form.status === 'Disable' && form.customWebhook.url.action === 'clear')),
);
const drawerHeader = computed(() => {
@@ -421,7 +423,7 @@ const acceptParams = (params: DrawerProps): void => {
isOK.value = false;
customWebhookValidationIssues.value = [];
- testedCustomWebhookRevision.value = null;
+ invalidateCustomWebhookTest(customWebhookTestState);
drawerVisible.value = true;
void loadAlertConfigs();
};
@@ -450,7 +452,7 @@ const onTypeChange = (type: string) => {
}
isOK.value = false;
customWebhookValidationIssues.value = [];
- testedCustomWebhookRevision.value = null;
+ invalidateCustomWebhookTest(customWebhookTestState);
formRef.value?.clearValidate();
};
@@ -554,7 +556,9 @@ const onSave = async (formEl: FormInstance | undefined) => {
}
if (form.type === 'custom') {
if (!customWebhookSaveAllowed.value) return;
+ const testedAttempt = customWebhookTestState.attempt;
if (await validateCustomWebhook(formEl, form.status === 'Disable')) {
+ if (testedAttempt !== customWebhookTestState.attempt || !customWebhookSaveAllowed.value) return;
try {
await saveAlertConfig();
} catch {
@@ -576,11 +580,9 @@ const onSave = async (formEl: FormInstance | undefined) => {
const onTest = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
if (form.type === 'custom') {
- testedCustomWebhookRevision.value = null;
- if (!(await validateCustomWebhook(formEl))) return;
- testLoading.value = true;
+ const attempt = beginCustomWebhookTest(customWebhookTestState);
try {
- const testedRevision = customWebhookRevision.value;
+ if (!(await validateCustomWebhook(formEl)) || attempt !== customWebhookTestState.attempt) return;
const config = JSON.stringify(serializeCustomWebhookDraft(form.customWebhook));
const res = await TestCustomAlertConfig({
...(form.id ? { id: form.id } : {}),
@@ -589,23 +591,17 @@ const onTest = async (formEl: FormInstance | undefined) => {
});
const raw = res.data as Alert.AlertConfigCustomTestResult | boolean;
const result = typeof raw === 'boolean' ? undefined : raw;
- const success = typeof raw === 'boolean' ? raw : Boolean(result?.success);
- const message =
- result?.message || i18n.global.t(success ? 'xpack.alert.alertTestOk' : 'xpack.alert.alertTestFailed');
+ const success = typeof raw === 'boolean' ? raw : result?.success === true;
+ if (!completeCustomWebhookTest(customWebhookTestState, attempt, success)) return;
if (success) {
- if (testedRevision !== customWebhookRevision.value) {
- MsgWarning(i18n.global.t('xpack.alert.testResultStale'));
- return;
- }
- testedCustomWebhookRevision.value = testedRevision;
- MsgSuccess(message);
+ MsgSuccess(i18n.global.t('xpack.alert.alertTestOk'));
return;
}
- MsgError(message);
+ MsgError(result?.message || i18n.global.t('xpack.alert.alertTestFailed'));
} catch {
return;
} finally {
- testLoading.value = false;
+ completeCustomWebhookTest(customWebhookTestState, attempt, false);
}
return;
}
@@ -669,8 +665,7 @@ watch(
() => {
if (form.type !== 'custom') return;
customWebhookValidationIssues.value = [];
- customWebhookRevision.value += 1;
- testedCustomWebhookRevision.value = null;
+ invalidateCustomWebhookTest(customWebhookTestState);
},
{ deep: true, flush: 'sync' },
);
@@ -698,7 +693,7 @@ watch(
const handleClose = () => {
isOK.value = false;
- testedCustomWebhookRevision.value = null;
+ invalidateCustomWebhookTest(customWebhookTestState);
drawerVisible.value = false;
};