mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
632 lines
20 KiB
Vue
632 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import uPlot from 'uplot'
|
|
import 'uplot/dist/uPlot.min.css'
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import type { WebRTCStats } from '@/composables/useWebRTC'
|
|
import { formatFpsValue } from '@/lib/fps'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps<{
|
|
open: boolean
|
|
videoMode: 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
|
// MJPEG stats
|
|
mjpegFps?: number
|
|
wsLatency?: number
|
|
// WebRTC stats
|
|
webrtcStats?: WebRTCStats
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:open', value: boolean): void
|
|
}>()
|
|
|
|
const stabilityChartRef = ref<HTMLDivElement | null>(null)
|
|
const delayChartRef = ref<HTMLDivElement | null>(null)
|
|
const packetLossChartRef = ref<HTMLDivElement | null>(null)
|
|
const fpsChartRef = ref<HTMLDivElement | null>(null)
|
|
|
|
let stabilityChart: uPlot | null = null
|
|
let delayChart: uPlot | null = null
|
|
let packetLossChart: uPlot | null = null
|
|
let fpsChart: uPlot | null = null
|
|
|
|
const MAX_POINTS = 120
|
|
const timestamps = ref<number[]>([])
|
|
const jitterHistory = ref<number[]>([])
|
|
const delayHistory = ref<number[]>([])
|
|
const packetLossHistory = ref<number[]>([])
|
|
const fpsHistory = ref<number[]>([])
|
|
const bitrateHistory = ref<number[]>([])
|
|
|
|
let lastBytesReceived = 0
|
|
let lastPacketsLost = 0
|
|
let lastTimestamp = 0
|
|
|
|
// Is WebRTC mode
|
|
const isWebRTC = computed(() => props.videoMode !== 'mjpeg')
|
|
|
|
function formatTime(ts: number): string {
|
|
const date = new Date(ts * 1000)
|
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
const chartColors = {
|
|
line: '#3b82f6',
|
|
fill: 'rgba(59, 130, 246, 0.1)',
|
|
grid: 'rgba(148, 163, 184, 0.1)',
|
|
axis: '#64748b',
|
|
text: '#94a3b8',
|
|
}
|
|
|
|
function createChartOptions(
|
|
container: HTMLElement,
|
|
_yLabel: string,
|
|
yFormatter: (v: number) => string
|
|
): uPlot.Options {
|
|
const width = container.clientWidth || 300
|
|
|
|
return {
|
|
width,
|
|
height: 100,
|
|
cursor: {
|
|
show: true,
|
|
x: true,
|
|
y: false,
|
|
drag: { x: false, y: false },
|
|
},
|
|
legend: { show: false },
|
|
scales: {
|
|
x: { time: false },
|
|
y: { auto: true, range: (_u, min, max) => [Math.max(0, min - 1), max + 1] },
|
|
},
|
|
axes: [
|
|
{
|
|
show: true,
|
|
stroke: chartColors.axis,
|
|
grid: { show: false },
|
|
ticks: { show: false },
|
|
gap: 4,
|
|
size: 20,
|
|
values: (_, splits) => splits.map(v => formatTime(v)),
|
|
font: '10px system-ui',
|
|
},
|
|
{
|
|
show: true,
|
|
side: 1, // Right side
|
|
stroke: chartColors.axis,
|
|
size: 55,
|
|
gap: 8,
|
|
grid: { stroke: chartColors.grid, width: 1 },
|
|
values: (_, splits) => splits.map(v => yFormatter(v)),
|
|
font: '10px system-ui',
|
|
},
|
|
],
|
|
series: [
|
|
{},
|
|
{
|
|
stroke: chartColors.line,
|
|
width: 1.5,
|
|
fill: chartColors.fill,
|
|
paths: uPlot.paths.spline?.() || undefined,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
const activeTooltip = ref<{
|
|
chartId: string
|
|
time: string
|
|
value: string
|
|
unit: string
|
|
left: number
|
|
top: number
|
|
visible: boolean
|
|
}>({
|
|
chartId: '',
|
|
time: '',
|
|
value: '',
|
|
unit: '',
|
|
left: 0,
|
|
top: 0,
|
|
visible: false,
|
|
})
|
|
|
|
function createTooltipPlugin(chartId: string, unit: string): uPlot.Plugin {
|
|
return {
|
|
hooks: {
|
|
setCursor: [
|
|
(u) => {
|
|
const idx = u.cursor.idx
|
|
if (idx !== null && idx !== undefined && u.cursor.left !== undefined && u.cursor.top !== undefined) {
|
|
const ts = u.data[0]?.[idx]
|
|
const val = u.data[1]?.[idx]
|
|
if (ts !== undefined && ts !== null && val !== undefined && val !== null) {
|
|
const date = new Date(ts * 1000)
|
|
activeTooltip.value = {
|
|
chartId,
|
|
time: date.toLocaleTimeString('zh-CN'),
|
|
value: val.toFixed(1),
|
|
unit,
|
|
left: u.cursor.left,
|
|
top: u.cursor.top,
|
|
visible: true,
|
|
}
|
|
}
|
|
}
|
|
},
|
|
],
|
|
ready: [
|
|
(u) => {
|
|
const over = u.over
|
|
over.addEventListener('mouseleave', () => {
|
|
if (activeTooltip.value.chartId === chartId) {
|
|
activeTooltip.value.visible = false
|
|
}
|
|
})
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
function initCharts() {
|
|
if (!props.open) return
|
|
|
|
nextTick(() => {
|
|
if (timestamps.value.length === 0) {
|
|
const now = Date.now() / 1000
|
|
for (let i = MAX_POINTS - 1; i >= 0; i--) {
|
|
timestamps.value.push(now - i)
|
|
}
|
|
jitterHistory.value = new Array(MAX_POINTS).fill(0)
|
|
delayHistory.value = new Array(MAX_POINTS).fill(0)
|
|
packetLossHistory.value = new Array(MAX_POINTS).fill(0)
|
|
fpsHistory.value = new Array(MAX_POINTS).fill(0)
|
|
bitrateHistory.value = new Array(MAX_POINTS).fill(0)
|
|
}
|
|
|
|
if (stabilityChartRef.value && !stabilityChart) {
|
|
const opts = createChartOptions(stabilityChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
|
|
opts.plugins = [createTooltipPlugin('stability', 'ms')]
|
|
stabilityChart = new uPlot(
|
|
opts,
|
|
[timestamps.value, jitterHistory.value],
|
|
stabilityChartRef.value
|
|
)
|
|
}
|
|
|
|
if (delayChartRef.value && !delayChart) {
|
|
const opts = createChartOptions(delayChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
|
|
opts.plugins = [createTooltipPlugin('delay', 'ms')]
|
|
delayChart = new uPlot(
|
|
opts,
|
|
[timestamps.value, delayHistory.value],
|
|
delayChartRef.value
|
|
)
|
|
}
|
|
|
|
// Packet Loss Chart
|
|
if (packetLossChartRef.value && !packetLossChart) {
|
|
const opts = createChartOptions(packetLossChartRef.value, '', (v) => `${v.toFixed(0)} 个`)
|
|
opts.plugins = [createTooltipPlugin('packetLoss', '个')]
|
|
packetLossChart = new uPlot(
|
|
opts,
|
|
[timestamps.value, packetLossHistory.value],
|
|
packetLossChartRef.value
|
|
)
|
|
}
|
|
|
|
if (fpsChartRef.value && !fpsChart) {
|
|
const opts = createChartOptions(fpsChartRef.value, 'fps', (v) => `${v.toFixed(0)} fps`)
|
|
opts.plugins = [createTooltipPlugin('fps', 'fps')]
|
|
fpsChart = new uPlot(
|
|
opts,
|
|
[timestamps.value, fpsHistory.value],
|
|
fpsChartRef.value
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
function destroyCharts() {
|
|
stabilityChart?.destroy()
|
|
stabilityChart = null
|
|
delayChart?.destroy()
|
|
delayChart = null
|
|
packetLossChart?.destroy()
|
|
packetLossChart = null
|
|
fpsChart?.destroy()
|
|
fpsChart = null
|
|
}
|
|
|
|
function addDataPoint() {
|
|
const now = Date.now() / 1000
|
|
|
|
timestamps.value.push(now)
|
|
if (timestamps.value.length > MAX_POINTS) {
|
|
timestamps.value.shift()
|
|
}
|
|
|
|
if (isWebRTC.value && props.webrtcStats) {
|
|
const jitter = (props.webrtcStats.jitter || 0) * 1000
|
|
jitterHistory.value.push(jitter)
|
|
|
|
const rtt = (props.webrtcStats.roundTripTime || 0) * 1000
|
|
delayHistory.value.push(rtt)
|
|
|
|
// Packet loss delta
|
|
const currentLost = props.webrtcStats.packetsLost || 0
|
|
const lostDelta = lastPacketsLost > 0 ? Math.max(0, currentLost - lastPacketsLost) : 0
|
|
lastPacketsLost = currentLost
|
|
packetLossHistory.value.push(lostDelta)
|
|
|
|
fpsHistory.value.push(props.webrtcStats.framesPerSecond || 0)
|
|
|
|
const currentBytes = props.webrtcStats.bytesReceived || 0
|
|
const currentTime = Date.now()
|
|
if (lastTimestamp > 0 && currentBytes > lastBytesReceived) {
|
|
const timeDiff = (currentTime - lastTimestamp) / 1000
|
|
const bytesDiff = currentBytes - lastBytesReceived
|
|
const bitrate = (bytesDiff * 8) / (timeDiff * 1000000)
|
|
bitrateHistory.value.push(Math.round(bitrate * 100) / 100)
|
|
} else {
|
|
bitrateHistory.value.push(bitrateHistory.value[bitrateHistory.value.length - 1] || 0)
|
|
}
|
|
lastBytesReceived = currentBytes
|
|
lastTimestamp = currentTime
|
|
} else {
|
|
// MJPEG mode
|
|
jitterHistory.value.push(0)
|
|
delayHistory.value.push(props.wsLatency || 0)
|
|
packetLossHistory.value.push(0)
|
|
fpsHistory.value.push(props.mjpegFps || 0)
|
|
bitrateHistory.value.push(0)
|
|
}
|
|
|
|
if (jitterHistory.value.length > MAX_POINTS) jitterHistory.value.shift()
|
|
if (delayHistory.value.length > MAX_POINTS) delayHistory.value.shift()
|
|
if (packetLossHistory.value.length > MAX_POINTS) packetLossHistory.value.shift()
|
|
if (fpsHistory.value.length > MAX_POINTS) fpsHistory.value.shift()
|
|
if (bitrateHistory.value.length > MAX_POINTS) bitrateHistory.value.shift()
|
|
|
|
updateCharts()
|
|
}
|
|
|
|
function updateCharts() {
|
|
stabilityChart?.setData([timestamps.value, jitterHistory.value])
|
|
delayChart?.setData([timestamps.value, delayHistory.value])
|
|
packetLossChart?.setData([timestamps.value, packetLossHistory.value])
|
|
fpsChart?.setData([timestamps.value, fpsHistory.value])
|
|
}
|
|
|
|
let dataInterval: number | null = null
|
|
|
|
function startDataCollection() {
|
|
if (dataInterval) return
|
|
dataInterval = window.setInterval(addDataPoint, 1000)
|
|
}
|
|
|
|
function stopDataCollection() {
|
|
if (dataInterval) {
|
|
clearInterval(dataInterval)
|
|
dataInterval = null
|
|
}
|
|
}
|
|
|
|
function formatCandidateType(type: string): string {
|
|
const typeMap: Record<string, string> = {
|
|
host: 'Host (Local)',
|
|
srflx: 'STUN (NAT)',
|
|
prflx: 'Peer Reflexive',
|
|
relay: 'TURN Relay',
|
|
unknown: '-',
|
|
}
|
|
return typeMap[type] || type
|
|
}
|
|
|
|
// Current stats for header display
|
|
const currentStats = computed(() => {
|
|
if (isWebRTC.value && props.webrtcStats) {
|
|
const lastBitrate = bitrateHistory.value[bitrateHistory.value.length - 1]
|
|
const bitrate = lastBitrate !== undefined ? lastBitrate : 0
|
|
return {
|
|
jitter: Math.round((props.webrtcStats.jitter || 0) * 1000 * 10) / 10,
|
|
delay: Math.round((props.webrtcStats.roundTripTime || 0) * 1000),
|
|
fps: props.webrtcStats.framesPerSecond || 0,
|
|
resolution: props.webrtcStats.frameWidth && props.webrtcStats.frameHeight
|
|
? `${props.webrtcStats.frameWidth}x${props.webrtcStats.frameHeight}`
|
|
: '-',
|
|
bitrate: bitrate.toFixed(2),
|
|
packetsLost: props.webrtcStats.packetsLost || 0,
|
|
// ICE connection info
|
|
isRelay: props.webrtcStats.isRelay || false,
|
|
transport: (props.webrtcStats.transportProtocol || '-').toUpperCase(),
|
|
localType: formatCandidateType(props.webrtcStats.localCandidateType || 'unknown'),
|
|
remoteType: formatCandidateType(props.webrtcStats.remoteCandidateType || 'unknown'),
|
|
}
|
|
}
|
|
return {
|
|
jitter: 0,
|
|
delay: props.wsLatency || 0,
|
|
fps: props.mjpegFps || 0,
|
|
resolution: '-',
|
|
bitrate: '0',
|
|
packetsLost: 0,
|
|
isRelay: false,
|
|
transport: '-',
|
|
localType: '-',
|
|
remoteType: '-',
|
|
}
|
|
})
|
|
|
|
watch(() => props.open, (isOpen) => {
|
|
if (isOpen) {
|
|
timestamps.value = []
|
|
jitterHistory.value = []
|
|
delayHistory.value = []
|
|
packetLossHistory.value = []
|
|
fpsHistory.value = []
|
|
bitrateHistory.value = []
|
|
lastBytesReceived = 0
|
|
lastPacketsLost = 0
|
|
lastTimestamp = 0
|
|
|
|
setTimeout(() => {
|
|
initCharts()
|
|
startDataCollection()
|
|
}, 150)
|
|
} else {
|
|
stopDataCollection()
|
|
destroyCharts()
|
|
}
|
|
})
|
|
|
|
function handleResize() {
|
|
if (!props.open) return
|
|
destroyCharts()
|
|
setTimeout(initCharts, 50)
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', handleResize)
|
|
if (props.open) {
|
|
initCharts()
|
|
startDataCollection()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleResize)
|
|
stopDataCollection()
|
|
destroyCharts()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
|
<SheetContent
|
|
side="right"
|
|
class="w-[90vw] max-w-[440px] p-0 border-l border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950"
|
|
>
|
|
<!-- Header -->
|
|
<SheetHeader class="px-6 py-3 border-b border-slate-200 dark:border-slate-800">
|
|
<div class="flex items-center gap-2">
|
|
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
|
|
<span class="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-muted-foreground">
|
|
{{ isWebRTC ? 'WebRTC' : 'MJPEG' }}
|
|
</span>
|
|
</div>
|
|
</SheetHeader>
|
|
|
|
<ScrollArea class="h-[calc(100dvh-60px)]">
|
|
<div class="px-6 py-4 space-y-6">
|
|
<!-- Video Section Header -->
|
|
<div>
|
|
<h3 class="text-sm font-medium">{{ t('stats.video') }}</h3>
|
|
<p class="text-xs text-muted-foreground mt-0.5">
|
|
{{ t('stats.videoDesc') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Network Stability (Jitter) -->
|
|
<div class="space-y-2" v-if="isWebRTC">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-medium">{{ t('stats.stability') }}</h4>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ t('stats.stabilityDesc') }}
|
|
</p>
|
|
<div class="relative">
|
|
<div
|
|
ref="stabilityChartRef"
|
|
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
|
/>
|
|
<div
|
|
v-if="activeTooltip.visible && activeTooltip.chartId === 'stability'"
|
|
class="chart-tooltip"
|
|
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
|
|
>
|
|
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
|
|
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Playback Delay -->
|
|
<div class="space-y-2" v-if="isWebRTC">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-medium">{{ t('stats.delay') }}</h4>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ currentStats.delay }} ms
|
|
</span>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ t('stats.delayDesc') }}
|
|
</p>
|
|
<div class="relative">
|
|
<div
|
|
ref="delayChartRef"
|
|
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
|
/>
|
|
<div
|
|
v-if="activeTooltip.visible && activeTooltip.chartId === 'delay'"
|
|
class="chart-tooltip"
|
|
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
|
|
>
|
|
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
|
|
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Packet Loss -->
|
|
<div class="space-y-2" v-if="isWebRTC">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-medium">{{ t('stats.packetLoss') }}</h4>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ currentStats.packetsLost }} {{ t('stats.total') }}
|
|
</span>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ t('stats.packetLossDesc') }}
|
|
</p>
|
|
<div class="relative">
|
|
<div
|
|
ref="packetLossChartRef"
|
|
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
|
/>
|
|
<div
|
|
v-if="activeTooltip.visible && activeTooltip.chartId === 'packetLoss'"
|
|
class="chart-tooltip"
|
|
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
|
|
>
|
|
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
|
|
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FPS -->
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-medium">{{ t('stats.frameRate') }}</h4>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ formatFpsValue(currentStats.fps) }} fps
|
|
</span>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ t('stats.frameRateDesc') }}
|
|
</p>
|
|
<div class="relative">
|
|
<div
|
|
ref="fpsChartRef"
|
|
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
|
/>
|
|
<div
|
|
v-if="activeTooltip.visible && activeTooltip.chartId === 'fps'"
|
|
class="chart-tooltip"
|
|
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
|
|
>
|
|
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
|
|
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Additional Stats -->
|
|
<div class="space-y-3 pt-2 border-t border-slate-200 dark:border-slate-800" v-if="isWebRTC">
|
|
<h4 class="text-sm font-medium">{{ t('stats.additional') }}</h4>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
|
|
<p class="text-xs text-muted-foreground">{{ t('stats.resolution') }}</p>
|
|
<p class="text-sm font-medium mt-1">{{ currentStats.resolution }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
|
|
<p class="text-xs text-muted-foreground">{{ t('stats.bitrate') }}</p>
|
|
<p class="text-sm font-medium mt-1">{{ currentStats.bitrate }} Mbps</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connection Info -->
|
|
<h4 class="text-sm font-medium pt-2">{{ t('stats.connection') }}</h4>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
|
|
<p class="text-xs text-muted-foreground">{{ t('stats.connectionType') }}</p>
|
|
<p class="text-sm font-medium mt-1 flex items-center gap-1.5">
|
|
<span
|
|
:class="[
|
|
'inline-block w-2 h-2 rounded-full',
|
|
currentStats.isRelay ? 'bg-amber-500' : 'bg-green-500'
|
|
]"
|
|
/>
|
|
{{ currentStats.isRelay ? t('stats.relay') : t('stats.p2p') }}
|
|
</p>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
|
|
<p class="text-xs text-muted-foreground">{{ t('stats.transport') }}</p>
|
|
<p class="text-sm font-medium mt-1">{{ currentStats.transport }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
|
|
<p class="text-xs text-muted-foreground">{{ t('stats.localCandidate') }}</p>
|
|
<p class="text-sm font-medium mt-1">{{ currentStats.localType }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
|
|
<p class="text-xs text-muted-foreground">{{ t('stats.remoteCandidate') }}</p>
|
|
<p class="text-sm font-medium mt-1">{{ currentStats.remoteType }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</template>
|
|
|
|
<style>
|
|
/* Override uPlot styles for dark mode */
|
|
.dark .u-wrap {
|
|
background: transparent !important;
|
|
}
|
|
|
|
.dark .u-over {
|
|
background: transparent !important;
|
|
}
|
|
|
|
/* Chart cursor line */
|
|
.u-cursor-x {
|
|
border-right: 1px dashed #64748b !important;
|
|
}
|
|
|
|
.u-cursor-y {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Chart tooltip */
|
|
.chart-tooltip {
|
|
position: absolute;
|
|
z-index: 50;
|
|
pointer-events: none;
|
|
padding: 6px 10px;
|
|
border-radius: 6px;
|
|
background: rgba(15, 23, 42, 0.9);
|
|
color: white;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
white-space: nowrap;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.dark .chart-tooltip {
|
|
background: rgba(30, 41, 59, 0.95);
|
|
border: 1px solid rgba(71, 85, 105, 0.5);
|
|
}
|
|
</style>
|