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:
@@ -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>
|
||||
Reference in New Issue
Block a user