2026-04-20 20:21:02 +08:00
|
|
|
<template>
|
|
|
|
|
<AppLayout>
|
2026-04-20 23:38:59 +08:00
|
|
|
<MonitorHero
|
|
|
|
|
:overall-status="overallStatus"
|
|
|
|
|
:updated-at="updatedAt"
|
|
|
|
|
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
|
|
|
|
|
:window="currentWindow"
|
|
|
|
|
:loading="loading"
|
|
|
|
|
@update:window="handleWindowChange"
|
|
|
|
|
@refresh="manualReload"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<MonitorCardGrid
|
|
|
|
|
:items="items"
|
|
|
|
|
:window="currentWindow"
|
|
|
|
|
:countdown-seconds="countdown"
|
|
|
|
|
:loading="loading"
|
|
|
|
|
:detail-cache="detailCache"
|
|
|
|
|
@card-click="openDetail"
|
|
|
|
|
/>
|
2026-04-20 20:21:02 +08:00
|
|
|
|
|
|
|
|
<MonitorDetailDialog
|
|
|
|
|
:show="showDetail"
|
|
|
|
|
:monitor-id="detailTarget?.id ?? null"
|
|
|
|
|
:title="detailTitle"
|
|
|
|
|
@close="closeDetail"
|
|
|
|
|
/>
|
|
|
|
|
</AppLayout>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-04-20 23:38:59 +08:00
|
|
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
2026-04-20 20:21:02 +08:00
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
import { useAppStore } from '@/stores/app'
|
|
|
|
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
|
|
|
|
import {
|
|
|
|
|
list as listChannelMonitorViews,
|
2026-04-20 23:38:59 +08:00
|
|
|
status as fetchChannelMonitorDetail,
|
2026-04-20 20:21:02 +08:00
|
|
|
type UserMonitorView,
|
2026-04-20 23:38:59 +08:00
|
|
|
type UserMonitorDetail,
|
2026-04-20 20:21:02 +08:00
|
|
|
} from '@/api/channelMonitor'
|
|
|
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
2026-04-20 23:38:59 +08:00
|
|
|
import MonitorHero, {
|
|
|
|
|
type MonitorWindow,
|
|
|
|
|
type OverallStatus,
|
|
|
|
|
} from '@/components/user/monitor/MonitorHero.vue'
|
|
|
|
|
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
|
2026-04-20 20:21:02 +08:00
|
|
|
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
|
2026-04-20 23:38:59 +08:00
|
|
|
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
|
2026-04-20 20:21:02 +08:00
|
|
|
|
|
|
|
|
const { t } = useI18n()
|
|
|
|
|
const appStore = useAppStore()
|
|
|
|
|
|
|
|
|
|
// ── State ──
|
|
|
|
|
const items = ref<UserMonitorView[]>([])
|
|
|
|
|
const loading = ref(false)
|
2026-04-20 23:38:59 +08:00
|
|
|
const updatedAt = ref<string | null>(null)
|
|
|
|
|
const currentWindow = ref<MonitorWindow>('7d')
|
|
|
|
|
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
|
|
|
|
|
const countdown = ref(DEFAULT_INTERVAL_SECONDS)
|
2026-04-20 20:21:02 +08:00
|
|
|
|
|
|
|
|
const showDetail = ref(false)
|
|
|
|
|
const detailTarget = ref<UserMonitorView | null>(null)
|
|
|
|
|
|
2026-04-20 23:38:59 +08:00
|
|
|
let countdownTimer: number | undefined
|
|
|
|
|
let abortController: AbortController | null = null
|
|
|
|
|
|
|
|
|
|
// ── Computed ──
|
|
|
|
|
const overallStatus = computed<OverallStatus>(() => {
|
|
|
|
|
if (items.value.length === 0) return 'operational'
|
|
|
|
|
let hasFailure = false
|
|
|
|
|
let hasDegraded = false
|
|
|
|
|
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 (hasFailure) return 'unavailable'
|
|
|
|
|
if (hasDegraded) return 'degraded'
|
|
|
|
|
return 'operational'
|
2026-04-20 20:21:02 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const detailTitle = computed(() => {
|
|
|
|
|
return detailTarget.value?.name || t('channelStatus.detailTitle')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Loaders ──
|
2026-04-20 23:38:59 +08:00
|
|
|
async function reload(silent = false) {
|
|
|
|
|
if (abortController) abortController.abort()
|
|
|
|
|
const ctrl = new AbortController()
|
|
|
|
|
abortController = ctrl
|
|
|
|
|
if (!silent) loading.value = true
|
2026-04-20 20:21:02 +08:00
|
|
|
try {
|
2026-04-20 23:38:59 +08:00
|
|
|
const res = await listChannelMonitorViews({ signal: ctrl.signal })
|
|
|
|
|
if (ctrl.signal.aborted || abortController !== ctrl) return
|
2026-04-20 20:21:02 +08:00
|
|
|
items.value = res.items || []
|
2026-04-20 23:38:59 +08:00
|
|
|
updatedAt.value = new Date().toISOString()
|
2026-04-20 20:21:02 +08:00
|
|
|
} catch (err: unknown) {
|
2026-04-20 23:38:59 +08:00
|
|
|
const e = err as { name?: string; code?: string }
|
|
|
|
|
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
|
2026-04-20 20:21:02 +08:00
|
|
|
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
|
|
|
|
|
} finally {
|
2026-04-20 23:38:59 +08:00
|
|
|
if (abortController === ctrl) {
|
|
|
|
|
if (!silent) loading.value = false
|
|
|
|
|
countdown.value = DEFAULT_INTERVAL_SECONDS
|
|
|
|
|
abortController = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function manualReload() {
|
|
|
|
|
await reload(false)
|
|
|
|
|
// After base reload, refresh any cached detail records so non-7d availability
|
|
|
|
|
// values stay in sync without forcing the user to switch tabs again.
|
|
|
|
|
if (currentWindow.value !== '7d') {
|
|
|
|
|
await Promise.all(items.value.map(it => loadDetail(it.id, true)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDetail(id: number, force = false) {
|
|
|
|
|
if (!force && detailCache[id]) return
|
|
|
|
|
try {
|
|
|
|
|
detailCache[id] = await fetchChannelMonitorDetail(id)
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
|
2026-04-20 20:21:02 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:38:59 +08:00
|
|
|
async function ensureDetailsForWindow() {
|
|
|
|
|
if (currentWindow.value === '7d') return
|
|
|
|
|
await Promise.all(items.value.map(it => loadDetail(it.id)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Handlers ──
|
|
|
|
|
async function handleWindowChange(value: MonitorWindow) {
|
|
|
|
|
currentWindow.value = value
|
|
|
|
|
await ensureDetailsForWindow()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 20:21:02 +08:00
|
|
|
function openDetail(row: UserMonitorView) {
|
|
|
|
|
detailTarget.value = row
|
|
|
|
|
showDetail.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeDetail() {
|
|
|
|
|
showDetail.value = false
|
|
|
|
|
detailTarget.value = null
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:38:59 +08:00
|
|
|
// ── 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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-20 20:21:02 +08:00
|
|
|
// ── Lifecycle ──
|
|
|
|
|
onMounted(() => {
|
2026-04-20 23:38:59 +08:00
|
|
|
void reload(false)
|
|
|
|
|
countdownTimer = setInterval(tick, 1000) as unknown as number
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (countdownTimer !== undefined) clearInterval(countdownTimer)
|
|
|
|
|
if (abortController) abortController.abort()
|
2026-04-20 20:21:02 +08:00
|
|
|
})
|
|
|
|
|
</script>
|