mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Compare commits
10
Commits
6b6fbcf871
...
609eb6c35e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
609eb6c35e | ||
|
|
c3c60fde18 | ||
|
|
aa3c0c0e7b | ||
|
|
8b5d7372f9 | ||
|
|
7539898f98 | ||
|
|
73255831f1 | ||
|
|
3b585b06b4 | ||
|
|
55ee6e05bb | ||
|
|
5d4f7fe5cc | ||
|
|
bdb32d13ac |
+16
-3
@@ -3,8 +3,8 @@
|
||||
# MultiRover Production Application Image
|
||||
# Purpose: Builds the web application, Node dependencies, native hardware
|
||||
# workers, and pinned runtime tools into one amd64 server image.
|
||||
# Scope: Packages the main application only. Compose, host hardware access, and
|
||||
# lifecycle/update control remain separate deployment concerns.
|
||||
# Scope: Packages the application and its private controller command in one
|
||||
# image. Compose still isolates their processes, mounts, and privileges.
|
||||
|
||||
ARG FEDORA_VERSION=43
|
||||
|
||||
@@ -98,8 +98,15 @@ RUN curl --fail --location --retry 3 \
|
||||
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||
ARG FEDORA_VERSION
|
||||
ARG TARGETARCH
|
||||
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
||||
# Fedora's restricted ffmpeg-free build omits the libx264 encoder used by every
|
||||
# replay output path. Enable RPM Fusion Free before installing runtime packages
|
||||
# so the image receives the complete FFmpeg build instead of requiring replay
|
||||
# code to work around a deployment-only codec omission.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm"
|
||||
# This is the complete runtime package set. Build headers and compilers live in
|
||||
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
||||
# because enabled services invoke them after startup. Weak dependencies are
|
||||
@@ -109,7 +116,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
bluez \
|
||||
bluez-libs \
|
||||
espeak \
|
||||
ffmpeg-free \
|
||||
ffmpeg \
|
||||
flite \
|
||||
gstreamer1 \
|
||||
gstreamer1-plugins-bad-free \
|
||||
@@ -126,6 +133,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
shadow-utils \
|
||||
tini \
|
||||
wiiuse \
|
||||
--allowerasing \
|
||||
&& dnf clean all \
|
||||
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
|
||||
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
|
||||
@@ -168,6 +176,11 @@ USER multirover
|
||||
# `/data` on an anonymous volume rather than the replaceable image layer.
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080/tcp 8554/tcp 8189/tcp 8189/udp
|
||||
# Use Node's built-in fetch so container readiness does not require curl or a
|
||||
# second probe binary in the runtime image. The endpoint verifies the writable
|
||||
# data mount and MediaMTX; reaching it already proves Node is accepting HTTP.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
|
||||
+34
-26
@@ -1,48 +1,56 @@
|
||||
# MultiRover production deployment
|
||||
#
|
||||
# This intentionally contains only the application container. The later update
|
||||
# controller will be added as a second service when it exists; keeping it out
|
||||
# now means this first deployment pass has no placeholder process or unused
|
||||
# privilege.
|
||||
name: multirover
|
||||
|
||||
# Change this one line to use a development branch image.
|
||||
x-multirover-image: &multirover-image ghcr.io/legop3/multiroombarover:latest
|
||||
|
||||
services:
|
||||
server:
|
||||
image: ghcr.io/legop3/multiroombarover:latest
|
||||
image: *multirover-image
|
||||
container_name: multirover
|
||||
|
||||
# Host networking preserves the server's existing LAN behavior and avoids
|
||||
# maintaining a second list of TCP, UDP, RTSP, and WebRTC port mappings.
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
|
||||
# SELinux cannot safely relabel the host's system D-Bus socket, and the
|
||||
# hardware services also need host-owned USB device nodes. Disable Docker's
|
||||
# per-container SELinux label while retaining its namespace, capability,
|
||||
# non-root-user, and seccomp isolation. This is narrower than privileged
|
||||
# mode and avoids changing labels on shared host resources.
|
||||
security_opt:
|
||||
- label=disable
|
||||
|
||||
volumes:
|
||||
# This is the application's only persistent storage. No SELinux relabel
|
||||
# is needed because this hardware-integrated container runs label-free.
|
||||
- ./data:/data
|
||||
|
||||
# bluetoothctl talks to the host Bluetooth daemon over this socket. The
|
||||
# socket is read-only as a filesystem mount; D-Bus method calls still flow
|
||||
# through it normally without exposing the rest of the host D-Bus tree.
|
||||
# All persistent application data is stored in this volume.
|
||||
- data:/data
|
||||
- lifecycle-socket:/run/multirover
|
||||
# Required for Bluetooth hardware such as the Balance Board.
|
||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
|
||||
|
||||
# Kinect USB node numbers change when it reconnects, so expose the bus
|
||||
# directory rather than one temporary device path. The existing host udev
|
||||
# rule remains responsible for granting the non-root container user access.
|
||||
# Required for Kinect USB access.
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
|
||||
# Only the Balance Board worker receives this capability through its file
|
||||
# capabilities. The Node process remains non-root and the container is not
|
||||
# privileged.
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
lifecycle:
|
||||
image: *multirover-image
|
||||
container_name: multirover-lifecycle
|
||||
command: ["node", "src/services/serverControlService/controller.js"]
|
||||
user: root
|
||||
network_mode: none
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
disable: true
|
||||
security_opt:
|
||||
- label=disable
|
||||
environment:
|
||||
MULTIROVER_TARGET_IMAGE: *multirover-image
|
||||
volumes:
|
||||
- lifecycle-socket:/run/multirover
|
||||
# Do not add this Docker socket mount to the server service.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
volumes:
|
||||
# `docker compose down -v` permanently deletes these volumes.
|
||||
data:
|
||||
name: multirover-data
|
||||
lifecycle-socket:
|
||||
name: multirover-lifecycle-socket
|
||||
|
||||
@@ -10,6 +10,8 @@ This document is the live implementation tracker for the migration.
|
||||
- [x] Phase 1, step 9: Complete the remaining legacy-deployment integration and hardware verification
|
||||
- [x] Phase 2, step 10: Build and locally verify the production application image
|
||||
- [x] Phase 2, steps 11-12: Add the single-container Compose deployment and locally verify its host-access contract
|
||||
- [x] Phase 2, step 13: Add application and container health checks
|
||||
- [x] Phase 2, step 14: Add the restricted lifecycle container and connect the System UI
|
||||
- [x] Phase 2, step 15: Build pull requests and publish the main branch to the single GHCR `latest` channel
|
||||
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
|
||||
|
||||
@@ -26,6 +28,7 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
|
||||
|
||||
- 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: 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-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: 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.
|
||||
@@ -51,7 +54,7 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
|
||||
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
|
||||
- The latest successful main-branch image is built automatically and published to GHCR as `ghcr.io/legop3/multiroombarover:latest`.
|
||||
- The admin UI can restart, update, health-check, and roll back the application container without giving the main application direct Docker access.
|
||||
- The final host installation contains as little project-specific material as possible: a Compose file, a data directory, and unavoidable hardware preparation.
|
||||
- The final host installation contains as little project-specific material as possible: a Compose file, one Docker-managed data volume, and unavoidable hardware preparation.
|
||||
|
||||
## Important boundary: application files versus server data
|
||||
|
||||
@@ -470,6 +473,10 @@ Implemented on 2026-09-14:
|
||||
- Fixed production WAL-mode snapshots creating unmanifested SQLite `-wal` and `-shm` files during schema inspection. Backup and restore validation now remove only those temporary staged sidecars before archiving or applying data, and the regression fixture uses WAL mode to match the real databases.
|
||||
- Added the early streaming `/video` middleware with `http-proxy-middleware`. Express removes the public prefix before forwarding WHEP/WHIP requests to `127.0.0.1:8889`, while root-relative MediaMTX session locations receive the prefix again so subsequent browser `PATCH` and `DELETE` requests follow the same path. MediaMTX signaling now binds to loopback; its ICE UDP/TCP listener remains directly reachable on port 8189.
|
||||
- Replaced Discord's `siteUrl`, the inter-instance profile's `publicUrl`, and media `whepBaseUrl` with one top-level `publicUrl`. A numbered internal database migration transforms every saved configuration revision before current validation, and the media section now contains only optional additional ICE hosts. WHEP and microphone WHIP URLs are fixed relative paths, so they work through the current origin without knowing its hostname.
|
||||
- Discord command authorization and lockdown moderation recipients now read the live administrator registry, so setup imports and later Discord-ID or role edits take effect without restarting the server.
|
||||
- Full-data restore now leaves `runtime/` untouched, matching its existing exclusion from backup archives and preventing the non-root application from trying to remove lifecycle-controller state owned by the root controller container.
|
||||
- The Users and administrators tab now requests at most 100 lightweight identity summaries through one bounded SQLite query. Search and moderation filters run on the server, while complete signals, permissions, and feature state load only after selecting a user, preventing large identity databases from blocking Socket.IO heartbeats or freezing the browser.
|
||||
- Removed the remaining server-local SRT hops after Fedora's newer libSRT rejected the zero-payload ACKACK packets emitted by MediaMTX's GoSRT implementation on every acknowledgement cycle. PTZ publishing, replay capture, and snapshot capture now share the existing RTSP/TCP listener, SRT is disabled, and browser-session authorization is bypassed only for loopback readers and rover `-fwd` speaker feeds.
|
||||
- Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response.
|
||||
|
||||
Local verification completed:
|
||||
@@ -550,6 +557,7 @@ Implemented and locally verified on 2026-09-15:
|
||||
- Configured `/data` as `SERVER_DATA_DIR`, ran Node as the dedicated uid 1000 `multirover` user, retained only the Balance Board worker's required capabilities, and used `tini` as the container init process.
|
||||
- Successfully built and loaded `multiroombarover:local` for `linux/amd64`. Its registry-style compressed content size is approximately 828 MB; Docker reports approximately 2.87 GB of local unpacked disk usage because the complete GStreamer, ffmpeg, Kinect, Node, and offline TTS runtime is intentionally included.
|
||||
- Confirmed at build time that Chrome TTS loads its packaged voice model and produces a nonempty WAV file.
|
||||
- Replaced Fedora's restricted `ffmpeg-free` package with RPM Fusion Free's complete `ffmpeg` package after development-container testing exposed that `ffmpeg-free` omits the `libx264` encoder required by rover replay capture, room-camera replay rendering, replay sidebars, and final replay assembly.
|
||||
- Started the image with host networking and a temporary SELinux-relabeled `/data` bind mount. The application reached its HTTP listener, generated first-run state only inside the mount, and started the packaged MediaMTX with its generated configuration under `/data`.
|
||||
- Confirmed `/`, `/setup`, and `/admin` return the production UI; `/video/` reaches the loopback MediaMTX proxy; MediaMTX and Neolink execute; both native workers link against the runtime image; and the Balance Board worker retains only `cap_net_admin` and `cap_net_bind_service`.
|
||||
- Restarted the same container and confirmed the setup credential and configuration database were byte-for-byte unchanged, then confirmed `/admin` returned successfully again.
|
||||
@@ -557,14 +565,17 @@ Implemented and locally verified on 2026-09-15:
|
||||
|
||||
## 11. Compose deployment
|
||||
|
||||
The host-visible installation should be only:
|
||||
The host-visible project installation should be only:
|
||||
|
||||
```text
|
||||
multirover/
|
||||
├── compose.yaml
|
||||
└── data/
|
||||
└── compose.yaml
|
||||
```
|
||||
|
||||
Docker owns the separately persisted `multirover-data` volume. Operators move
|
||||
or inspect its complete contents through the administration backup/restore UI
|
||||
rather than coordinating host filesystem ownership with the container user.
|
||||
|
||||
The Compose project contains:
|
||||
|
||||
- The main Multirover application container
|
||||
@@ -573,7 +584,7 @@ The Compose project contains:
|
||||
The application mounts:
|
||||
|
||||
```text
|
||||
./data:/data
|
||||
data:/data
|
||||
```
|
||||
|
||||
Host networking is the initial preferred design because it most closely preserves current rover RTSP, WebRTC ICE, UDP media, camera, and LAN integration behavior. The exact listeners must be audited before finalizing the Compose file.
|
||||
@@ -584,15 +595,15 @@ Expected externally relevant listeners are:
|
||||
- Rover RTSP publishing on TCP 8554
|
||||
- WebRTC media on TCP and UDP 8189
|
||||
|
||||
MediaMTX WHEP on 8889, API/metrics listeners, and server-local SRT should stay on loopback unless an identified remote consumer requires otherwise.
|
||||
MediaMTX WHEP on 8889 and API/metrics listeners should stay on loopback unless an identified remote consumer requires otherwise. Publishers and server-local replay/snapshot readers use the single RTSP/TCP listener on 8554; SRT is disabled.
|
||||
|
||||
### Compose implementation notes
|
||||
|
||||
Implemented and locally verified on 2026-09-15:
|
||||
|
||||
- Added one root `compose.yaml` containing only the main application. It uses `ghcr.io/legop3/multiroombarover:latest`, host networking, `restart: unless-stopped`, and the single `./data:/data` persistent bind mount. The lifecycle service remains a later, separate step rather than a placeholder in the initial deployment.
|
||||
- Added the Compose-mounted root `data` directory to `.dockerignore`, alongside the legacy `server/data`, so credentials, databases, recordings, backups, and generated state cannot enter later image builds.
|
||||
- Started the exact Compose definition from an empty root data directory using the locally built image tagged with the final GHCR name. The application created its configuration database, setup credential, and generated MediaMTX configuration only under that mount.
|
||||
- Added one root `compose.yaml` containing only the main application. It uses `ghcr.io/legop3/multiroombarover:latest`, host networking, `restart: unless-stopped`, and the single `data:/data` persistent named-volume mount. The lifecycle service remains a later, separate step rather than a placeholder in the initial deployment.
|
||||
- Added both possible local data directories to `.dockerignore`, alongside the legacy `server/data`, so credentials, databases, recordings, backups, and generated state cannot enter later image builds even during development or manual inspection.
|
||||
- Started the exact Compose definition from an empty Docker-managed volume using the locally built image tagged with the final GHCR name. The application created its configuration database, setup credential, and generated MediaMTX configuration only under that volume.
|
||||
- Confirmed the production UI responds on `/`, `/setup`, and `/admin`. A request to `/video/` reached the internal MediaMTX proxy and received MediaMTX's expected not-found response because the empty configuration had no requested stream.
|
||||
- Restarted through Compose and confirmed the setup credential and configuration database remained byte-for-byte unchanged. A separate marker created through `/data` also remained present after restart.
|
||||
|
||||
@@ -640,6 +651,14 @@ Optional remote integrations should report degraded status to administrators wit
|
||||
|
||||
Compose should use the health endpoint and a restart policy suitable for unattended operation.
|
||||
|
||||
### Health-check implementation notes
|
||||
|
||||
Implemented on 2026-09-15:
|
||||
|
||||
- Added an unauthenticated `GET /health` readiness endpoint that exposes only two non-sensitive booleans: whether the application user can read and write the configured data directory and whether MediaMTX answers through its loopback-only metrics listener.
|
||||
- Treated successful route execution as proof that Node is accepting HTTP and that configuration initialization completed. This avoids repeatedly querying every SQLite database or turning optional integrations and currently offline media sources into container restart conditions.
|
||||
- Added the image-level Docker health check using Node's built-in `fetch`, so Compose receives the readiness state without installing another command-line probe utility.
|
||||
|
||||
## 14. Restricted lifecycle container
|
||||
|
||||
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
|
||||
@@ -671,6 +690,17 @@ These operations require a lockdown administrator and recent password confirmati
|
||||
|
||||
The Compose contract should remain stable so ordinary main-branch image updates replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
|
||||
|
||||
### Lifecycle-controller implementation notes
|
||||
|
||||
Implemented on 2026-09-15:
|
||||
|
||||
- Reused the single published application image for the lifecycle service with a controller-only command. This avoids a second Dockerfile, image name, GHCR workflow, and release lifecycle while the two containers still run separate processes with separate privileges.
|
||||
- Mounted `/var/run/docker.sock` only in the network-disabled lifecycle container. The application communicates through a dedicated Unix-socket volume and cannot submit an image name, container name, command, or Docker option; the controller operates only on the fixed `multirover` container and the deployment-selected MultiRover image.
|
||||
- Added fixed status, update-check, restart, and update operations. Update checks pull the configured moving image and compare Docker image IDs. Updates retain the previous image ID, recreate the application with its existing Compose host contract, wait for the image health check, and restore the previous image when replacement health fails.
|
||||
- Persisted the current operation and result in the private lifecycle Unix-socket volume. This survives ordinary application replacement and browser reconnection without mounting the root lifecycle controller into the application's `/data` volume, leaving all application runtime paths owned by the non-root server.
|
||||
- Connected the existing lockdown-administrator password confirmation and audit history to the lifecycle operations. The Administration overview polls persisted progress, reports update and rollback results, and keeps the legacy process-level restart only when no controller socket exists.
|
||||
- Defined the deployment image once through a Compose YAML anchor. Both services and the controller target reuse that exact value, so production stays on `ghcr.io/legop3/multiroombarover:latest` and development requires changing only the single visible selector line to a branch tag.
|
||||
|
||||
## 15. GHCR publishing automation
|
||||
|
||||
Add repository automation that:
|
||||
@@ -702,7 +732,7 @@ The actual deployment migration should:
|
||||
1. Download and validate a full Phase 1 backup.
|
||||
2. Stop and disable the legacy Multirover systemd service.
|
||||
3. Ensure no legacy MediaMTX service remains active.
|
||||
4. Place the Compose file beside the existing data directory or move that directory once while the service is stopped.
|
||||
4. Place the Compose file on the host; Docker creates the named data volume on first start.
|
||||
5. Start the application and lifecycle containers.
|
||||
6. Confirm that database migrations complete.
|
||||
7. Confirm the active configuration revision and administrator access.
|
||||
@@ -717,9 +747,9 @@ The old systemd application and the Compose application must never run concurren
|
||||
|
||||
Containerization is complete when:
|
||||
|
||||
- A new host can start from one Compose file and an empty data directory.
|
||||
- A new host can start from one Compose file and an automatically created empty data volume.
|
||||
- Existing state can be restored from a Phase 1 full backup.
|
||||
- `./data:/data` is the only persistent application mount.
|
||||
- `data:/data` is the only persistent application mount.
|
||||
- Replacing the application container preserves all state.
|
||||
- The special external `/video` MediaMTX route is unnecessary.
|
||||
- The main container has no Docker socket access and is not fully privileged.
|
||||
@@ -749,7 +779,7 @@ Within the two hard phase boundaries, the safest order is:
|
||||
- [x] Build and verify the production application image.
|
||||
- [x] Add Compose, data mounting, networking, and hardware access.
|
||||
- [x] Add GHCR build and publication automation.
|
||||
- [ ] Add the restricted lifecycle container and connect the System UI.
|
||||
- [x] Add the restricted lifecycle container and connect the System UI.
|
||||
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
|
||||
- [ ] Perform the final systemd-to-Compose cutover.
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ case "$PATH_NAME" in
|
||||
esac
|
||||
|
||||
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
|
||||
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
||||
-rtsp_transport tcp \
|
||||
-i "rtsp://127.0.0.1:8554/${PATH_NAME}" \
|
||||
-an \
|
||||
-vf "$FILTER" \
|
||||
-q:v "$QUALITY" \
|
||||
|
||||
Generated
+363
@@ -13,6 +13,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"discord.js": "^14.25.1",
|
||||
"dockerode": "^5.0.1",
|
||||
"express": "^4.19.2",
|
||||
"fuse.js": "^7.4.2",
|
||||
"home-assistant-js-websocket": "^3.1.2",
|
||||
@@ -35,6 +36,12 @@
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@balena/dockerignore": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
|
||||
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@discordjs/builders": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||
@@ -185,6 +192,55 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.14.4",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
|
||||
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz",
|
||||
"integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"long": "^5.0.0",
|
||||
"protobufjs": "^7.5.5",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/proto-loader": {
|
||||
"version": "0.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
||||
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"long": "^5.0.0",
|
||||
"protobufjs": "^7.2.5",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@huggingface/jinja": {
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
|
||||
@@ -1088,6 +1144,16 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@js-sdsl/ordered-map": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -1276,6 +1342,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -1302,6 +1392,15 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -1373,6 +1472,15 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.11.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||
@@ -1500,6 +1608,15 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1572,6 +1689,20 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@@ -1666,6 +1797,20 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -1803,6 +1948,61 @@
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/docker-modem": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz",
|
||||
"integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"readable-stream": "^3.5.0",
|
||||
"split-ca": "^1.0.1",
|
||||
"ssh2": "^1.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docker-modem/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/docker-modem/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dockerode": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz",
|
||||
"integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"@grpc/grpc-js": "^1.11.1",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"docker-modem": "^5.0.7",
|
||||
"protobufjs": "^7.3.2",
|
||||
"tar-fs": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1823,6 +2023,12 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -1951,6 +2157,15 @@
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -2184,6 +2399,15 @@
|
||||
"url": "https://github.com/sponsors/krisk"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -2505,6 +2729,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -2585,6 +2818,12 @@
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.snakecase": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||
@@ -2796,6 +3035,13 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.29.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz",
|
||||
"integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -3264,6 +3510,15 @@
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -3724,12 +3979,35 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/split-ca": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
|
||||
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -3748,6 +4026,32 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
@@ -3875,6 +4179,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
@@ -3975,6 +4285,23 @@
|
||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -4024,6 +4351,15 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
@@ -4032,6 +4368,33 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
|
||||
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"discord.js": "^14.25.1",
|
||||
"dockerode": "^5.0.1",
|
||||
"express": "^4.19.2",
|
||||
"fuse.js": "^7.4.2",
|
||||
"home-assistant-js-websocket": "^3.1.2",
|
||||
|
||||
@@ -16,6 +16,20 @@ app.use(morgan('dev'));
|
||||
*/
|
||||
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({ logger }));
|
||||
app.use(express.json());
|
||||
/*
|
||||
Docker and the later lifecycle controller need one stable readiness result,
|
||||
but they do not need administrator credentials or application details. Load
|
||||
the health service only when the route is called so the global HTTP module
|
||||
remains safe to initialize before the service graph during bootstrap.
|
||||
*/
|
||||
app.get('/health', async (_req, res) => {
|
||||
const { getContainerHealth } = require('../services/healthService');
|
||||
const health = await getContainerHealth();
|
||||
res.status(health.healthy ? 200 : 503).json({
|
||||
status: health.healthy ? 'healthy' : 'unhealthy',
|
||||
checks: health.checks,
|
||||
});
|
||||
});
|
||||
app.use(express.static(config.staticDir, { index: false }));
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
@@ -116,7 +116,9 @@ test('applies validated replacement data and removes rollback only after startup
|
||||
const applied = startupRestore.applyPendingRestore();
|
||||
assert.equal(applied.status, 'awaiting-health');
|
||||
assert.equal(fs.existsSync(path.join(temporaryRoot, 'old-state.txt')), false);
|
||||
assert.equal(fs.existsSync(path.join(temporaryRoot, 'runtime')), false);
|
||||
// Runtime is outside the durable backup payload and can contain state owned
|
||||
// by the separate lifecycle controller, so applying a restore preserves it.
|
||||
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'utf8'), 'discard during restore');
|
||||
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), '{"preserved":true}\n');
|
||||
assert.equal(fs.existsSync(path.join(temporaryRoot, 'backup-restore', 'rollback', 'old-state.txt')), true);
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@ function listActiveDataEntries({ includeRuntime = true } = {}) {
|
||||
}
|
||||
|
||||
function removeActiveData() {
|
||||
for (const name of listActiveDataEntries()) {
|
||||
// Runtime contains disposable work owned by active companion processes as
|
||||
// well as the lifecycle controller's status directory. It is deliberately
|
||||
// absent from backup archives, so restore must leave it untouched instead
|
||||
// of trying to delete root-owned controller state from the non-root server.
|
||||
for (const name of listActiveDataEntries({ includeRuntime: false })) {
|
||||
fs.rmSync(path.join(resolveDataDir(), name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +90,7 @@ const {
|
||||
const config = structuredClone(loadConfig());
|
||||
const discordConfig = config.discord || {};
|
||||
let enabled = Boolean(discordConfig.enabled);
|
||||
// These normalized command names mirror the command router. Bridge-channel
|
||||
// command replies are mirrored into web chat, so this entrypoint needs to know
|
||||
// the configured command names before it wraps message.reply.
|
||||
const configuredAdministrators = getConfigurationDatabase().listAdministrators();
|
||||
const adminIds = new Set(configuredAdministrators.map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
|
||||
const lockdownAdminIds = new Set(configuredAdministrators.filter((admin) => admin.role === 'lockdown').map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
|
||||
const configurationDatabase = getConfigurationDatabase();
|
||||
|
||||
if (!enabled) logger.info('Discord disabled by config');
|
||||
|
||||
@@ -122,12 +117,34 @@ function sanitizeMentions(text) {
|
||||
.replace(/@here/gi, '[here]');
|
||||
}
|
||||
|
||||
function findDiscordAdministrator(discordId) {
|
||||
const normalizedDiscordId = String(discordId || '').trim();
|
||||
if (!normalizedDiscordId) return null;
|
||||
|
||||
// Read the administrator registry at the moment Discord checks permission.
|
||||
// Setup imports and administrator edits happen after this module starts, so
|
||||
// a startup-only Set would remain stale until the whole server restarted.
|
||||
return configurationDatabase.listAdministrators().find(
|
||||
(administrator) => String(administrator.discordId || '').trim() === normalizedDiscordId,
|
||||
) || null;
|
||||
}
|
||||
|
||||
function isAdminUser(discordId) {
|
||||
return adminIds.has(String(discordId || '').trim());
|
||||
return Boolean(findDiscordAdministrator(discordId));
|
||||
}
|
||||
|
||||
function isLockdownAdminUser(discordId) {
|
||||
return lockdownAdminIds.has(String(discordId || '').trim());
|
||||
return findDiscordAdministrator(discordId)?.role === 'lockdown';
|
||||
}
|
||||
|
||||
function getLockdownAdminIds() {
|
||||
// Moderation requests use the same live registry as command authorization,
|
||||
// ensuring newly imported or edited lockdown accounts receive DMs without a
|
||||
// restart or a second cache-synchronization system.
|
||||
return configurationDatabase.listAdministrators()
|
||||
.filter((administrator) => administrator.role === 'lockdown')
|
||||
.map((administrator) => String(administrator.discordId || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function countReady() {
|
||||
@@ -301,7 +318,7 @@ const commands = createCommandHandlers(commandDependencies);
|
||||
getPrivateAccessRequestByMessageId,
|
||||
approvePrivateAccessRequest,
|
||||
denyPrivateAccessRequest,
|
||||
lockdownAdminIds,
|
||||
getLockdownAdminIds,
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
|
||||
@@ -5,7 +5,7 @@ function createDmModerationHandlers(deps) {
|
||||
const {
|
||||
logger,
|
||||
client,
|
||||
lockdownAdminIds,
|
||||
getLockdownAdminIds,
|
||||
attachDmMessage,
|
||||
getRequestByMessageId,
|
||||
approveRequest,
|
||||
@@ -36,7 +36,9 @@ function createDmModerationHandlers(deps) {
|
||||
'',
|
||||
`React with ${APPROVE} to approve or ${DENY} to deny.`,
|
||||
].join('\n');
|
||||
await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => {
|
||||
// Resolve recipients when the request occurs so setup imports and account
|
||||
// edits take effect immediately instead of waiting for a server restart.
|
||||
await Promise.all(getLockdownAdminIds().map(async (adminId) => {
|
||||
try {
|
||||
const user = await client.users.fetch(String(adminId));
|
||||
if (!user) return;
|
||||
@@ -69,7 +71,9 @@ function createDmModerationHandlers(deps) {
|
||||
'',
|
||||
`React with ${APPROVE} to approve or ${DENY} to deny.`,
|
||||
].join('\n');
|
||||
await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => {
|
||||
// Keep private-access moderation on the same live administrator registry
|
||||
// used by command authorization and verification requests.
|
||||
await Promise.all(getLockdownAdminIds().map(async (adminId) => {
|
||||
try {
|
||||
const user = await client.users.fetch(String(adminId));
|
||||
if (!user) return;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// Purpose: Defines the health 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 fsp = require('fs/promises');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
|
||||
const { resolveDataDir, resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoomCameras } = require('../roomCameraService');
|
||||
const { getRoomCameraState } = require('../roomCameraService');
|
||||
@@ -13,6 +14,8 @@ const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
|
||||
const HEALTH_INTERVAL_MS = 5000;
|
||||
const ROOM_CAMERA_STALE_MS = 5000;
|
||||
const ROVER_SNAPSHOT_STALE_MS = 5000;
|
||||
const MEDIAMTX_HEALTH_URL = 'http://127.0.0.1:9998/metrics';
|
||||
const MEDIAMTX_HEALTH_TIMEOUT_MS = 1500;
|
||||
|
||||
let latest = {
|
||||
updatedAt: Date.now(),
|
||||
@@ -88,6 +91,49 @@ function getHealthSnapshot() {
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function getContainerHealth() {
|
||||
let dataDirectory = false;
|
||||
let mediaMtx = false;
|
||||
|
||||
try {
|
||||
/*
|
||||
The mounted data root is the container's only persistent storage. Check
|
||||
the directory itself instead of creating a probe file on every request;
|
||||
this proves that the running application user can reach the mount without
|
||||
adding health-check writes to backups or administrative file listings.
|
||||
*/
|
||||
await fsp.access(resolveDataDir(), fs.constants.R_OK | fs.constants.W_OK);
|
||||
dataDirectory = true;
|
||||
} catch {
|
||||
dataDirectory = false;
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
MediaMTX already exposes metrics only on loopback, so it is also the
|
||||
smallest reliable readiness probe. Reading the response closes the body
|
||||
before this request completes and avoids accumulating idle connections
|
||||
across Docker's recurring health checks.
|
||||
*/
|
||||
const response = await fetch(MEDIAMTX_HEALTH_URL, {
|
||||
signal: AbortSignal.timeout(MEDIAMTX_HEALTH_TIMEOUT_MS),
|
||||
});
|
||||
await response.text();
|
||||
mediaMtx = response.ok;
|
||||
} catch {
|
||||
mediaMtx = false;
|
||||
}
|
||||
|
||||
return {
|
||||
healthy: dataDirectory && mediaMtx,
|
||||
checks: {
|
||||
dataDirectory,
|
||||
mediaMtx,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getContainerHealth,
|
||||
getHealthSnapshot,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('identityAdminService');
|
||||
const { getRole } = require('../roleService');
|
||||
const {
|
||||
listUsersForAdmin,
|
||||
listUserSummariesForAdmin,
|
||||
getUserForAdmin,
|
||||
addUserSignal,
|
||||
removeUserSignal,
|
||||
@@ -71,10 +71,13 @@ function ackHandler(socket, eventName, handler) {
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
ackHandler(socket, 'identityAdmin:listUsers', () => ({
|
||||
users: listUsersForAdmin(),
|
||||
permissions: listRegisteredPermissions(),
|
||||
}));
|
||||
ackHandler(socket, 'identityAdmin:listUsers', ({ query, filter }) => {
|
||||
const result = listUserSummariesForAdmin({ query, filter });
|
||||
return {
|
||||
...result,
|
||||
permissions: listRegisteredPermissions(),
|
||||
};
|
||||
});
|
||||
|
||||
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
|
||||
permissions: listRegisteredPermissions(),
|
||||
|
||||
@@ -19,6 +19,7 @@ const DB_PATH = resolveDataPath('identity.sqlite');
|
||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||
const STORE_VERSION = 4;
|
||||
const ADMIN_USER_LIST_LIMIT = 100;
|
||||
const identityEvents = new EventEmitter();
|
||||
|
||||
let db = null;
|
||||
@@ -560,6 +561,85 @@ function listUsersForAdmin() {
|
||||
}));
|
||||
}
|
||||
|
||||
function listUserSummariesForAdmin({ query = '', filter = 'all' } = {}) {
|
||||
const conn = getDb();
|
||||
const normalizedQuery = String(query || '').trim().toLowerCase().slice(0, 200);
|
||||
const normalizedFilter = ['all', 'verified', 'deterred', 'muted', 'unverified'].includes(filter)
|
||||
? filter
|
||||
: 'all';
|
||||
const conditions = [];
|
||||
const parameters = [];
|
||||
|
||||
if (normalizedFilter === 'verified') conditions.push('coalesce(user_status.verified_enabled, 0) = 1');
|
||||
if (normalizedFilter === 'deterred') conditions.push('coalesce(user_status.deterrence_enabled, 0) = 1');
|
||||
if (normalizedFilter === 'muted') conditions.push('coalesce(user_status.muted_enabled, 0) = 1');
|
||||
if (normalizedFilter === 'unverified') conditions.push('coalesce(user_status.verified_enabled, 0) = 0');
|
||||
|
||||
if (normalizedQuery) {
|
||||
const pattern = `%${normalizedQuery}%`;
|
||||
/*
|
||||
Search stays inside one bounded SQLite statement. EXISTS checks preserve
|
||||
lookup by any known identity signal without constructing every user's
|
||||
complete signal and feature-state record in JavaScript first.
|
||||
*/
|
||||
conditions.push(`(
|
||||
lower(users.id) like ?
|
||||
or exists (select 1 from user_nicknames where user_id = users.id and lower(nickname) like ?)
|
||||
or exists (select 1 from user_cookie_ids where user_id = users.id and lower(cookie_user_id) like ?)
|
||||
or exists (select 1 from user_fingerprint_ids where user_id = users.id and lower(fingerprint_id) like ?)
|
||||
or exists (select 1 from user_known_ips where user_id = users.id and lower(ip) like ?)
|
||||
or exists (select 1 from user_feature_state where user_id = users.id and lower(namespace) like ?)
|
||||
or exists (select 1 from user_permissions where user_id = users.id and lower(permission_key) like ?)
|
||||
)`);
|
||||
parameters.push(pattern, pattern, pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `where ${conditions.join(' and ')}` : '';
|
||||
/*
|
||||
The list needs only the newest visible signal and moderation flags. Full
|
||||
signal histories, permissions, and feature JSON remain available through
|
||||
getUserForAdmin after an administrator selects one of these summaries.
|
||||
Reading one extra row tells the UI whether it should ask for a narrower
|
||||
search without running a second full COUNT query.
|
||||
*/
|
||||
const rows = conn.prepare(`
|
||||
select
|
||||
users.id,
|
||||
users.created_at,
|
||||
users.updated_at,
|
||||
users.last_seen_at,
|
||||
coalesce(user_status.verified_enabled, 0) as verified_enabled,
|
||||
coalesce(user_status.deterrence_enabled, 0) as deterrence_enabled,
|
||||
coalesce(user_status.muted_enabled, 0) as muted_enabled,
|
||||
(select nickname from user_nicknames where user_id = users.id order by last_seen_at desc limit 1) as nickname,
|
||||
(select cookie_user_id from user_cookie_ids where user_id = users.id order by last_seen_at desc limit 1) as cookie_user_id,
|
||||
(select fingerprint_id from user_fingerprint_ids where user_id = users.id order by last_seen_at desc limit 1) as fingerprint_id
|
||||
from users
|
||||
left join user_status on user_status.user_id = users.id
|
||||
${where}
|
||||
order by coalesce(users.last_seen_at, users.updated_at, users.created_at) desc
|
||||
limit ?
|
||||
`).all(...parameters, ADMIN_USER_LIST_LIMIT + 1);
|
||||
|
||||
return {
|
||||
truncated: rows.length > ADMIN_USER_LIST_LIMIT,
|
||||
users: rows.slice(0, ADMIN_USER_LIST_LIMIT).map((row) => ({
|
||||
id: row.id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
nickname: row.nickname || null,
|
||||
cookieUserIds: row.cookie_user_id ? [row.cookie_user_id] : [],
|
||||
fingerprintIds: row.fingerprint_id ? [row.fingerprint_id] : [],
|
||||
verified: { enabled: Boolean(row.verified_enabled) },
|
||||
deterrence: {
|
||||
enabled: Boolean(row.deterrence_enabled),
|
||||
muted: Boolean(row.muted_enabled),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function getUserForAdmin(userId) {
|
||||
const user = getUserById(userId, { includeFeatures: true });
|
||||
return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null;
|
||||
@@ -1111,6 +1191,7 @@ module.exports = {
|
||||
attachIdentitySignals,
|
||||
getUserById,
|
||||
listUsersForAdmin,
|
||||
listUserSummariesForAdmin,
|
||||
getUserForAdmin,
|
||||
addUserSignal,
|
||||
removeUserSignal,
|
||||
|
||||
@@ -80,3 +80,36 @@ test('unknown permission keys cannot be persisted', () => {
|
||||
/Unknown user permission/,
|
||||
);
|
||||
});
|
||||
|
||||
test('administrator user summaries are bounded and searchable without loading full records', () => {
|
||||
const db = identityService.getDb();
|
||||
const insertUser = db.prepare('insert or ignore into users (id, created_at, updated_at, last_seen_at) values (?, ?, ?, ?)');
|
||||
const insertStatus = db.prepare('insert or ignore into user_status (user_id, deterrence_enabled) values (?, ?)');
|
||||
const insertNickname = db.prepare('insert or ignore into user_nicknames (user_id, nickname, first_seen_at, last_seen_at) values (?, ?, ?, ?)');
|
||||
|
||||
/*
|
||||
Seed more records than one response may contain. Direct inserts keep this
|
||||
focused test independent from browser identity generation while exercising
|
||||
the real normalized tables and the same query used by the admin socket.
|
||||
*/
|
||||
db.transaction(() => {
|
||||
for (let index = 0; index < 105; index += 1) {
|
||||
const userId = `usr_${index.toString(16).padStart(32, '0')}`;
|
||||
insertUser.run(userId, index, index, index);
|
||||
insertStatus.run(userId, index === 104 ? 1 : 0);
|
||||
insertNickname.run(userId, index === 104 ? 'Unique Search Target' : `User ${index}`, index, index);
|
||||
}
|
||||
})();
|
||||
|
||||
const recent = identityService.listUserSummariesForAdmin();
|
||||
assert.equal(recent.users.length, 100);
|
||||
assert.equal(recent.truncated, true);
|
||||
|
||||
const searched = identityService.listUserSummariesForAdmin({ query: 'unique search target' });
|
||||
assert.equal(searched.users.length, 1);
|
||||
assert.equal(searched.users[0].nickname, 'Unique Search Target');
|
||||
|
||||
const deterred = identityService.listUserSummariesForAdmin({ filter: 'deterred' });
|
||||
assert.equal(deterred.users.length, 1);
|
||||
assert.equal(deterred.users[0].deterrence.enabled, true);
|
||||
});
|
||||
|
||||
@@ -69,13 +69,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
{ url: 'stun:stun.cloudflare.com:3478' },
|
||||
],
|
||||
|
||||
/*
|
||||
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot
|
||||
writer. Rover media moves to RTSP, but removing this listener would break those independent
|
||||
consumers, so both listeners remain deliberately enabled.
|
||||
*/
|
||||
srt: true,
|
||||
srtAddress: ':9000',
|
||||
// Every publisher and server-local reader uses RTSP over TCP. Disable SRT
|
||||
// completely so MediaMTX cannot reintroduce the GoSRT/libSRT ACKACK mismatch.
|
||||
srt: false,
|
||||
|
||||
authMethod: 'http',
|
||||
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
|
||||
|
||||
@@ -20,6 +20,8 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => {
|
||||
assert.equal(generated.rtsp, true);
|
||||
assert.equal(generated.rtspAddress, ':8554');
|
||||
assert.deepEqual(generated.rtspTransports, ['tcp']);
|
||||
assert.equal(generated.srt, false);
|
||||
assert.equal(Object.hasOwn(generated, 'srtAddress'), false);
|
||||
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
|
||||
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
|
||||
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
|
||||
|
||||
@@ -573,7 +573,7 @@ function schedulePublisherRestart(reason = 'publisher-restart') {
|
||||
function startPublisher() {
|
||||
if (!enabled || !state.rtspUri || publisherProcess) return;
|
||||
const input = addCredentialsToRtsp(state.rtspUri);
|
||||
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`;
|
||||
const output = `rtsp://127.0.0.1:8554/${encodeURIComponent(PTZ_STREAM_PATH)}`;
|
||||
/*
|
||||
The full-quality autotrack profile is H265, which is the right camera-side
|
||||
feed but has been unreliable through browser WHEP playback. Re-encoding is
|
||||
@@ -607,10 +607,9 @@ function startPublisher() {
|
||||
dead session. The existing exit handler then starts a new process, which is
|
||||
the part that creates a fresh RTSP connection after the camera comes back.
|
||||
|
||||
The mpegts muxer can also hold packets briefly before writing them to SRT.
|
||||
flush_packets/muxdelay/muxpreload are output-side latency knobs; they do not
|
||||
ask the camera or demuxer to discard frames, so they are a safer next step
|
||||
than the stale-frame dropping experiments that made the Reolink feed freeze.
|
||||
The MediaMTX output uses RTSP over TCP, matching every rover publisher and
|
||||
server-local reader. Keeping one media transport avoids the incompatible
|
||||
empty SRT ACKACK packets produced between GoSRT and Fedora's newer libSRT.
|
||||
*/
|
||||
const proc = spawn('ffmpeg', [
|
||||
'-hide_banner',
|
||||
@@ -671,12 +670,10 @@ function startPublisher() {
|
||||
'-2',
|
||||
'-flush_packets',
|
||||
'1',
|
||||
'-muxdelay',
|
||||
'0',
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-rtsp_transport',
|
||||
'tcp',
|
||||
'-f',
|
||||
'mpegts',
|
||||
'rtsp',
|
||||
output,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
publisherProcess = proc;
|
||||
@@ -1890,7 +1887,7 @@ module.exports = {
|
||||
audio the same way it already mixes rover audio.
|
||||
*/
|
||||
if (!enabled || !isReplayEnabled()) return [];
|
||||
const inputUrl = `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`;
|
||||
const inputUrl = `rtsp://127.0.0.1:8554/${encodeURIComponent(PTZ_STREAM_PATH)}`;
|
||||
const label = cameraConfig.name || 'PTZ Camera';
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -15,8 +15,8 @@ function sourceDirForKey(activeSegmentRoot, key) {
|
||||
return path.join(activeSegmentRoot, key);
|
||||
}
|
||||
|
||||
function toSrtReadPath(streamId) {
|
||||
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(streamId)}`;
|
||||
function toRtspReadPath(streamId) {
|
||||
return `rtsp://127.0.0.1:8554/${encodeURIComponent(streamId)}`;
|
||||
}
|
||||
|
||||
function getRoomCameraStream(camera) {
|
||||
@@ -37,9 +37,9 @@ function listDesiredSources() {
|
||||
const sources = [];
|
||||
for (const rover of roverManager.getRoster()) {
|
||||
const roverId = String(rover.id);
|
||||
sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toSrtReadPath(roverId) });
|
||||
sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toRtspReadPath(roverId) });
|
||||
if (hasRoverAudioCapture(rover)) {
|
||||
sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toSrtReadPath(`${roverId}-audio`) });
|
||||
sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toRtspReadPath(`${roverId}-audio`) });
|
||||
}
|
||||
}
|
||||
for (const camera of getRoomCameras()) {
|
||||
@@ -57,7 +57,12 @@ function listDesiredSources() {
|
||||
function buildWorkerArgs(activeSegmentRoot, source) {
|
||||
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
|
||||
const pattern = path.join(dir, 'seg-%06d.mp4');
|
||||
const common = ['-hide_banner', '-loglevel', 'warning', '-y', '-fflags', '+genpts', '-use_wallclock_as_timestamps', '1', '-i', source.inputUrl];
|
||||
// MediaMTX and RTSP cameras use TCP so replay capture has one reliable
|
||||
// transport and never falls back to separate RTP/RTCP UDP listeners.
|
||||
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
|
||||
? ['-rtsp_transport', 'tcp']
|
||||
: [];
|
||||
const common = ['-hide_banner', '-loglevel', 'warning', '-y', '-fflags', '+genpts', '-use_wallclock_as_timestamps', '1', ...inputTransport, '-i', source.inputUrl];
|
||||
|
||||
if (source.kind === 'audio') {
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Restricted Container Lifecycle Controller
|
||||
// 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.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const Docker = require('dockerode');
|
||||
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
const TARGET_CONTAINER_NAME = 'multirover';
|
||||
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
|
||||
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||
// Status belongs beside the controller's private socket instead of in the
|
||||
// application's data volume. The controller runs as root for Docker access;
|
||||
// keeping it out of /data prevents it from creating directories that the
|
||||
// non-root application cannot later use for replay and audio runtime work.
|
||||
const STATUS_PATH = process.env.MULTIROVER_LIFECYCLE_STATUS || '/run/multirover/status.json';
|
||||
const HEALTH_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const HEALTH_POLL_MS = 1000;
|
||||
|
||||
let operationRunning = false;
|
||||
let status = readStatus();
|
||||
|
||||
function readStatus() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
|
||||
// A controller replacement cannot resume an in-flight Docker operation.
|
||||
// Preserve its history but make the interrupted result explicit.
|
||||
if (saved.state === 'running') {
|
||||
return {
|
||||
...saved,
|
||||
state: 'failed',
|
||||
message: 'The lifecycle controller restarted before the operation completed.',
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
return saved;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') process.stderr.write(`Unable to read lifecycle status: ${error.message}\n`);
|
||||
return {
|
||||
state: 'idle',
|
||||
operation: null,
|
||||
message: 'No lifecycle operation has run yet.',
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
runningImage: null,
|
||||
availableImage: null,
|
||||
updateAvailable: null,
|
||||
rollback: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStatus(patch) {
|
||||
status = { ...status, ...patch };
|
||||
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
|
||||
const temporaryPath = `${STATUS_PATH}.tmp`;
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, STATUS_PATH);
|
||||
return status;
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function inspectTargetContainer() {
|
||||
return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
|
||||
}
|
||||
|
||||
async function inspectLocalTargetImage() {
|
||||
try {
|
||||
return await docker.getImage(TARGET_IMAGE).inspect();
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function pullTargetImage() {
|
||||
const stream = await new Promise((resolve, reject) => {
|
||||
docker.pull(TARGET_IMAGE, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
const image = await inspectLocalTargetImage();
|
||||
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
|
||||
return image;
|
||||
}
|
||||
|
||||
function environmentMap(values = []) {
|
||||
const result = new Map();
|
||||
values.forEach((entry) => {
|
||||
const separator = String(entry).indexOf('=');
|
||||
const key = separator >= 0 ? entry.slice(0, separator) : entry;
|
||||
result.set(key, String(entry));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeEnvironment({ current = [], previousDefaults = [], nextDefaults = [] }) {
|
||||
const previous = environmentMap(previousDefaults);
|
||||
const overrides = current.filter((entry) => previous.get(String(entry).split('=', 1)[0]) !== entry);
|
||||
const merged = environmentMap(nextDefaults);
|
||||
overrides.forEach((entry) => merged.set(String(entry).split('=', 1)[0], entry));
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
async function buildReplacementOptions({ current, imageReference }) {
|
||||
const [previousImage, nextImage] = await Promise.all([
|
||||
docker.getImage(current.Image).inspect(),
|
||||
docker.getImage(imageReference).inspect(),
|
||||
]);
|
||||
const imageConfig = nextImage.Config || {};
|
||||
|
||||
/*
|
||||
Image-owned defaults come from the replacement image so updates can change
|
||||
their command, health check, or runtime environment. Only environment
|
||||
values that Compose overrode on the running container survive. HostConfig
|
||||
is copied intact because it contains the named volume, host networking,
|
||||
device access, capability, security, and restart-policy contract.
|
||||
*/
|
||||
return {
|
||||
name: TARGET_CONTAINER_NAME,
|
||||
Image: imageReference,
|
||||
AttachStdin: false,
|
||||
AttachStdout: false,
|
||||
AttachStderr: false,
|
||||
Tty: false,
|
||||
OpenStdin: false,
|
||||
StdinOnce: false,
|
||||
Env: mergeEnvironment({
|
||||
current: current.Config?.Env,
|
||||
previousDefaults: previousImage.Config?.Env,
|
||||
nextDefaults: imageConfig.Env,
|
||||
}),
|
||||
Cmd: imageConfig.Cmd,
|
||||
Entrypoint: imageConfig.Entrypoint,
|
||||
WorkingDir: imageConfig.WorkingDir,
|
||||
User: imageConfig.User,
|
||||
ExposedPorts: imageConfig.ExposedPorts,
|
||||
Volumes: imageConfig.Volumes,
|
||||
Healthcheck: imageConfig.Healthcheck,
|
||||
StopSignal: imageConfig.StopSignal,
|
||||
StopTimeout: imageConfig.StopTimeout,
|
||||
Labels: { ...(imageConfig.Labels || {}), ...(current.Config?.Labels || {}) },
|
||||
HostConfig: current.HostConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async function removeContainerIfPresent() {
|
||||
const container = docker.getContainer(TARGET_CONTAINER_NAME);
|
||||
try {
|
||||
const current = await container.inspect();
|
||||
if (current.State?.Running) {
|
||||
try {
|
||||
await container.stop({ t: 20 });
|
||||
} catch (error) {
|
||||
if (error.statusCode !== 304) throw error;
|
||||
}
|
||||
}
|
||||
await container.remove();
|
||||
} catch (error) {
|
||||
if (error.statusCode !== 404) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceContainer({ template, imageReference }) {
|
||||
const options = await buildReplacementOptions({ current: template, imageReference });
|
||||
await removeContainerIfPresent();
|
||||
const replacement = await docker.createContainer(options);
|
||||
await replacement.start();
|
||||
return replacement;
|
||||
}
|
||||
|
||||
async function waitForHealthyContainer() {
|
||||
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const current = await inspectTargetContainer();
|
||||
const health = current.State?.Health?.Status;
|
||||
if (current.State?.Running && health === 'healthy') return current;
|
||||
if (health === 'unhealthy') throw new Error('The replacement container failed its health check.');
|
||||
await sleep(HEALTH_POLL_MS);
|
||||
}
|
||||
throw new Error('The replacement container did not become healthy in time.');
|
||||
}
|
||||
|
||||
async function refreshImageStatus() {
|
||||
const [container, image] = await Promise.all([
|
||||
inspectTargetContainer(),
|
||||
inspectLocalTargetImage(),
|
||||
]);
|
||||
return writeStatus({
|
||||
runningImage: container.Image,
|
||||
availableImage: image?.Id || null,
|
||||
updateAvailable: image ? container.Image !== image.Id : null,
|
||||
});
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…` });
|
||||
await pullTargetImage();
|
||||
const current = await refreshImageStatus();
|
||||
return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
|
||||
}
|
||||
|
||||
async function runRestart() {
|
||||
writeStatus({ message: 'Restarting the application container…' });
|
||||
await docker.getContainer(TARGET_CONTAINER_NAME).restart({ t: 20 });
|
||||
await waitForHealthyContainer();
|
||||
await refreshImageStatus();
|
||||
return 'The application container restarted successfully.';
|
||||
}
|
||||
|
||||
async function runUpdate() {
|
||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…`, rollback: null });
|
||||
const targetImage = await pullTargetImage();
|
||||
const previousContainer = await inspectTargetContainer();
|
||||
const previousImage = previousContainer.Image;
|
||||
let replacementStarted = false;
|
||||
|
||||
try {
|
||||
writeStatus({
|
||||
message: 'Replacing the application container…',
|
||||
runningImage: previousImage,
|
||||
availableImage: targetImage.Id,
|
||||
updateAvailable: previousImage !== targetImage.Id,
|
||||
});
|
||||
replacementStarted = true;
|
||||
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE });
|
||||
writeStatus({ message: 'Waiting for the updated application to become healthy…' });
|
||||
const healthy = await waitForHealthyContainer();
|
||||
writeStatus({
|
||||
runningImage: healthy.Image,
|
||||
availableImage: targetImage.Id,
|
||||
updateAvailable: false,
|
||||
});
|
||||
return previousImage === targetImage.Id
|
||||
? 'The current application image was restarted successfully.'
|
||||
: 'The application was updated successfully.';
|
||||
} catch (error) {
|
||||
if (!replacementStarted) throw error;
|
||||
writeStatus({ message: 'The update failed; restoring the previous application image…' });
|
||||
try {
|
||||
await replaceContainer({ template: previousContainer, imageReference: previousImage });
|
||||
await waitForHealthyContainer();
|
||||
writeStatus({
|
||||
rollback: { status: 'succeeded', image: previousImage, completedAt: Date.now() },
|
||||
runningImage: previousImage,
|
||||
updateAvailable: true,
|
||||
});
|
||||
} catch (rollbackError) {
|
||||
writeStatus({
|
||||
rollback: { status: 'failed', image: previousImage, error: rollbackError.message, completedAt: Date.now() },
|
||||
});
|
||||
throw new Error(`${error.message} Rollback also failed: ${rollbackError.message}`);
|
||||
}
|
||||
throw new Error(`${error.message} The previous image was restored.`);
|
||||
}
|
||||
}
|
||||
|
||||
function queueOperation(operation, runner) {
|
||||
if (operationRunning) throw new Error('Another lifecycle operation is already running.');
|
||||
operationRunning = true;
|
||||
writeStatus({
|
||||
state: 'running',
|
||||
operation,
|
||||
message: `Starting ${operation}…`,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
// Respond to the application before replacement disconnects its Socket.IO
|
||||
// clients. The persisted status remains available when the new process asks
|
||||
// for the result after reconnecting.
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const message = await runner();
|
||||
writeStatus({ state: 'succeeded', message, completedAt: Date.now() });
|
||||
} catch (error) {
|
||||
writeStatus({ state: 'failed', message: error.message, completedAt: Date.now() });
|
||||
} finally {
|
||||
operationRunning = false;
|
||||
}
|
||||
}, 500);
|
||||
return status;
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, value) {
|
||||
response.writeHead(statusCode, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
try {
|
||||
if (request.method === 'GET' && request.url === '/status') {
|
||||
refreshImageStatus()
|
||||
.catch(() => status)
|
||||
.then((current) => sendJson(response, 200, { available: true, ...current }));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/check') {
|
||||
sendJson(response, 202, { available: true, ...queueOperation('check', runCheck) });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/restart') {
|
||||
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/update') {
|
||||
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: 'Lifecycle operation not found.' });
|
||||
} catch (error) {
|
||||
sendJson(response, 409, { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true });
|
||||
fs.rmSync(SOCKET_PATH, { force: true });
|
||||
server.listen(SOCKET_PATH, () => {
|
||||
// The application runs as uid 1000 while this Docker-authorized controller
|
||||
// runs as root. The socket carries only the fixed API above, so permitting
|
||||
// the application user to connect does not expose arbitrary Docker access.
|
||||
fs.chmodSync(SOCKET_PATH, 0o666);
|
||||
process.stdout.write(`Lifecycle controller listening on ${SOCKET_PATH}\n`);
|
||||
});
|
||||
|
||||
function stop() {
|
||||
server.close(() => {
|
||||
fs.rmSync(SOCKET_PATH, { force: true });
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
@@ -4,7 +4,8 @@
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('serverControlService');
|
||||
const { getConfigurationDatabase } = require('../../configuration');
|
||||
const { requireRecentPassword } = require('../adminConfigurationService');
|
||||
const { requireLockdownAdministrator, requireRecentPassword } = require('../adminConfigurationService');
|
||||
const lifecycleClient = require('./lifecycleClient');
|
||||
|
||||
const database = getConfigurationDatabase();
|
||||
let restartPending = false;
|
||||
@@ -45,15 +46,60 @@ function requestApplicationRestart({ actor, reason = 'administrator-requested' }
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('server:lifecycleStatus', (_payload = {}, cb = () => {}) => {
|
||||
Promise.resolve()
|
||||
.then(() => requireLockdownAdministrator(socket))
|
||||
.then(() => lifecycleClient.getLifecycleStatus())
|
||||
.then((lifecycle) => cb({ success: true, lifecycle }))
|
||||
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||
});
|
||||
|
||||
socket.on('server:checkForUpdate', (_payload = {}, cb = () => {}) => {
|
||||
Promise.resolve()
|
||||
.then(() => requireRecentPassword(socket))
|
||||
.then(() => lifecycleClient.checkForUpdate())
|
||||
.then((lifecycle) => {
|
||||
database.recordAuditEvent(actorFor(socket), 'application.update-check-requested', {});
|
||||
cb({ success: true, lifecycle });
|
||||
})
|
||||
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||
});
|
||||
|
||||
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => {
|
||||
Promise.resolve()
|
||||
.then(() => requireRecentPassword(socket))
|
||||
.then(() => lifecycleClient.updateApplication())
|
||||
.then((lifecycle) => {
|
||||
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {});
|
||||
io.emit('server:restarting', { reason: 'application-update' });
|
||||
cb({ success: true, lifecycle });
|
||||
})
|
||||
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||
});
|
||||
|
||||
socket.on('server:restartApplication', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
Promise.resolve().then(async () => {
|
||||
requireRecentPassword(socket);
|
||||
if (restartPending) throw new Error('Application restart already pending.');
|
||||
const actor = actorFor(socket);
|
||||
requestApplicationRestart({ actor });
|
||||
cb({ success: true });
|
||||
} catch (error) {
|
||||
cb({ error: error.message, code: error.code || null });
|
||||
}
|
||||
try {
|
||||
const lifecycle = await lifecycleClient.restartApplication();
|
||||
restartPending = true;
|
||||
database.recordAuditEvent(actor, 'application.restart-requested', { reason: 'administrator-requested' });
|
||||
logger.warn('Application container restart requested', { actor });
|
||||
io.emit('server:restarting', { reason: 'administrator-requested' });
|
||||
cb({ success: true, lifecycle });
|
||||
} catch (error) {
|
||||
/*
|
||||
Legacy installations intentionally have no controller socket. Keep
|
||||
their existing process-signal restart during migration, but never
|
||||
bypass a real controller rejection such as an operation conflict.
|
||||
*/
|
||||
if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
|
||||
requestApplicationRestart({ actor });
|
||||
cb({ success: true, legacy: true });
|
||||
}
|
||||
}).catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Container Lifecycle Client
|
||||
// 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.
|
||||
const http = require('http');
|
||||
|
||||
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
function requestLifecycleController({ method = 'GET', path }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({
|
||||
socketPath: LIFECYCLE_SOCKET_PATH,
|
||||
method,
|
||||
path,
|
||||
headers: { Accept: 'application/json' },
|
||||
}, (response) => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => {
|
||||
// Controller responses contain only a small status document. Keeping a
|
||||
// hard bound prevents a broken peer from growing application memory.
|
||||
body += chunk;
|
||||
if (body.length > 64 * 1024) request.destroy(new Error('Lifecycle controller response is too large.'));
|
||||
});
|
||||
response.on('end', () => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
reject(new Error('Lifecycle controller returned an invalid response.'));
|
||||
return;
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
reject(new Error(parsed.error || 'Lifecycle controller request failed.'));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
});
|
||||
});
|
||||
|
||||
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('Lifecycle controller did not respond.')));
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getLifecycleStatus() {
|
||||
try {
|
||||
return await requestLifecycleController({ path: '/status' });
|
||||
} catch (error) {
|
||||
/*
|
||||
The legacy development/server installation deliberately has no lifecycle
|
||||
controller. Report that as an unavailable capability so the existing
|
||||
in-process restart remains usable instead of turning absence into a noisy
|
||||
server error.
|
||||
*/
|
||||
return {
|
||||
available: false,
|
||||
state: 'unavailable',
|
||||
message: error.message,
|
||||
operation: null,
|
||||
updateAvailable: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
|
||||
getLifecycleStatus,
|
||||
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
|
||||
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }),
|
||||
};
|
||||
@@ -32,13 +32,16 @@ function registerVideoAuthRoute(deps) {
|
||||
});
|
||||
}
|
||||
|
||||
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
|
||||
const isRtspProtocol = protocol === 'rtsp' || protocol.startsWith('rtsp');
|
||||
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
|
||||
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
|
||||
return res.status(200).end();
|
||||
}
|
||||
if (action === 'publish' && isSrtLikeProtocol) {
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1';
|
||||
const isRoverForwardAudioRead = action === 'read'
|
||||
&& isRtspProtocol
|
||||
&& streamInfo?.id?.endsWith('-fwd');
|
||||
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do
|
||||
// not represent a browser session. Rovers likewise read their dedicated
|
||||
// -fwd speaker feed without browser credentials; every other non-loopback
|
||||
// RTSP read remains subject to normal session authorization below.
|
||||
if ((action === 'read' && isRtspProtocol && isLoopback) || isRoverForwardAudioRead) {
|
||||
return res.status(200).end();
|
||||
}
|
||||
/*
|
||||
|
||||
@@ -5,7 +5,7 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { registerVideoAuthRoute } = require('./httpRoute');
|
||||
|
||||
function createHarness() {
|
||||
function createHarness({ requestIp = '127.0.0.1' } = {}) {
|
||||
let handler;
|
||||
const app = { post: (_path, fn) => { handler = fn; } };
|
||||
registerVideoAuthRoute({
|
||||
@@ -13,7 +13,7 @@ function createHarness() {
|
||||
io: { sockets: { sockets: new Map() } },
|
||||
logger: { info() {}, warn() {} },
|
||||
videoSessions: { getSession: () => null, revokeSession() {} },
|
||||
getRequestIp: () => '127.0.0.1',
|
||||
getRequestIp: () => requestIp,
|
||||
logAdminEvent() {},
|
||||
extractStreamInfoFromBody: (body) => ({ type: 'rover', id: body.path, baseId: body.path }),
|
||||
canAccessStream: () => false,
|
||||
@@ -42,6 +42,21 @@ test('allows an RTSP rover publisher without a browser session', () => {
|
||||
assert.equal(request({ protocol: 'rtsp', action: 'publish', path: 'rover-one' }), 200);
|
||||
});
|
||||
|
||||
test('allows server-local RTSP replay and snapshot readers without a browser session', () => {
|
||||
const { request } = createHarness();
|
||||
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one' }), 200);
|
||||
});
|
||||
|
||||
test('continues rejecting an unauthenticated remote RTSP reader', () => {
|
||||
const { request } = createHarness({ requestIp: '192.0.2.10' });
|
||||
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one' }), 401);
|
||||
});
|
||||
|
||||
test('allows a rover to read its RTSP speaker-forward stream without a browser session', () => {
|
||||
const { request } = createHarness({ requestIp: '192.0.2.10' });
|
||||
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one-fwd' }), 200);
|
||||
});
|
||||
|
||||
test('continues rejecting an unauthenticated WebRTC read', () => {
|
||||
const { request } = createHarness();
|
||||
assert.equal(request({ protocol: 'webrtc', action: 'read', path: 'rover-one' }), 401);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Video Auth Stream Parsing
|
||||
// Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks.
|
||||
// Scope: Handles native MediaMTX WHEP/WHIP paths and SRT streamid extraction without performing auth decisions.
|
||||
// Scope: Handles native MediaMTX path forms without performing auth decisions.
|
||||
|
||||
const PTZ_STREAM_PATH = 'ptz-camera';
|
||||
|
||||
@@ -44,42 +44,10 @@ function extractStreamInfo(path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSrtStreamId(rawValue) {
|
||||
const value = decodeURIComponent(String(rawValue || '').trim());
|
||||
if (!value) return '';
|
||||
|
||||
const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
if (!/[?&=,:]/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractStreamInfoFromBody(body = {}) {
|
||||
const fromPath = extractStreamInfo((body.path || '').replace(/^\//, ''));
|
||||
if (fromPath) return fromPath;
|
||||
|
||||
const srtId =
|
||||
extractSrtStreamId(body.streamid) ||
|
||||
extractSrtStreamId(body.streamId) ||
|
||||
extractSrtStreamId(body.query);
|
||||
if (!srtId) return null;
|
||||
|
||||
if (srtId === PTZ_STREAM_PATH) {
|
||||
return { type: 'ptz', id: srtId };
|
||||
}
|
||||
|
||||
if (srtId.endsWith('-fwd')) {
|
||||
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
|
||||
}
|
||||
|
||||
const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId;
|
||||
return { type: 'rover', id: srtId, baseId };
|
||||
// All enabled MediaMTX protocols now report the canonical path directly;
|
||||
// there is no second SRT stream-id syntax to normalize or authorize.
|
||||
return extractStreamInfo((body.path || '').replace(/^\//, ''));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -26,6 +26,9 @@ export const createAdministrator = (socket, payload) => emitAdminRequest(socket,
|
||||
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 getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
|
||||
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
|
||||
export const updateApplication = (socket) => emitAdminRequest(socket, 'server:updateApplication');
|
||||
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
|
||||
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
|
||||
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { restartApplication, restoreConfigurationRevision } from '../api.js';
|
||||
import {
|
||||
checkForApplicationUpdate,
|
||||
getApplicationLifecycleStatus,
|
||||
restartApplication,
|
||||
restoreConfigurationRevision,
|
||||
updateApplication,
|
||||
} from '../api.js';
|
||||
|
||||
function formatDate(value) {
|
||||
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
|
||||
@@ -13,6 +19,33 @@ function formatDate(value) {
|
||||
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
|
||||
const config = snapshot.configuration;
|
||||
const [restartRequested, setRestartRequested] = useState(false);
|
||||
const [lifecycle, setLifecycle] = useState(null);
|
||||
const [lifecycleError, setLifecycleError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function refreshLifecycle() {
|
||||
if (!socket.connected) return;
|
||||
try {
|
||||
const response = await getApplicationLifecycleStatus(socket);
|
||||
if (!mounted) return;
|
||||
setLifecycle(response.lifecycle);
|
||||
setLifecycleError('');
|
||||
} catch (error) {
|
||||
if (mounted) setLifecycleError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
refreshLifecycle();
|
||||
// Update work continues while this application is being replaced. Polling
|
||||
// the controller gives the page current progress before disconnect and the
|
||||
// persisted final result immediately after Socket.IO reconnects.
|
||||
const timer = window.setInterval(refreshLifecycle, 2500);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restartRequested) return undefined;
|
||||
@@ -47,6 +80,33 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
try {
|
||||
const response = await runSensitive(() => checkForApplicationUpdate(socket));
|
||||
setLifecycle(response.lifecycle);
|
||||
} catch (error) {
|
||||
setLifecycleError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function update() {
|
||||
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return;
|
||||
try {
|
||||
const response = await runSensitive(() => updateApplication(socket));
|
||||
setLifecycle(response.lifecycle);
|
||||
} catch (error) {
|
||||
setLifecycleError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const lifecycleBusy = lifecycle?.state === 'running';
|
||||
const lifecycleAvailable = Boolean(lifecycle?.available);
|
||||
const updateSummary = lifecycle?.updateAvailable === true
|
||||
? 'An update is available.'
|
||||
: lifecycle?.updateAvailable === false
|
||||
? 'The running application matches the latest checked image.'
|
||||
: 'Check for updates to compare the running container with the configured image.';
|
||||
|
||||
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">
|
||||
@@ -58,11 +118,24 @@ 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 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">
|
||||
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
|
||||
{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}
|
||||
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
|
||||
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
|
||||
</button>
|
||||
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy} onClick={update}>
|
||||
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
|
||||
</button>
|
||||
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
|
||||
{restartRequested ? 'Waiting for application…' : 'Restart application'}
|
||||
</button>
|
||||
</div>
|
||||
</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) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Identity Database Panel
|
||||
// Purpose: Implements the lockdown admin identity database editor UI inside the centralized administration application.
|
||||
// Scope: Keeps list, detail, signal, status, feature-state, and raw JSON editing local to this feature.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../components/Tabs/index.jsx';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
maskValue,
|
||||
parseEditableJson,
|
||||
stringifyJson,
|
||||
userMatchesFilter,
|
||||
userMatchesQuery,
|
||||
} from './identityDatabaseUtils.js';
|
||||
|
||||
const FILTERS = [
|
||||
@@ -44,21 +42,16 @@ function StatusPill({ active, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UserListCard({ users, selectedUserId, query, filter, loading, onQuery, onFilter, onRefresh, onSelect }) {
|
||||
const filtered = useMemo(
|
||||
() => users.filter((user) => userMatchesFilter(user, filter) && userMatchesQuery(user, query)),
|
||||
[filter, query, users],
|
||||
);
|
||||
|
||||
function UserListCard({ users, truncated, selectedUserId, query, filter, loading, onQuery, onFilter, onRefresh, onSearch, onSelect }) {
|
||||
const actions = (
|
||||
<button type="button" className="button-dark text-xs" onClick={onRefresh} disabled={loading}>
|
||||
<button type="button" className="button-dark text-xs" onClick={() => onRefresh()} disabled={loading}>
|
||||
{loading ? 'Loading' : 'Refresh'}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<CardFrame title="Identity database" meta={filtered.length} actions={actions} bodyClassName="flex min-h-0 flex-col gap-0.5 p-0.5 text-sm">
|
||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_10rem]">
|
||||
<CardFrame title="Identity database" meta={truncated ? `${users.length}+` : users.length} actions={actions} bodyClassName="flex min-h-0 flex-col gap-0.5 p-0.5 text-sm">
|
||||
<form className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_10rem_auto]" onSubmit={onSearch}>
|
||||
<input
|
||||
className="field-input text-sm"
|
||||
type="search"
|
||||
@@ -71,9 +64,15 @@ function UserListCard({ users, selectedUserId, query, filter, loading, onQuery,
|
||||
<option key={entry.key} value={entry.key}>{entry.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="button-dark text-xs" disabled={loading}>Search</button>
|
||||
</form>
|
||||
{truncated ? (
|
||||
<p className="surface-muted px-1 py-0.5 text-xs text-slate-400">
|
||||
Showing the first 100 matches. Narrow the search to find older users.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="min-h-[18rem] flex-1 overflow-y-auto">
|
||||
{filtered.length ? filtered.map((user) => (
|
||||
{users.length ? users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
@@ -333,14 +332,16 @@ export default function IdentityDatabasePanel() {
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const refreshUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await listUsers(socket);
|
||||
const resp = await listUsers(socket, { query, filter });
|
||||
setUsers(resp.users || []);
|
||||
setTruncated(Boolean(resp.truncated));
|
||||
setPermissions(resp.permissions || []);
|
||||
if (selectedUser?.id) {
|
||||
const updated = await getUser(socket, selectedUser.id);
|
||||
@@ -352,7 +353,7 @@ export default function IdentityDatabasePanel() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedUser?.id, socket]);
|
||||
}, [filter, query, selectedUser?.id, socket]);
|
||||
|
||||
const selectUser = useCallback(async (userId) => {
|
||||
setLoading(true);
|
||||
@@ -391,6 +392,11 @@ export default function IdentityDatabasePanel() {
|
||||
refreshUsers();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const submitSearch = (event) => {
|
||||
event.preventDefault();
|
||||
refreshUsers();
|
||||
};
|
||||
|
||||
const handleAddSignal = (type, value) =>
|
||||
runMutation((userId) => addSignal(socket, userId, type, value), 'Signal added.');
|
||||
const handleRemoveSignal = (type, value) =>
|
||||
@@ -415,6 +421,7 @@ export default function IdentityDatabasePanel() {
|
||||
<div className="grid min-h-0 flex-1 gap-0.5 lg:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<UserListCard
|
||||
users={users}
|
||||
truncated={truncated}
|
||||
selectedUserId={selectedUser?.id || null}
|
||||
query={query}
|
||||
filter={filter}
|
||||
@@ -422,6 +429,7 @@ export default function IdentityDatabasePanel() {
|
||||
onQuery={setQuery}
|
||||
onFilter={setFilter}
|
||||
onRefresh={refreshUsers}
|
||||
onSearch={submitSearch}
|
||||
onSelect={selectUser}
|
||||
/>
|
||||
<div className="min-h-0 space-y-0.5 overflow-y-auto">
|
||||
|
||||
@@ -13,8 +13,8 @@ export function emitIdentityAdmin(socket, eventName, payload = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function listUsers(socket) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:listUsers');
|
||||
export function listUsers(socket, { query = '', filter = 'all' } = {}) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:listUsers', { query, filter });
|
||||
}
|
||||
|
||||
export function getUser(socket, userId) {
|
||||
|
||||
Reference in New Issue
Block a user