analytics and embed meta update!!!

This commit is contained in:
legop3
2026-07-31 22:12:04 -04:00
parent 1aecab66e7
commit 8c5d98bed6
45 changed files with 395 additions and 699 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ webui/package-lock.json
!server/data/barcode-registry.json !server/data/barcode-registry.json
webui/src/config/analytics.jsx webui/src/config/analytics.jsx
webui/src/config/driverAnalytics.json webui/src/config/driverAnalytics.json
webui/src/config/analytics.html server/data/analytics.html
plans/barcodegames.txt plans/barcodegames.txt
.gitignore .gitignore
server/data/identity.sqlite server/data/identity.sqlite
+22
View File
@@ -0,0 +1,22 @@
<!--
Umami example for the optional provider-neutral rover analytics bridge.
Copy this file to analytics.html in the same data directory, replace the
example URLs and attributes, and restart the server. The server injects the
copied file into every web UI entry page; this example filename is not loaded
automatically.
-->
<!-- Replace these example URLs, website IDs, domains, and recorder settings. -->
<script defer src="https://analytics.example.com/script.js" data-website-id="replace-with-website-id" data-domains="rover.example.com"></script>
<script defer src="https://analytics.example.com/recorder.js" data-website-id="replace-with-website-id" data-domains="rover.example.com" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<script>
window.roverAnalytics = {
track: function (name, data) {
window.umami?.track(name, data);
},
identify: function (data) {
window.umami?.identify(data);
},
};
</script>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -72
View File
@@ -4,82 +4,16 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bitmap.png" /> <link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" /> <link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" /> <!-- The server renders this manifest so installed shortcuts use the local instance's configured branding. -->
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. --> <!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <!-- site-metadata:inject -->
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. --> <!-- analytics:inject -->
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. --> <script type="module" crossorigin src="/assets/index-B0pLdqo3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CoK6vO1j.css">
<script>
/*
Build-time analytics adapter for the rover UI.
React only calls window.roverAnalytics.track/identify. Keeping the Umami
adapter here means analytics can still be removed, replaced, or configured
by changing this injected file instead of rebuilding app logic around a
specific analytics provider.
*/
(function () {
var pendingCalls = [];
var flushTimer = null;
function callUmami(method, args) {
if (!window.umami || typeof window.umami[method] !== 'function') return false;
window.umami[method].apply(window.umami, args);
return true;
}
function flushPendingCalls() {
if (!pendingCalls.length) return;
if (!window.umami) return;
pendingCalls = pendingCalls.filter(function (call) {
return !callUmami(call.method, call.args);
});
if (!pendingCalls.length && flushTimer) {
window.clearInterval(flushTimer);
flushTimer = null;
}
}
function enqueue(method, args) {
if (callUmami(method, args)) return;
pendingCalls.push({ method: method, args: args });
/*
The React app may fire route/session events before Umami's deferred
script has executed. Queueing preserves those early events while still
letting the whole adapter no-op harmlessly if the script is blocked.
*/
if (!flushTimer) {
flushTimer = window.setInterval(flushPendingCalls, 500);
}
}
window.roverAnalytics = {
track: function (name, data) {
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
},
identify: function (data) {
enqueue('identify', [data || {}]);
},
};
window.addEventListener('load', flushPendingCalls);
})();
</script>
<!-- otterlytics testing for blocking local -->
<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>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-B8HPO9Qz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3R_zn0v.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-18
View File
@@ -1,18 +0,0 @@
{
"name": "Multi Roomba Rover",
"short_name": "MRR",
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#020617",
"icons": [
{
"src": "/bitmap.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+120
View File
@@ -0,0 +1,120 @@
// Site Metadata Helper
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
const { loadConfig } = require('./configLoader');
const DEFAULT_SITE_METADATA = Object.freeze({
name: 'Multi Roomba Rover',
shortName: 'Multi Roomba Rover',
description: 'Drive and watch remote rovers from your browser.',
accentColor: '#38bdf8',
backgroundColor: '#020617',
publicUrl: null,
});
const BACKGROUND_BLEND_AMOUNT = 0.15;
function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeHexColor(value) {
const color = asTrimmedString(value).toLowerCase();
/*
Supporting both common CSS hex forms keeps the operator-facing setting
forgiving while still preventing arbitrary CSS from being injected into
generated HTML and SVG attributes.
*/
if (/^#[0-9a-f]{6}$/.test(color)) return color;
if (/^#[0-9a-f]{3}$/.test(color)) {
return `#${color.slice(1).split('').map((character) => character.repeat(2)).join('')}`;
}
return null;
}
function blendHexColors(baseColor, accentColor, accentAmount) {
const base = baseColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
const accent = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
/*
The profile color is deliberately only a tint. A full-strength profile
color could produce a glaring PWA launch screen, while this blend preserves
the application's established dark appearance and still makes each server
visually recognizable.
*/
const channels = base.map((channel, index) =>
Math.round(channel * (1 - accentAmount) + accent[index] * accentAmount),
);
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
}
function normalizePublicUrl(value) {
const candidate = asTrimmedString(value);
if (!candidate) return null;
/*
URL() helpfully repairs strings such as `http:192.168.0.1`, but preserving
that typo in public metadata would conceal a configuration mistake. Require
the conventional absolute URL form so the published address is explicit.
*/
if (!/^https?:\/\//i.test(candidate)) return null;
try {
const url = new URL(candidate);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
/*
Removing a trailing slash gives callers one stable base URL to combine
with paths. Invalid values are ignored instead of producing broken
canonical and social metadata on every page.
*/
return url.toString().replace(/\/$/, '');
} catch {
return null;
}
}
function getReadableAccentText(accentColor) {
const channels = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
const luminance = (channels[0] * 299 + channels[1] * 587 + channels[2] * 114) / 1000;
// A simple luminance split keeps the generated preview badge legible for both dark and light profile colors.
return luminance > 150 ? '#020617' : '#ffffff';
}
function resolveSiteMetadata(config = loadConfig()) {
const interInstance = config?.interInstance;
const profile = interInstance?.profile;
const profileName = asTrimmedString(profile?.name);
/*
A partially filled profile must not unexpectedly rename the site. The
inter-instance feature must be explicitly enabled and have a usable name
before any profile branding is applied; otherwise every value comes from
the coherent default set above.
*/
if (interInstance?.enabled !== true || !profileName) {
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
}
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
return {
name: profileName,
shortName: profileName,
description: asTrimmedString(profile.description) || DEFAULT_SITE_METADATA.description,
accentColor,
backgroundColor: blendHexColors(
DEFAULT_SITE_METADATA.backgroundColor,
accentColor,
BACKGROUND_BLEND_AMOUNT,
),
accentTextColor: getReadableAccentText(accentColor),
publicUrl: normalizePublicUrl(profile.publicUrl),
};
}
module.exports = {
DEFAULT_SITE_METADATA,
resolveSiteMetadata,
};
+11 -1
View File
@@ -2,7 +2,7 @@
// Purpose: Defines the embed Http Service module and the helpers/state used by this service unit. // Purpose: Defines the embed Http Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService'); const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedService');
/* /*
Every client-side BrowserRouter entry point must also be an explicit HTTP Every client-side BrowserRouter entry point must also be an explicit HTTP
@@ -27,3 +27,13 @@ app.get('/og/preview.png', async (req, res) => {
res.status(500).send('Failed to render embed image'); res.status(500).send('Failed to render embed image');
} }
}); });
app.get('/manifest.webmanifest', (req, res) => {
/*
The manifest varies with server configuration, so it is served by the
application rather than copied into Vite's static output. Revalidation
lets browsers pick up branding changes after the server is restarted.
*/
res.set('Cache-Control', 'no-cache');
res.type('application/manifest+json').send(renderWebManifest());
});
+140 -16
View File
@@ -2,26 +2,60 @@
// Purpose: Defines the embed Service module and the helpers/state used by this service unit. // Purpose: Defines the embed Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const path = require('path'); const path = require('path');
const fs = require('fs');
const fsp = require('fs/promises'); const fsp = require('fs/promises');
const sharp = require('sharp'); const sharp = require('sharp');
const logger = require('../../globals/logger').child('embedService');
const { getMode } = require('../modeManager'); const { getMode } = require('../modeManager');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getActiveDrivers, getTurnQueues } = require('../turnService'); const { getActiveDrivers, getTurnQueues } = require('../turnService');
const { getRoomCameras } = require('../roomCameraService'); const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService'); const { getRoomCameraState } = require('../roomCameraService');
const { loadConfig } = require('../../helpers/configLoader'); const { resolveDataPath } = require('../../helpers/dataPaths');
const { resolveSiteMetadata } = require('../../helpers/siteMetadata');
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html'); const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html');
const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png'); const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png');
const ANALYTICS_HTML_PATH = resolveDataPath('analytics.html');
const ANALYTICS_PLACEHOLDER = '<!-- analytics:inject -->';
const SITE_METADATA_PLACEHOLDER = '<!-- site-metadata:inject -->';
const OG_WIDTH = 1200; const OG_WIDTH = 1200;
const OG_HEIGHT = 630; const OG_HEIGHT = 630;
const BASE_BG = { r: 8, g: 12, b: 22 };
let cachedIndexHtml = null; let cachedIndexHtml = null;
let cachedIndexMtimeMs = 0; let cachedIndexMtimeMs = 0;
/*
Analytics provider markup belongs to the server operator, not to the shared
web build. Loading the snippet once at process startup makes deployment
behavior predictable: replacing analytics.html takes effect on the next
normal server restart, and no analytics configuration needs to travel over
Socket.IO or be exposed through a JSON endpoint.
This file is intentionally trusted as raw HTML. Anyone able to write files in
the server data directory already controls the deployment, and allowing a
complete head snippet is what keeps this integration compatible with Umami,
Plausible, Matomo, or a custom provider without provider-specific server code.
*/
function loadAnalyticsHeadHtml() {
if (!fs.existsSync(ANALYTICS_HTML_PATH)) return '';
try {
return fs.readFileSync(ANALYTICS_HTML_PATH, 'utf8').trim();
} catch (err) {
/*
Analytics is observability-only, so a permissions or read error must not
prevent operators and drivers from loading the rover controls.
*/
logger.warn('Unable to read analytics head HTML; continuing without analytics', err.message);
return '';
}
}
const analyticsHeadHtml = loadAnalyticsHeadHtml();
function escapeHtml(value) { function escapeHtml(value) {
return String(value || '') return String(value || '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -52,6 +86,20 @@ function getBaseUrl(req) {
return `${proto}://${host}`; return `${proto}://${host}`;
} }
function getPagePath(req) {
/*
Canonical URLs should describe the page rather than a tracking/query
variant of it. Express's path value excludes the query string and is safe
to combine with either the configured public URL or the current request.
*/
return req.path || '/';
}
function joinPublicUrl(baseUrl, pagePath) {
const normalizedPath = pagePath.startsWith('/') ? pagePath : `/${pagePath}`;
return `${baseUrl}${normalizedPath}`;
}
function getPrimaryRoomCamera() { function getPrimaryRoomCamera() {
const cameras = getRoomCameras(); const cameras = getRoomCameras();
if (!cameras.length) return null; if (!cameras.length) return null;
@@ -133,12 +181,12 @@ function buildEmbedCopy(state, camera) {
}; };
} }
function buildMetaTags({ title, description, imageUrl, pageUrl }) { function buildMetaTags({ title, description, imageUrl, pageUrl, canonicalUrl }) {
const safeTitle = escapeHtml(title); const safeTitle = escapeHtml(title);
const safeDescription = escapeHtml(description); const safeDescription = escapeHtml(description);
const safeImage = escapeHtml(imageUrl); const safeImage = escapeHtml(imageUrl);
const safeUrl = escapeHtml(pageUrl); const safeUrl = escapeHtml(pageUrl);
return [ const tags = [
'<!-- embed meta -->', '<!-- embed meta -->',
`<meta name="description" content="${safeDescription}" />`, `<meta name="description" content="${safeDescription}" />`,
`<meta property="og:title" content="${safeTitle}" />`, `<meta property="og:title" content="${safeTitle}" />`,
@@ -153,7 +201,27 @@ function buildMetaTags({ title, description, imageUrl, pageUrl }) {
`<meta name="twitter:title" content="${safeTitle}" />`, `<meta name="twitter:title" content="${safeTitle}" />`,
`<meta name="twitter:description" content="${safeDescription}" />`, `<meta name="twitter:description" content="${safeDescription}" />`,
`<meta name="twitter:image" content="${safeImage}" />`, `<meta name="twitter:image" content="${safeImage}" />`,
'<!-- /embed meta -->', ];
/*
Only advertise a canonical address when the operator supplied a valid
public URL. Guessing from request headers would permanently identify a LAN
hostname or reverse-proxy hop as the public home of the instance.
*/
if (canonicalUrl) {
tags.push(`<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />`);
}
tags.push('<!-- /embed meta -->');
return tags.join('\n ');
}
function buildSiteMetadataTags(siteMetadata) {
return [
'<!-- site metadata -->',
`<meta name="theme-color" content="${escapeHtml(siteMetadata.accentColor)}" />`,
`<meta name="apple-mobile-web-app-title" content="${escapeHtml(siteMetadata.shortName)}" />`,
`<title>${escapeHtml(siteMetadata.name)}</title>`,
'<!-- /site metadata -->',
].join('\n '); ].join('\n ');
} }
@@ -165,23 +233,47 @@ async function renderIndexHtml(req) {
activeDrivers: getActiveDrivers(), activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
}; };
const config = loadConfig(); const siteMetadata = resolveSiteMetadata();
const pageTitle = config?.site?.title || 'Roomba Rover';
const camera = getPrimaryRoomCamera(); const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera); const copy = buildEmbedCopy(state, camera);
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000)); const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`; const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`; const pagePath = getPagePath(req);
const canonicalUrl = siteMetadata.publicUrl
? joinPublicUrl(siteMetadata.publicUrl, pagePath)
: null;
const pageUrl = canonicalUrl || joinPublicUrl(baseUrl, pagePath);
const metaBlock = buildMetaTags({ const metaBlock = buildMetaTags({
title: pageTitle, title: siteMetadata.name,
description: copy.description, description: siteMetadata.description,
imageUrl, imageUrl,
pageUrl, pageUrl,
canonicalUrl,
}); });
const siteMetadataBlock = buildSiteMetadataTags(siteMetadata);
let html = await loadIndexHtml(); let html = await loadIndexHtml();
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`); /*
Prefer the explicit marker so the insertion point remains stable across
Vite output changes. The closing-head fallback also keeps deployed builds
made before the marker was introduced compatible with the runtime loader.
*/
if (html.includes(ANALYTICS_PLACEHOLDER)) {
html = html.replace(ANALYTICS_PLACEHOLDER, analyticsHeadHtml);
} else if (analyticsHeadHtml) {
html = html.replace('</head>', ` ${analyticsHeadHtml}\n </head>`);
}
/*
Keeping all instance-specific head values behind one marker prevents the
built index from carrying a second set of hardcoded titles and colors.
The fallback supports an older built index during a rolling deployment.
*/
if (html.includes(SITE_METADATA_PLACEHOLDER)) {
html = html.replace(SITE_METADATA_PLACEHOLDER, siteMetadataBlock);
} else {
html = html.replace('</head>', ` ${siteMetadataBlock}\n </head>`);
}
if (html.includes('<!-- embed meta -->')) { if (html.includes('<!-- embed meta -->')) {
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock); html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
} else { } else {
@@ -190,7 +282,7 @@ async function renderIndexHtml(req) {
return html; return html;
} }
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) { function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame, accentColor, accentTextColor }) {
const titleSize = 64; const titleSize = 64;
const subtitleSize = 34; const subtitleSize = 34;
const statsSize = 30; const statsSize = 30;
@@ -207,8 +299,8 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
</defs> </defs>
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" /> <rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" /> <rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" /> <rect x="58" y="50" width="206" height="36" rx="18" fill="${accentColor}" />
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018"> <text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="${accentTextColor}">
${escapeXml(badgeText)} ${escapeXml(badgeText)}
</text> </text>
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff"> <text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
@@ -235,6 +327,7 @@ async function renderOgImage() {
}; };
const camera = getPrimaryRoomCamera(); const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera); const copy = buildEmbedCopy(state, camera);
const siteMetadata = resolveSiteMetadata();
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id); const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
const frame = cameraState?.frame || null; const frame = cameraState?.frame || null;
const hasFrame = Boolean(frame); const hasFrame = Boolean(frame);
@@ -246,17 +339,19 @@ async function renderOgImage() {
width: OG_WIDTH, width: OG_WIDTH,
height: OG_HEIGHT, height: OG_HEIGHT,
channels: 3, channels: 3,
background: BASE_BG, background: siteMetadata.backgroundColor,
}, },
}); });
const overlaySvg = Buffer.from( const overlaySvg = Buffer.from(
buildOverlaySvg({ buildOverlaySvg({
title: copy.title, title: siteMetadata.name,
subtitle: copy.subtitle, subtitle: copy.subtitle,
stats: copy.stats, stats: copy.stats,
cameraLabel: copy.cameraLabel, cameraLabel: copy.cameraLabel,
hasFrame, hasFrame,
accentColor: siteMetadata.accentColor,
accentTextColor: siteMetadata.accentTextColor,
}), }),
); );
@@ -272,7 +367,36 @@ async function renderOgImage() {
return base.composite(composite).png().toBuffer(); return base.composite(composite).png().toBuffer();
} }
function renderWebManifest() {
const siteMetadata = resolveSiteMetadata();
/*
The manifest is generated from the same resolved values as the HTML and
social image, so browser tabs, installed shortcuts, and launch screens do
not drift into three separately configured identities.
*/
return JSON.stringify({
name: siteMetadata.name,
short_name: siteMetadata.shortName,
description: siteMetadata.description,
start_url: '/',
scope: '/',
display: 'standalone',
background_color: siteMetadata.backgroundColor,
theme_color: siteMetadata.accentColor,
icons: [
{
src: '/bitmap.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
],
});
}
module.exports = { module.exports = {
renderIndexHtml, renderIndexHtml,
renderOgImage, renderOgImage,
renderWebManifest,
}; };
+3 -4
View File
@@ -4,15 +4,14 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bitmap.png" /> <link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" /> <link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" /> <!-- The server renders this manifest so installed shortcuts use the local instance's configured branding. -->
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. --> <!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <!-- site-metadata:inject -->
<!-- analytics:inject --> <!-- analytics:inject -->
<title>Roomba Rover</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-18
View File
@@ -1,18 +0,0 @@
{
"name": "Multi Roomba Rover",
"short_name": "MRR",
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#020617",
"icons": [
{
"src": "/bitmap.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+9 -41
View File
@@ -58,7 +58,6 @@ import {
themeGapClass, themeGapClass,
themeStackClass, themeStackClass,
} from './themes/index.js'; } from './themes/index.js';
import { trackAnalyticsEvent } from './analytics/index.js';
import useLayoutMode from './hooks/useLayoutMode.js'; import useLayoutMode from './hooks/useLayoutMode.js';
function DesktopLayout({ layout, onOpenHelpOverlay }) { function DesktopLayout({ layout, onOpenHelpOverlay }) {
@@ -117,15 +116,9 @@ function MobileFeatureTabs({
}); });
const handleTabChange = useCallback( const handleTabChange = useCallback(
(tab) => { (tab) => {
/*
Tab changes are one of the highest-signal UI events because the app is a
dense single-page control surface. Recording the selected panel gives
Umami useful journeys without tracking every button inside each panel.
*/
setActiveTab(tab); setActiveTab(tab);
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'mobile_features' });
}, },
[layout], [],
); );
return ( return (
<section className="text-base"> <section className="text-base">
@@ -342,71 +335,46 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
} }
}, [quickstartStatus, quickstartSettings?.showOnLoad]); }, [quickstartStatus, quickstartSettings?.showOnLoad]);
useEffect(() => {
if (!fullscreenVisible) return;
trackAnalyticsEvent('fullscreen_prompt_show', { layout, mode: fullscreenMode });
}, [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' }); }, []);
}, [layout]);
const closeHelp = useCallback(() => { const closeHelp = useCallback(() => {
setHelpVisible(false); setHelpVisible(false);
trackAnalyticsEvent('help_close', { layout }); }, []);
}, [layout]);
const closeQuickstart = useCallback(() => { const closeQuickstart = useCallback(() => {
setQuickstartVisible(false); setQuickstartVisible(false);
trackAnalyticsEvent('quickstart_close', { layout }); }, []);
}, [layout]);
const handleFloatingFullscreen = useCallback(async () => { const handleFloatingFullscreen = useCallback(async () => {
if (fullscreenIsIOS) { if (fullscreenIsIOS) {
trackAnalyticsEvent('fullscreen_prompt_manual_open', { layout, mode: 'pwa-hint', source: 'floating_button' });
showPrompt(); showPrompt();
return; return;
} }
const entered = await enterFullscreen(); const entered = await enterFullscreen();
trackAnalyticsEvent(entered ? 'fullscreen_enter' : 'fullscreen_enter_failed', {
layout,
source: 'floating_button',
});
if (!entered) { if (!entered) {
showPrompt(); showPrompt();
} }
}, [enterFullscreen, fullscreenIsIOS, layout, showPrompt]); }, [enterFullscreen, fullscreenIsIOS, showPrompt]);
const setQuickstartShowOnLoad = useCallback( const setQuickstartShowOnLoad = useCallback(
(enabled) => { (enabled) => {
const next = Boolean(enabled); const next = Boolean(enabled);
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next })); saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
trackAnalyticsEvent('quickstart_show_on_load_change', { layout, enabled: next });
if (!next) { if (!next) {
setQuickstartVisible(false); setQuickstartVisible(false);
} }
}, },
[layout, saveQuickstartSettings], [saveQuickstartSettings],
); );
const openHelpFromQuickstart = useCallback(() => { const openHelpFromQuickstart = useCallback(() => {
setQuickstartVisible(false); setQuickstartVisible(false);
setHelpVisible(true); setHelpVisible(true);
trackAnalyticsEvent('help_open', { layout, source: 'quickstart' }); }, []);
}, [layout]);
const handleFullscreenPromptEnter = useCallback(async () => { const handleFullscreenPromptEnter = useCallback(async () => {
const entered = await enterFullscreen(); const entered = await enterFullscreen();
trackAnalyticsEvent(entered ? 'fullscreen_enter' : 'fullscreen_enter_failed', {
layout,
source: 'prompt',
});
return entered; return entered;
}, [enterFullscreen, layout]); }, [enterFullscreen]);
const handleFullscreenPromptDismiss = useCallback(() => { const handleFullscreenPromptDismiss = useCallback(() => {
dismiss(); dismiss();
trackAnalyticsEvent('fullscreen_dismiss', { layout, mode: fullscreenMode }); }, [dismiss]);
}, [dismiss, fullscreenMode, layout]);
const renderedLayout = useMemo( const renderedLayout = useMemo(
() => () =>
+7 -32
View File
@@ -1,12 +1,11 @@
// Analytics Reporter // Analytics Reporter
// Purpose: Publishes page/session context to the optional build-time analytics // Purpose: Publishes page/session context to the optional runtime analytics
// adapter. Scope: observes route, layout, nickname, role, verification, and // adapter. Scope: observes route, layout, nickname, role, verification, and
// rover assignment without owning any analytics vendor implementation. // rover assignment without owning any analytics vendor implementation.
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { identifyAnalyticsSession, trackAnalyticsEvent } from './index.js'; import { identifyAnalyticsSession } from './index.js';
function detectLayout() { function detectLayout() {
if (typeof window === 'undefined') return 'desktop'; if (typeof window === 'undefined') return 'desktop';
@@ -31,15 +30,10 @@ function useAnalyticsLayout() {
} }
export default function AnalyticsReporter() { export default function AnalyticsReporter() {
const location = useLocation();
const layout = useAnalyticsLayout(); const layout = useAnalyticsLayout();
const { value: profile } = useSettingsNamespace('profile', { nickname: '' }); const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
const session = useSessionSelector((state) => state.session); const session = useSessionSelector((state) => state.session);
const previousRouteRef = useRef(null);
const previousLayoutRef = useRef(null);
const previousRoverRef = useRef(null);
const previousIdentityRef = useRef(''); const previousIdentityRef = useRef('');
const route = location.pathname || '/';
const nickname = String(profile?.nickname || session?.nickname || '').trim(); const nickname = String(profile?.nickname || session?.nickname || '').trim();
const roverId = String(session?.assignment?.roverId || '').trim(); const roverId = String(session?.assignment?.roverId || '').trim();
const role = String(session?.role || '').trim(); const role = String(session?.role || '').trim();
@@ -47,16 +41,14 @@ export default function AnalyticsReporter() {
const identity = useMemo( const identity = useMemo(
() => ({ () => ({
route,
layout, layout,
nickname,
hasNickname: Boolean(nickname), hasNickname: Boolean(nickname),
roverId, roverId,
assignedRover: Boolean(roverId), assignedRover: Boolean(roverId),
role, role,
verified, verified,
}), }),
[layout, nickname, role, route, roverId, verified], [layout, nickname, role, roverId, verified],
); );
useEffect(() => { useEffect(() => {
@@ -65,30 +57,13 @@ export default function AnalyticsReporter() {
previousIdentityRef.current = serialized; previousIdentityRef.current = serialized;
/* /*
This pushes the current browser/session context into the injected adapter. Session properties describe durable segmentation dimensions. Route is
The adapter is responsible for applying build-time privacy/config choices, intentionally absent because Umami attaches the current URL to pageviews
such as whether nickname and rover id should be sent to Umami. and events automatically, and nickname is reduced to a non-identifying
boolean so aggregate analytics does not store user-entered names.
*/ */
identifyAnalyticsSession(identity); identifyAnalyticsSession(identity);
}, [identity]); }, [identity]);
useEffect(() => {
if (previousRouteRef.current === route) return;
previousRouteRef.current = route;
trackAnalyticsEvent('route_enter', { route, layout });
}, [layout, route]);
useEffect(() => {
if (previousLayoutRef.current === layout) return;
previousLayoutRef.current = layout;
trackAnalyticsEvent('layout_change', { route, layout });
}, [layout, route]);
useEffect(() => {
if (!roverId || previousRoverRef.current === roverId) return;
previousRoverRef.current = roverId;
trackAnalyticsEvent('rover_assigned', { roverId, route, layout });
}, [layout, route, roverId]);
return null; return null;
} }
+1 -20
View File
@@ -1,13 +1,12 @@
// Analytics Bridge // Analytics Bridge
// Purpose: Gives React a tiny, provider-neutral analytics surface. Scope: // Purpose: Gives React a tiny, provider-neutral analytics surface. Scope:
// forwards optional app events to a build-time injected browser adapter without // forwards optional app events to a runtime-injected browser adapter without
// importing Umami, embedding website ids, or making rover controls depend on // importing Umami, embedding website ids, or making rover controls depend on
// analytics availability. // analytics availability.
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;
@@ -101,21 +100,3 @@ 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,7 +9,6 @@ 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' },
@@ -161,23 +160,17 @@ 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);
} }
}; };
@@ -187,12 +180,9 @@ 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 }));
@@ -206,7 +196,6 @@ 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
@@ -214,9 +203,7 @@ 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 }));
@@ -232,22 +219,15 @@ export default function AdminPanelContent() {
); );
if (!ok) return; if (!ok) return;
trackAnalyticsEvent('rover_update_all_click', { roverCount });
try { try {
// The server owns the fan-out because it has the authoritative online // The server owns the fan-out because it has the authoritative online
// rover map and can enforce admin privileges once before issuing the // rover map and can enforce admin privileges once before issuing the
// existing per-rover update command to every connected rover. // existing per-rover update command to every connected rover.
const result = await updateAllRovers(); 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) { if (result?.failed?.length) {
alert(`Update requested for ${result.updated?.length || 0} rover(s). ${result.failed.length} rover(s) failed to queue.`); alert(`Update requested for ${result.updated?.length || 0} rover(s). ${result.failed.length} rover(s) failed to queue.`);
} }
} catch (err) { } catch (err) {
trackAnalyticsEvent('rover_update_all_result', { status: 'failed', reason: err?.message || 'unknown' });
alert(err.message); 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.'); 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,10 +1,9 @@
// 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, useRef, useState } from 'react'; import { useEffect, useMemo, 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 { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js'; import { isFeatureEnabled } from '../../lib/features.js';
@@ -261,10 +260,8 @@ export default function BarcodeGamesPanel() {
} }
function BarcodeGamesPanelContent() { function BarcodeGamesPanelContent() {
const { state, connectionState, voteForGame } = useBarcodeGameState(); const { state, 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.
@@ -278,77 +275,10 @@ function BarcodeGamesPanelContent() {
const participants = Array.isArray(state.participants) ? state.participants : []; const participants = Array.isArray(state.participants) ? state.participants : [];
const activeTheme = getGameTheme(activeGame?.themeColor); 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) => { const handleVote = async (gameId) => {
setPendingGameId(gameId); setPendingGameId(gameId);
trackAnalyticsEvent('barcode_game_vote', {
gameId,
phase: state.phase || 'unknown',
status: 'started',
});
try { try {
await voteForGame(gameId); 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 { } finally {
setPendingGameId(null); setPendingGameId(null);
} }
@@ -8,7 +8,6 @@ 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';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js'; import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
function StatusRow({ value, tone = 'neutral' }) { function StatusRow({ value, tone = 'neutral' }) {
@@ -129,14 +128,11 @@ export default function DriveDockAction({
if (!roverId || pending) return; if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button'); if (isMobile) triggerTouchHaptic('button');
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);
@@ -149,14 +145,11 @@ 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);
@@ -167,12 +160,9 @@ export default function DriveDockAction({
if (!roverId || pending) return; if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button'); if (isMobile) triggerTouchHaptic('button');
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);
@@ -186,12 +176,10 @@ export default function DriveDockAction({
if (isMobile) triggerTouchHaptic('button'); if (isMobile) triggerTouchHaptic('button');
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,7 +9,6 @@ import GPIOToggleControl from '../GPIOToggleControl/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';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
const CAMERA_TILT_STEP_DEGREES = 0.5; const CAMERA_TILT_STEP_DEGREES = 0.5;
@@ -56,25 +55,22 @@ function AuxColumnContent() {
const handleHeadlightToggle = useCallback( const handleHeadlightToggle = useCallback(
(nextOn) => { (nextOn) => {
if (!headlightAvailable) return; if (!headlightAvailable) return;
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
setHeadlight(nextOn); setHeadlight(nextOn);
}, },
[headlightAvailable, roverId, setHeadlight], [headlightAvailable, setHeadlight],
); );
const handleLaserToggle = useCallback( const handleLaserToggle = useCallback(
(nextOn) => { (nextOn) => {
if (!laserAvailable) return; if (!laserAvailable) return;
trackAnalyticsEvent('laser_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
setLaser(nextOn); setLaser(nextOn);
}, },
[laserAvailable, roverId, setLaser], [laserAvailable, setLaser],
); );
const handleHornStart = useCallback(() => { const handleHornStart = useCallback(() => {
trackAnalyticsEvent('horn_start', { roverId, source: 'mobile_control' });
return startHorn(); return startHorn();
}, [roverId, startHorn]); }, [startHorn]);
const handleAuxPress = useCallback( const handleAuxPress = useCallback(
(id, values) => { (id, values) => {
+1 -9
View File
@@ -22,7 +22,6 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js'; import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js'; import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js'; import { isFeatureEnabled } from '../../lib/features.js';
import { trackAnalyticsEvent } from '../../analytics/index.js';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js'; import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.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 featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified)); const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
@@ -886,7 +885,6 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
return; return;
} }
setPending(true); setPending(true);
trackAnalyticsEvent('ptz_queue_join', { layout });
try { try {
const response = await ptzClaim(); const response = await ptzClaim();
/* /*
@@ -897,13 +895,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
if (response?.state?.isOperator || response?.state?.queuedPosition) { if (response?.state?.isOperator || response?.state?.queuedPosition) {
navigate('/ptz'); navigate('/ptz');
} }
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
} catch (err) { } catch (err) {
trackAnalyticsEvent('ptz_queue_join_result', {
layout,
status: 'failed',
reason: err?.message || 'unknown',
});
alert(err.message || 'PTZ request failed.'); alert(err.message || 'PTZ request failed.');
} finally { } finally {
setPending(false); setPending(false);
+9 -15
View File
@@ -36,7 +36,6 @@ import CardFrame from '../CardFrame/index.jsx';
import { useCallback, useLayoutEffect, useMemo, 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 '../../themes/index.js'; import { themeGapClass, themeStackClass } from '../../themes/index.js';
import { trackAnalyticsEvent } from '../../analytics/index.js';
const CHAT_DOCK_INITIAL_HEIGHT = 224; const CHAT_DOCK_INITIAL_HEIGHT = 224;
const CHAT_DOCK_MIN_HEIGHT = 144; const CHAT_DOCK_MIN_HEIGHT = 144;
@@ -103,23 +102,20 @@ function DriveDockPanel() {
const cameraTiltStep = camera?.precisionMode const cameraTiltStep = camera?.precisionMode
? CAMERA_TILT_PRECISION_STEP_DEGREES ? CAMERA_TILT_PRECISION_STEP_DEGREES
: CAMERA_TILT_STEP_DEGREES; : CAMERA_TILT_STEP_DEGREES;
const trackedControls = useMemo( const auxControls = useMemo(
() => ({ () => ({
setHeadlight: (nextOn) => { setHeadlight: (nextOn) => {
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
setHeadlight(nextOn); setHeadlight(nextOn);
}, },
setLaser: (nextOn) => { setLaser: (nextOn) => {
trackAnalyticsEvent('laser_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
setLaser(nextOn); setLaser(nextOn);
}, },
startHorn: () => { startHorn: () => {
trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' });
return startHorn(); return startHorn();
}, },
stopHorn, stopHorn,
}), }),
[roverId, setHeadlight, setLaser, startHorn, stopHorn], [setHeadlight, setLaser, startHorn, stopHorn],
); );
return ( return (
@@ -139,7 +135,7 @@ function DriveDockPanel() {
label="Headlight" label="Headlight"
on={headlightState?.headlightOn} on={headlightState?.headlightOn}
disabled={!roverId} disabled={!roverId}
onToggle={trackedControls.setHeadlight} onToggle={auxControls.setHeadlight}
keyLabel={headlightLabel} keyLabel={headlightLabel}
/> />
)} )}
@@ -148,7 +144,7 @@ function DriveDockPanel() {
label="Laser" label="Laser"
on={laserState?.laserOn} on={laserState?.laserOn}
disabled={!roverId || roomLightsLockedOn} disabled={!roverId || roomLightsLockedOn}
onToggle={trackedControls.setLaser} onToggle={auxControls.setLaser}
keyLabel={laserLabel} keyLabel={laserLabel}
/> />
)} )}
@@ -157,8 +153,8 @@ function DriveDockPanel() {
{hornAvailable && ( {hornAvailable && (
<HornControl <HornControl
disabled={!roverId || hornBlocked} disabled={!roverId || hornBlocked}
onStart={trackedControls.startHorn} onStart={auxControls.startHorn}
onStop={trackedControls.stopHorn} onStop={auxControls.stopHorn}
keyLabel={hornLabel} keyLabel={hornLabel}
active={horn?.active} active={horn?.active}
heat={horn?.heat} heat={horn?.heat}
@@ -259,14 +255,12 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const handleTabChange = useCallback( const handleTabChange = useCallback(
(tab) => { (tab) => {
/* /*
Desktop users spend most of their time on this one route, so panel Keep the selected desktop panel controlled here so the tab strip and
changes are the cleanest way to understand feature usage without adding panel content always move together.
analytics calls to every nested control in the rover dashboard.
*/ */
setActiveTab(tab); setActiveTab(tab);
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'desktop_right_pane' });
}, },
[layout], [],
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -10,7 +10,6 @@ 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,
@@ -439,20 +438,6 @@ 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();
} }
}; };
@@ -469,15 +454,6 @@ 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();
}); });
@@ -168,22 +168,13 @@ export default function RoverQueuesPanel({
return; return;
} }
setPending((prev) => ({ ...prev, [targetRoverId]: true })); setPending((prev) => ({ ...prev, [targetRoverId]: true }));
trackAnalyticsEvent('rover_queue_join', { trackAnalyticsEvent('rover_selected', {
roverId: targetRoverId, roverId: targetRoverId,
alreadyAssigned: assignedRoverId === String(targetRoverId), alreadySelected: 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 }));
@@ -198,18 +189,10 @@ export default function RoverQueuesPanel({
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);
@@ -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 { 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';
import { import {
DEFAULT_PAGE_THEME_KEY, DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS, PAGE_THEME_OPTIONS,
@@ -196,7 +195,6 @@ 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 });
reconnectSocketWithTransport(socket, next); reconnectSocketWithTransport(socket, next);
}; };
@@ -204,30 +202,21 @@ 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 handleInterInstanceTransferSettings = (event) => { const handleInterInstanceTransferSettings = (event) => {
@@ -238,7 +227,6 @@ export default function SettingsPanel() {
letting users opt out of sending their current settings cookie. letting users opt out of sending their current settings cookie.
*/ */
savePageSettings((current) => ({ ...(current ?? {}), interInstanceTransferSettings: checked })); savePageSettings((current) => ({ ...(current ?? {}), interInstanceTransferSettings: checked }));
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
}; };
const movePageThemePreview = (direction) => { const movePageThemePreview = (direction) => {
@@ -261,10 +249,6 @@ export default function SettingsPanel() {
...(current ?? {}), ...(current ?? {}),
backgroundTheme: previewPageThemeKey, backgroundTheme: previewPageThemeKey,
})); }));
trackAnalyticsEvent('settings_change', {
setting: 'backgroundTheme',
value: previewPageThemeKey,
});
}; };
const handleVideoFilterChange = (event) => { const handleVideoFilterChange = (event) => {
@@ -276,7 +260,6 @@ export default function SettingsPanel() {
...(current ?? {}), ...(current ?? {}),
colorFilter: nextFilter, colorFilter: nextFilter,
})); }));
trackAnalyticsEvent('settings_change', { setting: 'videoColorFilter', value: nextFilter });
}; };
return ( return (
+2 -5
View File
@@ -40,14 +40,11 @@ export default function SocialButton({ id = null, label, url, icon, color, layou
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={text} aria-label={text}
onClick={() => { onClick={() => {
trackAnalyticsEvent('social_link_click', { trackAnalyticsEvent('social_link_clicked', {
id: id || '', destination: id || 'custom',
label: text, label: text,
layout, 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,6 +1,5 @@
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;
@@ -42,39 +41,23 @@ export default function SocketConnectionPill() {
setLastReason(''); setLastReason('');
show(); show();
scheduleFade(); scheduleFade();
trackAnalyticsEvent('socket_connect', { connected: true });
}; };
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',
{ connected: false },
{ key: 'socket_reconnect_attempt', throttleMs: 30 * 1000 },
);
}; };
socket.on('connect', onConnect); socket.on('connect', onConnect);
@@ -4,7 +4,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { useSession } from '../../context/SessionContext.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 Sliders are a 0-1 fraction of whichever ceiling the server resolved for this
@@ -87,11 +86,6 @@ export default function VolumeSettingsCard() {
setDraft(nextDraft); setDraft(nextDraft);
if (commitTimerRef.current) clearTimeout(commitTimerRef.current); if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS); 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. // 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 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 = '',
@@ -88,9 +87,8 @@ 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 });
}, },
[roverId, saveVipAudio], [saveVipAudio],
); );
const handleUploadPlay = async () => { const handleUploadPlay = async () => {
@@ -109,11 +107,6 @@ 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));
@@ -124,9 +117,7 @@ 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);
@@ -141,13 +132,10 @@ 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);
@@ -378,24 +366,20 @@ 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);
@@ -407,10 +391,8 @@ 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.');
} }
}, },
@@ -426,7 +408,6 @@ 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(() => {
@@ -453,17 +434,11 @@ 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);
} }
@@ -651,7 +626,6 @@ export default function VipAudioUploadCard({
onChange={(event) => { onChange={(event) => {
const enabled = Boolean(event.target.checked); const enabled = Boolean(event.target.checked);
saveVipAudio((current) => ({ ...(current || {}), openMicEnabled: enabled })); 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>
@@ -5,7 +5,6 @@ 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 = [],
@@ -30,24 +29,14 @@ 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,7 +5,6 @@ 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,
@@ -26,7 +25,6 @@ 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 = () => {
@@ -46,10 +44,6 @@ 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?.();
@@ -57,9 +51,7 @@ 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);
-56
View File
@@ -1,56 +0,0 @@
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
<script>
/*
Example analytics adapter. React talks only to window.roverAnalytics, so
this file owns the provider-specific forwarding without app-side Umami code.
*/
(function () {
var pendingCalls = [];
var flushTimer = null;
function callUmami(method, args) {
if (!window.umami || typeof window.umami[method] !== 'function') return false;
window.umami[method].apply(window.umami, args);
return true;
}
function flushPendingCalls() {
if (!pendingCalls.length) return;
if (!window.umami) return;
pendingCalls = pendingCalls.filter(function (call) {
return !callUmami(call.method, call.args);
});
if (!pendingCalls.length && flushTimer) {
window.clearInterval(flushTimer);
flushTimer = null;
}
}
function enqueue(method, args) {
if (callUmami(method, args)) return;
pendingCalls.push({ method: method, args: args });
if (!flushTimer) {
flushTimer = window.setInterval(flushPendingCalls, 500);
}
}
window.roverAnalytics = {
track: function (name, data) {
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
},
identify: function (data) {
enqueue('identify', [data || {}]);
},
};
window.addEventListener('load', flushPendingCalls);
})();
</script>
<!-- otterlytics testing for blocking local -->
<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>
+2 -2
View File
@@ -235,8 +235,8 @@ export function ChatProvider({ children }) {
} else { } else {
/* /*
Count successful chat sends without forwarding chat text. The Count successful chat sends without forwarding chat text. The
centralized analytics session identity can still attach nickname centralized analytics session identity adds aggregate role and
when the build-time adapter is configured to include it. assignment context without attaching message text or nickname.
*/ */
trackAnalyticsEvent('chat_send', { trackAnalyticsEvent('chat_send', {
hasTts: Boolean(tts), hasTts: Boolean(tts),
+16
View File
@@ -32,6 +32,7 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js'; import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import { useOvercurrentLimiter } from './overcurrentLimiter.js'; import { useOvercurrentLimiter } from './overcurrentLimiter.js';
import { usePtzControlAdapter } from './ptzControlAdapter.js'; import { usePtzControlAdapter } from './ptzControlAdapter.js';
import { trackAnalyticsEvent } from '../analytics/index.js';
const ControlSystemContext = createContext(null); const ControlSystemContext = createContext(null);
@@ -283,6 +284,13 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/record-intent' }); dispatch({ type: 'control/record-intent' });
}, []); }, []);
const driveEngagementRoverRef = useRef(null);
useEffect(() => {
/* A new assignment gets one meaningful movement conversion event. */
driveEngagementRoverRef.current = null;
}, [pipeline.roverId]);
const setDriveVector = useCallback( const setDriveVector = useCallback(
(vector, meta = {}) => { (vector, meta = {}) => {
const speedOptions = { ...(meta.speedOptions || {}) }; const speedOptions = { ...(meta.speedOptions || {}) };
@@ -302,6 +310,14 @@ export function ControlSystemProvider({ children }) {
); );
} }
const computed = computeDifferentialSpeeds(vector, speedOptions); const computed = computeDifferentialSpeeds(vector, speedOptions);
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
driveEngagementRoverRef.current = pipeline.roverId;
trackAnalyticsEvent('drive_engaged', {
roverId: pipeline.roverId,
source: meta.source || 'unknown',
});
}
dispatch({ dispatch({
type: 'control/update-drive', type: 'control/update-drive',
payload: { ...computed, source: meta.source ?? null }, payload: { ...computed, source: meta.source ?? null },
@@ -12,7 +12,6 @@ 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 };
@@ -281,7 +280,6 @@ 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');
@@ -290,21 +288,18 @@ 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.headlightToggle && handleButtonEdge('headlightToggle', true)) { if (outputs.buttons.headlightToggle && handleButtonEdge('headlightToggle', true)) {
trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
latest.toggleHeadlight(); latest.toggleHeadlight();
} else if (!outputs.buttons.headlightToggle) { } else if (!outputs.buttons.headlightToggle) {
handleButtonEdge('headlightToggle', false); handleButtonEdge('headlightToggle', false);
} }
if (outputs.buttons.laserToggle && handleButtonEdge('laserToggle', true)) { if (outputs.buttons.laserToggle && handleButtonEdge('laserToggle', true)) {
trackAnalyticsEvent('laser_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
latest.toggleLaser(); latest.toggleLaser();
} else if (!outputs.buttons.laserToggle) { } else if (!outputs.buttons.laserToggle) {
handleButtonEdge('laserToggle', false); handleButtonEdge('laserToggle', false);
@@ -24,7 +24,6 @@ import {
isPrecisionDriveActive, isPrecisionDriveActive,
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 };
@@ -445,18 +444,14 @@ 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.headlightToggle?.has(token))) { } else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
latest.toggleHeadlight(); latest.toggleHeadlight();
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) { } else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
trackAnalyticsEvent('laser_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
latest.toggleLaser(); latest.toggleLaser();
} else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) { } else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
cycleVideoFilter(); cycleVideoFilter();
+16 -25
View File
@@ -19,9 +19,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
{ enabled: false }, { enabled: false },
); );
const inFlightRef = useRef(false);
const lastAckSocketRef = useRef(null); const lastAckSocketRef = useRef(null);
const retryTimerRef = useRef(null);
const fingerprintRef = useRef(''); const fingerprintRef = useRef('');
const ready = const ready =
@@ -31,16 +29,8 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
const overseerEnabled = Boolean(overseerPreference?.enabled); const overseerEnabled = Boolean(overseerPreference?.enabled);
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive'; const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
const clearRetry = useCallback(() => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const sendIdentify = useCallback(async () => { const sendIdentify = useCallback(async () => {
if (!ready || !connected || !socket?.id || inFlightRef.current) return; if (!ready || !connected || !socket?.id) return;
inFlightRef.current = true;
try { try {
if (!fingerprintRef.current) { if (!fingerprintRef.current) {
/* /*
@@ -68,17 +58,14 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey })); saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
} }
lastAckSocketRef.current = socket.id; lastAckSocketRef.current = socket.id;
clearRetry();
} catch { } catch {
clearRetry(); /*
retryTimerRef.current = setTimeout(() => { The permanent heartbeat below is the retry mechanism. A failed or
sendIdentify(); half-open request must not create separate timer state that can stop
}, 2000); future identity sends or disappear during a socket transition.
} finally { */
inFlightRef.current = false;
} }
}, [ }, [
clearRetry,
connected, connected,
cookieUserId, cookieUserId,
identifySession, identifySession,
@@ -87,7 +74,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
overseerEnabled, overseerEnabled,
ready, ready,
saveIdentity, saveIdentity,
socket?.id, socket,
]); ]);
useEffect(() => { useEffect(() => {
@@ -120,13 +107,17 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
}, [sendIdentify, socket?.connected]); }, [sendIdentify, socket?.connected]);
useEffect(() => { useEffect(() => {
if (!ready || !socket?.connected) return undefined; /*
Keep this interval installed whenever persisted identity settings are
ready, including while Socket.IO is reconnecting. Each tick checks the
current connection before sending, so reconnects resume heartbeats without
depending on an acknowledgement or another effect recreating the timer.
*/
if (!ready) return undefined;
const timer = setInterval(() => { const timer = setInterval(() => {
if (!socket?.connected) return; if (!socket?.connected) return;
sendIdentify(); sendIdentify();
}, 60000); }, 2000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [ready, sendIdentify, socket?.connected]); }, [ready, sendIdentify, socket]);
useEffect(() => () => clearRetry(), [clearRetry]);
} }
-18
View File
@@ -2,7 +2,6 @@
// 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;
@@ -96,23 +95,6 @@ export function useVideoRequests(sourceList = [], options = {}) {
: { roverId: 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 }));
}); });
} }
-2
View File
@@ -20,7 +20,6 @@ import DatabaseAdminApp from './database/DatabaseAdminApp.jsx'
import { SettingsProvider } from './settings/index.js' import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx' import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx' import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import SessionDocumentTitle from './components/SessionDocumentTitle/index.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx' import PtzAppRoot from './ptz/PtzAppRoot.jsx'
// The reporting route includes the charting and CSV libraries. Loading that // The reporting route includes the charting and CSV libraries. Loading that
@@ -32,7 +31,6 @@ createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<SocketProvider> <SocketProvider>
<SessionProvider> <SessionProvider>
<SessionDocumentTitle />
<TelemetryProvider> <TelemetryProvider>
<SettingsProvider> <SettingsProvider>
<ChatProvider> <ChatProvider>
@@ -9,7 +9,6 @@ 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,
@@ -141,9 +140,6 @@ export default function ScannerContent() {
lastReceivedAt = Date.now(); lastReceivedAt = Date.now();
if (!scannerReadyTrackedRef.current) { if (!scannerReadyTrackedRef.current) {
scannerReadyTrackedRef.current = true; scannerReadyTrackedRef.current = true;
trackAnalyticsEvent('scanner_route_ready', {
beepAllowed: Boolean(nextState?.beepAllowed),
});
} }
setScannerState({ setScannerState({
...EMPTY_SCANNER_STATE, ...EMPTY_SCANNER_STATE,
@@ -158,9 +154,6 @@ 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() {
@@ -206,13 +199,6 @@ 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),
@@ -261,10 +247,6 @@ 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();
+1 -15
View File
@@ -1,23 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import fs from 'node:fs'
const ANALYTICS_PLACEHOLDER = '<!-- analytics:inject -->'
function analyticsTags() {
return {
name: 'analytics-tags',
transformIndexHtml(html) {
const tagsPath = new URL('./src/config/analytics.html', import.meta.url)
const tags = fs.existsSync(tagsPath) ? fs.readFileSync(tagsPath, 'utf8').trim() : ''
return html.replace(ANALYTICS_PLACEHOLDER, tags)
},
}
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), analyticsTags()], plugins: [react()],
server: { server: {
// Listen on every local interface during development so a phone or tablet // Listen on every local interface during development so a phone or tablet
// on the same LAN can load the touch UI using this machine's network IP. // on the same LAN can load the touch UI using this machine's network IP.