FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
Vita49StreamingView.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import FilterListIcon from '@mui/icons-material/FilterList';
5import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
6import SaveAltIcon from '@mui/icons-material/SaveAlt';
7import StopCircleIcon from '@mui/icons-material/StopCircle';
8import {
9 Alert,
10 Box,
11 Button,
12 Chip,
13 FormControl,
14 Grid,
15 InputLabel,
16 MenuItem,
17 Paper,
18 Select,
19 Stack,
20 Switch,
21 Table,
22 TableBody,
23 TableCell,
24 TableContainer,
25 TableHead,
26 TableRow,
27 TextField,
28 Typography,
29} from '@mui/material';
30import { invoke } from '@tauri-apps/api/core';
31import { listen } from '@tauri-apps/api/event';
32import { dirname } from '@tauri-apps/api/path';
33import React, {
34 useCallback,
35 useEffect,
36 useMemo,
37 useRef,
38 useState,
39} from 'react';
40import { useScenarioStore } from '@/stores/scenarioStore';
41import { getBlockingFmcwValidationMessage } from '@/stores/scenarioStore/fmcwValidation';
42import {
43 normalizeSimulationOutputMetadata,
44 type RawSimulationOutputMetadata,
45} from '@/stores/simulationProgressStore';
46import {
47 deriveExpectedVita49Streams,
48 mergeVita49StreamRows,
49 toVita49BackendConfig,
50 useVita49StreamingStore,
51 type Vita49PacketTraceEvent,
52 type Vita49StreamStatsEvent,
53 type Vita49TelemetryPoll,
54 type Vita49Timestamp,
55 validateVita49Config,
56} from '@/stores/vita49StreamingStore';
57
58const TELEMETRY_POLL_INTERVAL_MS = 100;
59const TELEMETRY_POLL_ERROR_INTERVAL_MS = 250;
60const TELEMETRY_POLL_PACKET_LIMIT = 1000;
61const PACKET_TRACE_TABLE_HEIGHT = 420;
62const PACKET_TRACE_ROW_HEIGHT = 36;
63const PACKET_TRACE_OVERSCAN = 8;
64
65const formatMetric = (value: number | null | undefined) =>
66 value === null || value === undefined
67 ? '-'
68 : value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
69
70const formatExact = (value: string | number | null | undefined) =>
71 value === null || value === undefined ? '-' : String(value);
72
73const formatSeconds = (value: number | null | undefined) =>
74 value === null || value === undefined
75 ? '-'
76 : `${value.toLocaleString(undefined, { maximumSignificantDigits: 9 })} s`;
77
78const formatVita49Timestamp = (
79 timestamp: Vita49Timestamp | null | undefined
80) => {
81 if (!timestamp) {
82 return '-';
83 }
84
85 const second = Number(timestamp.integer_seconds);
86 if (!Number.isFinite(second)) {
87 return '-';
88 }
89
90 const base = new Date(second * 1000).toISOString().replace('.000Z', '');
91 const fractionalPicoseconds = Math.trunc(timestamp.fractional_picoseconds)
92 .toString()
93 .padStart(12, '0')
94 .replace(/0+$/, '');
95 return fractionalPicoseconds
96 ? `${base}.${fractionalPicoseconds}Z`
97 : `${base}Z`;
98};
99
100const formatSimulationSpan = (
101 start: number | null | undefined,
102 end: number | null | undefined
103) => {
104 const first = formatSeconds(start);
105 const last = formatSeconds(end);
106 return first === '-' && last === '-' ? '-' : `${first} - ${last}`;
107};
108
109const formatTimestampSpan = (
110 start: Vita49Timestamp | null | undefined,
111 end: Vita49Timestamp | null | undefined
112) => {
113 const first = formatVita49Timestamp(start);
114 const last = formatVita49Timestamp(end);
115 return first === '-' && last === '-' ? '-' : `${first} - ${last}`;
116};
117
118const formatStreamId = (streamId: number | null | undefined) =>
119 streamId === null || streamId === undefined
120 ? '-'
121 : `0x${streamId.toString(16).toUpperCase().padStart(8, '0')}`;
122
123export const Vita49StreamingView = React.memo(function Vita49StreamingView() {
124 const config = useVita49StreamingStore((state) => state.config);
125 const runState = useVita49StreamingStore((state) => state.runState);
126 const expectedStreams = useVita49StreamingStore(
127 (state) => state.expectedStreams
128 );
129 const streamStats = useVita49StreamingStore((state) => state.streamStats);
130 const packetTrace = useVita49StreamingStore((state) => state.packetTrace);
131 const omittedPacketTraceEvents = useVita49StreamingStore(
132 (state) => state.omittedPacketTraceEvents
133 );
134 const finalVita49Metadata = useVita49StreamingStore(
135 (state) => state.finalVita49Metadata
136 );
137 const error = useVita49StreamingStore((state) => state.error);
138 const setConfig = useVita49StreamingStore((state) => state.setConfig);
139 const startRun = useVita49StreamingStore((state) => state.startRun);
140 const markStopping = useVita49StreamingStore((state) => state.markStopping);
141 const markDraining = useVita49StreamingStore((state) => state.markDraining);
142 const setStreamStats = useVita49StreamingStore(
143 (state) => state.setStreamStats
144 );
145 const appendPacketBatch = useVita49StreamingStore(
146 (state) => state.appendPacketBatch
147 );
148 const completeRun = useVita49StreamingStore((state) => state.completeRun);
149 const cancelRun = useVita49StreamingStore((state) => state.cancelRun);
150 const failRun = useVita49StreamingStore((state) => state.failRun);
151 const showError = useScenarioStore((state) => state.showError);
152 const showWarning = useScenarioStore((state) => state.showWarning);
153 const showSuccess = useScenarioStore((state) => state.showSuccess);
154 const scenarioFilePath = useScenarioStore(
155 (state) => state.scenarioFilePath
156 );
157 const outputDirectory = useScenarioStore((state) => state.outputDirectory);
158
159 const [metadataExportPath, setMetadataExportPath] = useState<string | null>(
160 null
161 );
162 const [streamFilter, setStreamFilter] = useState('all');
163 const [packetKindFilter, setPacketKindFilter] = useState('all');
164 const [droppedOnly, setDroppedOnly] = useState(false);
165 const [overRangeOnly, setOverRangeOnly] = useState(false);
166 const [sampleLossOnly, setSampleLossOnly] = useState(false);
167 const [packetTraceScrollTop, setPacketTraceScrollTop] = useState(0);
168 const packetTraceContainerRef = useRef<HTMLDivElement | null>(null);
169 const pendingTelemetryRef = useRef<{
170 stats: Vita49StreamStatsEvent | null;
171 packets: Vita49PacketTraceEvent[];
172 omittedPacketTraceEvents: number;
173 }>({
174 stats: null,
175 packets: [],
176 omittedPacketTraceEvents: 0,
177 });
178 const telemetryFlushFrameRef = useRef<number | null>(null);
179 const isRunning =
180 runState === 'running' ||
181 runState === 'stopping' ||
182 runState === 'draining';
183 const configErrors = validateVita49Config(config);
184
185 const appendTelemetry = useCallback(
186 (telemetry: Vita49TelemetryPoll) => {
187 if (telemetry.stats) {
188 setStreamStats(telemetry.stats);
189 }
190 if (
191 telemetry.packets.length > 0 ||
192 telemetry.omitted_packet_trace_events > 0
193 ) {
194 appendPacketBatch(
195 telemetry.packets,
196 telemetry.omitted_packet_trace_events
197 );
198 }
199 },
200 [appendPacketBatch, setStreamStats]
201 );
202
203 const flushPendingTelemetry = useCallback(() => {
204 if (telemetryFlushFrameRef.current !== null) {
205 window.cancelAnimationFrame(telemetryFlushFrameRef.current);
206 telemetryFlushFrameRef.current = null;
207 }
208
209 const pending = pendingTelemetryRef.current;
210 pendingTelemetryRef.current = {
211 stats: null,
212 packets: [],
213 omittedPacketTraceEvents: 0,
214 };
215
216 if (pending.stats) {
217 setStreamStats(pending.stats);
218 }
219 if (
220 pending.packets.length > 0 ||
221 pending.omittedPacketTraceEvents > 0
222 ) {
223 appendPacketBatch(
224 pending.packets,
225 pending.omittedPacketTraceEvents
226 );
227 }
228 }, [appendPacketBatch, setStreamStats]);
229
230 const drainAvailableTelemetry = useCallback(async () => {
231 flushPendingTelemetry();
232 let hasMore = false;
233 do {
234 const telemetry = await invoke<Vita49TelemetryPoll>(
235 'poll_vita49_telemetry',
236 { maxPackets: TELEMETRY_POLL_PACKET_LIMIT }
237 );
238 appendTelemetry(telemetry);
239 hasMore = telemetry.has_more;
240 } while (hasMore);
241 flushPendingTelemetry();
242 }, [appendTelemetry, flushPendingTelemetry]);
243
244 useEffect(() => {
245 let active = true;
246 const unlisteners = Promise.all([
247 listen<string>('vita49-output-metadata', (event) => {
248 if (!active) return;
249 const metadata = normalizeSimulationOutputMetadata(
250 JSON.parse(event.payload) as RawSimulationOutputMetadata
251 );
252 void drainAvailableTelemetry().finally(() => {
253 if (active) completeRun(metadata);
254 });
255 }),
256 listen<string>('vita49-stream-complete', (event) => {
257 if (!active) return;
258 const metadata = normalizeSimulationOutputMetadata(
259 JSON.parse(event.payload) as RawSimulationOutputMetadata
260 );
261 void drainAvailableTelemetry().finally(() => {
262 if (active) completeRun(metadata);
263 });
264 }),
265 listen<string>('vita49-stream-draining', () => {
266 if (!active) return;
267 markDraining();
268 }),
269 listen<string>('vita49-stream-cancelled', (event) => {
270 if (!active) return;
271 const metadata = normalizeSimulationOutputMetadata(
272 JSON.parse(event.payload) as RawSimulationOutputMetadata
273 );
274 void drainAvailableTelemetry().finally(() => {
275 if (active) cancelRun(metadata);
276 });
277 }),
278 listen<string>('vita49-stream-error', (event) => {
279 if (!active) return;
280 failRun(event.payload);
281 showError(`VITA49 streaming failed: ${event.payload}`);
282 }),
283 ]);
284
285 return () => {
286 active = false;
287 unlisteners.then((listeners) =>
288 listeners.forEach((unlisten) => unlisten())
289 );
290 };
291 }, [
292 cancelRun,
293 completeRun,
294 drainAvailableTelemetry,
295 failRun,
296 markDraining,
297 showError,
298 ]);
299
300 useEffect(() => {
301 if (!isRunning) {
302 return;
303 }
304
305 let cancelled = false;
306 let pollTimer: ReturnType<typeof setTimeout> | null = null;
307
308 const scheduleFlush = () => {
309 if (telemetryFlushFrameRef.current !== null) {
310 return;
311 }
312 telemetryFlushFrameRef.current = window.requestAnimationFrame(
313 flushPendingTelemetry
314 );
315 };
316
317 const queueTelemetry = (telemetry: Vita49TelemetryPoll) => {
318 const pending = pendingTelemetryRef.current;
319 pending.stats = telemetry.stats ?? pending.stats;
320 pending.packets.push(...telemetry.packets);
321 pending.omittedPacketTraceEvents +=
322 telemetry.omitted_packet_trace_events;
323 scheduleFlush();
324 };
325
326 const pollTelemetry = async () => {
327 if (cancelled) {
328 return;
329 }
330
331 try {
332 let hasMore = false;
333 do {
334 const telemetry = await invoke<Vita49TelemetryPoll>(
335 'poll_vita49_telemetry',
336 { maxPackets: TELEMETRY_POLL_PACKET_LIMIT }
337 );
338 queueTelemetry(telemetry);
339 hasMore = telemetry.has_more;
340 } while (!cancelled && hasMore);
341
342 if (!cancelled) {
343 pollTimer = setTimeout(
344 pollTelemetry,
345 TELEMETRY_POLL_INTERVAL_MS
346 );
347 }
348 } catch (err) {
349 console.error('Failed to poll VITA49 telemetry:', err);
350 if (!cancelled) {
351 pollTimer = setTimeout(
352 pollTelemetry,
353 TELEMETRY_POLL_ERROR_INTERVAL_MS
354 );
355 }
356 }
357 };
358
359 void pollTelemetry();
360
361 return () => {
362 cancelled = true;
363 if (pollTimer !== null) {
364 clearTimeout(pollTimer);
365 }
366 flushPendingTelemetry();
367 };
368 }, [flushPendingTelemetry, isRunning]);
369
370 const streamRows = useMemo(
371 () => mergeVita49StreamRows(expectedStreams, streamStats),
372 [expectedStreams, streamStats]
373 );
374
375 const aggregate = useMemo(
376 () =>
377 streamRows.reduce(
378 (acc, row) => ({
379 packets: acc.packets + row.packetsEmitted,
380 samples: acc.samples + row.samplesEmitted,
381 drops: acc.drops + row.packetsDropped,
382 late: acc.late + row.latePacketCount,
383 overRange: acc.overRange + row.overRangeCount,
384 context: acc.context + row.contextPackets,
385 }),
386 {
387 packets: 0,
388 samples: 0,
389 drops: 0,
390 late: 0,
391 overRange: 0,
392 context: 0,
393 }
394 ),
395 [streamRows]
396 );
397
398 const streamIdOptions = useMemo(() => {
399 const ids = new Set<number>();
400 for (const row of streamRows) {
401 if (row.streamId !== null) ids.add(row.streamId);
402 }
403 for (const packet of packetTrace) {
404 if (packet.stream_id) ids.add(packet.stream_id);
405 }
406 return Array.from(ids).sort((a, b) => a - b);
407 }, [packetTrace, streamRows]);
408
409 const filteredPackets = useMemo(
410 () =>
411 packetTrace.filter((packet) => {
412 if (
413 streamFilter !== 'all' &&
414 packet.stream_id !== Number(streamFilter)
415 ) {
416 return false;
417 }
418 if (packetKindFilter === 'data' && !packet.data_packet) {
419 return false;
420 }
421 if (packetKindFilter === 'context' && !packet.context_packet) {
422 return false;
423 }
424 if (droppedOnly && !packet.dropped) return false;
425 if (overRangeOnly && !packet.over_range) return false;
426 if (sampleLossOnly && !packet.sample_loss) return false;
427 return true;
428 }),
429 [
430 droppedOnly,
431 overRangeOnly,
432 packetKindFilter,
433 packetTrace,
434 sampleLossOnly,
435 streamFilter,
436 ]
437 );
438
439 useEffect(() => {
440 setPacketTraceScrollTop(0);
441 if (packetTraceContainerRef.current) {
442 packetTraceContainerRef.current.scrollTop = 0;
443 }
444 }, [
445 droppedOnly,
446 overRangeOnly,
447 packetKindFilter,
448 sampleLossOnly,
449 streamFilter,
450 ]);
451
452 const packetTraceWindow = useMemo(() => {
453 const rawStart = Math.max(
454 0,
455 Math.floor(packetTraceScrollTop / PACKET_TRACE_ROW_HEIGHT) -
456 PACKET_TRACE_OVERSCAN
457 );
458 const start = Math.min(filteredPackets.length, rawStart);
459 const end = Math.min(
460 filteredPackets.length,
461 Math.ceil(
462 (packetTraceScrollTop + PACKET_TRACE_TABLE_HEIGHT) /
463 PACKET_TRACE_ROW_HEIGHT
464 ) + PACKET_TRACE_OVERSCAN
465 );
466 return {
467 start,
468 end,
469 packets: filteredPackets.slice(start, end),
470 topSpacerHeight: start * PACKET_TRACE_ROW_HEIGHT,
471 bottomSpacerHeight:
472 Math.max(0, filteredPackets.length - end) *
473 PACKET_TRACE_ROW_HEIGHT,
474 };
475 }, [filteredPackets, packetTraceScrollTop]);
476
477 const getEffectiveOutputDir = async () => {
478 if (outputDirectory) return outputDirectory;
479 if (scenarioFilePath) {
480 return dirname(scenarioFilePath);
481 }
482 return '.';
483 };
484
485 const handleStart = async () => {
486 const scenarioState = useScenarioStore.getState();
487 const validationMessage =
488 getBlockingFmcwValidationMessage(scenarioState);
489 if (validationMessage) {
490 showError(`FMCW validation failed: ${validationMessage}`);
491 return;
492 }
493
494 const errors = validateVita49Config(config);
495 if (errors.length > 0) {
496 showError(errors.join(' '));
497 return;
498 }
499
500 const expected = deriveExpectedVita49Streams(scenarioState);
501 if (expected.length === 0) {
502 showWarning('No receiver streams are configured.');
503 }
504
505 setMetadataExportPath(null);
506 startRun(expected);
507 try {
508 await invoke('set_output_directory', {
509 dir: await getEffectiveOutputDir(),
510 });
511 await scenarioState.syncBackend();
512 await invoke('start_vita49_stream', {
513 config: toVita49BackendConfig(config),
514 });
515 } catch (err) {
516 const message = err instanceof Error ? err.message : String(err);
517 failRun(message);
518 showError(`Failed to start VITA49 streaming: ${message}`);
519 }
520 };
521
522 const handleStop = async () => {
523 markStopping();
524 try {
525 await invoke('stop_simulation');
526 } catch (err) {
527 const message = err instanceof Error ? err.message : String(err);
528 showError(`Failed to stop simulation: ${message}`);
529 }
530 };
531
532 const exportMetadataJson = async () => {
533 try {
534 const outputPath = await invoke<string>(
535 'export_output_metadata_json'
536 );
537 setMetadataExportPath(outputPath);
538 showSuccess(`Metadata JSON saved to ${outputPath}`);
539 } catch (err) {
540 const message = err instanceof Error ? err.message : String(err);
541 showError(`Failed to export metadata JSON: ${message}`);
542 }
543 };
544
545 return (
546 <Box sx={{ p: 3, height: '100%', overflowY: 'auto' }}>
547 <Stack
548 direction="row"
549 spacing={2}
550 alignItems="center"
551 sx={{ mb: 2 }}
552 >
553 <Typography variant="h4">VITA49 Streams</Typography>
554 <Chip
555 label={runState}
556 color={isRunning ? 'primary' : 'default'}
557 />
558 </Stack>
559
560 {error && (
561 <Alert severity="error" sx={{ mb: 2 }}>
562 {error}
563 </Alert>
564 )}
565
566 <Grid container spacing={2} sx={{ mb: 2 }}>
567 <Grid size={{ xs: 12, lg: 4 }}>
568 <Paper variant="outlined" sx={{ p: 2, height: '100%' }}>
569 <Typography variant="h6" sx={{ mb: 2 }}>
570 Runtime
571 </Typography>
572 <Stack spacing={2}>
573 <TextField
574 label="Host"
575 size="small"
576 value={config.host}
577 disabled={isRunning}
578 onChange={(event) =>
579 setConfig({ host: event.target.value })
580 }
581 />
582 <TextField
583 label="Port"
584 size="small"
585 type="number"
586 value={config.port}
587 disabled={isRunning}
588 onChange={(event) =>
589 setConfig({
590 port: Number(event.target.value),
591 })
592 }
593 />
594 <TextField
595 label="Full-scale"
596 size="small"
597 type="number"
598 value={config.fullscale}
599 disabled={isRunning}
600 onChange={(event) =>
601 setConfig({
602 fullscale: Number(event.target.value),
603 })
604 }
605 />
606 <FormControl size="small">
607 <InputLabel id="vita49-epoch-mode-label">
608 Epoch
609 </InputLabel>
610 <Select
611 labelId="vita49-epoch-mode-label"
612 label="Epoch"
613 value={config.epochMode}
614 disabled={isRunning}
615 onChange={(event) =>
616 setConfig({
617 epochMode: event.target
618 .value as typeof config.epochMode,
619 })
620 }
621 >
622 <MenuItem value="auto">Auto</MenuItem>
623 <MenuItem value="fixed">Fixed</MenuItem>
624 </Select>
625 </FormControl>
626 {config.epochMode === 'fixed' && (
627 <TextField
628 label="Unix ns"
629 size="small"
630 value={config.epochUnixNanoseconds}
631 disabled={isRunning}
632 onChange={(event) =>
633 setConfig({
634 epochUnixNanoseconds:
635 event.target.value,
636 })
637 }
638 />
639 )}
640 <TextField
641 label="Max UDP payload"
642 size="small"
643 type="number"
644 value={config.maxUdpPayload}
645 disabled={isRunning}
646 onChange={(event) =>
647 setConfig({
648 maxUdpPayload: Number(
649 event.target.value
650 ),
651 })
652 }
653 />
654 <TextField
655 label="Queue depth"
656 size="small"
657 type="number"
658 value={config.queueDepth}
659 disabled={isRunning}
660 onChange={(event) =>
661 setConfig({
662 queueDepth: Number(event.target.value),
663 })
664 }
665 />
666 <Stack
667 direction="row"
668 alignItems="center"
669 justifyContent="space-between"
670 spacing={1}
671 >
672 <Typography variant="body2">
673 Packet trace
674 </Typography>
675 <Switch
676 checked={config.traceEnabled}
677 disabled={isRunning}
678 onChange={(event) =>
679 setConfig({
680 traceEnabled: event.target.checked,
681 })
682 }
683 />
684 </Stack>
685 <TextField
686 label="Trace ring"
687 size="small"
688 type="number"
689 value={config.packetTraceRingSize}
690 disabled={isRunning}
691 onChange={(event) =>
692 setConfig({
693 packetTraceRingSize: Number(
694 event.target.value
695 ),
696 })
697 }
698 />
699 {configErrors.length > 0 && (
700 <Alert severity="warning">
701 {configErrors.join(' ')}
702 </Alert>
703 )}
704 <Stack direction="row" spacing={1}>
705 <Button
706 variant="contained"
707 startIcon={<PlayCircleOutlineIcon />}
708 disabled={
709 isRunning || configErrors.length > 0
710 }
711 onClick={handleStart}
712 >
713 Start
714 </Button>
715 <Button
716 variant="outlined"
717 color="error"
718 startIcon={<StopCircleIcon />}
719 disabled={runState !== 'running'}
720 onClick={handleStop}
721 >
722 Stop
723 </Button>
724 </Stack>
725 </Stack>
726 </Paper>
727 </Grid>
728
729 <Grid size={{ xs: 12, lg: 8 }}>
730 <Stack spacing={2}>
731 <Paper variant="outlined" sx={{ p: 2 }}>
732 <Stack
733 direction={{ xs: 'column', md: 'row' }}
734 spacing={2}
735 justifyContent="space-between"
736 >
737 <Box>
738 <Typography variant="overline">
739 Endpoint
740 </Typography>
741 <Typography variant="h6">
742 {config.host}:{config.port}
743 </Typography>
744 </Box>
745 <Box>
746 <Typography variant="overline">
747 Profile
748 </Typography>
749 <Typography variant="h6">
750 {finalVita49Metadata?.class_id ??
751 '0xFA52530001000101'}
752 </Typography>
753 </Box>
754 <Box>
755 <Typography variant="overline">
756 Epoch
757 </Typography>
758 <Typography variant="h6">
759 {formatExact(
760 streamStats?.epoch_unix_nanoseconds ??
761 finalVita49Metadata?.epoch_unix_nanoseconds
762 )}
763 </Typography>
764 </Box>
765 </Stack>
766 </Paper>
767
768 <Grid container spacing={1}>
769 {[
770 ['Packets', aggregate.packets],
771 ['Samples', aggregate.samples],
772 ['Drops', aggregate.drops],
773 ['Late', aggregate.late],
774 ['Over-range', aggregate.overRange],
775 ['Context', aggregate.context],
776 ].map(([label, value]) => (
777 <Grid size={{ xs: 6, md: 2 }} key={label}>
778 <Paper variant="outlined" sx={{ p: 1.5 }}>
779 <Typography variant="caption">
780 {label}
781 </Typography>
782 <Typography variant="h6">
783 {formatMetric(value as number)}
784 </Typography>
785 </Paper>
786 </Grid>
787 ))}
788 </Grid>
789 </Stack>
790 </Grid>
791 </Grid>
792
793 <Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
794 <Stack
795 direction="row"
796 justifyContent="space-between"
797 alignItems="center"
798 sx={{ mb: 1 }}
799 >
800 <Typography variant="h6">Streams</Typography>
801 <Button
802 variant="outlined"
803 startIcon={<SaveAltIcon />}
804 disabled={!finalVita49Metadata}
805 onClick={exportMetadataJson}
806 >
807 Export JSON
808 </Button>
809 </Stack>
810 {metadataExportPath && (
811 <Typography
812 variant="body2"
813 color="text.secondary"
814 sx={{ mb: 1, overflowWrap: 'anywhere' }}
815 >
816 {metadataExportPath}
817 </Typography>
818 )}
819 <TableContainer>
820 <Table size="small">
821 <TableHead>
822 <TableRow>
823 <TableCell>Receiver</TableCell>
824 <TableCell>Stream ID</TableCell>
825 <TableCell align="right">Rate</TableCell>
826 <TableCell align="right">RF</TableCell>
827 <TableCell align="right">Packets</TableCell>
828 <TableCell align="right">Samples</TableCell>
829 <TableCell align="right">Drops</TableCell>
830 <TableCell align="right">Late</TableCell>
831 <TableCell align="right">Context</TableCell>
832 <TableCell>Simulation span</TableCell>
833 <TableCell>UTC span</TableCell>
834 </TableRow>
835 </TableHead>
836 <TableBody>
837 {streamRows.map((row) => (
838 <TableRow key={row.key}>
839 <TableCell>
840 <Stack spacing={0.25}>
841 <Typography variant="body2">
842 {row.receiverName}
843 </Typography>
844 <Typography
845 variant="caption"
846 color="text.secondary"
847 >
848 {row.platformName
849 ? `${row.platformName} / ${row.mode}`
850 : row.mode}
851 </Typography>
852 </Stack>
853 </TableCell>
854 <TableCell>
855 {formatStreamId(row.streamId)}
856 </TableCell>
857 <TableCell align="right">
858 {formatMetric(row.sampleRate)}
859 </TableCell>
860 <TableCell align="right">
861 {formatMetric(row.referenceFrequency)}
862 </TableCell>
863 <TableCell align="right">
864 {formatMetric(row.packetsEmitted)}
865 </TableCell>
866 <TableCell align="right">
867 {formatMetric(row.samplesEmitted)}
868 </TableCell>
869 <TableCell align="right">
870 {formatMetric(row.packetsDropped)}
871 </TableCell>
872 <TableCell align="right">
873 {formatMetric(row.latePacketCount)}
874 </TableCell>
875 <TableCell align="right">
876 {formatMetric(row.contextPackets)}
877 </TableCell>
878 <TableCell>
879 {formatSimulationSpan(
880 row.firstSampleTime,
881 row.endSampleTime
882 )}
883 </TableCell>
884 <TableCell>
885 {formatTimestampSpan(
886 row.firstTimestamp,
887 row.endTimestamp
888 )}
889 </TableCell>
890 </TableRow>
891 ))}
892 {streamRows.length === 0 && (
893 <TableRow>
894 <TableCell colSpan={11}>
895 <Typography color="text.secondary">
896 No streams
897 </Typography>
898 </TableCell>
899 </TableRow>
900 )}
901 </TableBody>
902 </Table>
903 </TableContainer>
904 </Paper>
905
906 <Paper variant="outlined" sx={{ p: 2 }}>
907 <Stack
908 direction={{ xs: 'column', md: 'row' }}
909 spacing={2}
910 alignItems={{ xs: 'stretch', md: 'center' }}
911 sx={{ mb: 2 }}
912 >
913 <Typography variant="h6" sx={{ flexGrow: 1 }}>
914 Packet Trace
915 </Typography>
916 <FilterListIcon color="action" />
917 <FormControl size="small" sx={{ minWidth: 160 }}>
918 <InputLabel id="vita49-stream-filter-label">
919 Stream
920 </InputLabel>
921 <Select
922 labelId="vita49-stream-filter-label"
923 label="Stream"
924 value={streamFilter}
925 onChange={(event) =>
926 setStreamFilter(event.target.value)
927 }
928 >
929 <MenuItem value="all">All</MenuItem>
930 {streamIdOptions.map((streamId) => (
931 <MenuItem
932 value={String(streamId)}
933 key={streamId}
934 >
935 {formatStreamId(streamId)}
936 </MenuItem>
937 ))}
938 </Select>
939 </FormControl>
940 <FormControl size="small" sx={{ minWidth: 140 }}>
941 <InputLabel id="vita49-kind-filter-label">
942 Kind
943 </InputLabel>
944 <Select
945 labelId="vita49-kind-filter-label"
946 label="Kind"
947 value={packetKindFilter}
948 onChange={(event) =>
949 setPacketKindFilter(event.target.value)
950 }
951 >
952 <MenuItem value="all">All</MenuItem>
953 <MenuItem value="data">Data</MenuItem>
954 <MenuItem value="context">Context</MenuItem>
955 </Select>
956 </FormControl>
957 {[
958 ['Dropped', droppedOnly, setDroppedOnly],
959 ['Over-range', overRangeOnly, setOverRangeOnly],
960 ['Sample-loss', sampleLossOnly, setSampleLossOnly],
961 ].map(([label, checked, setter]) => (
962 <Stack
963 direction="row"
964 alignItems="center"
965 spacing={0.5}
966 key={label as string}
967 >
968 <Switch
969 size="small"
970 checked={checked as boolean}
971 onChange={(event) =>
972 (setter as (value: boolean) => void)(
973 event.target.checked
974 )
975 }
976 />
977 <Typography variant="body2">
978 {label as string}
979 </Typography>
980 </Stack>
981 ))}
982 </Stack>
983 {omittedPacketTraceEvents > 0 && (
984 <Alert severity="info" sx={{ mb: 2 }}>
985 Showing last {formatMetric(packetTrace.length)} trace
986 events; {formatMetric(omittedPacketTraceEvents)} older
987 trace events discarded from trace history. Stream
988 packets and samples unaffected.
989 </Alert>
990 )}
991 <TableContainer
992 ref={packetTraceContainerRef}
993 onScroll={(event) =>
994 setPacketTraceScrollTop(event.currentTarget.scrollTop)
995 }
996 sx={{ maxHeight: PACKET_TRACE_TABLE_HEIGHT }}
997 >
998 <Table size="small" stickyHeader>
999 <TableHead>
1000 <TableRow>
1001 <TableCell align="right">Seq</TableCell>
1002 <TableCell>Event</TableCell>
1003 <TableCell>Stream</TableCell>
1004 <TableCell align="right">Bytes</TableCell>
1005 <TableCell align="right">Samples</TableCell>
1006 <TableCell align="right">t</TableCell>
1007 <TableCell align="right">UTC</TableCell>
1008 <TableCell>Flags</TableCell>
1009 </TableRow>
1010 </TableHead>
1011 <TableBody>
1012 {packetTraceWindow.topSpacerHeight > 0 && (
1013 <TableRow
1014 sx={{
1015 height: `${packetTraceWindow.topSpacerHeight}px`,
1016 }}
1017 >
1018 <TableCell
1019 colSpan={8}
1020 sx={{ p: 0, border: 0 }}
1021 />
1022 </TableRow>
1023 )}
1024 {packetTraceWindow.packets.map((packet) => (
1025 <TableRow key={packet.sequence}>
1026 <TableCell align="right">
1027 {packet.sequence}
1028 </TableCell>
1029 <TableCell>{packet.event}</TableCell>
1030 <TableCell>
1031 {formatStreamId(packet.stream_id)}
1032 </TableCell>
1033 <TableCell align="right">
1034 {formatMetric(packet.byte_count)}
1035 </TableCell>
1036 <TableCell align="right">
1037 {formatMetric(packet.sample_count)}
1038 </TableCell>
1039 <TableCell align="right">
1040 {formatSeconds(
1041 packet.first_sample_time
1042 )}
1043 </TableCell>
1044 <TableCell align="right">
1045 {formatVita49Timestamp(
1046 packet.timestamp
1047 )}
1048 </TableCell>
1049 <TableCell>
1050 <Stack direction="row" spacing={0.5}>
1051 {packet.dropped && (
1052 <Chip
1053 label="drop"
1054 size="small"
1055 color="error"
1056 />
1057 )}
1058 {packet.over_range && (
1059 <Chip
1060 label="over"
1061 size="small"
1062 color="warning"
1063 />
1064 )}
1065 {packet.sample_loss && (
1066 <Chip
1067 label="loss"
1068 size="small"
1069 color="warning"
1070 />
1071 )}
1072 </Stack>
1073 </TableCell>
1074 </TableRow>
1075 ))}
1076 {packetTraceWindow.bottomSpacerHeight > 0 && (
1077 <TableRow
1078 sx={{
1079 height: `${packetTraceWindow.bottomSpacerHeight}px`,
1080 }}
1081 >
1082 <TableCell
1083 colSpan={8}
1084 sx={{ p: 0, border: 0 }}
1085 />
1086 </TableRow>
1087 )}
1088 {filteredPackets.length === 0 && (
1089 <TableRow>
1090 <TableCell colSpan={8}>
1091 <Typography color="text.secondary">
1092 No packets
1093 </Typography>
1094 </TableCell>
1095 </TableRow>
1096 )}
1097 </TableBody>
1098 </Table>
1099 </TableContainer>
1100 </Paper>
1101 </Box>
1102 );
1103});