mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
optional but always on transfer
This commit is contained in:
@@ -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 }));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 : {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user