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
+3 -17
View File
@@ -52,9 +52,10 @@
</template>
<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 { api } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { UpdateStatus } from '../api/types'
const show = defineModel<boolean>('show', { required: true })
@@ -66,7 +67,6 @@ const appVersion = ref(__APP_VERSION__)
const loading = ref(false)
const applying = ref(false)
const localMessage = ref('')
let pollTimer: number | undefined
const modeLabel = computed(() => {
if (status.value?.mode === 'git') return 'Git 仓库构建'
@@ -115,24 +115,17 @@ async function startUpdate() {
localMessage.value = '升级任务已启动,正在等待容器重建…'
try {
status.value = await api.applyUpdate()
startPolling()
} catch (error) {
applying.value = false
localMessage.value = error instanceof Error ? error.message : '启动升级失败'
}
}
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => void pollStatus(), 2000)
}
async function pollStatus() {
try {
status.value = await api.getUpdateStatus()
localMessage.value = ''
if (status.value.phase === 'success') {
stopPolling()
applying.value = false
message.success('Fonu 已升级完成')
emit('updated')
@@ -140,7 +133,6 @@ async function pollStatus() {
return
}
if (status.value.phase === 'failed') {
stopPolling()
applying.value = false
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) => {
if (!visible) return
void loadStatus()
})
onBeforeUnmount(stopPolling)
useVisibilityPolling(pollStatus, 2000, { enabled: applying })
</script>
<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),
}
}
/**
* 页面可见时才轮询:标签页隐藏期间跳过这一轮,回到前台立即补一次。
*
* 各页原来的写法是「自己 setInterval + 在回调里判断 document.visibilityState」,
* 但只有仪表盘这么做了,其余页面在后台标签页里仍在持续打接口(公网反代页每轮 7 个请求)。
*
* @param fn 每轮执行的回调;由调用方自行保证幂等与「无数据时直接 return」
* @param intervalMs 轮询间隔
* @param enabled 可选开关,为 false 时整轮跳过(例如未选中服务端)
* 可测试的轮询核心:隐藏时跳过、上一轮未结束时跳过、回到前台立即补一次。
* 不依赖 Vue 生命周期,宿主能力全部由 host 注入。
*/
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
const tick = () => {
if (document.visibilityState === 'hidden') return
if (enabled && !enabled.value) return
void fn()
const tick = async () => {
if (host.isHidden()) return
if (options.enabled && !options.enabled.value) return
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 = () => {
if (timer === undefined) return
window.clearInterval(timer)
host.clearInterval(timer)
timer = undefined
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') return
tick() // 回到前台立刻补一次,不用等下一个周期
if (host.isHidden()) return
void tick()
}
onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange)
timer = window.setInterval(tick, intervalMs)
})
const attach = () => {
host.addVisibilityListener(onVisibilityChange)
start()
}
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange)
const detach = () => {
host.removeVisibilityListener(onVisibilityChange)
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>
<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 {
AlertCircleOutline,
@@ -375,6 +375,7 @@ import {
} from '@vicons/ionicons5'
import bannerImg from '../assets/brand/banner.png'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type {
AccessLogEntry,
CertificateRecord,
@@ -440,8 +441,6 @@ const trafficByRule = ref<Record<number, ProxyTraffic>>({})
type RatePoint = { at: number; upload: number; download: number }
const rateHistory = ref<RatePoint[]>([])
const RATE_HISTORY_MS = 5 * 60 * 1000
let trafficTimer: ReturnType<typeof setInterval> | null = null
let statusTimer: ReturnType<typeof setInterval> | null = null
// 实时流量的数据源:本机(Fonu 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数)
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 秒刷新一次
async function refreshStatus() {
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 / 内存 / 磁盘(容器内为宿主机视角)
const resourcesAvailable = computed(
() => (status.value?.mem_total_bytes ?? 0) > 0 || (status.value?.disk_total_bytes ?? 0) > 0,
@@ -1033,27 +997,20 @@ async function loadChinaCIDR() {
}
// 中国 IP 段更新是后端后台任务:刷新后按 2 秒轮询直到就绪或结束
let cidrPollTimer: ReturnType<typeof setInterval> | null = null
function stopCIDRPoll() {
if (cidrPollTimer) {
clearInterval(cidrPollTimer)
cidrPollTimer = null
}
}
const cidrPolling = ref(false)
async function pollCIDR() {
await loadChinaCIDR()
const data = chinaCIDR.value
if (!data || data.ready || !data.updating) {
stopCIDRPoll()
cidrPolling.value = false
if (data?.ready) message.success('中国 IP 段已更新')
}
}
async function refreshCIDR() {
refreshingCIDR.value = true
stopCIDRPoll()
cidrPolling.value = false
try {
chinaCIDR.value = await api.refreshChinaCIDR()
if (chinaCIDR.value.ready) {
@@ -1061,7 +1018,7 @@ async function refreshCIDR() {
`中国 IP 段已更新:IPv4 ${chinaCIDR.value.entry_count_v4} 条,IPv6 ${chinaCIDR.value.entry_count_v6} 条`,
)
} else if (chinaCIDR.value.updating) {
cidrPollTimer = setInterval(() => void pollCIDR(), 2000)
cidrPolling.value = true
} else {
message.warning('更新完成,但数据尚未就绪,请查看错误信息')
}
@@ -1188,18 +1145,11 @@ async function load() {
onMounted(() => {
load()
refreshTraffic()
startTrafficPoll()
startStatusPoll()
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onUnmounted(() => {
stopTrafficPoll()
stopStatusPoll()
stopCIDRPoll()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
useVisibilityPolling(refreshTraffic, 2000, { immediate: true })
useVisibilityPolling(refreshStatus, 15000, { immediate: true })
useVisibilityPolling(pollCIDR, 2000, { enabled: cidrPolling })
</script>
<style scoped>
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</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>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.3。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.4。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</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>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.5.3。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.3。</li>
<li>验证标识更新为 <span class="mono">v1.5.3-example-page</span>。</li>
<li>页面版本提示更新为 v1.5.4。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.4。</li>
<li>验证标识更新为 <span class="mono">v1.5.4-example-page</span>。</li>
</ul>
</FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__)
const checkedAt = ref('')
const refreshing = ref(false)
const verificationRound = 'v1.5.3'
const verificationMarker = 'v1.5.3-example-page'
const verificationRound = 'v1.5.4'
const verificationMarker = 'v1.5.4-example-page'
async function refreshVersion() {
refreshing.value = true
+11 -24
View File
@@ -642,7 +642,7 @@
</template>
<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 { FrpProxyTraffic, FrpProxiesTraffic } from '../api/types'
import { NButton, NIcon, NSpace, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
@@ -652,12 +652,12 @@ import {
} from '@vicons/ionicons5'
import { RouterLink } from 'vue-router'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import { formatBytes, formatDate, formatRate } from '../utils/format'
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import EmptyState from '../components/EmptyState.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -699,7 +699,7 @@ const downloadProxyOptions = [
{ label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' },
]
const editingProxy = ref<FrpProxy | null>(null)
let downloadTimer: number | undefined
const downloadPolling = ref(false)
function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } {
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 快照
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 now = Date.now()
@@ -909,14 +909,10 @@ function openProxyDetail(proxy: FrpProxy, tab: 'overview' | 'logs' = 'overview')
proxyRateHistory.value = []
recordProxyRateSample()
if (tab === 'logs') void loadProxyLogs(proxy.id)
// 弹窗打开期间每 5 秒补一个速率采样点,支撑最近 5 分钟实时流量图
if (proxyRateTimer) window.clearInterval(proxyRateTimer)
proxyRateTimer = window.setInterval(recordProxyRateSample, 5_000)
}
function closeProxyDetail() {
showProxyDetail.value = false
if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null }
}
async function loadProxyLogs(proxyID: number) {
@@ -1045,7 +1041,7 @@ async function loadRuntimeConfig() {
binaries.value = asList(nextBinaries)
releases.value = asList(nextReleases)
downloadTask.value = nextDownloadTask.status === 'completed' ? emptyDownloadTask() : nextDownloadTask
ensureDownloadPolling()
downloadPolling.value = downloadInProgress.value
} catch (error) { message.error(error instanceof Error ? error.message : '读取 FRP 应用配置失败') }
finally { runtimeLoading.value = false }
}
@@ -1078,21 +1074,16 @@ async function downloadRelease(version: string) {
downloadingVersion.value = version
try {
downloadTask.value = await api.downloadFrpRelease(version)
ensureDownloadPolling()
downloadPolling.value = downloadInProgress.value
} catch (error) { message.error(error instanceof Error ? error.message : '下载 FRP 客户端失败') }
finally { downloadingVersion.value = null }
}
function ensureDownloadPolling() {
if (!downloadInProgress.value || downloadTimer) return
downloadTimer = window.setInterval(refreshDownloadTask, 500)
}
async function refreshDownloadTask() {
try {
downloadTask.value = await api.getFrpDownloadStatus()
if (downloadInProgress.value) return
stopDownloadPolling()
downloadPolling.value = false
if (downloadTask.value.status === 'completed') {
message.success(`${downloadTask.value.version} 已下载并完成校验`)
await loadRuntimeConfig()
@@ -1101,17 +1092,11 @@ async function refreshDownloadTask() {
}
if (downloadTask.value.status === 'failed') message.error(downloadTask.value.error || '下载 FRP 客户端失败')
} catch (error) {
stopDownloadPolling()
downloadPolling.value = false
message.error(error instanceof Error ? error.message : '读取下载进度失败')
}
}
function stopDownloadPolling() {
if (!downloadTimer) return
window.clearInterval(downloadTimer)
downloadTimer = undefined
}
function emptyDownloadTask(): FrpDownloadTask {
return { status: 'idle', message: '', downloaded: 0, total: 0 }
}
@@ -1211,7 +1196,9 @@ function proxyTransportTags(proxy: FrpProxy) {
useVisibilityPolling(() => load(), 5_000)
onMounted(async () => { await load() })
onUnmounted(() => { if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() })
useVisibilityPolling(recordProxyRateSample, 5000, { enabled: proxyRatePolling })
useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
</script>
<style scoped>
+3 -9
View File
@@ -162,7 +162,7 @@
</template>
<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 {
NButton,
@@ -177,6 +177,7 @@ import {
type DataTableColumns,
} from 'naive-ui'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
@@ -296,7 +297,6 @@ function resolveTab(queryTab: unknown): LogTab {
const tab = ref(resolveTab(route.query.tab))
const pageError = ref('')
const autoRefresh = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const accessLogs = ref<AccessLogEntry[]>([])
const proxyRules = ref<ProxyRule[]>([])
@@ -802,10 +802,7 @@ watch([frpKeyword, frpLevel], () => {
frpPage.value = 1
})
watch(autoRefresh, (on) => {
if (refreshTimer) clearInterval(refreshTimer)
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)
})
useVisibilityPolling(() => loadAccess(), 10000, { enabled: autoRefresh })
watch(
() => route.query.tab,
@@ -824,9 +821,6 @@ onMounted(() => {
tab.value = resolveTab(route.query.tab)
loadAll()
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<style scoped>
+6 -36
View File
@@ -860,6 +860,7 @@ import {
} from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import ProxyAccessLogBox from '../components/ProxyAccessLogBox.vue'
@@ -912,8 +913,6 @@ const clientsLoading = ref(false)
type RatePoint = { at: number; upload: number; download: number }
const rateHistory = ref<RatePoint[]>([])
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 nginxHttpsPort = ref(443)
@@ -1794,18 +1793,6 @@ async function refreshAll() {
await Promise.all([load(), refreshTraffic()])
}
function startTrafficPoll() {
stopTrafficPoll()
trafficTimer = setInterval(refreshTraffic, 2000)
}
function stopTrafficPoll() {
if (trafficTimer) {
clearInterval(trafficTimer)
trafficTimer = null
}
}
async function loadClients() {
const rule = selectedRule.value
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]) => {
if (!visible || !id || tab !== 'overview') {
stopClientsPoll()
return
}
startClientsPoll()
if (visible && id && tab === 'overview') void loadClients()
})
async function loadDefaults() {
@@ -2191,17 +2161,17 @@ watch(
onMounted(async () => {
await loadDefaults()
await load()
await refreshTraffic()
startTrafficPoll()
})
onUnmounted(() => {
destroyRowSortable()
stopLogStream()
stopTrafficPoll()
stopClientsPoll()
document.removeEventListener('keydown', onLogFullscreenKeydown)
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>
<style scoped>
+7 -24
View File
@@ -799,7 +799,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import {
NButton,
NIcon,
@@ -825,6 +825,7 @@ import {
SunnyOutline,
} from '@vicons/ionicons5'
import { api } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type {
ApiToken,
BackupArchive,
@@ -1055,7 +1056,7 @@ const globalIPWhitelistText = ref('')
const chinaCIDRHours = ref(24)
const refreshingCIDR = ref(false)
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 globalNginxGeneratedSnippet = ref('')
@@ -1335,31 +1336,15 @@ async function resetGlobalNginxAuto() {
async function loadChinaCIDRStatus() {
try {
chinaCIDR.value = await api.getChinaCIDRStatus()
if (chinaCIDR.value.updating) {
startChinaCIDRPoll()
} else {
stopChinaCIDRPoll()
}
chinaCIDRPolling.value = Boolean(chinaCIDR.value.updating)
} catch {
// optional on load
}
}
function stopChinaCIDRPoll() {
if (chinaCIDRPollTimer) {
clearInterval(chinaCIDRPollTimer)
chinaCIDRPollTimer = undefined
}
}
function startChinaCIDRPoll() {
if (chinaCIDRPollTimer) return
chinaCIDRPollTimer = setInterval(() => void loadChinaCIDRStatus(), 2000)
}
async function refreshChinaCIDR() {
refreshingCIDR.value = true
stopChinaCIDRPoll()
chinaCIDRPolling.value = false
try {
chinaCIDR.value = await api.refreshChinaCIDR()
if (chinaCIDR.value.ready) {
@@ -1377,10 +1362,6 @@ async function refreshChinaCIDR() {
}
}
onBeforeUnmount(() => {
stopChinaCIDRPoll()
})
function applyNotifySettings(settings: Record<string, string>) {
notifyType.value = (settings.notify_type as NotifyType) || ''
notifySMTPPassword.value = ''
@@ -1770,6 +1751,8 @@ onMounted(() => {
void loadApiTokens()
void loadBackupArchives()
})
useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
</script>
<style scoped>