mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
server restart thingy yay
This commit is contained in:
@@ -6,7 +6,7 @@ This document is the live implementation tracker for the migration.
|
||||
|
||||
- [x] Phase 1, step 1: Establish the single data-directory contract
|
||||
- [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
|
||||
- [ ] Phase 1, steps 6-9: Backup/restore, restart, and internal video proxy
|
||||
- [ ] Phase 1, steps 6-9: Restart, backup/restore, and internal video proxy
|
||||
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
|
||||
|
||||
The single data-directory implementation and local verification are complete. Real snapshot generation, legacy-directory cleanup, and runtime filesystem tracing remain deployment checks for the actual server; they do not leave the implementation step open.
|
||||
@@ -22,7 +22,9 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
|
||||
|
||||
- 2026-09-14: Apply every committed configuration revision immediately. The configuration coordinator atomically replaces the process-wide snapshot, compares top-level service sections, serially reloads only affected service runtimes, and then refreshes all sessions. Long-lived HTTP/socket handlers remain registered once and delegate to the current runtime; integrations may reconnect or replace their own child process, worker, client, timers, and subscriptions without restarting Node.
|
||||
- 2026-09-14: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet.
|
||||
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file path retained is an operator-selected YAML upload on `/setup`. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
|
||||
- 2026-09-14: Restart only the application process, never the host. A lockdown administrator with recent password confirmation requests one audited restart, Node acknowledges and announces it, then sends itself SIGTERM. Existing service signal handlers clean up their owned children, while systemd `Restart=always` and the later container restart policy start the application again.
|
||||
- 2026-09-14: Keep backup and restore together in one server service after application restart exists. Neither operation stops running services or writers, and no command-line interface is maintained. Backup uses online SQLite snapshots and stable copies of non-database files; restore validates and stages an uploaded archive, records a marker, and uses the normal application restart to replace the data directory during earliest startup.
|
||||
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file paths retained are operator-selected YAML uploads on `/setup` and the protected Configuration page. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
|
||||
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
|
||||
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
|
||||
- 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries.
|
||||
@@ -277,9 +279,26 @@ Authorization rules:
|
||||
|
||||
Configuration uses one schema-generated typed form rather than a raw YAML or JSON text editor. Repeatable values such as cameras, entities, links, and buttons receive the form library's generic add, remove, and reorder workflow.
|
||||
|
||||
## 6. Implement complete backup and restore
|
||||
## 6. Standardize application restart
|
||||
|
||||
Everything durable living under one data directory makes the backup boundary simple, but copying live SQLite files and JSON files without coordination would not guarantee a consistent backup. The implementation must create a consistent snapshot before archiving it.
|
||||
Replace the current host reboot operation with one deployment-neutral **Restart application** operation.
|
||||
|
||||
The restart operation must:
|
||||
|
||||
1. Require a lockdown administrator and recent password confirmation.
|
||||
2. Reject a second request while one is already pending.
|
||||
3. Persist an audit event, acknowledge the requester, and notify connected browsers.
|
||||
4. Stop accepting new HTTP connections and send SIGTERM to the Node process after a short acknowledgement delay.
|
||||
5. Reuse the cleanup hooks already owned by MediaMTX, ffmpeg, Kinect, Balance Board, and other child-process services.
|
||||
6. Exit normally and rely on the process supervisor to start the application again.
|
||||
|
||||
During Phase 1, systemd uses `Restart=always`. During Phase 2, the container uses a restart policy such as `unless-stopped`. An explicit operator `systemctl stop` or container stop remains stopped; only a process exit is restarted. The browser shows the announced reconnect state and reloads the active administration snapshot after Socket.IO reconnects.
|
||||
|
||||
Host rebooting is a separate privilege and is not part of this application contract. The server never invokes `systemctl reboot`.
|
||||
|
||||
## 7. Implement complete backup and restore
|
||||
|
||||
Everything durable living under one data directory makes the backup boundary simple. Backup and restore remain together under one `backupRestoreService`; there is no generic maintenance framework and no command-line workflow.
|
||||
|
||||
### Full backup
|
||||
|
||||
@@ -294,29 +313,25 @@ The primary admin action is **Download full backup**. A full backup includes the
|
||||
- Generated and cached files that are part of the current server state
|
||||
- A manifest describing the application and schema versions
|
||||
|
||||
The backup service must:
|
||||
The backup operation must:
|
||||
|
||||
1. Require a lockdown administrator and recent password confirmation.
|
||||
2. Enter a short maintenance/snapshot state that prevents new persistent mutations.
|
||||
3. Ask services with buffered state to flush it, stop active audio/replay workers, and clear `runtime/` so FIFOs and incomplete scratch files are never archived.
|
||||
4. Create consistent SQLite snapshots using SQLite's supported backup/checkpoint facilities rather than copying active WAL files blindly.
|
||||
5. Copy non-database durable files into temporary staging.
|
||||
2. Leave every service and writer running.
|
||||
3. Create consistent SQLite snapshots using SQLite's online backup support rather than copying active WAL files.
|
||||
4. Copy non-database durable files and verify their size and modification time before and after each copy, retrying a file that changed during the copy.
|
||||
5. Exclude `runtime/`, backup/restore staging, SQLite WAL/SHM files, and incomplete files that never become stable during bounded retries.
|
||||
6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums.
|
||||
7. Create the archive in temporary storage and stream it to the browser.
|
||||
8. Remove temporary staging whether the operation succeeds or fails.
|
||||
9. Resume normal mutations after the consistent snapshot has been captured; archive compression does not need to hold the server in maintenance mode.
|
||||
7. Stream the completed archive to the authorized browser and remove temporary staging afterward.
|
||||
|
||||
The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory.
|
||||
|
||||
`runtime/` is inside the filesystem boundary but is not durable backup content. Excluding it is necessary because an audio FIFO is a live process primitive rather than a regular file, and incomplete uploads or replay builds have no restore value. The backup coordinator must quiesce the owning services before clearing it so exclusion cannot disrupt active work.
|
||||
|
||||
An optional smaller **Download settings and state backup** may exclude explicitly regenerable, high-volume snapshots, replay segments, completed replays, and caches. This is secondary; the full backup remains the authoritative complete-server backup.
|
||||
`runtime/` is inside the filesystem boundary but is not durable backup content. Audio FIFOs, incomplete uploads, and in-progress replay builds have no restore value and are excluded without stopping their owners. The initial implementation provides only the authoritative full backup.
|
||||
|
||||
### Restore
|
||||
|
||||
Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation.
|
||||
|
||||
The restore service must:
|
||||
The restore operation must:
|
||||
|
||||
1. Require a lockdown administrator and recent password confirmation.
|
||||
2. Upload the archive into bounded staging controlled by the data directory.
|
||||
@@ -327,45 +342,14 @@ The restore service must:
|
||||
7. Display exactly what will be replaced.
|
||||
8. Require a final explicit confirmation.
|
||||
9. Record a pending-restore marker.
|
||||
10. Gracefully stop the application.
|
||||
10. Request the normal application restart.
|
||||
11. Apply the restore before ordinary services open their databases on the next start.
|
||||
12. Run database migrations against the restored data when necessary.
|
||||
13. Start the application and verify its health.
|
||||
|
||||
The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation.
|
||||
|
||||
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally.
|
||||
|
||||
### Command-line recovery
|
||||
|
||||
Backup and restore must also have command-line entry points that use the same implementation as the admin UI. They are needed when the web server cannot start or authentication data is damaged.
|
||||
|
||||
The command-line tools must support:
|
||||
|
||||
- Creating a consistent backup while the server is stopped
|
||||
- Validating a backup without applying it
|
||||
- Restoring while the server is stopped
|
||||
- Printing a concise manifest summary
|
||||
- Refusing unsafe or malformed archives
|
||||
|
||||
The UI and command line must not develop separate archive formats or validation behavior.
|
||||
|
||||
## 7. Standardize graceful application restart
|
||||
|
||||
Replace the current host reboot operation with a deployment-neutral **Restart application** operation.
|
||||
|
||||
The restart coordinator must:
|
||||
|
||||
1. Authorize and acknowledge the request.
|
||||
2. Stop accepting new persistent mutations.
|
||||
3. Flush or close persistent stores.
|
||||
4. Stop MediaMTX, ffmpeg, and native workers.
|
||||
5. Close HTTP and socket listeners within a bounded timeout.
|
||||
6. Exit with the status expected by the current supervisor.
|
||||
|
||||
During Phase 1, systemd restarts the process. During Phase 2, the container restart policy or lifecycle service restarts it. The browser should show a reconnect state and confirm the active configuration revision after reconnecting.
|
||||
|
||||
Host rebooting is a separate privilege and is not part of this application restart contract.
|
||||
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally. Backup and restore exist only in the protected admin application.
|
||||
|
||||
## 8. Internalize MediaMTX WHEP signaling
|
||||
|
||||
@@ -472,11 +456,12 @@ Implemented on 2026-09-14:
|
||||
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
|
||||
- Added configuration revision history, rollback, audit history, and immediate application reporting.
|
||||
- Added a serialized live-configuration coordinator and converted configurable service runtimes to apply changed sections without restarting Node. Passive policies read the current immutable snapshot; network, hardware, timer, and child-process services replace or retune their owned runtime while stable HTTP/socket handlers continue delegating to it. The admin editor reports any service-specific reload failure after the revision is safely committed.
|
||||
- Replaced the privileged host-reboot action with one lockdown-only, recently confirmed, audited application restart on the admin Overview. Node announces the restart, stops accepting new HTTP connections, and signals itself after acknowledging the browser; the existing service signal hooks clean up owned child processes, and systemd now restarts clean application exits without making `systemctl stop` ineffective.
|
||||
|
||||
Local verification completed:
|
||||
|
||||
- All 109 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, setup and initialized-server YAML import safety, recursive removal of nonexistent fields, and the earlier filesystem coverage.
|
||||
- All 24 server test files passed after live application was added. The new isolated coordinator test confirms coherent snapshot replacement, top-level change detection, per-service invocation, applied revision reporting, and failure isolation.
|
||||
- All 24 server test files passed after live application was added. The isolated coordinator test confirms coherent snapshot replacement, top-level change detection, per-service invocation, applied revision reporting, and failure isolation. Application-restart syntax, authorization wiring, and supervisor configuration were checked without exercising the real process signal on the development machine.
|
||||
- Focused admin, route, and identity UI lint passed.
|
||||
- All 20 existing focused web UI tests passed.
|
||||
- The production web UI build completed successfully and regenerated the checked-in server assets.
|
||||
@@ -693,8 +678,8 @@ Within the two hard phase boundaries, the safest order is:
|
||||
- [x] Build the centralized admin configuration UI.
|
||||
- [x] Add persistent audit history.
|
||||
- [x] Apply every configuration revision to running services without restarting the application.
|
||||
- [ ] Implement coordinated backup and staged restore.
|
||||
- [ ] Standardize graceful application restart.
|
||||
- [x] Standardize graceful application restart.
|
||||
- [ ] Implement online backup and restart-bound staged restore in one service.
|
||||
- [ ] Add the internal `/video` proxy and remove the special external route.
|
||||
- [ ] Run the full Phase 1 completion gate on the legacy deployment.
|
||||
- [ ] Build and verify the production application image.
|
||||
|
||||
@@ -305,7 +305,10 @@ Environment=NODE_ENV=production
|
||||
Environment=SERVER_DATA_DIR=$DATA_DIR
|
||||
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
# Application-requested restarts use the same clean SIGTERM path as an
|
||||
# operator stop. Restart=always lets that process exit come back automatically,
|
||||
# while an explicit `systemctl stop` still remains stopped by systemd design.
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
SuccessExitStatus=130 143
|
||||
|
||||
|
||||
+36
-36
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
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-Dt89YsCl.js";import{e as q,f as D,h as I}from"./api-DxMdZ4GH.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-DfXTyMt5.js.map
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-BwGg4g8v.js";import{f as q,h as D,j as I}from"./api-B_aBHpPa.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-TGICTsKn.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
function n(i,t,a={}){return new Promise((e,s)=>{i.emit(t,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=i=>n(i,"adminConfig:get"),m=(i,t)=>n(i,"adminConfig:confirmPassword",{password:t}),c=(i,t)=>n(i,"adminConfig:updateConfiguration",t),u=(i,t)=>n(i,"adminConfig:importConfigurationFile",t),f=(i,t)=>n(i,"adminConfig:restoreRevision",t),g=(i,t)=>n(i,"adminConfig:createAdministrator",t),C=(i,t)=>n(i,"adminConfig:updateAdministrator",t),l=(i,t)=>n(i,"adminConfig:deleteAdministrator",{id:t}),A=i=>n(i,"server:restartApplication"),p=i=>n(i,"setup:status"),v=(i,t)=>n(i,"setup:createAdministrator",t),F=(i,t)=>n(i,"setup:importConfigurationFile",t);export{A as a,c as b,g as c,l as d,m as e,p as f,d as g,v as h,u as i,F as j,f as r,C as u};
|
||||
//# sourceMappingURL=api-B_aBHpPa.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-B_aBHpPa.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\nexport const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","restartApplication","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EACxGC,EAAsBjB,GAAWD,EAAiBC,EAAQ,2BAA2B,EAErFkB,EAAkBlB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEmB,EAA2B,CAACnB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GkB,EAA0B,CAACpB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
@@ -1,2 +0,0 @@
|
||||
function n(i,t,a={}){return new Promise((e,s)=>{i.emit(t,a,(o={})=>{if(o?.error){const r=new Error(o.error);r.code=o.code||null,r.validationErrors=o.validationErrors||[],r.currentRevision=o.currentRevision||null,s(r);return}e(o)})})}const d=i=>n(i,"adminConfig:get"),m=(i,t)=>n(i,"adminConfig:confirmPassword",{password:t}),u=(i,t)=>n(i,"adminConfig:updateConfiguration",t),c=(i,t)=>n(i,"adminConfig:importConfigurationFile",t),f=(i,t)=>n(i,"adminConfig:restoreRevision",t),g=(i,t)=>n(i,"adminConfig:createAdministrator",t),C=(i,t)=>n(i,"adminConfig:updateAdministrator",t),l=(i,t)=>n(i,"adminConfig:deleteAdministrator",{id:t}),A=i=>n(i,"setup:status"),p=(i,t)=>n(i,"setup:createAdministrator",t),v=(i,t)=>n(i,"setup:importConfigurationFile",t);export{u as a,m as b,g as c,l as d,A as e,p as f,d as g,v as h,c as i,f as r,C as u};
|
||||
//# sourceMappingURL=api-DxMdZ4GH.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"api-DxMdZ4GH.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EAExGC,EAAkBjB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEkB,EAA2B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GiB,EAA0B,CAACnB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
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
@@ -12,7 +12,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-Dt89YsCl.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BwGg4g8v.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -342,6 +342,16 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
}));
|
||||
}
|
||||
|
||||
function recordAuditEvent(actor, action, details = {}) {
|
||||
/*
|
||||
Operational admin services need the same persistent audit trail as
|
||||
configuration changes, but they must not gain access to the underlying
|
||||
statement or database handle. This narrow method retains the existing
|
||||
redacted-details contract at the database boundary.
|
||||
*/
|
||||
writeAudit(actor, action, details);
|
||||
}
|
||||
|
||||
const importConfigurationFileTransaction = db.transaction(({ config, administrators, actor, source }) => {
|
||||
// A setup upload initializes an empty installation; it is deliberately not
|
||||
// a general-purpose replacement path for a running server's configuration.
|
||||
@@ -377,6 +387,7 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
countLockdownAdministrators,
|
||||
isSetupComplete,
|
||||
listAuditEvents,
|
||||
recordAuditEvent,
|
||||
importConfigurationFile,
|
||||
close: () => db.close(),
|
||||
};
|
||||
|
||||
@@ -208,4 +208,5 @@ io.on('connection', (socket) => {
|
||||
module.exports = {
|
||||
PASSWORD_CONFIRMATION_WINDOW_MS,
|
||||
requireLockdownAdministrator,
|
||||
requireRecentPassword,
|
||||
};
|
||||
|
||||
@@ -15,3 +15,16 @@ httpServer.listen(config.port, () => {
|
||||
*/
|
||||
startMediaMtx();
|
||||
});
|
||||
|
||||
function stopAcceptingConnections() {
|
||||
/*
|
||||
Child-process services already own their SIGTERM cleanup. The HTTP service
|
||||
only stops accepting new work; MediaMTX's bounded signal handler remains
|
||||
responsible for ending the Node process even if an existing socket keeps
|
||||
the close callback waiting.
|
||||
*/
|
||||
if (httpServer.listening) httpServer.close();
|
||||
}
|
||||
|
||||
process.once('SIGINT', stopAcceptingConnections);
|
||||
process.once('SIGTERM', stopAcceptingConnections);
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
// server Control Service
|
||||
// Purpose: Defines the server Control Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const { spawn } = require('child_process');
|
||||
// Purpose: Lets a lockdown administrator restart the Node application without rebooting or controlling the host.
|
||||
// Scope: Authorizes and announces one restart request; existing SIGTERM hooks own service and child-process cleanup.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('serverControlService');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { getConfigurationDatabase } = require('../../configuration');
|
||||
const { requireRecentPassword } = require('../adminConfigurationService');
|
||||
|
||||
const ALERT_COLOR = '#ff5722';
|
||||
let rebootPending = false;
|
||||
const database = getConfigurationDatabase();
|
||||
let restartPending = false;
|
||||
|
||||
function scheduleSystemReboot() {
|
||||
if (rebootPending) {
|
||||
throw new Error('Server reboot already pending');
|
||||
}
|
||||
rebootPending = true;
|
||||
function actorFor(socket) {
|
||||
return socket?.data?.user?.username || socket.id;
|
||||
}
|
||||
|
||||
function scheduleApplicationRestart() {
|
||||
if (restartPending) throw new Error('Application restart already pending.');
|
||||
restartPending = true;
|
||||
|
||||
/*
|
||||
Socket acknowledgements are asynchronous network writes. This short delay
|
||||
lets the response and restarting notification leave before SIGTERM invokes
|
||||
the cleanup hooks already owned by the server's long-running services.
|
||||
*/
|
||||
setTimeout(() => {
|
||||
logger.warn('Issuing system reboot command');
|
||||
try {
|
||||
const child = spawn('systemctl', ['reboot'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
} catch (err) {
|
||||
rebootPending = false;
|
||||
logger.error('Server reboot command failed', err.message);
|
||||
process.kill(process.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
// If signaling fails, this process is still usable and must allow the
|
||||
// administrator to try again instead of remaining permanently pending.
|
||||
restartPending = false;
|
||||
logger.error('Application restart signal failed', error.message);
|
||||
}
|
||||
}, 400);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('server:reboot', (_, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Not authorized' });
|
||||
return;
|
||||
}
|
||||
socket.on('server:restartApplication', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
scheduleSystemReboot();
|
||||
const who = socket?.data?.user?.username || socket.id;
|
||||
logger.warn('Server reboot requested', { by: who });
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Server Reboot',
|
||||
message: `Reboot requested by ${who}`,
|
||||
});
|
||||
requireRecentPassword(socket);
|
||||
const actor = actorFor(socket);
|
||||
if (restartPending) throw new Error('Application restart already pending.');
|
||||
database.recordAuditEvent(actor, 'application.restart-requested');
|
||||
scheduleApplicationRestart();
|
||||
logger.warn('Application restart requested', { actor });
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
// Every connected browser receives one explicit reason for the upcoming
|
||||
// disconnect instead of interpreting the brief outage as a network fault.
|
||||
io.emit('server:restarting', { reason: 'administrator-requested' });
|
||||
} catch (error) {
|
||||
cb({ error: error.message, code: error.code || null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const restoreConfigurationRevision = (socket, payload) => emitAdminReques
|
||||
export const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);
|
||||
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
|
||||
export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });
|
||||
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
|
||||
|
||||
export const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');
|
||||
export const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Administration Overview
|
||||
// Purpose: Summarizes configuration state, revision history, audit history, and links to existing health/report surfaces.
|
||||
// Scope: Presents persisted administration metadata without duplicating operational service implementations.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { restoreConfigurationRevision } from '../api.js';
|
||||
import { restartApplication, restoreConfigurationRevision } from '../api.js';
|
||||
|
||||
function formatDate(value) {
|
||||
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
|
||||
@@ -11,6 +12,16 @@ function formatDate(value) {
|
||||
|
||||
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
|
||||
const config = snapshot.configuration;
|
||||
const [restartRequested, setRestartRequested] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restartRequested) return undefined;
|
||||
// The socket reconnect is the simplest authoritative success signal: the
|
||||
// replacement process is accepting browser connections again.
|
||||
const handleReconnect = () => setRestartRequested(false);
|
||||
socket.once('connect', handleReconnect);
|
||||
return () => socket.off('connect', handleReconnect);
|
||||
}, [restartRequested, socket]);
|
||||
|
||||
async function restore(revision) {
|
||||
if (!window.confirm(`Restore configuration revision ${revision}? This creates and immediately applies a new active revision.`)) return;
|
||||
@@ -25,6 +36,17 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
}
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
if (!window.confirm('Restart the MultiRover application now? Connected rovers and browsers will disconnect briefly; the server host will not reboot.')) return;
|
||||
setRestartRequested(true);
|
||||
try {
|
||||
await runSensitive(() => restartApplication(socket));
|
||||
} catch (error) {
|
||||
setRestartRequested(false);
|
||||
window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<CardFrame title="Administration overview" meta={`revision ${config.revision}`} bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-3">
|
||||
@@ -36,6 +58,12 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
<Link className="button-dark" to="/reports">Open fleet reports</Link>
|
||||
<Link className="button-dark" to="/">Open driver application</Link>
|
||||
</CardFrame>
|
||||
<CardFrame title="Application" bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
<p className="text-slate-300">Restart only the MultiRover application. The process supervisor starts it again automatically without rebooting the host.</p>
|
||||
<button type="button" className="button-danger" disabled={restartRequested} onClick={restart}>
|
||||
{restartRequested ? 'Waiting for application…' : 'Restart application'}
|
||||
</button>
|
||||
</CardFrame>
|
||||
<CardFrame title="Configuration revisions" meta={snapshot.revisions.length} bodyClassName="max-h-64 overflow-y-auto p-0.5 text-xs">
|
||||
{snapshot.revisions.map((revision) => (
|
||||
<div key={revision.revision} className="surface mb-0.5 grid items-center gap-0.5 p-0.5 md:grid-cols-[5rem_1fr_1fr_1fr_auto]">
|
||||
|
||||
@@ -105,7 +105,6 @@ export default function AdminPanelContent() {
|
||||
rebootRover,
|
||||
updateRover,
|
||||
updateAllRovers,
|
||||
rebootServer,
|
||||
setAudioLevels,
|
||||
setPersonalAudioAdjustmentRange,
|
||||
setPrivateSafety,
|
||||
@@ -119,7 +118,6 @@ export default function AdminPanelContent() {
|
||||
const [lockStates, setLockStates] = useState({});
|
||||
const [rebootStates, setRebootStates] = useState({});
|
||||
const [updateStates, setUpdateStates] = useState({});
|
||||
const [serverRebooting, setServerRebooting] = useState(false);
|
||||
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
||||
const [clearingOverseerHistory, setClearingOverseerHistory] = useState(false);
|
||||
const health = session?.health || null;
|
||||
@@ -229,18 +227,6 @@ export default function AdminPanelContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerReboot = async () => {
|
||||
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
||||
if (!ok) return;
|
||||
setServerRebooting(true);
|
||||
try {
|
||||
await rebootServer();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
setServerRebooting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearLlmHistory = async () => {
|
||||
const ok = window.confirm(
|
||||
'Clear LLM commentary history now? This resets chat context, bot memory, and rover activity metrics for narration.',
|
||||
@@ -421,14 +407,6 @@ export default function AdminPanelContent() {
|
||||
return (
|
||||
<CardFrame title="Admin controls" actions={actions} bodyClassName="space-y-0.5 text-base">
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleServerReboot}
|
||||
disabled={serverRebooting}
|
||||
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{serverRebooting ? 'Server rebooting...' : 'Reboot Server'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestRewardOverlay}
|
||||
|
||||
@@ -392,7 +392,6 @@ export function SessionProvider({ children }) {
|
||||
// intent, then the server checks privileges and fans out the same fixed
|
||||
// update command to every currently connected rover.
|
||||
updateAllRovers: () => emitWithAck('command:updateAllRovers'),
|
||||
rebootServer: () => emitWithAck('server:reboot'),
|
||||
playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
|
||||
emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),
|
||||
stopUploadedAudio: (roverId) => emitWithAck('audio:uploadStop', { roverId }),
|
||||
|
||||
Reference in New Issue
Block a user