From 0dcc0e0504c36b3fa2671f4e2b5f564bd4e2bc7f Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 23 Apr 2026 23:34:58 +0800 Subject: [PATCH] feat(monitor): proportion-based overall status + reusable auto-refresh - Change overall status logic: >50% failed = UNAVAILABLE, any failed or degraded = DEGRADED, all ok = OPERATIONAL - Extract useAutoRefresh composable with localStorage persistence - Create AutoRefreshButton dropdown component (reusable) - Integrate auto-refresh into channel status page via MonitorHero --- .../components/common/AutoRefreshButton.vue | 82 +++++++++++++ .../components/user/monitor/MonitorHero.vue | 21 +++- frontend/src/composables/useAutoRefresh.ts | 112 ++++++++++++++++++ frontend/src/i18n/locales/en.ts | 6 + frontend/src/i18n/locales/zh.ts | 6 + frontend/src/views/user/ChannelStatusView.vue | 61 ++++------ 6 files changed, 250 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/common/AutoRefreshButton.vue create mode 100644 frontend/src/composables/useAutoRefresh.ts 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() })