mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
analytics and embed meta update!!!
This commit is contained in:
@@ -9,7 +9,6 @@ import OverseerControlPanel from './OverseerControlPanel.jsx';
|
||||
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
||||
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const MODES = [
|
||||
{ key: 'open', label: 'Open' },
|
||||
@@ -161,23 +160,17 @@ export default function AdminPanelContent() {
|
||||
|
||||
const handleModeChange = async (event) => {
|
||||
const mode = event.target.value;
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'started' });
|
||||
try {
|
||||
await setMode(mode);
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceControl = async (roverId) => {
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'started' });
|
||||
try {
|
||||
await requestControl(roverId, { force: true });
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
@@ -187,12 +180,9 @@ export default function AdminPanelContent() {
|
||||
const ok = window.confirm(`Reboot rover "${rover.name || rover.id}" now?`);
|
||||
if (!ok) return;
|
||||
setRebootStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||
trackAnalyticsEvent('rover_reboot_click', { roverId: rover.id, scope: 'admin' });
|
||||
try {
|
||||
await rebootRover(rover.id);
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setRebootStates((prev) => ({ ...prev, [rover.id]: false }));
|
||||
@@ -206,7 +196,6 @@ export default function AdminPanelContent() {
|
||||
);
|
||||
if (!ok) return;
|
||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||
trackAnalyticsEvent('rover_update_click', { roverId: rover.id });
|
||||
try {
|
||||
// The rover acknowledges once the privileged self-update helper has been
|
||||
// launched, not when the full install finishes. That is deliberate: a
|
||||
@@ -214,9 +203,7 @@ export default function AdminPanelContent() {
|
||||
// the same websocket would make the button look failed even when the
|
||||
// update is doing exactly what it should.
|
||||
await updateRover(rover.id);
|
||||
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: false }));
|
||||
@@ -232,22 +219,15 @@ export default function AdminPanelContent() {
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
trackAnalyticsEvent('rover_update_all_click', { roverCount });
|
||||
try {
|
||||
// The server owns the fan-out because it has the authoritative online
|
||||
// rover map and can enforce admin privileges once before issuing the
|
||||
// existing per-rover update command to every connected rover.
|
||||
const result = await updateAllRovers();
|
||||
trackAnalyticsEvent('rover_update_all_result', {
|
||||
status: 'accepted',
|
||||
updated: result?.updated?.length || 0,
|
||||
failed: result?.failed?.length || 0,
|
||||
});
|
||||
if (result?.failed?.length) {
|
||||
alert(`Update requested for ${result.updated?.length || 0} rover(s). ${result.failed.length} rover(s) failed to queue.`);
|
||||
}
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_update_all_result', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
@@ -256,12 +236,9 @@ export default function AdminPanelContent() {
|
||||
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
||||
if (!ok) return;
|
||||
setServerRebooting(true);
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'started' });
|
||||
try {
|
||||
await rebootServer();
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
setServerRebooting(false);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Barcode Games Panel
|
||||
// Purpose: Shows barcode game selection, current game status, and player points in the Activities tab.
|
||||
// Scope: Keeps the driver-side UI compact but informative; game-specific rules stay in server game modules.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
@@ -261,10 +260,8 @@ export default function BarcodeGamesPanel() {
|
||||
}
|
||||
|
||||
function BarcodeGamesPanelContent() {
|
||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||
const { state, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeSignatureRef = useRef('');
|
||||
const participantCountRef = useRef(0);
|
||||
const activeGame = state.activeGame;
|
||||
const display = activeGame?.display || {};
|
||||
// Voting should stop once the shared game system has moved past selection.
|
||||
@@ -278,77 +275,10 @@ function BarcodeGamesPanelContent() {
|
||||
const participants = Array.isArray(state.participants) ? state.participants : [];
|
||||
const activeTheme = getGameTheme(activeGame?.themeColor);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionState.stale || !connectionState.lastReceivedAt) return;
|
||||
/*
|
||||
Stale barcode-game state is worth tracking because it points to a real
|
||||
interaction problem: users can be looking at old game choices or scores
|
||||
even though the rest of the page appears loaded.
|
||||
*/
|
||||
trackAnalyticsEvent('barcode_game_state_stale', {
|
||||
connected: connectionState.connected,
|
||||
phase: state.phase || 'unknown',
|
||||
});
|
||||
}, [connectionState.connected, connectionState.lastReceivedAt, connectionState.stale, state.phase]);
|
||||
|
||||
useEffect(() => {
|
||||
const signature = `${activeGame?.id || 'none'}:${activeGame?.status || state.phase || 'unknown'}`;
|
||||
if (activeSignatureRef.current === signature) return;
|
||||
activeSignatureRef.current = signature;
|
||||
|
||||
/*
|
||||
Game lifecycle events are derived from server state instead of button
|
||||
clicks so they still report games started by another user or by a scanner
|
||||
workflow outside this panel.
|
||||
*/
|
||||
trackAnalyticsEvent('barcode_game_active_change', {
|
||||
gameId: activeGame?.id || '',
|
||||
status: activeGame?.status || '',
|
||||
phase: state.phase || 'unknown',
|
||||
});
|
||||
|
||||
if (activeGame?.status === 'ending' || state.phase === 'ending') {
|
||||
trackAnalyticsEvent('barcode_game_round_end', {
|
||||
gameId: activeGame?.id || '',
|
||||
participantCount: participants.length,
|
||||
});
|
||||
}
|
||||
}, [activeGame?.id, activeGame?.status, participants.length, state.phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (participants.length <= participantCountRef.current) {
|
||||
participantCountRef.current = participants.length;
|
||||
return;
|
||||
}
|
||||
participantCountRef.current = participants.length;
|
||||
trackAnalyticsEvent('barcode_game_player_join', {
|
||||
gameId: activeGame?.id || '',
|
||||
participantCount: participants.length,
|
||||
});
|
||||
}, [activeGame?.id, participants.length]);
|
||||
|
||||
const handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
trackAnalyticsEvent('barcode_game_vote', {
|
||||
gameId,
|
||||
phase: state.phase || 'unknown',
|
||||
status: 'started',
|
||||
});
|
||||
try {
|
||||
await voteForGame(gameId);
|
||||
trackAnalyticsEvent('barcode_game_vote', {
|
||||
gameId,
|
||||
phase: state.phase || 'unknown',
|
||||
status: 'accepted',
|
||||
});
|
||||
} catch (error) {
|
||||
trackAnalyticsEvent('barcode_game_vote', {
|
||||
gameId,
|
||||
phase: state.phase || 'unknown',
|
||||
status: 'failed',
|
||||
reason: error?.message || 'unknown',
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
setPendingGameId(null);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetry
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function StatusRow({ value, tone = 'neutral' }) {
|
||||
@@ -129,14 +128,11 @@ export default function DriveDockAction({
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('drive');
|
||||
trackAnalyticsEvent('drive_return', { roverId, source: 'drive_dock_action' });
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
trackAnalyticsEvent('drive_return_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('drive_return_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -149,14 +145,11 @@ export default function DriveDockAction({
|
||||
setConfirmOpen(false);
|
||||
setShowModal(false);
|
||||
setPending('drive');
|
||||
trackAnalyticsEvent('drive_start', { roverId, source: 'drive_dock_action' });
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
trackAnalyticsEvent('drive_start_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('drive_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -167,12 +160,9 @@ export default function DriveDockAction({
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('dock');
|
||||
trackAnalyticsEvent('dock_assist_start', { roverId });
|
||||
try {
|
||||
dockAssist.enterAssist();
|
||||
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -186,12 +176,10 @@ export default function DriveDockAction({
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
if (manualAssistActive) {
|
||||
dockAssist.exitAssist();
|
||||
trackAnalyticsEvent('dock_assist_exit', { roverId });
|
||||
return;
|
||||
}
|
||||
setShowModal(true);
|
||||
setConfirmOpen(true);
|
||||
trackAnalyticsEvent('dock_assist_open', { roverId });
|
||||
};
|
||||
|
||||
// The drive/dock card appears inside the mobile controls and can be held or
|
||||
|
||||
@@ -9,7 +9,6 @@ import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import { AUX_ZERO } from './constants.js';
|
||||
import VacuumControls from './VacuumControls.jsx';
|
||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
const CAMERA_TILT_STEP_DEGREES = 0.5;
|
||||
@@ -56,25 +55,22 @@ function AuxColumnContent() {
|
||||
const handleHeadlightToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!headlightAvailable) return;
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
[headlightAvailable, roverId, setHeadlight],
|
||||
[headlightAvailable, setHeadlight],
|
||||
);
|
||||
|
||||
const handleLaserToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!laserAvailable) return;
|
||||
trackAnalyticsEvent('laser_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setLaser(nextOn);
|
||||
},
|
||||
[laserAvailable, roverId, setLaser],
|
||||
[laserAvailable, setLaser],
|
||||
);
|
||||
|
||||
const handleHornStart = useCallback(() => {
|
||||
trackAnalyticsEvent('horn_start', { roverId, source: 'mobile_control' });
|
||||
return startHorn();
|
||||
}, [roverId, startHorn]);
|
||||
}, [startHorn]);
|
||||
|
||||
const handleAuxPress = useCallback(
|
||||
(id, values) => {
|
||||
|
||||
@@ -22,7 +22,6 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
@@ -861,7 +860,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
export default function PtzQueueCard() {
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
@@ -886,7 +885,6 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
trackAnalyticsEvent('ptz_queue_join', { layout });
|
||||
try {
|
||||
const response = await ptzClaim();
|
||||
/*
|
||||
@@ -897,13 +895,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
if (response?.state?.isOperator || response?.state?.queuedPosition) {
|
||||
navigate('/ptz');
|
||||
}
|
||||
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('ptz_queue_join_result', {
|
||||
layout,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message || 'PTZ request failed.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
|
||||
@@ -36,7 +36,6 @@ import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { themeGapClass, themeStackClass } from '../../themes/index.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
||||
const CHAT_DOCK_MIN_HEIGHT = 144;
|
||||
@@ -103,23 +102,20 @@ function DriveDockPanel() {
|
||||
const cameraTiltStep = camera?.precisionMode
|
||||
? CAMERA_TILT_PRECISION_STEP_DEGREES
|
||||
: CAMERA_TILT_STEP_DEGREES;
|
||||
const trackedControls = useMemo(
|
||||
const auxControls = useMemo(
|
||||
() => ({
|
||||
setHeadlight: (nextOn) => {
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
setLaser: (nextOn) => {
|
||||
trackAnalyticsEvent('laser_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setLaser(nextOn);
|
||||
},
|
||||
startHorn: () => {
|
||||
trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' });
|
||||
return startHorn();
|
||||
},
|
||||
stopHorn,
|
||||
}),
|
||||
[roverId, setHeadlight, setLaser, startHorn, stopHorn],
|
||||
[setHeadlight, setLaser, startHorn, stopHorn],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -139,7 +135,7 @@ function DriveDockPanel() {
|
||||
label="Headlight"
|
||||
on={headlightState?.headlightOn}
|
||||
disabled={!roverId}
|
||||
onToggle={trackedControls.setHeadlight}
|
||||
onToggle={auxControls.setHeadlight}
|
||||
keyLabel={headlightLabel}
|
||||
/>
|
||||
)}
|
||||
@@ -148,7 +144,7 @@ function DriveDockPanel() {
|
||||
label="Laser"
|
||||
on={laserState?.laserOn}
|
||||
disabled={!roverId || roomLightsLockedOn}
|
||||
onToggle={trackedControls.setLaser}
|
||||
onToggle={auxControls.setLaser}
|
||||
keyLabel={laserLabel}
|
||||
/>
|
||||
)}
|
||||
@@ -157,8 +153,8 @@ function DriveDockPanel() {
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={trackedControls.startHorn}
|
||||
onStop={trackedControls.stopHorn}
|
||||
onStart={auxControls.startHorn}
|
||||
onStop={auxControls.stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
@@ -259,14 +255,12 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
const handleTabChange = useCallback(
|
||||
(tab) => {
|
||||
/*
|
||||
Desktop users spend most of their time on this one route, so panel
|
||||
changes are the cleanest way to understand feature usage without adding
|
||||
analytics calls to every nested control in the rover dashboard.
|
||||
Keep the selected desktop panel controlled here so the tab strip and
|
||||
panel content always move together.
|
||||
*/
|
||||
setActiveTab(tab);
|
||||
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'desktop_right_pane' });
|
||||
},
|
||||
[layout],
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
import {
|
||||
RESTART_DELAY_MS,
|
||||
UNMUTE_RETRY_MS,
|
||||
@@ -439,20 +438,6 @@ export default function RoverMediaPlayer({
|
||||
ensurePlayback();
|
||||
}
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
||||
/*
|
||||
WHEP status changes can repeat during automatic restart attempts. The
|
||||
throttled event keeps media reliability visible in analytics without
|
||||
reporting every retry loop as a separate user action.
|
||||
*/
|
||||
trackAnalyticsEventThrottled(
|
||||
'whep_player_error',
|
||||
{
|
||||
roverId: effectiveRoverId,
|
||||
status: nextStatus,
|
||||
detail: info || '',
|
||||
},
|
||||
{ key: `${effectiveRoverId || 'unknown'}:${nextStatus}:${info || ''}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
scheduleRestart();
|
||||
}
|
||||
};
|
||||
@@ -469,15 +454,6 @@ export default function RoverMediaPlayer({
|
||||
if (!active) return;
|
||||
setStatus('error');
|
||||
setDetail(err.message);
|
||||
trackAnalyticsEventThrottled(
|
||||
'whep_player_error',
|
||||
{
|
||||
roverId: effectiveRoverId,
|
||||
status: 'start_failed',
|
||||
detail: err.message,
|
||||
},
|
||||
{ key: `${effectiveRoverId || 'unknown'}:start_failed:${err.message}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
|
||||
@@ -168,22 +168,13 @@ export default function RoverQueuesPanel({
|
||||
return;
|
||||
}
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||
trackAnalyticsEvent('rover_queue_join', {
|
||||
trackAnalyticsEvent('rover_selected', {
|
||||
roverId: targetRoverId,
|
||||
alreadyAssigned: assignedRoverId === String(targetRoverId),
|
||||
alreadySelected: assignedRoverId === String(targetRoverId),
|
||||
});
|
||||
try {
|
||||
await requestControl(targetRoverId);
|
||||
trackAnalyticsEvent('rover_queue_join_result', {
|
||||
roverId: targetRoverId,
|
||||
status: 'accepted',
|
||||
});
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_queue_join_result', {
|
||||
roverId: targetRoverId,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
||||
@@ -198,18 +189,10 @@ export default function RoverQueuesPanel({
|
||||
const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`);
|
||||
if (!ok) return;
|
||||
setRebootPending(true);
|
||||
trackAnalyticsEvent('rover_reboot_click', { roverId: assignedRoverId, scope: 'own_rover' });
|
||||
try {
|
||||
await rebootOwnRover();
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: assignedRoverId, scope: 'own_rover', status: 'accepted' });
|
||||
alert('Reboot command sent.');
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_reboot_result', {
|
||||
roverId: assignedRoverId,
|
||||
scope: 'own_rover',
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setRebootPending(false);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Session Document Title
|
||||
// Purpose: Uses the configured local inter-instance profile name as the browser tab title.
|
||||
// Scope: Owns only the document title lifecycle; the static HTML title remains the fallback.
|
||||
import { useEffect } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
const DEFAULT_DOCUMENT_TITLE = document.title;
|
||||
|
||||
export default function SessionDocumentTitle() {
|
||||
const interInstanceEnabled = useSessionSelector((state) => Boolean(state.session?.interInstances?.enabled));
|
||||
const interInstanceName = useSessionSelector((state) =>
|
||||
String(state.session?.interInstances?.profile?.name || '').trim(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
The static title from index.html remains the source of truth until the
|
||||
server confirms that inter-instance sharing is enabled and supplies a
|
||||
usable profile name. This prevents the profile's loading state from
|
||||
replacing the familiar fallback title with an empty or temporary value.
|
||||
*/
|
||||
if (!interInstanceEnabled || !interInstanceName) return undefined;
|
||||
|
||||
document.title = interInstanceName;
|
||||
|
||||
/*
|
||||
Restore the static title when the synchronized profile disappears or the
|
||||
feature is disabled. React also runs this cleanup during development's
|
||||
StrictMode effect check, so the component never leaves a stale server name
|
||||
behind when its session-derived conditions stop being true.
|
||||
*/
|
||||
return () => {
|
||||
document.title = DEFAULT_DOCUMENT_TITLE;
|
||||
};
|
||||
}, [interInstanceEnabled, interInstanceName]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
import {
|
||||
DEFAULT_PAGE_THEME_KEY,
|
||||
PAGE_THEME_OPTIONS,
|
||||
@@ -196,7 +195,6 @@ export default function SettingsPanel() {
|
||||
const handleTransportChange = (event) => {
|
||||
const next = event.target.value;
|
||||
savePageSettings((current) => ({ ...(current ?? {}), connectionTransport: next }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'connection_transport', value: next });
|
||||
reconnectSocketWithTransport(socket, next);
|
||||
};
|
||||
|
||||
@@ -204,30 +202,21 @@ export default function SettingsPanel() {
|
||||
const raw = Number(event.target.value);
|
||||
const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0;
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next }));
|
||||
trackAnalyticsEventThrottled(
|
||||
'settings_change',
|
||||
{ setting: key, value: next },
|
||||
{ key: `audio:${key}`, throttleMs: 3 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const handleMainBrushDuckEnabled = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), mainBrushDuckEnabled: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'mainBrushDuckEnabled', value: checked });
|
||||
};
|
||||
|
||||
const handleSwapMobileControlColumns = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
savePageSettings((current) => ({ ...(current ?? {}), swapMobileControlColumns: checked }));
|
||||
trackAnalyticsEvent('mobile_controls_swap', { enabled: checked });
|
||||
trackAnalyticsEvent('settings_change', { setting: 'swapMobileControlColumns', value: checked });
|
||||
};
|
||||
|
||||
const handleDriveMacroBackoffEnabled = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
savePageSettings((current) => ({ ...(current ?? {}), driveMacroBackoffEnabled: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'driveMacroBackoffEnabled', value: checked });
|
||||
};
|
||||
|
||||
const handleInterInstanceTransferSettings = (event) => {
|
||||
@@ -238,7 +227,6 @@ export default function SettingsPanel() {
|
||||
letting users opt out of sending their current settings cookie.
|
||||
*/
|
||||
savePageSettings((current) => ({ ...(current ?? {}), interInstanceTransferSettings: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
|
||||
};
|
||||
|
||||
const movePageThemePreview = (direction) => {
|
||||
@@ -261,10 +249,6 @@ export default function SettingsPanel() {
|
||||
...(current ?? {}),
|
||||
backgroundTheme: previewPageThemeKey,
|
||||
}));
|
||||
trackAnalyticsEvent('settings_change', {
|
||||
setting: 'backgroundTheme',
|
||||
value: previewPageThemeKey,
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoFilterChange = (event) => {
|
||||
@@ -276,7 +260,6 @@ export default function SettingsPanel() {
|
||||
...(current ?? {}),
|
||||
colorFilter: nextFilter,
|
||||
}));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'videoColorFilter', value: nextFilter });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,14 +40,11 @@ export default function SocialButton({ id = null, label, url, icon, color, layou
|
||||
rel="noopener noreferrer"
|
||||
aria-label={text}
|
||||
onClick={() => {
|
||||
trackAnalyticsEvent('social_link_click', {
|
||||
id: id || '',
|
||||
trackAnalyticsEvent('social_link_clicked', {
|
||||
destination: id || 'custom',
|
||||
label: text,
|
||||
layout,
|
||||
});
|
||||
if (id === 'discord') {
|
||||
trackAnalyticsEvent('discord_click', { layout });
|
||||
}
|
||||
}}
|
||||
className={`grid h-full min-h-0 w-full place-items-center rounded-md bg-slate-700 px-0.5 ${isInline ? 'py-0.5' : 'pb-3'} text-center text-sm font-medium text-white transition hover:opacity-90 ${className}`}
|
||||
style={bgColor ? { backgroundColor: bgColor } : undefined}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
const CONNECTED_FADE_DELAY_MS = 4000;
|
||||
|
||||
@@ -42,39 +41,23 @@ export default function SocketConnectionPill() {
|
||||
setLastReason('');
|
||||
show();
|
||||
scheduleFade();
|
||||
trackAnalyticsEvent('socket_connect', { connected: true });
|
||||
};
|
||||
|
||||
const onDisconnect = (reason) => {
|
||||
setConnected(false);
|
||||
setLastReason(typeof reason === 'string' ? reason : 'disconnected');
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_disconnect',
|
||||
{ reason: typeof reason === 'string' ? reason : 'disconnected' },
|
||||
{ key: `disconnect:${reason || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const onConnectError = (err) => {
|
||||
setConnected(false);
|
||||
setLastReason(err?.message || 'connect error');
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_connect_error',
|
||||
{ message: err?.message || 'connect error' },
|
||||
{ key: `connect_error:${err?.message || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const onReconnectAttempt = () => {
|
||||
setConnected(false);
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_reconnect_attempt',
|
||||
{ connected: false },
|
||||
{ key: 'socket_reconnect_attempt', throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
socket.on('connect', onConnect);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
/*
|
||||
Sliders are a 0-1 fraction of whichever ceiling the server resolved for this
|
||||
@@ -87,11 +86,6 @@ export default function VolumeSettingsCard() {
|
||||
setDraft(nextDraft);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS);
|
||||
trackAnalyticsEventThrottled(
|
||||
'settings_change',
|
||||
{ setting: key, value: next },
|
||||
{ key: `volume:${key}`, throttleMs: 3 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
// Sliders would be misleading before the first session sync lands.
|
||||
|
||||
@@ -18,7 +18,6 @@ import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||
import StatusIndicator from './StatusIndicator.jsx';
|
||||
import KeyPill from './KeyPill.jsx';
|
||||
import CardFrame from '../../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../../analytics/index.js';
|
||||
|
||||
export default function VipAudioUploadCard({
|
||||
ownRoverId = '',
|
||||
@@ -88,9 +87,8 @@ export default function VipAudioUploadCard({
|
||||
(nextMode) => {
|
||||
const mode = nextMode === 'clip' ? 'clip' : 'live';
|
||||
saveVipAudio((current) => ({ ...(current || {}), pttMode: mode }));
|
||||
trackAnalyticsEvent('vip_ptt_mode_change', { mode, roverId });
|
||||
},
|
||||
[roverId, saveVipAudio],
|
||||
[saveVipAudio],
|
||||
);
|
||||
|
||||
const handleUploadPlay = async () => {
|
||||
@@ -109,11 +107,6 @@ export default function VipAudioUploadCard({
|
||||
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
trackAnalyticsEvent('vip_upload_play', {
|
||||
roverId,
|
||||
size: selectedUpload.size,
|
||||
mime: selectedUpload.type || '',
|
||||
});
|
||||
try {
|
||||
const buffer = await selectedUpload.arrayBuffer();
|
||||
const dataBase64 = bytesToBase64(new Uint8Array(buffer));
|
||||
@@ -124,9 +117,7 @@ export default function VipAudioUploadCard({
|
||||
dataBase64,
|
||||
});
|
||||
setMessage('Upload playback started.');
|
||||
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to play upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -141,13 +132,10 @@ export default function VipAudioUploadCard({
|
||||
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
trackAnalyticsEvent('vip_upload_stop', { roverId });
|
||||
try {
|
||||
await stopUploadedAudio?.(roverId);
|
||||
setMessage('Upload playback stopped.');
|
||||
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to stop upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -378,24 +366,20 @@ export default function VipAudioUploadCard({
|
||||
|
||||
if (!send) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_record_cancel', { roverId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roverId) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_no_rover' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chunks.length) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
setClipState('sending');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'started' });
|
||||
try {
|
||||
const merged = mergeFloatChunks(chunks);
|
||||
const wavBytes = encodeWavMono16(merged, clipSampleRateRef.current || TARGET_SAMPLE_RATE);
|
||||
@@ -407,10 +391,8 @@ export default function VipAudioUploadCard({
|
||||
dataBase64,
|
||||
});
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
setClipState('error');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to send PTT clip.');
|
||||
}
|
||||
},
|
||||
@@ -426,7 +408,6 @@ export default function VipAudioUploadCard({
|
||||
clipChunksRef.current = [];
|
||||
clipRecordingRef.current = true;
|
||||
setClipState('recording');
|
||||
trackAnalyticsEvent('vip_clip_record_start', { roverId });
|
||||
}, [ensureClipPipeline, roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -453,17 +434,11 @@ export default function VipAudioUploadCard({
|
||||
await startWhipMic(roverId);
|
||||
if (!cancelled) {
|
||||
setMicState('live');
|
||||
trackAnalyticsEvent('vip_live_mic_start', { roverId });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setMicState('error');
|
||||
setMessage(err?.message || 'Failed to start mic forwarding.');
|
||||
trackAnalyticsEventThrottled(
|
||||
'vip_live_mic_error',
|
||||
{ roverId, reason: err?.message || 'unknown' },
|
||||
{ key: `${roverId}:${err?.message || 'unknown'}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
await stopMicCapture(roverId);
|
||||
}
|
||||
@@ -651,7 +626,6 @@ export default function VipAudioUploadCard({
|
||||
onChange={(event) => {
|
||||
const enabled = Boolean(event.target.checked);
|
||||
saveVipAudio((current) => ({ ...(current || {}), openMicEnabled: enabled }));
|
||||
trackAnalyticsEvent('vip_open_mic_change', { roverId, enabled });
|
||||
}}
|
||||
/>
|
||||
<span>{clipMode ? 'Open mic (live mode only)' : 'Open mic'}</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useMemo, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass } from './constants.js';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
export default function VipPrivateRoverAccessCard({
|
||||
requestableRovers = [],
|
||||
@@ -30,24 +29,14 @@ export default function VipPrivateRoverAccessCard({
|
||||
if (!roverId) return;
|
||||
setPendingByRover((prev) => ({ ...prev, [roverId]: true }));
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_private_rover_request', { roverId, status: 'started' });
|
||||
try {
|
||||
const response = await requestPrivateRoverAccess?.(roverId);
|
||||
trackAnalyticsEvent('vip_private_rover_request', {
|
||||
roverId,
|
||||
status: response?.existing ? 'existing' : 'accepted',
|
||||
});
|
||||
if (response?.existing) {
|
||||
onMessage?.('You already have a pending request for that rover.');
|
||||
} else {
|
||||
onMessage?.('Private rover access request sent to lockdown admins.');
|
||||
}
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_private_rover_request', {
|
||||
roverId,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
onMessage?.(err.message || 'Failed to send request.');
|
||||
} finally {
|
||||
setPendingByRover((prev) => ({ ...prev, [roverId]: false }));
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useState } from 'react';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
export default function VipVerificationCard({
|
||||
pendingRequestId,
|
||||
@@ -26,7 +25,6 @@ export default function VipVerificationCard({
|
||||
setRequestKeyInput(currentStoredKey);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_verify_flow_start', { hasNickname: Boolean(nickname) });
|
||||
};
|
||||
|
||||
const cancelRequestFlow = () => {
|
||||
@@ -46,10 +44,6 @@ export default function VipVerificationCard({
|
||||
}
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_verify_attempt', {
|
||||
hasNickname: Boolean(nickname),
|
||||
hasIdentityKey: Boolean(requestKeyInput),
|
||||
});
|
||||
try {
|
||||
const applied = await applyIdentityKey(requestKeyInput);
|
||||
await requestVerification?.();
|
||||
@@ -57,9 +51,7 @@ export default function VipVerificationCard({
|
||||
setRequestFlowStep(0);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('Verification request sent to lockdown admins.');
|
||||
trackAnalyticsEvent('vip_verify_result', { status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_verify_result', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
onMessage?.(err.message || 'Failed to submit request.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
|
||||
Reference in New Issue
Block a user