feat: 视频与控制程序合并
This commit is contained in:
298
frontend/src/components/ControlPanel.vue
Normal file
298
frontend/src/components/ControlPanel.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { buildControlWebSocketUrl } from '@/lib/api'
|
||||
|
||||
const TRACKED_KEYS = new Set(['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft', 'ShiftRight', 'Space'])
|
||||
|
||||
const pressedKeys = ref<Set<string>>(new Set())
|
||||
const socketState = ref<'connecting' | 'open' | 'closed'>('connecting')
|
||||
const lastServerMessage = ref('waiting')
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let sendTimer: number | null = null
|
||||
let reconnectTimer: number | null = null
|
||||
let manualClose = false
|
||||
|
||||
function commandValues() {
|
||||
const keys = pressedKeys.value
|
||||
const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? 1.5 : 1.0
|
||||
|
||||
let lx = 0
|
||||
let ly = 0
|
||||
let az = 0
|
||||
|
||||
if (keys.has('KeyW')) lx += 0.2
|
||||
if (keys.has('KeyS')) lx -= 0.2
|
||||
if (keys.has('KeyA')) ly += 0.15
|
||||
if (keys.has('KeyD')) ly -= 0.15
|
||||
if (keys.has('KeyQ')) az += 0.4
|
||||
if (keys.has('KeyE')) az -= 0.4
|
||||
|
||||
if (keys.has('Space')) {
|
||||
lx = 0
|
||||
ly = 0
|
||||
az = 0
|
||||
}
|
||||
|
||||
return [lx * turbo, ly * turbo, 0, 0, 0, az * turbo]
|
||||
}
|
||||
|
||||
function packCommand(values: number[]) {
|
||||
const buffer = new ArrayBuffer(24)
|
||||
const view = new DataView(buffer)
|
||||
values.forEach((value, index) => view.setFloat32(index * 4, value, true))
|
||||
return buffer
|
||||
}
|
||||
|
||||
function isZeroCommand(values: number[]) {
|
||||
return values.every((value) => value === 0)
|
||||
}
|
||||
|
||||
function sendCurrentCommand() {
|
||||
if (socket == null || socket.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
socket.send(packCommand(commandValues()))
|
||||
}
|
||||
|
||||
function stopSendLoop() {
|
||||
if (sendTimer != null) {
|
||||
window.clearInterval(sendTimer)
|
||||
sendTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSendLoop() {
|
||||
const values = commandValues()
|
||||
stopSendLoop()
|
||||
sendCurrentCommand()
|
||||
if (isZeroCommand(values)) {
|
||||
return
|
||||
}
|
||||
sendTimer = window.setInterval(() => {
|
||||
sendCurrentCommand()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function clearCommands() {
|
||||
pressedKeys.value = new Set()
|
||||
refreshSendLoop()
|
||||
}
|
||||
|
||||
function connectSocket() {
|
||||
if (socket != null && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
manualClose = false
|
||||
socketState.value = 'connecting'
|
||||
socket = new WebSocket(buildControlWebSocketUrl())
|
||||
socket.binaryType = 'arraybuffer'
|
||||
|
||||
socket.onopen = () => {
|
||||
socketState.value = 'open'
|
||||
lastServerMessage.value = 'connected'
|
||||
refreshSendLoop()
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
lastServerMessage.value = event.data
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
socketState.value = 'closed'
|
||||
stopSendLoop()
|
||||
socket = null
|
||||
if (manualClose) {
|
||||
return
|
||||
}
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer)
|
||||
}
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
connectSocket()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectSocket() {
|
||||
manualClose = true
|
||||
stopSendLoop()
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
socket?.close()
|
||||
socket = null
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!TRACKED_KEYS.has(event.code)) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const tag = event.target.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const next = new Set(pressedKeys.value)
|
||||
next.add(event.code)
|
||||
pressedKeys.value = next
|
||||
refreshSendLoop()
|
||||
}
|
||||
|
||||
function handleKeyup(event: KeyboardEvent) {
|
||||
if (!TRACKED_KEYS.has(event.code)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const next = new Set(pressedKeys.value)
|
||||
next.delete(event.code)
|
||||
pressedKeys.value = next
|
||||
refreshSendLoop()
|
||||
}
|
||||
|
||||
const pressedKeyLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || 'none')
|
||||
const socketLabel = computed(() => {
|
||||
if (socketState.value === 'open') return 'ws open'
|
||||
if (socketState.value === 'connecting') return 'connecting'
|
||||
return 'reconnecting'
|
||||
})
|
||||
const commandLabel = computed(() => {
|
||||
const [lx, ly, _lz, _ax, _ay, az] = commandValues()
|
||||
return `lx=${lx.toFixed(2)} ly=${ly.toFixed(2)} az=${az.toFixed(2)}`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
connectSocket()
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('keyup', handleKeyup)
|
||||
window.addEventListener('blur', clearCommands)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('keyup', handleKeyup)
|
||||
window.removeEventListener('blur', clearCommands)
|
||||
disconnectSocket()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel control-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Control</p>
|
||||
<h2>Web Control</h2>
|
||||
</div>
|
||||
<span class="badge" :class="{ warm: socketState !== 'open' }">{{ socketLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stat-card">
|
||||
<span>Pressed</span>
|
||||
<strong>{{ pressedKeyLabel }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Command</span>
|
||||
<strong>{{ commandLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
Keyboard mapping: <code>W/S</code> forward-back, <code>A/D</code> lateral, <code>Q/E</code> turn,
|
||||
<code>Shift</code> turbo, <code>Space</code> stop.
|
||||
</p>
|
||||
<p class="hint subtle">Server: {{ lastServerMessage }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.control-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #ffb057;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(40, 199, 111, 0.16);
|
||||
color: #63e6a9;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge.warm {
|
||||
background: rgba(255, 176, 87, 0.16);
|
||||
color: #ffcf97;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 18px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #d5dbee;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hint.subtle {
|
||||
color: #96a5c3;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,10 +9,12 @@ const props = defineProps<{
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
if (!props.network?.updated_at) {
|
||||
return '暂无'
|
||||
return 'unavailable'
|
||||
}
|
||||
return new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
|
||||
const activeSource = computed(() => props.network?.active_control_source ?? 'none')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,41 +22,64 @@ const updatedAt = computed(() => {
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Network</p>
|
||||
<h2>链路状态</h2>
|
||||
<h2>Session Telemetry</h2>
|
||||
</div>
|
||||
<span class="badge">{{ network?.peer_status ?? 'loading' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>延迟</span>
|
||||
<span>Latency</span>
|
||||
<strong>{{ network?.latency_ms ?? '--' }} ms</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>抖动</span>
|
||||
<span>Jitter</span>
|
||||
<strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>丢包率</span>
|
||||
<strong>{{ network?.packet_loss_pct ?? '--' }} %</strong>
|
||||
<span>Active Control</span>
|
||||
<strong>{{ activeSource }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>信号强度</span>
|
||||
<strong>{{ network?.signal_dbm ?? '--' }} dBm</strong>
|
||||
<span>Lease</span>
|
||||
<strong>{{ network?.control_lease_remaining_ms ?? '--' }} ms</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>发送速率</span>
|
||||
<span>TX Rate</span>
|
||||
<strong>{{ network?.tx_kbps ?? '--' }} kbps</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>接收速率</span>
|
||||
<span>RX Rate</span>
|
||||
<strong>{{ network?.rx_kbps ?? '--' }} kbps</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<p><strong>来源:</strong>{{ network?.transport ?? '暂无' }} / {{ network?.source_mode ?? '暂无' }}</p>
|
||||
<p><strong>刷新:</strong>{{ updatedAt }}</p>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="summary">
|
||||
<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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -102,7 +127,9 @@ h2 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
.stat-card,
|
||||
.summary,
|
||||
.session-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
@@ -120,32 +147,39 @@ h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
.summary,
|
||||
.session-card {
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.summary p {
|
||||
.summary p,
|
||||
.session-card h3,
|
||||
.session-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary p + p {
|
||||
.summary p + p,
|
||||
.session-card p + p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.session-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats {
|
||||
.stats,
|
||||
.session-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats {
|
||||
.stats,
|
||||
.session-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
||||
video.value = snapshot.video
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '数据加载失败'
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Failed to load monitoring data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -36,9 +36,9 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
||||
return errorMessage.value
|
||||
}
|
||||
if (loading.value) {
|
||||
return '正在连接 Django 后端并加载监控数据...'
|
||||
return 'Connecting to the Django backend and loading live monitoring data...'
|
||||
}
|
||||
return '页面已连接 Django 后端。GPS 与网络状态按当前页面策略轮询更新,视频区域单独按目标 30FPS 请求单帧 JPEG。'
|
||||
return 'Dashboard connected. Video, GPS, and live session telemetry refresh continuously from the unified A-side daemon.'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -23,3 +23,13 @@ export function fetchVideoStatus() {
|
||||
export function buildVideoFrameUrl(frameKey: number) {
|
||||
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
export function buildControlWebSocketUrl() {
|
||||
const url = new URL(API_BASE, window.location.origin)
|
||||
const basePath = url.pathname.replace(/\/$/, '')
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.pathname = `${basePath}/ws/control/`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
@@ -12,17 +12,96 @@ export interface GpsTelemetry {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SessionAppStats {
|
||||
connected: number
|
||||
send_calls?: number
|
||||
send_bytes?: number
|
||||
send_errors?: number
|
||||
recv_calls?: number
|
||||
recv_bytes?: number
|
||||
recv_timeouts?: number
|
||||
recv_errors?: number
|
||||
}
|
||||
|
||||
export interface SessionKcpStats {
|
||||
connected?: number
|
||||
conv?: number
|
||||
rto_ms?: number
|
||||
srtt_ms?: number
|
||||
srttvar_ms?: number
|
||||
snd_queue?: number
|
||||
rcv_queue?: number
|
||||
snd_buffer?: number
|
||||
xmit_total?: number
|
||||
}
|
||||
|
||||
export interface SessionTelemetry {
|
||||
app: SessionAppStats
|
||||
kcp: SessionKcpStats
|
||||
}
|
||||
|
||||
export interface NativeUdpIngress {
|
||||
started: boolean
|
||||
bind_addr: string
|
||||
packets_received: number
|
||||
invalid_packets: number
|
||||
last_sender: string
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface ControlArbiterStatus {
|
||||
active_source: string | null
|
||||
control_lease_remaining_ms: number
|
||||
packet_counts: Record<string, number>
|
||||
send_rate_hz: number
|
||||
source_lease_ms: number
|
||||
zero_burst_packets: number
|
||||
last_error: string
|
||||
last_sent_at_monotonic: number
|
||||
}
|
||||
|
||||
export interface ControlSenderStatus {
|
||||
backend_ready: boolean
|
||||
started: boolean
|
||||
connected: boolean
|
||||
peer_id: string
|
||||
target_peer: string
|
||||
send_count: number
|
||||
send_errors: number
|
||||
drain_errors: number
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface NetworkTelemetry {
|
||||
peer_status: string
|
||||
latency_ms: number
|
||||
jitter_ms: number
|
||||
packet_loss_pct: number
|
||||
latency_ms: number | null
|
||||
jitter_ms: number | null
|
||||
packet_loss_pct: number | null
|
||||
tx_kbps: number
|
||||
rx_kbps: number
|
||||
signal_dbm: number
|
||||
transport: string
|
||||
source_mode: string
|
||||
updated_at: string
|
||||
active_control_source: string | null
|
||||
control_lease_remaining_ms: number
|
||||
combined: {
|
||||
connected_sessions: number
|
||||
send_bytes: number
|
||||
recv_bytes: number
|
||||
tx_kbps: number
|
||||
rx_kbps: number
|
||||
}
|
||||
sessions: {
|
||||
video: SessionTelemetry
|
||||
control: SessionTelemetry
|
||||
}
|
||||
ingress: {
|
||||
native_udp: NativeUdpIngress
|
||||
}
|
||||
control: {
|
||||
arbiter: ControlArbiterStatus
|
||||
sender: ControlSenderStatus
|
||||
}
|
||||
}
|
||||
|
||||
export interface VideoStatus {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ControlPanel from '@/components/ControlPanel.vue'
|
||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||
import VideoPanel from '@/components/VideoPanel.vue'
|
||||
@@ -12,11 +13,11 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>机器人竞赛指挥台</h1>
|
||||
<h1>Robot Command Center</h1>
|
||||
</div>
|
||||
<p class="hero-text">
|
||||
当前版本已经接通三块核心能力:JPEG 视频流、GPS 地图定位、网络状态展示。后面接真实
|
||||
C 数据源时,前端页面不需要大改。
|
||||
The A-side daemon now owns video receive, control ingress arbitration, and live session
|
||||
telemetry in one backend process.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -26,6 +27,7 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||
|
||||
<main class="layout">
|
||||
<VideoPanel :video="video" />
|
||||
<ControlPanel />
|
||||
<GpsMapPanel :gps="gps" />
|
||||
<NetworkPanel :network="network" />
|
||||
</main>
|
||||
|
||||
@@ -10,10 +10,11 @@ const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Network</p>
|
||||
<h1>网络状态页面</h1>
|
||||
<h1>Network Telemetry</h1>
|
||||
</div>
|
||||
<p class="description">
|
||||
当前先展示模拟网络遥测数据,后续只需要把后端采集函数替换成真实 C 输出,就能保留同样的渲染界面。
|
||||
Live per-session OmniSocket telemetry from the unified A-side daemon, including active control
|
||||
source and native UDP ingress status.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -69,4 +70,3 @@ h1 {
|
||||
border-color: rgba(255, 107, 107, 0.28);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user