mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-02 00:30:32 +00:00
feat(channel-monitor-v2): 实现用户端 Ops 风格被动监控界面
提供筛选/矩阵/趋势/排行等组件与 en/zh 文案,按 mode 切换 V1/V2 壳层, 并统一 useI18n 避免语言包未挂载时键路径裸露。
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<MonitorHero
|
||||
:overall-status="overallStatus"
|
||||
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
|
||||
:window="currentWindow"
|
||||
:loading="loading"
|
||||
:auto-refresh="autoRefresh"
|
||||
@update:window="handleWindowChange"
|
||||
@refresh="manualReload"
|
||||
/>
|
||||
|
||||
<MonitorCardGrid
|
||||
:items="items"
|
||||
:window="currentWindow"
|
||||
:countdown-seconds="countdown"
|
||||
:loading="loading"
|
||||
:detail-cache="detailCache"
|
||||
@card-click="openDetail"
|
||||
/>
|
||||
|
||||
<MonitorDetailDialog
|
||||
:show="showDetail"
|
||||
:monitor-id="detailTarget?.id ?? null"
|
||||
:title="detailTitle"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import {
|
||||
list as listChannelMonitorViews,
|
||||
status as fetchChannelMonitorDetail,
|
||||
type UserMonitorView,
|
||||
type UserMonitorDetail,
|
||||
} from '@/api/channelMonitor'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import MonitorHero, {
|
||||
type MonitorWindow,
|
||||
type OverallStatus,
|
||||
} from '@/components/user/monitor/MonitorHero.vue'
|
||||
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()
|
||||
|
||||
// ── State ──
|
||||
const items = ref<UserMonitorView[]>([])
|
||||
const loading = ref(false)
|
||||
const currentWindow = ref<MonitorWindow>('7d')
|
||||
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
|
||||
const showDetail = ref(false)
|
||||
const detailTarget = ref<UserMonitorView | 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 ──
|
||||
const overallStatus = computed<OverallStatus>(() => {
|
||||
if (items.value.length === 0) return 'operational'
|
||||
for (const it of items.value) {
|
||||
if (it.primary_status === 'failed' || it.primary_status === 'error') return 'degraded'
|
||||
if (it.primary_status !== STATUS_OPERATIONAL) return 'degraded'
|
||||
}
|
||||
return 'operational'
|
||||
})
|
||||
|
||||
const detailTitle = computed(() => {
|
||||
return detailTarget.value?.name || t('channelStatus.detailTitle')
|
||||
})
|
||||
|
||||
// ── Loaders ──
|
||||
async function reload(silent = false) {
|
||||
if (abortController) abortController.abort()
|
||||
const ctrl = new AbortController()
|
||||
abortController = ctrl
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const res = await listChannelMonitorViews({ signal: ctrl.signal })
|
||||
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||||
items.value = res.items || []
|
||||
} catch (err: unknown) {
|
||||
const e = err as { name?: string; code?: string }
|
||||
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
|
||||
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
|
||||
} finally {
|
||||
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')))
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function openDetail(row: UserMonitorView) {
|
||||
detailTarget.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
showDetail.value = false
|
||||
detailTarget.value = null
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
void ensureDetailsForWindow()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
|
||||
(enabled) => {
|
||||
if (enabled === false) autoRefresh.stop()
|
||||
else if (autoRefresh.enabled.value) autoRefresh.start()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void reload(false)
|
||||
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
|
||||
autoRefresh.setEnabled(true)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (abortController) abortController.abort()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,886 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6 pb-12">
|
||||
<!-- Ops-style elevated shell: title toolbar + filters (mirrors OpsDashboardHeader) -->
|
||||
<section
|
||||
class="card sticky top-0 z-20 !rounded-3xl !border-0 p-0 shadow-sm ring-1 ring-gray-900/5 backdrop-blur-sm dark:!bg-dark-800 dark:ring-dark-700 supports-[backdrop-filter]:bg-white/95 dark:supports-[backdrop-filter]:bg-dark-800/95"
|
||||
>
|
||||
<header class="page-header mb-0 flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 px-5 py-4 dark:border-dark-700 sm:px-6">
|
||||
<div class="min-w-0">
|
||||
<h1 class="page-title flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
<Icon name="chart" size="sm" />
|
||||
</span>
|
||||
{{ t('channelMonitorV2.title') }}
|
||||
</h1>
|
||||
<div class="page-description mt-1.5 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="relative flex h-2 w-2 shrink-0">
|
||||
<span
|
||||
class="relative inline-flex h-2 w-2 rounded-full"
|
||||
:class="loading || refreshing ? 'bg-gray-400' : 'bg-green-500'"
|
||||
></span>
|
||||
</span>
|
||||
<span v-if="refreshing" class="inline-flex items-center gap-1 text-primary-600 dark:text-primary-300">
|
||||
<LoadingSpinner size="sm" />
|
||||
{{ t('channelMonitorV2.updating') }}
|
||||
</span>
|
||||
<span v-else-if="snapshot?.coverage.data_through">
|
||||
{{ t('channelMonitorV2.updatedTo', { time: formatTime(snapshot.coverage.data_through) }) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">{{ t('common.loading') }}</span>
|
||||
<span
|
||||
v-if="snapshot && !snapshot.coverage.coverage_complete"
|
||||
class="badge badge-warning"
|
||||
>
|
||||
{{ t('channelMonitorV2.partialCoverage') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary btn-icon flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||
type="button"
|
||||
:title="t('common.refresh')"
|
||||
:disabled="loading"
|
||||
@click="reload(false)"
|
||||
>
|
||||
<Icon name="refresh" size="sm" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Single compact toolbar row: range · filters · view controls -->
|
||||
<div class="monitor-toolbar flex flex-nowrap items-center gap-1.5 overflow-x-auto px-4 py-3 sm:gap-2 sm:px-5">
|
||||
<div
|
||||
class="tabs inline-flex shrink-0"
|
||||
role="group"
|
||||
:aria-label="t('channelMonitorV2.timeRange')"
|
||||
>
|
||||
<button
|
||||
v-for="option in ranges"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="tab !px-2 !py-1 text-xs sm:!px-2.5"
|
||||
:class="filter.range === option.value ? 'tab-active' : ''"
|
||||
@click="setRange(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="mx-0.5 hidden h-5 w-px shrink-0 bg-gray-200 dark:bg-dark-700 sm:block" aria-hidden="true"></span>
|
||||
|
||||
<FilterMultiSelect
|
||||
v-model="filter.platforms"
|
||||
compact
|
||||
:label="t('channelMonitorV2.filters.platform')"
|
||||
:all-label="t('channelMonitorV2.filters.allPlatforms')"
|
||||
:options="platformOptions"
|
||||
/>
|
||||
<FilterMultiSelect
|
||||
v-model="selectedGroupIds"
|
||||
compact
|
||||
:label="t('channelMonitorV2.filters.group')"
|
||||
:all-label="t('channelMonitorV2.filters.allGroups')"
|
||||
:options="groupOptions"
|
||||
/>
|
||||
<FilterMultiSelect
|
||||
v-model="filter.models"
|
||||
compact
|
||||
:label="t('channelMonitorV2.filters.model')"
|
||||
:all-label="t('channelMonitorV2.filters.allModels')"
|
||||
:options="modelOptions"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm shrink-0 !px-2 !py-1 text-xs"
|
||||
:disabled="!hasDimensionFilter"
|
||||
:class="!hasDimensionFilter ? 'opacity-40' : ''"
|
||||
@click="clearDimensions"
|
||||
>
|
||||
{{ t('channelMonitorV2.clearFilters') }}
|
||||
</button>
|
||||
|
||||
<span class="mx-0.5 hidden h-5 w-px shrink-0 bg-gray-200 dark:bg-dark-700 md:block" aria-hidden="true"></span>
|
||||
|
||||
<Select
|
||||
v-model="matrixGroupBy"
|
||||
:options="matrixGroupOptions"
|
||||
:placeholder="t('channelMonitorV2.groupBy.label')"
|
||||
class="monitor-toolbar-select w-[7.5rem] shrink-0 sm:w-[8.5rem]"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="tabs inline-flex shrink-0"
|
||||
role="group"
|
||||
:aria-label="t('channelMonitorV2.trendView.label')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tab !px-2 !py-1 text-xs"
|
||||
:class="trendView === 'pulse' ? 'tab-active' : ''"
|
||||
@click="trendView = 'pulse'"
|
||||
>
|
||||
{{ t('channelMonitorV2.trendView.pulse') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab !px-2 !py-1 text-xs"
|
||||
:class="trendView === 'line' ? 'tab-active' : ''"
|
||||
@click="trendView = 'line'"
|
||||
>
|
||||
{{ t('channelMonitorV2.trendView.line') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="trendView === 'pulse'"
|
||||
class="tabs inline-flex shrink-0"
|
||||
role="group"
|
||||
:aria-label="t('channelMonitorV2.healthMode.label')"
|
||||
>
|
||||
<button
|
||||
v-for="option in healthModeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="tab !px-2 !py-1 text-xs"
|
||||
:class="healthMode === option.value ? 'tab-active' : ''"
|
||||
@click="healthMode = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Overview-first KPI strip (Ops metric nest order) -->
|
||||
<section
|
||||
v-if="snapshot"
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3"
|
||||
:class="showThroughput ? 'xl:grid-cols-6' : 'xl:grid-cols-4'"
|
||||
:aria-label="t('channelMonitorV2.summaryAria')"
|
||||
>
|
||||
<MetricCell
|
||||
v-if="showThroughput"
|
||||
:label="t('channelMonitorV2.metrics.rpm')"
|
||||
:value="formatRate(snapshot.metrics.rpm)"
|
||||
:detail="t('channelMonitorV2.metrics.rpmDetail')"
|
||||
:title="exactRate(snapshot.metrics.rpm)"
|
||||
/>
|
||||
<MetricCell
|
||||
v-if="showThroughput"
|
||||
:label="t('channelMonitorV2.metrics.tpm')"
|
||||
:value="formatRate(snapshot.metrics.tpm)"
|
||||
:detail="t('channelMonitorV2.metrics.tpmDetail')"
|
||||
:title="exactRate(snapshot.metrics.tpm)"
|
||||
/>
|
||||
<MetricCell
|
||||
:label="t('channelMonitorV2.metrics.errorRate')"
|
||||
:value="formatPercent(snapshot.metrics.error_rate)"
|
||||
:detail="t('channelMonitorV2.metrics.successRateValue', { value: formatPercent(1 - snapshot.metrics.error_rate) })"
|
||||
:state="snapshot.health.error_rate"
|
||||
/>
|
||||
<MetricCell
|
||||
:label="t('channelMonitorV2.metrics.ttftP50')"
|
||||
:value="formatMs(snapshot.metrics.ttft.p50_ms)"
|
||||
:detail="latencyKpiSecondary(snapshot.metrics.ttft)"
|
||||
:title="latencyDetail(snapshot.metrics.ttft)"
|
||||
:state="snapshot.health.ttft"
|
||||
/>
|
||||
<MetricCell
|
||||
:label="t('channelMonitorV2.metrics.durationP50')"
|
||||
:value="formatMs(snapshot.metrics.duration.p50_ms)"
|
||||
:detail="latencyKpiSecondary(snapshot.metrics.duration)"
|
||||
:title="latencyDetail(snapshot.metrics.duration)"
|
||||
/>
|
||||
<MetricCell
|
||||
:label="t('channelMonitorV2.metrics.cacheRate')"
|
||||
:value="formatPercent(snapshot.metrics.cache_rate)"
|
||||
:detail="t('channelMonitorV2.metrics.cacheDetail')"
|
||||
:state="snapshot.health.cache || snapshot.health.overall"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="loading"
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3"
|
||||
:class="showThroughput ? 'xl:grid-cols-6' : 'xl:grid-cols-4'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="i in (showThroughput ? 6 : 4)"
|
||||
:key="i"
|
||||
class="h-24 animate-pulse rounded-2xl bg-gray-50 dark:bg-dark-900/30"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="relative min-h-[320px]">
|
||||
<MonitorTrendChart
|
||||
v-if="trendView === 'line'"
|
||||
:trend="snapshot?.trend || []"
|
||||
:coverage="snapshot?.coverage || null"
|
||||
:loading="loading && !snapshot"
|
||||
/>
|
||||
<RelayPulseMatrix
|
||||
v-else-if="matrix"
|
||||
:rows="matrixRows"
|
||||
:coverage="matrix.coverage"
|
||||
:health-mode="healthMode"
|
||||
:show-throughput="showThroughput"
|
||||
/>
|
||||
<div
|
||||
v-else-if="loading"
|
||||
class="card flex min-h-[320px] items-center justify-center !rounded-3xl !border-0 text-sm text-gray-400 shadow-sm ring-1 ring-gray-900/5 dark:ring-dark-700"
|
||||
>
|
||||
<span class="animate-pulse">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card flex min-h-0 flex-col overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
|
||||
<div class="border-b border-gray-100 px-5 pt-4 dark:border-dark-700 sm:px-6">
|
||||
<nav class="tabs w-full max-w-md sm:w-auto" role="tablist" :aria-label="t('channelMonitorV2.tabs.aria')">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab flex-1 sm:flex-none"
|
||||
:aria-selected="activeTab === item.value"
|
||||
:class="activeTab === item.value ? 'tab-active' : ''"
|
||||
@click="activeTab = item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="min-h-0 max-h-[min(52vh,520px)] overflow-auto p-4 sm:p-5">
|
||||
<div v-if="activeTab === 'models'" class="table-container border-0">
|
||||
<table class="table monitor-table min-w-[720px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('channelMonitorV2.table.platformModel') }}</th>
|
||||
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.rpm') }}</th>
|
||||
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.tpm') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.errorRate') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.ttftP50') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.durationP50') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.cacheRate') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in modelRows"
|
||||
:key="`${row.platform}:${row.model}`"
|
||||
class="cursor-pointer"
|
||||
@click="drillModel(row)"
|
||||
>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="statusDot(row.health)" aria-hidden="true"></span>
|
||||
<div>
|
||||
<span class="block text-xs text-gray-500 dark:text-dark-400">{{ row.platform }}</span>
|
||||
<strong class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ row.model === '__other__' ? t('channelMonitorV2.otherModels') : row.model }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="showThroughput">{{ formatRate(row.metrics.rpm) }}</td>
|
||||
<td v-if="showThroughput">{{ formatRate(row.metrics.tpm) }}</td>
|
||||
<td>
|
||||
<span class="block">{{ formatPercent(row.metrics.error_rate) }}</span>
|
||||
<small class="text-xs text-gray-400">{{ t('channelMonitorV2.metrics.successRateValue', { value: formatPercent(1 - row.metrics.error_rate) }) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block">{{ formatMs(row.metrics.ttft.p50_ms) }}</span>
|
||||
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.ttft) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block">{{ formatMs(row.metrics.duration.p50_ms) }}</span>
|
||||
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.duration) }}</small>
|
||||
</td>
|
||||
<td>{{ formatPercent(row.metrics.cache_rate) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'errors'" class="space-y-3">
|
||||
<div
|
||||
v-for="row in errorRows"
|
||||
:key="row.category"
|
||||
class="rounded-2xl bg-gray-50 p-4 text-sm dark:bg-dark-900/30"
|
||||
:class="row.ignored ? 'opacity-60' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[minmax(100px,200px)_1fr_auto_auto] items-center gap-3 text-left"
|
||||
@click="toggleError(row.category)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1.5 truncate text-gray-700 dark:text-gray-200">
|
||||
<span class="truncate">{{ errorLabel(row.category) }}</span>
|
||||
<span v-if="row.ignored" class="badge badge-gray shrink-0 !px-1.5 !py-0 text-[10px]">{{ t('channelMonitorV2.ignored') }}</span>
|
||||
</span>
|
||||
<span class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<i
|
||||
class="block h-full rounded-full"
|
||||
:class="row.ignored ? 'bg-gray-400 dark:bg-gray-500' : 'bg-gradient-to-r from-red-400 to-red-500'"
|
||||
:style="{ width: `${Math.max(2, row.rate * 100)}%` }"
|
||||
></i>
|
||||
</span>
|
||||
<small
|
||||
class="w-14 text-right text-xs tabular-nums"
|
||||
:class="row.ignored ? 'text-gray-400' : 'text-gray-500'"
|
||||
>{{ formatPercent(row.rate) }}</small>
|
||||
<Icon name="chevronDown" size="sm" :class="['text-gray-400 transition-transform', expandedErrors.has(row.category) ? 'rotate-180' : '']" />
|
||||
</button>
|
||||
<div v-if="expandedErrors.has(row.category)" class="mt-3 space-y-2 border-t border-gray-100 pt-3 dark:border-dark-700">
|
||||
<template v-if="isAdmin && (row.details || []).length">
|
||||
<div
|
||||
v-for="(detail, index) in row.details || []"
|
||||
:key="`${row.category}:${index}:${detail.message}`"
|
||||
class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-900/50 dark:text-dark-300"
|
||||
>
|
||||
<div class="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-gray !px-1.5 !py-0 text-[10px]">{{ detail.platform || '-' }}</span>
|
||||
<span class="truncate font-medium">{{ detail.model || '-' }}</span>
|
||||
<span v-if="detail.status_code" class="text-gray-400">{{ t('channelMonitorV2.errorDetail.http', { code: detail.status_code }) }}</span>
|
||||
<span v-if="detail.upstream_status_code" class="text-gray-400">{{ t('channelMonitorV2.errorDetail.upstream', { code: detail.upstream_status_code }) }}</span>
|
||||
<span class="ml-auto text-gray-400">×{{ detail.count }}</span>
|
||||
</div>
|
||||
<p class="break-words leading-relaxed">{{ detail.message || detail.error_type || t('channelMonitorV2.errorDetail.noMessage') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-xs text-gray-400">{{ t('channelMonitorV2.errorDetail.empty') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container border-0">
|
||||
<table class="table monitor-table min-w-[640px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-16">{{ t('channelMonitorV2.table.rank') }}</th>
|
||||
<th>{{ t('channelMonitorV2.table.user') }}</th>
|
||||
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.rpm') }}</th>
|
||||
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.tpm') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.errorRate') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.ttftP50') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.durationP50') }}</th>
|
||||
<th>{{ t('channelMonitorV2.metrics.cacheRate') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in userRows"
|
||||
:key="row.user_id || row.display_label"
|
||||
:class="row.is_self
|
||||
? 'bg-primary-50 ring-1 ring-inset ring-primary-200/80 dark:bg-primary-900/25 dark:ring-primary-700/50'
|
||||
: ''"
|
||||
>
|
||||
<td><MonitorRankBadge :rank="row.rank" /></td>
|
||||
<td>
|
||||
<strong
|
||||
class="font-semibold"
|
||||
:class="row.is_self ? 'text-primary-700 dark:text-primary-300' : 'text-gray-900 dark:text-white'"
|
||||
>
|
||||
{{ row.display_label }}
|
||||
<span
|
||||
v-if="row.is_self"
|
||||
class="badge badge-primary ml-2 !px-1.5 !py-0 text-[10px]"
|
||||
>{{ t('channelMonitorV2.currentUser') }}</span>
|
||||
</strong>
|
||||
</td>
|
||||
<td v-if="showThroughput">{{ formatRate(row.metrics.rpm) }}</td>
|
||||
<td v-if="showThroughput">{{ formatRate(row.metrics.tpm) }}</td>
|
||||
<td>
|
||||
<span class="block">{{ formatPercent(row.metrics.error_rate) }}</span>
|
||||
<small class="text-xs text-gray-400">{{ t('channelMonitorV2.metrics.successRateValue', { value: formatPercent(1 - row.metrics.error_rate) }) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block">{{ formatMs(row.metrics.ttft.p50_ms) }}</span>
|
||||
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.ttft) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block">{{ formatMs(row.metrics.duration.p50_ms) }}</span>
|
||||
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.duration) }}</small>
|
||||
</td>
|
||||
<td>{{ formatPercent(row.metrics.cache_rate) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="tabLoading" class="empty-state py-10 text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="activeRowsEmpty" class="empty-state py-10">
|
||||
<p class="empty-state-title text-base">{{ t('channelMonitorV2.empty.title') }}</p>
|
||||
<p class="empty-state-description">{{ t('channelMonitorV2.empty.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import FilterMultiSelect from '@/features/channel-monitor-v2/FilterMultiSelect.vue'
|
||||
import MetricCell from '@/features/channel-monitor-v2/MetricCell.vue'
|
||||
import MonitorRankBadge from '@/features/channel-monitor-v2/MonitorRankBadge.vue'
|
||||
import MonitorTrendChart from '@/features/channel-monitor-v2/MonitorTrendChart.vue'
|
||||
import RelayPulseMatrix from '@/features/channel-monitor-v2/RelayPulseMatrix.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { isChannelMonitorThroughputHidden } from '@/utils/featureFlags'
|
||||
import * as api from '@/api/channelMonitorV2'
|
||||
import type {
|
||||
HealthState,
|
||||
MonitorDimensions,
|
||||
MonitorErrorRow,
|
||||
MonitorFilter,
|
||||
MonitorHealth,
|
||||
MonitorMatrixGroupBy,
|
||||
MonitorMatrixResponse,
|
||||
MonitorModelRow,
|
||||
MonitorRange,
|
||||
MonitorSnapshot,
|
||||
MonitorUserRow,
|
||||
} from '@/api/channelMonitorV2'
|
||||
import {
|
||||
formatLatencyKpiSecondary,
|
||||
formatLatencyPrivacy,
|
||||
formatMonitorMs,
|
||||
formatMonitorPercent,
|
||||
formatMonitorThroughput,
|
||||
healthScoreClass,
|
||||
monitorErrorCategoryLabel,
|
||||
} from '@/features/channel-monitor-v2/monitorFormat'
|
||||
|
||||
type Tab = 'models' | 'errors' | 'users'
|
||||
type HealthMode = 'overall' | 'success' | 'ttft' | 'cache'
|
||||
type TrendView = 'pulse' | 'line'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
const { t, te, locale } = useI18n()
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
/** Admins always see RPM/TPM; users honor the hide-throughput system setting. */
|
||||
const showThroughput = computed(() => isAdmin.value || !isChannelMonitorThroughputHidden())
|
||||
|
||||
const ranges = computed(() => [
|
||||
{ value: '90m' as MonitorRange, label: t('channelMonitorV2.ranges.90m') },
|
||||
{ value: '24h' as MonitorRange, label: t('channelMonitorV2.ranges.24h') },
|
||||
{ value: '7d' as MonitorRange, label: t('channelMonitorV2.ranges.7d') },
|
||||
{ value: '30d' as MonitorRange, label: t('channelMonitorV2.ranges.30d') },
|
||||
])
|
||||
const tabs = computed(() => [
|
||||
{ value: 'models' as Tab, label: t('channelMonitorV2.tabs.models') },
|
||||
{ value: 'errors' as Tab, label: t('channelMonitorV2.tabs.errors') },
|
||||
{ value: 'users' as Tab, label: t('channelMonitorV2.tabs.users') },
|
||||
])
|
||||
const matrixGroupOptions = computed(() => [
|
||||
{ value: 'platform' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platform') },
|
||||
{ value: 'platform_group' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroup') },
|
||||
{ value: 'platform_model' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformModel') },
|
||||
{ value: 'platform_group_model' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroupModel') },
|
||||
])
|
||||
const healthModeOptions = computed(() => [
|
||||
{ value: 'overall' as HealthMode, label: t('channelMonitorV2.healthMode.overall') },
|
||||
{ value: 'success' as HealthMode, label: t('channelMonitorV2.healthMode.success') },
|
||||
{ value: 'ttft' as HealthMode, label: t('channelMonitorV2.healthMode.ttft') },
|
||||
{ value: 'cache' as HealthMode, label: t('channelMonitorV2.healthMode.cache') },
|
||||
])
|
||||
|
||||
const filter = ref<MonitorFilter>({
|
||||
range: parseRange(route.query.range),
|
||||
platforms: csv(route.query.platform),
|
||||
groupIds: csv(route.query.group).map(Number).filter(Boolean),
|
||||
models: csv(route.query.model),
|
||||
})
|
||||
const activeTab = ref<Tab>(
|
||||
(['models', 'errors', 'users'].includes(String(route.query.tab)) ? route.query.tab : 'models') as Tab
|
||||
)
|
||||
const matrixGroupBy = ref<MonitorMatrixGroupBy>(parseMatrixGroupBy(route.query.group_by))
|
||||
const healthMode = ref<HealthMode>(parseHealthMode(route.query.health_mode))
|
||||
const trendView = ref<TrendView>(parseTrendView(route.query.trend_view))
|
||||
const dimensions = ref<MonitorDimensions>({ platforms: [], groups: [], models: [] })
|
||||
const snapshot = ref<MonitorSnapshot | null>(null)
|
||||
const matrix = ref<MonitorMatrixResponse | null>(null)
|
||||
const modelRows = ref<MonitorModelRow[]>([])
|
||||
const errorRows = ref<MonitorErrorRow[]>([])
|
||||
const userRows = ref<MonitorUserRow[]>([])
|
||||
const loading = ref(false)
|
||||
const tabLoading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const expandedErrors = ref(new Set<string>())
|
||||
let controller: AbortController | null = null
|
||||
let sequence = 0
|
||||
let autoRefreshTimer: number | null = null
|
||||
|
||||
const hasDimensionFilter = computed(
|
||||
() => filter.value.platforms.length + filter.value.groupIds.length + filter.value.models.length > 0
|
||||
)
|
||||
// Full platform catalog (never pruned). Groups/models cascade by selected platforms
|
||||
// so choosing a platform narrows the other pickers without collapsing platforms.
|
||||
const platformOptions = computed(() =>
|
||||
(dimensions.value.platforms || []).map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))
|
||||
)
|
||||
const selectedPlatforms = computed(() => new Set(filter.value.platforms))
|
||||
const groupOptions = computed(() =>
|
||||
(dimensions.value.groups || [])
|
||||
.filter(
|
||||
(item) =>
|
||||
selectedPlatforms.value.size === 0 ||
|
||||
!item.platform ||
|
||||
selectedPlatforms.value.has(item.platform),
|
||||
)
|
||||
.map((item) => ({
|
||||
value: String(item.id),
|
||||
label: item.platform ? `${item.platform} / ${item.name || `#${item.id}`}` : item.name || `#${item.id}`,
|
||||
}))
|
||||
)
|
||||
const modelOptions = computed(() =>
|
||||
(dimensions.value.models || [])
|
||||
.filter(
|
||||
(item) =>
|
||||
selectedPlatforms.value.size === 0 ||
|
||||
!item.platform ||
|
||||
selectedPlatforms.value.has(item.platform),
|
||||
)
|
||||
.map((item) => ({
|
||||
value: item.value,
|
||||
label:
|
||||
item.platform && !item.label.includes(item.platform)
|
||||
? `${item.platform} / ${item.label}`
|
||||
: item.label,
|
||||
}))
|
||||
)
|
||||
const selectedGroupIds = computed({
|
||||
get: () => filter.value.groupIds.map(String),
|
||||
set: (value: string[]) => {
|
||||
filter.value.groupIds = value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
||||
},
|
||||
})
|
||||
// Soft-prune group/model selections that fall outside the platform cascade.
|
||||
// Do NOT wipe when options are temporarily empty (loading); only drop invalid ids.
|
||||
watch(
|
||||
[groupOptions, modelOptions],
|
||||
() => {
|
||||
if (groupOptions.value.length > 0) {
|
||||
const allowed = new Set(groupOptions.value.map((item) => item.value))
|
||||
const next = filter.value.groupIds.filter((id) => allowed.has(String(id)))
|
||||
if (next.length !== filter.value.groupIds.length) {
|
||||
filter.value.groupIds = next
|
||||
}
|
||||
}
|
||||
if (modelOptions.value.length > 0) {
|
||||
const allowed = new Set(modelOptions.value.map((item) => item.value))
|
||||
const next = filter.value.models.filter((model) => allowed.has(model))
|
||||
if (next.length !== filter.value.models.length) {
|
||||
filter.value.models = next
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
const activeRowsEmpty = computed(() =>
|
||||
activeTab.value === 'models'
|
||||
? modelRows.value.length === 0
|
||||
: activeTab.value === 'errors'
|
||||
? errorRows.value.length === 0
|
||||
: userRows.value.length === 0
|
||||
)
|
||||
const matrixRows = computed(() => {
|
||||
const items = matrix.value?.items || []
|
||||
// platform_group views should only show real groups, never bare platform placeholders.
|
||||
if (matrixGroupBy.value === 'platform_group' || matrixGroupBy.value === 'platform_group_model') {
|
||||
return items.filter((row) => row.group_id != null && Number(row.group_id) > 0)
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
function csv(value: unknown) {
|
||||
return typeof value === 'string' ? value.split(',').filter(Boolean) : []
|
||||
}
|
||||
function parseRange(value: unknown): MonitorRange {
|
||||
return ['90m', '24h', '7d', '30d'].includes(String(value)) ? (value as MonitorRange) : '90m'
|
||||
}
|
||||
function parseMatrixGroupBy(value: unknown): MonitorMatrixGroupBy {
|
||||
const allowed: MonitorMatrixGroupBy[] = [
|
||||
'platform',
|
||||
'platform_group',
|
||||
'platform_model',
|
||||
'platform_group_model',
|
||||
]
|
||||
return allowed.includes(value as MonitorMatrixGroupBy)
|
||||
? (value as MonitorMatrixGroupBy)
|
||||
: 'platform_group'
|
||||
}
|
||||
function parseHealthMode(value: unknown): HealthMode {
|
||||
const allowed: HealthMode[] = ['overall', 'success', 'ttft', 'cache']
|
||||
return allowed.includes(value as HealthMode) ? (value as HealthMode) : 'overall'
|
||||
}
|
||||
function parseTrendView(value: unknown): TrendView {
|
||||
return value === 'line' ? 'line' : 'pulse'
|
||||
}
|
||||
function syncQuery() {
|
||||
void router.replace({
|
||||
query: {
|
||||
range: filter.value.range,
|
||||
platform: filter.value.platforms.join(',') || undefined,
|
||||
group: filter.value.groupIds.join(',') || undefined,
|
||||
model: filter.value.models.join(',') || undefined,
|
||||
group_by: matrixGroupBy.value,
|
||||
health_mode: healthMode.value,
|
||||
trend_view: trendView.value === 'line' ? 'line' : undefined,
|
||||
tab: activeTab.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/** Dimensions catalog: range only — never re-filtered by platform/group/model selection. */
|
||||
async function loadDimensions(signal?: AbortSignal, id = sequence) {
|
||||
const rangeOnly: MonitorFilter = {
|
||||
range: filter.value.range,
|
||||
platforms: [],
|
||||
groupIds: [],
|
||||
models: [],
|
||||
}
|
||||
const next = await api.getDimensions(rangeOnly, isAdmin.value, signal)
|
||||
if (id !== sequence) return
|
||||
dimensions.value = next
|
||||
}
|
||||
|
||||
async function loadMetrics(signal?: AbortSignal, id = sequence) {
|
||||
const [nextSnapshot, nextMatrix] = await Promise.all([
|
||||
api.getSnapshot(filter.value, isAdmin.value, signal),
|
||||
api.getMatrix(filter.value, matrixGroupBy.value, isAdmin.value, signal),
|
||||
])
|
||||
if (id !== sequence) return
|
||||
snapshot.value = nextSnapshot
|
||||
matrix.value = nextMatrix
|
||||
scheduleAutoRefresh()
|
||||
await loadTab(signal, id)
|
||||
}
|
||||
|
||||
async function reload(silent = true) {
|
||||
controller?.abort()
|
||||
const request = new AbortController()
|
||||
controller = request
|
||||
const id = ++sequence
|
||||
refreshing.value = true
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
// Catalog + metrics in parallel; catalog ignores dimension filters so options never shrink.
|
||||
await Promise.all([
|
||||
loadDimensions(request.signal, id),
|
||||
loadMetrics(request.signal, id),
|
||||
])
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name !== 'CanceledError') {
|
||||
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.loadFailed')))
|
||||
}
|
||||
} finally {
|
||||
if (id === sequence) {
|
||||
loading.value = false
|
||||
tabLoading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When only range changes, still refresh dimensions; dimension filters only re-load metrics. */
|
||||
async function reloadMetricsOnly(silent = true) {
|
||||
controller?.abort()
|
||||
const request = new AbortController()
|
||||
controller = request
|
||||
const id = ++sequence
|
||||
refreshing.value = true
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
await loadMetrics(request.signal, id)
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name !== 'CanceledError') {
|
||||
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.loadFailed')))
|
||||
}
|
||||
} finally {
|
||||
if (id === sequence) {
|
||||
loading.value = false
|
||||
tabLoading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
async function loadTab(signal?: AbortSignal, id = sequence) {
|
||||
tabLoading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'models') {
|
||||
modelRows.value = (await api.getModels(filter.value, isAdmin.value, signal)).items || []
|
||||
} else if (activeTab.value === 'errors') {
|
||||
errorRows.value = (await api.getErrors(filter.value, isAdmin.value, signal)).items || []
|
||||
} else {
|
||||
userRows.value = (await api.getUsers(filter.value, isAdmin.value, signal)).items || []
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as { name?: string; code?: string }
|
||||
if (e?.name === 'AbortError' || e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
|
||||
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.detailLoadFailed')))
|
||||
} finally {
|
||||
if (id === sequence) tabLoading.value = false
|
||||
}
|
||||
}
|
||||
function setRange(value: MonitorRange) {
|
||||
filter.value.range = value
|
||||
}
|
||||
function clearDimensions() {
|
||||
// Replace arrays so deep watch always fires and metrics reload full window.
|
||||
filter.value = {
|
||||
...filter.value,
|
||||
platforms: [],
|
||||
groupIds: [],
|
||||
models: [],
|
||||
}
|
||||
}
|
||||
function scheduleAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
window.clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
const seconds = snapshot.value?.config?.refresh_interval_seconds || 300
|
||||
autoRefreshTimer = window.setInterval(() => {
|
||||
if (!loading.value && !refreshing.value) {
|
||||
void reload(true)
|
||||
}
|
||||
}, Math.max(60, seconds) * 1000)
|
||||
}
|
||||
function drillModel(row: MonitorModelRow) {
|
||||
filter.value.platforms = [row.platform]
|
||||
filter.value.models = [row.model]
|
||||
}
|
||||
function formatRate(value: number) {
|
||||
return formatMonitorThroughput(value)
|
||||
}
|
||||
function exactRate(value: number) {
|
||||
return Intl.NumberFormat(locale.value || undefined, { maximumFractionDigits: 2 }).format(value || 0)
|
||||
}
|
||||
function formatPercent(value: number) {
|
||||
return formatMonitorPercent(value)
|
||||
}
|
||||
function formatMs(value: number | null) {
|
||||
return formatMonitorMs(value)
|
||||
}
|
||||
function latencyDetail(metric: {
|
||||
p50_ms: number | null
|
||||
p90_ms?: number | null
|
||||
p95_ms: number | null
|
||||
avg_ms?: number | null
|
||||
}) {
|
||||
return formatLatencyPrivacy(metric.p50_ms, metric.p90_ms, metric.avg_ms, metric.p95_ms)
|
||||
}
|
||||
/** KPI secondary: AVG · P90 under the P50 primary value. */
|
||||
function latencyKpiSecondary(metric: {
|
||||
p90_ms?: number | null
|
||||
p95_ms: number | null
|
||||
avg_ms?: number | null
|
||||
}) {
|
||||
return formatLatencyKpiSecondary(metric.avg_ms, metric.p90_ms, metric.p95_ms)
|
||||
}
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
function statusDot(health?: MonitorHealth | HealthState) {
|
||||
if (!health || typeof health === 'string') {
|
||||
return `status-dot health-${health || 'unknown'}`
|
||||
}
|
||||
// Prefer multi-band score when available; otherwise fall back to the coarse
|
||||
// overall state for mixed-version/older payloads.
|
||||
const klass =
|
||||
health.score != null
|
||||
? healthScoreClass(health, 'overall', 0)
|
||||
: `health-${health.overall || 'unknown'}`
|
||||
return `status-dot ${klass}`
|
||||
}
|
||||
function errorLabel(value: string) {
|
||||
const key = `channelMonitorV2.errorCategories.${value}`
|
||||
return te(key) ? t(key) : monitorErrorCategoryLabel(value)
|
||||
}
|
||||
function toggleError(category: string) {
|
||||
const next = new Set(expandedErrors.value)
|
||||
if (next.has(category)) next.delete(category)
|
||||
else next.add(category)
|
||||
expandedErrors.value = next
|
||||
}
|
||||
|
||||
let lastRange: MonitorRange = filter.value.range
|
||||
watch(
|
||||
filter,
|
||||
() => {
|
||||
syncQuery()
|
||||
const rangeChanged = filter.value.range !== lastRange
|
||||
lastRange = filter.value.range
|
||||
if (rangeChanged) void reload(true)
|
||||
else void reloadMetricsOnly(true)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(matrixGroupBy, () => {
|
||||
syncQuery()
|
||||
void reloadMetricsOnly(true)
|
||||
})
|
||||
watch(healthMode, syncQuery)
|
||||
watch(trendView, syncQuery)
|
||||
watch(activeTab, () => {
|
||||
syncQuery()
|
||||
void loadTab()
|
||||
})
|
||||
onMounted(() => void reload(false))
|
||||
onBeforeUnmount(() => {
|
||||
controller?.abort()
|
||||
if (autoRefreshTimer) window.clearInterval(autoRefreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
height: 0.5rem;
|
||||
width: 0.5rem;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
/* Multi-stop green → yellow → red score bands */
|
||||
.health-score10 { background: #16a34a; }
|
||||
.health-score9 { background: #22c55e; }
|
||||
.health-score8 { background: #4ade80; }
|
||||
.health-score7 { background: #a3e635; }
|
||||
.health-score6 { background: #facc15; }
|
||||
.health-score5 { background: #fbbf24; }
|
||||
.health-score4 { background: #f59e0b; }
|
||||
.health-score3 { background: #f97316; }
|
||||
.health-score2 { background: #fb7185; }
|
||||
.health-score1 { background: #f87171; }
|
||||
.health-score0 { background: rgb(239, 67, 67); }
|
||||
.health-healthy { background: #22c55e; }
|
||||
.health-warning { background: #f59e0b; }
|
||||
.health-critical { background: #ef4444; }
|
||||
.health-unknown { background: #9ca3af; }
|
||||
.matrix-select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,172 +1,13 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<MonitorHero
|
||||
:overall-status="overallStatus"
|
||||
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
|
||||
:window="currentWindow"
|
||||
:loading="loading"
|
||||
:auto-refresh="autoRefresh"
|
||||
@update:window="handleWindowChange"
|
||||
@refresh="manualReload"
|
||||
/>
|
||||
|
||||
<MonitorCardGrid
|
||||
:items="items"
|
||||
:window="currentWindow"
|
||||
:countdown-seconds="countdown"
|
||||
:loading="loading"
|
||||
:detail-cache="detailCache"
|
||||
@card-click="openDetail"
|
||||
/>
|
||||
|
||||
<MonitorDetailDialog
|
||||
:show="showDetail"
|
||||
:monitor-id="detailTarget?.id ?? null"
|
||||
:title="detailTitle"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
</AppLayout>
|
||||
<ChannelStatusV1View v-if="isV1" />
|
||||
<ChannelStatusV2View v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import {
|
||||
list as listChannelMonitorViews,
|
||||
status as fetchChannelMonitorDetail,
|
||||
type UserMonitorView,
|
||||
type UserMonitorDetail,
|
||||
} from '@/api/channelMonitor'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import MonitorHero, {
|
||||
type MonitorWindow,
|
||||
type OverallStatus,
|
||||
} from '@/components/user/monitor/MonitorHero.vue'
|
||||
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'
|
||||
import { computed } from 'vue'
|
||||
import { isChannelMonitorV1Mode } from '@/utils/featureFlags'
|
||||
import ChannelStatusV1View from './ChannelStatusV1View.vue'
|
||||
import ChannelStatusV2View from './ChannelStatusV2View.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ── State ──
|
||||
const items = ref<UserMonitorView[]>([])
|
||||
const loading = ref(false)
|
||||
const currentWindow = ref<MonitorWindow>('7d')
|
||||
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
|
||||
const showDetail = ref(false)
|
||||
const detailTarget = ref<UserMonitorView | 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 ──
|
||||
const overallStatus = computed<OverallStatus>(() => {
|
||||
if (items.value.length === 0) return 'operational'
|
||||
for (const it of items.value) {
|
||||
if (it.primary_status === 'failed' || it.primary_status === 'error') return 'degraded'
|
||||
if (it.primary_status !== STATUS_OPERATIONAL) return 'degraded'
|
||||
}
|
||||
return 'operational'
|
||||
})
|
||||
|
||||
const detailTitle = computed(() => {
|
||||
return detailTarget.value?.name || t('channelStatus.detailTitle')
|
||||
})
|
||||
|
||||
// ── Loaders ──
|
||||
async function reload(silent = false) {
|
||||
if (abortController) abortController.abort()
|
||||
const ctrl = new AbortController()
|
||||
abortController = ctrl
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const res = await listChannelMonitorViews({ signal: ctrl.signal })
|
||||
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||||
items.value = res.items || []
|
||||
} catch (err: unknown) {
|
||||
const e = err as { name?: string; code?: string }
|
||||
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
|
||||
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
|
||||
} finally {
|
||||
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')))
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function openDetail(row: UserMonitorView) {
|
||||
detailTarget.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
showDetail.value = false
|
||||
detailTarget.value = null
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
void ensureDetailsForWindow()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
|
||||
(enabled) => {
|
||||
if (enabled === false) autoRefresh.stop()
|
||||
else if (autoRefresh.enabled.value) autoRefresh.start()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void reload(false)
|
||||
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
|
||||
autoRefresh.setEnabled(true)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (abortController) abortController.abort()
|
||||
})
|
||||
const isV1 = computed(() => isChannelMonitorV1Mode())
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const isV1 = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/utils/featureFlags', () => ({
|
||||
isChannelMonitorV1Mode: () => isV1(),
|
||||
}))
|
||||
|
||||
vi.mock('../ChannelStatusV1View.vue', () => ({
|
||||
default: defineComponent({ name: 'ChannelStatusV1View', setup: () => () => h('div', { 'data-testid': 'v1' }) }),
|
||||
}))
|
||||
vi.mock('../ChannelStatusV2View.vue', () => ({
|
||||
default: defineComponent({ name: 'ChannelStatusV2View', setup: () => () => h('div', { 'data-testid': 'v2' }) }),
|
||||
}))
|
||||
|
||||
import ChannelStatusView from '../ChannelStatusView.vue'
|
||||
|
||||
describe('ChannelStatusView mode switch', () => {
|
||||
beforeEach(() => {
|
||||
isV1.mockReset()
|
||||
})
|
||||
|
||||
it('renders V2 when not in v1 mode', () => {
|
||||
isV1.mockReturnValue(false)
|
||||
const wrapper = mount(ChannelStatusView)
|
||||
expect(wrapper.find('[data-testid="v2"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="v1"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders V1 when in v1 mode', () => {
|
||||
isV1.mockReturnValue(true)
|
||||
const wrapper = mount(ChannelStatusView)
|
||||
expect(wrapper.find('[data-testid="v1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="v2"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user