- 自动备份:每日一份(按最新备份是否在 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:
@@ -349,6 +349,18 @@ export const api = {
|
||||
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
|
||||
getFrpRouteHealthSummary: () =>
|
||||
request<import('./types').FrpRouteHealthSummary>('/api/frpmulti/routes/summary'),
|
||||
getFrpRouteUptime: (hours = 24) =>
|
||||
request<{ hours: number; items: import('./types').FrpRouteUptime[] }>(`/api/frpmulti/routes/uptime?hours=${hours}`),
|
||||
|
||||
// 指标历史:窗口内的序列已按桶取平均,拿到就能直接画
|
||||
getMetricsHistory: (hours = 24, buckets = 48) =>
|
||||
request<import('./types').MetricsHistory>(`/api/metrics/history?hours=${hours}&buckets=${buckets}`),
|
||||
|
||||
// 匿名状态页:未开启或访问码不对时后端回 404
|
||||
getStatusPage: (code?: string) =>
|
||||
request<import('./types').StatusPagePayload>(
|
||||
`/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`,
|
||||
),
|
||||
removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
|
||||
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }),
|
||||
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||
@@ -399,6 +411,28 @@ export const api = {
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
listBackupArchives: () =>
|
||||
request<{ archives: import('./types').BackupArchive[]; keep: number }>('/api/backup/archives'),
|
||||
createBackupArchive: () =>
|
||||
request<{ name: string; removed: number }>('/api/backup/archives', { method: 'POST' }),
|
||||
restoreBackupArchive: (name: string) =>
|
||||
request<{ message: string }>(`/api/backup/archives/${encodeURIComponent(name)}/restore`, { method: 'POST' }),
|
||||
deleteBackupArchive: (name: string) =>
|
||||
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
async downloadBackupArchive(name: string) {
|
||||
const response = await fetch(`/api/backup/archives/${encodeURIComponent(name)}`, { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('下载备份失败')
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = name
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
async restoreBackup(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
@@ -720,6 +720,8 @@ export interface FrpAgentRouteHealth {
|
||||
cert_ok: boolean
|
||||
cert_detail?: string
|
||||
tunnel_ok: boolean
|
||||
/** 隧道状态是否判得出来:frps 管理接口不可用时 tunnel_ok 恒为 false,但那不代表隧道不通 */
|
||||
tunnel_known?: boolean
|
||||
tunnel_name?: string
|
||||
tunnel_detail?: string
|
||||
service_ok: boolean
|
||||
@@ -732,6 +734,8 @@ export interface FrpRouteHealthSummary {
|
||||
dns_failed: number
|
||||
cert_missing: number
|
||||
tunnel_down: number
|
||||
/** frps 管理接口不可用、隧道状态判不出来的条数 */
|
||||
tunnel_unknown?: number
|
||||
service_down: number
|
||||
failed_servers?: string[]
|
||||
}
|
||||
@@ -940,3 +944,68 @@ export interface ApiToken {
|
||||
export interface CreatedApiToken extends ApiToken {
|
||||
token: string
|
||||
}
|
||||
|
||||
// 留在服务器上的备份(自动备份 + 手动「立即备份」)
|
||||
export interface BackupArchive {
|
||||
name: string
|
||||
size: number
|
||||
mod_time: string
|
||||
}
|
||||
|
||||
// 规则可用率:由后台巡检落库的状态变更推导,不是页面现算
|
||||
// (不可用区间与判不出来的时长分开给,unknown 不计入可用率分母)
|
||||
export interface FrpRouteUptimeIncident {
|
||||
from: string
|
||||
to?: string
|
||||
reason?: string
|
||||
detail?: string
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export interface FrpRouteUptime {
|
||||
server_id: number
|
||||
domain: string
|
||||
state: string
|
||||
uptime_pct: number
|
||||
down_minutes: number
|
||||
unknown_minutes: number
|
||||
covered_minutes: number
|
||||
incidents: FrpRouteUptimeIncident[]
|
||||
}
|
||||
|
||||
// 指标历史:scope=host 是本机,scope=route:<serverID>:<domain> 是单条规则的流量
|
||||
export interface MetricsPoint {
|
||||
at: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface MetricsSeries {
|
||||
scope: string
|
||||
metric: string
|
||||
points: MetricsPoint[]
|
||||
}
|
||||
|
||||
export interface MetricsHistory {
|
||||
hours: number
|
||||
buckets: number
|
||||
series: MetricsSeries[]
|
||||
}
|
||||
|
||||
// 公开状态页(匿名只读):只有域名与可用率,不含内网地址/端口/证书路径
|
||||
export interface StatusPageRoute {
|
||||
domain: string
|
||||
state: string
|
||||
uptime_pct: number
|
||||
down_minutes: number
|
||||
unknown_minutes: number
|
||||
}
|
||||
|
||||
export interface StatusPagePayload {
|
||||
title: string
|
||||
code_required: boolean
|
||||
checked_at: string
|
||||
up: number
|
||||
down: number
|
||||
unknown: number
|
||||
routes: StatusPageRoute[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<VChart class="trend-chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { formatRate } from '../utils/format'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[]
|
||||
series: { name: string; values: (number | null)[] }[]
|
||||
unit: 'percent' | 'rate'
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// 与其它图表保持同一套配色(ECharts 是色值的既有例外)
|
||||
const SERIES_COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6']
|
||||
|
||||
function axisValue(value: number): string {
|
||||
if (props.unit === 'percent') return `${Math.round(value)}%`
|
||||
return formatRate(value)
|
||||
}
|
||||
|
||||
const option = computed(() => {
|
||||
const textColor = isDark.value ? '#94a3b8' : '#9ca3af'
|
||||
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
|
||||
|
||||
return {
|
||||
grid: { left: 4, right: 12, top: 30, bottom: 6, containLabel: true },
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
icon: 'roundRect',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 14,
|
||||
textStyle: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
valueFormatter: (value: number | null | undefined) =>
|
||||
value === null || value === undefined ? '—' : axisValue(value),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
splitNumber: 3,
|
||||
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
|
||||
axisLabel: { color: textColor, fontSize: 11, formatter: (v: number) => axisValue(v) },
|
||||
},
|
||||
series: props.series.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
// 刚开始采样时只有一两个点:必须画点,否则「无线可画」看起来像没有数据
|
||||
showSymbol: props.labels.length <= 12,
|
||||
symbolSize: 5,
|
||||
// 空桶在数据里是 null:图上断开而不是补 0,避免凭空出现谷底
|
||||
connectNulls: false,
|
||||
data: item.values,
|
||||
lineStyle: { width: 2, color: SERIES_COLORS[index % SERIES_COLORS.length] },
|
||||
itemStyle: { color: SERIES_COLORS[index % SERIES_COLORS.length] },
|
||||
})),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="trend-panel" :class="{ 'trend-panel--loading': loading }">
|
||||
<p v-if="error" class="trend-panel__hint trend-panel__hint--error">{{ error }}</p>
|
||||
<template v-else>
|
||||
<section class="trend-panel__section">
|
||||
<span class="trend-panel__label">资源占用</span>
|
||||
<MetricsTrendChart :labels="resource.labels" :series="resource.series" unit="percent" />
|
||||
<p v-if="resource.labels.length === 0" class="trend-panel__hint">
|
||||
还没有资源数据:从这一版起每 2 分钟采样一次,等一两分钟就会出现。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="trend-panel__section">
|
||||
<span class="trend-panel__label">隧道流量</span>
|
||||
<MetricsTrendChart :labels="traffic.labels" :series="traffic.series" unit="rate" />
|
||||
<p v-if="traffic.labels.length === 0" class="trend-panel__hint">
|
||||
还没有流量数据(需要反代规则产生过访问)。
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import MetricsTrendChart from './MetricsTrendChart.vue'
|
||||
import { api } from '../api/client'
|
||||
import { METRICS_RANGES } from '../constants/metrics'
|
||||
import type { MetricsSeries } from '../api/types'
|
||||
|
||||
// 时间档与切换按钮都由父级(卡片表头)给,本组件只负责取数与绘制
|
||||
const props = defineProps<{ hours: number }>()
|
||||
|
||||
const seriesRaw = ref<MetricsSeries[]>([])
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const range = METRICS_RANGES.find((item) => item.hours === props.hours) ?? METRICS_RANGES[1]
|
||||
const result = await api.getMetricsHistory(range.hours, range.buckets)
|
||||
seriesRaw.value = result.series ?? []
|
||||
error.value = ''
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '读取指标历史失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatLabel(at: string): string {
|
||||
const date = new Date(at)
|
||||
if (Number.isNaN(date.getTime())) return at
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
return props.hours <= 24 ? time : `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
|
||||
}
|
||||
|
||||
function pick(items: MetricsSeries[], metric: string) {
|
||||
return items.find((item) => item.metric === metric)
|
||||
}
|
||||
|
||||
// 各序列的点数不一致(空桶不产点),所以按时间戳对齐、并集做横轴
|
||||
function unionLabels(items: MetricsSeries[]): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const item of items) {
|
||||
for (const point of item.points) set.add(point.at)
|
||||
}
|
||||
return [...set].sort()
|
||||
}
|
||||
|
||||
function valueByTime(series?: MetricsSeries) {
|
||||
const map = new Map<string, number>()
|
||||
for (const point of series?.points ?? []) map.set(point.at, point.value)
|
||||
return map
|
||||
}
|
||||
|
||||
const resource = computed(() => {
|
||||
const host = seriesRaw.value.filter((item) => item.scope === 'host')
|
||||
const labels = unionLabels(host)
|
||||
const cpu = valueByTime(pick(host, 'cpu_percent'))
|
||||
const memUsed = valueByTime(pick(host, 'mem_used_bytes'))
|
||||
const memTotal = valueByTime(pick(host, 'mem_total_bytes'))
|
||||
const diskUsed = valueByTime(pick(host, 'disk_used_bytes'))
|
||||
const diskTotal = valueByTime(pick(host, 'disk_total_bytes'))
|
||||
|
||||
const percentOf = (used: Map<string, number>, total: Map<string, number>, at: string) => {
|
||||
const u = used.get(at)
|
||||
const t = total.get(at)
|
||||
if (u === undefined || t === undefined || t <= 0) return null
|
||||
return (u / t) * 100
|
||||
}
|
||||
|
||||
return {
|
||||
labels: labels.map(formatLabel),
|
||||
series: [
|
||||
{ name: 'CPU', values: labels.map((at) => cpu.get(at) ?? null) },
|
||||
{ name: '内存', values: labels.map((at) => percentOf(memUsed, memTotal, at)) },
|
||||
{ name: '磁盘', values: labels.map((at) => percentOf(diskUsed, diskTotal, at)) },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const traffic = computed(() => {
|
||||
const routes = seriesRaw.value.filter((item) => item.scope.startsWith('route:'))
|
||||
const labels = unionLabels(routes)
|
||||
const sumByTime = (metric: string) => {
|
||||
const totals = new Map<string, number>()
|
||||
for (const item of routes) {
|
||||
if (item.metric !== metric) continue
|
||||
for (const point of item.points) {
|
||||
totals.set(point.at, (totals.get(point.at) ?? 0) + point.value)
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
const upload = sumByTime('traffic_in_rate')
|
||||
const download = sumByTime('traffic_out_rate')
|
||||
return {
|
||||
labels: labels.map(formatLabel),
|
||||
series: [
|
||||
{ name: '上传', values: labels.map((at) => upload.get(at) ?? null) },
|
||||
{ name: '下载', values: labels.map((at) => download.get(at) ?? null) },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
watch(() => props.hours, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-panel { display: flex; flex-direction: column; gap: var(--fonu-space-4); }
|
||||
.trend-panel--loading { opacity: 0.6; }
|
||||
/* 每块自带小标题,卡片被同行撑高时也不会显得内容散掉 */
|
||||
.trend-panel__section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.trend-panel__label { font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
|
||||
.trend-panel__hint { margin: 0; font-size: 12.5px; color: var(--fonu-text-muted); }
|
||||
.trend-panel__hint--error { color: var(--fonu-error); }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
// 趋势图的时间档:后端按 hours 取窗口、按 buckets 取桶数(点位在该数量级最清楚)
|
||||
export interface MetricsRange {
|
||||
label: string
|
||||
hours: number
|
||||
buckets: number
|
||||
}
|
||||
|
||||
export const METRICS_RANGES: MetricsRange[] = [
|
||||
{ label: '1 小时', hours: 1, buckets: 30 },
|
||||
{ label: '24 小时', hours: 24, buckets: 48 },
|
||||
{ label: '7 天', hours: 24 * 7, buckets: 84 },
|
||||
]
|
||||
@@ -71,6 +71,11 @@ export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
|
||||
label: 'Nginx 重载失败',
|
||||
hint: '保存反代或证书后 Nginx 未能成功重载',
|
||||
},
|
||||
{
|
||||
key: 'on_backup_failure',
|
||||
label: '自动备份失败',
|
||||
hint: '定时备份导出或校验失败——没有备份这件事,往往到需要它的那天才发现',
|
||||
},
|
||||
{
|
||||
key: 'on_resource_threshold',
|
||||
label: '资源占用过高',
|
||||
|
||||
@@ -11,12 +11,15 @@ import SettingsView from '../views/SettingsView.vue'
|
||||
import FrpView from '../views/FrpView.vue'
|
||||
import AgentView from '../views/AgentView.vue'
|
||||
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
|
||||
import StatusPageView from '../views/StatusPageView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/setup', redirect: '/login' },
|
||||
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||
// 公开状态页:挂到公网给别人看的,不经过登录守卫
|
||||
{ path: '/status', name: 'status', component: StatusPageView, meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
@@ -83,6 +86,8 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
// 公开页(状态页)不登录也能打开,也不必为它去问一次登录状态
|
||||
if (to.meta.public && to.name !== 'login') return true
|
||||
const status = await api.authStatus()
|
||||
if (to.meta.requiresAuth && !status.authenticated) return { name: 'login' }
|
||||
if (to.name === 'login' && status.authenticated) return { name: 'dashboard' }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -422,7 +422,7 @@ import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
CertificateRecord, FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic,
|
||||
FrpRouteDriftReport, FrpServer, NginxConfigVersionEntry,
|
||||
FrpRouteDriftReport, FrpRouteUptime, FrpServer, NginxConfigVersionEntry,
|
||||
} from '../api/types'
|
||||
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
@@ -447,6 +447,7 @@ const trafficErrors = ref<Record<string, string>>({})
|
||||
const agentStatus = ref<FrpAgentStatus | null>(null)
|
||||
const statusLoaded = ref(false)
|
||||
const routeDrift = ref<FrpRouteDriftReport | null>(null)
|
||||
const routeUptime = ref<FrpRouteUptime[]>([])
|
||||
const certificates = ref<CertificateRecord[]>([])
|
||||
const redeploying = ref(false)
|
||||
const settingsModal = ref(false)
|
||||
@@ -552,6 +553,7 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||
track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value }),
|
||||
track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value }),
|
||||
track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value }),
|
||||
track('可用率', () => api.getFrpRouteUptime(), (value) => { routeUptime.value = value.items ?? [] }),
|
||||
track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value }),
|
||||
track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
|
||||
proxyTraffic.value = value.items ?? {}
|
||||
@@ -611,18 +613,29 @@ function certSummary(domain: string) {
|
||||
return `Fonu 证书库:有(剩余 ${record.days_left} 天)`
|
||||
}
|
||||
|
||||
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务
|
||||
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务。
|
||||
// unknown 表示这一段判不出来(frps 管理接口未配或不可用),UI 必须与「失败」区分开,
|
||||
// 否则用户会看到一条红色的「隧道」却怎么查都没问题。
|
||||
function healthOf(domain: string) {
|
||||
const health = routeHealth.value.find((item) => item.domain === domain)
|
||||
const tunnelUnknown = health?.tunnel_known === false
|
||||
return [
|
||||
{ label: 'DNS', ok: health?.dns_ok ?? false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
|
||||
{ label: 'DNS', ok: health?.dns_ok ?? false, unknown: false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
|
||||
{
|
||||
label: '证书',
|
||||
ok: health?.cert_ok ?? false,
|
||||
unknown: false,
|
||||
detail: `${health?.cert_detail || (health?.cert_ok ? '证书已覆盖该域名' : '未发现该域名的证书')}|${certSummary(domain)}`,
|
||||
},
|
||||
{ label: '隧道', ok: health?.tunnel_ok ?? false, detail: health?.tunnel_detail || (health?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
||||
{ label: '服务', ok: health?.service_ok ?? false, detail: health?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||
{
|
||||
label: '隧道',
|
||||
ok: health?.tunnel_ok ?? false,
|
||||
unknown: tunnelUnknown,
|
||||
detail: tunnelUnknown
|
||||
? `无法判定:${health?.tunnel_detail || 'frps 管理接口未配置或不可用'}(不代表隧道不通)`
|
||||
: health?.tunnel_detail || (health?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道'),
|
||||
},
|
||||
{ label: '服务', ok: health?.service_ok ?? false, unknown: false, detail: health?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -670,10 +683,34 @@ async function redeployAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// 可用率:由后台巡检落库的状态变更推导;没有记录时返回 undefined(UI 要写「暂无数据」)
|
||||
function uptimeOf(candidate: FrpProxy) {
|
||||
const domain = domainsOf(candidate)[0]
|
||||
if (!domain) return undefined
|
||||
return routeUptime.value.find((item) => item.domain === domain)
|
||||
}
|
||||
|
||||
function uptimeDetail(stat: FrpRouteUptime) {
|
||||
const parts = [
|
||||
`可用率 ${stat.uptime_pct.toFixed(1)}%`,
|
||||
`不可用 ${stat.down_minutes} 分钟`,
|
||||
`无法判定 ${stat.unknown_minutes} 分钟`,
|
||||
`观测 ${stat.covered_minutes} 分钟`,
|
||||
]
|
||||
if (stat.incidents.length > 0) {
|
||||
const list = stat.incidents
|
||||
.map((item) => `${formatDate(item.from)}${item.to ? ` → ${formatDate(item.to)}` : ' → 至今'}(${item.reason || '未知环节'},${item.minutes} 分钟)`)
|
||||
.join(';')
|
||||
parts.push(`不可用区间:${list}`)
|
||||
}
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function isHealthy(candidate: FrpProxy) {
|
||||
const domains = domainsOf(candidate)
|
||||
if (!domains.length) return false
|
||||
return domains.every((domain) => healthOf(domain).every((item) => item.ok))
|
||||
// 判不出来的那一段不能算作「不健康」:否则 dashboard 一挂,整页规则都会变红
|
||||
return domains.every((domain) => healthOf(domain).every((item) => item.ok || item.unknown))
|
||||
}
|
||||
|
||||
// 域名快捷操作:复制 / 新窗口打开(http 规则走 http,https 规则走 https)
|
||||
@@ -741,7 +778,18 @@ const columns = computed<DataTableColumns<FrpProxy>>(() => [
|
||||
title: '健康(DNS · 证书 · 隧道 · 服务)', key: 'health', width: 260,
|
||||
render: (row) => h('div', { class: 'cell-health' }, domainsOf(row).flatMap((domain) =>
|
||||
healthOf(domain).map((item) => h('span', { title: item.detail },
|
||||
[h(StatusBadge, { kind: item.ok ? 'success' : 'error', text: item.label })])))),
|
||||
[h(StatusBadge, { kind: item.unknown ? 'warning' : item.ok ? 'success' : 'error', text: item.label })])))),
|
||||
},
|
||||
{
|
||||
title: '可用率 24h', key: 'uptime', width: 120, align: 'right',
|
||||
render: (row) => {
|
||||
const stat = uptimeOf(row)
|
||||
// 没有巡检记录时明说「暂无数据」:直接显示 100% 会让人以为一直没问题
|
||||
if (!stat) return h('span', { class: 'mono cell-muted' }, '暂无数据')
|
||||
const kind = stat.state === 'down' ? 'error' : stat.state === 'up' ? 'success' : 'warning'
|
||||
return h('span', { class: 'mono', title: uptimeDetail(stat) },
|
||||
[h(StatusBadge, { kind, text: `${stat.uptime_pct.toFixed(1)}%` })])
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '连接', key: 'conns', width: 80, align: 'right',
|
||||
|
||||
@@ -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(子项被推到右侧、整卡变很高) */
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="status-page">
|
||||
<div class="status-page__card">
|
||||
<header class="status-page__head">
|
||||
<h1 class="status-page__title">{{ page?.title || '服务状态' }}</h1>
|
||||
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
|
||||
</header>
|
||||
|
||||
<p v-if="page" class="status-page__meta">
|
||||
共 {{ page.routes.length }} 项 · 正常 {{ page.up }} · 异常 {{ page.down }} · 无法判定 {{ page.unknown }}
|
||||
<span v-if="page.checked_at">|数据截至 {{ formatDate(page.checked_at) }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="needsCode" class="status-page__code">
|
||||
<n-input v-model:value="code" placeholder="请输入访问码" @keyup.enter="load" />
|
||||
<n-button type="primary" :loading="loading" @click="load">查看</n-button>
|
||||
</div>
|
||||
|
||||
<p v-else-if="error" class="status-page__error">{{ error }}</p>
|
||||
|
||||
<div v-if="page && page.routes.length" class="status-page__list">
|
||||
<div v-for="route in page.routes" :key="route.domain" class="status-row">
|
||||
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
|
||||
<span class="status-row__domain mono">{{ route.domain }}</span>
|
||||
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="page && !error" class="status-page__hint">还没有巡检记录。</p>
|
||||
|
||||
<p class="status-page__hint status-page__hint--foot">本页只展示域名与可用率,由 Fonu 生成。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NButton, NInput } from 'naive-ui'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { api } from '../api/client'
|
||||
import type { StatusPagePayload } from '../api/types'
|
||||
import type { StatusKind } from '../utils/status'
|
||||
import { formatDate } from '../utils/format'
|
||||
|
||||
const page = ref<StatusPagePayload | null>(null)
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const needsCode = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
page.value = await api.getStatusPage(code.value.trim())
|
||||
error.value = ''
|
||||
needsCode.value = false
|
||||
} catch (err) {
|
||||
page.value = null
|
||||
// 状态页关闭与访问码错误都返回 404(不向匿名访问者确认页面是否存在)
|
||||
error.value = code.value.trim() ? '访问码不正确或状态页未开启' : '状态页未开启'
|
||||
needsCode.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function kindOf(state: string): StatusKind {
|
||||
if (state === 'up') return 'success'
|
||||
if (state === 'down') return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function stateText(state: string): string {
|
||||
if (state === 'up') return '正常'
|
||||
if (state === 'down') return '异常'
|
||||
return '无法判定'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--fonu-space-6) var(--fonu-space-4);
|
||||
background: var(--fonu-bg);
|
||||
}
|
||||
.status-page__card {
|
||||
width: min(680px, 100%);
|
||||
align-self: flex-start;
|
||||
padding: var(--fonu-space-5);
|
||||
background: var(--fonu-surface);
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius);
|
||||
box-shadow: var(--fonu-shadow);
|
||||
}
|
||||
.status-page__head { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
|
||||
.status-page__title { margin: 0; font-size: 18px; color: var(--fonu-text); }
|
||||
.status-page__meta { margin: 8px 0 0; font-size: 12.5px; color: var(--fonu-text-secondary); }
|
||||
.status-page__code { display: flex; gap: 8px; margin-top: var(--fonu-space-4); }
|
||||
.status-page__error { margin: var(--fonu-space-4) 0 0; font-size: 13px; color: var(--fonu-error); }
|
||||
.status-page__list { display: grid; gap: 6px; margin-top: var(--fonu-space-4); }
|
||||
.status-row {
|
||||
display: flex; align-items: center; gap: var(--fonu-space-3);
|
||||
padding: 8px var(--fonu-space-3);
|
||||
border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
|
||||
}
|
||||
.status-row__domain { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.status-row__uptime { flex: none; font-size: 12.5px; color: var(--fonu-text-secondary); }
|
||||
.status-page__hint { margin: var(--fonu-space-3) 0 0; font-size: 12.5px; color: var(--fonu-text-muted); }
|
||||
.status-page__hint--foot { margin-top: var(--fonu-space-5); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user