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
This commit is contained in:
82
frontend/src/components/common/AutoRefreshButton.vue
Normal file
82
frontend/src/components/common/AutoRefreshButton.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" ref="dropdownRef">
|
||||||
|
<button
|
||||||
|
@click="showDropdown = !showDropdown"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||||
|
:title="t('common.autoRefresh.title')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
:class="enabled ? 'animate-spin' : ''"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||||
|
>
|
||||||
|
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H4.598a.75.75 0 00-.75.75v3.634a.75.75 0 001.5 0v-2.033l.312.312a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm-10.624-2.848a5.5 5.5 0 019.201-2.466l.312.311H11.768a.75.75 0 000 1.5h3.634a.75.75 0 00.75-.75V3.537a.75.75 0 00-1.5 0v2.034l-.312-.312A7 7 0 002.628 8.397a.75.75 0 001.449.39z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{{ enabled
|
||||||
|
? t('common.autoRefresh.countdown', { seconds: countdown })
|
||||||
|
: t('common.autoRefresh.title')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showDropdown"
|
||||||
|
class="absolute right-0 z-20 mt-1 w-44 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<div class="p-1.5">
|
||||||
|
<button
|
||||||
|
@click="$emit('update:enabled', !enabled)"
|
||||||
|
class="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span>{{ t('common.autoRefresh.enable') }}</span>
|
||||||
|
<svg v-if="enabled" class="h-4 w-4 text-primary-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
|
||||||
|
<button
|
||||||
|
v-for="sec in intervals"
|
||||||
|
:key="sec"
|
||||||
|
@click="$emit('update:interval', sec)"
|
||||||
|
class="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span>{{ t('common.autoRefresh.seconds', { n: sec }) }}</span>
|
||||||
|
<svg v-if="intervalSeconds === sec" class="h-4 w-4 text-primary-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
enabled: boolean
|
||||||
|
intervalSeconds: number
|
||||||
|
countdown: number
|
||||||
|
intervals: readonly number[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:enabled', value: boolean): void
|
||||||
|
(e: 'update:interval', value: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const showDropdown = ref(false)
|
||||||
|
const dropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
||||||
|
</script>
|
||||||
@@ -42,8 +42,18 @@
|
|||||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<AutoRefreshButton
|
||||||
|
v-if="autoRefresh"
|
||||||
|
:enabled="autoRefresh.enabled.value"
|
||||||
|
:interval-seconds="autoRefresh.intervalSeconds.value"
|
||||||
|
:countdown="autoRefresh.countdown.value"
|
||||||
|
:intervals="autoRefresh.intervals"
|
||||||
|
@update:enabled="autoRefresh.setEnabled"
|
||||||
|
@update:interval="autoRefresh.setInterval"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
<div class="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||||||
{{ updatedLabel }}<span v-if="intervalSeconds > 0"> · {{ t('monitorCommon.pollEvery', { n: intervalSeconds }) }}</span>
|
{{ updatedLabel }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -53,6 +63,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import AutoRefreshButton from '@/components/common/AutoRefreshButton.vue'
|
||||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||||
|
|
||||||
export type MonitorWindow = '7d' | '15d' | '30d'
|
export type MonitorWindow = '7d' | '15d' | '30d'
|
||||||
@@ -64,6 +75,14 @@ const props = defineProps<{
|
|||||||
intervalSeconds: number
|
intervalSeconds: number
|
||||||
window: MonitorWindow
|
window: MonitorWindow
|
||||||
loading: boolean
|
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<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
112
frontend/src/composables/useAutoRefresh.ts
Normal file
112
frontend/src/composables/useAutoRefresh.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { ref, onBeforeUnmount, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface UseAutoRefreshOptions {
|
||||||
|
storageKey: string
|
||||||
|
intervals?: readonly number[]
|
||||||
|
defaultInterval?: number
|
||||||
|
onRefresh: () => Promise<void> | 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<boolean>,
|
||||||
|
intervalSeconds: intervalSeconds as Ref<number>,
|
||||||
|
countdown: countdown as Ref<number>,
|
||||||
|
fetching: fetching as Ref<boolean>,
|
||||||
|
intervals,
|
||||||
|
setEnabled,
|
||||||
|
setInterval: setInterval_,
|
||||||
|
resetCountdown,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,6 +308,12 @@ export default {
|
|||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
selectedCount: '({count} selected)',
|
selectedCount: '({count} selected)',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
|
autoRefresh: {
|
||||||
|
title: 'Auto Refresh',
|
||||||
|
enable: 'Enable auto refresh',
|
||||||
|
countdown: 'Auto refresh: {seconds}s',
|
||||||
|
seconds: '{n} seconds',
|
||||||
|
},
|
||||||
view: 'View',
|
view: 'View',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
chooseFile: 'Choose File',
|
chooseFile: 'Choose File',
|
||||||
|
|||||||
@@ -308,6 +308,12 @@ export default {
|
|||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
selectedCount: '(已选 {count} 个)',
|
selectedCount: '(已选 {count} 个)',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
|
autoRefresh: {
|
||||||
|
title: '自动刷新',
|
||||||
|
enable: '启用自动刷新',
|
||||||
|
countdown: '自动刷新: {seconds}s',
|
||||||
|
seconds: '{n} 秒',
|
||||||
|
},
|
||||||
view: '查看',
|
view: '查看',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
chooseFile: '选择文件',
|
chooseFile: '选择文件',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
|
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
|
||||||
:window="currentWindow"
|
:window="currentWindow"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:auto-refresh="autoRefresh"
|
||||||
@update:window="handleWindowChange"
|
@update:window="handleWindowChange"
|
||||||
@refresh="manualReload"
|
@refresh="manualReload"
|
||||||
/>
|
/>
|
||||||
@@ -47,6 +48,7 @@ import MonitorHero, {
|
|||||||
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
|
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
|
||||||
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
|
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
|
||||||
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
|
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
|
||||||
|
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -57,25 +59,32 @@ const loading = ref(false)
|
|||||||
const updatedAt = ref<string | null>(null)
|
const updatedAt = ref<string | null>(null)
|
||||||
const currentWindow = ref<MonitorWindow>('7d')
|
const currentWindow = ref<MonitorWindow>('7d')
|
||||||
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
|
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
|
||||||
const countdown = ref(DEFAULT_INTERVAL_SECONDS)
|
|
||||||
|
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
const detailTarget = ref<UserMonitorView | null>(null)
|
const detailTarget = ref<UserMonitorView | null>(null)
|
||||||
|
|
||||||
let countdownTimer: number | undefined
|
|
||||||
let abortController: AbortController | null = null
|
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 ──
|
// ── Computed ──
|
||||||
const overallStatus = computed<OverallStatus>(() => {
|
const overallStatus = computed<OverallStatus>(() => {
|
||||||
if (items.value.length === 0) return 'operational'
|
const total = items.value.length
|
||||||
let hasFailure = false
|
if (total === 0) return 'operational'
|
||||||
let hasDegraded = false
|
let failCount = 0
|
||||||
|
let degradedCount = 0
|
||||||
for (const it of items.value) {
|
for (const it of items.value) {
|
||||||
if (it.primary_status === 'failed' || it.primary_status === 'error') hasFailure = true
|
if (it.primary_status === 'failed' || it.primary_status === 'error') failCount++
|
||||||
else if (it.primary_status !== STATUS_OPERATIONAL) hasDegraded = true
|
else if (it.primary_status !== STATUS_OPERATIONAL) degradedCount++
|
||||||
}
|
}
|
||||||
if (hasFailure) return 'unavailable'
|
if (failCount > total / 2) return 'unavailable'
|
||||||
if (hasDegraded) return 'degraded'
|
if (failCount > 0 || degradedCount > 0) return 'degraded'
|
||||||
return 'operational'
|
return 'operational'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -146,48 +155,26 @@ function closeDetail() {
|
|||||||
detailTarget.value = null
|
detailTarget.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Polling ──
|
|
||||||
function tick() {
|
|
||||||
if (countdown.value <= 1) {
|
|
||||||
void reload(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
countdown.value -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(items, () => {
|
watch(items, () => {
|
||||||
// Lazily load detail entries when window requires it and the list refreshes.
|
|
||||||
void ensureDetailsForWindow()
|
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(
|
watch(
|
||||||
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
|
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
|
||||||
(enabled) => {
|
(enabled) => {
|
||||||
if (enabled === false) stopTimer()
|
if (enabled === false) autoRefresh.stop()
|
||||||
else startTimer()
|
else if (autoRefresh.enabled.value) autoRefresh.start()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Lifecycle ──
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void reload(false)
|
void reload(false)
|
||||||
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) startTimer()
|
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
|
||||||
|
autoRefresh.setEnabled(true)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopTimer()
|
|
||||||
if (abortController) abortController.abort()
|
if (abortController) abortController.abort()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user