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