diff --git a/frontend/src/components/common/AutoRefreshButton.vue b/frontend/src/components/common/AutoRefreshButton.vue
new file mode 100644
index 00000000..797c8752
--- /dev/null
+++ b/frontend/src/components/common/AutoRefreshButton.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/user/monitor/MonitorHero.vue b/frontend/src/components/user/monitor/MonitorHero.vue
index e978e66c..dd74825b 100644
--- a/frontend/src/components/user/monitor/MonitorHero.vue
+++ b/frontend/src/components/user/monitor/MonitorHero.vue
@@ -42,8 +42,18 @@
+
+
- {{ updatedLabel }} · {{ t('monitorCommon.pollEvery', { n: intervalSeconds }) }}
+ {{ updatedLabel }}
@@ -53,6 +63,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
+import AutoRefreshButton from '@/components/common/AutoRefreshButton.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
export type MonitorWindow = '7d' | '15d' | '30d'
@@ -64,6 +75,14 @@ const props = defineProps<{
intervalSeconds: number
window: MonitorWindow
loading: boolean
+ autoRefresh?: {
+ enabled: { value: boolean }
+ intervalSeconds: { value: number }
+ countdown: { value: number }
+ intervals: readonly number[]
+ setEnabled: (v: boolean) => void
+ setInterval: (v: number) => void
+ }
}>()
const emit = defineEmits<{
diff --git a/frontend/src/composables/useAutoRefresh.ts b/frontend/src/composables/useAutoRefresh.ts
new file mode 100644
index 00000000..37e9abad
--- /dev/null
+++ b/frontend/src/composables/useAutoRefresh.ts
@@ -0,0 +1,112 @@
+import { ref, onBeforeUnmount, type Ref } from 'vue'
+
+export interface UseAutoRefreshOptions {
+ storageKey: string
+ intervals?: readonly number[]
+ defaultInterval?: number
+ onRefresh: () => Promise | void
+ /** Skip tick when this returns true (e.g. modal open, document hidden). */
+ shouldPause?: () => boolean
+}
+
+export function useAutoRefresh(options: UseAutoRefreshOptions) {
+ const {
+ storageKey,
+ intervals = [5, 10, 15, 30] as const,
+ defaultInterval,
+ onRefresh,
+ shouldPause,
+ } = options
+
+ const enabled = ref(false)
+ const intervalSeconds = ref(defaultInterval ?? intervals[intervals.length - 1])
+ const countdown = ref(0)
+ const fetching = ref(false)
+
+ let timerId: number | undefined
+
+ function loadFromStorage() {
+ try {
+ const saved = localStorage.getItem(storageKey)
+ if (!saved) return
+ const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
+ enabled.value = parsed.enabled === true
+ const iv = Number(parsed.interval_seconds)
+ if (intervals.includes(iv as any)) intervalSeconds.value = iv
+ } catch { /* ignore */ }
+ }
+
+ function saveToStorage() {
+ try {
+ localStorage.setItem(storageKey, JSON.stringify({
+ enabled: enabled.value,
+ interval_seconds: intervalSeconds.value,
+ }))
+ } catch { /* ignore */ }
+ }
+
+ async function tick() {
+ if (!enabled.value) return
+ if (shouldPause?.()) return
+ if (fetching.value) return
+
+ if (countdown.value <= 0) {
+ countdown.value = intervalSeconds.value
+ fetching.value = true
+ try { await onRefresh() } finally { fetching.value = false }
+ return
+ }
+ countdown.value -= 1
+ }
+
+ function start() {
+ if (timerId !== undefined) return
+ timerId = setInterval(tick, 1000) as unknown as number
+ }
+
+ function stop() {
+ if (timerId !== undefined) {
+ clearInterval(timerId)
+ timerId = undefined
+ }
+ }
+
+ function setEnabled(value: boolean) {
+ enabled.value = value
+ saveToStorage()
+ if (value) {
+ countdown.value = intervalSeconds.value
+ start()
+ } else {
+ stop()
+ countdown.value = 0
+ }
+ }
+
+ function setInterval_(seconds: number) {
+ intervalSeconds.value = seconds
+ saveToStorage()
+ if (enabled.value) countdown.value = seconds
+ }
+
+ function resetCountdown() {
+ countdown.value = intervalSeconds.value
+ }
+
+ loadFromStorage()
+
+ onBeforeUnmount(stop)
+
+ return {
+ enabled: enabled as Ref,
+ intervalSeconds: intervalSeconds as Ref,
+ countdown: countdown as Ref,
+ fetching: fetching as Ref,
+ intervals,
+ setEnabled,
+ setInterval: setInterval_,
+ resetCountdown,
+ start,
+ stop,
+ }
+}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 822dad39..eeb6087b 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -308,6 +308,12 @@ export default {
saving: 'Saving...',
selectedCount: '({count} selected)',
refresh: 'Refresh',
+ autoRefresh: {
+ title: 'Auto Refresh',
+ enable: 'Enable auto refresh',
+ countdown: 'Auto refresh: {seconds}s',
+ seconds: '{n} seconds',
+ },
view: 'View',
settings: 'Settings',
chooseFile: 'Choose File',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 318b40d6..5f91a2f6 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -308,6 +308,12 @@ export default {
saving: '保存中...',
selectedCount: '(已选 {count} 个)',
refresh: '刷新',
+ autoRefresh: {
+ title: '自动刷新',
+ enable: '启用自动刷新',
+ countdown: '自动刷新: {seconds}s',
+ seconds: '{n} 秒',
+ },
view: '查看',
settings: '设置',
chooseFile: '选择文件',
diff --git a/frontend/src/views/user/ChannelStatusView.vue b/frontend/src/views/user/ChannelStatusView.vue
index d9100890..2f9b408d 100644
--- a/frontend/src/views/user/ChannelStatusView.vue
+++ b/frontend/src/views/user/ChannelStatusView.vue
@@ -6,6 +6,7 @@
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
:window="currentWindow"
:loading="loading"
+ :auto-refresh="autoRefresh"
@update:window="handleWindowChange"
@refresh="manualReload"
/>
@@ -47,6 +48,7 @@ import MonitorHero, {
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
+import { useAutoRefresh } from '@/composables/useAutoRefresh'
const { t } = useI18n()
const appStore = useAppStore()
@@ -57,25 +59,32 @@ const loading = ref(false)
const updatedAt = ref(null)
const currentWindow = ref('7d')
const detailCache = reactive>({})
-const countdown = ref(DEFAULT_INTERVAL_SECONDS)
-
const showDetail = ref(false)
const detailTarget = ref(null)
-let countdownTimer: number | undefined
let abortController: AbortController | null = null
+const autoRefresh = useAutoRefresh({
+ storageKey: 'channel-status-auto-refresh',
+ intervals: [30, 60, 120] as const,
+ defaultInterval: DEFAULT_INTERVAL_SECONDS,
+ onRefresh: () => reload(true),
+ shouldPause: () => document.hidden || loading.value,
+})
+const countdown = autoRefresh.countdown
+
// ── Computed ──
const overallStatus = computed(() => {
- if (items.value.length === 0) return 'operational'
- let hasFailure = false
- let hasDegraded = false
+ const total = items.value.length
+ if (total === 0) return 'operational'
+ let failCount = 0
+ let degradedCount = 0
for (const it of items.value) {
- if (it.primary_status === 'failed' || it.primary_status === 'error') hasFailure = true
- else if (it.primary_status !== STATUS_OPERATIONAL) hasDegraded = true
+ if (it.primary_status === 'failed' || it.primary_status === 'error') failCount++
+ else if (it.primary_status !== STATUS_OPERATIONAL) degradedCount++
}
- if (hasFailure) return 'unavailable'
- if (hasDegraded) return 'degraded'
+ if (failCount > total / 2) return 'unavailable'
+ if (failCount > 0 || degradedCount > 0) return 'degraded'
return 'operational'
})
@@ -146,48 +155,26 @@ function closeDetail() {
detailTarget.value = null
}
-// ── Polling ──
-function tick() {
- if (countdown.value <= 1) {
- void reload(true)
- return
- }
- countdown.value -= 1
-}
-
watch(items, () => {
- // Lazily load detail entries when window requires it and the list refreshes.
void ensureDetailsForWindow()
})
-function startTimer() {
- if (countdownTimer !== undefined) return
- countdownTimer = setInterval(tick, 1000) as unknown as number
-}
-
-function stopTimer() {
- if (countdownTimer !== undefined) {
- clearInterval(countdownTimer)
- countdownTimer = undefined
- }
-}
-
watch(
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
(enabled) => {
- if (enabled === false) stopTimer()
- else startTimer()
+ if (enabled === false) autoRefresh.stop()
+ else if (autoRefresh.enabled.value) autoRefresh.start()
},
)
-// ── Lifecycle ──
onMounted(() => {
void reload(false)
- if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) startTimer()
+ if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
+ autoRefresh.setEnabled(true)
+ }
})
onBeforeUnmount(() => {
- stopTimer()
if (abortController) abortController.abort()
})