Files
sub2api/frontend/src/components/user/monitor/MonitorTimeline.vue
erio a1425b457d 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
2026-04-20 23:38:59 +08:00

114 lines
3.2 KiB
Vue

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