feat: 自动备份与保留策略、健康持续化与状态页、指标历史与趋势图,并修隧道判定误报与仪表盘布局(v1.2.11)
- 自动备份:每日一份(按最新备份是否在 24h 内判定,重启不重复)、保留份数可配、列表/下载/恢复/删除,恢复前先校验归档;顺手修 writeArchive 逐个检查 tar/gzip/文件 Close()(审查报告 §二) - 健康持续化:只存状态变更(up/down/unknown)并推导可用率与不可用区间;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率 - 隧道判定修复:RouteHealthCandidate 新增 tunnel_known,frps 管理接口不可用时不再把「判不出来」当成「不通」(此前会误报「隧道连续不通」);汇总与徽标同样区分「无法判定」 - 指标历史:metrics_samples 纵向点表(本机 CPU/内存/磁盘 + 每规则上下行,30 天保留、查询按桶平均、不做降采样表);仪表盘趋势卡支持 1h/24h/7d - 仪表盘布局:运行状态并入顶部统计区(运行时长补进「服务」卡);性能并入系统资源那一行,整行 4 列 - 测试:backup/metrics/frp/api/app 新增用例;gofmt、go build/vet/test、vue-tsc、vite build 全绿 - agent 无改动,AgentVersion 保持 0.12.2
This commit is contained in:
@@ -106,6 +106,66 @@
|
||||
</div>
|
||||
</n-upload>
|
||||
</div>
|
||||
<div class="backup-auto">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">自动备份</div>
|
||||
<div class="form-switch-row__hint">
|
||||
每天自动备份一份到服务器,超过保留份数后自动清理最旧的(重启不会重复备份)
|
||||
</div>
|
||||
</div>
|
||||
<n-switch v-model:value="backupAutoEnabled" />
|
||||
</div>
|
||||
<div class="backup-auto__actions">
|
||||
<div class="settings-field backup-auto__keep">
|
||||
<label class="settings-field__label">保留份数</label>
|
||||
<n-input-number v-model:value="backupKeepCount" :min="1" :max="60" class="settings-field__input" />
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
:loading="backupBusy === 'create'"
|
||||
:disabled="backupBusy !== null"
|
||||
@click="createBackupArchive"
|
||||
>
|
||||
立即备份
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-list">
|
||||
<div v-for="archive in backupArchives" :key="archive.name" class="backup-row">
|
||||
<div class="backup-row__main">
|
||||
<span class="mono">{{ formatDate(archive.mod_time) }}</span>
|
||||
<span class="backup-row__size">{{ formatBytes(archive.size) }}</span>
|
||||
</div>
|
||||
<div class="backup-row__actions">
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
:loading="backupBusy === `download:${archive.name}`"
|
||||
:disabled="backupBusy !== null"
|
||||
@click="downloadBackupArchive(archive)"
|
||||
>
|
||||
下载
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="restoreBackupArchive(archive)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary type="warning" :disabled="backupBusy !== null">恢复</n-button>
|
||||
</template>
|
||||
恢复会覆盖当前的数据库、证书与 Nginx 配置,确定继续?
|
||||
</n-popconfirm>
|
||||
<n-popconfirm @positive-click="deleteBackupArchive(archive)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary type="error" :disabled="backupBusy !== null">删除</n-button>
|
||||
</template>
|
||||
删除这份备份?
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="backupError" class="field-hint field-hint--error">{{ backupError }}</p>
|
||||
<p v-else-if="backupArchives.length === 0" class="field-hint">还没有留在服务器上的备份。</p>
|
||||
</div>
|
||||
|
||||
<p class="field-hint data-note">恢复配置将覆盖当前数据,操作前请确保已备份。</p>
|
||||
</FonuCard>
|
||||
</div>
|
||||
@@ -676,6 +736,34 @@
|
||||
</div>
|
||||
<p v-else-if="!tokenError" class="field-hint">还没有令牌。</p>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard title="公开状态页" subtitle="给外部看的只读页面(默认关闭)">
|
||||
<p class="field-hint">
|
||||
开启后 <code>/status</code> 任何人都能访问,只展示域名与 24 小时可用率——不含内网地址、端口与证书路径。
|
||||
建议设一个访问码,它会让页面需要带码才能打开。
|
||||
</p>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">开启状态页</div>
|
||||
<div class="form-switch-row__hint">关闭时接口一律返回 404,且匿名访问按 IP 限流</div>
|
||||
</div>
|
||||
<n-switch v-model:value="statusPageEnabled" />
|
||||
</div>
|
||||
<div class="settings-field status-page-field">
|
||||
<ConfiguredSecretField
|
||||
v-model="statusPageCode"
|
||||
label="访问码"
|
||||
:configured="statusPageConfigured"
|
||||
placeholder="留空表示不需要访问码"
|
||||
/>
|
||||
</div>
|
||||
<div class="status-page-actions">
|
||||
<n-button size="small" :disabled="!statusPageEnabled" @click="openStatusPage">
|
||||
打开状态页
|
||||
</n-button>
|
||||
<span class="field-hint">访问码只存 SHA-256,忘了只能重设。</span>
|
||||
</div>
|
||||
</FonuCard>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
@@ -715,6 +803,7 @@ import {
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
ApiToken,
|
||||
BackupArchive,
|
||||
ChinaCIDRStatus,
|
||||
GlobalNginxView,
|
||||
NginxConfigVersionEntry,
|
||||
@@ -761,6 +850,9 @@ import { formatBytes, formatDate, formatRelativeTime } from '../utils/format'
|
||||
import type { StatusKind } from '../utils/status'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 与后端 backup.DefaultKeepCount 一致:设置项没填时两边都用 7
|
||||
const DEFAULT_BACKUP_KEEP_COUNT = 7
|
||||
const { setThemeMode } = useTheme()
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -769,6 +861,18 @@ const changingPassword = ref(false)
|
||||
const exporting = ref(false)
|
||||
const restoring = ref(false)
|
||||
|
||||
// 备份:开关与保留份数走设置项(随「保存设置」提交),备份列表单独读
|
||||
const backupAutoEnabled = ref(true)
|
||||
const backupKeepCount = ref(DEFAULT_BACKUP_KEEP_COUNT)
|
||||
const backupArchives = ref<BackupArchive[]>([])
|
||||
const backupBusy = ref<string | null>(null)
|
||||
const backupError = ref('')
|
||||
|
||||
// 公开状态页:开关与访问码走设置项(访问码按「掩码往返」处理)
|
||||
const statusPageEnabled = ref(false)
|
||||
const statusPageCode = ref('')
|
||||
const statusPageConfigured = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
theme: 'system',
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -791,6 +895,7 @@ const notifyEventFlags = reactive<Record<string, boolean>>({
|
||||
on_frps_down: false,
|
||||
on_agent_unreachable: false,
|
||||
on_route_drift: false,
|
||||
on_backup_failure: true,
|
||||
})
|
||||
const notifyIPFrequentThreshold = ref(100)
|
||||
const notifyIPFrequentWindowSec = ref(60)
|
||||
@@ -996,6 +1101,12 @@ function applySettingsToForm(settings: Record<string, string>) {
|
||||
ddnsInterval.value = Number(settings.ddns_check_interval_minutes ?? 5)
|
||||
certThreshold.value = Number(settings.cert_renew_threshold_days ?? 30)
|
||||
logRetention.value = Number(settings.log_retention_days ?? 30)
|
||||
// 未配置时按默认值处理:自动备份是默认开的
|
||||
backupAutoEnabled.value = settings.backup_auto_enabled !== '0'
|
||||
backupKeepCount.value = Number(settings.backup_keep_count || DEFAULT_BACKUP_KEEP_COUNT)
|
||||
statusPageEnabled.value = settings.status_page_enabled === '1'
|
||||
statusPageCode.value = settings.status_page_code ?? ''
|
||||
statusPageConfigured.value = statusPageCode.value === MASKED_SECRET
|
||||
acmeEmail.value = settings.acme_email ?? ''
|
||||
zerosslHasKey.value = Boolean(settings.zerossl_api_key?.trim())
|
||||
zerosslApiKey.value = ''
|
||||
@@ -1308,6 +1419,7 @@ function applyNotifySettings(settings: Record<string, string>) {
|
||||
notifyEventFlags.on_frps_down = flagOptIn('notify_on_frps_down')
|
||||
notifyEventFlags.on_agent_unreachable = flagOptIn('notify_on_agent_unreachable')
|
||||
notifyEventFlags.on_route_drift = flagOptIn('notify_on_route_drift')
|
||||
notifyEventFlags.on_backup_failure = flagOn('notify_on_backup_failure')
|
||||
} else {
|
||||
const legacyDDNS = flagOn('notify_on_ddns_error')
|
||||
const legacyCert = flagOn('notify_on_cert_error')
|
||||
@@ -1324,6 +1436,7 @@ function applyNotifySettings(settings: Record<string, string>) {
|
||||
notifyEventFlags.on_frps_down = false
|
||||
notifyEventFlags.on_agent_unreachable = false
|
||||
notifyEventFlags.on_route_drift = false
|
||||
notifyEventFlags.on_backup_failure = true
|
||||
}
|
||||
notifyIPFrequentThreshold.value = Number(settings.notify_ip_frequent_threshold || 100)
|
||||
notifyIPFrequentWindowSec.value = Number(settings.notify_ip_frequent_window_sec || 60)
|
||||
@@ -1446,9 +1559,14 @@ function buildSavePayload() {
|
||||
notify_on_frps_down: notifyEventFlags.on_frps_down ? '1' : '0',
|
||||
notify_on_agent_unreachable: notifyEventFlags.on_agent_unreachable ? '1' : '0',
|
||||
notify_on_route_drift: notifyEventFlags.on_route_drift ? '1' : '0',
|
||||
notify_on_backup_failure: notifyEventFlags.on_backup_failure ? '1' : '0',
|
||||
notify_frps_fail_threshold: String(notifyFrpsFailThreshold.value || DEFAULT_FRPS_FAIL_THRESHOLD),
|
||||
notify_agent_fail_threshold: String(notifyAgentFailThreshold.value || DEFAULT_AGENT_FAIL_THRESHOLD),
|
||||
notify_drift_cooldown_hours: String(notifyDriftCooldownHours.value || DEFAULT_DRIFT_COOLDOWN_HOURS),
|
||||
backup_auto_enabled: backupAutoEnabled.value ? '1' : '0',
|
||||
backup_keep_count: String(backupKeepCount.value || DEFAULT_BACKUP_KEEP_COUNT),
|
||||
status_page_enabled: statusPageEnabled.value ? '1' : '0',
|
||||
status_page_code: statusPageCode.value.trim(),
|
||||
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),
|
||||
@@ -1526,6 +1644,63 @@ async function changePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackupArchive() {
|
||||
if (backupBusy.value) return
|
||||
backupBusy.value = 'create'
|
||||
try {
|
||||
await api.createBackupArchive()
|
||||
await loadBackupArchives()
|
||||
message.success('已创建一份备份')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '备份失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
async function loadBackupArchives() {
|
||||
try {
|
||||
const result = await api.listBackupArchives()
|
||||
backupArchives.value = result.archives ?? []
|
||||
backupError.value = ''
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : '读取备份列表失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackupArchive(archive: BackupArchive) {
|
||||
backupBusy.value = `download:${archive.name}`
|
||||
try {
|
||||
await api.downloadBackupArchive(archive.name)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '下载失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
async function restoreBackupArchive(archive: BackupArchive) {
|
||||
backupBusy.value = `restore:${archive.name}`
|
||||
try {
|
||||
const result = await api.restoreBackupArchive(archive.name)
|
||||
message.success(result.message || '备份已恢复,建议重启 Fonu')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '恢复失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
async function deleteBackupArchive(archive: BackupArchive) {
|
||||
backupBusy.value = `delete:${archive.name}`
|
||||
try {
|
||||
await api.deleteBackupArchive(archive.name)
|
||||
await loadBackupArchives()
|
||||
message.success('已删除该备份')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
// 存了访问码时会先要求输入,所以这里只开页面、不带参数
|
||||
function openStatusPage() {
|
||||
window.open('/status', '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function exportBackup() {
|
||||
exporting.value = true
|
||||
try {
|
||||
@@ -1554,6 +1729,7 @@ async function onRestoreFile({ file }: { file: UploadFileInfo }) {
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadApiTokens()
|
||||
void loadBackupArchives()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1694,6 +1870,27 @@ onMounted(() => {
|
||||
.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; }
|
||||
|
||||
/* 自动备份:开关 + 保留份数 + 备份列表 */
|
||||
.backup-auto {
|
||||
display: grid; gap: var(--fonu-space-3);
|
||||
margin-top: var(--fonu-space-4); padding-top: var(--fonu-space-4);
|
||||
border-top: 1px solid var(--fonu-border);
|
||||
}
|
||||
.backup-auto__actions { display: flex; align-items: end; gap: var(--fonu-space-3); }
|
||||
.backup-auto__keep { max-width: 160px; }
|
||||
.backup-list { display: grid; gap: 6px; margin-top: var(--fonu-space-3); }
|
||||
.backup-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3);
|
||||
padding: 6px var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
|
||||
}
|
||||
.backup-row__main { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||
.backup-row__size { font-size: 12px; color: var(--fonu-text-muted); }
|
||||
.backup-row__actions { display: flex; align-items: center; gap: 4px; flex: none; }
|
||||
|
||||
/* 公开状态页:访问码 + 打开链接 */
|
||||
.status-page-field { margin-top: var(--fonu-space-3); }
|
||||
.status-page-actions { display: flex; align-items: center; gap: var(--fonu-space-3); margin-top: var(--fonu-space-3); flex-wrap: wrap; }
|
||||
|
||||
/* API Token:创建区一行放四项,列表一行一项 */
|
||||
/* 必须带上 .settings-fields 提高优先级:那条纵向 flex 规则在样式表里位置更靠后,
|
||||
否则 display 会被它覆盖回 flex-column(子项被推到右侧、整卡变很高) */
|
||||
|
||||
Reference in New Issue
Block a user