feat(channel-monitor): redesign user dashboard as card grid
Reference check-cx UI: INTELLIGENCE MONITOR hero + 3-column card grid with 60-point timeline bars. Backend: - Add PrimaryPingLatencyMs + Timeline[60] to UserMonitorView - ListRecentHistoryForMonitors: batch CTE + ROW_NUMBER() window query - indexLatestByModel / indexAvailabilityByModel helpers Frontend: - 7 new components: ProviderIcon, MonitorMetricPair, MonitorAvailabilityRow, MonitorTimeline, MonitorHero, MonitorCard, MonitorCardGrid - ChannelStatusView 381→~180 lines (delegated to subcomponents) - AbortController reload concurrency protection - HSL 0-120° availability color mapping - Replace emoji with Icon component (bolt / globe) - i18n: monitorCommon.* shared namespace, channelStatus.hero.* Bump VERSION to 0.1.114.24
This commit is contained in:
@@ -14,6 +14,13 @@ export interface UserMonitorExtraModel {
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
export interface MonitorTimelinePoint {
|
||||
status: MonitorStatus
|
||||
latency_ms: number | null
|
||||
ping_latency_ms: number | null
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface UserMonitorView {
|
||||
id: number
|
||||
name: string
|
||||
@@ -22,8 +29,10 @@ export interface UserMonitorView {
|
||||
primary_model: string
|
||||
primary_status: MonitorStatus
|
||||
primary_latency_ms: number | null
|
||||
primary_ping_latency_ms: number | null
|
||||
availability_7d: number
|
||||
extra_models: UserMonitorExtraModel[]
|
||||
timeline: MonitorTimelinePoint[]
|
||||
}
|
||||
|
||||
export interface UserMonitorListResponse {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
|
||||
<HelpTooltip>
|
||||
<template #trigger>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
:class="statusBadgeClass(row.primary_status)"
|
||||
>
|
||||
{{ statusLabel(row.primary_status) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold text-gray-100">
|
||||
{{ row.primary_model }}
|
||||
<span
|
||||
class="ml-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
:class="statusBadgeClass(row.primary_status)"
|
||||
>
|
||||
{{ statusLabel(row.primary_status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="(row.extra_models?.length ?? 0) === 0" class="text-[11px] text-gray-300">
|
||||
{{ t('monitorCommon.extraModelsEmpty') }}
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-400">
|
||||
{{ t('monitorCommon.extraModelsHeader') }}
|
||||
</div>
|
||||
<table class="w-full text-left text-[11px]">
|
||||
<thead>
|
||||
<tr class="text-gray-400">
|
||||
<th class="py-0.5 pr-2 font-medium">{{ t('channelStatus.detailColumns.model') }}</th>
|
||||
<th class="py-0.5 pr-2 font-medium">{{ t('channelStatus.detailColumns.latestStatus') }}</th>
|
||||
<th class="py-0.5 font-medium">{{ t('channelStatus.detailColumns.latestLatency') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in row.extra_models" :key="m.model">
|
||||
<td class="py-0.5 pr-2 text-gray-100">{{ m.model }}</td>
|
||||
<td class="py-0.5 pr-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
:class="statusBadgeClass(m.status)"
|
||||
>
|
||||
{{ statusLabel(m.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-0.5 text-gray-100">{{ formatLatency(m.latency_ms) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UserMonitorView } from '@/api/channelMonitor'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||
|
||||
defineProps<{
|
||||
row: UserMonitorView
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { statusLabel, statusBadgeClass, formatLatency } = useChannelMonitorFormat()
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="mt-3 flex items-end justify-between">
|
||||
<div class="text-[11px] uppercase tracking-widest text-gray-400">
|
||||
{{ windowLabel }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<span
|
||||
class="text-3xl font-bold tabular-nums leading-none"
|
||||
:style="colorStyle"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span
|
||||
class="text-base font-semibold leading-none"
|
||||
:style="colorStyle"
|
||||
>%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="samplesLabel"
|
||||
class="mt-1 text-[11px] text-gray-400 text-right"
|
||||
>
|
||||
{{ samplesLabel }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { hslForPct } from '@/composables/useChannelMonitorFormat'
|
||||
|
||||
const props = defineProps<{
|
||||
windowLabel: string
|
||||
value: number | null
|
||||
samplesLabel?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.value === null || Number.isNaN(props.value)) return t('monitorCommon.latencyEmpty')
|
||||
return props.value.toFixed(2)
|
||||
})
|
||||
|
||||
const colorStyle = computed(() => {
|
||||
const colour = hslForPct(props.value)
|
||||
return colour ? { color: colour } : { color: 'rgb(156 163 175)' }
|
||||
})
|
||||
</script>
|
||||
128
frontend/src/components/user/monitor/MonitorCard.vue
Normal file
128
frontend/src/components/user/monitor/MonitorCard.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="group text-left p-5 rounded-2xl min-h-[280px] w-full bg-white/70 backdrop-blur-xl border border-gray-200/80 shadow-card dark:bg-dark-800/60 dark:border-dark-700/70 hover:-translate-y-1 hover:shadow-card-hover dark:hover:border-primary-500/30 hover:border-gray-300 transition-all duration-300 ease-out flex flex-col"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Header: icon + name/model + status chip -->
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="w-9 h-9 rounded-xl ring-1 ring-black/5 dark:ring-white/10 grid place-items-center flex-shrink-0"
|
||||
:class="[providerGradient(item.provider), providerTintClass]"
|
||||
>
|
||||
<ProviderIcon :provider="item.provider" :size="20" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-base font-semibold truncate text-gray-900 dark:text-gray-100">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0"
|
||||
:class="providerBadgeClass(item.provider)"
|
||||
>
|
||||
{{ providerLabel(item.provider) }}
|
||||
</span>
|
||||
<span class="font-mono text-xs truncate text-gray-500 dark:text-gray-400">
|
||||
{{ item.primary_model }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.group_name"
|
||||
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300 flex-shrink-0"
|
||||
>
|
||||
{{ item.group_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-full text-xs font-semibold flex-shrink-0"
|
||||
:class="statusBadgeClass(item.primary_status)"
|
||||
>
|
||||
{{ statusLabel(item.primary_status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics -->
|
||||
<MonitorMetricPair
|
||||
primary-icon="bolt"
|
||||
:primary-label="t('monitorCommon.dialogLatency')"
|
||||
:primary-value="formatLatency(item.primary_latency_ms)"
|
||||
primary-unit="ms"
|
||||
secondary-icon="globe"
|
||||
:secondary-label="t('monitorCommon.endpointPing')"
|
||||
:secondary-value="formatLatency(item.primary_ping_latency_ms)"
|
||||
secondary-unit="ms"
|
||||
/>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="mt-4 border-t border-gray-100 dark:border-dark-700/60"></div>
|
||||
|
||||
<!-- Availability row -->
|
||||
<MonitorAvailabilityRow
|
||||
:window-label="availabilityLabel"
|
||||
:value="availabilityValue"
|
||||
:samples-label="extraModelsCountLabel"
|
||||
/>
|
||||
|
||||
<!-- Timeline -->
|
||||
<MonitorTimeline
|
||||
:buckets="item.timeline"
|
||||
:countdown-seconds="countdownSeconds"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UserMonitorView } from '@/api/channelMonitor'
|
||||
import {
|
||||
useChannelMonitorFormat,
|
||||
providerGradient,
|
||||
} from '@/composables/useChannelMonitorFormat'
|
||||
import ProviderIcon from './ProviderIcon.vue'
|
||||
import MonitorMetricPair from './MonitorMetricPair.vue'
|
||||
import MonitorAvailabilityRow from './MonitorAvailabilityRow.vue'
|
||||
import MonitorTimeline from './MonitorTimeline.vue'
|
||||
|
||||
const PROVIDER_TINT: Record<string, string> = {
|
||||
openai: 'text-emerald-600 dark:text-emerald-300',
|
||||
anthropic: 'text-orange-600 dark:text-orange-300',
|
||||
gemini: 'text-sky-600 dark:text-sky-300',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
item: UserMonitorView
|
||||
window: '7d' | '15d' | '30d'
|
||||
availabilityValue: number | null
|
||||
countdownSeconds: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
statusLabel,
|
||||
statusBadgeClass,
|
||||
providerLabel,
|
||||
providerBadgeClass,
|
||||
formatLatency,
|
||||
} = useChannelMonitorFormat()
|
||||
|
||||
const providerTintClass = computed(() =>
|
||||
PROVIDER_TINT[props.item.provider] ?? 'text-gray-500 dark:text-gray-300'
|
||||
)
|
||||
|
||||
const availabilityLabel = computed(() => {
|
||||
const win = t(`channelStatus.windowTab.${props.window}`)
|
||||
return `${t('monitorCommon.availabilityPrefix')} · ${win}`
|
||||
})
|
||||
|
||||
const extraModelsCountLabel = computed(() => {
|
||||
const count = props.item.extra_models?.length ?? 0
|
||||
if (count === 0) return undefined
|
||||
return t('monitorCommon.extraModelsCount', { n: count })
|
||||
})
|
||||
</script>
|
||||
81
frontend/src/components/user/monitor/MonitorCardGrid.vue
Normal file
81
frontend/src/components/user/monitor/MonitorCardGrid.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="loading && items.length === 0"
|
||||
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="i in 6"
|
||||
:key="i"
|
||||
class="p-5 rounded-2xl min-h-[280px] bg-white/70 dark:bg-dark-800/60 border border-gray-200/80 dark:border-dark-700/70 animate-pulse"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 w-2/3 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-3 w-1/2 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
<div class="h-6 w-16 rounded-full bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-2 gap-2">
|
||||
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
|
||||
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
|
||||
</div>
|
||||
<div class="mt-6 h-5 w-full rounded bg-gray-100 dark:bg-dark-900/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="items.length === 0"
|
||||
:title="t('channelStatus.empty.title')"
|
||||
:description="t('channelStatus.empty.description')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
>
|
||||
<MonitorCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:window="window"
|
||||
:availability-value="resolveAvailability(item)"
|
||||
:countdown-seconds="countdownSeconds"
|
||||
@click="emit('cardClick', item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UserMonitorView, UserMonitorDetail } from '@/api/channelMonitor'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import MonitorCard from './MonitorCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: UserMonitorView[]
|
||||
window: '7d' | '15d' | '30d'
|
||||
countdownSeconds: number
|
||||
loading: boolean
|
||||
detailCache: Record<number, UserMonitorDetail>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cardClick', item: UserMonitorView): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function resolveAvailability(item: UserMonitorView): number | null {
|
||||
if (props.window === '7d') {
|
||||
return item.availability_7d ?? null
|
||||
}
|
||||
const detail = props.detailCache[item.id]
|
||||
if (!detail) return null
|
||||
const primary = detail.models.find(m => m.model === item.primary_model)
|
||||
if (!primary) return null
|
||||
return props.window === '15d' ? primary.availability_15d ?? null : primary.availability_30d ?? null
|
||||
}
|
||||
</script>
|
||||
133
frontend/src/components/user/monitor/MonitorHero.vue
Normal file
133
frontend/src/components/user/monitor/MonitorHero.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<section class="pt-8 pb-10 md:pb-14">
|
||||
<div class="text-xs font-medium tracking-widest uppercase text-gray-400 dark:text-gray-500 mb-4">
|
||||
{{ t('channelStatus.hero.breadcrumb') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h1
|
||||
class="text-5xl md:text-6xl xl:text-7xl font-bold leading-[1.05] tracking-tight text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{{ t('channelStatus.hero.title') }}
|
||||
</h1>
|
||||
<p class="mt-4 text-sm md:text-base text-gray-500 dark:text-gray-400 max-w-xl">
|
||||
{{ t('channelStatus.hero.subtitleZh') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs md:text-sm italic opacity-80 text-gray-500 dark:text-gray-400 max-w-xl">
|
||||
{{ t('channelStatus.hero.subtitleEn') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start md:items-end gap-2.5">
|
||||
<div
|
||||
role="tablist"
|
||||
class="inline-flex p-0.5 rounded-xl bg-gray-100 dark:bg-dark-800 border border-gray-200/60 dark:border-dark-700/60 text-xs"
|
||||
>
|
||||
<button
|
||||
v-for="opt in windowOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="window === opt.value"
|
||||
class="px-3 py-1.5 rounded-lg transition-colors"
|
||||
:class="window === opt.value
|
||||
? 'bg-white dark:bg-dark-700 shadow-sm text-gray-900 dark:text-white font-semibold'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
@click="emit('update:window', opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold tracking-wider uppercase"
|
||||
:class="overallChipClass"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full mr-1.5"
|
||||
:class="overallDotClass"
|
||||
></span>
|
||||
{{ overallLabel }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 w-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-dark-700 transition-colors disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
:title="t('common.refresh')"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 tabular-nums text-right">
|
||||
{{ updatedLabel }}<span v-if="intervalSeconds > 0"> · {{ t('monitorCommon.pollEvery', { n: intervalSeconds }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||
|
||||
export type MonitorWindow = '7d' | '15d' | '30d'
|
||||
export type OverallStatus = 'operational' | 'degraded' | 'unavailable'
|
||||
|
||||
const props = defineProps<{
|
||||
overallStatus: OverallStatus
|
||||
updatedAt: string | null
|
||||
intervalSeconds: number
|
||||
window: MonitorWindow
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:window', value: MonitorWindow): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatRelativeTime } = useChannelMonitorFormat()
|
||||
|
||||
const windowOptions = computed<{ value: MonitorWindow; label: string }[]>(() => [
|
||||
{ value: '7d', label: t('channelStatus.windowTab.7d') },
|
||||
{ value: '15d', label: t('channelStatus.windowTab.15d') },
|
||||
{ value: '30d', label: t('channelStatus.windowTab.30d') },
|
||||
])
|
||||
|
||||
const overallLabel = computed(() => t(`channelStatus.overall.${props.overallStatus}`))
|
||||
|
||||
const overallChipClass = computed(() => {
|
||||
switch (props.overallStatus) {
|
||||
case 'operational':
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
|
||||
case 'degraded':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
|
||||
case 'unavailable':
|
||||
default:
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300'
|
||||
}
|
||||
})
|
||||
|
||||
const overallDotClass = computed(() => {
|
||||
switch (props.overallStatus) {
|
||||
case 'operational':
|
||||
return 'bg-emerald-500 animate-pulse'
|
||||
case 'degraded':
|
||||
return 'bg-amber-500 animate-pulse'
|
||||
case 'unavailable':
|
||||
default:
|
||||
return 'bg-red-500 animate-pulse'
|
||||
}
|
||||
})
|
||||
|
||||
const updatedLabel = computed(() => {
|
||||
if (!props.updatedAt) return t('monitorCommon.updatedAt', { time: '--' })
|
||||
return t('monitorCommon.updatedAt', { time: formatRelativeTime(props.updatedAt) })
|
||||
})
|
||||
</script>
|
||||
45
frontend/src/components/user/monitor/MonitorMetricPair.vue
Normal file
45
frontend/src/components/user/monitor/MonitorMetricPair.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="mt-5 grid grid-cols-2 gap-2">
|
||||
<div
|
||||
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
<Icon :name="primaryIcon" size="xs" />
|
||||
<span>{{ primaryLabel }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
|
||||
{{ primaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ primaryUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
<Icon :name="secondaryIcon" size="xs" />
|
||||
<span>{{ secondaryLabel }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
|
||||
{{ secondaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ secondaryUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
defineProps<{
|
||||
primaryLabel: string
|
||||
primaryValue: string
|
||||
primaryUnit: string
|
||||
primaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
|
||||
secondaryLabel: string
|
||||
secondaryValue: string
|
||||
secondaryUnit: string
|
||||
secondaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
|
||||
}>()
|
||||
</script>
|
||||
113
frontend/src/components/user/monitor/MonitorTimeline.vue
Normal file
113
frontend/src/components/user/monitor/MonitorTimeline.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="mt-4 pt-3 border-t border-gray-100 dark:border-dark-700/60">
|
||||
<div
|
||||
class="flex justify-between text-[10px] font-semibold uppercase tracking-widest text-gray-400 mb-2"
|
||||
>
|
||||
<span>{{ t('monitorCommon.history60pts', { n: length }) }}</span>
|
||||
<span class="tabular-nums">{{ t('monitorCommon.nextUpdateIn', { n: countdownSeconds }) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="maintenance"
|
||||
class="flex h-5 w-full items-center justify-center rounded border border-dashed border-gray-300 dark:border-dark-600 text-[10px] uppercase tracking-widest text-gray-400"
|
||||
>
|
||||
{{ t('monitorCommon.maintenancePaused') }}
|
||||
</div>
|
||||
<div v-else class="flex items-end gap-[2px] h-5 w-full">
|
||||
<div
|
||||
v-for="(bar, idx) in displayBars"
|
||||
:key="idx"
|
||||
class="flex-1 min-w-[3px] rounded-sm"
|
||||
:class="bar.colorClass"
|
||||
:style="{ height: bar.heightPct + '%' }"
|
||||
:title="bar.title"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-1 flex justify-between text-[9px] uppercase tracking-widest text-gray-400"
|
||||
>
|
||||
<span>{{ t('monitorCommon.past') }}</span>
|
||||
<span>{{ t('monitorCommon.now') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MonitorTimelinePoint } from '@/api/channelMonitor'
|
||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
buckets?: MonitorTimelinePoint[]
|
||||
countdownSeconds: number
|
||||
length?: number
|
||||
maintenance?: boolean
|
||||
}>(), {
|
||||
buckets: () => [],
|
||||
length: 60,
|
||||
maintenance: false,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { statusLabel, formatLatency, formatRelativeTime } = useChannelMonitorFormat()
|
||||
|
||||
interface Bar {
|
||||
colorClass: string
|
||||
heightPct: number
|
||||
title: string
|
||||
}
|
||||
|
||||
const STATUS_HEIGHT: Record<string, number> = {
|
||||
operational: 100,
|
||||
degraded: 70,
|
||||
failed: 55,
|
||||
error: 35,
|
||||
empty: 20,
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
operational: 'bg-emerald-500',
|
||||
degraded: 'bg-amber-500',
|
||||
failed: 'bg-red-500',
|
||||
error: 'bg-gray-400 dark:bg-dark-500',
|
||||
empty: 'bg-gray-300 dark:bg-dark-600',
|
||||
}
|
||||
|
||||
const displayBars = computed<Bar[]>(() => {
|
||||
// Real points come newest-first; convert to oldest-first so the rightmost
|
||||
// bar represents "now". Pad the left with empty placeholders to keep the
|
||||
// bar count stable at `length`.
|
||||
const real = [...(props.buckets ?? [])]
|
||||
.slice(0, props.length)
|
||||
.reverse()
|
||||
|
||||
const padCount = Math.max(0, props.length - real.length)
|
||||
const bars: Bar[] = []
|
||||
|
||||
for (let i = 0; i < padCount; i += 1) {
|
||||
bars.push({
|
||||
colorClass: STATUS_COLOR.empty,
|
||||
heightPct: STATUS_HEIGHT.empty,
|
||||
title: '',
|
||||
})
|
||||
}
|
||||
|
||||
for (const point of real) {
|
||||
const status = point.status as keyof typeof STATUS_HEIGHT
|
||||
const colorClass = STATUS_COLOR[status] ?? STATUS_COLOR.empty
|
||||
const heightPct = STATUS_HEIGHT[status] ?? STATUS_HEIGHT.empty
|
||||
const latency = formatLatency(point.latency_ms)
|
||||
const relative = formatRelativeTime(point.checked_at)
|
||||
const label = statusLabel(point.status)
|
||||
bars.push({
|
||||
colorClass,
|
||||
heightPct,
|
||||
title: `${relative} · ${label} · ${latency}ms`,
|
||||
})
|
||||
}
|
||||
|
||||
return bars
|
||||
})
|
||||
</script>
|
||||
71
frontend/src/components/user/monitor/ProviderIcon.vue
Normal file
71
frontend/src/components/user/monitor/ProviderIcon.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<svg
|
||||
v-if="iconInfo"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-for="(p, idx) in iconInfo.paths"
|
||||
:key="idx"
|
||||
:d="p"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center justify-center font-bold text-gray-500"
|
||||
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
|
||||
>
|
||||
{{ fallbackText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Provider } from '@/api/admin/channelMonitor'
|
||||
|
||||
interface IconData {
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
// Provider SVG paths extracted from src/components/common/ModelIcon.vue (which
|
||||
// in turn pulls from @lobehub/icons Mono.js). Keep in sync if upstream changes.
|
||||
// SVG uses fill="currentColor" so the wrapper controls the icon tint.
|
||||
const PROVIDER_ICONS: Record<Provider, IconData> = {
|
||||
openai: {
|
||||
paths: [
|
||||
'M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z',
|
||||
],
|
||||
},
|
||||
anthropic: {
|
||||
paths: [
|
||||
'M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z',
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
paths: [
|
||||
'M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
provider: Provider | string
|
||||
size?: number
|
||||
}>(), {
|
||||
size: 20,
|
||||
})
|
||||
|
||||
const iconInfo = computed<IconData | null>(() => {
|
||||
const key = props.provider as Provider
|
||||
return PROVIDER_ICONS[key] ?? null
|
||||
})
|
||||
|
||||
const fallbackText = computed(() =>
|
||||
(props.provider || '?').charAt(0).toUpperCase()
|
||||
)
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@
|
||||
* Centralises:
|
||||
* - status / provider label + badge class lookups
|
||||
* - latency / availability / percent number formatting
|
||||
* - dashboard-style helpers (HSL for availability, provider gradient, relative time)
|
||||
*
|
||||
* i18n keys live under `monitorCommon.*` so admin and user views share the
|
||||
* same translation source.
|
||||
@@ -23,6 +24,11 @@ import {
|
||||
|
||||
const NEUTRAL_BADGE = 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-300'
|
||||
|
||||
/** Availability HSL hue multiplier: 0%=red(0) / 50%=yellow(60) / 100%=green(120). */
|
||||
const HSL_HUE_PER_PERCENT = 1.2
|
||||
const HSL_SATURATION = 72
|
||||
const HSL_LIGHTNESS = 42
|
||||
|
||||
export interface AvailabilityRow {
|
||||
primary_status: MonitorStatus | ''
|
||||
availability_7d: number | null | undefined
|
||||
@@ -39,11 +45,11 @@ export function useChannelMonitorFormat() {
|
||||
function statusBadgeClass(s: MonitorStatus | ''): string {
|
||||
switch (s) {
|
||||
case STATUS_OPERATIONAL:
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
|
||||
case STATUS_DEGRADED:
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
|
||||
case STATUS_FAILED:
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300'
|
||||
case STATUS_ERROR:
|
||||
default:
|
||||
return NEUTRAL_BADGE
|
||||
@@ -60,11 +66,11 @@ export function useChannelMonitorFormat() {
|
||||
function providerBadgeClass(p: Provider | string): string {
|
||||
switch (p) {
|
||||
case PROVIDER_OPENAI:
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
|
||||
case PROVIDER_ANTHROPIC:
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300'
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300'
|
||||
case PROVIDER_GEMINI:
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
|
||||
default:
|
||||
return NEUTRAL_BADGE
|
||||
}
|
||||
@@ -85,6 +91,20 @@ export function useChannelMonitorFormat() {
|
||||
return formatPercent(row.availability_7d)
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return t('monitorCommon.latencyEmpty')
|
||||
const ts = Date.parse(iso)
|
||||
if (Number.isNaN(ts)) return t('monitorCommon.latencyEmpty')
|
||||
const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
||||
if (diffSec < 60) return t('monitorCommon.relativeSecondsAgo', { n: diffSec })
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
if (diffMin < 60) return t('monitorCommon.relativeMinutesAgo', { n: diffMin })
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
if (diffHour < 24) return t('monitorCommon.relativeHoursAgo', { n: diffHour })
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
return t('monitorCommon.relativeDaysAgo', { n: diffDay })
|
||||
}
|
||||
|
||||
return {
|
||||
statusLabel,
|
||||
statusBadgeClass,
|
||||
@@ -93,5 +113,33 @@ export function useChannelMonitorFormat() {
|
||||
formatLatency,
|
||||
formatPercent,
|
||||
formatAvailability,
|
||||
formatRelativeTime,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map availability percent to an HSL colour (red -> yellow -> green).
|
||||
* Returns undefined for null/NaN so callers can fall back to a neutral colour.
|
||||
*/
|
||||
export function hslForPct(pct: number | null | undefined): string | undefined {
|
||||
if (pct === null || pct === undefined || Number.isNaN(pct)) return undefined
|
||||
const clamped = Math.max(0, Math.min(100, pct))
|
||||
const hue = clamped * HSL_HUE_PER_PERCENT
|
||||
return `hsl(${hue} ${HSL_SATURATION}% ${HSL_LIGHTNESS}%)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind gradient class for the provider icon tile background.
|
||||
*/
|
||||
export function providerGradient(provider: string): string {
|
||||
switch (provider) {
|
||||
case PROVIDER_OPENAI:
|
||||
return 'bg-gradient-to-br from-emerald-50 to-emerald-100 dark:from-emerald-500/10 dark:to-emerald-500/20'
|
||||
case PROVIDER_ANTHROPIC:
|
||||
return 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-500/10 dark:to-amber-500/20'
|
||||
case PROVIDER_GEMINI:
|
||||
return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20'
|
||||
default:
|
||||
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +867,22 @@ export default {
|
||||
},
|
||||
extraModelsHeader: 'Extra Models',
|
||||
extraModelsEmpty: 'No extra models',
|
||||
latencyEmpty: '-'
|
||||
latencyEmpty: '-',
|
||||
availabilityPrefix: 'Availability',
|
||||
dialogLatency: 'Dialog Latency',
|
||||
endpointPing: 'Endpoint PING',
|
||||
history60pts: 'HISTORY ({n} PTS)',
|
||||
nextUpdateIn: 'NEXT UPDATE IN {n}s',
|
||||
past: 'PAST',
|
||||
now: 'NOW',
|
||||
maintenancePaused: 'Maintenance · timeline paused',
|
||||
extraModelsCount: '+ {n} models',
|
||||
pollEvery: '{n}s polling',
|
||||
updatedAt: 'Updated {time}',
|
||||
relativeSecondsAgo: '{n}s ago',
|
||||
relativeMinutesAgo: '{n}m ago',
|
||||
relativeHoursAgo: '{n}h ago',
|
||||
relativeDaysAgo: '{n}d ago'
|
||||
},
|
||||
|
||||
// Channel Status (user-facing read-only view)
|
||||
@@ -880,6 +895,22 @@ export default {
|
||||
detailLoadError: 'Failed to load channel detail',
|
||||
detailTitle: 'Channel Detail',
|
||||
closeDetail: 'Close',
|
||||
hero: {
|
||||
breadcrumb: 'CHANNEL · STATUS',
|
||||
title: 'INTELLIGENCE MONITOR',
|
||||
subtitleZh: 'Real-time tracking of availability, latency and status for leading AI endpoints.',
|
||||
subtitleEn: 'Advanced performance metrics for next-gen intelligence.'
|
||||
},
|
||||
windowTab: {
|
||||
'7d': '7 days',
|
||||
'15d': '15 days',
|
||||
'30d': '30 days'
|
||||
},
|
||||
overall: {
|
||||
operational: 'OPERATIONAL',
|
||||
degraded: 'DEGRADED',
|
||||
unavailable: 'UNAVAILABLE'
|
||||
},
|
||||
columns: {
|
||||
name: 'Name',
|
||||
provider: 'Provider',
|
||||
|
||||
@@ -871,7 +871,22 @@ export default {
|
||||
},
|
||||
extraModelsHeader: '附加模型',
|
||||
extraModelsEmpty: '无附加模型',
|
||||
latencyEmpty: '-'
|
||||
latencyEmpty: '-',
|
||||
availabilityPrefix: '可用性',
|
||||
dialogLatency: '对话延迟',
|
||||
endpointPing: '端点 PING',
|
||||
history60pts: '近 {n} 次记录',
|
||||
nextUpdateIn: '{n}s 后刷新',
|
||||
past: 'PAST',
|
||||
now: 'NOW',
|
||||
maintenancePaused: '维护中 · 已暂停时间线采集',
|
||||
extraModelsCount: '+ {n} 模型',
|
||||
pollEvery: '{n}s 轮询',
|
||||
updatedAt: '更新于 {time}',
|
||||
relativeSecondsAgo: '{n} 秒前',
|
||||
relativeMinutesAgo: '{n} 分钟前',
|
||||
relativeHoursAgo: '{n} 小时前',
|
||||
relativeDaysAgo: '{n} 天前'
|
||||
},
|
||||
|
||||
// Channel Status (user-facing read-only view)
|
||||
@@ -884,6 +899,22 @@ export default {
|
||||
detailLoadError: '加载渠道详情失败',
|
||||
detailTitle: '渠道详情',
|
||||
closeDetail: '关闭',
|
||||
hero: {
|
||||
breadcrumb: '渠道 · 状态',
|
||||
title: 'INTELLIGENCE MONITOR',
|
||||
subtitleZh: '实时追踪各大 AI 模型对话接口的可用性、延迟与官方服务状态。',
|
||||
subtitleEn: 'Advanced performance metrics for next-gen intelligence.'
|
||||
},
|
||||
windowTab: {
|
||||
'7d': '7 天',
|
||||
'15d': '15 天',
|
||||
'30d': '30 天'
|
||||
},
|
||||
overall: {
|
||||
operational: 'OPERATIONAL',
|
||||
degraded: 'DEGRADED',
|
||||
unavailable: 'UNAVAILABLE'
|
||||
},
|
||||
columns: {
|
||||
name: '名称',
|
||||
provider: '供应商',
|
||||
|
||||
@@ -1,93 +1,23 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<div class="relative w-full sm:w-64">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('channelStatus.searchPlaceholder')"
|
||||
class="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<MonitorHero
|
||||
:overall-status="overallStatus"
|
||||
:updated-at="updatedAt"
|
||||
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
|
||||
:window="currentWindow"
|
||||
:loading="loading"
|
||||
@update:window="handleWindowChange"
|
||||
@refresh="manualReload"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-model="providerFilter"
|
||||
:options="providerFilterOptions"
|
||||
:placeholder="t('channelStatus.allProviders')"
|
||||
class="w-44"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto">
|
||||
<button
|
||||
@click="reload"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="filteredItems" :loading="loading">
|
||||
<template #cell-name="{ row }">
|
||||
<button
|
||||
@click="openDetail(row)"
|
||||
class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ row.name }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-provider="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
:class="providerBadgeClass(row.provider)"
|
||||
>
|
||||
{{ providerLabel(row.provider) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-group_name="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-primary_model="{ row }">
|
||||
<MonitorPrimaryModelCell :row="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-availability_7d="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatAvailability(row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-latency="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatLatency(row.primary_latency_ms) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('channelStatus.empty.title')"
|
||||
:description="t('channelStatus.empty.description')"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
<MonitorCardGrid
|
||||
:items="items"
|
||||
:window="currentWindow"
|
||||
:countdown-seconds="countdown"
|
||||
:loading="loading"
|
||||
:detail-cache="detailCache"
|
||||
@card-click="openDetail"
|
||||
/>
|
||||
|
||||
<MonitorDetailDialog
|
||||
:show="showDetail"
|
||||
@@ -99,79 +29,54 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
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,
|
||||
type Provider,
|
||||
status as fetchChannelMonitorDetail,
|
||||
type UserMonitorView,
|
||||
type UserMonitorDetail,
|
||||
} from '@/api/channelMonitor'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.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 MonitorPrimaryModelCell from '@/components/user/MonitorPrimaryModelCell.vue'
|
||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||
import {
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
} from '@/constants/channelMonitor'
|
||||
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const {
|
||||
providerLabel,
|
||||
providerBadgeClass,
|
||||
formatLatency,
|
||||
formatAvailability,
|
||||
} = useChannelMonitorFormat()
|
||||
|
||||
// ── State ──
|
||||
const items = ref<UserMonitorView[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const providerFilter = ref<Provider | ''>('')
|
||||
const updatedAt = ref<string | null>(null)
|
||||
const currentWindow = ref<MonitorWindow>('7d')
|
||||
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
|
||||
const countdown = ref(DEFAULT_INTERVAL_SECONDS)
|
||||
|
||||
const showDetail = ref(false)
|
||||
const detailTarget = ref<UserMonitorView | null>(null)
|
||||
|
||||
// ── Options ──
|
||||
const providerFilterOptions = computed(() => [
|
||||
{ value: '', label: t('channelStatus.allProviders') },
|
||||
{ value: PROVIDER_OPENAI, label: providerLabel(PROVIDER_OPENAI) },
|
||||
{ value: PROVIDER_ANTHROPIC, label: providerLabel(PROVIDER_ANTHROPIC) },
|
||||
{ value: PROVIDER_GEMINI, label: providerLabel(PROVIDER_GEMINI) },
|
||||
])
|
||||
let countdownTimer: number | undefined
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// ── Columns ──
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('channelStatus.columns.name'), sortable: false },
|
||||
{ key: 'provider', label: t('channelStatus.columns.provider'), sortable: false },
|
||||
{ key: 'group_name', label: t('channelStatus.columns.groupName'), sortable: false },
|
||||
{ key: 'primary_model', label: t('channelStatus.columns.primaryModel'), sortable: false },
|
||||
{ key: 'availability_7d', label: t('channelStatus.columns.availability7d'), sortable: false },
|
||||
{ key: 'latency', label: t('channelStatus.columns.latency'), sortable: false },
|
||||
])
|
||||
|
||||
// ── Filtered data ──
|
||||
const filteredItems = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return items.value.filter(it => {
|
||||
if (providerFilter.value && it.provider !== providerFilter.value) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
it.name.toLowerCase().includes(q) ||
|
||||
(it.group_name || '').toLowerCase().includes(q) ||
|
||||
it.primary_model.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
// ── 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'
|
||||
})
|
||||
|
||||
const detailTitle = computed(() => {
|
||||
@@ -179,18 +84,58 @@ const detailTitle = computed(() => {
|
||||
})
|
||||
|
||||
// ── Loaders ──
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
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()
|
||||
const res = await listChannelMonitorViews({ signal: ctrl.signal })
|
||||
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||||
items.value = res.items || []
|
||||
updatedAt.value = new Date().toISOString()
|
||||
} 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 {
|
||||
loading.value = false
|
||||
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
|
||||
@@ -201,8 +146,28 @@ 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()
|
||||
})
|
||||
|
||||
// ── Lifecycle ──
|
||||
onMounted(() => {
|
||||
reload()
|
||||
void reload(false)
|
||||
countdownTimer = setInterval(tick, 1000) as unknown as number
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countdownTimer !== undefined) clearInterval(countdownTimer)
|
||||
if (abortController) abortController.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user