reboot your own rover

This commit is contained in:
legop3
2026-05-28 16:12:42 -04:00
parent 6dfc30d1f6
commit 955b613527
10 changed files with 252 additions and 130 deletions
+1
View File
@@ -15,6 +15,7 @@ require('./src/services/roverManager');
require('./src/services/commandService'); require('./src/services/commandService');
require('./src/services/roverConnectionService'); require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/roverRebootService');
require('./src/services/nicknameService'); require('./src/services/nicknameService');
require('./src/services/verificationService'); require('./src/services/verificationService');
require('./src/services/privateRoverAccessRequestService'); require('./src/services/privateRoverAccessRequestService');
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
+2 -2
View File
@@ -11,8 +11,8 @@
<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" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BEU30yxg.js"></script> <script type="module" crossorigin src="/assets/index-IYeJHM9w.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BGy4iG0w.css"> <link rel="stylesheet" crossorigin href="/assets/index-BsongPZ9.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -192,6 +192,14 @@ function createBusEventHandler(deps) {
announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xe53935, title: 'Rover Offline', description: `${payload?.roverId} went offline.` }); announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xe53935, title: 'Rover Offline', description: `${payload?.roverId} went offline.` });
schedulePresenceRotation(); schedulePresenceRotation();
break; break;
case 'rover.reboot.userRequested':
announce({
channelId: channels.adminAlerts,
color: 0xf0b651,
title: 'User Rover Reboot Requested',
description: `${payload?.by || 'unknown user'} requested reboot for ${payload?.roverName || payload?.roverId || 'unknown rover'}.`,
});
break;
case 'rover.dockGuard': case 'rover.dockGuard':
announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` }); announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` });
break; break;
@@ -0,0 +1,76 @@
// rover Reboot Service
// Purpose: Handles one-shot user-triggered rover reboot requests with server-side safety checks.
// Scope: Validates ownership/control, stops the rover, issues reboot, and publishes admin-alert events.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('roverReboot');
const { getRole } = require('../roleService');
const roverManager = require('../roverManager');
const assignmentService = require('../assignmentService');
const { stopRover } = require('../turnService/actions');
const { issueCommand } = require('../commandService');
const { publishEvent } = require('../eventBus');
const USER_COOLDOWN_MS = 2 * 60 * 1000;
const userCooldowns = new Map(); // socketId -> blockedUntil
function getCooldownLeftMs(socketId) {
const blockedUntil = Number(userCooldowns.get(socketId) || 0);
return Math.max(0, blockedUntil - Date.now());
}
function setCooldown(socketId) {
userCooldowns.set(socketId, Date.now() + USER_COOLDOWN_MS);
}
function canRequestReboot(socket) {
if (!socket?.id) throw new Error('Socket required');
const role = getRole(socket);
if (role === 'spectator') throw new Error('Spectators cannot reboot rovers');
const roverId = assignmentService.getAssignedRover(socket.id);
if (!roverId) throw new Error('No assigned rover');
const record = roverManager.rovers.get(String(roverId));
if (!record || !record.ws) throw new Error('Assigned rover is offline');
if (!roverManager.canDrive(roverId, socket)) throw new Error('Not your turn or no control');
if (!(record.drivers instanceof Set) || record.drivers.size !== 1 || !record.drivers.has(socket.id)) {
throw new Error('Reboot blocked while other drivers are connected');
}
const cooldownLeftMs = getCooldownLeftMs(socket.id);
if (cooldownLeftMs > 0) {
throw new Error(`Reboot cooldown active (${Math.ceil(cooldownLeftMs / 1000)}s)`);
}
return { roverId: String(roverId), record };
}
io.on('connection', (socket) => {
function handleRebootOwnRover(_, cb = () => {}) {
try {
const { roverId, record } = canRequestReboot(socket);
stopRover(roverId);
issueCommand(roverId, { type: 'reboot', reboot: { delayMs: 300 } });
setCooldown(socket.id);
const requester = socket?.data?.user?.username || socket.id;
publishEvent({
source: 'roverRebootService',
type: 'rover.reboot.userRequested',
payload: {
roverId,
roverName: record?.meta?.name || roverId,
by: requester,
},
});
logger.info('User-requested rover reboot', { roverId, by: requester, socketId: socket.id });
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message || 'Reboot failed' });
}
}
socket.on('session:rebootOwnRover', handleRebootOwnRover);
socket.on('rebootOwnRover', handleRebootOwnRover);
socket.on('disconnect', () => {
userCooldowns.delete(socket.id);
});
});
module.exports = {};
@@ -51,8 +51,16 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {}); const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const users = useSessionSelector((state) => state.session?.users ?? []); const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { requestControl } = useSessionActions(); const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const assignedRoverName = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return '';
const rover = (state.session?.roster || []).find((entry) => String(entry?.id) === roverId);
return rover?.name || roverId;
});
const { requestControl, rebootOwnRover } = useSessionActions();
const [pending, setPending] = useState({}); const [pending, setPending] = useState({});
const [rebootPending, setRebootPending] = useState(false);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const canRequest = useMemo(() => role && role !== 'spectator', [role]); const canRequest = useMemo(() => role && role !== 'spectator', [role]);
@@ -96,8 +104,36 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const lookupUser = (socketId) => const lookupUser = (socketId) =>
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null }; users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
async function handleRebootOwnRover() {
if (rebootPending) return;
const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`);
if (!ok) return;
setRebootPending(true);
try {
await rebootOwnRover();
alert('Reboot command sent.');
} catch (err) {
alert(err.message);
} finally {
setRebootPending(false);
}
}
const headerActions =
role !== 'spectator' && assignedRoverId ? (
<button
type="button"
onClick={handleRebootOwnRover}
disabled={rebootPending}
className="button-dark bg-amber-700/80 text-amber-50 hover:bg-amber-600 disabled:opacity-40"
title="Reboot your assigned rover"
>
{rebootPending ? 'Rebooting...' : 'Reboot My Rover'}
</button>
) : null;
return ( return (
<CardFrame title={title} bodyClassName="space-y-0.5 text-sm"> <CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm">
{rosterItems.length === 0 ? ( {rosterItems.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p> <p className="text-sm text-slate-500">No rovers registered.</p>
) : ( ) : (
+1
View File
@@ -182,6 +182,7 @@ export function SessionProvider({ children }) {
requestVerification: () => emitWithAck('verification:request'), requestVerification: () => emitWithAck('verification:request'),
requestPrivateRoverAccess: (roverId) => requestPrivateRoverAccess: (roverId) =>
emitWithAck('session:privateRover:requestAccess', { roverId }), emitWithAck('session:privateRover:requestAccess', { roverId }),
rebootOwnRover: () => emitWithAck('session:rebootOwnRover'),
triggerReplay: (sourcesOrPayload = [], title = '') => { triggerReplay: (sourcesOrPayload = [], title = '') => {
if (sourcesOrPayload && typeof sourcesOrPayload === 'object' && !Array.isArray(sourcesOrPayload)) { if (sourcesOrPayload && typeof sourcesOrPayload === 'object' && !Array.isArray(sourcesOrPayload)) {
return emitWithAck('replay:trigger', sourcesOrPayload); return emitWithAck('replay:trigger', sourcesOrPayload);