feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)

- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回

- agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编

- 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值

- 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知

- API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
This commit is contained in:
2026-09-24 20:50:17 +08:00
parent 8f75e99339
commit 862442e77f
42 changed files with 3034 additions and 190 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "fonu-web",
"private": true,
"version": "1.2.8",
"version": "1.2.9",
"packageManager": "npm@10.9.4",
"type": "module",
"scripts": {
+28
View File
@@ -223,7 +223,24 @@ export const api = {
method: 'POST',
body: JSON.stringify(backup ? { backup } : {}),
}),
getNginxConfigVersions: () =>
request<import('./types').NginxConfigVersionsView>('/api/settings/nginx/config/versions'),
getNginxConfigVersion: (name: string) =>
request<import('./types').NginxConfigVersionView>(`/api/settings/nginx/config/versions/${encodeURIComponent(name)}`),
rollbackNginxConfigVersion: (name: string) =>
request<import('./types').NginxConfigVersionsView>(
`/api/settings/nginx/config/versions/${encodeURIComponent(name)}/rollback`,
{ method: 'POST' },
),
getChinaCIDRStatus: () => request<ChinaCIDRStatus>('/api/settings/china-cidr'),
listApiTokens: () => request<{ tokens: import('./types').ApiToken[] }>('/api/settings/tokens'),
createApiToken: (payload: { name: string; scope: 'read' | 'write'; expires_in_days: number }) =>
request<import('./types').CreatedApiToken>('/api/settings/tokens', {
method: 'POST',
body: JSON.stringify(payload),
}),
deleteApiToken: (id: number) =>
request<{ ok: boolean }>(`/api/settings/tokens/${id}`, { method: 'DELETE' }),
refreshChinaCIDR: () =>
request<ChinaCIDRStatus>('/api/settings/china-cidr/refresh', { method: 'POST' }),
testNotify: (payload: NotifyTestPayload) =>
@@ -315,6 +332,17 @@ export const api = {
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean; basic_auth_user?: string; basic_auth_password?: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
getFrpAgentRouteConf: (serverId: number, proxyId: number) =>
request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
getFrpAgentRouteVersions: (serverId: number, proxyId: number) =>
request<import('./types').FrpAgentRouteVersions>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions`),
getFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) =>
request<import('./types').FrpAgentRouteVersion>(
`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}`,
),
rollbackFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) =>
request<{ ok: boolean; domain: string; name: string }>(
`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}/rollback`,
{ method: 'POST' },
),
installFrpAgentNginx: (serverId: number) =>
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }),
getFrpAgentRouteHealth: (serverId: number) =>
+47 -1
View File
@@ -237,7 +237,7 @@ export interface SystemLogEntry {
}
export type NotifyType = '' | 'email' | 'webhook' | 'telegram'
export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'custom'
export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'wecom' | 'dingtalk' | 'feishu' | 'custom'
export interface NotifyEmailConfig {
host: string
@@ -894,3 +894,49 @@ export interface GlobalNginxView {
content: string
backups: NginxBackupEntry[]
}
// 配置版本(本机主配置与公网 vhost 共用同一形状)
export interface NginxConfigVersionEntry {
name: string
size: number
created_at: string
}
export interface NginxConfigVersionsView {
message?: string
content: string
versions: NginxConfigVersionEntry[]
}
export interface NginxConfigVersionView {
name: string
content: string
}
// 公网 VPS 上某个域名 vhost 的版本历史(需 agent ≥ 0.12.2)
export interface FrpAgentRouteVersions {
domain: string
content: string
versions: NginxConfigVersionEntry[]
}
export interface FrpAgentRouteVersion {
domain: string
name: string
content: string
}
// API Token:明文只在创建响应里出现一次
// (令牌本身不含密钥材料,是随机串的 SHA-256 存库)
export interface ApiToken {
id: number
name: string
scope: 'read' | 'write'
expires_at?: string
last_used_at?: string
created_at: string
}
export interface CreatedApiToken extends ApiToken {
token: string
}
+69
View File
@@ -0,0 +1,69 @@
<template>
<div class="config-diff">
<div class="config-diff__head">
<span class="config-diff__range">{{ fromLabel }} → {{ toLabel }}</span>
<span class="config-diff__stats">
<span class="config-diff__add">+{{ stats.added }}</span>
<span class="config-diff__del">−{{ stats.removed }}</span>
</span>
</div>
<div class="config-diff__body">
<div
v-for="(line, index) in lines"
:key="index"
class="config-diff__row"
:class="`config-diff__row--${line.kind}`"
>
<span class="config-diff__sign">{{ sign(line.kind) }}</span>
<span class="config-diff__text">{{ line.text || ' ' }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { diffLines, diffStats, type DiffLine } from '../utils/diff'
const props = withDefaults(
defineProps<{
fromText: string
toText: string
fromLabel?: string
toLabel?: string
}>(),
{ fromLabel: '当前', toLabel: '所选版本' },
)
const lines = computed<DiffLine[]>(() => diffLines(props.fromText, props.toText))
const stats = computed(() => diffStats(lines.value))
function sign(kind: DiffLine['kind']): string {
if (kind === 'add') return '+'
if (kind === 'del') return '−'
return ' '
}
</script>
<style scoped>
.config-diff { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
.config-diff__head {
display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3);
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
background: var(--fonu-bg-muted); font-size: 12px; color: var(--fonu-text-secondary);
}
.config-diff__stats { display: flex; gap: 8px; font-family: var(--fonu-mono); }
.config-diff__add { color: var(--fonu-brand-text); }
.config-diff__del { color: var(--fonu-error); }
.config-diff__body { max-height: 420px; overflow: auto; background: var(--fonu-surface); }
.config-diff__row {
display: flex; gap: 8px; padding: 1px 10px;
font-family: var(--fonu-mono); font-size: 12px; line-height: 1.6;
}
.config-diff__row--add { background: var(--fonu-brand-soft); }
.config-diff__row--del { background: var(--fonu-error-soft); }
.config-diff__sign { flex: none; width: 10px; color: var(--fonu-text-muted); }
.config-diff__row--add .config-diff__sign { color: var(--fonu-brand-text); }
.config-diff__row--del .config-diff__sign { color: var(--fonu-error); }
.config-diff__text { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
+19 -1
View File
@@ -3,7 +3,7 @@ export interface NotifyEventOption {
label: string
hint?: string
hasThreshold?: boolean
thresholdKey?: 'ip_frequent' | 'login_failure'
thresholdKey?: 'ip_frequent' | 'login_failure' | 'resource' | 'tunnel_fail'
}
export interface NotifyEventGroup {
@@ -71,6 +71,20 @@ export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
label: 'Nginx 重载失败',
hint: '保存反代或证书后 Nginx 未能成功重载',
},
{
key: 'on_resource_threshold',
label: '资源占用过高',
hint: 'CPU / 内存 / 磁盘持续超过阈值,同一项每 30 分钟最多提醒一次',
hasThreshold: true,
thresholdKey: 'resource',
},
{
key: 'on_tunnel_down',
label: '隧道连续不通',
hint: '连续多次巡检未在 frps 上注册,恢复时再通知一次',
hasThreshold: true,
thresholdKey: 'tunnel_fail',
},
],
},
]
@@ -79,3 +93,7 @@ export const DEFAULT_IP_FREQUENT_THRESHOLD = 100
export const DEFAULT_IP_FREQUENT_WINDOW_SEC = 60
export const DEFAULT_LOGIN_FAILURE_THRESHOLD = 5
export const DEFAULT_LOGIN_FAILURE_WINDOW_SEC = 300
export const DEFAULT_CPU_THRESHOLD = 90
export const DEFAULT_MEMORY_THRESHOLD = 90
export const DEFAULT_DISK_THRESHOLD = 85
export const DEFAULT_TUNNEL_FAIL_THRESHOLD = 3
+6
View File
@@ -11,9 +11,15 @@ export const WEBHOOK_PROVIDER_OPTIONS: { label: string; value: WebhookProvider }
{ label: 'Bark', value: 'bark' },
{ label: 'ntfy', value: 'ntfy' },
{ label: 'Gotify', value: 'gotify' },
{ label: '企业微信机器人', value: 'wecom' },
{ label: '钉钉机器人', value: 'dingtalk' },
{ label: '飞书机器人', value: 'feishu' },
{ label: '自定义 Webhook', value: 'custom' },
]
export const DEFAULT_BARK_SERVER = 'https://api.day.app'
export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh'
export const DEFAULT_WECOM_SERVER = 'https://qyapi.weixin.qq.com'
export const DEFAULT_DINGTALK_SERVER = 'https://oapi.dingtalk.com'
export const DEFAULT_FEISHU_SERVER = 'https://open.feishu.cn'
export const MASKED_SECRET = '********'
+2
View File
@@ -11,6 +11,7 @@
--fonu-disabled: #94a3b8;
--fonu-info: #3b82f6;
--fonu-info-soft: rgba(59, 130, 246, 0.1);
--fonu-error-soft: rgba(239, 68, 68, 0.1);
--fonu-bg: #f8fafc;
--fonu-bg-muted: #f3f4f6;
@@ -47,6 +48,7 @@ html.dark,
--fonu-brand-soft: rgba(16, 185, 129, 0.18);
--fonu-brand-text: #6ee7b7;
--fonu-info-soft: rgba(59, 130, 246, 0.16);
--fonu-error-soft: rgba(239, 68, 68, 0.16);
--fonu-bg: #0f172a;
--fonu-bg-muted: #1e293b;
+73
View File
@@ -0,0 +1,73 @@
// 极简行级差异:Nginx 配置通常只有几十行,用 LCS 就够,不为此引入第三方 diff 依赖。
export interface DiffLine {
kind: 'same' | 'add' | 'del'
text: string
oldLine?: number
newLine?: number
}
// maxDiffCells 限制 LCS 表大小:超长文本(例如整份日志误粘进来)退化为「整体替换」,
// 避免 O(n*m) 的表把页面卡死。
const maxDiffCells = 250_000
export function diffLines(fromText: string, toText: string): DiffLine[] {
const from = splitLines(fromText)
const to = splitLines(toText)
if (from.length * to.length > maxDiffCells) {
return [
...from.map((text, index) => ({ kind: 'del' as const, text, oldLine: index + 1 })),
...to.map((text, index) => ({ kind: 'add' as const, text, newLine: index + 1 })),
]
}
// dp[i][j] = from[i..] 与 to[j..] 的最长公共子序列长度
const dp: number[][] = Array.from({ length: from.length + 1 }, () => new Array<number>(to.length + 1).fill(0))
for (let i = from.length - 1; i >= 0; i -= 1) {
for (let j = to.length - 1; j >= 0; j -= 1) {
dp[i][j] = from[i] === to[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
}
}
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < from.length && j < to.length) {
if (from[i] === to[j]) {
out.push({ kind: 'same', text: from[i], oldLine: i + 1, newLine: j + 1 })
i += 1
j += 1
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ kind: 'del', text: from[i], oldLine: i + 1 })
i += 1
} else {
out.push({ kind: 'add', text: to[j], newLine: j + 1 })
j += 1
}
}
while (i < from.length) {
out.push({ kind: 'del', text: from[i], oldLine: i + 1 })
i += 1
}
while (j < to.length) {
out.push({ kind: 'add', text: to[j], newLine: j + 1 })
j += 1
}
return out
}
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
let added = 0
let removed = 0
for (const line of lines) {
if (line.kind === 'add') added += 1
else if (line.kind === 'del') removed += 1
}
return { added, removed }
}
function splitLines(text: string): string[] {
if (text === '') return []
return text.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n')
}
+135 -3
View File
@@ -340,7 +340,7 @@
</div>
</n-modal>
<!-- 反代配置查看 -->
<!-- 反代配置查看 / 版本对比与回滚 -->
<n-modal v-model:show="confModal" :mask-closable="true">
<div class="proxy-modal">
<div class="proxy-modal__header">
@@ -353,7 +353,49 @@
<span class="mono">fonu-{{ confDomain }}.conf</span>)。请勿在 VPS 上手工修改:
下次部署会被重新生成覆盖,改动请到「部署设置」或穿透规则里调整。
</p>
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
<div class="conf-layout">
<aside class="conf-versions">
<div class="conf-versions__head">
<span>历史版本</span>
<span class="conf-versions__count">{{ routeVersions.length }} / 最近 10 次</span>
</div>
<p v-if="versionError" class="conf-versions__empty conf-versions__empty--error">{{ versionError }}</p>
<p v-else-if="routeVersions.length === 0" class="conf-versions__empty">暂无历史版本(本次部署是第一个版本)</p>
<ul v-else class="conf-versions__list">
<li
v-for="version in routeVersions"
:key="version.name"
class="conf-versions__item"
:class="{ 'conf-versions__item--active': selectedVersionName === version.name }"
@click="selectVersion(version)"
>
<span class="mono conf-versions__time">{{ formatDate(version.created_at) }}</span>
<span class="conf-versions__size">{{ formatBytes(version.size) }}</span>
</li>
</ul>
</aside>
<div class="conf-body">
<ConfigDiffView
v-if="selectedVersionName"
:from-text="confContent"
:to-text="selectedVersionContent"
from-label="当前配置"
:to-label="`版本 ${formatDate(versionCreatedAt(selectedVersionName))}`"
/>
<pre v-else class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
<div v-if="selectedVersionName" class="conf-actions">
<span class="conf-actions__hint">回滚前会先做语法校验与 nginx -t,校验不过不会覆盖当前配置。</span>
<n-popconfirm @positive-click="rollbackToVersion">
<template #trigger>
<n-button size="small" type="warning" :loading="rollingBack" :disabled="versionLoading">
回滚到该版本
</n-button>
</template>
确定把 <span class="mono">{{ confDomain }}</span> 的 vhost 回滚到该版本?
</n-popconfirm>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<n-button :disabled="!confContent" @click="copyConf">复制</n-button>
@@ -371,6 +413,7 @@ import {
CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline,
} from '@vicons/ionicons5'
import EmptyState from '../components/EmptyState.vue'
import ConfigDiffView from '../components/ConfigDiffView.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
@@ -379,7 +422,7 @@ import StatusBadge from '../components/StatusBadge.vue'
import { api } from '../api/client'
import type {
CertificateRecord, FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic,
FrpRouteDriftReport, FrpServer,
FrpRouteDriftReport, FrpServer, NginxConfigVersionEntry,
} from '../api/types'
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
@@ -430,6 +473,13 @@ const settingsForm = ref({
const confModal = ref(false)
const confDomain = ref('')
const confContent = ref('')
const confProxyId = ref<number | null>(null)
const routeVersions = ref<NginxConfigVersionEntry[]>([])
const selectedVersionName = ref('')
const selectedVersionContent = ref('')
const versionLoading = ref(false)
const versionError = ref('')
const rollingBack = ref(false)
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
@@ -840,16 +890,74 @@ async function viewConf(candidate: FrpProxy) {
if (!current) return
if (busy.value) return
busy.value = `conf:${candidate.id}`
confProxyId.value = candidate.id
try {
const conf = await api.getFrpAgentRouteConf(current.id, candidate.id)
confDomain.value = conf.domain
confContent.value = conf.content
confModal.value = true
await loadRouteVersions(candidate.id)
} catch (error) {
loadErrorMsg.value = `读取配置失败 —— ${error instanceof Error ? error.message : '未知错误'}`
} finally { busy.value = null }
}
// 版本历史读不到不影响「查看配置」本身:只在弹窗内提示(老版本 agent 没有这个端点)
async function loadRouteVersions(proxyId: number) {
const current = server.value
if (!current) return
routeVersions.value = []
selectedVersionName.value = ''
selectedVersionContent.value = ''
versionError.value = ''
try {
const result = await api.getFrpAgentRouteVersions(current.id, proxyId)
routeVersions.value = result.versions ?? []
} catch (error) {
versionError.value = error instanceof Error ? error.message : '读取版本历史失败'
}
}
// 点已选中的版本 = 取消对比,回到当前配置
async function selectVersion(version: NginxConfigVersionEntry) {
const current = server.value
if (!current || confProxyId.value === null || versionLoading.value) return
if (selectedVersionName.value === version.name) {
selectedVersionName.value = ''
selectedVersionContent.value = ''
return
}
versionLoading.value = true
try {
const result = await api.getFrpAgentRouteVersion(current.id, confProxyId.value, version.name)
selectedVersionName.value = version.name
selectedVersionContent.value = result.content
} catch (error) {
message.error(`读取该版本失败:${error instanceof Error ? error.message : '未知错误'}`)
} finally { versionLoading.value = false }
}
async function rollbackToVersion() {
const current = server.value
if (!current || confProxyId.value === null || !selectedVersionName.value) return
rollingBack.value = true
try {
await api.rollbackFrpAgentRouteVersion(current.id, confProxyId.value, selectedVersionName.value)
message.success('已回滚到所选版本,Nginx 已重载')
const conf = await api.getFrpAgentRouteConf(current.id, confProxyId.value)
confContent.value = conf.content
// 回滚会新增一个版本,且可能改变漂移状态,两边都要重读
await loadRouteVersions(confProxyId.value)
await refreshAll()
} catch (error) {
message.error(`回滚失败:${error instanceof Error ? error.message : '未知错误'}`)
} finally { rollingBack.value = false }
}
function versionCreatedAt(name: string): string {
return routeVersions.value.find((item) => item.name === name)?.created_at ?? ''
}
async function copyConf() {
if (await copyText(confContent.value)) message.success('已复制反代配置')
else message.error('复制失败,请手动选择文本复制')
@@ -979,6 +1087,30 @@ onMounted(() => { void load() })
.conf-scroll { padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
.conf-scroll .field-hint { margin-bottom: var(--fonu-space-3); line-height: 1.65; }
.conf-view { max-height: 62vh; }
/* 版本面板 + 差异区:左侧固定宽度列表,右侧自适应(与弹窗 900px 宽度对齐) */
.conf-layout { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--fonu-space-4); }
.conf-versions { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
.conf-versions__head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
background: var(--fonu-bg-muted); font-size: 12px; color: var(--fonu-text-secondary);
}
.conf-versions__count { color: var(--fonu-text-muted); }
.conf-versions__empty { padding: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
.conf-versions__empty--error { color: var(--fonu-error); }
.conf-versions__list { max-height: 52vh; overflow: auto; margin: 0; padding: 0; list-style: none; }
.conf-versions__item {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
font-size: 12px; color: var(--fonu-text-secondary); cursor: pointer;
}
.conf-versions__item:hover { background: var(--fonu-bg-muted); }
.conf-versions__item--active { background: var(--fonu-info-soft); color: var(--fonu-text); }
.conf-versions__time { font-size: 12px; }
.conf-versions__size { flex: none; color: var(--fonu-text-muted); }
.conf-body { min-width: 0; display: grid; gap: var(--fonu-space-3); align-content: start; }
.conf-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.conf-actions__hint { font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); font-size: 13px; }
.mono-wrap { white-space: normal; word-break: break-all; }
+378 -4
View File
@@ -164,7 +164,11 @@
<div v-else-if="notifyType === 'webhook'" class="settings-fields settings-fields--compact">
<div class="settings-field">
<div class="settings-field__label">Webhook 预设</div>
<n-select v-model:value="notifyWebhook.provider" :options="WEBHOOK_PROVIDER_OPTIONS" />
<n-select
v-model:value="notifyWebhook.provider"
:options="WEBHOOK_PROVIDER_OPTIONS"
@update:value="onWebhookProviderChange"
/>
</div>
<template v-if="notifyWebhook.provider === 'bark'">
<div class="settings-field">
@@ -206,6 +210,46 @@
placeholder="Gotify App Token"
/>
</template>
<template v-else-if="notifyWebhook.provider === 'wecom'">
<div class="settings-field">
<div class="settings-field__label">企业微信服务地址</div>
<n-input v-model:value="notifyWebhook.server" :placeholder="DEFAULT_WECOM_SERVER" />
</div>
<ConfiguredSecretField
v-model="notifyWebhookKey"
label="机器人 Key"
:configured="notifyWebhook.has_secret"
placeholder="Webhook 地址里 key= 后面的部分"
/>
<p class="field-hint">群机器人 → 添加机器人 → 复制 Webhook 地址,取其中 key= 之后的部分。</p>
</template>
<template v-else-if="notifyWebhook.provider === 'dingtalk'">
<div class="settings-field">
<div class="settings-field__label">钉钉服务地址</div>
<n-input v-model:value="notifyWebhook.server" :placeholder="DEFAULT_DINGTALK_SERVER" />
</div>
<ConfiguredSecretField
v-model="notifyWebhookKey"
label="Access Token"
:configured="notifyWebhook.has_secret"
placeholder="access_token 参数值"
/>
<p class="field-hint">
安全设置选「自定义关键词」并填 <span class="mono">Fonu</span>:消息正文固定以「Fonu · 」开头。加签方式暂不支持。
</p>
</template>
<template v-else-if="notifyWebhook.provider === 'feishu'">
<div class="settings-field">
<div class="settings-field__label">飞书服务地址</div>
<n-input v-model:value="notifyWebhook.server" :placeholder="DEFAULT_FEISHU_SERVER" />
</div>
<ConfiguredSecretField
v-model="notifyWebhookKey"
label="机器人 Key"
:configured="notifyWebhook.has_secret"
placeholder="Webhook 地址 /hook/ 后面的部分"
/>
</template>
<template v-else>
<div class="settings-field">
<div class="settings-field__label">Webhook URL</div>
@@ -270,6 +314,26 @@
<n-input-number v-model:value="notifyLoginFailureWindowSec" :min="10" :max="3600" class="settings-field__input" />
</div>
</template>
<template v-else-if="event.thresholdKey === 'resource'">
<div class="settings-field">
<label class="settings-field__label">CPU 阈值(%)</label>
<n-input-number v-model:value="notifyCPUThreshold" :min="1" :max="100" class="settings-field__input" />
</div>
<div class="settings-field">
<label class="settings-field__label">内存阈值(%)</label>
<n-input-number v-model:value="notifyMemoryThreshold" :min="1" :max="100" class="settings-field__input" />
</div>
<div class="settings-field">
<label class="settings-field__label">磁盘阈值(%)</label>
<n-input-number v-model:value="notifyDiskThreshold" :min="1" :max="100" class="settings-field__input" />
</div>
</template>
<template v-else-if="event.thresholdKey === 'tunnel_fail'">
<div class="settings-field">
<label class="settings-field__label">连续失败次数</label>
<n-input-number v-model:value="notifyTunnelFailThreshold" :min="1" :max="20" class="settings-field__input" />
</div>
</template>
</div>
</template>
</div>
@@ -421,6 +485,50 @@
class="nginx-framework__editor"
/>
</details>
<details class="nginx-framework">
<summary>主配置版本与回滚({{ configVersions.length }} 个历史版本)</summary>
<p class="field-hint">
保存规则或设置时会重新生成 <code>nginx.conf</code>,这里保留最近 10 次内容有变化的版本。
若配置被外部改动导致 Nginx 起不来,可直接回滚(回滚前先校验语法与 nginx -t,不过关不会覆盖当前配置)。
</p>
<p v-if="configVersionsError" class="config-versions__error">{{ configVersionsError }}</p>
<div class="config-versions">
<div class="config-versions__list">
<div
v-for="version in configVersions"
:key="version.name"
class="config-versions__item"
:class="{ 'config-versions__item--active': selectedConfigVersion === version.name }"
@click="selectConfigVersion(version)"
>
<span class="mono">{{ formatDate(version.created_at) }}</span>
<span class="config-versions__size">{{ formatBytes(version.size) }}</span>
</div>
<p v-if="configVersions.length === 0" class="config-versions__empty">暂无历史版本</p>
</div>
<div class="config-versions__body">
<ConfigDiffView
v-if="selectedConfigVersion"
:from-text="configCurrentContent"
:to-text="selectedConfigVersionContent"
from-label="当前主配置"
:to-label="`版本 ${formatDate(configVersionCreatedAt(selectedConfigVersion))}`"
/>
<NginxCodeEditor v-else :model-value="configCurrentContent" readonly min-height="200px" />
<div v-if="selectedConfigVersion" class="config-versions__actions">
<span class="config-versions__hint">回滚会先校验该版本,通过后才写回并重载 Nginx。</span>
<n-popconfirm @positive-click="rollbackConfigVersion">
<template #trigger>
<n-button size="small" type="warning" :loading="configVersionBusy">
回滚到该版本
</n-button>
</template>
确定把主配置回滚到该版本?
</n-popconfirm>
</div>
</div>
</div>
</details>
<div class="log-panel-head global-nginx-head">
<div class="nginx-mode-toggle">
<n-button
@@ -487,6 +595,68 @@
<n-alert type="warning" :bordered="false" class="global-nginx-warn">
小白勿碰。若保存后 Nginx 启动失败,请点「恢复自动生成」或「回滚」还原配置。
</n-alert>
</FonuCard>
<FonuCard title="API Token" subtitle="给脚本、HomeAssistant、手机快捷指令调用">
<p class="field-hint">
浏览器之外的调用方拿不到会话 cookie,可在这里签发令牌,用
<code>Authorization: Bearer &lt;token&gt;</code> 调用接口。明文只在创建时显示一次。
</p>
<n-alert v-if="createdToken" type="success" :bordered="false" class="token-created">
<div class="token-created__head">
<strong>令牌已创建,请立即复制</strong>
<span>关闭后无法再次查看明文,只能撤销重建。</span>
</div>
<div class="token-created__row">
<code class="token-created__value">{{ createdToken }}</code>
<n-button size="small" @click="copyCreatedToken">复制</n-button>
</div>
</n-alert>
<div class="settings-fields settings-fields--compact token-create">
<div class="settings-field">
<label class="settings-field__label">名称</label>
<n-input v-model:value="tokenForm.name" placeholder="例如 homeassistant" />
</div>
<div class="settings-field">
<label class="settings-field__label">权限</label>
<n-select v-model:value="tokenForm.scope" :options="tokenScopeOptions" />
</div>
<div class="settings-field">
<label class="settings-field__label">有效期</label>
<n-select v-model:value="tokenForm.expiresInDays" :options="tokenExpiryOptions" />
</div>
<div class="settings-field">
<label class="settings-field__label">新建</label>
<n-button type="primary" :loading="creatingToken" :disabled="!tokenForm.name.trim()" @click="createToken">
创建令牌
</n-button>
</div>
</div>
<LoadError v-if="tokenError" :message="tokenError" />
<div v-if="apiTokens.length > 0" class="token-list">
<div v-for="token in apiTokens" :key="token.id" class="token-row">
<div class="token-row__main">
<span class="token-row__name">{{ token.name }}</span>
<StatusBadge
:text="token.scope === 'write' ? '读写' : '只读'"
:kind="token.scope === 'write' ? 'warning' : 'disabled'"
/>
</div>
<div class="token-row__meta">
<span>创建 {{ formatDate(token.created_at) }}</span>
<span>{{ token.last_used_at ? `最近使用 ${formatDate(token.last_used_at)}` : '尚未使用' }}</span>
<span>{{ token.expires_at ? `有效期至 ${formatDate(token.expires_at)}` : '永不过期' }}</span>
</div>
<n-popconfirm @positive-click="deleteToken(token)">
<template #trigger>
<n-button size="small" type="warning" quaternary>撤销</n-button>
</template>
撤销后该令牌立即失效,确定继续?
</n-popconfirm>
</div>
</div>
<p v-else-if="!tokenError" class="field-hint">还没有令牌。</p>
</FonuCard>
</div>
</n-tab-pane>
@@ -526,8 +696,10 @@ import {
} from '@vicons/ionicons5'
import { api } from '../api/client'
import type {
ApiToken,
ChinaCIDRStatus,
GlobalNginxView,
NginxConfigVersionEntry,
NotifyEmailConfig,
NotifyTelegramConfig,
NotifyTestPayload,
@@ -536,6 +708,7 @@ import type {
WebhookProvider,
} from '../api/types'
import ConfiguredSecretField from '../components/ConfiguredSecretField.vue'
import ConfigDiffView from '../components/ConfigDiffView.vue'
import FonuCard from '../components/FonuCard.vue'
import NginxCodeEditor from '../components/NginxCodeEditor.vue'
import LoadError from '../components/LoadError.vue'
@@ -543,19 +716,27 @@ import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { DEFAULT_PRIVATE_CIDRS, mergeIPList, stripDefaultIPList } from '../constants/ipPolicy'
import {
DEFAULT_CPU_THRESHOLD,
DEFAULT_DISK_THRESHOLD,
DEFAULT_LOGIN_FAILURE_THRESHOLD,
DEFAULT_LOGIN_FAILURE_WINDOW_SEC,
DEFAULT_MEMORY_THRESHOLD,
DEFAULT_TUNNEL_FAIL_THRESHOLD,
NOTIFY_EVENT_GROUPS,
} from '../constants/notifyEvents'
import {
DEFAULT_BARK_SERVER,
DEFAULT_DINGTALK_SERVER,
DEFAULT_FEISHU_SERVER,
DEFAULT_NTFY_SERVER,
DEFAULT_WECOM_SERVER,
MASKED_SECRET,
NOTIFY_TYPE_OPTIONS,
WEBHOOK_PROVIDER_OPTIONS,
} from '../constants/notifyProviders'
import { useTheme } from '../composables/useTheme'
import { formatDate, formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { formatBytes, formatDate, formatRelativeTime } from '../utils/format'
import type { StatusKind } from '../utils/status'
const message = useMessage()
@@ -584,11 +765,79 @@ const notifyEventFlags = reactive<Record<string, boolean>>({
on_ip_frequent_access: false,
on_login_failure: false,
on_nginx_reload_failure: true,
on_resource_threshold: false,
on_tunnel_down: false,
})
const notifyIPFrequentThreshold = ref(100)
const notifyIPFrequentWindowSec = ref(60)
const notifyLoginFailureThreshold = ref(DEFAULT_LOGIN_FAILURE_THRESHOLD)
const notifyLoginFailureWindowSec = ref(DEFAULT_LOGIN_FAILURE_WINDOW_SEC)
const notifyCPUThreshold = ref(DEFAULT_CPU_THRESHOLD)
const notifyMemoryThreshold = ref(DEFAULT_MEMORY_THRESHOLD)
const notifyDiskThreshold = ref(DEFAULT_DISK_THRESHOLD)
const notifyTunnelFailThreshold = ref(DEFAULT_TUNNEL_FAIL_THRESHOLD)
// API Token:明文只在创建响应里回一次,因此单独用一个 ref 暂存展示
const apiTokens = ref<ApiToken[]>([])
const tokenError = ref('')
const creatingToken = ref(false)
const createdToken = ref('')
const tokenForm = reactive({ name: '', scope: 'read' as 'read' | 'write', expiresInDays: 0 })
const tokenScopeOptions = [
{ label: '只读(仅 GET)', value: 'read' },
{ label: '读写(与会话同权)', value: 'write' },
]
const tokenExpiryOptions = [
{ label: '永不过期', value: 0 },
{ label: '30 天', value: 30 },
{ label: '90 天', value: 90 },
{ label: '365 天', value: 365 },
]
async function loadApiTokens() {
try {
const result = await api.listApiTokens()
apiTokens.value = result.tokens ?? []
tokenError.value = ''
} catch (error) {
tokenError.value = `读取令牌失败 —— ${error instanceof Error ? error.message : '未知错误'}`
}
}
async function createToken() {
const name = tokenForm.name.trim()
if (!name || creatingToken.value) return
creatingToken.value = true
try {
const result = await api.createApiToken({
name,
scope: tokenForm.scope,
expires_in_days: tokenForm.expiresInDays,
})
createdToken.value = result.token
tokenForm.name = ''
await loadApiTokens()
message.success('令牌已创建,请复制明文')
} catch (error) {
message.error(`创建令牌失败:${error instanceof Error ? error.message : '未知错误'}`)
} finally { creatingToken.value = false }
}
async function copyCreatedToken() {
if (await copyText(createdToken.value)) message.success('已复制令牌')
else message.error('复制失败,请手动选择文本复制')
}
async function deleteToken(token: ApiToken) {
try {
await api.deleteApiToken(token.id)
if (createdToken.value) createdToken.value = ''
await loadApiTokens()
message.success('令牌已撤销')
} catch (error) {
message.error(`撤销失败:${error instanceof Error ? error.message : '未知错误'}`)
}
}
const notifyTesting = ref(false)
const notifySMTPPassword = ref('')
const notifyWebhookKey = ref('')
@@ -609,6 +858,22 @@ const notifyWebhook = reactive({
url: '',
has_secret: false,
})
function defaultWebhookServer(provider: WebhookProvider): string {
switch (provider) {
case 'ntfy': return DEFAULT_NTFY_SERVER
case 'wecom': return DEFAULT_WECOM_SERVER
case 'dingtalk': return DEFAULT_DINGTALK_SERVER
case 'feishu': return DEFAULT_FEISHU_SERVER
case 'gotify': return ''
default: return DEFAULT_BARK_SERVER
}
}
// 切换预设时把服务地址换成该预设的默认值:否则会把上一个预设的地址(如 api.day.app)带到新预设上
function onWebhookProviderChange(provider: WebhookProvider) {
notifyWebhook.server = defaultWebhookServer(provider)
}
const notifyTelegram = reactive({
chat_id: '',
proxy_url: '',
@@ -639,6 +904,12 @@ const globalNginxServerMode = ref<'auto' | 'custom'>('auto')
const globalNginxDirty = ref(false)
const globalNginxSaving = ref(false)
const globalNginxBackups = ref<{ name: string; created_at: string }[]>([])
const configVersions = ref<NginxConfigVersionEntry[]>([])
const configCurrentContent = ref('')
const selectedConfigVersion = ref('')
const selectedConfigVersionContent = ref('')
const configVersionsError = ref('')
const configVersionBusy = ref(false)
const globalNginxCustomHint = `# 在此添加 http 块内的自定义指令
# 上传大小限制(全局默认 50M,单条规则可在 server 块覆盖)
@@ -777,6 +1048,56 @@ async function loadGlobalNginx() {
} catch {
// optional on load
}
await loadConfigVersions()
}
// 主配置(nginx.conf)的版本历史:与全局片段是两份文件,分开读、分开回滚
async function loadConfigVersions() {
try {
const view = await api.getNginxConfigVersions()
configCurrentContent.value = view.content
configVersions.value = view.versions ?? []
configVersionsError.value = ''
} catch (error) {
configVersionsError.value = error instanceof Error ? error.message : '读取主配置版本失败'
}
}
// 点已选中的版本 = 取消对比
async function selectConfigVersion(version: NginxConfigVersionEntry) {
if (configVersionBusy.value) return
if (selectedConfigVersion.value === version.name) {
selectedConfigVersion.value = ''
selectedConfigVersionContent.value = ''
return
}
configVersionBusy.value = true
try {
const result = await api.getNginxConfigVersion(version.name)
selectedConfigVersion.value = version.name
selectedConfigVersionContent.value = result.content
} catch (error) {
message.error(`读取该版本失败:${error instanceof Error ? error.message : '未知错误'}`)
} finally { configVersionBusy.value = false }
}
async function rollbackConfigVersion() {
if (!selectedConfigVersion.value) return
configVersionBusy.value = true
try {
const view = await api.rollbackNginxConfigVersion(selectedConfigVersion.value)
configCurrentContent.value = view.content
configVersions.value = view.versions ?? []
selectedConfigVersion.value = ''
selectedConfigVersionContent.value = ''
message.success(view.message || '已回滚到所选版本')
} catch (error) {
message.error(`回滚失败:${error instanceof Error ? error.message : '未知错误'}`)
} finally { configVersionBusy.value = false }
}
function configVersionCreatedAt(name: string): string {
return configVersions.value.find((item) => item.name === name)?.created_at ?? ''
}
function setGlobalNginxEditMode(mode: 'auto' | 'custom') {
@@ -911,7 +1232,7 @@ function applyNotifySettings(settings: Record<string, string>) {
try {
const webhook = JSON.parse(settings.notify_webhook_json || '{}') as NotifyWebhookConfig
notifyWebhook.provider = webhook.provider ?? 'bark'
notifyWebhook.server = webhook.server ?? (webhook.provider === 'ntfy' ? DEFAULT_NTFY_SERVER : DEFAULT_BARK_SERVER)
notifyWebhook.server = webhook.server ?? defaultWebhookServer(webhook.provider)
notifyWebhook.topic = webhook.topic ?? ''
notifyWebhook.url = webhook.url ?? ''
notifyWebhook.has_secret = webhook.has_secret ?? false
@@ -955,6 +1276,8 @@ function applyNotifySettings(settings: Record<string, string>) {
notifyEventFlags.on_ip_frequent_access = flagOptIn('notify_on_ip_frequent_access')
notifyEventFlags.on_login_failure = flagOptIn('notify_on_login_failure')
notifyEventFlags.on_nginx_reload_failure = flagOn('notify_on_nginx_reload_failure')
notifyEventFlags.on_resource_threshold = flagOptIn('notify_on_resource_threshold')
notifyEventFlags.on_tunnel_down = flagOptIn('notify_on_tunnel_down')
} else {
const legacyDDNS = flagOn('notify_on_ddns_error')
const legacyCert = flagOn('notify_on_cert_error')
@@ -966,11 +1289,17 @@ function applyNotifySettings(settings: Record<string, string>) {
notifyEventFlags.on_nginx_reload_failure = true
notifyEventFlags.on_ip_frequent_access = false
notifyEventFlags.on_login_failure = false
notifyEventFlags.on_resource_threshold = false
notifyEventFlags.on_tunnel_down = false
}
notifyIPFrequentThreshold.value = Number(settings.notify_ip_frequent_threshold || 100)
notifyIPFrequentWindowSec.value = Number(settings.notify_ip_frequent_window_sec || 60)
notifyLoginFailureThreshold.value = Number(settings.notify_login_failure_threshold || DEFAULT_LOGIN_FAILURE_THRESHOLD)
notifyLoginFailureWindowSec.value = Number(settings.notify_login_failure_window_sec || DEFAULT_LOGIN_FAILURE_WINDOW_SEC)
notifyCPUThreshold.value = Number(settings.notify_cpu_threshold || DEFAULT_CPU_THRESHOLD)
notifyMemoryThreshold.value = Number(settings.notify_memory_threshold || DEFAULT_MEMORY_THRESHOLD)
notifyDiskThreshold.value = Number(settings.notify_disk_threshold || DEFAULT_DISK_THRESHOLD)
notifyTunnelFailThreshold.value = Number(settings.notify_tunnel_fail_threshold || DEFAULT_TUNNEL_FAIL_THRESHOLD)
}
function buildNotifyEmailJSON(): string {
@@ -1076,6 +1405,12 @@ function buildSavePayload() {
notify_on_ip_frequent_access: notifyEventFlags.on_ip_frequent_access ? '1' : '0',
notify_on_login_failure: notifyEventFlags.on_login_failure ? '1' : '0',
notify_on_nginx_reload_failure: notifyEventFlags.on_nginx_reload_failure ? '1' : '0',
notify_on_resource_threshold: notifyEventFlags.on_resource_threshold ? '1' : '0',
notify_on_tunnel_down: notifyEventFlags.on_tunnel_down ? '1' : '0',
notify_cpu_threshold: String(notifyCPUThreshold.value || DEFAULT_CPU_THRESHOLD),
notify_memory_threshold: String(notifyMemoryThreshold.value || DEFAULT_MEMORY_THRESHOLD),
notify_disk_threshold: String(notifyDiskThreshold.value || DEFAULT_DISK_THRESHOLD),
notify_tunnel_fail_threshold: String(notifyTunnelFailThreshold.value || DEFAULT_TUNNEL_FAIL_THRESHOLD),
notify_ip_frequent_threshold: String(notifyIPFrequentThreshold.value || 100),
notify_ip_frequent_window_sec: String(notifyIPFrequentWindowSec.value || 60),
notify_login_failure_threshold: String(notifyLoginFailureThreshold.value || DEFAULT_LOGIN_FAILURE_THRESHOLD),
@@ -1174,7 +1509,10 @@ async function onRestoreFile({ file }: { file: UploadFileInfo }) {
}
}
onMounted(load)
onMounted(() => {
void load()
void loadApiTokens()
})
</script>
<style scoped>
@@ -1297,6 +1635,42 @@ onMounted(load)
margin-top: 8px;
}
/* 主配置版本:左侧版本列表 + 右侧当前内容 / 差异 */
.config-versions { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--fonu-space-4); margin-top: 8px; }
.config-versions__list { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: auto; max-height: 320px; }
.config-versions__item {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
font-size: 12px; color: var(--fonu-text-secondary); cursor: pointer;
}
.config-versions__item:hover { background: var(--fonu-bg-muted); }
.config-versions__item--active { background: var(--fonu-info-soft); color: var(--fonu-text); }
.config-versions__size { flex: none; color: var(--fonu-text-muted); }
.config-versions__empty { padding: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); }
.config-versions__body { min-width: 0; display: grid; gap: var(--fonu-space-3); align-content: start; }
.config-versions__actions { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.config-versions__hint { font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
.config-versions__error { margin: 8px 0 0; font-size: 12px; color: var(--fonu-error); line-height: 1.6; }
/* API Token:创建区一行放四项,列表一行一项 */
/* 必须带上 .settings-fields 提高优先级:那条纵向 flex 规则在样式表里位置更靠后,
否则 display 会被它覆盖回 flex-column(子项被推到右侧、整卡变很高) */
.settings-fields.token-create {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}
.token-created { margin: 12px 0; }
.token-created__head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-size: 13px; }
.token-created__row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.token-created__value { flex: 1; min-width: 0; font-family: var(--fonu-mono); font-size: 12px; overflow-wrap: anywhere; }
.token-list { display: grid; gap: 8px; margin-top: 12px; }
.token-row { display: flex; align-items: center; gap: var(--fonu-space-3); padding: 10px var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
.token-row__main { display: flex; align-items: center; gap: 8px; min-width: 0; }
.token-row__name { font-weight: 600; color: var(--fonu-text); }
.token-row__meta { flex: 1; display: flex; gap: var(--fonu-space-3); flex-wrap: wrap; font-size: 12px; color: var(--fonu-text-muted); }
.global-nginx-head {
margin-top: 12px;
}