feat: 增加日志模块
This commit is contained in:
@@ -33,6 +33,9 @@ function formatScalar(value?: number | string | null, suffix = '') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--'
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return `${value.toFixed(1)}${suffix}`
|
||||
}
|
||||
return `${value}${suffix}`
|
||||
}
|
||||
|
||||
@@ -58,20 +61,20 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>Latency</span>
|
||||
<strong>{{ formatScalar(network?.latency_ms, ' ms') }}</strong>
|
||||
<span>Control Loop RTT</span>
|
||||
<strong>{{ formatScalar(network?.latency_estimate?.control_loop_rtt_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Jitter</span>
|
||||
<strong>{{ formatScalar(network?.jitter_ms, ' ms') }}</strong>
|
||||
<span>Control to Persist</span>
|
||||
<strong>{{ formatScalar(network?.latency_estimate?.control_to_persist_est_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Active Control</span>
|
||||
<strong>{{ activeSource }}</strong>
|
||||
<span>Control SRTT One-way</span>
|
||||
<strong>{{ formatScalar(network?.latency_estimate?.control_oneway_srtt_est_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Lease</span>
|
||||
<strong>{{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</strong>
|
||||
<span>Video One-way Est.</span>
|
||||
<strong>{{ formatScalar(network?.latency_estimate?.video_network_oneway_est_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>TX Rate</span>
|
||||
@@ -89,6 +92,10 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
||||
<p><strong>Health Confidence:</strong> {{ network?.robot_health?.confidence ?? 'n/a' }}</p>
|
||||
<p><strong>Health Updated:</strong> {{ formatTime(network?.robot_health?.updated_at) }}</p>
|
||||
<p><strong>Transport:</strong> {{ network?.transport ?? 'n/a' }} / {{ network?.source_mode ?? 'n/a' }}</p>
|
||||
<p><strong>Active Control:</strong> {{ activeSource }}</p>
|
||||
<p><strong>Lease:</strong> {{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</p>
|
||||
<p><strong>ACK Mode:</strong> {{ network?.control_ack_status?.ack_available ? 'ack-loop' : 'srtt-fallback' }}</p>
|
||||
<p><strong>ACK Updated:</strong> {{ formatTime(network?.control_ack_status?.updated_at) }}</p>
|
||||
<p><strong>Telemetry Peer:</strong> {{ network?.telemetry_receiver?.peer_id ?? 'n/a' }}</p>
|
||||
<p><strong>Telemetry Registered:</strong> {{ network?.telemetry_receiver?.registered ? 'yes' : 'no' }}</p>
|
||||
<p><strong>Hub Freshness:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
|
||||
@@ -182,8 +189,12 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
||||
|
||||
<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>Video E2E Est.:</strong> {{ formatScalar(network?.latency_estimate?.video_e2e_est_ms, ' ms') }} / confidence={{ network?.latency_estimate?.confidence?.video ?? 'n/a' }}</p>
|
||||
<p><strong>Control Estimate Confidence:</strong> {{ network?.latency_estimate?.confidence?.control ?? 'n/a' }}</p>
|
||||
<p><strong>Video Freshness:</strong> repeat={{ formatScalar((network?.video_freshness?.repeated_frame_ratio ?? 0) * 100, '%') }} skip={{ formatScalar((network?.video_freshness?.skip_ratio ?? 0) * 100, '%') }} freeze={{ formatScalar(network?.video_freshness?.longest_freeze_ms, ' ms') }}</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 }} registered={{ network?.control?.sender?.registered ? 'yes' : 'no' }}</p>
|
||||
<p><strong>ACK Receiver:</strong> {{ network?.control?.ack_receiver?.peer_id ?? 'n/a' }} reconnects={{ network?.control?.ack_receiver?.reconnect_count ?? 0 }}</p>
|
||||
<p><strong>Control Reconnects:</strong> {{ network?.control?.sender?.reconnect_count ?? 0 }}</p>
|
||||
<p v-if="network?.control?.sender?.last_server_error"><strong>Control Session Error:</strong> {{ network?.control?.sender?.last_server_error }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,77 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { buildVideoFrameUrl, fetchVideoStatus } from '@/lib/api'
|
||||
import type { VideoStatus } from '@/types'
|
||||
|
||||
import { buildVideoFrameUrl, fetchVideoStatus, postVideoDisplayProbe } from '@/lib/api'
|
||||
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
|
||||
import type { NetworkTelemetry, VideoStatus } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
video: VideoStatus | null
|
||||
network?: NetworkTelemetry | null
|
||||
}>()
|
||||
|
||||
const STATUS_REFRESH_MS = 300
|
||||
const DISPLAY_PROBE_INTERVAL_MS = 200
|
||||
|
||||
type PendingInputProbe = {
|
||||
token: number
|
||||
triggeredPerfMs: number
|
||||
baselineFrameSeq: number | null
|
||||
baselineFrameHash: string
|
||||
freshResolved: boolean
|
||||
changedResolved: boolean
|
||||
paintResolved: boolean
|
||||
}
|
||||
|
||||
const liveVideo = ref<VideoStatus | null>(props.video)
|
||||
const frameUrl = ref(buildVideoFrameUrl(0))
|
||||
const displayVideo = computed(() => liveVideo.value ?? props.video)
|
||||
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
|
||||
const canRequestFrames = computed(() => displayVideo.value?.available === true)
|
||||
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
|
||||
const operatorMetrics = ref({
|
||||
input_to_next_fresh_frame_ms: null as number | null,
|
||||
input_to_next_changed_frame_ms: null as number | null,
|
||||
input_to_next_paint_ms: null as number | null,
|
||||
})
|
||||
|
||||
const {
|
||||
operatorInputSequence,
|
||||
lastOperatorInputPerfMs,
|
||||
} = useOperatorInputTelemetry()
|
||||
|
||||
const freshness = computed(() => displayVideo.value?.freshness)
|
||||
const networkEstimate = computed(() => props.network?.latency_estimate ?? null)
|
||||
const senderClockDebug = computed(() => displayVideo.value?.timing ?? null)
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (!displayVideo.value) {
|
||||
return '正在获取视频状态'
|
||||
return 'loading'
|
||||
}
|
||||
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
|
||||
return `${displayVideo.value.fps} FPS 实时接收`
|
||||
return `${displayVideo.value.fps} FPS live`
|
||||
}
|
||||
if (displayVideo.value.source_mode === 'omnisocket-waiting') {
|
||||
return '未实时获取真实值'
|
||||
}
|
||||
return `${displayVideo.value.fps} FPS`
|
||||
return displayVideo.value.source_mode
|
||||
})
|
||||
const placeholderText = computed(() => {
|
||||
if (!displayVideo.value) {
|
||||
return '正在获取视频状态...'
|
||||
}
|
||||
return '未实时获取真实值'
|
||||
})
|
||||
const latencyLabels = computed(() => {
|
||||
const sampleWindowSize = displayVideo.value?.timing?.sample_window_size ?? 10
|
||||
const samples = displayVideo.value?.timing?.delta_samples_ms ?? []
|
||||
return Array.from({ length: sampleWindowSize }, (_, index) => samples[index] ?? null)
|
||||
})
|
||||
const timingHeadline = computed(() => {
|
||||
const latest = displayVideo.value?.timing?.latest_delta_ms
|
||||
if (latest == null) {
|
||||
return '等待帧尾时间'
|
||||
}
|
||||
return `最新 ${latest.toFixed(1)} ms`
|
||||
})
|
||||
const timingHint = computed(() => {
|
||||
const timing = displayVideo.value?.timing
|
||||
if (!timing?.available) {
|
||||
return '当前还没有从 JPEG 结尾后的尾字节里解析到时间戳,标签会在收到有效帧尾时间后自动填充。'
|
||||
}
|
||||
|
||||
const unitText = timing.timestamp_unit ? `,单位按 ${timing.timestamp_unit}` : ''
|
||||
const endiannessText = timing.timestamp_endianness
|
||||
? `,字节序按 ${timing.timestamp_endianness}`
|
||||
: ''
|
||||
return `最近 ${timing.sample_window_size} 个差值样本,面板按 ${STATUS_REFRESH_MS} ms 刷新一组${unitText}${endiannessText}。`
|
||||
const timingHeadline = computed(() => {
|
||||
const latest = senderClockDebug.value?.sender_clock_delta_ms_raw
|
||||
if (latest == null) {
|
||||
return 'waiting'
|
||||
}
|
||||
return `${latest.toFixed(1)} ms`
|
||||
})
|
||||
|
||||
const timingHint = computed(() => {
|
||||
const timing = senderClockDebug.value
|
||||
if (!timing?.available) {
|
||||
return 'raw sender clock delta is waiting for the first valid video trailer'
|
||||
}
|
||||
return 'raw sender clock delta only, unsynced clocks'
|
||||
})
|
||||
|
||||
function formatNumber(value: number | null | undefined, suffix = '') {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
return `${value.toFixed(1)}${suffix}`
|
||||
}
|
||||
|
||||
let frameTimer: number | null = null
|
||||
let statusTimer: number | null = null
|
||||
let probeTimer: number | null = null
|
||||
let frameKey = 0
|
||||
let probeKey = 0
|
||||
let statusRequestPending = false
|
||||
let probeRequestPending = false
|
||||
let lastObservedFrameSeq: number | null = null
|
||||
let lastObservedFrameHash = ''
|
||||
let pendingInputProbe: PendingInputProbe | null = null
|
||||
|
||||
async function refreshStatus() {
|
||||
if (statusRequestPending) {
|
||||
return
|
||||
}
|
||||
|
||||
statusRequestPending = true
|
||||
try {
|
||||
liveVideo.value = await fetchVideoStatus()
|
||||
} catch {
|
||||
// 保持当前已显示状态,避免短暂请求失败把面板内容清空。
|
||||
// Keep the last good state.
|
||||
} finally {
|
||||
statusRequestPending = false
|
||||
}
|
||||
@@ -90,16 +114,12 @@ function startFrameLoop() {
|
||||
window.clearInterval(frameTimer)
|
||||
frameTimer = null
|
||||
}
|
||||
|
||||
if (!canRequestFrames.value) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshFrame()
|
||||
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value))
|
||||
frameTimer = window.setInterval(() => {
|
||||
refreshFrame()
|
||||
}, intervalMs)
|
||||
frameTimer = window.setInterval(refreshFrame, intervalMs)
|
||||
}
|
||||
|
||||
function startStatusLoop() {
|
||||
@@ -107,16 +127,144 @@ function startStatusLoop() {
|
||||
window.clearInterval(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
|
||||
void refreshStatus()
|
||||
statusTimer = window.setInterval(() => {
|
||||
void refreshStatus()
|
||||
}, STATUS_REFRESH_MS)
|
||||
}
|
||||
|
||||
function maybeTrackOperatorInput() {
|
||||
pendingInputProbe = {
|
||||
token: operatorInputSequence.value,
|
||||
triggeredPerfMs: lastOperatorInputPerfMs.value,
|
||||
baselineFrameSeq: lastObservedFrameSeq,
|
||||
baselineFrameHash: lastObservedFrameHash,
|
||||
freshResolved: false,
|
||||
changedResolved: false,
|
||||
paintResolved: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function runDisplayProbe() {
|
||||
if (probeRequestPending || !canRequestFrames.value) {
|
||||
return
|
||||
}
|
||||
|
||||
probeRequestPending = true
|
||||
const requestStartedUnixMs = performance.timeOrigin + performance.now()
|
||||
|
||||
try {
|
||||
probeKey += 1
|
||||
const response = await fetch(buildVideoFrameUrl(probeKey), {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const frameSeqHeader = response.headers.get('X-Blitz-Frame-Seq')
|
||||
const backendReceivedHeader = response.headers.get('X-Blitz-Backend-Received-Unix-Ns')
|
||||
const frameHashHeader = response.headers.get('X-Blitz-Frame-Hash') ?? ''
|
||||
const frameSeq = frameSeqHeader ? Number(frameSeqHeader) : null
|
||||
const backendReceivedUnixNs = backendReceivedHeader ? Number(backendReceivedHeader) : null
|
||||
const responseReceivedUnixMs = performance.timeOrigin + performance.now()
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
try {
|
||||
const probeImage = new Image()
|
||||
probeImage.src = objectUrl
|
||||
await probeImage.decode()
|
||||
const decodedUnixMs = performance.timeOrigin + performance.now()
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
const paintUnixMs = performance.timeOrigin + performance.now()
|
||||
|
||||
let inputToNextFreshFrameMs: number | null = null
|
||||
let inputToNextChangedFrameMs: number | null = null
|
||||
let inputToNextPaintMs: number | null = null
|
||||
|
||||
if (pendingInputProbe != null) {
|
||||
if (
|
||||
!pendingInputProbe.freshResolved &&
|
||||
frameSeq != null &&
|
||||
(pendingInputProbe.baselineFrameSeq == null || frameSeq > pendingInputProbe.baselineFrameSeq)
|
||||
) {
|
||||
inputToNextFreshFrameMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
|
||||
pendingInputProbe.freshResolved = true
|
||||
operatorMetrics.value.input_to_next_fresh_frame_ms = inputToNextFreshFrameMs
|
||||
}
|
||||
|
||||
if (
|
||||
!pendingInputProbe.changedResolved &&
|
||||
frameHashHeader &&
|
||||
frameHashHeader !== pendingInputProbe.baselineFrameHash
|
||||
) {
|
||||
inputToNextChangedFrameMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
|
||||
pendingInputProbe.changedResolved = true
|
||||
operatorMetrics.value.input_to_next_changed_frame_ms = inputToNextChangedFrameMs
|
||||
}
|
||||
|
||||
if (!pendingInputProbe.paintResolved) {
|
||||
inputToNextPaintMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
|
||||
pendingInputProbe.paintResolved = true
|
||||
operatorMetrics.value.input_to_next_paint_ms = inputToNextPaintMs
|
||||
}
|
||||
|
||||
if (
|
||||
pendingInputProbe.freshResolved &&
|
||||
pendingInputProbe.changedResolved &&
|
||||
pendingInputProbe.paintResolved
|
||||
) {
|
||||
pendingInputProbe = null
|
||||
}
|
||||
}
|
||||
|
||||
lastObservedFrameSeq = frameSeq
|
||||
lastObservedFrameHash = frameHashHeader
|
||||
|
||||
await postVideoDisplayProbe({
|
||||
updated_at: new Date().toISOString(),
|
||||
frame_seq: frameSeq,
|
||||
backend_received_unix_ns: backendReceivedUnixNs,
|
||||
frame_hash: frameHashHeader,
|
||||
request_started_unix_ms: Number(requestStartedUnixMs.toFixed(3)),
|
||||
response_received_unix_ms: Number(responseReceivedUnixMs.toFixed(3)),
|
||||
image_decoded_unix_ms: Number(decodedUnixMs.toFixed(3)),
|
||||
paint_unix_ms: Number(paintUnixMs.toFixed(3)),
|
||||
input_to_next_fresh_frame_ms: inputToNextFreshFrameMs,
|
||||
input_to_next_changed_frame_ms: inputToNextChangedFrameMs,
|
||||
input_to_next_paint_ms: inputToNextPaintMs,
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
} catch {
|
||||
// Probe is best-effort only.
|
||||
} finally {
|
||||
probeRequestPending = false
|
||||
}
|
||||
}
|
||||
|
||||
function startProbeLoop() {
|
||||
if (probeTimer != null) {
|
||||
window.clearInterval(probeTimer)
|
||||
probeTimer = null
|
||||
}
|
||||
if (!canRequestFrames.value) {
|
||||
return
|
||||
}
|
||||
void runDisplayProbe()
|
||||
probeTimer = window.setInterval(() => {
|
||||
void runDisplayProbe()
|
||||
}, DISPLAY_PROBE_INTERVAL_MS)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startStatusLoop()
|
||||
startFrameLoop()
|
||||
startProbeLoop()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -126,6 +274,9 @@ onUnmounted(() => {
|
||||
if (statusTimer != null) {
|
||||
window.clearInterval(statusTimer)
|
||||
}
|
||||
if (probeTimer != null) {
|
||||
window.clearInterval(probeTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -138,7 +289,15 @@ watch(
|
||||
|
||||
watch([currentFps, canRequestFrames], () => {
|
||||
startFrameLoop()
|
||||
startProbeLoop()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => operatorInputSequence.value,
|
||||
() => {
|
||||
maybeTrackOperatorInput()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -146,10 +305,10 @@ watch([currentFps, canRequestFrames], () => {
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Video</p>
|
||||
<h2>JPEG 视频流</h2>
|
||||
<h2>Live Video</h2>
|
||||
</div>
|
||||
<span class="badge" :class="{ bad: !displayVideo?.available }">
|
||||
{{ displayVideo?.source_mode ?? 'loading' }}
|
||||
{{ modeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -158,37 +317,72 @@ watch([currentFps, canRequestFrames], () => {
|
||||
v-if="canRequestFrames"
|
||||
class="video-frame"
|
||||
:src="frameUrl"
|
||||
alt="Robot jpeg frame stream"
|
||||
alt="Robot live frame"
|
||||
/>
|
||||
<div v-else class="video-placeholder">
|
||||
{{ placeholderText }}
|
||||
waiting for live video frames
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>帧源</span>
|
||||
<strong>{{ displayVideo?.frame_count ?? '--' }} 张 JPEG</strong>
|
||||
<span>Frames</span>
|
||||
<strong>{{ displayVideo?.frame_count ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>当前模式</span>
|
||||
<strong>{{ modeLabel }}</strong>
|
||||
<span>Latest Seq</span>
|
||||
<strong>{{ displayVideo?.receiver?.latest_sequence ?? '--' }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Video E2E Est.</span>
|
||||
<strong>{{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Paint Delay</span>
|
||||
<strong>{{ formatNumber(displayVideo?.display_probe?.a_recv_to_paint_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-group">
|
||||
<h3>Pipeline Estimate</h3>
|
||||
<p><strong>Capture to send:</strong> {{ formatNumber(displayVideo?.receiver?.latest_capture_to_send_ms, ' ms') }}</p>
|
||||
<p><strong>Network one-way:</strong> {{ formatNumber(networkEstimate?.video_network_oneway_est_ms, ' ms') }}</p>
|
||||
<p><strong>Partial estimate:</strong> {{ formatNumber(networkEstimate?.video_partial_est_ms, ' ms') }}</p>
|
||||
<p><strong>End-to-end estimate:</strong> {{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="metric-group">
|
||||
<h3>Freshness</h3>
|
||||
<p><strong>Inter-frame avg:</strong> {{ formatNumber(freshness?.inter_frame_avg_ms, ' ms') }}</p>
|
||||
<p><strong>Inter-frame p95:</strong> {{ formatNumber(freshness?.inter_frame_p95_ms, ' ms') }}</p>
|
||||
<p><strong>Repeated ratio:</strong> {{ formatNumber((freshness?.repeated_frame_ratio ?? 0) * 100, ' %') }}</p>
|
||||
<p><strong>Skip ratio:</strong> {{ formatNumber((freshness?.skip_ratio ?? 0) * 100, ' %') }}</p>
|
||||
<p><strong>Longest freeze:</strong> {{ formatNumber(freshness?.longest_freeze_ms, ' ms') }}</p>
|
||||
<p><strong>Lag frames:</strong> {{ freshness?.relative_freshness_lag_frames ?? 0 }}</p>
|
||||
</div>
|
||||
|
||||
<div class="metric-group">
|
||||
<h3>Operator Loop</h3>
|
||||
<p><strong>Input to next seq:</strong> {{ formatNumber(operatorMetrics.input_to_next_fresh_frame_ms, ' ms') }}</p>
|
||||
<p><strong>Input to changed frame:</strong> {{ formatNumber(operatorMetrics.input_to_next_changed_frame_ms, ' ms') }}</p>
|
||||
<p><strong>Input to paint:</strong> {{ formatNumber(operatorMetrics.input_to_next_paint_ms, ' ms') }}</p>
|
||||
<p><strong>Display probe recv-to-paint:</strong> {{ formatNumber(displayVideo?.display_probe?.a_recv_to_paint_ms, ' ms') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timing-panel">
|
||||
<div class="timing-head">
|
||||
<span>帧尾时间差</span>
|
||||
<span>Sender Clock Delta</span>
|
||||
<strong>{{ timingHeadline }}</strong>
|
||||
</div>
|
||||
<div class="timing-grid">
|
||||
<span
|
||||
v-for="(sample, index) in latencyLabels"
|
||||
v-for="(sample, index) in (senderClockDebug?.sender_clock_delta_samples_ms_raw ?? [])"
|
||||
:key="index"
|
||||
class="timing-label"
|
||||
:class="{ empty: sample == null }"
|
||||
>
|
||||
{{ sample == null ? '--' : `${sample.toFixed(1)} ms` }}
|
||||
{{ `${sample.toFixed(1)} ms` }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="hint subtle">
|
||||
@@ -197,12 +391,7 @@ watch([currentFps, canRequestFrames], () => {
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
这里只有在后端已经收到 OmniSocket 的真实 JPEG 帧时,才会开始逐帧请求并显示画面。
|
||||
如果当前没有真实帧,页面会保持占位提示,不再回退测试视频流。
|
||||
</p>
|
||||
|
||||
<p class="hint subtle">
|
||||
当前帧源状态:{{ displayVideo?.source_detail ?? '暂无' }}
|
||||
{{ displayVideo?.source_detail ?? 'no live video detail available' }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -224,16 +413,24 @@ watch([currentFps, canRequestFrames], () => {
|
||||
margin: 0 0 4px;
|
||||
color: #5b7aff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
@@ -249,11 +446,10 @@ h2 {
|
||||
}
|
||||
|
||||
.video-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||
background: linear-gradient(180deg, #09111f 0%, #050812 100%);
|
||||
background: #050812;
|
||||
}
|
||||
|
||||
.video-frame {
|
||||
@@ -261,115 +457,107 @@ h2 {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: #02050d;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 24px;
|
||||
color: #a8b4ce;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(91, 122, 255, 0.14), transparent 42%),
|
||||
#02050d;
|
||||
color: #95a4c6;
|
||||
}
|
||||
|
||||
.stats,
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.metric-group,
|
||||
.timing-panel {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
background: rgba(7, 14, 26, 0.86);
|
||||
}
|
||||
|
||||
.stat-card span,
|
||||
.metric-group p,
|
||||
.hint {
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8d99b3;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #9aaccc;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.timing-panel {
|
||||
.metric-group {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 14, 26, 0.88);
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-group p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.timing-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: #cfd7e6;
|
||||
}
|
||||
|
||||
.timing-head span {
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timing-head strong {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timing-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(91, 122, 255, 0.12);
|
||||
border: 1px solid rgba(91, 122, 255, 0.28);
|
||||
color: #dce4ff;
|
||||
color: #dbe5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timing-label.empty {
|
||||
background: rgba(133, 147, 169, 0.08);
|
||||
border-color: rgba(133, 147, 169, 0.18);
|
||||
color: #7e8aa5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #8d99b3;
|
||||
line-height: 1.65;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hint.subtle {
|
||||
font-size: 13px;
|
||||
color: #96a5c3;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timing-grid {
|
||||
@media (max-width: 1100px) {
|
||||
.stats,
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stats,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,6 +66,8 @@ const gamepadMapping = ref('')
|
||||
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
|
||||
const gamepadButtonPressed = ref<boolean[]>(Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false))
|
||||
const activeSource = ref<ControlSource>('idle')
|
||||
const operatorInputSequence = ref(0)
|
||||
const lastOperatorInputPerfMs = ref(0)
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
@@ -152,6 +154,11 @@ let consumerCount = 0
|
||||
let lastGamepadSignature = ''
|
||||
let lastCommandSignature = ''
|
||||
|
||||
function noteOperatorInput() {
|
||||
operatorInputSequence.value += 1
|
||||
lastOperatorInputPerfMs.value = performance.now()
|
||||
}
|
||||
|
||||
function normalizeAxis(raw: number) {
|
||||
if (Math.abs(raw) < GAMEPAD_DEADZONE) {
|
||||
return 0
|
||||
@@ -366,7 +373,7 @@ function sendCurrentCommand() {
|
||||
socket.send(packCommand(resolvedCommandValues()))
|
||||
}
|
||||
|
||||
function refreshSendLoop(force = false) {
|
||||
function refreshSendLoop(force = false, noteInput = true) {
|
||||
const source = resolvedSource()
|
||||
const values = resolvedCommandValues()
|
||||
const signature = commandSignature(values, source)
|
||||
@@ -375,6 +382,9 @@ function refreshSendLoop(force = false) {
|
||||
return
|
||||
}
|
||||
lastCommandSignature = signature
|
||||
if (noteInput) {
|
||||
noteOperatorInput()
|
||||
}
|
||||
|
||||
stopSendLoop()
|
||||
if (socket == null || socket.readyState !== WebSocket.OPEN) {
|
||||
@@ -497,7 +507,7 @@ function connectSocket() {
|
||||
socket.onopen = () => {
|
||||
socketState.value = 'open'
|
||||
lastServerMessage.value = 'control link live'
|
||||
refreshSendLoop(true)
|
||||
refreshSendLoop(true, false)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
@@ -706,5 +716,14 @@ export function useControlInterface() {
|
||||
gamepadRightStick,
|
||||
gamepadAxes,
|
||||
gamepadActive: computed(() => gamepadActiveInternal()),
|
||||
operatorInputSequence,
|
||||
lastOperatorInputPerfMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOperatorInputTelemetry() {
|
||||
return {
|
||||
operatorInputSequence,
|
||||
lastOperatorInputPerfMs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,19 @@ export function buildVideoFrameUrl(frameKey: number) {
|
||||
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
export async function postVideoDisplayProbe(payload: Record<string, unknown>) {
|
||||
const response = await fetch(`${API_BASE}/api/video/display-probe/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`display probe post failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildControlWebSocketUrl() {
|
||||
const url = new URL(API_BASE, window.location.origin)
|
||||
const basePath = url.pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -32,7 +32,9 @@ export interface SessionKcpStats {
|
||||
conv?: number
|
||||
rto_ms?: number
|
||||
srtt_ms?: number
|
||||
min_srtt_ms?: number
|
||||
srttvar_ms?: number
|
||||
last_feedback_age_ms?: number
|
||||
snd_wnd?: number
|
||||
rmt_wnd?: number
|
||||
inflight?: number
|
||||
@@ -133,6 +135,16 @@ export interface ControlSenderStatus {
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface ControlAckReceiverStatus {
|
||||
backend_ready: boolean
|
||||
started: boolean
|
||||
connected: boolean
|
||||
peer_id: string
|
||||
expected_sender: string
|
||||
reconnect_count: number
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface TelemetryReceiverStatus {
|
||||
hub_connected: boolean
|
||||
hub_updated_at: string | null
|
||||
@@ -151,6 +163,47 @@ export interface RobotHealthStatus {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface VideoFreshnessStatus {
|
||||
inter_frame_avg_ms: number | null
|
||||
inter_frame_p95_ms: number | null
|
||||
repeated_frame_ratio: number
|
||||
skip_ratio: number
|
||||
longest_freeze_ms: number
|
||||
stale_frame_run_length: number
|
||||
relative_freshness_lag_frames: number
|
||||
}
|
||||
|
||||
export interface LatencyEstimateStatus {
|
||||
control_loop_rtt_ms: number | null
|
||||
control_to_persist_est_ms: number | null
|
||||
control_oneway_srtt_est_ms: number | null
|
||||
control_oneway_bestcase_est_ms: number | null
|
||||
video_network_oneway_est_ms: number | null
|
||||
video_partial_est_ms: number | null
|
||||
video_e2e_est_ms: number | null
|
||||
estimate_method: {
|
||||
control: string
|
||||
video: string
|
||||
}
|
||||
clock_sync_required: boolean
|
||||
assumptions: string[]
|
||||
confidence: {
|
||||
control: string
|
||||
video: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ControlAckStatus {
|
||||
ack_available: boolean
|
||||
updated_at: string | null
|
||||
control_loop_rtt_ms: number | null
|
||||
b_recv_to_persist_ms: number | null
|
||||
control_oneway_network_est_ms: number | null
|
||||
control_to_persist_est_ms: number | null
|
||||
sample_reason: string | null
|
||||
receiver: ControlAckReceiverStatus
|
||||
}
|
||||
|
||||
export interface NetworkTelemetry {
|
||||
peer_status: string
|
||||
latency_ms: number | null
|
||||
@@ -178,6 +231,9 @@ export interface NetworkTelemetry {
|
||||
a_to_d: LinkTelemetry
|
||||
d_to_b: LinkTelemetry
|
||||
}
|
||||
latency_estimate: LatencyEstimateStatus
|
||||
video_freshness: VideoFreshnessStatus
|
||||
control_ack_status: ControlAckStatus
|
||||
telemetry_receiver: TelemetryReceiverStatus
|
||||
robot_health: RobotHealthStatus
|
||||
ingress: {
|
||||
@@ -186,6 +242,7 @@ export interface NetworkTelemetry {
|
||||
control: {
|
||||
arbiter: ControlArbiterStatus
|
||||
sender: ControlSenderStatus
|
||||
ack_receiver: ControlAckReceiverStatus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,12 +255,23 @@ export interface VideoStatus {
|
||||
source_detail?: string
|
||||
timing?: {
|
||||
available: boolean
|
||||
latest_delta_ms: number | null
|
||||
delta_samples_ms: number[]
|
||||
sender_clock_delta_ms_raw: number | null
|
||||
sender_clock_delta_samples_ms_raw: number[]
|
||||
sample_count: number
|
||||
sample_window_size: number
|
||||
timestamp_unit: string | null
|
||||
timestamp_endianness: string | null
|
||||
unsynced_clock: boolean
|
||||
}
|
||||
freshness?: VideoFreshnessStatus
|
||||
display_probe?: {
|
||||
updated_at: string | null
|
||||
frame_seq: number | null
|
||||
frame_hash: string
|
||||
input_to_next_fresh_frame_ms: number | null
|
||||
input_to_next_changed_frame_ms: number | null
|
||||
input_to_next_paint_ms: number | null
|
||||
a_recv_to_paint_ms: number | null
|
||||
}
|
||||
receiver?: {
|
||||
backend_ready: boolean
|
||||
@@ -213,6 +281,11 @@ export interface VideoStatus {
|
||||
has_recent_frame: boolean
|
||||
frames_received: number
|
||||
latest_sequence: number | null
|
||||
latest_frame_hash?: string
|
||||
latest_backend_received_unix_ns?: number | null
|
||||
latest_backend_received_mono_ns?: number | null
|
||||
latest_frame_bytes?: number
|
||||
latest_capture_to_send_ms?: number | null
|
||||
reconnect_count: number
|
||||
last_server_error: string
|
||||
last_error: string
|
||||
@@ -223,13 +296,15 @@ export interface VideoStatus {
|
||||
buffer_bytes?: number
|
||||
timing?: {
|
||||
available: boolean
|
||||
latest_delta_ms: number | null
|
||||
delta_samples_ms: number[]
|
||||
sender_clock_delta_ms_raw: number | null
|
||||
sender_clock_delta_samples_ms_raw: number[]
|
||||
sample_count: number
|
||||
sample_window_size: number
|
||||
timestamp_unit: string | null
|
||||
timestamp_endianness: string | null
|
||||
unsynced_clock: boolean
|
||||
}
|
||||
freshness?: VideoFreshnessStatus
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||
|
||||
<main class="layout">
|
||||
<section class="primary-grid">
|
||||
<VideoPanel :video="video" />
|
||||
<VideoPanel :video="video" :network="network" />
|
||||
<ControlPanel />
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import VideoPanel from '@/components/VideoPanel.vue'
|
||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||
|
||||
const { video, errorMessage, headerStatus } = useMonitoringData()
|
||||
const { video, network, errorMessage, headerStatus } = useMonitoringData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +19,7 @@ const { video, errorMessage, headerStatus } = useMonitoringData()
|
||||
{{ headerStatus }}
|
||||
</section>
|
||||
|
||||
<VideoPanel :video="video" />
|
||||
<VideoPanel :video="video" :network="network" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user