feat: configurable traffic threshold and Feishu bot alerts

This commit is contained in:
Codex committed 2026-10-23 17:49:19 +08:00
1 parent 3e6e481b79
commit 1273f226fd
16 files changed
+660 -20

No files matched your search

+24
View File
@@ -778,6 +778,30 @@
"verifySuccess": "Verification successful",
"codeError": "Incorrect code. Please try again."
},
"alertSettings": {
"title": "Lark alerts",
"hint": "The rate threshold detects traffic spikes; per-tunnel quotas remain in tunnel details. Notifications are off by default.",
"threshold": "Spike threshold (Mbps)",
"webhook": "Feishu / Lark group bot webhook",
"secret": "Signing secret (optional)",
"optional": "Set if bot signing is enabled",
"keepWebhook": "Configured; leave blank to keep",
"keepSecret": "Configured; leave blank to keep",
"enabled": "Send traffic alerts automatically",
"secretHint": "Webhook and signing secret are never shown after saving. The same tunnel event is sent at most once every 5 minutes.",
"configure": "Alert settings",
"logHint": "Traffic spikes use the platform threshold; quota alerts use each tunnel's traffic policy.",
"save": "Save settings",
"clear": "Remove bot",
"clearConfirm": "This stops notifications and removes bot credentials. In-app alerts remain. Continue?",
"cleared": "Bot configuration removed",
"saved": "Alert settings saved",
"test": "Send test message",
"testSent": "Test message sent; check the group",
"loadFailed": "Could not load alert settings",
"saveFailed": "Could not save alert settings",
"testFailed": "Could not send test message"
},
"ipsecPeer": {
"localParams": "Local IPSec Parameters",
"loadTemplate": "Load Device Template",
+24
View File
@@ -778,6 +778,30 @@
"verifySuccess": "验证成功",
"codeError": "验证码错误,请重试"
},
"alertSettings": {
"title": "飞书告警",
"hint": "速率阈值用于识别突发流量;专线配额仍在专线详情设置。飞书通知默认关闭。",
"threshold": "突发速率阈值(Mbps)",
"webhook": "飞书 / Lark 群机器人 Webhook",
"secret": "签名密钥(可选)",
"optional": "机器人开启签名校验时填写",
"keepWebhook": "已配置,留空保持原地址",
"keepSecret": "已配置,留空保持原密钥",
"enabled": "自动推送流量告警",
"secretHint": "Webhook 和签名密钥保存后不回显;同一专线的同类通知 5 分钟内不重复发送。",
"configure": "设置告警",
"logHint": "突发流量按平台阈值记录;配额超额由专线流量策略触发。",
"save": "保存设置",
"clear": "清除机器人",
"clearConfirm": "清除后停止推送并删除机器人凭据,站内告警仍保留。继续?",
"cleared": "机器人配置已清除",
"saved": "告警设置已保存",
"test": "发送测试消息",
"testSent": "测试消息已发送,请到群内确认",
"loadFailed": "读取告警设置失败",
"saveFailed": "保存告警设置失败",
"testFailed": "测试消息发送失败"
},
"ipsecPeer": {
"localParams": "本端 IPSec 参数",
"loadTemplate": "载入设备模板",
+6 -1
View File
@@ -54,9 +54,13 @@
<template #header>
<div class="row-between">
<b style="color:var(--color-danger)">{{ $t('logs.alertTitle') }}</b>
<el-button link @click="loadAlerts">{{ $t('common.refresh') }}</el-button>
<div>
<el-button v-if="auth.isAdmin()" link type="primary" @click="$router.push('/settings?tab=alerts')">{{ $t('alertSettings.configure') }}</el-button>
<el-button link @click="loadAlerts">{{ $t('common.refresh') }}</el-button>
</div>
</div>
</template>
<p class="alert-hint">{{ $t('alertSettings.logHint') }}</p>
<el-table :data="alerts" size="small" v-loading="alertLoading">
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
@@ -265,6 +269,7 @@ onMounted(() => {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.log-error { margin-bottom: var(--space-4); }
.alert-hint { color: var(--el-text-color-secondary); font-size: 13px; margin: 0 0 16px; }
.pagination-bar {
display: flex; justify-content: flex-end; margin-top: 12px;
}
+5 -1
View File
@@ -46,6 +46,9 @@
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
<GatewaySettings />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('alertSettings.title')" name="alerts">
<AlertSettings />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.hostTab')" name="host">
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
@@ -95,13 +98,14 @@ import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import GatewaySettings from './settings/components/GatewaySettings.vue'
import AlertSettings from './settings/components/AlertSettings.vue'
import api, { auth } from '../api'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const activeTab = ref('security')
const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host'].includes(name) ? name : 'security'
const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host', 'alerts'].includes(name) ? name : 'security'
watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true })
function changeTab(name) {
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
@@ -0,0 +1,89 @@
<template>
<div class="alert-settings">
<el-card class="page-card" shadow="never" v-loading="loading">
<template #header><b>{{ $t('alertSettings.title') }}</b></template>
<p class="setting-hint">{{ $t('alertSettings.hint') }}</p>
<el-form label-position="top" class="alert-form">
<el-form-item :label="$t('alertSettings.threshold')">
<el-input-number v-model="form.rate_threshold_mbps" :min="1" :max="100000" :step="10" />
</el-form-item>
<el-form-item :label="$t('alertSettings.webhook')">
<el-input v-model="form.webhook" type="password" show-password autocomplete="new-password"
:placeholder="status.configured ? $t('alertSettings.keepWebhook') : 'https://open.feishu.cn/open-apis/bot/v2/hook/…'" />
</el-form-item>
<el-form-item :label="$t('alertSettings.secret')">
<el-input v-model="form.signing_secret" type="password" show-password autocomplete="new-password"
:placeholder="status.signed ? $t('alertSettings.keepSecret') : $t('alertSettings.optional')" />
</el-form-item>
<el-form-item :label="$t('alertSettings.enabled')"><el-switch v-model="form.enabled" /></el-form-item>
</el-form>
<p class="setting-hint">{{ $t('alertSettings.secretHint') }}</p>
<div class="actions">
<el-button type="primary" :loading="saving" @click="save">{{ $t('alertSettings.save') }}</el-button>
<el-button :disabled="!status.enabled || !status.configured" :loading="testing" @click="test">{{ $t('alertSettings.test') }}</el-button>
<el-button v-if="status.configured" type="danger" plain @click="clear">{{ $t('alertSettings.clear') }}</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../../../api'
const { t } = useI18n()
const status = ref({ enabled: false, configured: false, signed: false })
const form = ref({ rate_threshold_mbps: 50, enabled: false, webhook: '', signing_secret: '' })
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
async function load() {
loading.value = true
try {
status.value = (await api.get('/system/alerts')).data
form.value.rate_threshold_mbps = status.value.rate_threshold_mbps
form.value.enabled = status.value.enabled
} catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.loadFailed')) }
finally { loading.value = false }
}
async function save() {
saving.value = true
try {
status.value = (await api.put('/system/alerts', form.value)).data
form.value.webhook = ''
form.value.signing_secret = ''
ElMessage.success(t('alertSettings.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.saveFailed')) }
finally { saving.value = false }
}
async function test() {
testing.value = true
try { await api.post('/system/alerts/test'); ElMessage.success(t('alertSettings.testSent')) }
catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.testFailed')) }
finally { testing.value = false }
}
async function clear() {
try { await ElMessageBox.confirm(t('alertSettings.clearConfirm'), t('alertSettings.clear'), { type: 'warning' }) }
catch { return }
try {
status.value = (await api.delete('/system/alerts')).data
form.value.enabled = false
form.value.webhook = ''
form.value.signing_secret = ''
ElMessage.success(t('alertSettings.cleared'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.saveFailed')) }
}
onMounted(load)
</script>
<style scoped>
.alert-settings { max-width: 800px; }
.setting-hint { color: var(--el-text-color-secondary); line-height: 1.6; font-size: 13px; margin: 0 0 16px; }
.alert-form { max-width: 650px; }
.alert-form :deep(.el-input-number) { width: 190px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.actions .el-button + .el-button { margin-left: 0; }
</style>