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:
erio
2026-04-20 23:38:59 +08:00
parent 20a4e41872
commit a1425b457d
19 changed files with 1134 additions and 278 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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'
}
}

View File

@@ -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',

View File

@@ -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: '供应商',

View File

@@ -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>