feat: 自动备份与保留策略、健康持续化与状态页、指标历史与趋势图,并修隧道判定误报与仪表盘布局(v1.2.11)
CI / verify (push) Canceled after 0s

- 自动备份:每日一份(按最新备份是否在 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:
2026-09-25 13:05:58 +08:00
parent c8264bf280
commit 74d731d0ac
46 changed files with 2967 additions and 175 deletions
+39 -25
View File
@@ -60,8 +60,10 @@
<template #value>{{ serviceHealth.value }}</template>
<p class="stat-desc">{{ serviceHealth.desc }}</p>
<div class="stat-foot">
<div class="stat-foot__line">Fonu / Nginx / DDNS / 证书</div>
<div class="stat-foot__muted">上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}</div>
<div class="stat-foot__line">Fonu · Nginx · DDNS · 证书</div>
<div class="stat-foot__muted">
运行 {{ formatUptime(status?.uptime_seconds ?? 0) }} · 上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}
</div>
</div>
</StatCard>
@@ -138,33 +140,34 @@
</div>
</FonuCard>
<FonuCard class="panel health-panel">
<FonuCard class="panel trend-card">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="PulseOutline" class="panel-title__icon panel-title__icon--teal" />
<span>运行状态</span>
<n-icon :component="StatsChartOutline" class="panel-title__icon panel-title__icon--teal" />
<span>资源与流量趋势</span>
</div>
<div class="trend-range">
<n-button
v-for="item in METRICS_RANGES"
:key="item.hours"
size="tiny"
quaternary
:type="trendHours === item.hours ? 'primary' : 'default'"
@click="trendHours = item.hours"
>
{{ item.label }}
</n-button>
</div>
<router-link :to="{ name: 'settings' }" class="card-link">
查看详情
<n-icon :component="ChevronForwardOutline" />
</router-link>
</div>
<div class="panel__body health-list">
<div v-for="item in healthItems" :key="item.name" class="health-item">
<div class="health-item__icon" :class="`health-item__icon--${item.tone}`">
<n-icon :component="item.icon" />
</div>
<div class="health-item__main">
<span class="health-item__name">{{ item.name }}</span>
<span class="health-item__meta">{{ item.desc }}</span>
<span class="health-item__detail">{{ item.meta }}</span>
</div>
<StatusBadge :value="item.status" :text="item.label" />
</div>
<div class="panel__body">
<MetricsTrendPanel :hours="trendHours" />
</div>
</FonuCard>
<FonuCard class="panel">
</div>
<div class="ops-row">
<FonuCard class="panel perf-panel">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="FlashOutline" class="panel-title__icon panel-title__icon--amber" />
@@ -201,9 +204,7 @@
</div>
</div>
</FonuCard>
</div>
<div class="ops-row">
<FonuCard class="panel">
<div class="panel__head">
<div class="panel-title">
@@ -352,6 +353,7 @@ import {
PulseOutline,
ServerOutline,
ShieldCheckmarkOutline,
StatsChartOutline,
SwapVerticalOutline,
WifiOutline,
} from '@vicons/ionicons5'
@@ -379,6 +381,11 @@ import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
import MiniBarChart from '../components/MiniBarChart.vue'
import MiniTrafficChart from '../components/MiniTrafficChart.vue'
import MetricsTrendPanel from '../components/MetricsTrendPanel.vue'
import { METRICS_RANGES } from '../constants/metrics'
// 「资源与流量趋势」的时间档:切换即让子组件按新窗口重新取数
const trendHours = ref(24)
import StatCard from '../components/StatCard.vue'
import StatusBadge from '../components/StatusBadge.vue'
import {
@@ -582,6 +589,7 @@ const remoteHealthMeta = computed(() => {
if (summary.dns_failed) issues.push(`DNS ${summary.dns_failed}`)
if (summary.cert_missing) issues.push(`证书 ${summary.cert_missing}`)
if (summary.tunnel_down) issues.push(`隧道 ${summary.tunnel_down}`)
if (summary.tunnel_unknown) issues.push(`隧道无法判定 ${summary.tunnel_unknown}`)
if (summary.service_down) issues.push(`服务 ${summary.service_down}`)
const parts = [`${summary.healthy}/${summary.routes} 条健康`]
if (issues.length > 0) parts.push(issues.join(' · '))
@@ -1392,7 +1400,7 @@ html.dark .hero-art {
.ops-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
}
@@ -1405,6 +1413,9 @@ html.dark .hero-art {
align-items: stretch;
}
/* 趋势卡片占两列:这样「性能」才能跟「系统资源」等一起排到下一行的四列里 */
.trend-card { grid-column: span 2; }
.metrics-row > .fonu-card {
height: 100%;
}
@@ -1446,6 +1457,9 @@ html.dark .hero-art {
color: #14b8a6;
}
/* 趋势卡片表头里的时间档切换(对齐「实时流量」的 range-tag 位置) */
.trend-range { display: flex; gap: 2px; }
.range-tag {
background: var(--fonu-bg) !important;
color: var(--fonu-text-secondary) !important;