mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
MOREE!!!!
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CL-6TNyJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-DZGFVNgK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C6VExJdW.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C6VExJdW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -343,6 +343,11 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
trackAnalyticsEvent('fullscreen_prompt_show', { layout, mode: fullscreenMode });
|
trackAnalyticsEvent('fullscreen_prompt_show', { layout, mode: fullscreenMode });
|
||||||
}, [fullscreenMode, fullscreenVisible, layout]);
|
}, [fullscreenMode, fullscreenVisible, layout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!quickstartVisible) return;
|
||||||
|
trackAnalyticsEvent('quickstart_open', { layout });
|
||||||
|
}, [layout, quickstartVisible]);
|
||||||
|
|
||||||
const openHelp = useCallback(() => {
|
const openHelp = useCallback(() => {
|
||||||
setHelpVisible(true);
|
setHelpVisible(true);
|
||||||
trackAnalyticsEvent('help_open', { layout, source: 'panel' });
|
trackAnalyticsEvent('help_open', { layout, source: 'panel' });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
const MAX_EVENT_NAME_LENGTH = 80;
|
const MAX_EVENT_NAME_LENGTH = 80;
|
||||||
const MAX_PROPERTY_KEY_LENGTH = 80;
|
const MAX_PROPERTY_KEY_LENGTH = 80;
|
||||||
const MAX_STRING_VALUE_LENGTH = 240;
|
const MAX_STRING_VALUE_LENGTH = 240;
|
||||||
|
const throttledEvents = new Map();
|
||||||
|
|
||||||
function getAdapter() {
|
function getAdapter() {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
@@ -90,3 +91,21 @@ export function identifyAnalyticsSession(payload = {}) {
|
|||||||
console.warn('Analytics identify failed', error);
|
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 ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
||||||
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
const MODES = [
|
const MODES = [
|
||||||
{ key: 'open', label: 'Open' },
|
{ key: 'open', label: 'Open' },
|
||||||
@@ -77,17 +78,23 @@ export default function AdminPanelContent() {
|
|||||||
|
|
||||||
const handleModeChange = async (event) => {
|
const handleModeChange = async (event) => {
|
||||||
const mode = event.target.value;
|
const mode = event.target.value;
|
||||||
|
trackAnalyticsEvent('admin_mode_change', { mode, status: 'started' });
|
||||||
try {
|
try {
|
||||||
await setMode(mode);
|
await setMode(mode);
|
||||||
|
trackAnalyticsEvent('admin_mode_change', { mode, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('admin_mode_change', { mode, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForceControl = async (roverId) => {
|
const handleForceControl = async (roverId) => {
|
||||||
|
trackAnalyticsEvent('admin_force_control', { roverId, status: 'started' });
|
||||||
try {
|
try {
|
||||||
await requestControl(roverId, { force: true });
|
await requestControl(roverId, { force: true });
|
||||||
|
trackAnalyticsEvent('admin_force_control', { roverId, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('admin_force_control', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -97,9 +104,12 @@ export default function AdminPanelContent() {
|
|||||||
const ok = window.confirm(`Reboot rover "${rover.name || rover.id}" now?`);
|
const ok = window.confirm(`Reboot rover "${rover.name || rover.id}" now?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setRebootStates((prev) => ({ ...prev, [rover.id]: true }));
|
setRebootStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||||
|
trackAnalyticsEvent('rover_reboot_click', { roverId: rover.id, scope: 'admin' });
|
||||||
try {
|
try {
|
||||||
await rebootRover(rover.id);
|
await rebootRover(rover.id);
|
||||||
|
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setRebootStates((prev) => ({ ...prev, [rover.id]: false }));
|
setRebootStates((prev) => ({ ...prev, [rover.id]: false }));
|
||||||
@@ -113,6 +123,7 @@ export default function AdminPanelContent() {
|
|||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: true }));
|
setUpdateStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||||
|
trackAnalyticsEvent('rover_update_click', { roverId: rover.id });
|
||||||
try {
|
try {
|
||||||
// The rover acknowledges once the privileged self-update helper has been
|
// The rover acknowledges once the privileged self-update helper has been
|
||||||
// launched, not when the full install finishes. That is deliberate: a
|
// 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
|
// the same websocket would make the button look failed even when the
|
||||||
// update is doing exactly what it should.
|
// update is doing exactly what it should.
|
||||||
await updateRover(rover.id);
|
await updateRover(rover.id);
|
||||||
|
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: false }));
|
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.');
|
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setServerRebooting(true);
|
setServerRebooting(true);
|
||||||
|
trackAnalyticsEvent('server_reboot_click', { status: 'started' });
|
||||||
try {
|
try {
|
||||||
await rebootServer();
|
await rebootServer();
|
||||||
|
trackAnalyticsEvent('server_reboot_click', { status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('server_reboot_click', { status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
setServerRebooting(false);
|
setServerRebooting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Barcode Games Panel
|
// Barcode Games Panel
|
||||||
// Purpose: Shows barcode game selection, current game status, and player points in the Activities tab.
|
// 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.
|
// 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 useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
@@ -192,6 +192,8 @@ function Leaderboard({ players, ownPlayer }) {
|
|||||||
export default function BarcodeGamesPanel() {
|
export default function BarcodeGamesPanel() {
|
||||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||||
const [pendingGameId, setPendingGameId] = useState(null);
|
const [pendingGameId, setPendingGameId] = useState(null);
|
||||||
|
const activeSignatureRef = useRef('');
|
||||||
|
const participantCountRef = useRef(0);
|
||||||
const activeGame = state.activeGame;
|
const activeGame = state.activeGame;
|
||||||
const display = activeGame?.display || {};
|
const display = activeGame?.display || {};
|
||||||
// Voting should stop once the shared game system has moved past selection.
|
// 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]);
|
}, [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) => {
|
const handleVote = async (gameId) => {
|
||||||
setPendingGameId(gameId);
|
setPendingGameId(gameId);
|
||||||
trackAnalyticsEvent('barcode_game_vote', {
|
trackAnalyticsEvent('barcode_game_vote', {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetry
|
|||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
function StatusRow({ value, tone = 'neutral' }) {
|
function StatusRow({ value, tone = 'neutral' }) {
|
||||||
const toneClasses =
|
const toneClasses =
|
||||||
@@ -126,11 +127,14 @@ export default function DriveDockAction({
|
|||||||
const handleReturnToDrive = async () => {
|
const handleReturnToDrive = async () => {
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
setPending('drive');
|
setPending('drive');
|
||||||
|
trackAnalyticsEvent('drive_return', { roverId, source: 'drive_dock_action' });
|
||||||
try {
|
try {
|
||||||
dockAssist.exitAssist();
|
dockAssist.exitAssist();
|
||||||
actions.setMode('drive');
|
actions.setMode('drive');
|
||||||
await actions.runMacro('drive-sequence');
|
await actions.runMacro('drive-sequence');
|
||||||
|
trackAnalyticsEvent('drive_return_result', { roverId, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('drive_return_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
@@ -142,11 +146,14 @@ export default function DriveDockAction({
|
|||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setPending('drive');
|
setPending('drive');
|
||||||
|
trackAnalyticsEvent('drive_start', { roverId, source: 'drive_dock_action' });
|
||||||
try {
|
try {
|
||||||
dockAssist.exitAssist();
|
dockAssist.exitAssist();
|
||||||
actions.setMode('drive');
|
actions.setMode('drive');
|
||||||
await actions.runMacro('drive-sequence');
|
await actions.runMacro('drive-sequence');
|
||||||
|
trackAnalyticsEvent('drive_start_result', { roverId, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('drive_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
@@ -156,9 +163,12 @@ export default function DriveDockAction({
|
|||||||
const handleConfirmDock = async () => {
|
const handleConfirmDock = async () => {
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
setPending('dock');
|
setPending('dock');
|
||||||
|
trackAnalyticsEvent('dock_assist_start', { roverId });
|
||||||
try {
|
try {
|
||||||
dockAssist.enterAssist();
|
dockAssist.enterAssist();
|
||||||
|
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
@@ -171,10 +181,12 @@ export default function DriveDockAction({
|
|||||||
if (dockDisabled) return;
|
if (dockDisabled) return;
|
||||||
if (manualAssistActive) {
|
if (manualAssistActive) {
|
||||||
dockAssist.exitAssist();
|
dockAssist.exitAssist();
|
||||||
|
trackAnalyticsEvent('dock_assist_exit', { roverId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setConfirmOpen(true);
|
setConfirmOpen(true);
|
||||||
|
trackAnalyticsEvent('dock_assist_open', { roverId });
|
||||||
};
|
};
|
||||||
|
|
||||||
// The drive/dock card appears inside the mobile controls and can be held or
|
// 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 { AUX_ZERO } from './constants.js';
|
||||||
import VacuumControls from './VacuumControls.jsx';
|
import VacuumControls from './VacuumControls.jsx';
|
||||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
function AuxColumnContent() {
|
function AuxColumnContent() {
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
@@ -39,11 +40,17 @@ function AuxColumnContent() {
|
|||||||
const handleNightVisionToggle = useCallback(
|
const handleNightVisionToggle = useCallback(
|
||||||
(nextOn) => {
|
(nextOn) => {
|
||||||
if (!nightVisionAvailable) return;
|
if (!nightVisionAvailable) return;
|
||||||
|
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||||
setNightVision(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(
|
const handleAuxPress = useCallback(
|
||||||
(id, values) => {
|
(id, values) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
@@ -99,7 +106,7 @@ function AuxColumnContent() {
|
|||||||
{hornAvailable ? (
|
{hornAvailable ? (
|
||||||
<HornControl
|
<HornControl
|
||||||
disabled={disabled || hornBlocked}
|
disabled={disabled || hornBlocked}
|
||||||
onStart={startHorn}
|
onStart={handleHornStart}
|
||||||
onStop={stopHorn}
|
onStop={stopHorn}
|
||||||
active={horn?.active}
|
active={horn?.active}
|
||||||
heat={horn?.heat}
|
heat={horn?.heat}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
|||||||
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||||
import CardFrame from '../CardFrame/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 { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
||||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
@@ -85,6 +85,20 @@ function DriveDockPanel() {
|
|||||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
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 (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
@@ -100,15 +114,15 @@ function DriveDockPanel() {
|
|||||||
<NightVisionControl
|
<NightVisionControl
|
||||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||||
disabled={!roverId}
|
disabled={!roverId}
|
||||||
onToggle={setNightVision}
|
onToggle={trackedControls.setNightVision}
|
||||||
keyLabel={nightVisionLabel}
|
keyLabel={nightVisionLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hornAvailable && (
|
{hornAvailable && (
|
||||||
<HornControl
|
<HornControl
|
||||||
disabled={!roverId || hornBlocked}
|
disabled={!roverId || hornBlocked}
|
||||||
onStart={startHorn}
|
onStart={trackedControls.startHorn}
|
||||||
onStop={stopHorn}
|
onStop={trackedControls.stopHorn}
|
||||||
keyLabel={hornLabel}
|
keyLabel={hornLabel}
|
||||||
active={horn?.active}
|
active={horn?.active}
|
||||||
heat={horn?.heat}
|
heat={horn?.heat}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
|||||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
|
import { trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||||
import {
|
import {
|
||||||
RESTART_DELAY_MS,
|
RESTART_DELAY_MS,
|
||||||
UNMUTE_RETRY_MS,
|
UNMUTE_RETRY_MS,
|
||||||
@@ -323,6 +324,20 @@ export default function RoverMediaPlayer({
|
|||||||
ensurePlayback();
|
ensurePlayback();
|
||||||
}
|
}
|
||||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
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();
|
scheduleRestart();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -339,6 +354,15 @@ export default function RoverMediaPlayer({
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setDetail(err.message);
|
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();
|
scheduleRestart();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -356,6 +380,7 @@ export default function RoverMediaPlayer({
|
|||||||
ensurePlayback,
|
ensurePlayback,
|
||||||
hasDedicatedAudio,
|
hasDedicatedAudio,
|
||||||
logAudio,
|
logAudio,
|
||||||
|
effectiveRoverId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
|||||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import RoverLabel from '../RoverLabel/index.jsx';
|
import RoverLabel from '../RoverLabel/index.jsx';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
function classNames(...values) {
|
function classNames(...values) {
|
||||||
return values.filter(Boolean).join(' ');
|
return values.filter(Boolean).join(' ');
|
||||||
@@ -90,9 +91,22 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
async function handleRequest(targetRoverId) {
|
async function handleRequest(targetRoverId) {
|
||||||
if (!targetRoverId) return;
|
if (!targetRoverId) return;
|
||||||
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||||
|
trackAnalyticsEvent('rover_queue_join', {
|
||||||
|
roverId: targetRoverId,
|
||||||
|
alreadyAssigned: assignedRoverId === String(targetRoverId),
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await requestControl(targetRoverId);
|
await requestControl(targetRoverId);
|
||||||
|
trackAnalyticsEvent('rover_queue_join_result', {
|
||||||
|
roverId: targetRoverId,
|
||||||
|
status: 'accepted',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('rover_queue_join_result', {
|
||||||
|
roverId: targetRoverId,
|
||||||
|
status: 'failed',
|
||||||
|
reason: err?.message || 'unknown',
|
||||||
|
});
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
||||||
@@ -107,10 +121,18 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`);
|
const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setRebootPending(true);
|
setRebootPending(true);
|
||||||
|
trackAnalyticsEvent('rover_reboot_click', { roverId: assignedRoverId, scope: 'own_rover' });
|
||||||
try {
|
try {
|
||||||
await rebootOwnRover();
|
await rebootOwnRover();
|
||||||
|
trackAnalyticsEvent('rover_reboot_result', { roverId: assignedRoverId, scope: 'own_rover', status: 'accepted' });
|
||||||
alert('Reboot command sent.');
|
alert('Reboot command sent.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('rover_reboot_result', {
|
||||||
|
roverId: assignedRoverId,
|
||||||
|
scope: 'own_rover',
|
||||||
|
status: 'failed',
|
||||||
|
reason: err?.message || 'unknown',
|
||||||
|
});
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setRebootPending(false);
|
setRebootPending(false);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
|||||||
import { useSocket } from '../../context/SocketContext.jsx';
|
import { useSocket } from '../../context/SocketContext.jsx';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
|
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||||
|
|
||||||
const manualTabs = [
|
const manualTabs = [
|
||||||
{ key: 'start', label: 'Start OI' },
|
{ key: 'start', label: 'Start OI' },
|
||||||
@@ -142,6 +143,7 @@ export default function SettingsPanel() {
|
|||||||
const handleTransportChange = (event) => {
|
const handleTransportChange = (event) => {
|
||||||
const next = event.target.value;
|
const next = event.target.value;
|
||||||
savePageSettings((current) => ({ ...(current ?? {}), connectionTransport: next }));
|
savePageSettings((current) => ({ ...(current ?? {}), connectionTransport: next }));
|
||||||
|
trackAnalyticsEvent('settings_change', { setting: 'connection_transport', value: next });
|
||||||
if (!socket?.io?.opts) return;
|
if (!socket?.io?.opts) return;
|
||||||
socket.io.opts.transports = next === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
socket.io.opts.transports = next === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
@@ -152,21 +154,30 @@ export default function SettingsPanel() {
|
|||||||
const raw = Number(event.target.value);
|
const raw = Number(event.target.value);
|
||||||
const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0;
|
const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0;
|
||||||
saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next }));
|
saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next }));
|
||||||
|
trackAnalyticsEventThrottled(
|
||||||
|
'settings_change',
|
||||||
|
{ setting: key, value: next },
|
||||||
|
{ key: `audio:${key}`, throttleMs: 3 * 1000 },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMainBrushDuckEnabled = (event) => {
|
const handleMainBrushDuckEnabled = (event) => {
|
||||||
const checked = Boolean(event.target.checked);
|
const checked = Boolean(event.target.checked);
|
||||||
saveAudioSettings((current) => ({ ...(current ?? {}), mainBrushDuckEnabled: checked }));
|
saveAudioSettings((current) => ({ ...(current ?? {}), mainBrushDuckEnabled: checked }));
|
||||||
|
trackAnalyticsEvent('settings_change', { setting: 'mainBrushDuckEnabled', value: checked });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwapMobileControlColumns = (event) => {
|
const handleSwapMobileControlColumns = (event) => {
|
||||||
const checked = Boolean(event.target.checked);
|
const checked = Boolean(event.target.checked);
|
||||||
savePageSettings((current) => ({ ...(current ?? {}), swapMobileControlColumns: checked }));
|
savePageSettings((current) => ({ ...(current ?? {}), swapMobileControlColumns: checked }));
|
||||||
|
trackAnalyticsEvent('mobile_controls_swap', { enabled: checked });
|
||||||
|
trackAnalyticsEvent('settings_change', { setting: 'swapMobileControlColumns', value: checked });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDriveMacroBackoffEnabled = (event) => {
|
const handleDriveMacroBackoffEnabled = (event) => {
|
||||||
const checked = Boolean(event.target.checked);
|
const checked = Boolean(event.target.checked);
|
||||||
savePageSettings((current) => ({ ...(current ?? {}), driveMacroBackoffEnabled: checked }));
|
savePageSettings((current) => ({ ...(current ?? {}), driveMacroBackoffEnabled: checked }));
|
||||||
|
trackAnalyticsEvent('settings_change', { setting: 'driveMacroBackoffEnabled', value: checked });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoFilterChange = (event) => {
|
const handleVideoFilterChange = (event) => {
|
||||||
@@ -178,6 +189,7 @@ export default function SettingsPanel() {
|
|||||||
...(current ?? {}),
|
...(current ?? {}),
|
||||||
colorFilter: nextFilter,
|
colorFilter: nextFilter,
|
||||||
}));
|
}));
|
||||||
|
trackAnalyticsEvent('settings_change', { setting: 'videoColorFilter', value: nextFilter });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as FaIcons from 'react-icons/fa';
|
|||||||
import { FaLink } from 'react-icons/fa';
|
import { FaLink } from 'react-icons/fa';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { getSocialById } from '../../lib/socials.js';
|
import { getSocialById } from '../../lib/socials.js';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
function sanitizeCssColor(value) {
|
function sanitizeCssColor(value) {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
@@ -38,6 +39,16 @@ export default function SocialButton({ id = null, label, url, icon, color, layou
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={text}
|
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}`}
|
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}
|
style={bgColor ? { backgroundColor: bgColor } : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSocket } from '../../context/SocketContext.jsx';
|
import { useSocket } from '../../context/SocketContext.jsx';
|
||||||
|
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||||
|
|
||||||
const CONNECTED_FADE_DELAY_MS = 4000;
|
const CONNECTED_FADE_DELAY_MS = 4000;
|
||||||
|
|
||||||
@@ -41,23 +42,35 @@ export default function SocketConnectionPill() {
|
|||||||
setLastReason('');
|
setLastReason('');
|
||||||
show();
|
show();
|
||||||
scheduleFade();
|
scheduleFade();
|
||||||
|
trackAnalyticsEvent('socket_connect');
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDisconnect = (reason) => {
|
const onDisconnect = (reason) => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setLastReason(typeof reason === 'string' ? reason : 'disconnected');
|
setLastReason(typeof reason === 'string' ? reason : 'disconnected');
|
||||||
show();
|
show();
|
||||||
|
trackAnalyticsEventThrottled(
|
||||||
|
'socket_disconnect',
|
||||||
|
{ reason: typeof reason === 'string' ? reason : 'disconnected' },
|
||||||
|
{ key: `disconnect:${reason || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onConnectError = (err) => {
|
const onConnectError = (err) => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setLastReason(err?.message || 'connect error');
|
setLastReason(err?.message || 'connect error');
|
||||||
show();
|
show();
|
||||||
|
trackAnalyticsEventThrottled(
|
||||||
|
'socket_connect_error',
|
||||||
|
{ message: err?.message || 'connect error' },
|
||||||
|
{ key: `connect_error:${err?.message || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onReconnectAttempt = () => {
|
const onReconnectAttempt = () => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
show();
|
show();
|
||||||
|
trackAnalyticsEventThrottled('socket_reconnect_attempt', {}, { key: 'socket_reconnect_attempt', throttleMs: 30 * 1000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on('connect', onConnect);
|
socket.on('connect', onConnect);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
|||||||
import StatusIndicator from './StatusIndicator.jsx';
|
import StatusIndicator from './StatusIndicator.jsx';
|
||||||
import KeyPill from './KeyPill.jsx';
|
import KeyPill from './KeyPill.jsx';
|
||||||
import CardFrame from '../../CardFrame/index.jsx';
|
import CardFrame from '../../CardFrame/index.jsx';
|
||||||
|
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../../analytics/index.js';
|
||||||
|
|
||||||
export default function VipAudioUploadCard({
|
export default function VipAudioUploadCard({
|
||||||
ownRoverId = '',
|
ownRoverId = '',
|
||||||
@@ -87,8 +88,9 @@ export default function VipAudioUploadCard({
|
|||||||
(nextMode) => {
|
(nextMode) => {
|
||||||
const mode = nextMode === 'clip' ? 'clip' : 'live';
|
const mode = nextMode === 'clip' ? 'clip' : 'live';
|
||||||
saveVipAudio((current) => ({ ...(current || {}), pttMode: mode }));
|
saveVipAudio((current) => ({ ...(current || {}), pttMode: mode }));
|
||||||
|
trackAnalyticsEvent('vip_ptt_mode_change', { mode, roverId });
|
||||||
},
|
},
|
||||||
[saveVipAudio],
|
[roverId, saveVipAudio],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUploadPlay = async () => {
|
const handleUploadPlay = async () => {
|
||||||
@@ -107,6 +109,11 @@ export default function VipAudioUploadCard({
|
|||||||
|
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
trackAnalyticsEvent('vip_upload_play', {
|
||||||
|
roverId,
|
||||||
|
size: selectedUpload.size,
|
||||||
|
mime: selectedUpload.type || '',
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const buffer = await selectedUpload.arrayBuffer();
|
const buffer = await selectedUpload.arrayBuffer();
|
||||||
const dataBase64 = bytesToBase64(new Uint8Array(buffer));
|
const dataBase64 = bytesToBase64(new Uint8Array(buffer));
|
||||||
@@ -117,7 +124,9 @@ export default function VipAudioUploadCard({
|
|||||||
dataBase64,
|
dataBase64,
|
||||||
});
|
});
|
||||||
setMessage('Upload playback started.');
|
setMessage('Upload playback started.');
|
||||||
|
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
setMessage(err?.message || 'Failed to play upload.');
|
setMessage(err?.message || 'Failed to play upload.');
|
||||||
} finally {
|
} finally {
|
||||||
setWorking(false);
|
setWorking(false);
|
||||||
@@ -132,10 +141,13 @@ export default function VipAudioUploadCard({
|
|||||||
|
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
trackAnalyticsEvent('vip_upload_stop', { roverId });
|
||||||
try {
|
try {
|
||||||
await stopUploadedAudio?.(roverId);
|
await stopUploadedAudio?.(roverId);
|
||||||
setMessage('Upload playback stopped.');
|
setMessage('Upload playback stopped.');
|
||||||
|
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
setMessage(err?.message || 'Failed to stop upload.');
|
setMessage(err?.message || 'Failed to stop upload.');
|
||||||
} finally {
|
} finally {
|
||||||
setWorking(false);
|
setWorking(false);
|
||||||
@@ -366,20 +378,24 @@ export default function VipAudioUploadCard({
|
|||||||
|
|
||||||
if (!send) {
|
if (!send) {
|
||||||
setClipState('idle');
|
setClipState('idle');
|
||||||
|
trackAnalyticsEvent('vip_clip_record_cancel', { roverId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
setClipState('idle');
|
setClipState('idle');
|
||||||
|
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_no_rover' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chunks.length) {
|
if (!chunks.length) {
|
||||||
setClipState('idle');
|
setClipState('idle');
|
||||||
|
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setClipState('sending');
|
setClipState('sending');
|
||||||
|
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'started' });
|
||||||
try {
|
try {
|
||||||
const merged = mergeFloatChunks(chunks);
|
const merged = mergeFloatChunks(chunks);
|
||||||
const wavBytes = encodeWavMono16(merged, clipSampleRateRef.current || TARGET_SAMPLE_RATE);
|
const wavBytes = encodeWavMono16(merged, clipSampleRateRef.current || TARGET_SAMPLE_RATE);
|
||||||
@@ -391,8 +407,10 @@ export default function VipAudioUploadCard({
|
|||||||
dataBase64,
|
dataBase64,
|
||||||
});
|
});
|
||||||
setClipState('idle');
|
setClipState('idle');
|
||||||
|
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setClipState('error');
|
setClipState('error');
|
||||||
|
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||||
setMessage(err?.message || 'Failed to send PTT clip.');
|
setMessage(err?.message || 'Failed to send PTT clip.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -408,6 +426,7 @@ export default function VipAudioUploadCard({
|
|||||||
clipChunksRef.current = [];
|
clipChunksRef.current = [];
|
||||||
clipRecordingRef.current = true;
|
clipRecordingRef.current = true;
|
||||||
setClipState('recording');
|
setClipState('recording');
|
||||||
|
trackAnalyticsEvent('vip_clip_record_start', { roverId });
|
||||||
}, [ensureClipPipeline, roverId]);
|
}, [ensureClipPipeline, roverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -434,11 +453,17 @@ export default function VipAudioUploadCard({
|
|||||||
await startWhipMic(roverId);
|
await startWhipMic(roverId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMicState('live');
|
setMicState('live');
|
||||||
|
trackAnalyticsEvent('vip_live_mic_start', { roverId });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMicState('error');
|
setMicState('error');
|
||||||
setMessage(err?.message || 'Failed to start mic forwarding.');
|
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);
|
await stopMicCapture(roverId);
|
||||||
}
|
}
|
||||||
@@ -623,9 +648,11 @@ export default function VipAudioUploadCard({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={openMicEnabled}
|
checked={openMicEnabled}
|
||||||
disabled={!roverId || clipMode}
|
disabled={!roverId || clipMode}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
saveVipAudio((current) => ({ ...(current || {}), openMicEnabled: Boolean(event.target.checked) }))
|
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>
|
<span>{clipMode ? 'Open mic (live mode only)' : 'Open mic'}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { flowWrapClass, innerFlowClass } from './constants.js';
|
import { flowWrapClass, innerFlowClass } from './constants.js';
|
||||||
import RoverLabel from '../RoverLabel/index.jsx';
|
import RoverLabel from '../RoverLabel/index.jsx';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
export default function VipPrivateRoverAccessCard({
|
export default function VipPrivateRoverAccessCard({
|
||||||
requestableRovers = [],
|
requestableRovers = [],
|
||||||
@@ -29,14 +30,24 @@ export default function VipPrivateRoverAccessCard({
|
|||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
setPendingByRover((prev) => ({ ...prev, [roverId]: true }));
|
setPendingByRover((prev) => ({ ...prev, [roverId]: true }));
|
||||||
onMessage?.('');
|
onMessage?.('');
|
||||||
|
trackAnalyticsEvent('vip_private_rover_request', { roverId, status: 'started' });
|
||||||
try {
|
try {
|
||||||
const response = await requestPrivateRoverAccess?.(roverId);
|
const response = await requestPrivateRoverAccess?.(roverId);
|
||||||
|
trackAnalyticsEvent('vip_private_rover_request', {
|
||||||
|
roverId,
|
||||||
|
status: response?.existing ? 'existing' : 'accepted',
|
||||||
|
});
|
||||||
if (response?.existing) {
|
if (response?.existing) {
|
||||||
onMessage?.('You already have a pending request for that rover.');
|
onMessage?.('You already have a pending request for that rover.');
|
||||||
} else {
|
} else {
|
||||||
onMessage?.('Private rover access request sent to lockdown admins.');
|
onMessage?.('Private rover access request sent to lockdown admins.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('vip_private_rover_request', {
|
||||||
|
roverId,
|
||||||
|
status: 'failed',
|
||||||
|
reason: err?.message || 'unknown',
|
||||||
|
});
|
||||||
onMessage?.(err.message || 'Failed to send request.');
|
onMessage?.(err.message || 'Failed to send request.');
|
||||||
} finally {
|
} finally {
|
||||||
setPendingByRover((prev) => ({ ...prev, [roverId]: false }));
|
setPendingByRover((prev) => ({ ...prev, [roverId]: false }));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
|||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
export default function VipVerificationCard({
|
export default function VipVerificationCard({
|
||||||
pendingRequestId,
|
pendingRequestId,
|
||||||
@@ -25,6 +26,7 @@ export default function VipVerificationCard({
|
|||||||
setRequestKeyInput(currentStoredKey);
|
setRequestKeyInput(currentStoredKey);
|
||||||
setConfirmNickname(false);
|
setConfirmNickname(false);
|
||||||
onMessage?.('');
|
onMessage?.('');
|
||||||
|
trackAnalyticsEvent('vip_verify_flow_start', { hasNickname: Boolean(nickname) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelRequestFlow = () => {
|
const cancelRequestFlow = () => {
|
||||||
@@ -44,6 +46,10 @@ export default function VipVerificationCard({
|
|||||||
}
|
}
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
onMessage?.('');
|
onMessage?.('');
|
||||||
|
trackAnalyticsEvent('vip_verify_attempt', {
|
||||||
|
hasNickname: Boolean(nickname),
|
||||||
|
hasIdentityKey: Boolean(requestKeyInput),
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const applied = await applyIdentityKey(requestKeyInput);
|
const applied = await applyIdentityKey(requestKeyInput);
|
||||||
await requestVerification?.();
|
await requestVerification?.();
|
||||||
@@ -51,7 +57,9 @@ export default function VipVerificationCard({
|
|||||||
setRequestFlowStep(0);
|
setRequestFlowStep(0);
|
||||||
setConfirmNickname(false);
|
setConfirmNickname(false);
|
||||||
onMessage?.('Verification request sent to lockdown admins.');
|
onMessage?.('Verification request sent to lockdown admins.');
|
||||||
|
trackAnalyticsEvent('vip_verify_result', { status: 'accepted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackAnalyticsEvent('vip_verify_result', { status: 'failed', reason: err?.message || 'unknown' });
|
||||||
onMessage?.(err.message || 'Failed to submit request.');
|
onMessage?.(err.message || 'Failed to submit request.');
|
||||||
} finally {
|
} finally {
|
||||||
setWorking(false);
|
setWorking(false);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
const SOURCE = 'gamepad';
|
const SOURCE = 'gamepad';
|
||||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||||
@@ -58,6 +59,7 @@ export default function GamepadInputManager() {
|
|||||||
} = useControlActions();
|
} = useControlActions();
|
||||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||||
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const dockAssist = useManualDockAssist();
|
const dockAssist = useManualDockAssist();
|
||||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||||
'gamepad',
|
'gamepad',
|
||||||
@@ -166,6 +168,7 @@ export default function GamepadInputManager() {
|
|||||||
dockAssist,
|
dockAssist,
|
||||||
gamepadSettings,
|
gamepadSettings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
roverId,
|
||||||
runMacro,
|
runMacro,
|
||||||
saveGamepadSettings,
|
saveGamepadSettings,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
@@ -268,6 +271,7 @@ export default function GamepadInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||||
|
trackAnalyticsEvent('drive_start', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||||
latest.dockAssist.exitAssist();
|
latest.dockAssist.exitAssist();
|
||||||
latest.setMode('drive');
|
latest.setMode('drive');
|
||||||
latest.runMacro('drive-sequence');
|
latest.runMacro('drive-sequence');
|
||||||
@@ -276,12 +280,14 @@ export default function GamepadInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||||
|
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||||
latest.dockAssist.toggleAssist();
|
latest.dockAssist.toggleAssist();
|
||||||
} else if (!outputs.buttons.dockMacro) {
|
} else if (!outputs.buttons.dockMacro) {
|
||||||
handleButtonEdge('dockMacro', false);
|
handleButtonEdge('dockMacro', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.nightVisionToggle && handleButtonEdge('nightVisionToggle', true)) {
|
if (outputs.buttons.nightVisionToggle && handleButtonEdge('nightVisionToggle', true)) {
|
||||||
|
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||||
latest.toggleNightVision();
|
latest.toggleNightVision();
|
||||||
} else if (!outputs.buttons.nightVisionToggle) {
|
} else if (!outputs.buttons.nightVisionToggle) {
|
||||||
handleButtonEdge('nightVisionToggle', false);
|
handleButtonEdge('nightVisionToggle', false);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
getKeyboardDriveSpeedOptions,
|
getKeyboardDriveSpeedOptions,
|
||||||
resolveKeyboardSpeeds,
|
resolveKeyboardSpeeds,
|
||||||
} from './driveIntent.js';
|
} from './driveIntent.js';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
const SOURCE = 'keyboard';
|
const SOURCE = 'keyboard';
|
||||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||||
@@ -345,6 +346,7 @@ export default function KeyboardInputManager() {
|
|||||||
keymap,
|
keymap,
|
||||||
nudgeServo,
|
nudgeServo,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
roverId,
|
||||||
runMacro,
|
runMacro,
|
||||||
saveVideoSettings,
|
saveVideoSettings,
|
||||||
sendSong,
|
sendSong,
|
||||||
@@ -388,12 +390,15 @@ export default function KeyboardInputManager() {
|
|||||||
|
|
||||||
if (newlyPressed.length > 0) {
|
if (newlyPressed.length > 0) {
|
||||||
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
||||||
|
trackAnalyticsEvent('drive_start', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||||
latest.dockAssist.exitAssist();
|
latest.dockAssist.exitAssist();
|
||||||
latest.setMode('drive');
|
latest.setMode('drive');
|
||||||
latest.runMacro('drive-sequence');
|
latest.runMacro('drive-sequence');
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
||||||
|
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||||
latest.dockAssist.toggleAssist();
|
latest.dockAssist.toggleAssist();
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.nightVisionToggle?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.nightVisionToggle?.has(token))) {
|
||||||
|
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||||
latest.toggleNightVision();
|
latest.toggleNightVision();
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
|
||||||
cycleVideoFilter();
|
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.
|
// 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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSocket } from '../context/SocketContext.jsx';
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
|
import { trackAnalyticsEventThrottled } from '../analytics/index.js';
|
||||||
|
|
||||||
function normalizeEntry(entry) {
|
function normalizeEntry(entry) {
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
@@ -90,6 +91,23 @@ export function useVideoRequests(sourceList = [], options = {}) {
|
|||||||
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
|
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
|
||||||
socket.emit('video:request', payload, (resp = {}) => {
|
socket.emit('video:request', payload, (resp = {}) => {
|
||||||
if (cancelled) return;
|
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 }));
|
setSources((prev) => ({ ...prev, [entry.key]: resp }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
|||||||
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
||||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||||
import useScannerSpeech from './useScannerSpeech.js';
|
import useScannerSpeech from './useScannerSpeech.js';
|
||||||
|
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||||
|
|
||||||
const EMPTY_SCANNER_STATE = {
|
const EMPTY_SCANNER_STATE = {
|
||||||
beepAllowed: false,
|
beepAllowed: false,
|
||||||
@@ -70,6 +71,7 @@ export default function ScannerContent() {
|
|||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const flashTimerRef = useRef(null);
|
const flashTimerRef = useRef(null);
|
||||||
|
const scannerReadyTrackedRef = useRef(false);
|
||||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||||
const [scannerConnectionState, setScannerConnectionState] = useState({
|
const [scannerConnectionState, setScannerConnectionState] = useState({
|
||||||
connected: Boolean(socket.connected),
|
connected: Boolean(socket.connected),
|
||||||
@@ -108,6 +110,12 @@ export default function ScannerContent() {
|
|||||||
function handleScannerState(nextState = {}) {
|
function handleScannerState(nextState = {}) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
lastReceivedAt = Date.now();
|
lastReceivedAt = Date.now();
|
||||||
|
if (!scannerReadyTrackedRef.current) {
|
||||||
|
scannerReadyTrackedRef.current = true;
|
||||||
|
trackAnalyticsEvent('scanner_route_ready', {
|
||||||
|
beepAllowed: Boolean(nextState?.beepAllowed),
|
||||||
|
});
|
||||||
|
}
|
||||||
setScannerState({
|
setScannerState({
|
||||||
...EMPTY_SCANNER_STATE,
|
...EMPTY_SCANNER_STATE,
|
||||||
...(nextState && typeof nextState === 'object' ? nextState : {}),
|
...(nextState && typeof nextState === 'object' ? nextState : {}),
|
||||||
@@ -121,6 +129,9 @@ export default function ScannerContent() {
|
|||||||
|
|
||||||
function handleScanAudio(payload = null) {
|
function handleScanAudio(payload = null) {
|
||||||
setScanAudioEvent(payload && typeof payload === 'object' ? payload : null);
|
setScanAudioEvent(payload && typeof payload === 'object' ? payload : null);
|
||||||
|
trackAnalyticsEvent('barcode_scan_audio_play', {
|
||||||
|
hasPayload: Boolean(payload && typeof payload === 'object'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeToScannerState() {
|
function subscribeToScannerState() {
|
||||||
@@ -166,6 +177,13 @@ export default function ScannerContent() {
|
|||||||
|
|
||||||
staleTimer = window.setInterval(() => {
|
staleTimer = window.setInterval(() => {
|
||||||
const isStale = !lastReceivedAt || Date.now() - lastReceivedAt > SCANNER_STATE_STALE_MS;
|
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) => ({
|
setScannerConnectionState((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
connected: Boolean(socket.connected),
|
connected: Boolean(socket.connected),
|
||||||
@@ -214,6 +232,10 @@ export default function ScannerContent() {
|
|||||||
// The page intentionally sends only raw scanner text. The server reloads
|
// The page intentionally sends only raw scanner text. The server reloads
|
||||||
// the barcode registry, resolves labels/types, applies access policy, and
|
// the barcode registry, resolves labels/types, applies access policy, and
|
||||||
// broadcasts the display state back to every scanner page.
|
// broadcasts the display state back to every scanner page.
|
||||||
|
trackAnalyticsEvent('barcode_scan_submit', {
|
||||||
|
length: code.length,
|
||||||
|
beepAllowed: scannerState.beepAllowed,
|
||||||
|
});
|
||||||
socket.emit('barcode:scan', { code }, () => {});
|
socket.emit('barcode:scan', { code }, () => {});
|
||||||
inputRef.current.value = '';
|
inputRef.current.value = '';
|
||||||
focusInput();
|
focusInput();
|
||||||
|
|||||||
Reference in New Issue
Block a user