optional but always on transfer

This commit is contained in:
legop3
2026-07-08 12:53:39 -04:00
parent 5d60544904
commit a6f6ccf079
12 changed files with 253 additions and 178 deletions
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
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/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-B3q8p1Xf.js"></script>
<script type="module" crossorigin src="/assets/index-htN8tzuK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfZ_TWsb.css">
</head>
<body>
+6 -2
View File
@@ -1,7 +1,11 @@
1. improve spectator page, options on what to see and what not to see
2. assign rovers based on battery percentage, give people highest one
3. add more background gap themes
4. fix this:
3. add prefix config for discord bot to replace rs with something else
4. add config to disable client snapshot forcing, disable bandwidth saving
5. add admin ui for VIP and private requests instead of only through discord
6. make discord bots that can be fine with multiple in one server
7. add more background gap themes
8. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^
@@ -5,8 +5,9 @@ import { useMemo, useState } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
import { openExternalRoverWithPrompt } from '../../lib/interInstanceTransfer.js';
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { useSettingsNamespace } from '../../settings/index.js';
function classNames(...values) {
return values.filter(Boolean).join(' ');
@@ -72,6 +73,8 @@ function InstancePanel({ remote, children = null }) {
const features = featureEntries(instance.features);
const color = instance.color || '#64748b';
const online = Boolean(remote?.online);
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
const includeSettings = pageSettings?.interInstanceTransferSettings !== false;
return (
<CardFrame
title={instance.name || remote.url || 'External server'}
@@ -92,7 +95,11 @@ function InstancePanel({ remote, children = null }) {
</div>
<div className="flex flex-wrap items-center justify-center gap-1 text-center">
<InstanceStatus remote={remote} />
<button type="button" className="button-dark" onClick={() => openExternalRoverWithPrompt(remote, '')}>
<button
type="button"
className="button-dark"
onClick={() => openExternalRover(remote, '', { includeSettings })}
>
Visit server
</button>
</div>
@@ -7,9 +7,10 @@ import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx';
import RoverLabel from '../RoverLabel/index.jsx';
import { trackAnalyticsEvent } from '../../analytics/index.js';
import { openExternalRoverWithPrompt } from '../../lib/interInstanceTransfer.js';
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
import { ExternalInstancesCompact } from '../InterInstancePanel/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import { useSettingsNamespace } from '../../settings/index.js';
function classNames(...values) {
return values.filter(Boolean).join(' ');
@@ -62,6 +63,7 @@ export default function RoverQueuesPanel({
const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const localUsers = useSessionSelector((state) => state.session?.users ?? []);
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const assignedRoverName = useSessionSelector((state) => {
@@ -75,6 +77,7 @@ export default function RoverQueuesPanel({
const [rebootPending, setRebootPending] = useState(false);
const externalMode = Boolean(externalInstance);
const externalBlocked = Boolean(externalMode && disabledOverlay);
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
const turnQueues = turnQueuesOverride && typeof turnQueuesOverride === 'object' ? turnQueuesOverride : localTurnQueues;
const users = Array.isArray(usersOverride) ? usersOverride : localUsers;
@@ -113,9 +116,10 @@ export default function RoverQueuesPanel({
/*
External queue cards deliberately reuse the local row layout, but their
action cannot go through this Socket.IO server. The row opens the remote
instance, optionally carrying settings after the source-page prompt.
instance and follows the saved Page setting for cookie/settings transfer
instead of interrupting each click with a confirmation popup.
*/
openExternalRoverWithPrompt(externalInstance, targetRoverId);
openExternalRover(externalInstance, targetRoverId, { includeSettings: includeInterInstanceSettings });
return;
}
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
+40 -4
View File
@@ -91,6 +91,18 @@ function RangeSetting({ label, value, disabled = false, onChange }) {
);
}
function reconnectSocketWithTransport(socket, transport) {
if (!socket?.io?.opts) return;
/*
Socket.IO reads its manager options when reconnecting. Keep this mutation in
one helper instead of inside the component body so the settings handler only
expresses the user-facing action: save preference, then reconnect.
*/
socket.io.opts.transports = transport === 'polling' ? ['polling'] : ['websocket', 'polling'];
socket.disconnect();
socket.connect();
}
export default function SettingsPanel() {
const keymap = useControlSelector((control) => control.state.keymap);
const roverId = useControlSelector((control) => control.state.roverId);
@@ -103,6 +115,7 @@ export default function SettingsPanel() {
connectionTransport: 'websocket',
swapMobileControlColumns: false,
driveMacroBackoffEnabled: true,
interInstanceTransferSettings: true,
});
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
@@ -112,6 +125,7 @@ export default function SettingsPanel() {
typeof pageSettings?.driveMacroBackoffEnabled === 'boolean'
? pageSettings.driveMacroBackoffEnabled
: true;
const interInstanceTransferSettings = pageSettings?.interInstanceTransferSettings !== false;
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
@@ -144,10 +158,7 @@ export default function SettingsPanel() {
const next = event.target.value;
savePageSettings((current) => ({ ...(current ?? {}), connectionTransport: next }));
trackAnalyticsEvent('settings_change', { setting: 'connection_transport', value: next });
if (!socket?.io?.opts) return;
socket.io.opts.transports = next === 'polling' ? ['polling'] : ['websocket', 'polling'];
socket.disconnect();
socket.connect();
reconnectSocketWithTransport(socket, next);
};
const handleAudioRange = (key) => (event) => {
@@ -180,6 +191,17 @@ export default function SettingsPanel() {
trackAnalyticsEvent('settings_change', { setting: 'driveMacroBackoffEnabled', value: checked });
};
const handleInterInstanceTransferSettings = (event) => {
const checked = Boolean(event.target.checked);
/*
This replaces the old per-click transfer confirmation. Keeping the choice
in Page settings makes external-server navigation immediate while still
letting users opt out of sending their current settings cookie.
*/
savePageSettings((current) => ({ ...(current ?? {}), interInstanceTransferSettings: checked }));
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
};
const handleVideoFilterChange = (event) => {
const nextFilter = normalizeVideoFilter(event.target.value);
@@ -329,6 +351,20 @@ export default function SettingsPanel() {
</SettingRow>
<SettingHelp>Switching reconnects your session.</SettingHelp>
</CardFrame>
<CardFrame title="Inter-instance" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-emerald-500"
checked={interInstanceTransferSettings}
onChange={handleInterInstanceTransferSettings}
/>
<span className="font-semibold text-white">Transfer settings when opening external servers</span>
</SettingRow>
<SettingHelp>
Sends this browser's saved identity and page settings to the destination server automatically.
</SettingHelp>
</CardFrame>
</div>
</TabPanel>
<TabPanel id="admin">
+31 -3
View File
@@ -17,6 +17,8 @@ export const DEFAULT_OVERCURRENT_LIMITS = {
outputRateMs: 250,
};
const RECOVERED_CAP_THRESHOLD = 0.999;
function createInitialCaps() {
return OVERCURRENT_GROUPS.reduce((acc, group) => {
acc[group.key] = { cap: 1, clearSec: 0 };
@@ -54,7 +56,17 @@ export function useOvercurrentLimiter(roverId, options = {}) {
[overcurrentFlags],
);
const needsRecoveryTick = useMemo(
() => Object.values(caps || {}).some((entry) => (Number.isFinite(entry?.cap) ? entry.cap : 1) < 0.999),
() =>
Object.values(caps || {}).some((entry) => {
/*
Recovery intentionally completes at a tiny tolerance below exactly 1.
The limiter advances in timed floating-point steps, so requiring an
exact 1 can strand the UI at a visually empty bar while the limiter is
still technically active at a value like 0.9992.
*/
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
return cap < RECOVERED_CAP_THRESHOLD;
}),
[caps],
);
const shouldTick = Boolean(roverId) && (hasAnyOvercurrent || needsRecoveryTick);
@@ -81,9 +93,15 @@ export function useOvercurrentLimiter(roverId, options = {}) {
const over = group.motors.some((motor) => Boolean(flagsRef.current?.[motor]));
const nextClear = over ? 0 : prevClear + deltaSec;
const allowRecover = !over && nextClear >= releaseDelay;
const nextCap = clampUnit(
const rawNextCap = clampUnit(
over ? prevCap - downRate * deltaSec : allowRecover ? prevCap + upRate * deltaSec : prevCap,
);
/*
Once recovery reaches the shared completion threshold, snap the cap
to exactly full strength. This keeps the tick loop, command scaling,
and HUD visibility from disagreeing over a harmless fractional tail.
*/
const nextCap = !over && rawNextCap >= RECOVERED_CAP_THRESHOLD ? 1 : rawNextCap;
if (Math.abs(nextCap - prevCap) > 0.0001 || Math.abs(nextClear - prevClear) > 0.0001) {
changed = true;
}
@@ -137,7 +155,17 @@ export function useOvercurrentLimiter(roverId, options = {}) {
caps,
overcurrent,
scales,
isActive: (scales?.drive?.left ?? 1) < 1 || (scales?.drive?.right ?? 1) < 1 || (scales?.aux?.main ?? 1) < 1 || (scales?.aux?.side ?? 1) < 1,
/*
HUD and resend behavior should only remain active while the limiter has
meaningful scale left to recover. Using the same threshold as the tick
loop prevents an empty overcurrent overlay from staying mounted after
recovery has already stopped.
*/
isActive:
(scales?.drive?.left ?? 1) < RECOVERED_CAP_THRESHOLD ||
(scales?.drive?.right ?? 1) < RECOVERED_CAP_THRESHOLD ||
(scales?.aux?.main ?? 1) < RECOVERED_CAP_THRESHOLD ||
(scales?.aux?.side ?? 1) < RECOVERED_CAP_THRESHOLD,
config,
adminImmune,
}),
@@ -25,9 +25,9 @@ export default function useIncomingInterInstanceTransfer() {
}
try {
/*
The source page already asked before adding settingsTransfer. A present
transfer param is therefore an explicit instruction to replace the local
settings cookie without asking again on the destination server.
The source page only adds settingsTransfer when its saved Page setting
allows it. A present transfer param is therefore an explicit instruction
to replace the local settings cookie without asking again here.
*/
const nextSettings = base64UrlDecodeJson(transfer);
settings.saveAll(nextSettings && typeof nextSettings === 'object' ? nextSettings : {});
+7 -11
View File
@@ -1,5 +1,5 @@
// Inter-Instance Transfer Helpers
// Purpose: Builds cross-server links and moves the local settings cookie only when the user opts in before leaving.
// Purpose: Builds cross-server links and optionally carries the local settings cookie when page settings allow it.
// Scope: Keeps URL encoding and settings-transfer behavior out of the rover queue rendering code.
import { loadSettings } from '../settings/persistence.js';
@@ -27,8 +27,9 @@ export function buildExternalRoverUrl(instance, roverId, { includeSettings = fal
const url = new URL(publicUrl);
if (roverId) url.searchParams.set('rover', String(roverId));
/*
The destination always applies settingsTransfer if present, so this helper
only adds it after the current page has already asked for consent.
The destination always applies settingsTransfer if present. Whether this
source page includes the payload is controlled by the persistent Page
settings toggle, so cross-server navigation does not need a per-click popup.
*/
if (includeSettings) {
url.searchParams.set('settingsTransfer', base64UrlEncodeJson(loadSettings()));
@@ -36,13 +37,8 @@ export function buildExternalRoverUrl(instance, roverId, { includeSettings = fal
return url.toString();
}
export function openExternalRoverWithPrompt(instance, roverId) {
const withoutTransfer = buildExternalRoverUrl(instance, roverId);
if (!withoutTransfer) return;
const instanceName = String(instance?.instance?.name || instance?.url || 'that server');
const includeSettings = window.confirm(
`Transfer your identity and settings to ${instanceName}? Press Cancel to open without transferring them.`,
);
export function openExternalRover(instance, roverId, { includeSettings = true } = {}) {
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
window.location.href = targetUrl || withoutTransfer;
if (!targetUrl) return;
window.location.href = targetUrl;
}