Compare commits

...
Author SHA1 Message Date
legop3 6bfbb8ad80 replay beeps and boops and flashings
Container image / image (push) Failing after 1m3s
2026-10-07 14:31:54 -04:00
legop3 ba2f66ebc1 add react wrapper thingy to find crashings 2026-10-07 13:38:56 -04:00
legop3 fde2fca0a0 add image tag selection to admin ui
Container image / image (push) Failing after 50s
2026-10-04 13:29:46 -04:00
legop3 d36ea4f672 Merge pull request #31 from legop3/REFACTOR-docking-undocking-serverside
Container image / image (push) Failing after 49s
Refactor docking undocking serverside
2026-10-02 23:23:23 -04:00
legop3 4be6e6c9a2 slop 2026-10-02 23:18:41 -04:00
legop3 c71267873a micrationslop
Container image / image (push) Failing after 56s
2026-10-01 02:33:56 -04:00
19 changed files with 299 additions and 136 deletions
+2
View File
@@ -26,6 +26,8 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
## Decision log ## Decision log
- 2026-10-02: The admin container controls accept a tag within the repository supplied by Compose through `MULTIROVER_TARGET_IMAGE`. A successful healthy replacement saves the tag in the lifecycle status volume; failed replacements retain the previous selection. Checks and later updates follow that saved tag. Changing repositories in Compose resets the selection. Manual Compose deployments still use the Compose image and can replace a UI-selected image; the UI displays both references. The lifecycle helper itself is updated through Compose. Image rollback does not revert application data migrations. Deploy this feature to both the application and lifecycle services before using tag selection.
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback. - 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it. - 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree. - 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
+16
View File
@@ -64,6 +64,7 @@ socket.on('connect', async () => {
// previously stored session with this object instead of merging snapshots. // previously stored session with this object instead of merging snapshots.
socket.on('session:sync', (session) => { socket.on('session:sync', (session) => {
console.log('Session:', session); console.log('Session:', session);
console.log('Server mode:', session.mode);
}); });
// chat:init contains the recent chat history available when the bot connects. // chat:init contains the recent chat history available when the bot connects.
@@ -105,6 +106,21 @@ Run it with:
node bot.js node bot.js
``` ```
## Reading server mode
Read `session.mode` from the `session:sync` snapshot to get the current server
mode. Its possible values are `open`, `turns`, `admin`, and `lockdown`.
The server sends a fresh session snapshot whenever the mode changes, so the
example's handler receives both the initial mode and subsequent updates. No
polling or separate mode listener is needed. If your bot stores session data for
its chat-handling logic, replace the stored session on every `session:sync` and
read the mode from that latest snapshot.
`session.mode` is the server-wide mode; `session.role` is this connection's role
(such as `spectator`), and `session.operatingMode` is a separate connection field.
Use `session.mode` when answering chat questions about the server's current mode.
## Events used ## Events used
- `nickname:set` sets the bot's visible nickname. - `nickname:set` sets the bot's visible nickname.
+29 -1
View File
@@ -1,6 +1,6 @@
// Configuration Database Migrations // Configuration Database Migrations
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database. // Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation. // Scope: Upgrades database structure and stored configuration documents before current-schema validation.
const migrations = [ const migrations = [
{ {
version: 1, version: 1,
@@ -84,6 +84,34 @@ const migrations = [
`).run(); `).run();
}, },
}, },
{
version: 4,
run(db) {
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
// Upgrade history as well as the active revision so a settings rollback
// cannot reintroduce fields rejected by the new Automations schema.
for (const row of rows) {
const config = JSON.parse(row.config_json);
let changed = false;
for (const item of config.homeAssistantActivities?.items || []) {
const hasAction = Object.hasOwn(item, 'idleAction');
const hasValue = Object.hasOwn(item, 'idleValue');
if (!hasAction && !hasValue) continue;
item.automations ??= {};
item.automations.idle ??= {};
// Preserve any explicitly saved new-format setting when both formats
// exist, including an empty text value used to clear an entity.
if (hasAction && !Object.hasOwn(item.automations.idle, 'action')) item.automations.idle.action = item.idleAction;
if (hasValue && !Object.hasOwn(item.automations.idle, 'value')) item.automations.idle.value = item.idleValue;
delete item.idleAction;
delete item.idleValue;
changed = true;
}
if (changed) update.run(JSON.stringify(config), row.id);
}
},
},
]; ];
function applySchemaMigrations(db) { function applySchemaMigrations(db) {
@@ -109,6 +109,7 @@ async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
const relativePath = path.join(relativeDir, entry.name); const relativePath = path.join(relativeDir, entry.name);
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue; if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue; if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
if (relativePath === path.join('rover-learning', 'recordings')) continue;
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`); if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
if (entry.isDirectory()) { if (entry.isDirectory()) {
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath)); skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
@@ -1,6 +1,6 @@
// Discord Replay Command // Discord Replay Command
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow. // Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs. // Scope: Resolves sources, enforces replay availability, reports job progress, uploads video, and broadcasts media URLs.
const { AttachmentBuilder } = require('discord.js'); const { AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io'); const io = require('../../../globals/io');
const { hostReplay } = require('../../replayMediaService'); const { hostReplay } = require('../../replayMediaService');
@@ -60,12 +60,6 @@ function createReplayCommand({
return; return;
} }
const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' });
if (!attempt.ok) {
await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`);
return;
}
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
const job = createReplayJob({ const job = createReplayJob({
id: buildReplayJobId('discord'), id: buildReplayJobId('discord'),
@@ -74,17 +68,23 @@ function createReplayCommand({
sources: resolved.sources || [], sources: resolved.sources || [],
includeSidebar: true, includeSidebar: true,
}); });
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) }); const attempt = tryTriggerReplay({ jobId: job.id, by: { source: 'discord', requester } });
if (!attempt.ok) {
const progressMessage = await message.reply({ await replyDenied(message, `Replay denied: ${attempt.error}`);
content: sanitizeMentions(buildAcceptedMessage(job)), return;
allowedMentions: DEFAULT_ALLOWED_MENTIONS, }
});
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
let progressMessage = null;
let stopTyping = () => {};
let builtReplay = null; let builtReplay = null;
let deliveredMedia = null; let deliveredMedia = null;
try { try {
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
progressMessage = await message.reply({
content: sanitizeMentions(buildAcceptedMessage(job)),
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') }); jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (progressMessage?.edit) { if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
@@ -35,9 +35,9 @@ async function deliverReplay(payload = {}) {
requestedBy: payload.requestedBy, requestedBy: payload.requestedBy,
}); });
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerContext = null; let providerContext = null;
try { try {
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerError = null; let providerError = null;
if (preferredDeliveryProvider?.begin) { if (preferredDeliveryProvider?.begin) {
try { try {
@@ -2,6 +2,7 @@
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text. // Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline. // Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
const { updateReplayStatus } = require('../replayEngineV2/cooldown');
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false }; const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
const FUZZY_THRESHOLD = 0.42; const FUZZY_THRESHOLD = 0.42;
@@ -180,6 +181,22 @@ function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
function emit(job, status, extra = {}) { function emit(job, status, extra = {}) {
const payload = buildPayload(job, status, extra); const payload = buildPayload(job, status, extra);
updateReplayStatus(payload);
if (status === 'accepted') {
// Sound once per participating rover; a disconnected rover must not abort video creation.
const { issueCommand } = require('../commandService');
const roverIds = new Set(job.sources.filter((entry) => entry.type === 'rover').map((entry) => entry.id));
for (const roverId of roverIds) {
try {
issueCommand(roverId, {
type: 'song',
song: { notes: [{ note: 72, duration: 8 }, { note: 76, duration: 8 }, { note: 79, duration: 16 }] },
});
} catch (err) {
logger?.warn?.('Unable to play replay tune', { roverId, error: err.message });
}
}
}
io.emit('replay:status', payload); io.emit('replay:status', payload);
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media); if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
if (status === 'failed') io.emit('replay:failed', payload); if (status === 'failed') io.emit('replay:failed', payload);
+43 -22
View File
@@ -4,40 +4,61 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const COOLDOWN_MS = 10 * 1000; const COOLDOWN_MS = 10 * 1000;
const MAX_CONCURRENT_JOBS = 3;
const replayEvents = new EventEmitter(); const replayEvents = new EventEmitter();
const activeJobs = new Map();
let status = null;
let lastTriggeredAt = null; let lastTriggeredAt = null;
let lastTriggeredBy = null; let lastTriggeredBy = null;
function getRemainingMs(now = Date.now()) {
if (!lastTriggeredAt) return 0;
return Math.max(0, COOLDOWN_MS - (now - lastTriggeredAt));
}
function getReplayState() { function getReplayState() {
const remainingMs = getRemainingMs(); const remainingMs = lastTriggeredAt === null ? 0 : Math.max(0, COOLDOWN_MS - (Date.now() - lastTriggeredAt));
return { return {
cooldownMs: COOLDOWN_MS, cooldownMs: COOLDOWN_MS,
lastTriggeredAt, lastTriggeredAt,
lastTriggeredBy, lastTriggeredBy,
remainingMs, remainingMs,
available: remainingMs === 0, atJobLimit: activeJobs.size >= MAX_CONCURRENT_JOBS,
activeSourceKeys: [...new Set(Array.from(activeJobs.values()).flatMap((sources) =>
sources.map((source) => `${source.type}:${source.id}`),
))],
status,
available: remainingMs === 0 && activeJobs.size < MAX_CONCURRENT_JOBS,
}; };
} }
function tryTriggerReplay(by = null) { function emitUpdate() {
const remainingMs = getRemainingMs(); replayEvents.emit('update', { state: getReplayState(), by: lastTriggeredBy });
if (remainingMs > 0) {
return { ok: false, remainingMs, state: getReplayState() };
}
lastTriggeredAt = Date.now();
lastTriggeredBy = by || null;
const state = getReplayState();
replayEvents.emit('update', { state, by: lastTriggeredBy });
return { ok: true, state };
} }
module.exports = { function tryTriggerReplay({ jobId, by = null } = {}) {
tryTriggerReplay, const state = getReplayState();
getReplayState, if (state.atJobLimit) {
replayEvents, return { ok: false, error: 'Replay limit reached: 3 jobs are already being made', state };
}; }
if (state.remainingMs > 0) {
return { ok: false, error: `Replay cooldown active. Try again in ${Math.ceil(state.remainingMs / 1000)}s.`, remainingMs: state.remainingMs, state };
}
if (!jobId) throw new Error('Replay job id required');
activeJobs.set(jobId, []);
status = { jobId, status: 'accepted' };
lastTriggeredAt = Date.now();
lastTriggeredBy = by;
emitUpdate();
return { ok: true, state: getReplayState() };
}
function updateReplayStatus(payload) {
if (!activeJobs.has(payload.jobId)) return;
if (payload.jobId === status?.jobId) {
status = { jobId: payload.jobId, status: payload.status, title: payload.title, message: payload.message };
}
if (payload.status === 'ready' || payload.status === 'failed') {
activeJobs.delete(payload.jobId);
} else {
activeJobs.set(payload.jobId, payload.sources || []);
}
emitUpdate();
}
module.exports = { tryTriggerReplay, getReplayState, replayEvents, updateReplayStatus };
@@ -1,6 +1,6 @@
// Replay Socket Hooks // Replay Socket Hooks
// Purpose: Registers web socket replay-trigger handler that publishes replay requests. // Purpose: Registers web socket replay-trigger handler that publishes replay requests.
// Scope: Applies replay mode/cooldown/source validation for socket-triggered replay requests. // Scope: Applies replay mode/availability/source validation for socket-triggered replay requests.
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('replaySocket'); const logger = require('../../globals/logger').child('replaySocket');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
@@ -45,9 +45,9 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
const title = normalizeReplayTitle(payload?.title); const title = normalizeReplayTitle(payload?.title);
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar); const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
const jobId = buildReplayJobId('web'); const jobId = buildReplayJobId('web');
const attempt = tryTriggerReplay({ by: { source: 'web', requester } }); const attempt = tryTriggerReplay({ jobId, by: { source: 'web', requester } });
if (!attempt.ok) { if (!attempt.ok) {
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state }); cb({ error: attempt.error, remainingMs: attempt.remainingMs, state: attempt.state });
return; return;
} }
@@ -1,6 +1,6 @@
// Restricted Container Lifecycle Controller // Restricted Container Lifecycle Controller
// Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API. // Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API.
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts no caller-selected targets. // Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts tags only within the deployment-configured repository.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const http = require('http'); const http = require('http');
@@ -9,6 +9,12 @@ const Docker = require('dockerode');
const docker = new Docker({ socketPath: '/var/run/docker.sock' }); const docker = new Docker({ socketPath: '/var/run/docker.sock' });
const TARGET_CONTAINER_NAME = 'multirover'; const TARGET_CONTAINER_NAME = 'multirover';
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest'; const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
// A registry port is not a tag separator. Strip a digest before splitting the tag.
const configuredName = TARGET_IMAGE.split('@')[0];
const tagSeparator = configuredName.lastIndexOf(':');
const hasConfiguredTag = tagSeparator > configuredName.lastIndexOf('/');
const IMAGE_REPOSITORY = hasConfiguredTag ? configuredName.slice(0, tagSeparator) : configuredName;
const DEFAULT_TAG = TARGET_IMAGE.includes('@') ? null : (hasConfiguredTag ? configuredName.slice(tagSeparator + 1) : 'latest');
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock'; const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
// Status belongs beside the controller's private socket instead of in the // Status belongs beside the controller's private socket instead of in the
// application's data volume. The controller runs as root for Docker access; // application's data volume. The controller runs as root for Docker access;
@@ -20,6 +26,32 @@ const HEALTH_POLL_MS = 1000;
let operationRunning = false; let operationRunning = false;
let status = readStatus(); let status = readStatus();
// A repository change in Compose invalidates the previous repository's selection.
if (status.selectedRepository !== IMAGE_REPOSITORY) {
status.selectedRepository = IMAGE_REPOSITORY;
status.selectedTag = DEFAULT_TAG;
}
function selectedImage() {
return status.selectedTag ? `${IMAGE_REPOSITORY}:${status.selectedTag}` : TARGET_IMAGE;
}
function publicStatus() {
return {
...status,
configuredImage: TARGET_IMAGE,
repository: IMAGE_REPOSITORY,
targetImage: selectedImage(),
};
}
function requestedImage(tag) {
if (tag === null) return { imageReference: selectedImage(), tag: status.selectedTag };
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(tag)) {
throw new Error('Enter a valid container tag (1–128 letters, numbers, underscores, periods, or hyphens).');
}
return { imageReference: `${IMAGE_REPOSITORY}:${tag}`, tag };
}
function readStatus() { function readStatus() {
try { try {
@@ -52,11 +84,12 @@ function readStatus() {
} }
function writeStatus(patch) { function writeStatus(patch) {
status = { ...status, ...patch }; const nextStatus = { ...status, ...patch };
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true }); fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
const temporaryPath = `${STATUS_PATH}.tmp`; const temporaryPath = `${STATUS_PATH}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); fs.writeFileSync(temporaryPath, `${JSON.stringify(nextStatus, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(temporaryPath, STATUS_PATH); fs.renameSync(temporaryPath, STATUS_PATH);
status = nextStatus;
return status; return status;
} }
@@ -68,23 +101,23 @@ async function inspectTargetContainer() {
return docker.getContainer(TARGET_CONTAINER_NAME).inspect(); return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
} }
async function inspectLocalTargetImage() { async function inspectLocalTargetImage(imageReference = selectedImage()) {
try { try {
return await docker.getImage(TARGET_IMAGE).inspect(); return await docker.getImage(imageReference).inspect();
} catch (error) { } catch (error) {
if (error.statusCode === 404) return null; if (error.statusCode === 404) return null;
throw error; throw error;
} }
} }
async function pullTargetImage() { async function pullTargetImage(imageReference = selectedImage()) {
const stream = await new Promise((resolve, reject) => { const stream = await new Promise((resolve, reject) => {
docker.pull(TARGET_IMAGE, (error, pullStream) => (error ? reject(error) : resolve(pullStream))); docker.pull(imageReference, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
}); });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve())); docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
}); });
const image = await inspectLocalTargetImage(); const image = await inspectLocalTargetImage(imageReference);
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.'); if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
return image; return image;
} }
@@ -193,13 +226,14 @@ async function refreshImageStatus() {
]); ]);
return writeStatus({ return writeStatus({
runningImage: container.Image, runningImage: container.Image,
runningImageReference: container.Config?.Image || null,
availableImage: image?.Id || null, availableImage: image?.Id || null,
updateAvailable: image ? container.Image !== image.Id : null, updateAvailable: image ? container.Image !== image.Id : null,
}); });
} }
async function runCheck() { async function runCheck() {
writeStatus({ message: `Pulling ${TARGET_IMAGE}…` }); writeStatus({ message: `Pulling ${selectedImage()}…` });
await pullTargetImage(); await pullTargetImage();
const current = await refreshImageStatus(); const current = await refreshImageStatus();
return current.updateAvailable ? 'An application update is available.' : 'The application is current.'; return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
@@ -213,9 +247,9 @@ async function runRestart() {
return 'The application container restarted successfully.'; return 'The application container restarted successfully.';
} }
async function runUpdate() { async function runUpdate({ imageReference, tag }) {
writeStatus({ message: `Pulling ${TARGET_IMAGE}…`, rollback: null }); writeStatus({ message: `Pulling ${imageReference}…`, rollback: null });
const targetImage = await pullTargetImage(); const targetImage = await pullTargetImage(imageReference);
const previousContainer = await inspectTargetContainer(); const previousContainer = await inspectTargetContainer();
const previousImage = previousContainer.Image; const previousImage = previousContainer.Image;
let replacementStarted = false; let replacementStarted = false;
@@ -228,11 +262,15 @@ async function runUpdate() {
updateAvailable: previousImage !== targetImage.Id, updateAvailable: previousImage !== targetImage.Id,
}); });
replacementStarted = true; replacementStarted = true;
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE }); await replaceContainer({ template: previousContainer, imageReference: targetImage.Id });
writeStatus({ message: 'Waiting for the updated application to become healthy…' }); writeStatus({ message: 'Waiting for the updated application to become healthy…' });
const healthy = await waitForHealthyContainer(); const healthy = await waitForHealthyContainer();
writeStatus({ writeStatus({
// Commit the tag only once the replacement passes its health check.
selectedRepository: IMAGE_REPOSITORY,
selectedTag: tag,
runningImage: healthy.Image, runningImage: healthy.Image,
runningImageReference: healthy.Config?.Image || null,
availableImage: targetImage.Id, availableImage: targetImage.Id,
updateAvailable: false, updateAvailable: false,
}); });
@@ -284,7 +322,7 @@ function queueOperation(operation, runner) {
operationRunning = false; operationRunning = false;
} }
}, 500); }, 500);
return status; return publicStatus();
} }
function sendJson(response, statusCode, value) { function sendJson(response, statusCode, value) {
@@ -294,10 +332,11 @@ function sendJson(response, statusCode, value) {
const server = http.createServer((request, response) => { const server = http.createServer((request, response) => {
try { try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && request.url === '/status') { if (request.method === 'GET' && request.url === '/status') {
refreshImageStatus() refreshImageStatus()
.catch(() => status) .catch(() => status)
.then((current) => sendJson(response, 200, { available: true, ...current })); .then(() => sendJson(response, 200, { available: true, ...publicStatus() }));
return; return;
} }
if (request.method === 'POST' && request.url === '/check') { if (request.method === 'POST' && request.url === '/check') {
@@ -308,8 +347,10 @@ const server = http.createServer((request, response) => {
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) }); sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
return; return;
} }
if (request.method === 'POST' && request.url === '/update') { if (request.method === 'POST' && url.pathname === '/update') {
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) }); const target = requestedImage(url.searchParams.get('tag'));
queueOperation('update', () => runUpdate(target));
sendJson(response, 202, { available: true, ...publicStatus() });
return; return;
} }
sendJson(response, 404, { error: 'Lifecycle operation not found.' }); sendJson(response, 404, { error: 'Lifecycle operation not found.' });
@@ -88,14 +88,14 @@ io.on('connection', (socket) => {
.catch((error) => cb({ error: error.message, code: error.code || null })); .catch((error) => cb({ error: error.message, code: error.code || null }));
}); });
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => { socket.on('server:updateApplication', (payload = {}, cb = () => {}) => {
Promise.resolve() Promise.resolve()
.then(() => requireRecentPassword(socket)) .then(() => requireRecentPassword(socket))
.then(() => lifecycleClient.updateApplication()) .then(() => lifecycleClient.updateApplication(payload?.tag))
.then((lifecycle) => { .then((lifecycle) => {
// Wait for controller acceptance so rejected updates do not kick users. // Wait for controller acceptance so rejected updates do not kick users.
notifyDriversOfRestart(actorFor(socket)); notifyDriversOfRestart(actorFor(socket));
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {}); database.recordAuditEvent(actorFor(socket), 'application.update-requested', { tag: payload?.tag ?? lifecycle.selectedTag });
io.emit('server:restarting', { reason: 'application-update' }); io.emit('server:restarting', { reason: 'application-update' });
cb({ success: true, lifecycle }); cb({ success: true, lifecycle });
}) })
@@ -1,6 +1,6 @@
// Container Lifecycle Client // Container Lifecycle Client
// Purpose: Sends fixed lifecycle requests from the application to the private controller socket. // Purpose: Sends fixed lifecycle requests from the application to the private controller socket.
// Scope: This client cannot select images, containers, or Docker arguments; the controller owns those constants. // Scope: This client can request a tag; the controller owns the repository, container, and Docker arguments.
const http = require('http'); const http = require('http');
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock'; const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
@@ -68,5 +68,11 @@ module.exports = {
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }), checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
getLifecycleStatus, getLifecycleStatus,
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }), restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }), updateApplication: (tag) => {
if (tag !== undefined && typeof tag !== 'string') throw new Error('Container tag must be a string.');
return requestLifecycleController({
method: 'POST',
path: tag === undefined ? '/update' : `/update?tag=${encodeURIComponent(tag)}`,
});
},
}; };
+1 -1
View File
@@ -459,7 +459,7 @@ balanceBoardEvents.on('change', () => {
}); });
replayEvents.on('update', () => { replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients'); logger.info('Replay state updated; syncing all clients');
syncAll(); syncAll();
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adm
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication'); export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus'); export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate'); export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
export const updateApplication = (socket) => emitAdminRequest(socket, 'server:updateApplication'); export const updateApplication = (socket, tag) => emitAdminRequest(socket, 'server:updateApplication', { tag });
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status'); export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup'); export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload'); export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
+28 -3
View File
@@ -21,6 +21,7 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
const [restartRequested, setRestartRequested] = useState(false); const [restartRequested, setRestartRequested] = useState(false);
const [lifecycle, setLifecycle] = useState(null); const [lifecycle, setLifecycle] = useState(null);
const [lifecycleError, setLifecycleError] = useState(''); const [lifecycleError, setLifecycleError] = useState('');
const [containerTag, setContainerTag] = useState(null);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -90,10 +91,13 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
} }
async function update() { async function update() {
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return; const tag = containerTag === null ? undefined : containerTag.trim();
const target = tag === undefined ? lifecycle.targetImage : `${lifecycle.repository}:${tag}`;
if (!window.confirm(`Pull ${target} and replace the application container now? The page will reconnect automatically.`)) return;
try { try {
const response = await runSensitive(() => updateApplication(socket)); const response = await runSensitive(() => updateApplication(socket, tag));
setLifecycle(response.lifecycle); setLifecycle(response.lifecycle);
setContainerTag(null);
} catch (error) { } catch (error) {
setLifecycleError(error.message); setLifecycleError(error.message);
} }
@@ -121,15 +125,36 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm"> <CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300"> <div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p> <p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
{lifecycleAvailable ? (
<div className="space-y-0.5 break-all">
<p>Repository: {lifecycle.repository}</p>
<p>Selected image: {lifecycle.targetImage}</p>
<p>Running image: {lifecycle.runningImage}</p>
<p>Compose image: {lifecycle.configuredImage}</p>
{lifecycle.targetImage !== lifecycle.configuredImage ? <p className="text-amber-300">A manual Compose deployment may restore the Compose image.</p> : null}
</div>
) : null}
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null} {lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
{lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null} {lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null}
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null} {lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
</div> </div>
<label className="flex max-w-md flex-col gap-0.5">
<span>Container tag</span>
<input
className="field-input w-full"
value={containerTag ?? lifecycle?.selectedTag ?? ''}
placeholder="Enter a tag"
maxLength={128}
disabled={!lifecycleAvailable || lifecycleBusy}
onChange={(event) => setContainerTag(event.target.value)}
/>
<span className="text-xs text-slate-400">Update and restart applies this tag. Update checks use the saved selected image.</span>
</label>
<div className="flex flex-wrap gap-0.5"> <div className="flex flex-wrap gap-0.5">
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}> <button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'} {lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
</button> </button>
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy} onClick={update}> <button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy || (containerTag !== null && !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(containerTag.trim()))} onClick={update}>
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'} {lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
</button> </button>
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}> <button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
+51
View File
@@ -0,0 +1,51 @@
import { Component } from 'react';
export default class AppErrorBoundary extends Component {
state = { crashed: false, error: null, componentStack: '', copyStatus: '' };
static getDerivedStateFromError(error) {
return { crashed: true, error };
}
componentDidCatch(error, info) {
this.setState({ componentStack: info.componentStack || '' });
}
getDetails() {
const { error, componentStack } = this.state;
return [
String(error?.message ?? error ?? 'Unknown error'),
'JavaScript stack:',
error?.stack || 'Unavailable',
'React component stack:',
componentStack || 'Unavailable',
].join('\n\n');
}
copyDetails = async () => {
try {
await navigator.clipboard.writeText(this.getDetails());
this.setState({ copyStatus: 'Copied.' });
} catch {
// Clipboard access may be unavailable on the display device; the text
// remains selectable so the error can still be copied manually.
this.setState({ copyStatus: 'Copy unavailable. Select the details below to copy manually.' });
}
};
render() {
if (!this.state.crashed) return this.props.children;
// Keep recovery independent of providers and shared UI components: one of
// those dependencies may be the reason the application failed.
return (
<main style={{ backgroundColor: '#b2339f', minHeight: '100vh' }}>
<h1>Application crashed</h1>
<button type="button" onClick={this.copyDetails}>Copy details</button>
<button type="button" onClick={() => window.location.reload()}>Reload</button>
{this.state.copyStatus ? <p role="status">{this.state.copyStatus}</p> : null}
<pre>{this.getDetails()}</pre>
</main>
);
}
}
@@ -46,7 +46,7 @@ export default function ReplaySourcesPanel({
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []); const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null); const replayState = useSessionSelector((state) => state.session?.replay || null);
const replayStatus = useSessionSelector((state) => state.replayStatus); const replayStatus = replayState?.status;
const socket = useSocket(); const socket = useSocket();
const { triggerReplay } = useSessionActions(); const { triggerReplay } = useSessionActions();
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]); const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
@@ -161,48 +161,25 @@ export default function ReplaySourcesPanel({
}; };
}, [sources]); }, [sources]);
const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs); const cooldownNow = useSharedClock(1000, Boolean(replayState?.lastTriggeredAt));
const replayCooldownEndsAt = hasReplayCooldown const remainingMs = replayState?.lastTriggeredAt
? replayState.lastTriggeredAt + replayState.cooldownMs ? Math.max(0, replayState.lastTriggeredAt + replayState.cooldownMs - cooldownNow)
: 0; : 0;
const cooldownNow = useSharedClock(1000, hasReplayCooldown); const atJobLimit = replayState?.atJobLimit === true;
const remainingMs = useMemo(() => { const replaySourceKeys = replayState?.activeSourceKeys;
if (!hasReplayCooldown) return 0; const activeSourceKeys = useMemo(() => new Set(replaySourceKeys || []), [replaySourceKeys]);
/* const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || atJobLimit || !selected.length;
The button only shows whole seconds, so a shared one-second clock gives the
same useful information without each mounted replay panel owning a 250ms
interval. The exact server cooldown still decides whether the action is
accepted; this value is only the local disabled-state/display estimate.
*/
const next = replayCooldownEndsAt - cooldownNow;
return Math.max(0, next);
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
const selectedSet = useMemo(() => { const selectedSet = useMemo(() => {
// Checkbox rendering asks the same membership question for every source. // Checkbox rendering asks the same membership question for every source.
// A Set avoids repeated linear scans and, more importantly, gives memoized // A Set avoids repeated linear scans and, more importantly, gives memoized
// child lists a stable value while the selected keys have not changed. // child lists a stable value while the selected keys have not changed.
return new Set(selected); return new Set(selected);
}, [selected]); }, [selected]);
const replayStatusText = useMemo(() => { const replayStatusText = replayStatus?.status
if (!replayStatus?.status) return null; ? replayStatus.status === 'failed' && replayStatus.message
const titleText = replayStatus.title ? `: ${replayStatus.title}` : ''; ? replayStatus.message
switch (replayStatus.status) { : `Replay ${replayStatus.status}${replayStatus.title ? `: ${replayStatus.title}` : ''}`
case 'accepted': : null;
return `Replay accepted${titleText}`;
case 'building':
return `Replay building${titleText}`;
case 'uploading':
return `Replay uploading${titleText}`;
case 'ready':
return `Replay ready${titleText}`;
case 'failed':
return replayStatus.message || `Replay failed${titleText}`;
default:
return replayStatus.message || `Replay ${replayStatus.status}${titleText}`;
}
}, [replayStatus]);
const toggleKey = useCallback((key) => { const toggleKey = useCallback((key) => {
setSelected((prev) => { setSelected((prev) => {
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key]; return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
@@ -250,13 +227,13 @@ export default function ReplaySourcesPanel({
current desktop sidebar. They only collapse when the card becomes current desktop sidebar. They only collapse when the card becomes
narrower than the supported new-drive desktop column. */} narrower than the supported new-drive desktop column. */}
<div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}> <div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}>
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} onToggle={toggleKey} /> <GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} onToggle={toggleKey} /> <GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{error ? <div className="text-xs text-amber-400">{error}</div> : null} {error ? <div className="text-xs text-amber-400">{error}</div> : null}
{replayStatusText ? ( {replayStatusText ? (
<div className={`text-xs ${replayStatus?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}> <div className={`text-xs ${replayStatus.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
{replayStatusText} {replayStatusText}
</div> </div>
) : null} ) : null}
@@ -279,11 +256,6 @@ export default function ReplaySourcesPanel({
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next })); saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
// Enter is the keyboard equivalent of clicking Replay. Ignore
// composition events so confirming an IME candidate cannot
// accidentally submit a replay before the title is complete.
// handleReplay remains the single authority for cooldown,
// lockdown, busy, and empty-source checks.
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return; if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
event.preventDefault(); event.preventDefault();
handleReplay(); handleReplay();
@@ -314,7 +286,7 @@ export default function ReplaySourcesPanel({
onClick={handleReplay} onClick={handleReplay}
disabled={replayDisabled} disabled={replayDisabled}
> >
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'} {atJobLimit ? 'Replay (limit reached)' : remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
</button> </button>
</div> </div>
</div> </div>
@@ -323,14 +295,14 @@ export default function ReplaySourcesPanel({
); );
} }
const GroupList = React.memo(function GroupList({ title, items, selectedSet, onToggle }) { const GroupList = React.memo(function GroupList({ title, items, selectedSet, activeSourceKeys, onToggle }) {
if (!items.length) return null; if (!items.length) return null;
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="panel-muted text-xs">{title}</div> <div className="panel-muted text-xs">{title}</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{items.map((item) => ( {items.map((item) => (
<label key={item.key} className="surface flex items-center gap-0.5 text-xs"> <label key={item.key} className={`surface flex items-center gap-0.5 text-xs ${activeSourceKeys.has(item.key) ? 'animate-pulse' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={selectedSet.has(item.key)} checked={selectedSet.has(item.key)}
-20
View File
@@ -16,7 +16,6 @@ const INITIAL_STATE = {
overseerMemory: null, overseerMemory: null,
alerts: [], alerts: [],
latestReplay: null, latestReplay: null,
replayStatus: null,
duplicateIdentityBlock: null, duplicateIdentityBlock: null,
roverRemovalNotice: null, roverRemovalNotice: null,
}; };
@@ -199,23 +198,6 @@ export function SessionProvider({ children }) {
], ],
})); }));
} }
function handleReplayStatus(payload = {}) {
if (!payload?.status) return;
setState((prev) => ({
...prev,
/*
Replay creation is serialized by the server's cooldown and workflow,
so the browser needs one current progress value rather than a map of
jobs. The server remains authoritative for job identity and execution;
this scalar exists only to present accepted/building/uploading/ready/
failed progress in the web UI.
*/
replayStatus: {
...payload,
receivedAt: Date.now(),
},
}));
}
function handleReplayReady(payload = {}) { function handleReplayReady(payload = {}) {
if (!payload?.jobId || !payload?.url) return; if (!payload?.jobId || !payload?.url) return;
setState((prev) => ({ setState((prev) => ({
@@ -316,7 +298,6 @@ export function SessionProvider({ children }) {
socket.on('overseer:state', handleOverseerState); socket.on('overseer:state', handleOverseerState);
socket.on('overseer:memory', handleOverseerMemory); socket.on('overseer:memory', handleOverseerMemory);
socket.on('alert:new', handleAlertNew); socket.on('alert:new', handleAlertNew);
socket.on('replay:status', handleReplayStatus);
socket.on('replay:ready', handleReplayReady); socket.on('replay:ready', handleReplayReady);
socket.on('replay:failed', handleReplayFailed); socket.on('replay:failed', handleReplayFailed);
socket.on('session:duplicateIdentity', handleDuplicateIdentity); socket.on('session:duplicateIdentity', handleDuplicateIdentity);
@@ -331,7 +312,6 @@ export function SessionProvider({ children }) {
socket.off('overseer:state', handleOverseerState); socket.off('overseer:state', handleOverseerState);
socket.off('overseer:memory', handleOverseerMemory); socket.off('overseer:memory', handleOverseerMemory);
socket.off('alert:new', handleAlertNew); socket.off('alert:new', handleAlertNew);
socket.off('replay:status', handleReplayStatus);
socket.off('replay:ready', handleReplayReady); socket.off('replay:ready', handleReplayReady);
socket.off('replay:failed', handleReplayFailed); socket.off('replay:failed', handleReplayFailed);
socket.off('session:duplicateIdentity', handleDuplicateIdentity); socket.off('session:duplicateIdentity', handleDuplicateIdentity);
+4 -1
View File
@@ -22,6 +22,7 @@ import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx' import PtzAppRoot from './ptz/PtzAppRoot.jsx'
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx' import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx' import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
// Reporting and administration carry substantial route-specific libraries. // Reporting and administration carry substantial route-specific libraries.
// Loading each only on its own route keeps charting and JSON Schema tooling out // Loading each only on its own route keeps charting and JSON Schema tooling out
@@ -40,6 +41,7 @@ function lazyRoute(element, label) {
} }
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<AppErrorBoundary>
<StrictMode> <StrictMode>
<SocketProvider> <SocketProvider>
<SessionProvider> <SessionProvider>
@@ -84,5 +86,6 @@ createRoot(document.getElementById('root')).render(
</TelemetryProvider> </TelemetryProvider>
</SessionProvider> </SessionProvider>
</SocketProvider> </SocketProvider>
</StrictMode>, </StrictMode>
</AppErrorBoundary>,
) )