feat: 增加链路统计信息,两个链路分别显示在前端
This commit is contained in:
@@ -1,20 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { NetworkTelemetry } from '@/types'
|
||||
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
network: NetworkTelemetry | null
|
||||
}>()
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
if (!props.network?.updated_at) {
|
||||
return 'unavailable'
|
||||
}
|
||||
return new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
const legCards = computed(() => [
|
||||
{
|
||||
key: 'a_to_d',
|
||||
label: 'A <-> D',
|
||||
data: props.network?.links?.a_to_d ?? null,
|
||||
},
|
||||
{
|
||||
key: 'd_to_b',
|
||||
label: 'D <-> B',
|
||||
data: props.network?.links?.d_to_b ?? null,
|
||||
},
|
||||
])
|
||||
|
||||
const activeSource = computed(() => props.network?.active_control_source ?? 'none')
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return 'unavailable'
|
||||
}
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatScalar(value?: number | string | null, suffix = '') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--'
|
||||
}
|
||||
return `${value}${suffix}`
|
||||
}
|
||||
|
||||
function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: LinkSessionTelemetry | null }> {
|
||||
return [
|
||||
{ name: 'control', data: link?.sessions?.control ?? null },
|
||||
{ name: 'video', data: link?.sessions?.video ?? null },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,19 +49,21 @@ const activeSource = computed(() => props.network?.active_control_source ?? 'non
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Network</p>
|
||||
<h2>Session Telemetry</h2>
|
||||
<h2>Dual-Leg Telemetry</h2>
|
||||
</div>
|
||||
<span class="badge">{{ network?.peer_status ?? 'loading' }}</span>
|
||||
<span class="badge" :class="{ stale: network?.telemetry_receiver?.hub_stale }">
|
||||
{{ network?.peer_status ?? 'loading' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>Latency</span>
|
||||
<strong>{{ network?.latency_ms ?? '--' }} ms</strong>
|
||||
<strong>{{ formatScalar(network?.latency_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Jitter</span>
|
||||
<strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
|
||||
<strong>{{ formatScalar(network?.jitter_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Active Control</span>
|
||||
@@ -42,42 +71,108 @@ const activeSource = computed(() => props.network?.active_control_source ?? 'non
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Lease</span>
|
||||
<strong>{{ network?.control_lease_remaining_ms ?? '--' }} ms</strong>
|
||||
<strong>{{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>TX Rate</span>
|
||||
<strong>{{ network?.tx_kbps ?? '--' }} kbps</strong>
|
||||
<strong>{{ formatScalar(network?.tx_kbps, ' kbps') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>RX Rate</span>
|
||||
<strong>{{ network?.rx_kbps ?? '--' }} kbps</strong>
|
||||
<strong>{{ formatScalar(network?.rx_kbps, ' kbps') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary telemetry-strip">
|
||||
<p><strong>Transport:</strong> {{ network?.transport ?? 'n/a' }} / {{ network?.source_mode ?? 'n/a' }}</p>
|
||||
<p><strong>Combined:</strong> sessions={{ network?.combined?.connected_sessions ?? '--' }} send={{ network?.combined?.send_bytes ?? '--' }}B recv={{ network?.combined?.recv_bytes ?? '--' }}B</p>
|
||||
<p><strong>Refresh:</strong> {{ updatedAt }}</p>
|
||||
<p><strong>Telemetry Peer:</strong> {{ network?.telemetry_receiver?.peer_id ?? 'n/a' }}</p>
|
||||
<p><strong>Hub Freshness:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
|
||||
<p><strong>Hub State:</strong> {{ network?.telemetry_receiver?.hub_stale ? 'stale' : 'fresh' }}</p>
|
||||
<p v-if="network?.telemetry_receiver?.last_error"><strong>Hub Error:</strong> {{ network?.telemetry_receiver?.last_error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="session-grid">
|
||||
<div class="session-card">
|
||||
<h3>Video Session</h3>
|
||||
<p>connected={{ network?.sessions?.video?.app?.connected ?? 0 }}</p>
|
||||
<p>recv_bytes={{ network?.sessions?.video?.app?.recv_bytes ?? 0 }}</p>
|
||||
<p>srtt={{ network?.sessions?.video?.kcp?.srtt_ms ?? '--' }} ms</p>
|
||||
<p>snd_queue={{ network?.sessions?.video?.kcp?.snd_queue ?? '--' }}</p>
|
||||
</div>
|
||||
<div class="session-card">
|
||||
<h3>Control Session</h3>
|
||||
<p>connected={{ network?.sessions?.control?.app?.connected ?? 0 }}</p>
|
||||
<p>send_bytes={{ network?.sessions?.control?.app?.send_bytes ?? 0 }}</p>
|
||||
<p>srtt={{ network?.sessions?.control?.kcp?.srtt_ms ?? '--' }} ms</p>
|
||||
<p>snd_queue={{ network?.sessions?.control?.kcp?.snd_queue ?? '--' }}</p>
|
||||
</div>
|
||||
<div class="leg-grid">
|
||||
<article v-for="leg in legCards" :key="leg.key" class="leg-card" :class="{ stale: leg.data?.stale }">
|
||||
<div class="leg-head">
|
||||
<div>
|
||||
<p class="leg-label">{{ leg.label }}</p>
|
||||
<h3>{{ leg.data?.source ?? 'waiting' }}</h3>
|
||||
</div>
|
||||
<div class="leg-meta">
|
||||
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
|
||||
{{ leg.data?.stale ? 'stale' : 'fresh' }}
|
||||
</span>
|
||||
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aggregate-grid">
|
||||
<div>
|
||||
<span>Online</span>
|
||||
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Max Pressure</span>
|
||||
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Queued</span>
|
||||
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>In Flight Buffer</span>
|
||||
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Retrans Delta</span>
|
||||
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Repair Rate</span>
|
||||
<strong>{{ formatScalar(leg.data?.aggregate?.repair_rate_pct, '%') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-grid">
|
||||
<section v-for="session in legSessions(leg.data)" :key="session.name" class="session-card">
|
||||
<div class="session-head">
|
||||
<div>
|
||||
<p class="session-label">{{ session.name }}</p>
|
||||
<h4>{{ session.data?.peer_id ?? 'unassigned' }}</h4>
|
||||
</div>
|
||||
<span class="mini-badge" :class="{ stale: session.data?.stale, active: session.data?.connected }">
|
||||
{{ session.data?.connected ? 'online' : 'idle' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="kv-grid">
|
||||
<p><strong>Updated:</strong> {{ formatTime(session.data?.updated_at) }}</p>
|
||||
<p><strong>SRTT:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
|
||||
<p><strong>RTTVAR:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
|
||||
<p><strong>RTO:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
|
||||
<p><strong>SND WND:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
|
||||
<p><strong>RMT WND:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
|
||||
<p><strong>Inflight:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
|
||||
<p><strong>Window Limit:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
|
||||
<p><strong>Pressure:</strong> {{ formatScalar(session.data?.kcp?.window_pressure_pct, '%') }}</p>
|
||||
<p><strong>SND Queue:</strong> {{ formatScalar(session.data?.kcp?.snd_queue) }} / {{ session.data?.trend?.snd_queue_trend ?? 'stable' }}</p>
|
||||
<p><strong>SND Buffer:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ session.data?.trend?.snd_buffer_trend ?? 'stable' }}</p>
|
||||
<p><strong>Queue Delta:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
|
||||
<p><strong>Buffer Delta:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
|
||||
<p><strong>Retrans:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
|
||||
<p><strong>Fast Retrans:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
|
||||
<p><strong>Lost:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
|
||||
<p><strong>Repeat:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
|
||||
<p><strong>Repair Rate:</strong> {{ formatScalar(session.data?.trend?.repair_rate_pct, '%') }}</p>
|
||||
<p v-if="session.data?.app"><strong>App Bytes:</strong> tx={{ session.data.app.send_bytes ?? 0 }} / rx={{ session.data.app.recv_bytes ?? 0 }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<p><strong>Combined:</strong> sessions={{ network?.combined?.connected_sessions ?? 0 }} send={{ network?.combined?.send_bytes ?? 0 }}B recv={{ network?.combined?.recv_bytes ?? 0 }}B</p>
|
||||
<p><strong>Native UDP:</strong> {{ network?.ingress?.native_udp?.bind_addr ?? 'n/a' }} packets={{ network?.ingress?.native_udp?.packets_received ?? 0 }} invalid={{ network?.ingress?.native_udp?.invalid_packets ?? 0 }}</p>
|
||||
<p><strong>Control Sender:</strong> {{ network?.control?.sender?.peer_id ?? 'n/a' }} -> {{ network?.control?.sender?.target_peer ?? 'n/a' }} sends={{ network?.control?.sender?.send_count ?? 0 }}</p>
|
||||
</div>
|
||||
@@ -87,98 +182,192 @@ const activeSource = computed(() => props.network?.active_control_source ?? 'non
|
||||
<style scoped>
|
||||
.network-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
.panel-head,
|
||||
.leg-head,
|
||||
.session-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
.eyebrow,
|
||||
.leg-label,
|
||||
.session-label {
|
||||
margin: 0 0 4px;
|
||||
color: #4dd4ac;
|
||||
color: #5bd3b5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.mini-badge {
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(40, 199, 111, 0.16);
|
||||
color: #63e6a9;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mini-badge {
|
||||
padding: 6px 10px;
|
||||
background: rgba(91, 211, 181, 0.12);
|
||||
color: #8ff2db;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge.stale,
|
||||
.mini-badge.stale {
|
||||
background: rgba(255, 165, 0, 0.16);
|
||||
color: #ffd08a;
|
||||
}
|
||||
|
||||
.mini-badge.active {
|
||||
background: rgba(64, 187, 255, 0.16);
|
||||
color: #98dcff;
|
||||
}
|
||||
|
||||
.stats,
|
||||
.leg-grid,
|
||||
.session-grid,
|
||||
.aggregate-grid,
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.leg-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.session-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.aggregate-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.kv-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.summary,
|
||||
.leg-card,
|
||||
.session-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 14, 26, 0.8);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
.stat-card span,
|
||||
.aggregate-grid span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
.stat-card strong,
|
||||
.aggregate-grid strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.summary,
|
||||
.session-card {
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.summary p,
|
||||
.session-card h3,
|
||||
.session-card p {
|
||||
.kv-grid p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary p + p,
|
||||
.session-card p + p {
|
||||
.summary p + p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.session-grid {
|
||||
.telemetry-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.leg-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.leg-card.stale {
|
||||
border-color: rgba(255, 165, 0, 0.3);
|
||||
}
|
||||
|
||||
.leg-meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-time {
|
||||
color: #9aa6c2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
background: rgba(11, 19, 35, 0.86);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats,
|
||||
.session-grid {
|
||||
.aggregate-grid,
|
||||
.telemetry-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.leg-grid,
|
||||
.session-grid,
|
||||
.kv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 720px) {
|
||||
.stats,
|
||||
.session-grid {
|
||||
.aggregate-grid,
|
||||
.telemetry-strip,
|
||||
.kv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,19 @@ export interface SessionKcpStats {
|
||||
rto_ms?: number
|
||||
srtt_ms?: number
|
||||
srttvar_ms?: number
|
||||
snd_wnd?: number
|
||||
rmt_wnd?: number
|
||||
inflight?: number
|
||||
window_limit?: number
|
||||
window_pressure_pct?: number
|
||||
snd_queue?: number
|
||||
rcv_queue?: number
|
||||
snd_buffer?: number
|
||||
out_segs_total?: number
|
||||
retrans_total?: number
|
||||
fast_retrans_total?: number
|
||||
lost_total?: number
|
||||
repeat_total?: number
|
||||
xmit_total?: number
|
||||
}
|
||||
|
||||
@@ -40,6 +50,50 @@ export interface SessionTelemetry {
|
||||
kcp: SessionKcpStats
|
||||
}
|
||||
|
||||
export interface SessionTrendStats {
|
||||
snd_queue_delta: number
|
||||
snd_buffer_delta: number
|
||||
snd_queue_trend: string
|
||||
snd_buffer_trend: string
|
||||
retrans_delta: number
|
||||
fast_retrans_delta: number
|
||||
lost_delta: number
|
||||
repeat_delta: number
|
||||
out_segs_delta: number
|
||||
repair_rate_pct: number
|
||||
}
|
||||
|
||||
export interface LinkSessionTelemetry {
|
||||
peer_id: string
|
||||
connected: boolean
|
||||
updated_at: string | null
|
||||
stale: boolean
|
||||
app: SessionAppStats | null
|
||||
kcp: SessionKcpStats
|
||||
trend: SessionTrendStats
|
||||
}
|
||||
|
||||
export interface LinkAggregateTelemetry {
|
||||
online_sessions: number
|
||||
max_window_pressure_pct: number
|
||||
sum_snd_queue: number
|
||||
sum_snd_buffer: number
|
||||
sum_retrans_delta: number
|
||||
sum_out_segs_delta: number
|
||||
repair_rate_pct: number
|
||||
}
|
||||
|
||||
export interface LinkTelemetry {
|
||||
source: string
|
||||
updated_at: string | null
|
||||
stale: boolean
|
||||
aggregate: LinkAggregateTelemetry
|
||||
sessions: {
|
||||
control: LinkSessionTelemetry
|
||||
video: LinkSessionTelemetry
|
||||
}
|
||||
}
|
||||
|
||||
export interface NativeUdpIngress {
|
||||
started: boolean
|
||||
bind_addr: string
|
||||
@@ -72,6 +126,14 @@ export interface ControlSenderStatus {
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface TelemetryReceiverStatus {
|
||||
hub_connected: boolean
|
||||
hub_updated_at: string | null
|
||||
hub_stale: boolean
|
||||
last_error: string
|
||||
peer_id: string
|
||||
}
|
||||
|
||||
export interface NetworkTelemetry {
|
||||
peer_status: string
|
||||
latency_ms: number | null
|
||||
@@ -95,6 +157,11 @@ export interface NetworkTelemetry {
|
||||
video: SessionTelemetry
|
||||
control: SessionTelemetry
|
||||
}
|
||||
links: {
|
||||
a_to_d: LinkTelemetry
|
||||
d_to_b: LinkTelemetry
|
||||
}
|
||||
telemetry_receiver: TelemetryReceiverStatus
|
||||
ingress: {
|
||||
native_udp: NativeUdpIngress
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||
|
||||
const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||
const { network, errorMessage, headerStatus } = useMonitoringData({
|
||||
refreshIntervalMs: 500,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,8 +15,9 @@ const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||
<h1>Network Telemetry</h1>
|
||||
</div>
|
||||
<p class="description">
|
||||
Live per-session OmniSocket telemetry from the unified A-side daemon, including active control
|
||||
source and native UDP ingress status.
|
||||
Live dual-leg OmniSocket telemetry from the A-side daemon, separating the local `A <-> D`
|
||||
sessions from the hub-reported `D <-> B` leg with queue pressure, retransmission, and stale-link
|
||||
visibility.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user