feat: configurable traffic threshold and Feishu bot alerts
This commit is contained in:
1 parent
3e6e481b79
commit
1273f226fd
16 files changed
+660
-20
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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": "载入设备模板",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user