mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix(connection): ignore probe failures during restart (#682)
This commit is contained in:
+15
-127
@@ -2,7 +2,7 @@
|
|||||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
import api from '@/api'
|
||||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||||
import { SupportedLocale } from '@/types/i18n'
|
import { SupportedLocale } from '@/types/i18n'
|
||||||
@@ -30,7 +30,8 @@ import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette
|
|||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||||
|
import { useServerConnectionProbe } from '@/composables/useServerConnectionProbe'
|
||||||
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||||
import { loadMediaSources } from '@/composables/useMediaSources'
|
import { loadMediaSources } from '@/composables/useMediaSources'
|
||||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||||
@@ -159,6 +160,12 @@ const router = useRouter()
|
|||||||
const { initializePWA } = usePWA()
|
const { initializePWA } = usePWA()
|
||||||
const offlineStatus = useGlobalOfflineStatus()
|
const offlineStatus = useGlobalOfflineStatus()
|
||||||
const { isRestarting: isSystemRestarting } = useSystemRestartStatus()
|
const { isRestarting: isSystemRestarting } = useSystemRestartStatus()
|
||||||
|
const serverConnectionProbe = useServerConnectionProbe({
|
||||||
|
isLoggedIn: isLogin,
|
||||||
|
isRestarting: isSystemRestarting,
|
||||||
|
offlineStatus,
|
||||||
|
request: (path, config) => api.get(path, config),
|
||||||
|
})
|
||||||
|
|
||||||
// 全局设置store
|
// 全局设置store
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
@@ -370,130 +377,12 @@ void router.isReady().then(() => {
|
|||||||
isInitialRouteReady.value = true
|
isInitialRouteReady.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
let heartbeatInterval: number | null = null
|
|
||||||
let connectionRetryTimer: number | null = null
|
|
||||||
let connectionProbePromise: Promise<boolean> | null = null
|
|
||||||
let connectionProbeFailures = 0
|
|
||||||
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
||||||
|
|
||||||
const SERVER_PROBE_TIMEOUT_MS = 8_000
|
|
||||||
const SERVER_PROBE_FAILURE_THRESHOLD = 2
|
|
||||||
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
|
|
||||||
|
|
||||||
/** 清除等待中的服务重连任务。 */
|
|
||||||
function clearConnectionRetryTimer() {
|
|
||||||
if (!connectionRetryTimer) return
|
|
||||||
|
|
||||||
window.clearTimeout(connectionRetryTimer)
|
|
||||||
connectionRetryTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
|
|
||||||
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
|
|
||||||
if (!offlineStatus.browserOnline.value) return 'browser-offline'
|
|
||||||
|
|
||||||
const errorCode = (error as { code?: string } | null)?.code
|
|
||||||
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
|
|
||||||
|
|
||||||
return 'server-unreachable'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
|
|
||||||
function scheduleConnectionRetry() {
|
|
||||||
clearConnectionRetryTimer()
|
|
||||||
|
|
||||||
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
|
|
||||||
connectionRetryTimer = window.setTimeout(() => {
|
|
||||||
connectionRetryTimer = null
|
|
||||||
void probeServerConnection()
|
|
||||||
}, SERVER_RETRY_DELAYS_MS[retryIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
|
|
||||||
async function probeServerConnection(showChecking = false): Promise<boolean> {
|
|
||||||
if (!isLogin.value) return false
|
|
||||||
if (connectionProbePromise) return connectionProbePromise
|
|
||||||
|
|
||||||
clearConnectionRetryTimer()
|
|
||||||
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
|
|
||||||
|
|
||||||
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
|
||||||
const probePromise = (async () => {
|
|
||||||
try {
|
|
||||||
await api.get('system/ping', {
|
|
||||||
feedback: 'silent',
|
|
||||||
skipNavigationCancellation: true,
|
|
||||||
skipConnectionTracking: true,
|
|
||||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
|
||||||
} as ConnectionAwareRequestConfig)
|
|
||||||
connectionProbeFailures = 0
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
if (!isLogin.value) {
|
|
||||||
offlineStatus.markServerOnline()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
|
|
||||||
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
|
|
||||||
connectionProbeFailures = 0
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionProbeFailures += 1
|
|
||||||
const failureReason = resolveProbeFailureReason(error)
|
|
||||||
|
|
||||||
// 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。
|
|
||||||
if (isSystemRestarting.value) return false
|
|
||||||
|
|
||||||
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
|
|
||||||
offlineStatus.markServerOffline(failureReason)
|
|
||||||
} else {
|
|
||||||
offlineStatus.markConnectionChecking(failureReason)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleConnectionRetry()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
connectionProbePromise = probePromise
|
|
||||||
try {
|
|
||||||
return await probePromise
|
|
||||||
} finally {
|
|
||||||
if (connectionProbePromise === probePromise) connectionProbePromise = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启动即时服务探测和五分钟在线心跳。 */
|
|
||||||
function startHeartbeat() {
|
|
||||||
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
|
|
||||||
|
|
||||||
void probeServerConnection()
|
|
||||||
|
|
||||||
heartbeatInterval = window.setInterval(
|
|
||||||
async () => {
|
|
||||||
if (isLogin.value) await probeServerConnection()
|
|
||||||
},
|
|
||||||
5 * 60 * 1000,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止心跳和等待中的自动重连任务。 */
|
|
||||||
function stopHeartbeat() {
|
|
||||||
if (heartbeatInterval) {
|
|
||||||
window.clearInterval(heartbeatInterval)
|
|
||||||
heartbeatInterval = null
|
|
||||||
}
|
|
||||||
|
|
||||||
clearConnectionRetryTimer()
|
|
||||||
connectionProbeFailures = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => offlineStatus.connectionCheckRequestId.value,
|
() => offlineStatus.connectionCheckRequestId.value,
|
||||||
() => {
|
() => {
|
||||||
if (isLogin.value) void probeServerConnection(true)
|
if (isLogin.value) void serverConnectionProbe.probeServerConnection(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -501,8 +390,7 @@ watch(
|
|||||||
() => offlineStatus.connectionStatus.value,
|
() => offlineStatus.connectionStatus.value,
|
||||||
status => {
|
status => {
|
||||||
if (status !== 'online') return
|
if (status !== 'online') return
|
||||||
connectionProbeFailures = 0
|
serverConnectionProbe.resetAfterServerOnline()
|
||||||
clearConnectionRetryTimer()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1112,20 +1000,20 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
// 启动心跳
|
// 启动心跳
|
||||||
if (isLogin.value) {
|
if (isLogin.value) {
|
||||||
startHeartbeat()
|
serverConnectionProbe.startHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
|
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
|
||||||
watch(isLogin, loggedIn => {
|
watch(isLogin, loggedIn => {
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
startHeartbeat()
|
serverConnectionProbe.startHeartbeat()
|
||||||
scheduleAuthenticatedStateInitialization()
|
scheduleAuthenticatedStateInitialization()
|
||||||
} else {
|
} else {
|
||||||
if (authenticatedStateTimer) {
|
if (authenticatedStateTimer) {
|
||||||
window.clearTimeout(authenticatedStateTimer)
|
window.clearTimeout(authenticatedStateTimer)
|
||||||
authenticatedStateTimer = null
|
authenticatedStateTimer = null
|
||||||
}
|
}
|
||||||
stopHeartbeat()
|
serverConnectionProbe.stopHeartbeat()
|
||||||
offlineStatus.markServerOnline()
|
offlineStatus.markServerOnline()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1140,7 +1028,7 @@ onUnmounted(() => {
|
|||||||
authenticatedStateTimer = null
|
authenticatedStateTimer = null
|
||||||
}
|
}
|
||||||
// 停止心跳
|
// 停止心跳
|
||||||
stopHeartbeat()
|
serverConnectionProbe.stopHeartbeat()
|
||||||
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
||||||
prefersColorSchemeMediaQuery = null
|
prefersColorSchemeMediaQuery = null
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
|
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||||
|
import { useServerConnectionProbe } from '@/composables/useServerConnectionProbe'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
reject = rejectPromise
|
||||||
|
})
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOfflineStatus() {
|
||||||
|
const browserOnline = ref(true)
|
||||||
|
const connectionReason = ref<ConnectionFailureReason | null>(null)
|
||||||
|
const serverSuccessSequence = ref(0)
|
||||||
|
const markConnectionChecking = vi.fn((reason?: ConnectionFailureReason) => {
|
||||||
|
if (reason) connectionReason.value = reason
|
||||||
|
})
|
||||||
|
const markServerOffline = vi.fn()
|
||||||
|
const markServerOnline = vi.fn()
|
||||||
|
|
||||||
|
const offlineStatus = {
|
||||||
|
browserOnline,
|
||||||
|
connectionReason,
|
||||||
|
markConnectionChecking,
|
||||||
|
markServerOffline,
|
||||||
|
markServerOnline,
|
||||||
|
serverSuccessSequence,
|
||||||
|
} as unknown as ReturnType<typeof useGlobalOfflineStatus>
|
||||||
|
|
||||||
|
return { browserOnline, offlineStatus, serverSuccessSequence }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useServerConnectionProbe', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未登录时不探测,并用固定静默选项去重并发 ping', async () => {
|
||||||
|
const isLoggedIn = ref(false)
|
||||||
|
const pendingRequest = deferred<unknown>()
|
||||||
|
const request = vi.fn(() => pendingRequest.promise)
|
||||||
|
const probe = useServerConnectionProbe({
|
||||||
|
isLoggedIn,
|
||||||
|
isRestarting: ref(false),
|
||||||
|
offlineStatus: createOfflineStatus().offlineStatus,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(probe.probeServerConnection()).resolves.toBe(false)
|
||||||
|
expect(request).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
isLoggedIn.value = true
|
||||||
|
const firstProbe = probe.probeServerConnection()
|
||||||
|
const concurrentProbe = probe.probeServerConnection(true)
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledTimes(1)
|
||||||
|
expect(request).toHaveBeenCalledWith('system/ping', {
|
||||||
|
feedback: 'silent',
|
||||||
|
skipNavigationCancellation: true,
|
||||||
|
skipConnectionTracking: true,
|
||||||
|
timeout: 8_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
pendingRequest.resolve({})
|
||||||
|
await expect(Promise.all([firstProbe, concurrentProbe])).resolves.toEqual([true, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('普通失败先进入 checking,第二次失败才离线并按退避间隔重试', async () => {
|
||||||
|
const { offlineStatus } = createOfflineStatus()
|
||||||
|
const request = vi.fn().mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))
|
||||||
|
const probe = useServerConnectionProbe({
|
||||||
|
isLoggedIn: ref(true),
|
||||||
|
isRestarting: ref(false),
|
||||||
|
offlineStatus,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(probe.probeServerConnection(true)).resolves.toBe(false)
|
||||||
|
expect(offlineStatus.markConnectionChecking).toHaveBeenNthCalledWith(1, undefined)
|
||||||
|
expect(offlineStatus.markConnectionChecking).toHaveBeenNthCalledWith(2, 'timeout')
|
||||||
|
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
|
||||||
|
expect(vi.getTimerCount()).toBe(1)
|
||||||
|
|
||||||
|
await expect(probe.probeServerConnection()).resolves.toBe(false)
|
||||||
|
expect(offlineStatus.markServerOffline).toHaveBeenCalledWith('timeout')
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(4_999)
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
expect(request).toHaveBeenCalledTimes(3)
|
||||||
|
|
||||||
|
probe.stopHeartbeat()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按浏览器状态区分离线原因,并在任意接口已成功时忽略迟到失败', async () => {
|
||||||
|
const { browserOnline, offlineStatus, serverSuccessSequence } = createOfflineStatus()
|
||||||
|
const pendingRequest = deferred<unknown>()
|
||||||
|
const request = vi.fn(() => pendingRequest.promise)
|
||||||
|
const probe = useServerConnectionProbe({
|
||||||
|
isLoggedIn: ref(true),
|
||||||
|
isRestarting: ref(false),
|
||||||
|
offlineStatus,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
|
browserOnline.value = false
|
||||||
|
const pendingProbe = probe.probeServerConnection()
|
||||||
|
serverSuccessSequence.value += 1
|
||||||
|
pendingRequest.reject(new Error('offline'))
|
||||||
|
|
||||||
|
await expect(pendingProbe).resolves.toBe(true)
|
||||||
|
expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled()
|
||||||
|
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
request.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
await expect(probe.probeServerConnection()).resolves.toBe(false)
|
||||||
|
expect(offlineStatus.markConnectionChecking).toHaveBeenCalledWith('browser-offline')
|
||||||
|
|
||||||
|
probe.stopHeartbeat()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('探测期间退出登录时恢复在线状态且不安排重试', async () => {
|
||||||
|
const isLoggedIn = ref(true)
|
||||||
|
const pendingRequest = deferred<unknown>()
|
||||||
|
const { offlineStatus } = createOfflineStatus()
|
||||||
|
const probe = useServerConnectionProbe({
|
||||||
|
isLoggedIn,
|
||||||
|
isRestarting: ref(false),
|
||||||
|
offlineStatus,
|
||||||
|
request: () => pendingRequest.promise,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingProbe = probe.probeServerConnection()
|
||||||
|
isLoggedIn.value = false
|
||||||
|
pendingRequest.reject(new Error('signed out'))
|
||||||
|
|
||||||
|
await expect(pendingProbe).resolves.toBe(false)
|
||||||
|
expect(offlineStatus.markServerOnline).toHaveBeenCalledOnce()
|
||||||
|
expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled()
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重启期间的失败不提示、不重试,也不累计到重启后的离线阈值', async () => {
|
||||||
|
const isRestarting = ref(true)
|
||||||
|
const { offlineStatus } = createOfflineStatus()
|
||||||
|
const request = vi.fn().mockRejectedValue(new Error('restarting'))
|
||||||
|
const probe = useServerConnectionProbe({
|
||||||
|
isLoggedIn: ref(true),
|
||||||
|
isRestarting,
|
||||||
|
offlineStatus,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
|
await probe.probeServerConnection()
|
||||||
|
await probe.probeServerConnection()
|
||||||
|
expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled()
|
||||||
|
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
|
||||||
|
isRestarting.value = false
|
||||||
|
await probe.probeServerConnection()
|
||||||
|
|
||||||
|
expect(offlineStatus.markConnectionChecking).toHaveBeenCalledWith('server-unreachable')
|
||||||
|
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
|
||||||
|
probe.stopHeartbeat()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('启动时立即探测并按五分钟心跳运行,在线恢复和停止都会清理等待任务', async () => {
|
||||||
|
const request = vi.fn().mockResolvedValue({})
|
||||||
|
const probe = useServerConnectionProbe({
|
||||||
|
isLoggedIn: ref(true),
|
||||||
|
isRestarting: ref(false),
|
||||||
|
offlineStatus: createOfflineStatus().offlineStatus,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
|
probe.startHeartbeat()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(request).toHaveBeenCalledTimes(1)
|
||||||
|
expect(vi.getTimerCount()).toBe(1)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
probe.startHeartbeat()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(request).toHaveBeenCalledTimes(3)
|
||||||
|
expect(vi.getTimerCount()).toBe(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
|
||||||
|
request.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
await expect(probe.probeServerConnection()).resolves.toBe(false)
|
||||||
|
expect(vi.getTimerCount()).toBe(2)
|
||||||
|
|
||||||
|
probe.resetAfterServerOnline()
|
||||||
|
expect(vi.getTimerCount()).toBe(1)
|
||||||
|
probe.stopHeartbeat()
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ConnectionAwareRequestConfig } from '@/api'
|
||||||
|
import type { ConnectionFailureReason, useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||||
|
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
|
interface ServerConnectionProbeOptions {
|
||||||
|
/** 当前会话是否已具备访问 MoviePilot API 的登录态。 */
|
||||||
|
isLoggedIn: MaybeRefOrGetter<unknown>
|
||||||
|
/** 系统是否处于主动重启流程。 */
|
||||||
|
isRestarting: MaybeRefOrGetter<boolean>
|
||||||
|
/** 全局连接状态及其唯一写入操作。 */
|
||||||
|
offlineStatus: ReturnType<typeof useGlobalOfflineStatus>
|
||||||
|
/** 使用静默连接选项请求 MoviePilot API。 */
|
||||||
|
request: (path: string, config: ConnectionAwareRequestConfig) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_PROBE_TIMEOUT_MS = 8_000
|
||||||
|
const SERVER_PROBE_FAILURE_THRESHOLD = 2
|
||||||
|
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
|
||||||
|
const SERVER_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/** 编排 MoviePilot 服务探测、退避重试和在线心跳。 */
|
||||||
|
export function useServerConnectionProbe(options: ServerConnectionProbeOptions) {
|
||||||
|
let heartbeatInterval: number | null = null
|
||||||
|
let connectionRetryTimer: number | null = null
|
||||||
|
let connectionProbePromise: Promise<boolean> | null = null
|
||||||
|
let connectionProbeFailures = 0
|
||||||
|
|
||||||
|
/** 清除等待中的服务重连任务。 */
|
||||||
|
function clearConnectionRetryTimer() {
|
||||||
|
if (!connectionRetryTimer) return
|
||||||
|
|
||||||
|
window.clearTimeout(connectionRetryTimer)
|
||||||
|
connectionRetryTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
|
||||||
|
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
|
||||||
|
if (!options.offlineStatus.browserOnline.value) return 'browser-offline'
|
||||||
|
|
||||||
|
const errorCode = (error as { code?: string } | null)?.code
|
||||||
|
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
|
||||||
|
|
||||||
|
return 'server-unreachable'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
|
||||||
|
function scheduleConnectionRetry() {
|
||||||
|
clearConnectionRetryTimer()
|
||||||
|
|
||||||
|
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
|
||||||
|
connectionRetryTimer = window.setTimeout(() => {
|
||||||
|
connectionRetryTimer = null
|
||||||
|
void probeServerConnection()
|
||||||
|
}, SERVER_RETRY_DELAYS_MS[retryIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
|
||||||
|
async function probeServerConnection(showChecking = false): Promise<boolean> {
|
||||||
|
if (!toValue(options.isLoggedIn)) return false
|
||||||
|
if (connectionProbePromise) return connectionProbePromise
|
||||||
|
|
||||||
|
clearConnectionRetryTimer()
|
||||||
|
if (showChecking) {
|
||||||
|
options.offlineStatus.markConnectionChecking(options.offlineStatus.connectionReason.value ?? undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const successSequenceAtProbeStart = options.offlineStatus.serverSuccessSequence.value
|
||||||
|
const probePromise = (async () => {
|
||||||
|
try {
|
||||||
|
await options.request('system/ping', {
|
||||||
|
feedback: 'silent',
|
||||||
|
skipNavigationCancellation: true,
|
||||||
|
skipConnectionTracking: true,
|
||||||
|
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
connectionProbeFailures = 0
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (!toValue(options.isLoggedIn)) {
|
||||||
|
options.offlineStatus.markServerOnline()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
|
||||||
|
if (options.offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
|
||||||
|
connectionProbeFailures = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。
|
||||||
|
if (toValue(options.isRestarting)) return false
|
||||||
|
|
||||||
|
connectionProbeFailures += 1
|
||||||
|
const failureReason = resolveProbeFailureReason(error)
|
||||||
|
|
||||||
|
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
|
||||||
|
options.offlineStatus.markServerOffline(failureReason)
|
||||||
|
} else {
|
||||||
|
options.offlineStatus.markConnectionChecking(failureReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleConnectionRetry()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
connectionProbePromise = probePromise
|
||||||
|
try {
|
||||||
|
return await probePromise
|
||||||
|
} finally {
|
||||||
|
if (connectionProbePromise === probePromise) connectionProbePromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除失败计数和等待中的重试,用于任意 API 已证明服务恢复在线的场景。 */
|
||||||
|
function resetAfterServerOnline() {
|
||||||
|
connectionProbeFailures = 0
|
||||||
|
clearConnectionRetryTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动即时服务探测和五分钟在线心跳。 */
|
||||||
|
function startHeartbeat() {
|
||||||
|
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
|
||||||
|
|
||||||
|
void probeServerConnection()
|
||||||
|
heartbeatInterval = window.setInterval(() => {
|
||||||
|
if (toValue(options.isLoggedIn)) void probeServerConnection()
|
||||||
|
}, SERVER_HEARTBEAT_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止心跳、重试和失败计数。 */
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
window.clearInterval(heartbeatInterval)
|
||||||
|
heartbeatInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAfterServerOnline()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
probeServerConnection,
|
||||||
|
resetAfterServerOnline,
|
||||||
|
startHeartbeat,
|
||||||
|
stopHeartbeat,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -337,6 +337,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/composables/useMediaSubscribe.ts',
|
'src/composables/useMediaSubscribe.ts',
|
||||||
'src/composables/useLlmProviderDirectory.ts',
|
'src/composables/useLlmProviderDirectory.ts',
|
||||||
'src/composables/useOfflineStatus.ts',
|
'src/composables/useOfflineStatus.ts',
|
||||||
|
'src/composables/useServerConnectionProbe.ts',
|
||||||
'src/composables/useTorrentFilter.ts',
|
'src/composables/useTorrentFilter.ts',
|
||||||
'src/layouts/default/components/OfflinePage.vue',
|
'src/layouts/default/components/OfflinePage.vue',
|
||||||
'src/components/cards/SubscribeCard.vue',
|
'src/components/cards/SubscribeCard.vue',
|
||||||
@@ -433,6 +434,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/composables/useServerConnectionProbe.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/layouts/default/components/OfflinePage.vue': {
|
'src/layouts/default/components/OfflinePage.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user