MOREE!!!!

This commit is contained in:
legop3
2026-06-19 01:22:42 -04:00
parent ea8e027be9
commit c7f5cb68f4
22 changed files with 429 additions and 138 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+5
View File
@@ -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' });
+19
View File
@@ -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}
+18 -4
View File
@@ -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();
+18
View File
@@ -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();