This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user