refactor(web): 统一前端轮询组合式函数并发布 v1.5.4

This commit is contained in:
2026-09-28 16:06:52 +08:00
parent 21efc10536
commit 8481bc3ac8
16 changed files with 287 additions and 211 deletions
+17
View File
@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.5.4] - 2026-09-28
### 变更
- **P1-5 统一前端轮询组合式函数**:`useVisibilityPolling` 重写为支持 `enabled` / `immediate` / `onError` 选项,加入 in-flight 互斥,返回 `start` / `stop` / `busy` / `lastError`;核心逻辑抽成 `createVisibilityPoller`,可在无 DOM 环境下测试
- Dashboard、Proxy、Logs、Settings、Frp、UpdateModal、Agent 的轮询全部迁移到组合式函数;仅 AppLayout 时钟保留原生计时器
- 慢接口不再堆积:上一轮未结束时跳过下一轮;页面隐藏时暂停,回到前台立即补一次
### 测试
- 新增 6 项组合式函数单元测试:隐藏不执行、回到前台补一次、in-flight 互斥、`enabled=false`、卸载清理、`onError` 后继续轮询
- `npm run typecheck`、`npm test`(25 项)、`npm run build` 通过
### 变更
- 版本号提升到 1.5.4;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
## [v1.5.3] - 2026-09-28 ## [v1.5.3] - 2026-09-28
### 新增 ### 新增
+1 -1
View File
@@ -1 +1 @@
1.5.3 1.5.4
+1 -1
View File
@@ -1,7 +1,7 @@
# Fonu 文档索引 # Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.5.3) > 最近更新:2026-09-28(v1.5.4)
## 方案与设计(本目录) ## 方案与设计(本目录)
+2
View File
@@ -936,6 +936,8 @@ internal/frp/agent_versions_compat_test.go
## 4.5 P1-5:统一前端轮询组合式函数 ## 4.5 P1-5:统一前端轮询组合式函数
> 实施状态:2026-09-28 已完成(v1.5.4)。`useVisibilityPolling` 增加 in-flight 互斥、`enabled` / `immediate` / `onError` 选项与 `busy` / `lastError` 返回值;Dashboard、Proxy、Logs、Settings、Frp、UpdateModal、Agent 的轮询已全部迁移,仅 AppLayout 时钟保留原生计时器。
### 4.5.1 现状 ### 4.5.1 现状
已有 `web/src/composables/useVisibilityPolling.ts`,并被: 已有 `web/src/composables/useVisibilityPolling.ts`,并被:
+4 -4
View File
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
## 10. 在线升级验证 ## 10. 在线升级验证
示例页面用于验证升级结果。v1.5.3 的验证流程: 示例页面用于验证升级结果。v1.5.4 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.3`。 2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.4`。
3. 在侧栏看到「一键升级」后点击并确认。 3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。 4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「示例页面」,确认: 5. 打开侧栏「示例页面」,确认:
- 页面路由为 `/example`; - 页面路由为 `/example`;
- 页面显示的版本为 `1.5.3`; - 页面显示的版本为 `1.5.4`;
- 页面显示验证标识 `v1.5.3-example-page`; - 页面显示验证标识 `v1.5.4-example-page`;
- 页面显示独立的「本次升级验证」区块; - 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。 - 「升级验证」显示为「通过」。
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"version": "1.5.3", "version": "1.5.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fonu-web", "name": "fonu-web",
"version": "1.5.3", "version": "1.5.4",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1", "@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"private": true, "private": true,
"version": "1.5.3", "version": "1.5.4",
"packageManager": "npm@10.9.4", "packageManager": "npm@10.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
+3 -17
View File
@@ -52,9 +52,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { NAlert, NButton, NModal, useMessage } from 'naive-ui' import { NAlert, NButton, NModal, useMessage } from 'naive-ui'
import { api } from '../api/client' import { api } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { UpdateStatus } from '../api/types' import type { UpdateStatus } from '../api/types'
const show = defineModel<boolean>('show', { required: true }) const show = defineModel<boolean>('show', { required: true })
@@ -66,7 +67,6 @@ const appVersion = ref(__APP_VERSION__)
const loading = ref(false) const loading = ref(false)
const applying = ref(false) const applying = ref(false)
const localMessage = ref('') const localMessage = ref('')
let pollTimer: number | undefined
const modeLabel = computed(() => { const modeLabel = computed(() => {
if (status.value?.mode === 'git') return 'Git 仓库构建' if (status.value?.mode === 'git') return 'Git 仓库构建'
@@ -115,24 +115,17 @@ async function startUpdate() {
localMessage.value = '升级任务已启动,正在等待容器重建…' localMessage.value = '升级任务已启动,正在等待容器重建…'
try { try {
status.value = await api.applyUpdate() status.value = await api.applyUpdate()
startPolling()
} catch (error) { } catch (error) {
applying.value = false applying.value = false
localMessage.value = error instanceof Error ? error.message : '启动升级失败' localMessage.value = error instanceof Error ? error.message : '启动升级失败'
} }
} }
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => void pollStatus(), 2000)
}
async function pollStatus() { async function pollStatus() {
try { try {
status.value = await api.getUpdateStatus() status.value = await api.getUpdateStatus()
localMessage.value = '' localMessage.value = ''
if (status.value.phase === 'success') { if (status.value.phase === 'success') {
stopPolling()
applying.value = false applying.value = false
message.success('Fonu 已升级完成') message.success('Fonu 已升级完成')
emit('updated') emit('updated')
@@ -140,7 +133,6 @@ async function pollStatus() {
return return
} }
if (status.value.phase === 'failed') { if (status.value.phase === 'failed') {
stopPolling()
applying.value = false applying.value = false
message.error(status.value.message || '升级失败') message.error(status.value.message || '升级失败')
} }
@@ -149,18 +141,12 @@ async function pollStatus() {
} }
} }
function stopPolling() {
if (pollTimer === undefined) return
window.clearInterval(pollTimer)
pollTimer = undefined
}
watch(show, (visible) => { watch(show, (visible) => {
if (!visible) return if (!visible) return
void loadStatus() void loadStatus()
}) })
onBeforeUnmount(stopPolling) useVisibilityPolling(pollStatus, 2000, { enabled: applying })
</script> </script>
<style scoped> <style scoped>
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { createVisibilityPoller, type PollingHost } from './useVisibilityPolling'
function fakeHost() {
let hidden = false
let intervalFn: (() => void) | null = null
let intervalId = 0
const listeners: Array<() => void> = []
const host: PollingHost = {
setInterval: (fn) => {
intervalFn = fn
return ++intervalId
},
clearInterval: () => {
intervalFn = null
},
isHidden: () => hidden,
addVisibilityListener: (fn) => {
listeners.push(fn)
},
removeVisibilityListener: (fn) => {
const index = listeners.indexOf(fn)
if (index >= 0) listeners.splice(index, 1)
},
}
return {
host,
setHidden: (value: boolean) => {
hidden = value
},
fireInterval: () => intervalFn?.(),
fireVisibility: () => listeners.forEach((fn) => fn()),
hasTimer: () => intervalFn !== null,
listenerCount: () => listeners.length,
}
}
describe('createVisibilityPoller', () => {
it('页面隐藏时不执行', async () => {
const h = fakeHost()
let calls = 0
const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host)
h.setHidden(true)
await poller.tick()
expect(calls).toBe(0)
})
it('回到前台立即补一次', async () => {
const h = fakeHost()
let calls = 0
const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host)
poller.attach()
h.fireVisibility()
await Promise.resolve()
expect(calls).toBe(1)
})
it('上一轮未结束时跳过下一轮', async () => {
const h = fakeHost()
let calls = 0
let release: () => void = () => {}
const pending = new Promise<void>((resolve) => { release = resolve })
const poller = createVisibilityPoller(() => {
calls++
return calls === 1 ? pending : Promise.resolve()
}, 1000, {}, h.host)
const first = poller.tick()
await Promise.resolve()
await poller.tick()
expect(calls).toBe(1)
release()
await first
await poller.tick()
expect(calls).toBe(2)
})
it('enabled=false 时不执行', async () => {
const h = fakeHost()
const enabled = ref(false)
let calls = 0
const poller = createVisibilityPoller(() => { calls++ }, 1000, { enabled }, h.host)
await poller.tick()
expect(calls).toBe(0)
enabled.value = true
await poller.tick()
expect(calls).toBe(1)
})
it('卸载后不再保留计时器与监听', () => {
const h = fakeHost()
const poller = createVisibilityPoller(() => {}, 1000, {}, h.host)
poller.attach()
expect(h.hasTimer()).toBe(true)
expect(h.listenerCount()).toBe(1)
poller.detach()
expect(h.hasTimer()).toBe(false)
expect(h.listenerCount()).toBe(0)
})
it('onError 被调用且不影响后续轮询', async () => {
const h = fakeHost()
const errors: unknown[] = []
let calls = 0
const poller = createVisibilityPoller(() => {
calls++
if (calls === 1) throw new Error('boom')
}, 1000, { onError: (error) => errors.push(error) }, h.host)
await poller.tick()
expect(errors).toHaveLength(1)
expect(poller.lastError.value).toBeInstanceOf(Error)
await poller.tick()
expect(calls).toBe(2)
expect(poller.lastError.value).toBeNull()
})
})
+96 -25
View File
@@ -1,44 +1,115 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue' import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
export interface VisibilityPollingOptions {
/** 为 false 时整轮跳过(例如未选中服务端、弹窗未打开)。 */
enabled?: Ref<boolean>
/** 挂载后是否立即执行一次。 */
immediate?: boolean
/** 轮询回调抛错时调用;不传则只记录到 lastError。 */
onError?: (error: unknown) => void
}
/** 注入的宿主接口,便于在无 DOM 环境下测试轮询核心。 */
export interface PollingHost {
setInterval: (fn: () => void, ms: number) => number
clearInterval: (id: number) => void
isHidden: () => boolean
addVisibilityListener: (fn: () => void) => void
removeVisibilityListener: (fn: () => void) => void
}
export interface VisibilityPoller {
start: () => void
stop: () => void
tick: () => Promise<void>
attach: () => void
detach: () => void
busy: Ref<boolean>
lastError: Ref<unknown>
}
function browserPollingHost(): PollingHost {
return {
setInterval: (fn, ms) => window.setInterval(fn, ms),
clearInterval: (id) => window.clearInterval(id),
isHidden: () => document.visibilityState === 'hidden',
addVisibilityListener: (fn) => document.addEventListener('visibilitychange', fn),
removeVisibilityListener: (fn) => document.removeEventListener('visibilitychange', fn),
}
}
/** /**
* 页面可见时才轮询:标签页隐藏期间跳过这一轮,回到前台立即补一次。 * 可测试的轮询核心:隐藏时跳过、上一轮未结束时跳过、回到前台立即补一次。
* * 不依赖 Vue 生命周期,宿主能力全部由 host 注入。
* 各页原来的写法是「自己 setInterval + 在回调里判断 document.visibilityState」,
* 但只有仪表盘这么做了,其余页面在后台标签页里仍在持续打接口(公网反代页每轮 7 个请求)。
*
* @param fn 每轮执行的回调;由调用方自行保证幂等与「无数据时直接 return」
* @param intervalMs 轮询间隔
* @param enabled 可选开关,为 false 时整轮跳过(例如未选中服务端)
*/ */
export function useVisibilityPolling(fn: () => void | Promise<void>, intervalMs: number, enabled?: Ref<boolean>) { export function createVisibilityPoller(
fn: () => void | Promise<void>,
intervalMs: number,
options: VisibilityPollingOptions = {},
host: PollingHost = browserPollingHost(),
): VisibilityPoller {
const busy = ref(false)
const lastError = ref<unknown>(null)
let timer: number | undefined let timer: number | undefined
const tick = () => { const tick = async () => {
if (document.visibilityState === 'hidden') return if (host.isHidden()) return
if (enabled && !enabled.value) return if (options.enabled && !options.enabled.value) return
void fn() if (busy.value) return
busy.value = true
try {
await fn()
lastError.value = null
} catch (error) {
lastError.value = error
options.onError?.(error)
} finally {
busy.value = false
}
}
const start = () => {
if (timer !== undefined) return
timer = host.setInterval(() => void tick(), intervalMs)
} }
const stop = () => { const stop = () => {
if (timer === undefined) return if (timer === undefined) return
window.clearInterval(timer) host.clearInterval(timer)
timer = undefined timer = undefined
} }
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') return if (host.isHidden()) return
tick() // 回到前台立刻补一次,不用等下一个周期 void tick()
} }
onMounted(() => { const attach = () => {
document.addEventListener('visibilitychange', onVisibilityChange) host.addVisibilityListener(onVisibilityChange)
timer = window.setInterval(tick, intervalMs) start()
}) }
onBeforeUnmount(() => { const detach = () => {
document.removeEventListener('visibilitychange', onVisibilityChange) host.removeVisibilityListener(onVisibilityChange)
stop() stop()
}) }
return { stop } return { start, stop, tick, attach, detach, busy, lastError }
}
/**
* 页面可见时才轮询:隐藏期间跳过,回到前台立即补一次,上一轮未结束时跳过下一轮。
*/
export function useVisibilityPolling(
fn: () => void | Promise<void>,
intervalMs: number,
options: VisibilityPollingOptions = {},
): VisibilityPoller {
const poller = createVisibilityPoller(fn, intervalMs, options)
onMounted(() => {
poller.attach()
if (options.immediate) void poller.tick()
})
onBeforeUnmount(() => poller.detach())
return poller
} }
+9 -59
View File
@@ -353,7 +353,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, onUnmounted, ref } from 'vue' import { computed, h, onMounted, ref } from 'vue'
import { NButton, NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui' import { NButton, NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui'
import { import {
AlertCircleOutline, AlertCircleOutline,
@@ -375,6 +375,7 @@ import {
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import bannerImg from '../assets/brand/banner.png' import bannerImg from '../assets/brand/banner.png'
import { api, asList } from '../api/client' import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { import type {
AccessLogEntry, AccessLogEntry,
CertificateRecord, CertificateRecord,
@@ -440,8 +441,6 @@ const trafficByRule = ref<Record<number, ProxyTraffic>>({})
type RatePoint = { at: number; upload: number; download: number } type RatePoint = { at: number; upload: number; download: number }
const rateHistory = ref<RatePoint[]>([]) const rateHistory = ref<RatePoint[]>([])
const RATE_HISTORY_MS = 5 * 60 * 1000 const RATE_HISTORY_MS = 5 * 60 * 1000
let trafficTimer: ReturnType<typeof setInterval> | null = null
let statusTimer: ReturnType<typeof setInterval> | null = null
// 实时流量的数据源:本机(Fonu 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数) // 实时流量的数据源:本机(Fonu 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数)
type TrafficSource = 'local' | 'public' type TrafficSource = 'local' | 'public'
@@ -898,18 +897,6 @@ async function ensureFrpProxyNames() {
} }
} }
function startTrafficPoll() {
stopTrafficPoll()
trafficTimer = setInterval(refreshTraffic, 2000)
}
function stopTrafficPoll() {
if (trafficTimer) {
clearInterval(trafficTimer)
trafficTimer = null
}
}
// CPU 占用率需要两次采样才有意义,因此 /api/status 每 15 秒刷新一次 // CPU 占用率需要两次采样才有意义,因此 /api/status 每 15 秒刷新一次
async function refreshStatus() { async function refreshStatus() {
try { try {
@@ -919,29 +906,6 @@ async function refreshStatus() {
} }
} }
function startStatusPoll() {
stopStatusPoll()
statusTimer = setInterval(refreshStatus, 15000)
}
function stopStatusPoll() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
// 页面隐藏时暂停轮询,避免后台标签页持续请求
function handleVisibilityChange() {
if (document.hidden) {
stopTrafficPoll()
stopStatusPoll()
return
}
startTrafficPoll()
startStatusPoll()
}
// 系统资源:/api/status 返回的宿主机 CPU / 内存 / 磁盘(容器内为宿主机视角) // 系统资源:/api/status 返回的宿主机 CPU / 内存 / 磁盘(容器内为宿主机视角)
const resourcesAvailable = computed( const resourcesAvailable = computed(
() => (status.value?.mem_total_bytes ?? 0) > 0 || (status.value?.disk_total_bytes ?? 0) > 0, () => (status.value?.mem_total_bytes ?? 0) > 0 || (status.value?.disk_total_bytes ?? 0) > 0,
@@ -1033,27 +997,20 @@ async function loadChinaCIDR() {
} }
// 中国 IP 段更新是后端后台任务:刷新后按 2 秒轮询直到就绪或结束 // 中国 IP 段更新是后端后台任务:刷新后按 2 秒轮询直到就绪或结束
let cidrPollTimer: ReturnType<typeof setInterval> | null = null const cidrPolling = ref(false)
function stopCIDRPoll() {
if (cidrPollTimer) {
clearInterval(cidrPollTimer)
cidrPollTimer = null
}
}
async function pollCIDR() { async function pollCIDR() {
await loadChinaCIDR() await loadChinaCIDR()
const data = chinaCIDR.value const data = chinaCIDR.value
if (!data || data.ready || !data.updating) { if (!data || data.ready || !data.updating) {
stopCIDRPoll() cidrPolling.value = false
if (data?.ready) message.success('中国 IP 段已更新') if (data?.ready) message.success('中国 IP 段已更新')
} }
} }
async function refreshCIDR() { async function refreshCIDR() {
refreshingCIDR.value = true refreshingCIDR.value = true
stopCIDRPoll() cidrPolling.value = false
try { try {
chinaCIDR.value = await api.refreshChinaCIDR() chinaCIDR.value = await api.refreshChinaCIDR()
if (chinaCIDR.value.ready) { if (chinaCIDR.value.ready) {
@@ -1061,7 +1018,7 @@ async function refreshCIDR() {
`中国 IP 段已更新:IPv4 ${chinaCIDR.value.entry_count_v4} 条,IPv6 ${chinaCIDR.value.entry_count_v6} 条`, `中国 IP 段已更新:IPv4 ${chinaCIDR.value.entry_count_v4} 条,IPv6 ${chinaCIDR.value.entry_count_v6} 条`,
) )
} else if (chinaCIDR.value.updating) { } else if (chinaCIDR.value.updating) {
cidrPollTimer = setInterval(() => void pollCIDR(), 2000) cidrPolling.value = true
} else { } else {
message.warning('更新完成,但数据尚未就绪,请查看错误信息') message.warning('更新完成,但数据尚未就绪,请查看错误信息')
} }
@@ -1188,18 +1145,11 @@ async function load() {
onMounted(() => { onMounted(() => {
load() load()
refreshTraffic()
startTrafficPoll()
startStatusPoll()
document.addEventListener('visibilitychange', handleVisibilityChange)
}) })
onUnmounted(() => { useVisibilityPolling(refreshTraffic, 2000, { immediate: true })
stopTrafficPoll() useVisibilityPolling(refreshStatus, 15000, { immediate: true })
stopStatusPoll() useVisibilityPolling(pollCIDR, 2000, { enabled: cidrPolling })
stopCIDRPoll()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
</script> </script>
<style scoped> <style scoped>
+8 -8
View File
@@ -8,7 +8,7 @@
</template> </template>
</PageHeader> </PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.3 示例页面验证标识已生效"> <n-alert type="success" :bordered="false" class="example-alert" title="v1.5.4 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。 当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert> </n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong> <strong class="example-proof__value">{{ verificationRound }}</strong>
</div> </div>
<div class="example-proof__hint"> <div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.3。 看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.4。
</div> </div>
</div> </div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small> <StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template> <template #icon><n-icon :component="FlashOutline" /></template>
</StatCard> </StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.5.3 前端资源已加载" tone="teal"> <StatCard label="页面状态" value="已更新" sub="v1.5.4 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template> <template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard> </StatCard>
</div> </div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化"> <FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes"> <ul class="example-changes">
<li>页面版本提示更新为 v1.5.3。</li> <li>页面版本提示更新为 v1.5.4。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.3。</li> <li>「本次升级验证」区块和页面状态同步更新为 v1.5.4。</li>
<li>验证标识更新为 <span class="mono">v1.5.3-example-page</span>。</li> <li>验证标识更新为 <span class="mono">v1.5.4-example-page</span>。</li>
</ul> </ul>
</FonuCard> </FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__) const version = ref(__APP_VERSION__)
const checkedAt = ref('') const checkedAt = ref('')
const refreshing = ref(false) const refreshing = ref(false)
const verificationRound = 'v1.5.3' const verificationRound = 'v1.5.4'
const verificationMarker = 'v1.5.3-example-page' const verificationMarker = 'v1.5.4-example-page'
async function refreshVersion() { async function refreshVersion() {
refreshing.value = true refreshing.value = true
+11 -24
View File
@@ -642,7 +642,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, type VNode } from 'vue' import { computed, h, onMounted, reactive, ref, type VNode } from 'vue'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
import type { FrpProxyTraffic, FrpProxiesTraffic } from '../api/types' import type { FrpProxyTraffic, FrpProxiesTraffic } from '../api/types'
import { NButton, NIcon, NSpace, NSwitch, NTag, useDialog, useMessage } from 'naive-ui' import { NButton, NIcon, NSpace, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
@@ -652,12 +652,12 @@ import {
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { api, asList } from '../api/client' import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import { formatBytes, formatDate, formatRate } from '../utils/format' import { formatBytes, formatDate, formatRate } from '../utils/format'
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types' import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types'
import FonuCard from '../components/FonuCard.vue' import FonuCard from '../components/FonuCard.vue'
import EmptyState from '../components/EmptyState.vue' import EmptyState from '../components/EmptyState.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import LoadError from '../components/LoadError.vue' import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue' import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue' import StatusBadge from '../components/StatusBadge.vue'
@@ -699,7 +699,7 @@ const downloadProxyOptions = [
{ label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' }, { label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' },
] ]
const editingProxy = ref<FrpProxy | null>(null) const editingProxy = ref<FrpProxy | null>(null)
let downloadTimer: number | undefined const downloadPolling = ref(false)
function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } { function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } {
return { transport: {}, health_check: {}, load_balancer: {}, nat_traversal: { disable_assisted_addrs: false }, allow_ips_text: '' } return { transport: {}, health_check: {}, load_balancer: {}, nat_traversal: { disable_assisted_addrs: false }, allow_ips_text: '' }
@@ -865,7 +865,7 @@ const proxyLogsLoading = ref(false)
// 详情弹窗的实时速率采样,复用列表页 5 秒轮询更新的 proxyTraffic 快照 // 详情弹窗的实时速率采样,复用列表页 5 秒轮询更新的 proxyTraffic 快照
const proxyRateHistory = ref<Array<{ at: number; upload: number; download: number }>>([]) const proxyRateHistory = ref<Array<{ at: number; upload: number; download: number }>>([])
let proxyRateTimer: ReturnType<typeof setInterval> | null = null const proxyRatePolling = computed(() => showProxyDetail.value)
const proxyRateChart = computed(() => { const proxyRateChart = computed(() => {
const now = Date.now() const now = Date.now()
@@ -909,14 +909,10 @@ function openProxyDetail(proxy: FrpProxy, tab: 'overview' | 'logs' = 'overview')
proxyRateHistory.value = [] proxyRateHistory.value = []
recordProxyRateSample() recordProxyRateSample()
if (tab === 'logs') void loadProxyLogs(proxy.id) if (tab === 'logs') void loadProxyLogs(proxy.id)
// 弹窗打开期间每 5 秒补一个速率采样点,支撑最近 5 分钟实时流量图
if (proxyRateTimer) window.clearInterval(proxyRateTimer)
proxyRateTimer = window.setInterval(recordProxyRateSample, 5_000)
} }
function closeProxyDetail() { function closeProxyDetail() {
showProxyDetail.value = false showProxyDetail.value = false
if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null }
} }
async function loadProxyLogs(proxyID: number) { async function loadProxyLogs(proxyID: number) {
@@ -1045,7 +1041,7 @@ async function loadRuntimeConfig() {
binaries.value = asList(nextBinaries) binaries.value = asList(nextBinaries)
releases.value = asList(nextReleases) releases.value = asList(nextReleases)
downloadTask.value = nextDownloadTask.status === 'completed' ? emptyDownloadTask() : nextDownloadTask downloadTask.value = nextDownloadTask.status === 'completed' ? emptyDownloadTask() : nextDownloadTask
ensureDownloadPolling() downloadPolling.value = downloadInProgress.value
} catch (error) { message.error(error instanceof Error ? error.message : '读取 FRP 应用配置失败') } } catch (error) { message.error(error instanceof Error ? error.message : '读取 FRP 应用配置失败') }
finally { runtimeLoading.value = false } finally { runtimeLoading.value = false }
} }
@@ -1078,21 +1074,16 @@ async function downloadRelease(version: string) {
downloadingVersion.value = version downloadingVersion.value = version
try { try {
downloadTask.value = await api.downloadFrpRelease(version) downloadTask.value = await api.downloadFrpRelease(version)
ensureDownloadPolling() downloadPolling.value = downloadInProgress.value
} catch (error) { message.error(error instanceof Error ? error.message : '下载 FRP 客户端失败') } } catch (error) { message.error(error instanceof Error ? error.message : '下载 FRP 客户端失败') }
finally { downloadingVersion.value = null } finally { downloadingVersion.value = null }
} }
function ensureDownloadPolling() {
if (!downloadInProgress.value || downloadTimer) return
downloadTimer = window.setInterval(refreshDownloadTask, 500)
}
async function refreshDownloadTask() { async function refreshDownloadTask() {
try { try {
downloadTask.value = await api.getFrpDownloadStatus() downloadTask.value = await api.getFrpDownloadStatus()
if (downloadInProgress.value) return if (downloadInProgress.value) return
stopDownloadPolling() downloadPolling.value = false
if (downloadTask.value.status === 'completed') { if (downloadTask.value.status === 'completed') {
message.success(`${downloadTask.value.version} 已下载并完成校验`) message.success(`${downloadTask.value.version} 已下载并完成校验`)
await loadRuntimeConfig() await loadRuntimeConfig()
@@ -1101,17 +1092,11 @@ async function refreshDownloadTask() {
} }
if (downloadTask.value.status === 'failed') message.error(downloadTask.value.error || '下载 FRP 客户端失败') if (downloadTask.value.status === 'failed') message.error(downloadTask.value.error || '下载 FRP 客户端失败')
} catch (error) { } catch (error) {
stopDownloadPolling() downloadPolling.value = false
message.error(error instanceof Error ? error.message : '读取下载进度失败') message.error(error instanceof Error ? error.message : '读取下载进度失败')
} }
} }
function stopDownloadPolling() {
if (!downloadTimer) return
window.clearInterval(downloadTimer)
downloadTimer = undefined
}
function emptyDownloadTask(): FrpDownloadTask { function emptyDownloadTask(): FrpDownloadTask {
return { status: 'idle', message: '', downloaded: 0, total: 0 } return { status: 'idle', message: '', downloaded: 0, total: 0 }
} }
@@ -1211,7 +1196,9 @@ function proxyTransportTags(proxy: FrpProxy) {
useVisibilityPolling(() => load(), 5_000) useVisibilityPolling(() => load(), 5_000)
onMounted(async () => { await load() }) onMounted(async () => { await load() })
onUnmounted(() => { if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() })
useVisibilityPolling(recordProxyRateSample, 5000, { enabled: proxyRatePolling })
useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
</script> </script>
<style scoped> <style scoped>
+3 -9
View File
@@ -162,7 +162,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, defineComponent, h, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { import {
NButton, NButton,
@@ -177,6 +177,7 @@ import {
type DataTableColumns, type DataTableColumns,
} from 'naive-ui' } from 'naive-ui'
import { api, asList } from '../api/client' import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types' import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
import EmptyState from '../components/EmptyState.vue' import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue' import FonuCard from '../components/FonuCard.vue'
@@ -296,7 +297,6 @@ function resolveTab(queryTab: unknown): LogTab {
const tab = ref(resolveTab(route.query.tab)) const tab = ref(resolveTab(route.query.tab))
const pageError = ref('') const pageError = ref('')
const autoRefresh = ref(false) const autoRefresh = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const accessLogs = ref<AccessLogEntry[]>([]) const accessLogs = ref<AccessLogEntry[]>([])
const proxyRules = ref<ProxyRule[]>([]) const proxyRules = ref<ProxyRule[]>([])
@@ -802,10 +802,7 @@ watch([frpKeyword, frpLevel], () => {
frpPage.value = 1 frpPage.value = 1
}) })
watch(autoRefresh, (on) => { useVisibilityPolling(() => loadAccess(), 10000, { enabled: autoRefresh })
if (refreshTimer) clearInterval(refreshTimer)
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)
})
watch( watch(
() => route.query.tab, () => route.query.tab,
@@ -824,9 +821,6 @@ onMounted(() => {
tab.value = resolveTab(route.query.tab) tab.value = resolveTab(route.query.tab)
loadAll() loadAll()
}) })
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script> </script>
<style scoped> <style scoped>
+6 -36
View File
@@ -860,6 +860,7 @@ import {
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { api, asList } from '../api/client' import { api, asList } from '../api/client'
import type { ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types' import type { ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue' import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue' import FonuCard from '../components/FonuCard.vue'
import ProxyAccessLogBox from '../components/ProxyAccessLogBox.vue' import ProxyAccessLogBox from '../components/ProxyAccessLogBox.vue'
@@ -912,8 +913,6 @@ const clientsLoading = ref(false)
type RatePoint = { at: number; upload: number; download: number } type RatePoint = { at: number; upload: number; download: number }
const rateHistory = ref<RatePoint[]>([]) const rateHistory = ref<RatePoint[]>([])
const RATE_HISTORY_MS = 5 * 60 * 1000 const RATE_HISTORY_MS = 5 * 60 * 1000
let trafficTimer: ReturnType<typeof setInterval> | null = null
let clientsTimer: ReturnType<typeof setInterval> | null = null
const nginxHttpPort = ref(80) const nginxHttpPort = ref(80)
const nginxHttpsPort = ref(443) const nginxHttpsPort = ref(443)
@@ -1794,18 +1793,6 @@ async function refreshAll() {
await Promise.all([load(), refreshTraffic()]) await Promise.all([load(), refreshTraffic()])
} }
function startTrafficPoll() {
stopTrafficPoll()
trafficTimer = setInterval(refreshTraffic, 2000)
}
function stopTrafficPoll() {
if (trafficTimer) {
clearInterval(trafficTimer)
trafficTimer = null
}
}
async function loadClients() { async function loadClients() {
const rule = selectedRule.value const rule = selectedRule.value
if (!rule) return if (!rule) return
@@ -1819,25 +1806,8 @@ async function loadClients() {
} }
} }
function startClientsPoll() {
stopClientsPoll()
void loadClients()
clientsTimer = setInterval(loadClients, 5000)
}
function stopClientsPoll() {
if (clientsTimer) {
clearInterval(clientsTimer)
clientsTimer = null
}
}
watch([showDetailPanel, selectedRuleId, detailTab], ([visible, id, tab]) => { watch([showDetailPanel, selectedRuleId, detailTab], ([visible, id, tab]) => {
if (!visible || !id || tab !== 'overview') { if (visible && id && tab === 'overview') void loadClients()
stopClientsPoll()
return
}
startClientsPoll()
}) })
async function loadDefaults() { async function loadDefaults() {
@@ -2191,17 +2161,17 @@ watch(
onMounted(async () => { onMounted(async () => {
await loadDefaults() await loadDefaults()
await load() await load()
await refreshTraffic()
startTrafficPoll()
}) })
onUnmounted(() => { onUnmounted(() => {
destroyRowSortable() destroyRowSortable()
stopLogStream() stopLogStream()
stopTrafficPoll()
stopClientsPoll()
document.removeEventListener('keydown', onLogFullscreenKeydown) document.removeEventListener('keydown', onLogFullscreenKeydown)
document.body.style.overflow = '' document.body.style.overflow = ''
}) })
const clientsPolling = computed(() => showDetailPanel.value && !!selectedRuleId.value && detailTab.value === 'overview')
useVisibilityPolling(refreshTraffic, 2000, { immediate: true })
useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
</script> </script>
<style scoped> <style scoped>
+7 -24
View File
@@ -799,7 +799,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
NButton, NButton,
NIcon, NIcon,
@@ -825,6 +825,7 @@ import {
SunnyOutline, SunnyOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { api } from '../api/client' import { api } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { import type {
ApiToken, ApiToken,
BackupArchive, BackupArchive,
@@ -1055,7 +1056,7 @@ const globalIPWhitelistText = ref('')
const chinaCIDRHours = ref(24) const chinaCIDRHours = ref(24)
const refreshingCIDR = ref(false) const refreshingCIDR = ref(false)
const chinaCIDR = ref<ChinaCIDRStatus>({ entry_count_v4: 0, entry_count_v6: 0 }) const chinaCIDR = ref<ChinaCIDRStatus>({ entry_count_v4: 0, entry_count_v6: 0 })
let chinaCIDRPollTimer: ReturnType<typeof setInterval> | undefined const chinaCIDRPolling = ref(false)
const globalNginxFramework = ref('') const globalNginxFramework = ref('')
const globalNginxGeneratedSnippet = ref('') const globalNginxGeneratedSnippet = ref('')
@@ -1335,31 +1336,15 @@ async function resetGlobalNginxAuto() {
async function loadChinaCIDRStatus() { async function loadChinaCIDRStatus() {
try { try {
chinaCIDR.value = await api.getChinaCIDRStatus() chinaCIDR.value = await api.getChinaCIDRStatus()
if (chinaCIDR.value.updating) { chinaCIDRPolling.value = Boolean(chinaCIDR.value.updating)
startChinaCIDRPoll()
} else {
stopChinaCIDRPoll()
}
} catch { } catch {
// optional on load // optional on load
} }
} }
function stopChinaCIDRPoll() {
if (chinaCIDRPollTimer) {
clearInterval(chinaCIDRPollTimer)
chinaCIDRPollTimer = undefined
}
}
function startChinaCIDRPoll() {
if (chinaCIDRPollTimer) return
chinaCIDRPollTimer = setInterval(() => void loadChinaCIDRStatus(), 2000)
}
async function refreshChinaCIDR() { async function refreshChinaCIDR() {
refreshingCIDR.value = true refreshingCIDR.value = true
stopChinaCIDRPoll() chinaCIDRPolling.value = false
try { try {
chinaCIDR.value = await api.refreshChinaCIDR() chinaCIDR.value = await api.refreshChinaCIDR()
if (chinaCIDR.value.ready) { if (chinaCIDR.value.ready) {
@@ -1377,10 +1362,6 @@ async function refreshChinaCIDR() {
} }
} }
onBeforeUnmount(() => {
stopChinaCIDRPoll()
})
function applyNotifySettings(settings: Record<string, string>) { function applyNotifySettings(settings: Record<string, string>) {
notifyType.value = (settings.notify_type as NotifyType) || '' notifyType.value = (settings.notify_type as NotifyType) || ''
notifySMTPPassword.value = '' notifySMTPPassword.value = ''
@@ -1770,6 +1751,8 @@ onMounted(() => {
void loadApiTokens() void loadApiTokens()
void loadBackupArchives() void loadBackupArchives()
}) })
useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
</script> </script>
<style scoped> <style scoped>