mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
MOREE!!!!
This commit is contained in:
@@ -343,6 +343,11 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
trackAnalyticsEvent('fullscreen_prompt_show', { layout, mode: fullscreenMode });
|
||||
}, [fullscreenMode, fullscreenVisible, layout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickstartVisible) return;
|
||||
trackAnalyticsEvent('quickstart_open', { layout });
|
||||
}, [layout, quickstartVisible]);
|
||||
|
||||
const openHelp = useCallback(() => {
|
||||
setHelpVisible(true);
|
||||
trackAnalyticsEvent('help_open', { layout, source: 'panel' });
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const MAX_EVENT_NAME_LENGTH = 80;
|
||||
const MAX_PROPERTY_KEY_LENGTH = 80;
|
||||
const MAX_STRING_VALUE_LENGTH = 240;
|
||||
const throttledEvents = new Map();
|
||||
|
||||
function getAdapter() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -90,3 +91,21 @@ export function identifyAnalyticsSession(payload = {}) {
|
||||
console.warn('Analytics identify failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackAnalyticsEventThrottled(name, payload = {}, options = {}) {
|
||||
const eventName = normalizeEventName(name);
|
||||
if (!eventName) return;
|
||||
const throttleMs = Number.isFinite(options?.throttleMs) ? Math.max(0, options.throttleMs) : 30 * 1000;
|
||||
const key = `${eventName}:${typeof options?.key === 'string' ? options.key : JSON.stringify(normalizePayload(payload))}`;
|
||||
const now = Date.now();
|
||||
const lastTrackedAt = throttledEvents.get(key) || 0;
|
||||
|
||||
/*
|
||||
Reliability signals can repeat every reconnect loop or failed media retry.
|
||||
Throttling by a caller-owned key keeps Umami useful as an incident signal
|
||||
without turning transient outages into hundreds of duplicate custom events.
|
||||
*/
|
||||
if (now - lastTrackedAt < throttleMs) return;
|
||||
throttledEvents.set(key, now);
|
||||
trackAnalyticsEvent(eventName, payload);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import OverseerControlPanel from './OverseerControlPanel.jsx';
|
||||
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
||||
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const MODES = [
|
||||
{ key: 'open', label: 'Open' },
|
||||
@@ -77,17 +78,23 @@ export default function AdminPanelContent() {
|
||||
|
||||
const handleModeChange = async (event) => {
|
||||
const mode = event.target.value;
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'started' });
|
||||
try {
|
||||
await setMode(mode);
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceControl = async (roverId) => {
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'started' });
|
||||
try {
|
||||
await requestControl(roverId, { force: true });
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
@@ -97,9 +104,12 @@ export default function AdminPanelContent() {
|
||||
const ok = window.confirm(`Reboot rover "${rover.name || rover.id}" now?`);
|
||||
if (!ok) return;
|
||||
setRebootStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||
trackAnalyticsEvent('rover_reboot_click', { roverId: rover.id, scope: 'admin' });
|
||||
try {
|
||||
await rebootRover(rover.id);
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setRebootStates((prev) => ({ ...prev, [rover.id]: false }));
|
||||
@@ -113,6 +123,7 @@ export default function AdminPanelContent() {
|
||||
);
|
||||
if (!ok) return;
|
||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||
trackAnalyticsEvent('rover_update_click', { roverId: rover.id });
|
||||
try {
|
||||
// The rover acknowledges once the privileged self-update helper has been
|
||||
// launched, not when the full install finishes. That is deliberate: a
|
||||
@@ -120,7 +131,9 @@ export default function AdminPanelContent() {
|
||||
// the same websocket would make the button look failed even when the
|
||||
// update is doing exactly what it should.
|
||||
await updateRover(rover.id);
|
||||
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: false }));
|
||||
@@ -131,9 +144,12 @@ export default function AdminPanelContent() {
|
||||
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
||||
if (!ok) return;
|
||||
setServerRebooting(true);
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'started' });
|
||||
try {
|
||||
await rebootServer();
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
setServerRebooting(false);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Barcode Games Panel
|
||||
// Purpose: Shows barcode game selection, current game status, and player points in the Activities tab.
|
||||
// Scope: Keeps the driver-side UI compact but informative; game-specific rules stay in server game modules.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
@@ -192,6 +192,8 @@ function Leaderboard({ players, ownPlayer }) {
|
||||
export default function BarcodeGamesPanel() {
|
||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeSignatureRef = useRef('');
|
||||
const participantCountRef = useRef(0);
|
||||
const activeGame = state.activeGame;
|
||||
const display = activeGame?.display || {};
|
||||
// Voting should stop once the shared game system has moved past selection.
|
||||
@@ -218,6 +220,42 @@ export default function BarcodeGamesPanel() {
|
||||
});
|
||||
}, [connectionState.connected, connectionState.lastReceivedAt, connectionState.stale, state.phase]);
|
||||
|
||||
useEffect(() => {
|
||||
const signature = `${activeGame?.id || 'none'}:${activeGame?.status || state.phase || 'unknown'}`;
|
||||
if (activeSignatureRef.current === signature) return;
|
||||
activeSignatureRef.current = signature;
|
||||
|
||||
/*
|
||||
Game lifecycle events are derived from server state instead of button
|
||||
clicks so they still report games started by another user or by a scanner
|
||||
workflow outside this panel.
|
||||
*/
|
||||
trackAnalyticsEvent('barcode_game_active_change', {
|
||||
gameId: activeGame?.id || '',
|
||||
status: activeGame?.status || '',
|
||||
phase: state.phase || 'unknown',
|
||||
});
|
||||
|
||||
if (activeGame?.status === 'ending' || state.phase === 'ending') {
|
||||
trackAnalyticsEvent('barcode_game_round_end', {
|
||||
gameId: activeGame?.id || '',
|
||||
participantCount: participants.length,
|
||||
});
|
||||
}
|
||||
}, [activeGame?.id, activeGame?.status, participants.length, state.phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (participants.length <= participantCountRef.current) {
|
||||
participantCountRef.current = participants.length;
|
||||
return;
|
||||
}
|
||||
participantCountRef.current = participants.length;
|
||||
trackAnalyticsEvent('barcode_game_player_join', {
|
||||
gameId: activeGame?.id || '',
|
||||
participantCount: participants.length,
|
||||
});
|
||||
}, [activeGame?.id, participants.length]);
|
||||
|
||||
const handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
trackAnalyticsEvent('barcode_game_vote', {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetry
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
function StatusRow({ value, tone = 'neutral' }) {
|
||||
const toneClasses =
|
||||
@@ -126,11 +127,14 @@ export default function DriveDockAction({
|
||||
const handleReturnToDrive = async () => {
|
||||
if (!roverId || pending) return;
|
||||
setPending('drive');
|
||||
trackAnalyticsEvent('drive_return', { roverId, source: 'drive_dock_action' });
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
trackAnalyticsEvent('drive_return_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('drive_return_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -142,11 +146,14 @@ export default function DriveDockAction({
|
||||
setConfirmOpen(false);
|
||||
setShowModal(false);
|
||||
setPending('drive');
|
||||
trackAnalyticsEvent('drive_start', { roverId, source: 'drive_dock_action' });
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
trackAnalyticsEvent('drive_start_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('drive_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -156,9 +163,12 @@ export default function DriveDockAction({
|
||||
const handleConfirmDock = async () => {
|
||||
if (!roverId || pending) return;
|
||||
setPending('dock');
|
||||
trackAnalyticsEvent('dock_assist_start', { roverId });
|
||||
try {
|
||||
dockAssist.enterAssist();
|
||||
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -171,10 +181,12 @@ export default function DriveDockAction({
|
||||
if (dockDisabled) return;
|
||||
if (manualAssistActive) {
|
||||
dockAssist.exitAssist();
|
||||
trackAnalyticsEvent('dock_assist_exit', { roverId });
|
||||
return;
|
||||
}
|
||||
setShowModal(true);
|
||||
setConfirmOpen(true);
|
||||
trackAnalyticsEvent('dock_assist_open', { roverId });
|
||||
};
|
||||
|
||||
// The drive/dock card appears inside the mobile controls and can be held or
|
||||
|
||||
@@ -9,6 +9,7 @@ import NightVisionControl from '../NightVisionControl/index.jsx';
|
||||
import { AUX_ZERO } from './constants.js';
|
||||
import VacuumControls from './VacuumControls.jsx';
|
||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
function AuxColumnContent() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
@@ -39,11 +40,17 @@ function AuxColumnContent() {
|
||||
const handleNightVisionToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setNightVision(nextOn);
|
||||
},
|
||||
[nightVisionAvailable, setNightVision],
|
||||
[nightVisionAvailable, roverId, setNightVision],
|
||||
);
|
||||
|
||||
const handleHornStart = useCallback(() => {
|
||||
trackAnalyticsEvent('horn_start', { roverId, source: 'mobile_control' });
|
||||
return startHorn();
|
||||
}, [roverId, startHorn]);
|
||||
|
||||
const handleAuxPress = useCallback(
|
||||
(id, values) => {
|
||||
if (disabled) return;
|
||||
@@ -99,7 +106,7 @@ function AuxColumnContent() {
|
||||
{hornAvailable ? (
|
||||
<HornControl
|
||||
disabled={disabled || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStart={handleHornStart}
|
||||
onStop={stopHorn}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
|
||||
@@ -29,7 +29,7 @@ import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
@@ -85,6 +85,20 @@ function DriveDockPanel() {
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||
const trackedControls = useMemo(
|
||||
() => ({
|
||||
setNightVision: (nextOn) => {
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setNightVision(nextOn);
|
||||
},
|
||||
startHorn: () => {
|
||||
trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' });
|
||||
return startHorn();
|
||||
},
|
||||
stopHorn,
|
||||
}),
|
||||
[roverId, setNightVision, startHorn, stopHorn],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
@@ -100,15 +114,15 @@ function DriveDockPanel() {
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={!roverId}
|
||||
onToggle={setNightVision}
|
||||
onToggle={trackedControls.setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
onStart={trackedControls.startHorn}
|
||||
onStop={trackedControls.stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
import {
|
||||
RESTART_DELAY_MS,
|
||||
UNMUTE_RETRY_MS,
|
||||
@@ -323,6 +324,20 @@ export default function RoverMediaPlayer({
|
||||
ensurePlayback();
|
||||
}
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
||||
/*
|
||||
WHEP status changes can repeat during automatic restart attempts. The
|
||||
throttled event keeps media reliability visible in analytics without
|
||||
reporting every retry loop as a separate user action.
|
||||
*/
|
||||
trackAnalyticsEventThrottled(
|
||||
'whep_player_error',
|
||||
{
|
||||
roverId: effectiveRoverId,
|
||||
status: nextStatus,
|
||||
detail: info || '',
|
||||
},
|
||||
{ key: `${effectiveRoverId || 'unknown'}:${nextStatus}:${info || ''}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
scheduleRestart();
|
||||
}
|
||||
};
|
||||
@@ -339,6 +354,15 @@ export default function RoverMediaPlayer({
|
||||
if (!active) return;
|
||||
setStatus('error');
|
||||
setDetail(err.message);
|
||||
trackAnalyticsEventThrottled(
|
||||
'whep_player_error',
|
||||
{
|
||||
roverId: effectiveRoverId,
|
||||
status: 'start_failed',
|
||||
detail: err.message,
|
||||
},
|
||||
{ key: `${effectiveRoverId || 'unknown'}:start_failed:${err.message}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
@@ -356,6 +380,7 @@ export default function RoverMediaPlayer({
|
||||
ensurePlayback,
|
||||
hasDedicatedAudio,
|
||||
logAudio,
|
||||
effectiveRoverId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
@@ -90,9 +91,22 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
async function handleRequest(targetRoverId) {
|
||||
if (!targetRoverId) return;
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||
trackAnalyticsEvent('rover_queue_join', {
|
||||
roverId: targetRoverId,
|
||||
alreadyAssigned: assignedRoverId === String(targetRoverId),
|
||||
});
|
||||
try {
|
||||
await requestControl(targetRoverId);
|
||||
trackAnalyticsEvent('rover_queue_join_result', {
|
||||
roverId: targetRoverId,
|
||||
status: 'accepted',
|
||||
});
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_queue_join_result', {
|
||||
roverId: targetRoverId,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
||||
@@ -107,10 +121,18 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`);
|
||||
if (!ok) return;
|
||||
setRebootPending(true);
|
||||
trackAnalyticsEvent('rover_reboot_click', { roverId: assignedRoverId, scope: 'own_rover' });
|
||||
try {
|
||||
await rebootOwnRover();
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: assignedRoverId, scope: 'own_rover', status: 'accepted' });
|
||||
alert('Reboot command sent.');
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_reboot_result', {
|
||||
roverId: assignedRoverId,
|
||||
scope: 'own_rover',
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setRebootPending(false);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
const manualTabs = [
|
||||
{ key: 'start', label: 'Start OI' },
|
||||
@@ -142,6 +143,7 @@ export default function SettingsPanel() {
|
||||
const handleTransportChange = (event) => {
|
||||
const next = event.target.value;
|
||||
savePageSettings((current) => ({ ...(current ?? {}), connectionTransport: next }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'connection_transport', value: next });
|
||||
if (!socket?.io?.opts) return;
|
||||
socket.io.opts.transports = next === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
||||
socket.disconnect();
|
||||
@@ -152,21 +154,30 @@ export default function SettingsPanel() {
|
||||
const raw = Number(event.target.value);
|
||||
const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0;
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next }));
|
||||
trackAnalyticsEventThrottled(
|
||||
'settings_change',
|
||||
{ setting: key, value: next },
|
||||
{ key: `audio:${key}`, throttleMs: 3 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const handleMainBrushDuckEnabled = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), mainBrushDuckEnabled: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'mainBrushDuckEnabled', value: checked });
|
||||
};
|
||||
|
||||
const handleSwapMobileControlColumns = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
savePageSettings((current) => ({ ...(current ?? {}), swapMobileControlColumns: checked }));
|
||||
trackAnalyticsEvent('mobile_controls_swap', { enabled: checked });
|
||||
trackAnalyticsEvent('settings_change', { setting: 'swapMobileControlColumns', value: checked });
|
||||
};
|
||||
|
||||
const handleDriveMacroBackoffEnabled = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
savePageSettings((current) => ({ ...(current ?? {}), driveMacroBackoffEnabled: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'driveMacroBackoffEnabled', value: checked });
|
||||
};
|
||||
|
||||
const handleVideoFilterChange = (event) => {
|
||||
@@ -178,6 +189,7 @@ export default function SettingsPanel() {
|
||||
...(current ?? {}),
|
||||
colorFilter: nextFilter,
|
||||
}));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'videoColorFilter', value: nextFilter });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as FaIcons from 'react-icons/fa';
|
||||
import { FaLink } from 'react-icons/fa';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { getSocialById } from '../../lib/socials.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
function sanitizeCssColor(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
@@ -38,6 +39,16 @@ export default function SocialButton({ id = null, label, url, icon, color, layou
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={text}
|
||||
onClick={() => {
|
||||
trackAnalyticsEvent('social_link_click', {
|
||||
id: id || '',
|
||||
label: text,
|
||||
layout,
|
||||
});
|
||||
if (id === 'discord') {
|
||||
trackAnalyticsEvent('discord_click', { layout });
|
||||
}
|
||||
}}
|
||||
className={`grid h-full min-h-0 w-full place-items-center rounded-md bg-slate-700 px-0.5 ${isInline ? 'py-0.5' : 'pb-3'} text-center text-sm font-medium text-white transition hover:opacity-90 ${className}`}
|
||||
style={bgColor ? { backgroundColor: bgColor } : undefined}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
const CONNECTED_FADE_DELAY_MS = 4000;
|
||||
|
||||
@@ -41,23 +42,35 @@ export default function SocketConnectionPill() {
|
||||
setLastReason('');
|
||||
show();
|
||||
scheduleFade();
|
||||
trackAnalyticsEvent('socket_connect');
|
||||
};
|
||||
|
||||
const onDisconnect = (reason) => {
|
||||
setConnected(false);
|
||||
setLastReason(typeof reason === 'string' ? reason : 'disconnected');
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_disconnect',
|
||||
{ reason: typeof reason === 'string' ? reason : 'disconnected' },
|
||||
{ key: `disconnect:${reason || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const onConnectError = (err) => {
|
||||
setConnected(false);
|
||||
setLastReason(err?.message || 'connect error');
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_connect_error',
|
||||
{ message: err?.message || 'connect error' },
|
||||
{ key: `connect_error:${err?.message || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const onReconnectAttempt = () => {
|
||||
setConnected(false);
|
||||
show();
|
||||
trackAnalyticsEventThrottled('socket_reconnect_attempt', {}, { key: 'socket_reconnect_attempt', throttleMs: 30 * 1000 });
|
||||
};
|
||||
|
||||
socket.on('connect', onConnect);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||
import StatusIndicator from './StatusIndicator.jsx';
|
||||
import KeyPill from './KeyPill.jsx';
|
||||
import CardFrame from '../../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../../analytics/index.js';
|
||||
|
||||
export default function VipAudioUploadCard({
|
||||
ownRoverId = '',
|
||||
@@ -87,8 +88,9 @@ export default function VipAudioUploadCard({
|
||||
(nextMode) => {
|
||||
const mode = nextMode === 'clip' ? 'clip' : 'live';
|
||||
saveVipAudio((current) => ({ ...(current || {}), pttMode: mode }));
|
||||
trackAnalyticsEvent('vip_ptt_mode_change', { mode, roverId });
|
||||
},
|
||||
[saveVipAudio],
|
||||
[roverId, saveVipAudio],
|
||||
);
|
||||
|
||||
const handleUploadPlay = async () => {
|
||||
@@ -107,6 +109,11 @@ export default function VipAudioUploadCard({
|
||||
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
trackAnalyticsEvent('vip_upload_play', {
|
||||
roverId,
|
||||
size: selectedUpload.size,
|
||||
mime: selectedUpload.type || '',
|
||||
});
|
||||
try {
|
||||
const buffer = await selectedUpload.arrayBuffer();
|
||||
const dataBase64 = bytesToBase64(new Uint8Array(buffer));
|
||||
@@ -117,7 +124,9 @@ export default function VipAudioUploadCard({
|
||||
dataBase64,
|
||||
});
|
||||
setMessage('Upload playback started.');
|
||||
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to play upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -132,10 +141,13 @@ export default function VipAudioUploadCard({
|
||||
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
trackAnalyticsEvent('vip_upload_stop', { roverId });
|
||||
try {
|
||||
await stopUploadedAudio?.(roverId);
|
||||
setMessage('Upload playback stopped.');
|
||||
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to stop upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -366,20 +378,24 @@ export default function VipAudioUploadCard({
|
||||
|
||||
if (!send) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_record_cancel', { roverId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roverId) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_no_rover' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chunks.length) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
setClipState('sending');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'started' });
|
||||
try {
|
||||
const merged = mergeFloatChunks(chunks);
|
||||
const wavBytes = encodeWavMono16(merged, clipSampleRateRef.current || TARGET_SAMPLE_RATE);
|
||||
@@ -391,8 +407,10 @@ export default function VipAudioUploadCard({
|
||||
dataBase64,
|
||||
});
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
setClipState('error');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to send PTT clip.');
|
||||
}
|
||||
},
|
||||
@@ -408,6 +426,7 @@ export default function VipAudioUploadCard({
|
||||
clipChunksRef.current = [];
|
||||
clipRecordingRef.current = true;
|
||||
setClipState('recording');
|
||||
trackAnalyticsEvent('vip_clip_record_start', { roverId });
|
||||
}, [ensureClipPipeline, roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -434,11 +453,17 @@ export default function VipAudioUploadCard({
|
||||
await startWhipMic(roverId);
|
||||
if (!cancelled) {
|
||||
setMicState('live');
|
||||
trackAnalyticsEvent('vip_live_mic_start', { roverId });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setMicState('error');
|
||||
setMessage(err?.message || 'Failed to start mic forwarding.');
|
||||
trackAnalyticsEventThrottled(
|
||||
'vip_live_mic_error',
|
||||
{ roverId, reason: err?.message || 'unknown' },
|
||||
{ key: `${roverId}:${err?.message || 'unknown'}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
await stopMicCapture(roverId);
|
||||
}
|
||||
@@ -623,9 +648,11 @@ export default function VipAudioUploadCard({
|
||||
type="checkbox"
|
||||
checked={openMicEnabled}
|
||||
disabled={!roverId || clipMode}
|
||||
onChange={(event) =>
|
||||
saveVipAudio((current) => ({ ...(current || {}), openMicEnabled: Boolean(event.target.checked) }))
|
||||
}
|
||||
onChange={(event) => {
|
||||
const enabled = Boolean(event.target.checked);
|
||||
saveVipAudio((current) => ({ ...(current || {}), openMicEnabled: enabled }));
|
||||
trackAnalyticsEvent('vip_open_mic_change', { roverId, enabled });
|
||||
}}
|
||||
/>
|
||||
<span>{clipMode ? 'Open mic (live mode only)' : 'Open mic'}</span>
|
||||
</label>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass } from './constants.js';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
export default function VipPrivateRoverAccessCard({
|
||||
requestableRovers = [],
|
||||
@@ -29,14 +30,24 @@ export default function VipPrivateRoverAccessCard({
|
||||
if (!roverId) return;
|
||||
setPendingByRover((prev) => ({ ...prev, [roverId]: true }));
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_private_rover_request', { roverId, status: 'started' });
|
||||
try {
|
||||
const response = await requestPrivateRoverAccess?.(roverId);
|
||||
trackAnalyticsEvent('vip_private_rover_request', {
|
||||
roverId,
|
||||
status: response?.existing ? 'existing' : 'accepted',
|
||||
});
|
||||
if (response?.existing) {
|
||||
onMessage?.('You already have a pending request for that rover.');
|
||||
} else {
|
||||
onMessage?.('Private rover access request sent to lockdown admins.');
|
||||
}
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_private_rover_request', {
|
||||
roverId,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
onMessage?.(err.message || 'Failed to send request.');
|
||||
} finally {
|
||||
setPendingByRover((prev) => ({ ...prev, [roverId]: false }));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
export default function VipVerificationCard({
|
||||
pendingRequestId,
|
||||
@@ -25,6 +26,7 @@ export default function VipVerificationCard({
|
||||
setRequestKeyInput(currentStoredKey);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_verify_flow_start', { hasNickname: Boolean(nickname) });
|
||||
};
|
||||
|
||||
const cancelRequestFlow = () => {
|
||||
@@ -44,6 +46,10 @@ export default function VipVerificationCard({
|
||||
}
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_verify_attempt', {
|
||||
hasNickname: Boolean(nickname),
|
||||
hasIdentityKey: Boolean(requestKeyInput),
|
||||
});
|
||||
try {
|
||||
const applied = await applyIdentityKey(requestKeyInput);
|
||||
await requestVerification?.();
|
||||
@@ -51,7 +57,9 @@ export default function VipVerificationCard({
|
||||
setRequestFlowStep(0);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('Verification request sent to lockdown admins.');
|
||||
trackAnalyticsEvent('vip_verify_result', { status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_verify_result', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
onMessage?.(err.message || 'Failed to submit request.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const SOURCE = 'gamepad';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
@@ -58,6 +59,7 @@ export default function GamepadInputManager() {
|
||||
} = useControlActions();
|
||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||
'gamepad',
|
||||
@@ -166,6 +168,7 @@ export default function GamepadInputManager() {
|
||||
dockAssist,
|
||||
gamepadSettings,
|
||||
registerInputState,
|
||||
roverId,
|
||||
runMacro,
|
||||
saveGamepadSettings,
|
||||
setAuxMotors,
|
||||
@@ -268,6 +271,7 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||
trackAnalyticsEvent('drive_start', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.dockAssist.exitAssist();
|
||||
latest.setMode('drive');
|
||||
latest.runMacro('drive-sequence');
|
||||
@@ -276,12 +280,14 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.dockAssist.toggleAssist();
|
||||
} else if (!outputs.buttons.dockMacro) {
|
||||
handleButtonEdge('dockMacro', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.nightVisionToggle && handleButtonEdge('nightVisionToggle', true)) {
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.toggleNightVision();
|
||||
} else if (!outputs.buttons.nightVisionToggle) {
|
||||
handleButtonEdge('nightVisionToggle', false);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getKeyboardDriveSpeedOptions,
|
||||
resolveKeyboardSpeeds,
|
||||
} from './driveIntent.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const SOURCE = 'keyboard';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
@@ -345,6 +346,7 @@ export default function KeyboardInputManager() {
|
||||
keymap,
|
||||
nudgeServo,
|
||||
registerInputState,
|
||||
roverId,
|
||||
runMacro,
|
||||
saveVideoSettings,
|
||||
sendSong,
|
||||
@@ -388,12 +390,15 @@ export default function KeyboardInputManager() {
|
||||
|
||||
if (newlyPressed.length > 0) {
|
||||
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
||||
trackAnalyticsEvent('drive_start', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.dockAssist.exitAssist();
|
||||
latest.setMode('drive');
|
||||
latest.runMacro('drive-sequence');
|
||||
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
||||
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.dockAssist.toggleAssist();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.nightVisionToggle?.has(token))) {
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.toggleNightVision();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
|
||||
cycleVideoFilter();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Coordinates client-side video stream request intents and authorization timing. Scope: Provides reusable request helpers for rover and room video consumers.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { trackAnalyticsEventThrottled } from '../analytics/index.js';
|
||||
|
||||
function normalizeEntry(entry) {
|
||||
if (!entry) return null;
|
||||
@@ -90,6 +91,23 @@ export function useVideoRequests(sourceList = [], options = {}) {
|
||||
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
|
||||
socket.emit('video:request', payload, (resp = {}) => {
|
||||
if (cancelled) return;
|
||||
if (resp?.error) {
|
||||
/*
|
||||
Video authorization/session failures are operational signals, but
|
||||
this hook can request several feeds at once and retry after socket
|
||||
reconnects. Throttle by feed key so one broken camera does not flood
|
||||
Umami while still showing which rover or room camera is affected.
|
||||
*/
|
||||
trackAnalyticsEventThrottled(
|
||||
'video_request_failed',
|
||||
{
|
||||
type: entry.type,
|
||||
sourceId: entry.id,
|
||||
reason: resp.error,
|
||||
},
|
||||
{ key: `${entry.type}:${entry.id}:${resp.error}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
setSources((prev) => ({ ...prev, [entry.key]: resp }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import useScannerSpeech from './useScannerSpeech.js';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
const EMPTY_SCANNER_STATE = {
|
||||
beepAllowed: false,
|
||||
@@ -70,6 +71,7 @@ export default function ScannerContent() {
|
||||
const socket = useSocket();
|
||||
const inputRef = useRef(null);
|
||||
const flashTimerRef = useRef(null);
|
||||
const scannerReadyTrackedRef = useRef(false);
|
||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||
const [scannerConnectionState, setScannerConnectionState] = useState({
|
||||
connected: Boolean(socket.connected),
|
||||
@@ -108,6 +110,12 @@ export default function ScannerContent() {
|
||||
function handleScannerState(nextState = {}) {
|
||||
if (disposed) return;
|
||||
lastReceivedAt = Date.now();
|
||||
if (!scannerReadyTrackedRef.current) {
|
||||
scannerReadyTrackedRef.current = true;
|
||||
trackAnalyticsEvent('scanner_route_ready', {
|
||||
beepAllowed: Boolean(nextState?.beepAllowed),
|
||||
});
|
||||
}
|
||||
setScannerState({
|
||||
...EMPTY_SCANNER_STATE,
|
||||
...(nextState && typeof nextState === 'object' ? nextState : {}),
|
||||
@@ -121,6 +129,9 @@ export default function ScannerContent() {
|
||||
|
||||
function handleScanAudio(payload = null) {
|
||||
setScanAudioEvent(payload && typeof payload === 'object' ? payload : null);
|
||||
trackAnalyticsEvent('barcode_scan_audio_play', {
|
||||
hasPayload: Boolean(payload && typeof payload === 'object'),
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeToScannerState() {
|
||||
@@ -166,6 +177,13 @@ export default function ScannerContent() {
|
||||
|
||||
staleTimer = window.setInterval(() => {
|
||||
const isStale = !lastReceivedAt || Date.now() - lastReceivedAt > SCANNER_STATE_STALE_MS;
|
||||
if (isStale && lastReceivedAt) {
|
||||
trackAnalyticsEventThrottled(
|
||||
'scanner_state_stale',
|
||||
{ connected: Boolean(socket.connected) },
|
||||
{ key: `scanner_state_stale:${Boolean(socket.connected)}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
@@ -214,6 +232,10 @@ export default function ScannerContent() {
|
||||
// The page intentionally sends only raw scanner text. The server reloads
|
||||
// the barcode registry, resolves labels/types, applies access policy, and
|
||||
// broadcasts the display state back to every scanner page.
|
||||
trackAnalyticsEvent('barcode_scan_submit', {
|
||||
length: code.length,
|
||||
beepAllowed: scannerState.beepAllowed,
|
||||
});
|
||||
socket.emit('barcode:scan', { code }, () => {});
|
||||
inputRef.current.value = '';
|
||||
focusInput();
|
||||
|
||||
Reference in New Issue
Block a user