diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 7a136f19..06c410ad 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -50,6 +50,7 @@ jobs: matrix.node-version == 20 uses: codecov/codecov-action@v4 with: + flags: python verbose: true control_loop: @@ -111,6 +112,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: + flags: frontend verbose: true - name: Upload frontend build uses: actions/upload-artifact@v4 @@ -118,8 +120,38 @@ jobs: name: frontend path: ./dotbot/frontend/build + console: + name: check console + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up nodejs + uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm install + working-directory: ./dotbot/console-web + - run: npm run lint + working-directory: ./dotbot/console-web + - run: npm run typecheck + working-directory: ./dotbot/console-web + - run: npm run test + working-directory: ./dotbot/console-web + - run: npm run build + working-directory: ./dotbot/console-web + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + flags: console + verbose: true + - name: Upload console build + uses: actions/upload-artifact@v4 + with: + name: console + path: ./dotbot/console-web/dist + package: - needs: [test, doc, frontend, control_loop] + needs: [test, doc, frontend, console, control_loop] name: build source package runs-on: ${{ matrix.os }} strategy: diff --git a/.gitignore b/.gitignore index de2230ff..b95a706b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.log *.egg-info/ .coverage +coverage/ .tox/ dist/ node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 182fc968..df4ba4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,28 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +- **Breaking - the controller binds loopback by default.** `dotbot run + controller` served the REST/WebSocket API on `0.0.0.0`, putting an + unauthenticated API on every interface; the new `/swarmit/*` proxy would + republish the swarmit server the same way. It now binds `127.0.0.1`. To reach + it from another machine pass `--controller-http-host 0.0.0.0`, set + `[run.controller] http_host`, or `DOTBOT_RUN_CONTROLLER_HTTP_HOST`; binding + beyond loopback logs a warning. +- **`dotbot run controller` now opens the unified web console** at `/console` + instead of the classic dashboard at `/PyDotBot`. The classic UI is still + served and still carries the qrkey demo, the REST demo and the SailBot + views. If only one of the two is built, that one is opened; if neither is, + the controller serves the API and says so rather than opening a dead tab. +- **Device addresses are rendered uppercase everywhere**, through a single + `dotbot.addr_to_hex()` helper, and are matched case-sensitively. The address + is the join key between the control plane and swarmit, which already + uppercased it, so the two now agree; `DOTBOT_ADDRESS_DEFAULT` and + `GATEWAY_ADDRESS_DEFAULT` were already written this way. Consequences: + a lowercase address in a REST path or MQTT topic now reaches no DotBot, + and a `--csv-data-output` file spanning the upgrade holds both cases for + the same robot (re-normalise with `df.address.str.upper()` before grouping). + `-d/--dotbot-address` on `dotbot run joystick` / `keyboard` accepts either + case and normalises. - **Breaking — CLI reorganized into four object-namespaces.** The top level is now exactly `fw` (firmware artifacts), `device` (one cabled device), `swarm` (the fleet), and `run` (host-side processes). The flat diff --git a/README.md b/README.md index 6b56ddf7..2a0dac3e 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,19 @@ Every command and flag is documented in the [CLI reference][cli-doc]. See the whole thing run with nothing but Python! -The command below will run a simulated swarm, which you can observe in a web UI at http://localhost:8000/PyDotBot/ : +The command below will run a simulated swarm, which you can observe in the web console at http://localhost:8000/console/ : ```bash dotbot run simulator ``` -The web UI opens automatically; pass `--headless` to suppress it (it's still -served). Drive the simulated DotBots from the UI, or run a bundled demo in a +The console opens automatically; pass `--headless` to suppress it (it's still +served). It is one map-first UI for both driving the fleet and orchestrating the +testbed - firmware flashing, start/stop and live events - when a swarmit server +is reachable. The classic UI remains at `/PyDotBot`; it is where the qrkey demo, +the REST demo and the SailBot views live. + +Drive the simulated DotBots from the console, or run a bundled demo in a second terminal: ```bash @@ -140,7 +145,7 @@ dotbot swarm flash rc-car -ys # this firmware lets DotBots be remote-controlled Observe and control your swarm from a web interface: ```bash -dotbot run controller # opens a webpage at http://localhost:8000/PyDotBot/ +dotbot run controller # opens the console at http://localhost:8000/console/ ``` Full walkthrough of fleet operations - status, OTA flash, start/stop, monitor - diff --git a/codecov.yml b/codecov.yml index 020ac4f0..18bfc2c0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,17 +1,49 @@ -# Codecov config — split project-level (long-term) from patch-level -# (per-PR) policy. Project-level keeps the long-term floor honest; -# patch-level is informational because vendoring / refactor PRs can -# legitimately ship diffs with low instantaneous coverage even when -# the project total stays healthy. +# Codecov config — the gate belongs on the Python control plane, not on the +# two web apps. +# +# Coverage arrives from three uploads (pytest, the classic frontend's vitest, +# the console's vitest). Untagged they merge into one project total, so adding +# a few thousand lines of thinly-covered TS drags the number down and fails a +# PR that did nothing to the Python. Flags keep the three apart. +# +# The web apps are reported but never block: their vitest suites cover the pure +# logic (state merge, mission grouping, SSE parsing, the drive mixing) and +# deliberately not the rendering, so their absolute number is low by design and +# says little about whether a change is safe. + +flags: + python: + paths: + - dotbot/ + - utils/ + carryforward: true + frontend: + paths: + - dotbot/frontend/ + carryforward: true + console: + paths: + - dotbot/console-web/ + carryforward: true coverage: status: project: - default: + # No unflagged aggregate status: that is the one that mixed the three. + default: false + python: + flags: + - python # Compare against main's current coverage. Allow tiny dips # (rounding / one-off branches) without flapping CI. target: auto threshold: 1% + web: + flags: + - frontend + - console + # Reported in the PR comment, never a CI gate. + informational: true patch: default: # Report patch coverage but don't fail CI on it. Reviewers can diff --git a/doc/cli/run.md b/doc/cli/run.md index 339547dc..5a494f5a 100644 --- a/doc/cli/run.md +++ b/doc/cli/run.md @@ -19,9 +19,13 @@ dotbot run --help # the full list | `keyboard` | Drive a DotBot from the keyboard. | | `joystick` | Drive a DotBot from a joystick. | -## `controller` - the control plane + web UI +## `controller` - the control plane + web console -Connect to a swarm and serve the dashboard at `http://localhost:8000/PyDotBot/`. +Connect to a swarm and serve the console at `http://localhost:8000/console/`. +The console is one map-first UI for driving the fleet and, when a swarmit server +is reachable, orchestrating the testbed. The classic dashboard stays served at +`/PyDotBot`, which is where the qrkey demo, the REST demo and the SailBot views +live. `--conn` is one discriminated string: `mqtts://host:port`, a serial path, or `simulator`. @@ -34,8 +38,10 @@ dotbot run controller --conn /dev/ttyACM0 |---|---| | `-n/--conn` | `mqtts://host:port`, serial path, or `simulator` | | `-s/--swarm-id` | hex swarm id - **required for MQTT**, ignored for serial/simulator | -| `--headless` | don't open the dashboard in a browser (it's still served) | +| `--controller-http-host` | interface the API binds to (default `127.0.0.1`, loopback). Pass `0.0.0.0` to reach it from another machine - the API is unauthenticated and `/swarmit/*` reaches the swarmit server through it, so only on a network you trust. | +| `--headless` | don't open the console in a browser (it's still served) | | `--csv-data-output` | record DotBot data to a CSV file | +| `--swarmit-url` | swarmit server behind the console's orchestration panel (default `http://localhost:8001`, matching `swarmit serve`). Also `[run.controller] swarmit_url` in dotbot.toml, or `DOTBOT_SWARMIT_URL`. | Full options and the dashboard tour live in [the controller guide](../guides/controller.md). See `dotbot run controller --help`. @@ -57,7 +63,7 @@ dotbot run gateway # autodetect port, print-only (no broker) ## `simulator` - standalone simulator No hardware, no gateway. Exactly equivalent to `run controller --conn simulator`, -so it shares the controller's flags and serves the same dashboard. +so it shares the controller's flags and serves the same console. ```bash dotbot run simulator @@ -94,7 +100,7 @@ specific DotBot by hex address. ```bash dotbot run keyboard -dotbot run joystick -j 0 -d 1234567890abcdef +dotbot run joystick -j 0 -d 1234567890ABCDEF ``` See `dotbot run keyboard --help` / `dotbot run joystick --help` for the host, diff --git a/doc/reference/configuration.md b/doc/reference/configuration.md index 28a24842..f960b75e 100644 --- a/doc/reference/configuration.md +++ b/doc/reference/configuration.md @@ -122,6 +122,7 @@ The four tables mirror the four CLI namespaces (`fw` / `device` / `swarm` / | `conn` | Connection string for `dotbot run`. | | `swarm_id` | Swarm id (topic namespace). | | `[run.controller] http_port` | REST/WebSocket port (default 8000). | +| `[run.controller] http_host` | Interface the REST/WebSocket API binds to (default `127.0.0.1`). `0.0.0.0` exposes it to the network; the API is unauthenticated. | | `[run.controller] map_size` | Controller map size. | | `[run.controller] background_map` | Background map image. | | `[run.controller] log_output` | Log output path. | @@ -129,6 +130,7 @@ The four tables mirror the four CLI namespaces (`fw` / `device` / `swarm` / | `[run.controller] headless` | Stay headless - don't open the web UI in a browser on start (default false; it's still served). | | `[run.controller] gw_address` | Gateway address. | | `[run.controller] simulator_init_state` | Initial simulator state. | +| `[run.controller] swarmit_url` | swarmit server the console's orchestration panel talks to, proxied at `/swarmit/*` (default `http://localhost:8001`, which matches `swarmit serve`). | | `[run.gateway] serial_port` | Gateway serial port. | | `[run.gateway] mqtt` | Gateway MQTT connection string. | diff --git a/doc/reference/mqtt.md b/doc/reference/mqtt.md index 9d125902..e3e7bda9 100644 --- a/doc/reference/mqtt.md +++ b/doc/reference/mqtt.md @@ -37,7 +37,8 @@ base64 string derived from the current PIN code (see [Secured brokers](#secured- Command-topic fields: - `` - 4-hex swarm identifier (DotBots behind one gateway), e.g. `0000`. -- `
` - 16-hex DotBot address, e.g. `9903ef26257feb31`. +- `
` - 16-hex DotBot address, e.g. `9903EF26257FEB31`. Uppercase, and + matched case-sensitively: a lowercase address reaches no DotBot. - `` - application type: `0` = DotBot, `1` = SailBot. - `` - the command name (last segment). @@ -51,12 +52,12 @@ Payloads are JSON. Drive a DotBot forward and turn its LED red: ```bash # move_raw - left_y / right_y drive the wheels, values in [-100, 100] mosquitto_pub -h \ - -t '/pydotbot//command/0000/9903ef26257feb31/0/move_raw' \ + -t '/pydotbot//command/0000/9903EF26257FEB31/0/move_raw' \ -m '{"left_x": 0, "left_y": 80, "right_x": 0, "right_y": 80}' # rgb_led - 0..255 per channel mosquitto_pub -h \ - -t '/pydotbot//command/0000/9903ef26257feb31/0/rgb_led' \ + -t '/pydotbot//command/0000/9903EF26257FEB31/0/rgb_led' \ -m '{"red": 255, "green": 0, "blue": 0}' ``` diff --git a/doc/reference/rest.md b/doc/reference/rest.md index 4fb51026..d4e2261f 100644 --- a/doc/reference/rest.md +++ b/doc/reference/rest.md @@ -67,7 +67,7 @@ print(requests.get("http://localhost:8000/controller/dotbots").json()) ```py import requests -addr = "9903ef26257feb31" # from the list above +addr = "9903EF26257FEB31" # uppercase; the address is matched case-sensitively requests.put( f"http://localhost:8000/controller/dotbots/{addr}/0/rgb_led", json={"red": 255, "green": 0, "blue": 0}, @@ -79,7 +79,7 @@ requests.put( ```py import requests -addr = "9903ef26257feb31" +addr = "9903EF26257FEB31" requests.put( f"http://localhost:8000/controller/dotbots/{addr}/0/move_raw", json={"left_x": 0, "left_y": 60, "right_x": 0, "right_y": 60}, diff --git a/dotbot/__init__.py b/dotbot/__init__.py index 216b851b..16a792b8 100644 --- a/dotbot/__init__.py +++ b/dotbot/__init__.py @@ -1,5 +1,6 @@ """Pydotbot module.""" +from binascii import hexlify from importlib.metadata import PackageNotFoundError, version from dotbot_utils.serial_interface import get_default_port @@ -12,11 +13,27 @@ CONTROLLER_HTTP_PROTOCOL_DEFAULT = "http" CONTROLLER_HTTP_HOSTNAME_DEFAULT = "localhost" CONTROLLER_HTTP_PORT_DEFAULT = 8000 +# Loopback by default: the REST/WS API is unauthenticated and, since the +# controller proxies /swarmit/*, binding wider also republishes the swarmit +# server at the controller's reachability. +CONTROLLER_HTTP_HOST_DEFAULT = "127.0.0.1" CONTROLLER_ADAPTER_DEFAULT = "serial" MQTT_HOST_DEFAULT = "localhost" MQTT_PORT_DEFAULT = 1883 MAP_SIZE_DEFAULT = "2000x2000" # in mm unit SIMULATOR_INIT_STATE_DEFAULT = "simulator_init_state.toml" +SWARMIT_URL_DEFAULT = "http://localhost:8001" # swarmit server default port + + +def addr_to_hex(addr: int) -> str: + """Render a 64-bit device address as canonical hex. + + Uppercase is the canonical form across the DotBot stack: the swarm side + (swarmit) renders addresses this way, and `DOTBOT_ADDRESS_DEFAULT` / + `GATEWAY_ADDRESS_DEFAULT` are written this way. `binascii.hexlify` returns + lowercase, so every address that becomes a string goes through here. + """ + return hexlify(addr.to_bytes(8, "big")).decode().upper() def pydotbot_version() -> str: diff --git a/dotbot/adapter.py b/dotbot/adapter.py index 3742a269..3458f1fe 100644 --- a/dotbot/adapter.py +++ b/dotbot/adapter.py @@ -119,9 +119,9 @@ async def start(self, on_frame_received: callable): def _on_mari_event(event: EdgeEvent, event_data: MariNode | MariFrame): if event == EdgeEvent.NODE_JOINED: - LOGGER.debug(f"Node joined: {event_data.address:016x}") + LOGGER.debug(f"Node joined: {event_data.address:016X}") elif event == EdgeEvent.NODE_LEFT: - LOGGER.debug(f"Node left: {event_data.address:016x}") + LOGGER.debug(f"Node left: {event_data.address:016X}") elif event == EdgeEvent.NODE_DATA: if event_data.header.next_proto != NextProto.DOTBOT_APP: return @@ -183,9 +183,9 @@ async def start(self, on_frame_received: callable): def _on_mari_event(event: EdgeEvent, event_data: MariNode | MariFrame): if event == EdgeEvent.NODE_JOINED: - LOGGER.debug(f"Node joined: {event_data.address:016x}") + LOGGER.debug(f"Node joined: {event_data.address:016X}") elif event == EdgeEvent.NODE_LEFT: - LOGGER.debug(f"Node left: {event_data.address:016x}") + LOGGER.debug(f"Node left: {event_data.address:016X}") elif event == EdgeEvent.NODE_DATA: if event_data.header.next_proto != NextProto.DOTBOT_APP: return diff --git a/dotbot/cli/gateway.py b/dotbot/cli/gateway.py index 8075067d..6f1428be 100644 --- a/dotbot/cli/gateway.py +++ b/dotbot/cli/gateway.py @@ -26,6 +26,7 @@ import click +from dotbot import addr_to_hex from dotbot.cli._cfg import from_config from dotbot.cli._conn import parse_connection @@ -46,7 +47,7 @@ def _run_gateway(port, mqtt_url, do_print): # pragma: no cover - needs a gatewa def on_event(event, event_data): if do_print and event == EdgeEvent.NODE_DATA: click.echo( - f"<- {event_data.header.source:016x}: {event_data.payload.hex()}" + f"<- {addr_to_hex(event_data.header.source)}: {event_data.payload.hex()}" ) mqtt_interface = None diff --git a/dotbot/config.py b/dotbot/config.py index 7027c43d..35a50ca0 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -138,6 +138,7 @@ class SwarmSection(_Strict): class ControllerSection(_Strict): http_port: int | None = None + http_host: str | None = None map_size: str | None = None background_map: str | None = None log_output: str | None = None @@ -145,6 +146,7 @@ class ControllerSection(_Strict): headless: bool | None = None gw_address: str | None = None simulator_init_state: str | None = None + swarmit_url: str | None = None class GatewaySection(_Strict): @@ -292,11 +294,13 @@ def _env_candidates(section: str | None, key: str) -> tuple[str, ...]: """Env-var names to check, in priority order (Cargo's mechanical mapping). Sectioned key -> `DOTBOT_
_`, then the shared `DOTBOT_` - alias. Top-level key -> just `DOTBOT_`. + alias. Top-level key -> just `DOTBOT_`. A nested section like + `run.controller` flattens its dots: `DOTBOT_RUN_CONTROLLER_`. """ key_part = key.upper().replace("-", "_") if section: - return (f"DOTBOT_{section.upper()}_{key_part}", f"DOTBOT_{key_part}") + section_part = section.upper().replace(".", "_") + return (f"DOTBOT_{section_part}_{key_part}", f"DOTBOT_{key_part}") return (f"DOTBOT_{key_part}",) @@ -318,11 +322,17 @@ def _file_value( key: str, deployment: Deployment | None, ) -> Any: - """The value this key has in the file layer: section > deployment > top-level.""" + """The value this key has in the file layer: section > deployment > top-level. + + `section` may be nested (dot-separated, e.g. `run.controller`); each part + is walked with getattr. + """ if config is None: return None if section is not None: - section_obj = getattr(config, section, None) + section_obj: Any = config + for part in section.split("."): + section_obj = getattr(section_obj, part, None) value = getattr(section_obj, key, None) if value is not None: return value @@ -348,9 +358,10 @@ def resolve( `flag` > env (`DOTBOT_
_`, then shared `DOTBOT_`) > file (section > deployment > top-level) > `default`. - `section` is one of `SECTIONS` for a per-namespace key, or `None` for a - top-level shared key (e.g. `conn`, `swarm_id`). Env values are coerced to - the type of `default`. + `section` is one of `SECTIONS` for a per-namespace key, a dotted path for + a nested table (e.g. `run.controller`), or `None` for a top-level shared + key (e.g. `conn`, `swarm_id`). Env values are coerced to the type of + `default`. """ if flag is not None: return flag diff --git a/dotbot/console-web/dev/TESTING.md b/dotbot/console-web/dev/TESTING.md new file mode 100644 index 00000000..18814f0e --- /dev/null +++ b/dotbot/console-web/dev/TESTING.md @@ -0,0 +1,81 @@ +# Console manual test guide + +Start the stack (three terminals, from the workspace root, venv active): + +```bash +PYTHONPATH=repos/wt-PyDotBot-unified-web-ui dotbot run simulator --headless \ + --simulator-init-state repos/wt-PyDotBot-unified-web-ui/dotbot/console-web/dev/simulator_init_state.toml + +python repos/wt-PyDotBot-unified-web-ui/dotbot/console-web/dev/fake_swarmit_server.py + +npm --prefix repos/wt-PyDotBot-unified-web-ui/dotbot/console-web run start +``` + +Open http://localhost:5173. Handy URL params: `?sel=1111` (preselect), +`?view=list|grid`, `?rail=testbed|missions`, `?theme=light`. + +If the title bar says OFFLINE: the vite proxy targets do not match where the +controller runs. `curl localhost:5173/controller/dotbots` must return bots. + +## Checklist + +Map + selection +- [ ] Title bar: LIVE (green pulse), bot count; Dark|Light toggle flips theme +- [ ] Bots move smoothly; heading pointer at the circle edge; battery bar above +- [ ] Plain drag pans (clamped); plain click clears selection +- [ ] Shift-drag marquee selects; shift-click toggles one bot +- [ ] Click bot: red rectangle + id chip; hover another bot: chip appears +- [ ] Zoom +/-/recenter; arena keeps margins when rail opens or window resizes +- [ ] Layers panel: Battery Bars / Waypoints / DotBots / Real-scale / Trails toggle live + +Footer (bottom strip) +- [ ] Nothing selected: N/1000 + per-state rollup; click a state row selects those bots +- [ ] One bot: LED thumb, short id, device, Drivable pill, state, battery, "x, y mm" +- [ ] Ghost bot (0001/0002): "Not drivable" pill, dock grayed, warning hint +- [ ] Multi: "N selected", per-state mini-rollup, dock drives the group +- [ ] Minimap: square (arena aspect), dots colored by state, drag moves the map view + +Control dock +- [ ] Joystick: drag = bot drives, release = stops; knob shows LED color + live heading +- [ ] LED button -> swatch grid -> bot circle + map dot recolor (toast confirms) +- [ ] Alt-click map queues waypoints (dashed diamonds); popover lists "x, y mm" with per-item remove +- [ ] Go sends: bot navigates, diamonds go solid, button morphs to "Stop nav" +- [ ] Deselect and reselect other bots: the Planned mission survives (see rail Missions) + +Testbed rail +- [ ] Icon strip -> panel; Testbed tab: Target label follows selection +- [ ] Flash... dialog: pick image, Flash N device(s); bots blink amber, queue tab + shows per-device % bars, fleet % bar in Testbed tab, footer shows chunk progress +- [ ] After flash: bots in Bootloader (not drivable) -> Start returns them to Running +- [ ] Stop: Stopping -> Bootloader; Reset: Resetting -> Bootloader +- [ ] Console tab: log lines stream (flash/start/stop events), Clear works +- [ ] Missions tab: Planned (Go / discard) and Active (interrupt) rows; click row + reselects its bots; arrival adds a "Recently completed" line + +List / Grid +- [ ] Search by id, state filter, column sort; checkbox multi-select + select-all +- [ ] Selection carries across Map/List/Grid and drives the same footer dock + +## Scripted actions (against the running stack) + +```bash +# color a bot's LED +curl -X PUT localhost:8000/controller/dotbots/badcafe111111111/0/rgb_led \ + -H 'Content-Type: application/json' -d '{"red":34,"green":197,"blue":94}' + +# send a waypoint mission (bot navigates on its own) +curl -X PUT localhost:8000/controller/dotbots/badcafe111111111/0/waypoints \ + -H 'Content-Type: application/json' \ + -d '{"threshold":60,"waypoints":[{"x":400,"y":1600},{"x":1600,"y":400}]}' + +# flash two bots (watch rail queue + console) +curl -N -X POST localhost:8001/flash/stream -H 'Content-Type: application/json' \ + -d '{"firmware_b64":"ZmFrZQ==","devices":["badcafe111111111","deadbeef22222222"]}' + +# stop / start / reset a subset (or omit devices = whole fleet) +curl -X POST localhost:8001/stop -H 'Content-Type: application/json' \ + -d '{"devices":["badcafe111111111"]}' + +# screenshot for the Claude Design re-seed loop +node dotbot/console-web/dev/screenshot.mjs "http://127.0.0.1:5173/?sel=1111" out.png +``` diff --git a/dotbot/console-web/dev/fake_swarmit_server.py b/dotbot/console-web/dev/fake_swarmit_server.py new file mode 100644 index 00000000..2655d8cd --- /dev/null +++ b/dotbot/console-web/dev/fake_swarmit_server.py @@ -0,0 +1,425 @@ +"""Fake SwarmIT server for console development. + +Simulates the swarmit testbed server's HTTP surface with the same shapes: + +- GET /status -> {"response": {"": {device, status, battery, pos_x, pos_y}}} +- GET /settings -> {network_id, area_width, area_height, calibration_distance, auth_mode} +- POST /start|/stop|/reset {devices?} -> state transitions +- POST /flash {firmware_b64, devices?} (blocking) +- POST /flash/stream {firmware_b64, devices?} -> SSE: flash_started / chunk / + device_done / complete (same event shapes as swarmit) +- GET /events -> SSE: log_event entries (+ periodic status snapshots) + +Bot addresses are read live from the PyDotBot controller so both planes join +on the same ids; two ghost bots exist only on this plane (start in Bootloader, +never drivable). The DEVICE side is simulated; the HTTP contract is the real +one, so the console's write path exercises the same calls it will make against +a real swarmit server. + +Usage: python fake_swarmit_server.py [--controller http://localhost:8000] [--port 8001] +""" + +import argparse +import asyncio +import json +import time +import urllib.request + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse + +GHOSTS = { + "C0FFEE0000000001": {"pos_x": 300, "pos_y": 1800}, + "C0FFEE0000000002": {"pos_x": 1750, "pos_y": 1650}, +} + +TOTAL_CHUNKS = 320 +CHUNKS_PER_TICK = 12 +TICK_S = 0.15 + +app = FastAPI(title="fake-swarmit") +app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] +) + +settings = {"controller": "http://localhost:8000"} + +# Per-device simulated orchestration state. +states: dict = {} # addr -> {"status": str, "progress": {"acked", "total"} | None} +events: list = [] # [{"id", "type": "log_event", "level", "message", "ts"}] +_event_seq = 0 + + +def push_log(level: str, message: str) -> None: + global _event_seq + _event_seq += 1 + events.append( + { + "id": _event_seq, + "type": "log_event", + "level": level, + "message": message, + "ts": time.time(), + } + ) + del events[:-200] + + +def fetch_dotbots() -> list: + try: + with urllib.request.urlopen( + f"{settings['controller']}/controller/dotbots", timeout=2 + ) as res: + return json.loads(res.read()) + except Exception: + return [] + + +def known_devices() -> list: + return sorted({b["address"] for b in fetch_dotbots()} | set(GHOSTS)) + + +def state_of(addr: str) -> dict: + if addr not in states: + states[addr] = { + "status": "Bootloader" if addr in GHOSTS else "Running", + "progress": None, + } + return states[addr] + + +def resolve_devices(payload_devices) -> list: + if not payload_devices: + return known_devices() + if isinstance(payload_devices, str): + return [payload_devices] + return list(payload_devices) + + +# --- device-info fixtures ----------------------------------------------------- +# +# Shapes and vocabulary mirror swarmit's `_serialise_node` and the helpers it +# calls (`format_reset_cause`, `reset_severity`, `battery_pct`, `lh2_summary`). +# This file deliberately does NOT import swarmit: PyDotBot does not depend on +# it, and a dev harness is not the place to add a cross-repo import. The cost +# is that these tables have to be kept in step by hand if swarmit's wording +# changes, which is the normal bargain for a fake. +# +# Everything is picked deterministically from the address, so a screenshot taken +# twice looks the same. + +SANDBOX_FW = "0.8.0rc3-87-gb8957de" + +# (image_name, image_digest, image_size) +IMAGES = [ + ("dotbot-sandbox-dotbot-v3.bin", "c8a70af215722154", 6740), + ("spin-sandbox-dotbot-v3.bin", "5ff0e9023306b1eb", 2292), + ("move-sandbox-dotbot-v3.bin", "1b90c4de77a30265", 3128), + ("rgbled-sandbox-dotbot-v3.bin", "9d31fa07c25e4418", 1984), +] + +# (reset_cause, reset_severity, reset_reason, fault, fault_name, pc, lr) +# Chosen to exercise all three badge tiers. The "hung" entry is a real one: it +# is what a finished `spin` reports, pc landing in its terminal while(1). +RESETS = [ + ("power-on", "normal", 0x00000000, 0, "NoFault", 0, 0), + ("soft-reset", "normal", 0x00000008, 0, "NoFault", 0, 0), + ("stopped", "normal", 0x02000000, 0, "NoFault", 0, 0), + ("lockup", "normal", 0x00000010, 0, "NoFault", 0, 0), + ( + "hung (watchdog0 pc=0x00010230)", + "hung", + 0x00000002, + 3, + "WatchdogTimeout", + 0x00010230, + 0x0001022B, + ), + ( + "crashed (watchdog0 HardFault pc=0x2000abcd)", + "crashed", + 0x00000002, + 1, + "HardFault", + 0x2000ABCD, + 0x2000AB41, + ), +] + +# v3 pack: 3.0 V supercapacitor, brownout at 0.6 V, energy goes as V^2. +V_MAX_MV, V_EMPTY_MV, V_FULL_MV, V_WARN_MV = 3000, 600, 2900, 1500 + + +def battery_fields(mv: int) -> dict: + num = mv**2 - V_EMPTY_MV**2 + den = V_MAX_MV**2 - V_EMPTY_MV**2 + pct = max(0, min(100, int(num / den * 100))) + level = "full" if mv > V_FULL_MV else "ok" if mv > V_WARN_MV else "low" + return {"battery_pct": pct, "battery_level": level} + + +def _seed(addr: str) -> int: + return int(addr[-8:], 16) + + +def device_info(addr: str) -> dict: + seed = _seed(addr) + name, digest, size = IMAGES[seed % len(IMAGES)] + # A minority are uncalibrated, which is the state an operator acts on. + homographies = 0 if seed % 9 == 0 else (2 if seed % 5 == 0 else 1) + flags = 0 if not homographies else 0b11 + noun = "basestation" if homographies == 1 else "basestations" + summary = ( + "uncalibrated" + if not homographies + else f"{homographies} {noun} (valid, from flash)" + ) + return { + "info_version": 1, + "info_gen": 4, + "boot_count": 2 + seed % 30, + "uptime_s": 60 + seed % 9000, + "bl_version": SANDBOX_FW, + "net_version": SANDBOX_FW, + "image_state": 0, + "image_result": 1, + "image_state_name": "Idle", + "image_result_name": "Success", + "image_size": size, + "image_digest": digest, + "image_name": name, + "image_version": "", + "lh2_homography_count": homographies, + "lh2_flags": flags, + "lh2_summary": summary, + "raw": "8f0104" + f"{seed:08x}" * 4, + } + + +def node(addr: str, status: str, battery_mv: int, x: int, y: int) -> dict: + """One /status entry, the full shape a real swarmit daemon serves.""" + seed = _seed(addr) + # Weighted so most of the fleet is unremarkable and the badges mean + # something: about 5% of the fleet abnormal, split 2% a real fault and 3% + # a deliberate exit through the deadman. A badge on one bot in twenty is + # worth walking over to; a badge on one in five is wallpaper. + if seed % 50 == 0: + cause, severity, rr, fault, fault_name, pc, lr = RESETS[5] + elif seed % 33 == 0: + cause, severity, rr, fault, fault_name, pc, lr = RESETS[4] + else: + cause, severity, rr, fault, fault_name, pc, lr = RESETS[seed % 4] + return { + "device": "DotBotV3", + "status": status, + "battery": battery_mv, + "pos_x": x, + "pos_y": y, + "reset_reason": rr, + "fault": fault, + "fault_name": fault_name, + "reset_cause": cause, + "reset_severity": severity, + "from_ns": 1 if fault else 0, + "cfsr": 0x00008200 if fault == 1 else 0, + "sfsr": 0, + "pc": pc, + "lr": lr, + "raw": "8001" + f"{seed:08x}" * 3, + "last_updated_at": time.time(), + "info_gen": 4, + "info": device_info(addr), + **battery_fields(battery_mv), + } + + +def _sse(data: dict) -> str: + return f"data: {json.dumps(data)}\n\n" + + +@app.get("/status") +def status(): + response = {} + for bot in fetch_dotbots(): + addr = bot["address"] + pos = bot.get("lh2_position") or {} + response[addr] = node( + addr, + state_of(addr)["status"], + int(float(bot.get("battery", 3.0)) * 1000), + int(pos.get("x", 0)), + int(pos.get("y", 0)), + ) + for addr, pos in GHOSTS.items(): + response[addr] = node( + addr, state_of(addr)["status"], 2450, pos["pos_x"], pos["pos_y"] + ) + return {"response": response} + + +@app.get("/settings") +def get_settings(): + return { + "network_id": 0x12, + "area_width": 2000, + "area_height": 2000, + "calibration_distance": 400, + "auth_mode": "none", + } + + +async def transition(devices: list, via: str, to: str, delay: float) -> None: + for a in devices: + state_of(a)["status"] = via + await asyncio.sleep(delay) + for a in devices: + state_of(a)["status"] = to + + +@app.post("/start") +async def start(request: Request): + body = ( + await request.json() if int(request.headers.get("content-length") or 0) else {} + ) + devices = resolve_devices(body.get("devices")) + for a in devices: + state_of(a)["status"] = "Running" + push_log("ok", f"testbed started · {len(devices)} device(s)") + return {"result": "ok", "devices": devices} + + +@app.post("/stop") +async def stop(request: Request): + body = ( + await request.json() if int(request.headers.get("content-length") or 0) else {} + ) + devices = resolve_devices(body.get("devices")) + push_log("warn", f"stopping · {len(devices)} device(s)") + asyncio.create_task(transition(devices, "Stopping", "Bootloader", 1.2)) + return {"result": "ok", "devices": devices} + + +@app.post("/reset") +async def reset(request: Request): + body = ( + await request.json() if int(request.headers.get("content-length") or 0) else {} + ) + devices = resolve_devices( + body.get("devices") or list(body.get("locations", {}) or {}) + ) + push_log("warn", f"resetting · {len(devices)} device(s)") + asyncio.create_task(transition(devices, "Resetting", "Bootloader", 1.6)) + return {"result": "ok", "devices": devices} + + +async def flash_events(devices: list, fw_len: int): + for a in devices: + st = state_of(a) + st["status"] = "Programming" + st["progress"] = {"acked": 0, "total": TOTAL_CHUNKS} + push_log("info", f"flash started · {len(devices)} device(s) · {fw_len} bytes") + yield _sse( + { + "type": "flash_started", + "image_size": fw_len, + "total_chunks": TOTAL_CHUNKS, + "fw_hash": "F4KEF4KE", + "devices": sorted(devices), + } + ) + done: set = set() + while len(done) < len(devices): + await asyncio.sleep(TICK_S) + for a in devices: + if a in done: + continue + p = state_of(a)["progress"] + p["acked"] = min(p["total"], p["acked"] + CHUNKS_PER_TICK) + yield _sse( + {"type": "chunk", "addr": a, "acked": p["acked"], "total": p["total"]} + ) + if p["acked"] >= p["total"]: + done.add(a) + st = state_of(a) + st["status"] = "Bootloader" # flashed image sits ready; /start runs it + st["progress"] = None + push_log("ok", f"{a[-4:].upper()} flashed · {p['total']} chunks") + yield _sse( + { + "type": "device_done", + "addr": a, + "success": True, + "retries": 0, + "chunks_acked": p["total"], + "chunks_total": p["total"], + } + ) + push_log("ok", "flash complete · all devices") + yield _sse( + { + "type": "complete", + "all_success": True, + "elapsed_s": TOTAL_CHUNKS / CHUNKS_PER_TICK * TICK_S, + } + ) + + +@app.post("/flash/stream") +async def flash_stream(request: Request): + body = await request.json() + devices = resolve_devices(body.get("devices")) + fw_len = len(body.get("firmware_b64", "")) * 3 // 4 + return StreamingResponse( + flash_events(devices, fw_len), media_type="text/event-stream" + ) + + +@app.post("/flash") +async def flash(request: Request): + body = await request.json() + devices = resolve_devices(body.get("devices")) + fw_len = len(body.get("firmware_b64", "")) * 3 // 4 + async for _ in flash_events(devices, fw_len): + pass + return {"result": "ok", "devices": devices} + + +@app.get("/events") +async def sse_events(request: Request): + async def gen(): + last_id = max(0, _event_seq - 50) # replay recent history on connect + last_snapshot = 0.0 + while True: + if await request.is_disconnected(): + return + for ev in events: + if ev["id"] > last_id: + yield _sse(ev) + last_id = ev["id"] + now = time.time() + if now - last_snapshot > 2.0: + last_snapshot = now + # The real daemon drops the device-info hex from the stream: + # 310 characters per device twice a second is most of it, and + # only `info --raw` reads it. Mirror that, so a client tested + # here cannot come to depend on a field the real server + # withholds (swarmit testbed/webserver.py, _serialise_node). + snapshot = { + addr: {**n, "info": {**n["info"], "raw": ""}} + for addr, n in status()["response"].items() + } + yield _sse({"type": "status", "response": snapshot}) + await asyncio.sleep(0.3) + + return StreamingResponse(gen(), media_type="text/event-stream") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--controller", default="http://localhost:8000") + parser.add_argument("--port", type=int, default=8001) + args = parser.parse_args() + settings["controller"] = args.controller + uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="warning") diff --git a/dotbot/console-web/dev/screenshot.mjs b/dotbot/console-web/dev/screenshot.mjs new file mode 100644 index 00000000..ae987aec --- /dev/null +++ b/dotbot/console-web/dev/screenshot.mjs @@ -0,0 +1,23 @@ +// Dev screenshot helper: loads a console URL in headless Chrome, waits for +// live data to render (or a fixed delay), then captures a PNG. +// Usage: node dev/screenshot.mjs [waitMs=4000] +import puppeteer from "puppeteer-core"; + +const [url, outfile, waitMs = "4000"] = process.argv.slice(2); +if (!url || !outfile) { + console.error("usage: node dev/screenshot.mjs [waitMs]"); + process.exit(1); +} + +const browser = await puppeteer.launch({ + executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: "shell", + args: ["--disable-gpu", "--hide-scrollbars"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1440, height: 900 }); +await page.goto(url, { waitUntil: "domcontentloaded" }); +await new Promise((r) => setTimeout(r, Number(waitMs))); +await page.screenshot({ path: outfile }); +await browser.close(); +console.log("saved", outfile); diff --git a/dotbot/console-web/dev/simulator_init_state.toml b/dotbot/console-web/dev/simulator_init_state.toml new file mode 100644 index 00000000..8515ba73 --- /dev/null +++ b/dotbot/console-web/dev/simulator_init_state.toml @@ -0,0 +1,47 @@ +# Demo fleet for console development. Run the simulator from this directory +# (it picks up ./simulator_init_state.toml) or copy this file next to it. + +[network] +pdr = 100 + +[[dotbots]] +address = "BADCAFE111111111" +calibrated = 0xff +pos_x = 400 +pos_y = 350 +direction = 45 + +[[dotbots]] +address = "DEADBEEF22222222" +calibrated = 0xff +pos_x = 1500 +pos_y = 250 +direction = 180 + +[[dotbots]] +address = "B0B0F00D33333333" +calibrated = 0xff +pos_x = 1500 +pos_y = 1500 +direction = 270 + +[[dotbots]] +address = "CAFEBABE44444444" +calibrated = 0xff +pos_x = 600 +pos_y = 1200 +direction = 90 + +[[dotbots]] +address = "FACEFEED55555555" +calibrated = 0xff +pos_x = 1000 +pos_y = 800 +direction = 0 + +[[dotbots]] +address = "ABAD1DEA66666666" +calibrated = 0xff +pos_x = 250 +pos_y = 900 +direction = 135 diff --git a/dotbot/console-web/eslint.config.mjs b/dotbot/console-web/eslint.config.mjs new file mode 100644 index 00000000..aea6b8f7 --- /dev/null +++ b/dotbot/console-web/eslint.config.mjs @@ -0,0 +1,10 @@ +// @ts-check + +import js from "@eslint/js"; +import { defineConfig } from "eslint/config"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + js.configs.recommended, + tseslint.configs.recommended, +); diff --git a/dotbot/console-web/index.html b/dotbot/console-web/index.html new file mode 100644 index 00000000..9d8975e0 --- /dev/null +++ b/dotbot/console-web/index.html @@ -0,0 +1,18 @@ + + + + + + DotBot Console + + + + + +
+ + + diff --git a/dotbot/console-web/package-lock.json b/dotbot/console-web/package-lock.json new file mode 100644 index 00000000..c851ec83 --- /dev/null +++ b/dotbot/console-web/package-lock.json @@ -0,0 +1,4804 @@ +{ + "name": "dotbot-console-web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dotbot-console-web", + "version": "0.0.1", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^10.6.0", + "puppeteer-core": "^24.43.1", + "typescript": "^5.2.2", + "typescript-eslint": "^8.63.0", + "vite": "^5.0.8", + "vitest": "^2.1.9" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-stream/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "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==", + "dev": true, + "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", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.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==", + "dev": true, + "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/string-width-cjs": { + "name": "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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/wrap-ansi-cjs": { + "name": "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==", + "dev": true, + "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", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/dotbot/console-web/package.json b/dotbot/console-web/package.json new file mode 100644 index 00000000..bbc7fd92 --- /dev/null +++ b/dotbot/console-web/package.json @@ -0,0 +1,31 @@ +{ + "name": "dotbot-console-web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src/.", + "test": "vitest run --coverage" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^10.6.0", + "puppeteer-core": "^24.43.1", + "typescript": "^5.2.2", + "typescript-eslint": "^8.63.0", + "vite": "^5.0.8", + "vitest": "^2.1.9" + } +} diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx new file mode 100644 index 00000000..f2bd2227 --- /dev/null +++ b/dotbot/console-web/src/App.tsx @@ -0,0 +1,533 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import { fetchConnection, putWaypoints } from "./api"; +import { Footer } from "./Footer"; +import { GridView } from "./GridView"; +import { Inspector } from "./Inspector"; +import { ListView } from "./ListView"; +import { Camera, Layers, MapView, ViewGeom } from "./MapView"; +import { DoneMission, TestbedRail } from "./TestbedRail"; +import { + ControllerConnection, + LH2Position, + PlannedMission, +} from "./types"; +import { useFleet } from "./useFleet"; +import { useOrchestration } from "./useOrchestration"; + +const WAYPOINT_THRESHOLD = 60; // mm, arrival radius sent with waypoint missions + +type ViewKind = "map" | "list" | "grid"; + +export const App: React.FC = () => { + const { bots, mapSize, wsUp } = useFleet(); + // ?theme=dark|light presets the theme (handy for dev/screenshots). + const [theme, setTheme] = useState<"dark" | "light">(() => + new URLSearchParams(window.location.search).get("theme") === "light" ? "light" : "dark", + ); + // ?view=map|list|grid opens a specific view (handy for dev/screenshots). + const [view, setView] = useState(() => { + const v = new URLSearchParams(window.location.search).get("view"); + return v === "list" || v === "grid" ? v : "map"; + }); + const [cam, setCam] = useState({ scale: 1, tx: 0, ty: 0 }); + const [geom, setGeom] = useState(null); + const [toast, setToast] = useState(null); + const toastTimer = useRef(undefined); + const showToast = useCallback((msg: string) => { + setToast(msg); + window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(null), 2500); + }, []); + + const orch = useOrchestration(showToast); + + // ?sel=[,] preselects bots (handy for dev/screenshots). + const [selection, setSelection] = useState>(new Set()); + const preselRef = useRef(false); + React.useEffect(() => { + if (preselRef.current || bots.length === 0) return; + const raw = new URLSearchParams(window.location.search).get("sel"); + if (raw) { + const suffixes = raw.toLowerCase().split(","); + const hits = bots.filter((b) => suffixes.some((s) => b.id.toLowerCase().endsWith(s))).map((b) => b.id); + if (hits.length) setSelection(new Set(hits)); + } + preselRef.current = true; + }, [bots]); + + // Planned missions: local waypoint queues bound to bots at queue time. + const [planned, setPlanned] = useState([]); + const [layersOpen, setLayersOpen] = useState(false); + const [layers, setLayers] = useState({ + batteryBars: true, + waypoints: true, + hotSpots: false, + dotBots: true, + trueScale: true, + trails: false, + crashedOnly: false, + }); + const [inspectorOpen, setInspectorOpen] = useState(false); + const [conn, setConn] = useState(null); + + // Fetched once: the controller cannot change transport without restarting. + useEffect(() => { + fetchConnection().then(setConn); + }, []); + + // replace = set selection to ids · toggle = flip each id · add = union (range select) + const onSelect = useCallback((ids: string[], mode: "replace" | "toggle" | "add") => { + setSelection((prev) => { + if (mode === "replace") return new Set(ids); + const next = new Set(prev); + if (mode === "add") ids.forEach((id) => next.add(id)); + else ids.forEach((id) => (next.has(id) ? next.delete(id) : next.add(id))); + return next; + }); + }, []); + + // One filter for all three views: "who crashed" is the same question whether + // you are looking at the map, the list or the grid. + const shownBots = layers.crashedOnly + ? bots.filter((b) => b.severity === "crashed") + : bots; + const selectedBots = bots.filter((b) => selection.has(b.id)); + const drivableSelected = selectedBots.filter((b) => b.drivable); + const selKey = drivableSelected.map((b) => b.id).sort().join("-"); + const selPlanned = planned.find((m) => m.key === selKey); + const pending = selPlanned?.waypoints ?? []; + + const onAddWaypoint = useCallback( + (p: LH2Position) => { + if (drivableSelected.length === 0) return; + const ids = drivableSelected.map((b) => b.id).sort(); + const key = ids.join("-"); + setPlanned((prev) => { + const hit = prev.find((m) => m.key === key); + if (hit) return prev.map((m) => (m.key === key ? { ...m, waypoints: [...m.waypoints, p] } : m)); + return [...prev, { key, ids, waypoints: [p] }]; + }); + }, + [drivableSelected], + ); + + const sendMission = useCallback( + (m: PlannedMission) => { + const targets = bots.filter((b) => m.ids.includes(b.id) && b.drivable); + targets.forEach((b) => { + putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, m.waypoints).catch(() => {}); + }); + showToast( + `${m.waypoints.length} waypoint${m.waypoints.length > 1 ? "s" : ""} sent to ${targets.length} bot${ + targets.length > 1 ? "s" : "" + }`, + ); + setPlanned((prev) => prev.filter((x) => x.key !== m.key)); + }, + [bots, showToast], + ); + + const onGo = useCallback(() => { + if (selPlanned) sendMission(selPlanned); + }, [selPlanned, sendMission]); + + const onGoMission = useCallback( + (key: string) => { + const m = planned.find((x) => x.key === key); + if (m) sendMission(m); + }, + [planned, sendMission], + ); + + const onStopNav = useCallback(() => { + drivableSelected.forEach((b) => { + putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, []).catch(() => {}); + }); + if (drivableSelected.length > 0) showToast("Navigation stopped"); + }, [drivableSelected, showToast]); + + const onClearQueue = useCallback(() => { + setPlanned((prev) => prev.filter((m) => m.key !== selKey)); + }, [selKey]); + const onDiscardMission = useCallback((key: string) => { + setPlanned((prev) => prev.filter((m) => m.key !== key)); + }, []); + const onRemovePending = useCallback( + (i: number) => { + setPlanned((prev) => + prev + .map((m) => (m.key === selKey ? { ...m, waypoints: m.waypoints.filter((_, j) => j !== i) } : m)) + .filter((m) => m.waypoints.length > 0), + ); + }, + [selKey], + ); + + // Recently-completed missions: a bot flipping AUTO -> MANUAL just arrived. + const [doneMissions, setDoneMissions] = useState([]); + const prevNavRef = useRef>({}); + React.useEffect(() => { + const prev = prevNavRef.current; + const arrived = bots.filter((b) => prev[b.id] === "auto" && b.nav === "drive"); + if (arrived.length) { + const t = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + setDoneMissions((d) => + [...arrived.map((b) => ({ key: `${b.id}-${Date.now()}`, id: b.id.slice(-4).toUpperCase(), t })), ...d].slice(0, 8), + ); + } + prevNavRef.current = Object.fromEntries(bots.map((b) => [b.id, b.nav])); + }, [bots]); + + const onStopMission = useCallback( + (ids: string[]) => { + bots + .filter((b) => ids.includes(b.id) && b.drivable) + .forEach((b) => putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, []).catch(() => {})); + showToast("Mission interrupted"); + }, + [bots, showToast], + ); + + const layerRows: { key: keyof Layers; label: string }[] = [ + { key: "batteryBars", label: "Battery Bars" }, + { key: "waypoints", label: "Waypoints" }, + { key: "hotSpots", label: "HotSpots" }, + { key: "dotBots", label: "DotBots" }, + { key: "trueScale", label: "Real-scale bots" }, + { key: "trails", label: "Trails" }, + { key: "crashedOnly", label: "Only crashed bots" }, + ]; + + return ( +
+ {/* Title bar */} +
+
DotBots
+
+ {window.location.host} + {conn && ( + <> +
+ + {conn.connection} + + + swarm id {conn.swarm_id} + + + )} +
+
+ + {wsUp ? "LIVE" : "OFFLINE"} + + · {bots.length} bots +
+
+ {/* theme: Dark | Light segmented (v1) */} +
+ {(["dark", "light"] as const).map((t) => ( +
setTheme(t)} + style={{ + padding: "4px 12px", + borderRadius: 5, + fontSize: 12, + fontWeight: 500, + cursor: "pointer", + background: theme === t ? "var(--accent)" : "transparent", + color: theme === t ? "#fff" : "var(--muted)", + textTransform: "capitalize", + }} + > + {t} +
+ ))} +
+
+ + {/* Body row: testbed rail + view area */} +
+ + orch.flash( + image, + selection.size ? [...selection] : undefined, + window.localStorage.getItem("dotbot.console.startAfterFlash") === "1", + ) + } + onStart={() => orch.act("start", selection.size ? [...selection] : undefined)} + onStop={() => orch.act("stop", selection.size ? [...selection] : undefined)} + onSelectIds={(ids) => onSelect(ids, "replace")} + onGoMission={onGoMission} + onDiscardMission={onDiscardMission} + onStopMission={onStopMission} + /> + + {/* view area */} +
+ {view === "map" && ( + { + const owner = bots.find((b) => m.ids.includes(b.id) && b.led); + return { + waypoints: m.waypoints, + led: owner?.led ? `rgb(${owner.led.red},${owner.led.green},${owner.led.blue})` : null, + }; + })} + cam={cam} + setCam={setCam} + onGeom={setGeom} + onSelect={onSelect} + onAddWaypoint={onAddWaypoint} + /> + )} + {view === "list" && } + {view === "grid" && } + + {/* shared view switcher */} +
+
setInspectorOpen((v) => !v)} + title="Show the full device info for the selection" + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "7px 11px", + borderRadius: 8, + cursor: "pointer", + fontSize: 12, + background: inspectorOpen ? "var(--elevated)" : "var(--surface)", + border: "1px solid var(--hairline)", + }} + > + ⓘ Info +
+ {view === "map" && ( +
setLayersOpen((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "7px 11px", + borderRadius: 8, + cursor: "pointer", + fontSize: 12, + background: layersOpen ? "var(--elevated)" : "var(--surface)", + border: "1px solid var(--hairline)", + boxShadow: "0 4px 16px rgba(0,0,0,.3)", + }} + > + ▤ Layers +
+ )} +
+ {(["map", "list", "grid"] as ViewKind[]).map((v) => ( +
setView(v)} + style={{ + padding: "6px 14px", + borderRadius: 6, + fontSize: 12, + cursor: "pointer", + background: view === v ? "var(--accent)" : "transparent", + color: view === v ? "#fff" : "var(--muted)", + fontWeight: view === v ? 600 : 400, + textTransform: "capitalize", + }} + > + {v} +
+ ))} +
+
+ + {/* toast */} + {toast && ( +
+ {toast} +
+ )} + + {/* layers panel */} + {layersOpen && view === "map" && ( +
+
+ Layers +
+ setLayersOpen(false)} style={{ cursor: "pointer", color: "var(--muted)", fontSize: 14, lineHeight: 1 }}> + ✕ + +
+ {layerRows.map((l) => ( +
setLayers((prev) => ({ ...prev, [l.key]: !prev[l.key] }))} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "5px 4px", + borderRadius: 5, + cursor: "pointer", + fontSize: 12, + }} + > + {l.label} + + {layers[l.key] ? "✓" : ""} + +
+ ))} +
+ )} +
+ {inspectorOpen && ( + setInspectorOpen(false)} /> + )} +
+ +
onSelect(ids, "replace")} + onGo={onGo} + onStopNav={onStopNav} + onClearQueue={onClearQueue} + onRemovePending={onRemovePending} + onToast={showToast} + /> +
+ ); +}; diff --git a/dotbot/console-web/src/FirmwareSection.tsx b/dotbot/console-web/src/FirmwareSection.tsx new file mode 100644 index 00000000..e99ea995 --- /dev/null +++ b/dotbot/console-web/src/FirmwareSection.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useRef, useState } from "react"; + +import { FirmwareFile, decodedSize, readFirmwareFile } from "./firmwareFile"; +import { + FirmwareEntry, + load as loadHistory, + togglePin as togglePinIn, +} from "./firmwareHistory"; + +// Firmware block at the top of the Testbed tab: one picker, one list, one +// flash button. +// +// There is deliberately no separate slot for the control-plane image. Pinning +// keeps it at the top of the list, which is the same affordance doing the same +// job - a dedicated slot showed the same file twice and put two competing red +// buttons in a 340px rail. + +const label: React.CSSProperties = { + fontSize: 9, + letterSpacing: ".5px", + textTransform: "uppercase", + color: "var(--muted)", +}; +const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 11 }; + +export function buildTime(ms: number, now = Date.now()): string { + if (!ms) return "unknown"; + const mins = Math.round((now - ms) / 60000); + if (mins < 1) return "just built"; + if (mins < 60) return `${mins}m old`; + const h = Math.round(mins / 60); + if (h < 24) return `${h}h old`; + return `${Math.round(h / 24)}d old`; +} + +const sizeKb = (b64: string) => `${(decodedSize(b64) / 1024).toFixed(1)} kB`; + +const flashBtn = (enabled: boolean): React.CSSProperties => ({ + display: "block", + width: "100%", + textAlign: "center", + padding: "8px 0", + borderRadius: 8, + fontSize: 12, + fontWeight: 600, + cursor: enabled ? "pointer" : "not-allowed", + background: enabled ? "var(--accent)" : "var(--elevated)", + color: enabled ? "#fff" : "var(--muted)", + border: enabled ? "1px solid transparent" : "1px solid var(--hairline)", +}); + +const pickerBtn: React.CSSProperties = { + width: "100%", + boxSizing: "border-box", + background: "var(--elevated)", + border: "1px dashed var(--hairline)", + borderRadius: 8, + padding: "9px 10px", + cursor: "pointer", + textAlign: "center", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + ...mono, +}; + +interface Props { + targetCount: number; + flashing: boolean; + onFlash: (image: FirmwareFile) => void; +} + +export const FirmwareSection: React.FC = (props) => { + const [open, setOpen] = useState(true); + const [armed, setArmed] = useState(null); + const [history, setHistory] = useState([]); + const [error, setError] = useState(null); + const [startAfter, setStartAfter] = useState( + () => window.localStorage.getItem("dotbot.console.startAfterFlash") === "1", + ); + const fileRef = useRef(null); + + useEffect(() => { + setHistory(loadHistory()); + }, []); + + useEffect(() => { + window.localStorage.setItem( + "dotbot.console.startAfterFlash", + startAfter ? "1" : "0", + ); + }, [startAfter]); + + const pick = async (f: File | undefined) => { + if (!f) return; + setError(null); + try { + setArmed(await readFirmwareFile(f)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const flash = (image: FirmwareFile | null) => { + if (!image || props.flashing) return; + props.onFlash(image); + // Re-read rather than guess: useOrchestration owns the write. + setTimeout(() => setHistory(loadHistory()), 0); + }; + + const row = ( + name: string, + b64: string, + lastModified: number, + extra?: React.ReactNode, + ) => ( +
+ {extra} +
+ {name} +
+
+ {buildTime(lastModified)} +
+
+ ); + + return ( +
+
setOpen((v) => !v)} + style={{ ...label, cursor: "pointer", display: "flex", alignItems: "center", gap: 6 }} + > + {open ? "▾" : "▸"} Firmware +
+ + {open && ( +
+ { + (e.currentTarget as HTMLInputElement).value = ""; + }} + onChange={(e) => pick(e.target.files?.[0])} + /> +
fileRef.current?.click()} + style={{ ...pickerBtn, textAlign: armed ? "left" : "center", borderStyle: armed ? "solid" : "dashed" }} + > + {armed ? row(armed.name, armed.b64, armed.lastModified) : "Choose a .bin file…"} +
+ + {history.length > 0 && ( + <> +
Recent
+
+ {history.map((h) => { + const picked = armed?.b64 === h.b64; + return ( +
setArmed({ name: h.name, b64: h.b64, lastModified: h.lastModified })} + title="Flash these exact bytes again" + style={{ + padding: "6px 8px", + borderBottom: "1px solid var(--hairline)", + cursor: "pointer", + background: picked ? "var(--elevated)" : "transparent", + color: picked ? "var(--accent)" : "var(--text)", + }} + > + {row( + h.name, + h.b64, + h.lastModified, + { + e.stopPropagation(); + setHistory(togglePinIn(h)); + }} + title={h.pinned ? "Unpin" : "Pin to the top and keep"} + style={{ cursor: "pointer", fontSize: 10, color: h.pinned ? "var(--accent)" : "var(--muted)" }} + > + {h.pinned ? "★" : "☆"} + , + )} +
+ ); + })} +
+ + )} + +
flash(armed)} + style={{ ...flashBtn(Boolean(armed) && !props.flashing && props.targetCount > 0), marginTop: 6 }} + > + ⚡ Flash {props.targetCount} device(s) +
+ + {error && ( +
{error}
+ )} + + +
+ )} +
+ ); +}; diff --git a/dotbot/console-web/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx new file mode 100644 index 00000000..60a16370 --- /dev/null +++ b/dotbot/console-web/src/Footer.tsx @@ -0,0 +1,564 @@ +import React, { useState } from "react"; + +import { batteryColor, batteryPct, stateColor, stateLabel } from "./viewChrome"; + +import { putRgbLed } from "./api"; +import { Pad } from "./Joystick"; +import { Camera, ViewGeom } from "./MapView"; +import { Minimap } from "./Minimap"; +import { BotState, LH2Position, LINK_LABEL, MapSize, STATE_ORDER, UnifiedBot } from "./types"; +import { FlashJob } from "./useOrchestration"; + +// v1 swatch palette. +const SWATCHES: [number, number, number][] = [ + [228, 3, 46], + [255, 140, 0], + [255, 200, 0], + [34, 197, 94], + [13, 148, 136], + [56, 189, 248], + [64, 80, 230], + [168, 85, 247], + [255, 255, 255], + [60, 60, 60], +]; + +const label = { fontSize: 10, letterSpacing: ".5px", textTransform: "uppercase", color: "var(--muted)" } as const; +const mono = { fontFamily: "var(--font-mono)" } as const; +// v1 gate style for non-drivable selections. +const gateOff = { opacity: 0.32, pointerEvents: "none" as const, filter: "grayscale(.7)" }; + +const ledCss = (b: UnifiedBot | undefined) => + b?.led ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` : "var(--s-Inactive)"; +const short = (id: string) => id.slice(-4).toUpperCase(); + +interface FooterProps { + bots: UnifiedBot[]; + flashQueue: Record; + mapSize: MapSize; + selection: Set; + pendingWaypoints: LH2Position[]; + cam: Camera; + setCam: React.Dispatch>; + geom: ViewGeom | null; + onSelectState: (ids: string[]) => void; + onGo: () => void; + onStopNav: () => void; + onClearQueue: () => void; + onRemovePending: (index: number) => void; + onToast: (msg: string) => void; +} + +const StateDot: React.FC<{ state: BotState | null; glow?: boolean; size?: number }> = ({ state, glow, size = 9 }) => ( + +); + +// The two axes fail differently, so the hint names which one is blocking. +function notDrivableReason(one: UnifiedBot | null | undefined): string { + if (!one) return "nothing selected"; + if (one.link === "unknown") return "not on the control plane"; + if (one.link !== "active") return `the control plane is not hearing it (${one.link})`; + if (one.state && one.state !== "Running") return `its sandbox is ${one.state.toLowerCase()}, not running`; + return "no DBP in the running image"; +} + +const BatteryBar: React.FC<{ bot: UnifiedBot }> = ({ bot }) => { + const volts = bot.battery; + const pct = batteryPct(bot); + return ( +
+
+
+
+ {volts.toFixed(2)} V +
+ ); +}; + +// The control dock, per v1: pad + LED button + segmented waypoint group, +// with popovers anchored above and everything gated when nothing is drivable. +const ControlDock: React.FC<{ + targets: UnifiedBot[]; + pending: LH2Position[]; + isGroup: boolean; + selCount: number; + onGo: () => void; + onStopNav: () => void; + onClearQueue: () => void; + onRemovePending: (i: number) => void; + onToast: (msg: string) => void; +}> = ({ targets, pending, isGroup, selCount, onGo, onStopNav, onClearQueue, onRemovePending, onToast }) => { + const [ledOpen, setLedOpen] = useState(false); + const [wpOpen, setWpOpen] = useState(false); + const drivable = targets.filter((b) => b.drivable); + const enabled = drivable.length > 0; + const anyAuto = drivable.some((b) => b.nav === "auto"); + // The controller stores [own-start, ...targets]; count the targets. + const activeCount = Math.max( + ...drivable.map((b) => (b.waypoints.length > 1 ? b.waypoints.length - 1 : b.waypoints.length)), + 0, + ); + const wpCount = pending.length > 0 ? pending.length : anyAuto ? activeCount : 0; + const single = !isGroup ? targets[0] : undefined; + + const hint = !enabled + ? isGroup + ? "⚠ Not drivable - no DBP in selection" + : `⚠ Not drivable - ${notDrivableReason(single)}` + : anyAuto + ? `▶ Navigating · ${activeCount} waypoint${activeCount === 1 ? "" : "s"} left` + : `${isGroup ? `${drivable.length} of ${selCount} drivable · ` : "◉ "}Drag pad to drive · ⌥ Alt-click map to add waypoints${pending.length ? ` · ${pending.length} queued` : ""}`; + + const popBase: React.CSSProperties = { + position: "absolute", + bottom: 72, + left: 0, + width: 212, + background: "var(--surface)", + border: "1px solid var(--hairline)", + borderRadius: 10, + padding: 12, + boxShadow: "0 10px 30px rgba(0,0,0,.45)", + zIndex: 30, + }; + + return ( +
+
Control
+
+
+ +
+
+ {/* LED button */} +
enabled && setLedOpen((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 7, + padding: "8px 12px", + borderRadius: 8, + background: "var(--elevated)", + border: "1px solid var(--hairline)", + cursor: "pointer", + fontSize: 12, + ...(enabled ? {} : gateOff), + }} + > +
+ LED{isGroup ? " all" : ""} +
+ {/* waypoint group: [Waypoints · N][Go / Stop nav][Clear] */} +
+
setWpOpen((v) => !v)} + style={{ + padding: "6px 11px", + cursor: "pointer", + fontSize: 12, + display: "flex", + alignItems: "center", + gap: 6, + whiteSpace: "nowrap", + background: wpOpen ? "rgba(228,3,46,.14)" : "transparent", + }} + > + ◎ Waypoints{wpCount ? ` · ${wpCount}` : ""} +
+ {(pending.length > 0 || anyAuto) && ( +
(anyAuto ? onStopNav() : onGo())} + style={{ + display: "flex", + alignItems: "center", + padding: "6px 11px", + cursor: "pointer", + fontSize: 12, + fontWeight: 600, + whiteSpace: "nowrap", + borderLeft: "1px solid var(--hairline)", + color: anyAuto ? "var(--text)" : "var(--accent)", + }} + > + {anyAuto ? "■ Stop nav" : "▶ Go"} +
+ )} + {pending.length > 0 && ( +
+ Clear +
+ )} +
+
+
+
{hint}
+ + {/* click-away overlay */} + {(ledOpen || wpOpen) && ( +
{ + setLedOpen(false); + setWpOpen(false); + }} + /> + )} + + {/* LED popover */} + {ledOpen && ( +
+
LED color{isGroup ? ` · ${drivable.length} bots` : ""}
+
+ {SWATCHES.map(([r, g, b], i) => ( +
{ + drivable.forEach((bot) => + putRgbLed(bot.id, bot.application, { red: r, green: g, blue: b }).catch(() => {}), + ); + onToast(`LED set on ${drivable.length} bot${drivable.length > 1 ? "s" : ""}`); + setLedOpen(false); + }} + style={{ + width: "100%", + aspectRatio: 1, + borderRadius: 6, + cursor: "pointer", + background: `rgb(${r},${g},${b})`, + boxShadow: "0 0 0 1px rgba(255,255,255,.08)", + }} + /> + ))} +
+
+ )} + + {/* waypoint queue popover */} + {wpOpen && ( +
+
+ Waypoint queue + {pending.length > 0 && ( + + Clear all + + )} +
+ {pending.length > 0 ? ( +
+ {pending.map((w, i) => ( +
+ {i + 1} + + {Math.round(w.x)}, {Math.round(w.y)} mm + + onRemovePending(i)} + style={{ marginLeft: "auto", color: "var(--muted)", cursor: "pointer", fontSize: 14, lineHeight: 1, padding: "0 3px" }} + > + × + +
+ ))} +
+ ) : ( +
+ ⌥ Alt-click the map to add waypoints for {isGroup ? "the selection" : "this bot"}. +
+ )} +
+ )} +
+ ); +}; + +const Sep: React.FC = () =>
; + +export const Footer: React.FC = (props) => { + const selected = props.bots.filter((b) => props.selection.has(b.id)); + const one = selected.length === 1 ? selected[0] : undefined; + + return ( +
+ + +
+ {/* NONE: fleet rollup (all states, zero-count rows dimmed, per v1) */} + {selected.length === 0 && ( +
+
+
+ {props.bots.length} + / 1000 +
+
DotBots online
+
+ +
+ {STATE_ORDER.map((s) => { + const ids = props.bots.filter((b) => b.state === s).map((b) => b.id); + return ( +
ids.length && props.onSelectState(ids)} + title={ids.length ? "Select these bots" : undefined} + style={{ + display: "flex", + alignItems: "center", + gap: 9, + opacity: ids.length ? 1 : 0.4, + cursor: ids.length ? "pointer" : "default", + }} + > + + {ids.length} + {s} +
+ ); + })} +
+
+ )} + + {/* ONE: selected bot */} + {one && ( +
+
+
+
+ {short(one.id)} +
+ {one.id.toUpperCase()} + {one.deviceType} + {"—"} + + ◎ {one.drivable ? "Drivable" : "Not drivable"} + +
+ +
+
Status
+
+ + {stateLabel(one.state)} + {one.link !== "active" && ( + + {LINK_LABEL[one.link].toUpperCase()} + + )} +
+ {props.flashQueue[one.id] && !props.flashQueue[one.id].done && ( +
+
+ {props.flashQueue[one.id].acked} / {props.flashQueue[one.id].total} chunks +
+
+
+
+
+ )} +
+ + + {one.position ? `${Math.round(one.position.x)}, ${Math.round(one.position.y)} mm` : "— unknown"} + +
+
+ + {/* what the bot reports it is running, straight from swarmit /status */} +
+
Image
+
+ {one.image ?? "— unknown"} +
+
Last reset
+
+ {one.resetCause ?? "— unknown"} +
+
+ + +
+
+ )} + + {/* MULTI */} + {selected.length > 1 && ( +
+
+
+ ☰ +
+
+ {selected.length} + selected + props.onSelectState([])} style={{ fontSize: 11, color: "var(--accent)", cursor: "pointer" }}> + Clear selection + +
+
+ +
+
Status
+
+ {STATE_ORDER.map((s) => { + const n = selected.filter((b) => b.state === s).length; + return n > 0 ? ( +
+ + {n} + {s} +
+ ) : null; + })} +
+
+ + +
+
+ )} +
+
+ ); +}; diff --git a/dotbot/console-web/src/GridView.tsx b/dotbot/console-web/src/GridView.tsx new file mode 100644 index 00000000..42440aea --- /dev/null +++ b/dotbot/console-web/src/GridView.tsx @@ -0,0 +1,121 @@ +import React from "react"; + +import { UnifiedBot } from "./types"; +import { BatteryCell, FilterBar, LedDot, Pagination, ResetBadge, stateColor, useQueriedBots, useViewQuery } from "./viewChrome"; + +interface GridViewProps { + bots: UnifiedBot[]; + selection: Set; + onSelect: (ids: string[], mode: "replace" | "toggle" | "add") => void; +} + +export const GridView: React.FC = ({ bots, selection, onSelect }) => { + const { q, setQ } = useViewQuery(); + const { rows, total, pages } = useQueriedBots(bots, q); + // File-manager selection: click = single, shift+click = range from the + // anchor in the current card order, cmd/ctrl = toggle. + const anchorRef = React.useRef(null); + const cardClick = (e: React.MouseEvent, id: string) => { + if (e.shiftKey && anchorRef.current) { + const ids = rows.map((r) => r.id); + const a = ids.indexOf(anchorRef.current); + const b = ids.indexOf(id); + if (a >= 0 && b >= 0) { + onSelect(ids.slice(Math.min(a, b), Math.max(a, b) + 1), "add"); + return; + } + } + if (e.metaKey || e.ctrlKey) { + onSelect([id], "toggle"); + anchorRef.current = id; + return; + } + // Plain click on the sole selected item deselects it. + if (selection.has(id) && selection.size === 1) { + onSelect([], "replace"); + anchorRef.current = null; + return; + } + onSelect([id], "replace"); + anchorRef.current = id; + }; + + return ( +
onSelect([], "replace")} + > +
e.stopPropagation()}> + +
+
+
+ {rows.map((b) => { + const checked = selection.has(b.id); + return ( +
{ + e.stopPropagation(); + cardClick(e, b.id); + }} + style={{ + display: "flex", + flexDirection: "column", + gap: 9, + padding: "13px 14px", + borderRadius: 10, + background: "var(--surface)", + border: checked ? "1px solid var(--accent)" : "1px solid var(--hairline)", + boxShadow: checked ? "0 0 0 1px var(--accent)" : "none", + cursor: "pointer", + }} + > +
+ + + {b.id.slice(-4)} +
+ + {b.state} +
+
{b.id}
+ +
+ Device {b.deviceType} +
+
+ ); + })} +
+
+
e.stopPropagation()}> + +
+
+ ); +}; diff --git a/dotbot/console-web/src/Inspector.tsx b/dotbot/console-web/src/Inspector.tsx new file mode 100644 index 00000000..17a2f2e8 --- /dev/null +++ b/dotbot/console-web/src/Inspector.tsx @@ -0,0 +1,284 @@ +import React, { useState } from "react"; + +import { stateLabel } from "./viewChrome"; + +import { LINK_LABEL, UnifiedBot } from "./types"; + +// Right-side inspector: the low-level layer next to the map's high-level one. +// Renders what `dotbot swarm info` prints, from the same /status payload, and +// stacks one card per selected bot the way that command prints one panel per +// device. Everything is plain text in normal flow, so it selects and copies. +// +// swarmit serves the display strings it computes (reset_cause, fault_name, +// image_*_name, lh2_summary), so this renders them rather than keeping a +// second copy of the vocabulary in TypeScript. +export function formatUptime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + const pad = (n: number) => String(n).padStart(2, "0"); + if (h) return `${h}h ${pad(m)}m ${pad(s)}s`; + if (m) return `${m}m ${pad(s)}s`; + return `${s}s`; +} + +// swarmit distinguishes a device that never answered from one reporting zero +// homographies: one is re-provisioned, the other is a fetch that has not +// landed. The server words both; absence here means the former. +export function formatLh2(bot: UnifiedBot): string { + return bot.swarmit?.info?.lh2_summary ?? "unknown (no device info)"; +} + +const hex32 = (v: number) => `0x${(v >>> 0).toString(16).padStart(8, "0")}`; + +// FaultType values that actually populate the fault status registers. A +// watchdog timeout raises no fault, so cfsr/sfsr read zero and showing them +// invites decoding a status that was never written. pc/lr are still the whole +// answer there: they name where the app was stuck. Mirrors the CLI's rule. +const FAULT_HARD = 1; +const FAULT_SECURE = 2; +const setsFaultRegisters = (fault: number) => + fault === FAULT_HARD || fault === FAULT_SECURE; + +// The plain-text form, so one click hands over exactly what a bug report wants. +export function infoText(bot: UnifiedBot): string { + const sw = bot.swarmit; + const info = sw?.info; + const out: string[] = [bot.id]; + out.push(`Type ${bot.deviceType}`); + out.push(`Sandbox ${stateLabel(bot.state)}`); + out.push(`Control plane ${LINK_LABEL[bot.link]}`); + out.push(`Battery ${bot.battery.toFixed(2)}V`); + out.push( + `Position ${bot.position ? `${Math.round(bot.position.x)}, ${Math.round(bot.position.y)}` : "no fix"}`, + ); + if (info) { + out.push(""); + out.push(`Image ${info.image_name || "(unnamed)"}`); + out.push(` digest ${info.image_digest}`); + out.push(` size ${info.image_size ?? 0} B`); + out.push( + ` state ${info.image_state_name ?? info.image_state} / ${info.image_result_name ?? info.image_result}`, + ); + out.push(""); + out.push(`Sandbox fw bootloader ${info.bl_version}`); + out.push(` netcore ${info.net_version}`); + out.push(`Uptime ${formatUptime(info.uptime_s)} (boot #${info.boot_count})`); + } + out.push(""); + out.push(`LH2 calibration ${formatLh2(bot)}`); + if (sw?.reset_reason !== undefined) { + out.push(""); + out.push(`Last reset ${bot.resetCause}`); + out.push(` reset_reason ${hex32(sw.reset_reason)}`); + out.push(` fault ${sw.fault_name ?? sw.fault}`); + if (sw.fault) { + if (setsFaultRegisters(sw.fault)) { + out.push(` cfsr ${hex32(sw.cfsr ?? 0)}`); + out.push(` sfsr ${hex32(sw.sfsr ?? 0)}`); + } + out.push(` pc ${hex32(sw.pc ?? 0)}`); + out.push(` lr ${hex32(sw.lr ?? 0)}`); + } + } + return out.join("\n"); +} + +const label: React.CSSProperties = { + fontSize: 9, + letterSpacing: ".5px", + textTransform: "uppercase", + color: "var(--muted)", +}; +const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 11 }; + +const Row: React.FC<{ k: string; v: string; indent?: boolean; accent?: boolean }> = ({ + k, + v, + indent, + accent, +}) => ( +
+
{k}
+
+ {v} +
+
+); + +const Card: React.FC<{ bot: UnifiedBot }> = ({ bot }) => { + const [showRaw, setShowRaw] = useState(false); + const [copied, setCopied] = useState(false); + const sw = bot.swarmit; + const info = sw?.info; + + const copy = (text: string) => { + navigator.clipboard?.writeText(text).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }, + () => undefined, + ); + }; + + return ( +
+
+
+ {bot.id} +
+
copy(infoText(bot))} + title="Copy this panel as text" + style={{ + ...mono, + fontSize: 10, + cursor: "pointer", + padding: "2px 7px", + borderRadius: 6, + border: "1px solid var(--hairline)", + color: copied ? "var(--accent)" : "var(--muted)", + flex: "none", + }} + > + {copied ? "copied" : "copy"} +
+
+ + + + + + + + {info && ( + <> +
+ + + + +
+ + + + + )} + +
+ + + {sw?.reset_reason !== undefined && ( + <> +
+ + + + {Boolean(sw.fault) && ( + <> + {setsFaultRegisters(sw.fault ?? 0) && ( + <> + + + + )} + + + + )} + + )} + + {(sw?.raw || info?.raw) && ( + <> +
+
setShowRaw((v) => !v)} + style={{ ...mono, fontSize: 10, color: "var(--muted)", cursor: "pointer" }} + > + {showRaw ? "▾" : "▸"} wire bytes +
+ {showRaw && ( +
+ {sw?.raw && } + {info?.raw && } +
copy(`status ${sw?.raw ?? ""}\ninfo ${info?.raw ?? ""}`)} + style={{ + ...mono, + fontSize: 10, + marginTop: 6, + cursor: "pointer", + color: "var(--muted)", + textDecoration: "underline", + }} + > + copy wire bytes +
+
+ )} + + )} +
+ ); +}; + +export const Inspector: React.FC<{ + bots: UnifiedBot[]; + onClose: () => void; +}> = ({ bots, onClose }) => ( +
+
+
+ Inspector{bots.length > 1 ? ` · ${bots.length} bots` : ""} +
+
+ × +
+
+
+ {bots.length === 0 ? ( +
+ Select a bot to inspect it. +
+ ) : ( + bots.map((b) => ) + )} +
+
+); diff --git a/dotbot/console-web/src/Joystick.tsx b/dotbot/console-web/src/Joystick.tsx new file mode 100644 index 00000000..91a401d7 --- /dev/null +++ b/dotbot/console-web/src/Joystick.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useRef, useState } from "react"; + +import { putMoveRaw } from "./api"; +import { UnifiedBot } from "./types"; + +// v1 drive pad: 64px rounded square, crosshair guides, LED-colored knob with +// the bot's live heading pointer (single) or an accent knob with a xN count +// (group). +// +// Control model: BODY-relative, open loop - the mapping the classic frontend +// uses on real robots. Up/down is throttle along the bot's own heading, +// left/right is a differential that yaws it. Deliberately not a closed loop on +// reported heading: heading is derived from successive position fixes, so it +// is null on any bot without a position source (an uncalibrated arena, LH2 out +// of view), and a loop that cannot see heading cannot steer at all. +// +// Signs follow the robot, not the map: dragging right speeds up the LEFT wheel, +// which yaws the bot right (clockwise, so its CCW-positive `direction` +// decreases). +// +// The knob travels R px for looks, but the command is scaled over CONTROL_R px +// of pointer movement, which the pointer capture lets run outside the pad. The +// two are separate on purpose: a 20px control throw gives a handful of usable +// speed steps and is unusable, so the throw matches the classic pad's 100px +// while the knob stays inside a 64px control. +// +// SPEED_OFFSET jumps the first non-zero step over the motors' stall band; the +// firmware maps left_y/right_y linearly onto +/-100% with no deadband of its +// own (apps-sandbox/dotbot/main.c). +const SPEED_OFFSET = 30; +const PAD = 64; +const R = 20; // knob travel radius, as in v1 +const CONTROL_R = 100; // pointer travel for full command, as in the classic pad +const FULL_SCALE = 64; // command at full deflection, before SPEED_OFFSET +const DEADZONE = 3; // px of slop, so a click without a drag does not creep + +const clampPwm = (v: number) => Math.max(-128, Math.min(127, Math.trunc(v))); + +export function mixDrive(dx: number, dy: number): { left: number; right: number } { + if (Math.hypot(dx, dy) < DEADZONE) return { left: 0, right: 0 }; + const clamp1 = (v: number) => Math.max(-1, Math.min(1, v)); + const throttle = -clamp1(dy / CONTROL_R) * FULL_SCALE; // screen y grows down + const yaw = clamp1(dx / CONTROL_R) * FULL_SCALE; + let left = throttle + yaw; + let right = throttle - yaw; + if (left > 0) left += SPEED_OFFSET; + if (left < 0) left -= SPEED_OFFSET; + if (right > 0) right += SPEED_OFFSET; + if (right < 0) right -= SPEED_OFFSET; + return { left: clampPwm(left), right: clampPwm(right) }; +} + +interface PadProps { + targets: UnifiedBot[]; // drivable bots to drive together + disabled: boolean; // parent applies the gate style; this blocks input +} + +export const Pad: React.FC = ({ targets, disabled }) => { + const [knob, setKnob] = useState({ x: 0, y: 0 }); + const [active, setActive] = useState(false); + const knobRef = useRef(knob); + knobRef.current = knob; + const targetsRef = useRef(targets); + targetsRef.current = targets; + + // The knob shows the same direction as the command, scaled to fit the pad. + const knobPx = { x: (knob.x / CONTROL_R) * R, y: (knob.y / CONTROL_R) * R }; + + const single = targets.length === 1 ? targets[0] : null; + const led = single + ? single.led + ? `rgb(${single.led.red},${single.led.green},${single.led.blue})` + : "var(--s-Inactive)" + : null; + + useEffect(() => { + if (!active) return; + const t = setInterval(() => { + const { left, right } = mixDrive(knobRef.current.x, knobRef.current.y); + targetsRef.current.forEach((b) => { + putMoveRaw(b.id, b.application, left, right).catch(() => {}); + }); + }, 100); + return () => clearInterval(t); + }, [active]); + + const stop = () => { + if (!active) return; + setActive(false); + setKnob({ x: 0, y: 0 }); + targetsRef.current.forEach((b) => { + putMoveRaw(b.id, b.application, 0, 0).catch(() => {}); + }); + }; + + const move = (e: React.PointerEvent) => { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + let dx = e.clientX - (r.left + r.width / 2); + let dy = e.clientY - (r.top + r.height / 2); + const len = Math.hypot(dx, dy); + if (len > CONTROL_R) { + dx = (dx / len) * CONTROL_R; + dy = (dy / len) * CONTROL_R; + } + setKnob({ x: dx, y: dy }); + }; + + return ( +
{ + if (disabled || targets.length === 0) return; + setActive(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + move(e); + }} + onPointerMove={(e) => active && move(e)} + onPointerUp={stop} + onPointerCancel={stop} + title="Drag pad to drive" + style={{ + width: PAD, + height: PAD, + flex: "none", + borderRadius: 12, + background: "var(--elevated)", + border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`, + position: "relative", + touchAction: "none", + // Drag surface: a pan or marquee would otherwise smear a text + // selection across the UI and race the browser's native drag. + userSelect: "none", + cursor: "grab", + }} + > + {/* crosshair guides */} +
+
+ {single ? ( +
+ {single.heading !== null && ( +
+ )} +
+ ) : ( +
+ ×{targets.length} +
+ )} +
+ ); +}; diff --git a/dotbot/console-web/src/ListView.tsx b/dotbot/console-web/src/ListView.tsx new file mode 100644 index 00000000..1f35f58f --- /dev/null +++ b/dotbot/console-web/src/ListView.tsx @@ -0,0 +1,223 @@ +import React from "react"; + +import { UnifiedBot } from "./types"; +import { BatteryCell, FilterBar, LedDot, Pagination, ResetBadge, SortKey, stateColor, useQueriedBots, useViewQuery } from "./viewChrome"; + +interface ListViewProps { + bots: UnifiedBot[]; + selection: Set; + onSelect: (ids: string[], mode: "replace" | "toggle" | "add") => void; +} + +export const ListView: React.FC = ({ bots, selection, onSelect }) => { + const { q, setQ } = useViewQuery(); + const { rows, total, pages } = useQueriedBots(bots, q); + // File-manager selection: click = single, shift+click = range from the + // anchor (last plain/cmd click) in the current row order, cmd/ctrl = toggle. + const anchorRef = React.useRef(null); + const rowClick = (e: React.MouseEvent, id: string) => { + if (e.shiftKey && anchorRef.current) { + const ids = rows.map((r) => r.id); + const a = ids.indexOf(anchorRef.current); + const b = ids.indexOf(id); + if (a >= 0 && b >= 0) { + onSelect(ids.slice(Math.min(a, b), Math.max(a, b) + 1), "add"); + return; + } + } + if (e.metaKey || e.ctrlKey) { + onSelect([id], "toggle"); + anchorRef.current = id; + return; + } + // Plain click on the sole selected item deselects it. + if (selection.has(id) && selection.size === 1) { + onSelect([], "replace"); + anchorRef.current = null; + return; + } + onSelect([id], "replace"); + anchorRef.current = id; + }; + + const sortBy = (key: SortKey) => + setQ((p) => ({ + ...p, + sortKey: key, + sortDir: p.sortKey === key ? ((p.sortDir * -1) as 1 | -1) : 1, + })); + const arrow = (key: SortKey) => (q.sortKey === key ? (q.sortDir === 1 ? " ↑" : " ↓") : ""); + + const allVisibleSelected = rows.length > 0 && rows.every((b) => selection.has(b.id)); + const toggleAll = () => { + if (allVisibleSelected) onSelect(rows.map((b) => b.id), "toggle"); // all off + else onSelect(rows.filter((b) => !selection.has(b.id)).map((b) => b.id), "add"); + }; + + const th: React.CSSProperties = { + padding: "11px 12px", + position: "sticky", + top: 0, + background: "var(--surface)", + borderBottom: "1px solid var(--hairline)", + fontSize: 10, + letterSpacing: ".5px", + textTransform: "uppercase", + color: "var(--muted)", + textAlign: "left", + cursor: "pointer", + userSelect: "none", + }; + const checkBox = (checked: boolean): React.CSSProperties => ({ + width: 15, + height: 15, + borderRadius: 4, + border: "1px solid var(--hairline)", + background: checked ? "var(--accent)" : "transparent", + color: "#fff", + fontSize: 10, + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + }); + + return ( +
onSelect([], "replace")} + > +
e.stopPropagation()}> + +
+
+ + + + + + + + + + + + + {rows.map((b) => { + const checked = selection.has(b.id); + return ( + { + e.stopPropagation(); + rowClick(e, b.id); + }} + style={{ + cursor: "pointer", + background: checked ? "rgba(228,3,46,.07)" : "transparent", + borderLeft: checked ? "2px solid var(--accent)" : "2px solid transparent", + }} + > + + + + + + + + ); + })} + +
+
{ + e.stopPropagation(); + toggleAll(); + }} + style={checkBox(allVisibleSelected)} + > + {allVisibleSelected ? "✓" : ""} +
+
{ e.stopPropagation(); sortBy("id"); }}> + ID{arrow("id")} + { e.stopPropagation(); sortBy("fw"); }}> + Device{arrow("fw")} + { e.stopPropagation(); sortBy("image"); }}> + Image{arrow("image")} + { e.stopPropagation(); sortBy("battery"); }}> + Battery{arrow("battery")} + { e.stopPropagation(); sortBy("state"); }}> + State{arrow("state")} +
+
{ + e.stopPropagation(); + onSelect([b.id], "toggle"); + anchorRef.current = b.id; + }} + style={checkBox(checked)} + > + {checked ? "✓" : ""} +
+
+
+ + + {b.id} +
+
+ {b.deviceType} + + {b.image ?? "—"} + + + +
+ + {b.state} +
+
+
+
e.stopPropagation()}> + +
+
+ ); +}; diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx new file mode 100644 index 00000000..c4aed299 --- /dev/null +++ b/dotbot/console-web/src/MapView.tsx @@ -0,0 +1,584 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; + +import { ResetBadge, batteryColor, batteryPct, stateColor } from "./viewChrome"; + +import { LH2Position, MapSize, UnifiedBot } from "./types"; +import { useSmoothPositions } from "./useSmoothPositions"; + +// Layer set mirrors the v1 design (Battery Bars / Waypoints / HotSpots / +// DotBots / Real-scale bots); Trails is our addition on top. +export interface Layers { + batteryBars: boolean; + waypoints: boolean; + hotSpots: boolean; + dotBots: boolean; + trueScale: boolean; + crashedOnly: boolean; + trails: boolean; +} + +export interface Camera { + scale: number; + tx: number; + ty: number; +} + +export interface ViewGeom { + w: number; + h: number; + side: number; +} + +// v1 clampPan: keep the arena reachable, never fling it off-screen. +export function clampCam(cam: Camera, geom: ViewGeom): Camera { + const sw = geom.side * cam.scale; + const padX = Math.max(0, (geom.w - geom.side) / 2); + const padY = Math.max(0, (geom.h - geom.side) / 2); + const mx = Math.max(0, (sw - geom.w) / 2) + padX; + const my = Math.max(0, (sw - geom.h) / 2) + padY; + return { + ...cam, + tx: Math.max(-mx, Math.min(mx, cam.tx)), + ty: Math.max(-my, Math.min(my, cam.ty)), + }; +} + +interface MapViewProps { + bots: UnifiedBot[]; + mapSize: MapSize; + selection: Set; + layers: Layers; + plannedMissions: { waypoints: LH2Position[]; led: string | null }[]; // local queues, not yet sent + cam: Camera; + setCam: React.Dispatch>; + onGeom: (g: ViewGeom) => void; + onSelect: (ids: string[], mode: "replace" | "toggle" | "add") => void; + onAddWaypoint: (p: LH2Position) => void; +} + +const BOT_R = 11; // px radius of the bot circle at zoom 1 (22px glyph, as in v1) +const REAL_BOT_MM = 80; // approximate DotBot footprint for the Real-scale layer + +const ledCss = (b: UnifiedBot) => + b.led ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` : "var(--s-Inactive)"; + + + +export const MapView: React.FC = (props) => { + const wrapRef = useRef(null); + const { cam, setCam } = props; + const [marquee, setMarquee] = useState<{ x0: number; y0: number; x1: number; y1: number } | null>(null); + const [hoverId, setHoverId] = useState(null); + const panRef = useRef<{ x0: number; y0: number; tx0: number; ty0: number; moved: boolean } | null>(null); + const marqueeRef = useRef<{ additive: boolean } | null>(null); + const geomRef = useRef({ w: 1000, h: 600, side: 600 }); + + const mapDiagonal = Math.hypot(props.mapSize.width, props.mapSize.height); + const smoothPositions = useSmoothPositions(props.bots, mapDiagonal); + + const [side, setSide] = useState(600); + const onGeomRef = useRef(props.onGeom); + onGeomRef.current = props.onGeom; + // Track the canvas size live (rail open/close, window resize): the arena + // keeps its margins instead of overflowing when the canvas shrinks. + const measure = useCallback((el: HTMLDivElement | null) => { + (wrapRef as React.MutableRefObject).current = el; + }, []); + React.useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + const s = Math.max(200, Math.min(r.width, r.height) - 48); + setSide(s); + geomRef.current = { w: r.width, h: r.height, side: s }; + onGeomRef.current(geomRef.current); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // v1 coordinate convention: y grows NORTH (up); screen top = arena max-y. + const pctPos = (p: LH2Position) => ({ + left: (p.x / props.mapSize.width) * 100, + top: (1 - p.y / props.mapSize.height) * 100, + }); + + const pxToMm = (clientX: number, clientY: number): LH2Position | null => { + const el = wrapRef.current; + if (!el) return null; + const r = el.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const ux = (clientX - cx - cam.tx) / cam.scale + r.width / 2; + const uy = (clientY - cy - cam.ty) / cam.scale + r.height / 2; + const ax = ux - (r.width - side) / 2; + const ay = uy - (r.height - side) / 2; + const x = (ax / side) * props.mapSize.width; + const y = (1 - ay / side) * props.mapSize.height; + if (x < 0 || y < 0 || x > props.mapSize.width || y > props.mapSize.height) return null; + return { x: Math.round(x), y: Math.round(y) }; + }; + + // v1 canvas semantics: alt-click = waypoint, shift-drag = marquee, + // plain drag = pan, plain click (no movement) = clear selection. + const onCanvasDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + if (e.altKey) { + const p = pxToMm(e.clientX, e.clientY); + if (p) props.onAddWaypoint(p); + return; + } + if (e.shiftKey) { + marqueeRef.current = { additive: true }; + setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY }); + return; + } + panRef.current = { x0: e.clientX, y0: e.clientY, tx0: cam.tx, ty0: cam.ty, moved: false }; + }; + + const onCanvasMove = (e: React.PointerEvent) => { + if (panRef.current) { + const p = panRef.current; + const dx = e.clientX - p.x0; + const dy = e.clientY - p.y0; + if (Math.abs(dx) + Math.abs(dy) > 3) p.moved = true; + setCam((c) => clampCam({ ...c, tx: p.tx0 + dx, ty: p.ty0 + dy }, geomRef.current)); + return; + } + if (marqueeRef.current) setMarquee((m) => (m ? { ...m, x1: e.clientX, y1: e.clientY } : m)); + }; + + const onCanvasUp = () => { + if (panRef.current) { + const moved = panRef.current.moved; + panRef.current = null; + if (!moved) props.onSelect([], "replace"); // plain click on empty canvas clears + return; + } + if (!marqueeRef.current || !marquee) { + marqueeRef.current = null; + setMarquee(null); + return; + } + const x0 = Math.min(marquee.x0, marquee.x1); + const x1 = Math.max(marquee.x0, marquee.x1); + const y0 = Math.min(marquee.y0, marquee.y1); + const y1 = Math.max(marquee.y0, marquee.y1); + if (x1 - x0 >= 5 || y1 - y0 >= 5) { + const hits = props.bots + .filter((b) => { + if (!b.position) return false; + const el = document.getElementById(`bot-${b.id}`); + if (!el) return false; + const r = el.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1; + }) + .map((b) => b.id); + props.onSelect(hits, "add"); + } + marqueeRef.current = null; + setMarquee(null); + }; + + const gridStep = side / 10; + const gridBg = useMemo( + () => + `repeating-linear-gradient(0deg, var(--grid) 0 1px, transparent 1px ${gridStep}px),` + + `repeating-linear-gradient(90deg, var(--grid) 0 1px, transparent 1px ${gridStep}px)`, + [gridStep], + ); + + // Real-scale layer: glyphs scale to the actual DotBot footprint. + const gscale = props.layers.trueScale + ? Math.max(0.2, (side * (REAL_BOT_MM / props.mapSize.width)) / (BOT_R * 2)) + : 1; + + return ( +
+ {/* camera layer */} +
+ {/* arena */} +
+ {/* subtle center accents (v1) */} +
+
+ + {/* trails (our extra layer) */} + {props.layers.trails && ( + + {props.bots + .filter((b) => b.trail.length > 1) + .map((b) => ( + `${(p.x / props.mapSize.width) * side},${(1 - p.y / props.mapSize.height) * side}`) + .join(" ")} + fill="none" + stroke={ledCss(b)} + strokeWidth={1} + opacity={0.35} + /> + ))} + + )} + + {/* waypoints: per-bot LED-colored diamonds (v1: no connector lines) */} + {props.layers.waypoints && + props.bots.flatMap((b) => { + if (b.waypoints.length === 0) return []; + const isSel = props.selection.has(b.id); + const led = ledCss(b); + const s = (isSel ? 10 : 8) * gscale; + return b.waypoints.map((w, i) => { + const q = pctPos(w); + return ( +
+ ); + }); + })} + + {/* planned (queued, not sent) waypoints */} + {props.layers.waypoints && + props.plannedMissions.flatMap((m, mi) => + m.waypoints.map((p, i) => { + const q = pctPos(p); + const led = m.led ?? "var(--accent)"; + return ( +
+ ); + }), + )} + + {/* bots (v1 glyph: state-colored body, LED pip, drive dot, chip label) */} + {props.layers.dotBots && + props.bots + .filter((b) => b.position) + .map((b) => { + const q = pctPos(smoothPositions.get(b.id) ?? b.position!); + const selected = props.selection.has(b.id); + const hovered = hoverId === b.id; + const led = ledCss(b); + const stc = stateColor(b.state); + const pct = batteryPct(b); + const blink = b.state === "Programming" || b.state === "Resetting"; + return ( +
{ + e.stopPropagation(); + if (e.altKey) { + const p = pxToMm(e.clientX, e.clientY); + if (p) props.onAddWaypoint(p); + return; + } + if (e.shiftKey || e.metaKey || e.ctrlKey) { + props.onSelect([b.id], "toggle"); + } else if (props.selection.has(b.id) && props.selection.size === 1) { + props.onSelect([], "replace"); // click the sole selected bot again = deselect + } else { + props.onSelect([b.id], "replace"); + } + }} + onPointerEnter={() => setHoverId(b.id)} + onPointerLeave={() => setHoverId((h) => (h === b.id ? null : h))} + style={{ + position: "absolute", + left: `${q.left}%`, + top: `${q.top}%`, + transform: `translate(-50%, -50%) scale(${gscale})`, + cursor: "pointer", + zIndex: selected ? 6 : 2, + width: 0, + height: 0, + }} + > + {/* last-reset warning, centred over the glyph body */} +
+ +
+ {/* selection rectangle */} + {selected && ( +
+ )} + {/* battery bar */} + {props.layers.batteryBars && ( +
+
+
+ )} + {/* body: state-colored circle */} +
+ {/* heading pointer (state-colored, at the edge). The + controller reports direction as 0 = north (+y), + positive COUNTERclockwise; CSS rotates clockwise in + screen coords, so the angle is negated. */} + {b.heading !== null && ( +
+
+
+ )} + {/* drive dot: white ring at center = drivable; its FILL is + the LED color (experiment: merges the v1 LED pip into the + drive indicator - see design-feedback) */} + {b.drivable && ( +
+ )} + {/* chip label: selected or hovered only */} + {(selected || hovered) && ( +
+ {b.id.slice(-4).toUpperCase()} +
+ )} +
+ ); + })} +
+
+ + {/* marquee */} + {marquee && ( +
+ )} + + {/* zoom controls */} +
e.stopPropagation()} + > + {[ + { label: "+", fn: () => setCam((c) => clampCam({ ...c, scale: Math.min(4, c.scale * 1.25) }, geomRef.current)) }, + { label: "−", fn: () => setCam((c) => clampCam({ ...c, scale: Math.max(0.5, c.scale / 1.25) }, geomRef.current)) }, + { label: "◎", fn: () => setCam({ scale: 1, tx: 0, ty: 0 }) }, + ].map((z, i) => ( +
+ {z.label} +
+ ))} +
+ + {/* hint */} +
+ drag = pan · shift-drag = select · ⌥ alt-click = waypoint +
+
+ ); +}; diff --git a/dotbot/console-web/src/Minimap.tsx b/dotbot/console-web/src/Minimap.tsx new file mode 100644 index 00000000..fb0a3829 --- /dev/null +++ b/dotbot/console-web/src/Minimap.tsx @@ -0,0 +1,130 @@ +import React, { useRef } from "react"; + +import { stateColor } from "./viewChrome"; + +import { Camera, clampCam, ViewGeom } from "./MapView"; +import { MapSize, UnifiedBot } from "./types"; + +interface MinimapProps { + bots: UnifiedBot[]; + mapSize: MapSize; + cam: Camera; + setCam: React.Dispatch>; + geom: ViewGeom | null; +} + +// Whole-arena overview with the current map viewport as a rectangle. +// Dragging moves the camera (the design pans through here). The arena box +// keeps the real arena aspect ratio - a 2000x2000 arena is a square. +export const Minimap: React.FC = ({ bots, mapSize, cam, setCam, geom }) => { + const boxRef = useRef(null); + const dragging = useRef(false); + + const viewportRect = () => { + if (!geom) return null; + const { w, h, side } = geom; + const span = (extent: number, t: number) => { + const min = 0.5 - (extent / 2 + t) / (side * cam.scale); + const max = 0.5 + (extent / 2 - t) / (side * cam.scale); + return [Math.max(0, min), Math.min(1, max)]; + }; + const [x0, x1] = span(w, cam.tx); + const [y0, y1] = span(h, cam.ty); + return { x0, x1, y0, y1 }; + }; + + const centerOn = (clientX: number, clientY: number) => { + const el = boxRef.current; + if (!el || !geom) return; + const r = el.getBoundingClientRect(); + const fx = Math.max(0, Math.min(1, (clientX - r.left) / r.width)); + const fy = Math.max(0, Math.min(1, (clientY - r.top) / r.height)); + setCam((c) => + clampCam( + { ...c, tx: -(fx - 0.5) * geom.side * c.scale, ty: -(fy - 0.5) * geom.side * c.scale }, + geom, + ), + ); + }; + + const rect = viewportRect(); + + return ( +
+
+ Arena · {mapSize.width}×{mapSize.height}mm +
+
+
{ + dragging.current = true; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + centerOn(e.clientX, e.clientY); + }} + onPointerMove={(e) => dragging.current && centerOn(e.clientX, e.clientY)} + onPointerUp={() => (dragging.current = false)} + style={{ + position: "relative", + aspectRatio: `${mapSize.width} / ${mapSize.height}`, + height: "100%", + maxWidth: "100%", + background: "var(--canvas)", + border: "1px solid var(--hairline)", + borderRadius: 5, + overflow: "hidden", + cursor: "grab", + touchAction: "none", + // Drag surface: a pan or marquee would otherwise smear a text + // selection across the UI and race the browser's native drag. + userSelect: "none", + }} + > + {bots + .filter((b) => b.position) + .map((b) => ( +
+ ))} + {rect && ( +
+ )} +
+
+
+ ); +}; diff --git a/dotbot/console-web/src/TestbedRail.tsx b/dotbot/console-web/src/TestbedRail.tsx new file mode 100644 index 00000000..65324931 --- /dev/null +++ b/dotbot/console-web/src/TestbedRail.tsx @@ -0,0 +1,554 @@ +import React, { useState } from "react"; + +import { PlannedMission, UnifiedBot } from "./types"; +import { FirmwareSection } from "./FirmwareSection"; +import { FirmwareFile } from "./firmwareFile"; +import { FlashJob, LogRow } from "./useOrchestration"; + +// Left testbed rail, per v1: collapsed 52px icon strip <-> 340px panel with a +// Testbed tab (orchestration controls - disabled until the swarmit write path +// lands; never mocked) and a Missions tab (waypoint missions derived from +// live state: Planned = the local queue, Active = bots navigating). + +export interface DoneMission { + key: string; + id: string; // short label + t: string; // time string +} + +interface Mission { + key: string; + ids: string[]; + label: string; + count: number; + n: number; // waypoints (max left among the group) + phase: "planned" | "active"; + dots: string[]; // led css colors, max 4 +} + +interface TestbedRailProps { + bots: UnifiedBot[]; + selection: Set; + planned: PlannedMission[]; + doneMissions: DoneMission[]; + logs: LogRow[]; + jobs: FlashJob[]; + fleetPct: number; + flashing: boolean; + clearLogs: () => void; + targetCount: number; + onFlash: (image: FirmwareFile) => void; + onStart: () => void; + onStop: () => void; + onSelectIds: (ids: string[]) => void; + onGoMission: (key: string) => void; + onDiscardMission: (key: string) => void; + onStopMission: (ids: string[]) => void; +} + +const ledCss = (b: UnifiedBot) => + b.led ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` : "var(--s-Inactive)"; +const short = (id: string) => id.slice(-4).toUpperCase(); +const label10 = { fontSize: 10, letterSpacing: ".5px", textTransform: "uppercase", color: "var(--muted)" } as const; + +// v1 actBtn, rail variant (full width), rendered disabled until orchestration. +const railBtn = (accent: boolean): React.CSSProperties => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 6, + padding: "7px 13px", + borderRadius: 7, + fontSize: 12, + fontWeight: 500, + whiteSpace: "nowrap", + border: `1px solid ${accent ? "var(--accent)" : "var(--hairline)"}`, + background: accent ? "var(--accent)" : "var(--elevated)", + color: accent ? "#fff" : "var(--text)", + width: "100%", + boxSizing: "border-box", + cursor: "pointer", +}); + +const tabStyle = (active: boolean): React.CSSProperties => ({ + padding: "5px 11px", + borderRadius: 5, + fontSize: 12, + fontWeight: 500, + cursor: "pointer", + background: active ? "var(--elevated)" : "transparent", + color: active ? "var(--text)" : "var(--muted)", +}); + +const topTabStyle = (active: boolean): React.CSSProperties => ({ + padding: "5px 12px", + borderRadius: 6, + fontSize: 12, + fontWeight: active ? 600 : 500, + cursor: "pointer", + background: active ? "var(--accent)" : "transparent", + color: active ? "#fff" : "var(--muted)", + display: "flex", + alignItems: "center", + gap: 6, +}); + +export function deriveMissions(bots: UnifiedBot[], planned: PlannedMission[]): Mission[] { + const missions: Mission[] = []; + const byId = new Map(bots.map((b) => [b.id, b])); + // Planned: local queues, bound to their bots at queue time. + planned.forEach((m) => { + const bs = m.ids.map((id) => byId.get(id)).filter(Boolean) as UnifiedBot[]; + if (!bs.length) return; + missions.push({ + key: m.key, + ids: m.ids, + label: bs.length === 1 ? short(bs[0].id) : `${bs.length} bots`, + count: bs.length, + n: m.waypoints.length, + phase: "planned", + dots: bs.slice(0, 4).map(ledCss), + }); + }); + // Active: navigating bots, grouped by identical mission targets. The + // controller prepends each bot's own start position to the list it stores, + // so the shared mission is the TAIL - skip the first element when the list + // has more than one entry. + const targetsOf = (b: UnifiedBot) => (b.waypoints.length > 1 ? b.waypoints.slice(1) : b.waypoints); + const navving = bots.filter((b) => b.nav === "auto" && b.waypoints.length > 0); + const groups = new Map(); + navving.forEach((b) => { + const sig = targetsOf(b) + .map((w) => `${Math.round(w.x)},${Math.round(w.y)}`) + .join(";"); + const arr = groups.get(sig) ?? []; + arr.push(b); + groups.set(sig, arr); + }); + [...groups.entries()].forEach(([sig, bs]) => { + missions.push({ + key: `active-${sig}`, + ids: bs.map((b) => b.id), + label: bs.length === 1 ? short(bs[0].id) : `${bs.length} bots`, + count: bs.length, + n: Math.max(...bs.map((b) => targetsOf(b).length)), + phase: "active", + dots: bs.slice(0, 4).map(ledCss), + }); + }); + return missions; +} + +export const TestbedRail: React.FC = (props) => { + // The panel is open by default; ?rail=collapsed starts it as the icon strip, + // and ?rail=testbed|missions picks which tab is on top. + const railParam = new URLSearchParams(window.location.search).get("rail"); + const [mode, setMode] = useState<"collapsed" | "panel">( + railParam === "collapsed" ? "collapsed" : "panel", + ); + const [top, setTop] = useState<"testbed" | "missions">(railParam === "missions" ? "missions" : "testbed"); + const [tab, setTab] = useState<"console" | "flash">("console"); + + const missions = deriveMissions(props.bots, props.planned); + const targetLabel = props.selection.size ? `${props.selection.size} selected` : "whole fleet"; + + const ico: React.CSSProperties = { + width: 32, + height: 32, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 7, + background: "var(--elevated)", + border: "1px solid var(--hairline)", + fontSize: 13, + color: "var(--text)", + }; + + return ( +
+ {/* collapsed icon strip */} + {mode === "collapsed" && ( +
+
setMode("panel")} title="Open testbed" style={{ ...ico, cursor: "pointer" }}> + ▤ +
+
+ {[ + { g: "▶", t: "Start", fn: props.onStart }, + { g: "■", t: "Stop", fn: props.onStop }, + ].map((x, i) => ( +
+ {x.g} +
+ ))} +
+
{ + setMode("panel"); + setTop("testbed"); + setTab("console"); + }} + title="Console" + style={{ ...ico, cursor: "pointer" }} + > + ☰ +
+
{ + setMode("panel"); + setTop("missions"); + }} + title={`Missions (${missions.length})`} + style={{ ...ico, cursor: "pointer", position: "relative" }} + > + ◎ + {missions.length > 0 && ( + + {missions.length} + + )} +
+
+ )} + + {/* full panel */} + {mode === "panel" && ( +
+ {/* header */} +
+
+
setTop("testbed")} style={topTabStyle(top === "testbed")}> + Testbed +
+
setTop("missions")} style={topTabStyle(top === "missions")}> + Missions{" "} + + {missions.length} + +
+
+
+ setMode("collapsed")} + title="Collapse" + style={{ cursor: "pointer", color: "var(--muted)", fontSize: 15, lineHeight: 1 }} + > + ‹ + +
+ + {/* TESTBED tab */} + {top === "testbed" && ( +
+
+ +
+
+
+ Target · {targetLabel} +
+
+
+ ▶ Start +
+
+ ■ Stop +
+
+ {props.flashing && ( +
+
+ Flashing + {props.fleetPct}% +
+
+
+
+
+ )} +
+
+
setTab("console")} style={tabStyle(tab === "console")}> + Console +
+
setTab("flash")} style={tabStyle(tab === "flash")}> + Flash queue +
+
+ {tab === "console" && ( +
+
+ + /events · log_event + +
+ + Clear + +
+
+
+ {props.logs.length === 0 && ( +
No log events yet.
+ )} + {props.logs.map((l) => ( +
+ {l.t} + {l.msg} +
+ ))} +
+
+
+ )} + {tab === "flash" && ( +
+ {props.jobs.length === 0 ? ( +
+ No active flash. Pick targets in any view (or none = whole fleet), then Flash… +
+ ) : ( + props.jobs.map((j) => { + const pct = j.total ? Math.round((j.acked / j.total) * 100) : 0; + return ( +
+
+ {short(j.addr)} + + {j.done ? "done" : `${pct}%`} + +
+
+
+
+
+ ); + }) + )} +
+ )} +
+ )} + + {/* MISSIONS tab */} + {top === "missions" && ( +
+
+ Active waypoint missions +
+ {missions.length > 0 && ( + props.onSelectIds(missions.flatMap((m) => m.ids))} + style={{ fontSize: 11, color: "var(--accent)", cursor: "pointer" }} + > + Select all + + )} +
+
+ {missions.map((m) => ( +
props.onSelectIds(m.ids)} + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "9px 10px", + background: "var(--elevated)", + border: "1px solid var(--hairline)", + borderRadius: 8, + marginBottom: 8, + cursor: "pointer", + }} + > +
+ {m.dots.map((c, i) => ( +
+ ))} +
+ {m.label} + + {m.phase === "active" ? "Active" : "Planned"} + +
+ + ◎ {m.n} + + {m.phase === "planned" && ( +
{ + e.stopPropagation(); + props.onGoMission(m.key); + }} + style={{ + display: "inline-flex", + alignItems: "center", + padding: "3px 10px", + borderRadius: 6, + fontSize: 11, + fontWeight: 600, + cursor: "pointer", + background: "var(--accent)", + color: "#fff", + }} + > + ▶ Go +
+ )} +
{ + e.stopPropagation(); + if (m.phase === "planned") props.onDiscardMission(m.key); + else props.onStopMission(m.ids); + }} + title={m.phase === "active" ? "Interrupt & discard mission" : "Discard mission"} + style={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 22, + height: 22, + borderRadius: 6, + cursor: "pointer", + color: "var(--muted)", + fontSize: 16, + }} + > + × +
+
+ ))} + {missions.length === 0 && ( +
+ No missions yet. +
+
+ Select bots · ⌥ Alt-click the map to add waypoints (mission shows as Planned) · + press Go to make it Active. Click a mission to reselect its bots. +
+ )} + {props.doneMissions.length > 0 && ( +
+
Recently completed
+ {props.doneMissions.map((d) => ( +
+ + {d.id} + reached all waypoints +
+ {d.t} +
+ ))} +
+ )} +
+
+ )} +
+ )} +
+ ); +}; diff --git a/dotbot/console-web/src/api.test.ts b/dotbot/console-web/src/api.test.ts new file mode 100644 index 00000000..897fabe3 --- /dev/null +++ b/dotbot/console-web/src/api.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { parseSseChunk } from "./api"; + +describe("parseSseChunk", () => { + it("parses complete frames and keeps the partial remainder", () => { + const { events, rest } = parseSseChunk( + 'data: {"a": 1}\n\ndata: {"a": 2}\n\ndata: {"a"', + ); + expect(events).toEqual([{ a: 1 }, { a: 2 }]); + expect(rest).toBe('data: {"a"'); + }); + + it("skips malformed JSON frames", () => { + const { events } = parseSseChunk('data: not-json\n\ndata: {"ok": true}\n\n'); + expect(events).toEqual([{ ok: true }]); + }); + + it("skips frames without a data line", () => { + const { events } = parseSseChunk(': keepalive\n\ndata: {"ok": true}\n\n'); + expect(events).toEqual([{ ok: true }]); + }); + + it("finds the data line in a multi-field frame", () => { + const { events } = parseSseChunk('event: chunk\ndata: {"acked": 12}\n\n'); + expect(events).toEqual([{ acked: 12 }]); + }); + + it("returns everything as remainder when no frame is complete", () => { + const { events, rest } = parseSseChunk("data: {"); + expect(events).toEqual([]); + expect(rest).toBe("data: {"); + }); +}); diff --git a/dotbot/console-web/src/api.ts b/dotbot/console-web/src/api.ts new file mode 100644 index 00000000..aedf9fc1 --- /dev/null +++ b/dotbot/console-web/src/api.ts @@ -0,0 +1,173 @@ +import { + ControllerConnection, + LH2Position, + MapSize, + PyDotBot, + RgbLed, + SwarmitNode, +} from "./types"; + +// Same-origin in dev thanks to the vite proxy (see vite.config.ts). +const CONTROLLER = "/controller"; +const SWARMIT = "/swarmit"; + +export async function fetchDotBots(): Promise { + const res = await fetch(`${CONTROLLER}/dotbots`); + return res.json(); +} + +export async function fetchMapSize(): Promise { + const res = await fetch(`${CONTROLLER}/map_size`); + return res.json(); +} + +export async function fetchConnection(): Promise { + try { + const res = await fetch(`${CONTROLLER}/connection`); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; // an older controller has no such route; the bar just omits it + } +} + +export async function fetchSwarmitStatus(): Promise> { + const res = await fetch(`${SWARMIT}/status`); + const body = await res.json(); + return body.response ?? {}; +} + +export async function putMoveRaw( + address: string, + application: number, + left: number, + right: number, +): Promise { + await fetch(`${CONTROLLER}/dotbots/${address}/${application}/move_raw`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ left_x: 0, left_y: left, right_x: 0, right_y: right }), + }); +} + +export async function putRgbLed( + address: string, + application: number, + led: RgbLed, +): Promise { + await fetch(`${CONTROLLER}/dotbots/${address}/${application}/rgb_led`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(led), + }); +} + +export async function putWaypoints( + address: string, + application: number, + threshold: number, + waypoints: LH2Position[], +): Promise { + await fetch(`${CONTROLLER}/dotbots/${address}/${application}/waypoints`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threshold, waypoints }), + }); +} + +export function controllerWsUrl(): string { + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${window.location.host}${CONTROLLER}/ws/status`; +} + +// --- SwarmIT orchestration (write path; same contract as the real server) --- + +export async function swarmitAction( + action: "start" | "stop", + devices?: string[], +): Promise { + const res = await fetch(`${SWARMIT}/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(devices && devices.length ? { devices } : {}), + }); + // fetch resolves on any status, so without this a 502 from an absent swarmit + // server toasts as a sent command. + if (!res.ok) { + throw new Error(`${action} refused: ${res.status} ${await res.text()}`); + } +} + +export interface FlashEvent { + type: "flash_started" | "chunk" | "device_done" | "complete" | "error" | "warning"; + addr?: string; + acked?: number; + total?: number; + devices?: string[]; + total_chunks?: number; + success?: boolean; + all_success?: boolean; + message?: string; +} + +// Split an SSE buffer into parsed `data:` payloads plus the trailing +// incomplete remainder (kept for the next chunk). Malformed frames are +// skipped. +export function parseSseChunk(buf: string): { events: T[]; rest: string } { + const events: T[] = []; + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const line = frame.split("\n").find((l) => l.startsWith("data: ")); + if (!line) continue; + try { + events.push(JSON.parse(line.slice(6))); + } catch { + /* skip malformed frame */ + } + } + return { events, rest: buf }; +} + +// POST /flash/stream and feed each SSE event to the callback. `imageName` is +// stored with the image and reported back by the bot, so it shows up in the +// Image column once the flash lands. +export async function flashStream( + firmwareB64: string, + devices: string[] | undefined, + onEvent: (ev: FlashEvent) => void, + imageName = "", +): Promise { + const res = await fetch(`${SWARMIT}/flash/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + firmware_b64: firmwareB64, + image_name: imageName, + ...(devices && devices.length ? { devices } : {}), + }), + }); + // fetch only rejects on network failure, so a 4xx arrives here looking fine. + // Its body is JSON, which parses as zero SSE frames: without this the flash + // silently does nothing. + if (!res.ok) { + throw new Error(`flash refused: ${res.status} ${await res.text()}`); + } + const reader = res.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const { events, rest } = parseSseChunk(buf); + buf = rest; + events.forEach(onEvent); + } +} + +export function swarmitEventsUrl(): string { + return `${SWARMIT}/events`; +} diff --git a/dotbot/console-web/src/battery.test.ts b/dotbot/console-web/src/battery.test.ts new file mode 100644 index 00000000..595a2222 --- /dev/null +++ b/dotbot/console-web/src/battery.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { batteryColor, batteryPct } from "./viewChrome"; + +// The scale is a robot fact and lives in swarmit, per device type. These cover +// that the console renders what it is served and degrades visibly, not +// confidently, for a bot swarmit does not know. +describe("battery rendering", () => { + it("renders the served percentage verbatim", () => { + expect(batteryPct({ batteryPct: 57, battery: 2.3 })).toBe(57); + expect(batteryPct({ batteryPct: 0, battery: 0.6 })).toBe(0); + }); + + it("clamps a nonsense served value rather than overflowing the bar", () => { + expect(batteryPct({ batteryPct: 140, battery: 3.0 })).toBe(100); + expect(batteryPct({ batteryPct: -5, battery: 0 })).toBe(0); + }); + + it("falls back only for a bot swarmit does not know", () => { + expect(batteryPct({ batteryPct: null, battery: 3.0 })).toBe(100); + expect(batteryPct({ batteryPct: null, battery: 1.5 })).toBe(50); + }); + + it("colours by the served band, matching the bootloader LED", () => { + expect(batteryColor({ batteryLevel: "full" })).toBe("var(--s-Full)"); + expect(batteryColor({ batteryLevel: "ok" })).toBe("var(--s-Running)"); + expect(batteryColor({ batteryLevel: "low" })).toBe("var(--s-Stopping)"); + }); + + it("shows no band when there is none to show", () => { + expect(batteryColor({ batteryLevel: null })).toBe("var(--muted)"); + }); +}); diff --git a/dotbot/console-web/src/firmwareFile.ts b/dotbot/console-web/src/firmwareFile.ts new file mode 100644 index 00000000..88cc0127 --- /dev/null +++ b/dotbot/console-web/src/firmwareFile.ts @@ -0,0 +1,35 @@ +// Reading a firmware image out of a file input. +// +// swarmit takes the image as base64 in the request body, so the file never +// touches the server's filesystem and any .bin the operator can see is +// flashable. The browser cannot re-read a path later, so whatever is captured +// here is all we will ever have of that file. + +export interface FirmwareFile { + name: string; + b64: string; + /** The source file's own mtime, i.e. when it was built. */ + lastModified: number; +} + +/** Base64 without blowing the argument limit on a multi-hundred-kB image. */ +export function toBase64(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(bin); +} + +/** Decoded byte length of a base64 payload, for display. */ +export function decodedSize(b64: string): number { + if (!b64) return 0; + const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((b64.length * 3) / 4) - pad); +} + +export async function readFirmwareFile(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + if (bytes.length === 0) throw new Error("file is empty"); + return { name: file.name, b64: toBase64(bytes), lastModified: file.lastModified }; +} diff --git a/dotbot/console-web/src/firmwareHistory.test.ts b/dotbot/console-web/src/firmwareHistory.test.ts new file mode 100644 index 00000000..620db9d1 --- /dev/null +++ b/dotbot/console-web/src/firmwareHistory.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { buildTime } from "./FirmwareSection"; +import { decodedSize, toBase64 } from "./firmwareFile"; +import { + FirmwareEntry, + MAX_ENTRIES, + MAX_TOTAL_B64, + load, + remember, + togglePin, + trim, +} from "./firmwareHistory"; + +const store: Record = {}; +beforeEach(() => { + for (const k of Object.keys(store)) delete store[k]; + (globalThis as unknown as { window: unknown }).window = { + localStorage: { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + removeItem: (k: string) => { + delete store[k]; + }, + }, + }; +}); + +const img = (name: string, b64 = "AAAA", lastModified = 1000) => ({ + name, + b64, + lastModified, +}); +const entry = (over: Partial): FirmwareEntry => ({ + name: "a.bin", + b64: "AAAA", + lastModified: 0, + ts: 1, + pinned: false, + ...over, +}); + +describe("firmware history", () => { + it("orders by when it was flashed, newest first", () => { + remember(img("a.bin"), 1000); + remember(img("b.bin", "BBBB"), 2000); + expect(load().map((e) => e.name)).toEqual(["b.bin", "a.bin"]); + }); + + it("bumps a reflashed row instead of duplicating it", () => { + remember(img("a.bin"), 1000); + remember(img("b.bin", "BBBB"), 2000); + remember(img("a.bin"), 3000); + expect(load().map((e) => e.name)).toEqual(["a.bin", "b.bin"]); + }); + + it("keeps a rebuild under the same name as its own row", () => { + // Different bytes, so it is a different build and worth keeping both. + expect( + trim([entry({ b64: "AAAA", ts: 1 }), entry({ b64: "AAAAAAAA", ts: 2 })]), + ).toHaveLength(2); + }); + + it("keeps the source file's build time, which is not when it was flashed", () => { + remember(img("a.bin", "AAAA", 1_700_000_000_000), 1_800_000_000_000); + const [e] = load(); + expect(e.lastModified).toBe(1_700_000_000_000); + expect(e.ts).toBe(1_800_000_000_000); + }); +}); + +describe("pinning", () => { + it("sorts pinned rows above unpinned ones", () => { + const kept = trim([ + entry({ name: "new.bin", b64: "NNNN", ts: 9 }), + entry({ name: "old.bin", b64: "OOOO", ts: 1, pinned: true }), + ]); + expect(kept.map((e) => e.name)).toEqual(["old.bin", "new.bin"]); + }); + + it("exempts pinned rows from eviction", () => { + const rows = [entry({ name: "keep.bin", b64: "KKKK", ts: 0, pinned: true })]; + for (let i = 0; i < MAX_ENTRIES + 5; i++) { + rows.push(entry({ name: `fw-${i}.bin`, b64: `${i}`.padEnd(8, "x"), ts: 100 + i })); + } + const kept = trim(rows); + expect(kept.map((e) => e.name)).toContain("keep.bin"); + expect(kept[0].name).toBe("keep.bin"); + }); + + it("exempts pinned rows from the byte budget too", () => { + const big = "A".repeat(MAX_TOTAL_B64); + const kept = trim([ + entry({ name: "pinned.bin", b64: big, ts: 1, pinned: true }), + entry({ name: "other.bin", b64: big.slice(0, 1000), ts: 2 }), + ]); + expect(kept.map((e) => e.name)).toEqual(["pinned.bin"]); + }); + + it("toggles a pin and keeps it across a reflash", () => { + remember(img("a.bin"), 1000); + const pinned = togglePin(load()[0]); + expect(pinned[0].pinned).toBe(true); + remember(img("a.bin"), 5000); + expect(load()[0].pinned).toBe(true); + }); + + it("survives a corrupt store rather than throwing", () => { + store["dotbot.console.firmwareHistory.v3"] = "{not json"; + expect(load()).toEqual([]); + }); +}); + +describe("firmware file helpers", () => { + const decode = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + + it("round-trips an image larger than the chunk size", () => { + const bytes = new Uint8Array(0x8000 * 3 + 1234); + for (let i = 0; i < bytes.length; i++) bytes[i] = i % 256; + expect(decode(toBase64(bytes))).toEqual(bytes); + }); + + it("reports the decoded size", () => { + expect(decodedSize("Zm9vYmFy")).toBe(6); + expect(decodedSize("Zm9vYmE=")).toBe(5); + expect(decodedSize("")).toBe(0); + }); +}); + +describe("buildTime", () => { + const now = 1_700_000_000_000; + it("reads as the age of the build", () => { + expect(buildTime(0, now)).toBe("unknown"); + expect(buildTime(now - 10_000, now)).toBe("just built"); + expect(buildTime(now - 600_000, now)).toBe("10m old"); + expect(buildTime(now - 7_200_000, now)).toBe("2h old"); + expect(buildTime(now - 3 * 86_400_000, now)).toBe("3d old"); + }); +}); diff --git a/dotbot/console-web/src/firmwareHistory.ts b/dotbot/console-web/src/firmwareHistory.ts new file mode 100644 index 00000000..2951ef51 --- /dev/null +++ b/dotbot/console-web/src/firmwareHistory.ts @@ -0,0 +1,119 @@ +// Images flashed from the "other image" picker, kept in localStorage so +// reflashing the build you just pushed is one click. +// +// The bytes are kept, not just the names: the browser cannot re-read a file +// off disk without a fresh pick, so a name-only history could not reflash and +// would just be a log. That makes every row a SNAPSHOT - rebuild the same file +// and the stored row still holds the old bytes, which is a footgun when you +// meant to flash the rebuild and a feature when you want to reproduce a run. +// Each row carries the source file's own mtime so the two can be told apart. +// +// A sandbox image is a few kB, which makes keeping bytes cheap, but an app +// image need not be, so the store is capped by total size as well as by count +// and evicts oldest-first. Pinned rows are exempt from eviction. A write that +// still does not fit is dropped rather than thrown: losing history is a +// worse-but-fine outcome, a picker that breaks in private mode is not. + +import { FirmwareFile } from "./firmwareFile"; + +const KEY = "dotbot.console.firmwareHistory.v3"; +export const MAX_ENTRIES = 20; +export const MAX_TOTAL_B64 = 4 * 1024 * 1024; + +export interface FirmwareEntry extends FirmwareFile { + /** When it was last flashed, which is what "recent" orders by. */ + ts: number; + pinned: boolean; +} + +function key(e: FirmwareEntry): string { + // Same name and same byte count is the same build; reflashing it bumps the + // row rather than growing a column of identical ones. A rebuild under the + // same name has different bytes and is deliberately kept as its own row. + return `${e.name}:${e.b64.length}`; +} + +export function trim(entries: FirmwareEntry[]): FirmwareEntry[] { + const seen = new Set(); + const deduped = [...entries] + .sort((a, b) => b.ts - a.ts) + .filter((e) => { + if (seen.has(key(e))) return false; + seen.add(key(e)); + return true; + }); + + // Pinned rows sort to the top and survive eviction; that is what pinning is. + const pinned = deduped.filter((e) => e.pinned); + const rest = deduped.filter((e) => !e.pinned); + const out: FirmwareEntry[] = [...pinned]; + let total = pinned.reduce((n, e) => n + e.b64.length, 0); + for (const e of rest) { + if (out.length >= MAX_ENTRIES) break; + if (total + e.b64.length > MAX_TOTAL_B64) break; + total += e.b64.length; + out.push(e); + } + return out; +} + +export function load(): FirmwareEntry[] { + try { + const parsed = JSON.parse(window.localStorage.getItem(KEY) ?? "[]"); + if (!Array.isArray(parsed)) return []; + return trim( + parsed + .filter( + (e) => + e && + typeof e.name === "string" && + typeof e.b64 === "string" && + typeof e.ts === "number", + ) + .map((e) => ({ + ...e, + lastModified: typeof e.lastModified === "number" ? e.lastModified : 0, + pinned: Boolean(e.pinned), + })), + ); + } catch { + return []; // unparseable or storage blocked: start clean rather than break + } +} + +export function save(entries: FirmwareEntry[]): FirmwareEntry[] { + const kept = trim(entries); + try { + window.localStorage.setItem(KEY, JSON.stringify(kept)); + } catch { + /* quota or private mode: the picker still works, history just is not kept */ + } + return kept; +} + +export function remember( + image: FirmwareFile, + ts: number, + existing = load(), +): FirmwareEntry[] { + // Re-flashing a row keeps its pin rather than silently unpinning it. + const previous = existing.find( + (e) => e.name === image.name && e.b64.length === image.b64.length, + ); + return save([{ ...image, ts, pinned: previous?.pinned ?? false }, ...existing]); +} + +export function togglePin( + entry: FirmwareEntry, + existing = load(), +): FirmwareEntry[] { + return save( + existing.map((e) => + key(e) === key(entry) ? { ...e, pinned: !e.pinned } : e, + ), + ); +} + +export function clear(): FirmwareEntry[] { + return save([]); +} diff --git a/dotbot/console-web/src/inspector.test.ts b/dotbot/console-web/src/inspector.test.ts new file mode 100644 index 00000000..123a3e24 --- /dev/null +++ b/dotbot/console-web/src/inspector.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { formatLh2, formatUptime, infoText } from "./Inspector"; +import { SwarmitNode, UnifiedBot } from "./types"; + +const bot = (over: Partial = {}): UnifiedBot => ({ + id: "217B829760EBA3E0", + state: "Running", + link: "active", + position: { x: 1552, y: 267 }, + heading: null, + battery: 2.58, + led: null, + deviceType: "DotBotV3", + application: 0, + drivable: true, + nav: "drive", + waypoints: [], + trail: [], + image: null, + resetCause: null, + severity: "normal", + batteryPct: null, + batteryLevel: null, + swarmit: null, + ...over, +}); + +const node = (over: Partial = {}): SwarmitNode => ({ + device: "DotBotV3", + status: "Running", + battery: 2580, + pos_x: 1552, + pos_y: 267, + ...over, +}); + +describe("formatUptime", () => { + it("matches swarmit's format_uptime", () => { + expect(formatUptime(45)).toBe("45s"); + expect(formatUptime(336)).toBe("5m 36s"); + expect(formatUptime(7444)).toBe("2h 04m 04s"); + }); +}); + +describe("formatLh2", () => { + // A bot that never answered and one reporting zero homographies are + // different facts: one is re-provisioned, the other is a pending fetch. + it("separates never-answered from uncalibrated", () => { + expect(formatLh2(bot({ swarmit: null }))).toBe("unknown (no device info)"); + expect(formatLh2(bot({ swarmit: node({ info: null }) }))).toBe("unknown (no device info)"); + }); + + it("renders the summary swarmit computed, verbatim", () => { + const info = { bl_version: "", net_version: "", boot_count: 0, uptime_s: 0, image_name: "", image_version: "", image_digest: "" }; + const withInfo = (over: object) => bot({ swarmit: node({ info: { ...info, ...over } }) }); + expect(formatLh2(withInfo({ lh2_summary: "uncalibrated" }))).toBe("uncalibrated"); + expect(formatLh2(withInfo({ lh2_summary: "2 basestations (valid, from flash)" }))).toBe( + "2 basestations (valid, from flash)", + ); + }); +}); + +describe("infoText", () => { + it("is plain text carrying the identity and the reset cause", () => { + const t = infoText( + bot({ resetCause: "stopped", swarmit: node({ reset_reason: 1 << 25, fault: 0, fault_name: "NoFault" }) }), + ); + expect(t.split("\n")[0]).toBe("217B829760EBA3E0"); + expect(t).toContain("Last reset stopped"); + expect(t).toContain("reset_reason 0x02000000"); + expect(t).toContain("fault NoFault"); + }); + + it("spells out the fault registers only when a fault was latched", () => { + const clean = infoText(bot({ swarmit: node({ reset_reason: 0, fault: 0 }) })); + expect(clean).not.toContain("cfsr"); + const crashed = infoText( + bot({ severity: "crashed", swarmit: node({ reset_reason: 2, fault: 1, pc: 0x2000abcd }) }), + ); + expect(crashed).toContain("cfsr"); + expect(crashed).toContain("pc 0x2000abcd"); + }); + + // A watchdog timeout raises no fault, so cfsr/sfsr are structurally zero. + // Printing them invites decoding a status that was never populated - the + // CLI suppresses them for exactly this case, and so must this. + it("hides the fault registers for a watchdog timeout, but keeps pc and lr", () => { + const hung = infoText( + bot({ + severity: "hung", + resetCause: "hung (watchdog0 pc=0x00010230)", + swarmit: node({ + reset_reason: 0x02, + fault: 3, // WatchdogTimeout + fault_name: "WatchdogTimeout", + cfsr: 0, + sfsr: 0, + pc: 0x00010230, + lr: 0x0001022b, + }), + }), + ); + + expect(hung).not.toContain("cfsr"); + expect(hung).not.toContain("sfsr"); + // pc and lr are the whole answer for this failure mode. + expect(hung).toContain("pc 0x00010230"); + expect(hung).toContain("lr 0x0001022b"); + expect(hung).toContain("fault WatchdogTimeout"); + }); +}); diff --git a/dotbot/console-web/src/joystick.test.ts b/dotbot/console-web/src/joystick.test.ts new file mode 100644 index 00000000..25e53881 --- /dev/null +++ b/dotbot/console-web/src/joystick.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { mixDrive } from "./Joystick"; + +// The pad is body-relative and open loop. These pin the SIGNS, which is the +// part that inverted silently once already: a closed-loop version steered by +// heading error and drove the bot away from the target. +describe("mixDrive", () => { + it("is idle at rest and inside the deadzone", () => { + expect(mixDrive(0, 0)).toEqual({ left: 0, right: 0 }); + expect(mixDrive(2, 1)).toEqual({ left: 0, right: 0 }); + }); + + it("drives both wheels forward when dragged up", () => { + const { left, right } = mixDrive(0, -100); + expect(left).toBe(right); + expect(left).toBeGreaterThan(0); + }); + + it("drives both wheels back when dragged down", () => { + const { left, right } = mixDrive(0, 100); + expect(left).toBe(right); + expect(left).toBeLessThan(0); + }); + + // Dragging right speeds the LEFT wheel, which yaws the bot clockwise. + // Verified against dotbot_simulator: left faster => `direction` decreases. + it("speeds the left wheel when dragged right", () => { + const { left, right } = mixDrive(100, 0); + expect(left).toBeGreaterThan(right); + }); + + it("speeds the right wheel when dragged left", () => { + const { left, right } = mixDrive(-100, 0); + expect(right).toBeGreaterThan(left); + }); + + it("never exceeds the int8 range the protocol carries", () => { + for (const [dx, dy] of [[100, -100], [-100, -100], [100, 100], [-100, 100]]) { + const { left, right } = mixDrive(dx, dy); + expect(left).toBeGreaterThanOrEqual(-128); + expect(left).toBeLessThanOrEqual(127); + expect(right).toBeGreaterThanOrEqual(-128); + expect(right).toBeLessThanOrEqual(127); + } + }); + + // The stall-band jump: the first usable step must clear the motors' floor. + it("clears the stall band as soon as it commands motion", () => { + const { left } = mixDrive(0, -5); + expect(Math.abs(left)).toBeGreaterThanOrEqual(30); + }); + + it("gives finer control than the pad's 20px knob travel would", () => { + // Distinct commands well beyond a 20px throw = the resolution that was missing. + const near = mixDrive(0, -25).left; + const far = mixDrive(0, -75).left; + expect(far).toBeGreaterThan(near); + }); +}); diff --git a/dotbot/console-web/src/main.tsx b/dotbot/console-web/src/main.tsx new file mode 100644 index 00000000..e64b87be --- /dev/null +++ b/dotbot/console-web/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./tokens.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/dotbot/console-web/src/missions.test.ts b/dotbot/console-web/src/missions.test.ts new file mode 100644 index 00000000..66bae378 --- /dev/null +++ b/dotbot/console-web/src/missions.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { deriveMissions } from "./TestbedRail"; +import { PlannedMission, UnifiedBot } from "./types"; + +const bot = (id: string, over: Partial = {}): UnifiedBot => ({ + id, + state: "Running", + link: "active", + position: { x: 0, y: 0 }, + heading: null, + battery: 3.9, + led: null, + deviceType: "DotBotV3", + application: 0, + drivable: true, + nav: "drive", + waypoints: [], + trail: [], + image: null, + resetCause: null, + severity: "normal", + batteryPct: null, + batteryLevel: null, + swarmit: null, + ...over, +}); + +describe("deriveMissions", () => { + it("keeps a planned mission bound to its bots", () => { + const planned: PlannedMission[] = [ + { + key: "aaaa", + ids: ["aaaa"], + waypoints: [ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + ], + }, + ]; + const [m] = deriveMissions([bot("aaaa")], planned); + expect(m.phase).toBe("planned"); + expect(m.n).toBe(2); + expect(m.ids).toEqual(["aaaa"]); + }); + + it("drops a planned mission whose bots are gone", () => { + const planned: PlannedMission[] = [ + { key: "gone", ids: ["gone"], waypoints: [{ x: 1, y: 1 }] }, + ]; + expect(deriveMissions([bot("aaaa")], planned)).toEqual([]); + }); + + it("groups active bots by the mission TAIL (own start prepended)", () => { + // The controller stores [own-start, ...targets] per bot: same targets, + // different starts, must land in ONE mission. + const t = [ + { x: 500, y: 500 }, + { x: 900, y: 900 }, + ]; + const a = bot("aaaa", { nav: "auto", waypoints: [{ x: 1, y: 1 }, ...t] }); + const b = bot("bbbb", { nav: "auto", waypoints: [{ x: 2, y: 2 }, ...t] }); + const missions = deriveMissions([a, b], []); + expect(missions).toHaveLength(1); + expect(missions[0].phase).toBe("active"); + expect(missions[0].count).toBe(2); + expect(missions[0].n).toBe(2); + }); + + it("treats a single-entry waypoint list as the target itself", () => { + const a = bot("aaaa", { nav: "auto", waypoints: [{ x: 500, y: 500 }] }); + const [m] = deriveMissions([a], []); + expect(m.n).toBe(1); + }); + + it("ignores bots that are not navigating", () => { + const a = bot("aaaa", { waypoints: [{ x: 1, y: 1 }] }); // nav=drive + expect(deriveMissions([a], [])).toEqual([]); + }); +}); diff --git a/dotbot/console-web/src/tokens.css b/dotbot/console-web/src/tokens.css new file mode 100644 index 00000000..21cd8df3 --- /dev/null +++ b/dotbot/console-web/src/tokens.css @@ -0,0 +1,55 @@ +/* Design tokens - the single source of truth for every color in the console. + Swap values here to re-theme; components must reference tokens only. */ +:root { + --canvas: #14161b; + --surface: #1e222b; + --elevated: #2a2f3a; + --grid: #2c313c; + --text: #e6e9ef; + --muted: #9aa4b2; + --accent: #e4032e; + --hairline: rgba(255, 255, 255, 0.07); + + /* State colors are shared across themes: a color always means the same thing. */ + --s-Running: #22c55e; + --s-Full: #22d3ee; + --s-Programming: #f59e0b; + --s-Bootloader: #38bdf8; + --s-Stopping: #ef4444; + --s-Resetting: #a855f7; + --s-Inactive: #6b7280; + + --font-ui: "IBM Plex Sans", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; +} + +[data-theme="light"] { + --canvas: #f4f6fa; + --surface: #ffffff; + --elevated: #eef1f6; + --grid: #dbe1ea; + --text: #0f172a; + --muted: #5b6577; + --hairline: rgba(0, 0, 0, 0.07); +} + +* { + box-sizing: border-box; +} +body { + margin: 0; +} +a { + color: var(--accent); + text-decoration: none; +} + +@keyframes dbBlink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts new file mode 100644 index 00000000..32befa1d --- /dev/null +++ b/dotbot/console-web/src/types.ts @@ -0,0 +1,161 @@ +// Two independent axes, kept apart on purpose. +// +// BotState is the SwarmIT sandbox lifecycle, swarmit's vocabulary verbatim. It +// says what the TrustZone sandbox is doing. A bot swarmit does not know (one +// running bare, with no sandbox) has no value here at all, which is why the +// merged object carries `state: BotState | null` rather than inventing one. +// +// LinkState is PyDotBot's DotBotStatus: whether the control plane is still +// hearing the bot. Orthogonal to the sandbox - a bot can be mid-Programming +// and unheard at the same time, and collapsing the two lost exactly that. +export type BotState = + | "Running" + | "Programming" + | "Bootloader" + | "Stopping" + | "Resetting"; + +export type LinkState = "active" | "inactive" | "lost" | "unknown"; + +export const STATE_ORDER: BotState[] = [ + "Running", + "Programming", + "Bootloader", + "Stopping", + "Resetting", +]; + +export const LINK_LABEL: Record = { + active: "Live", + inactive: "Inactive", + lost: "Lost", + unknown: "Not on the control plane", +}; + +// PyDotBot REST/WS shapes (subset the console consumes). +export interface LH2Position { + x: number; + y: number; +} + +export interface RgbLed { + red: number; + green: number; + blue: number; +} + +export interface PyDotBot { + address: string; + application: number; // ApplicationType: 0 = DotBot + status: number; // 0 ACTIVE, 1 INACTIVE, 2 LOST + mode?: number; // ControlModeType: 0 MANUAL, 1 AUTO (navigating waypoints) + direction?: number; + lh2_position?: LH2Position; + position_history?: LH2Position[]; + waypoints?: LH2Position[]; + waypoints_threshold?: number; + rgb_led?: RgbLed; + battery?: number; // volts + calibrated?: number; +} + +export interface WsNotification { + cmd: number; // 1 RELOAD, 2 UPDATE, 4 NEW_DOTBOT + data?: Partial & { + lh2_waypoints?: LH2Position[]; + }; +} + +// What a bot reports it is running, as carried in SwarmitNode.info. +export interface SwarmitDeviceInfo { + info_version?: number; + bl_version: string; + net_version: string; + boot_count: number; + uptime_s: number; + image_state?: number; + image_result?: number; + image_size?: number; + image_name: string; + image_version: string; + image_digest: string; + lh2_homography_count?: number; + lh2_flags?: number; + // Display strings swarmit computes; the console renders them verbatim. + lh2_summary?: string; + image_state_name?: string; + image_result_name?: string; + raw?: string; // hex of the device-info packet, only on /status +} + +// SwarmIT /status record. Only the fields the console binds to are declared; +// the server sends the full NodeStatus and the extras are ignored. +export interface SwarmitNode { + device: string; + status: string; // Bootloader | Running | Stopping | Resetting | Programming + battery: number; // millivolts + pos_x: number; + pos_y: number; + reset_reason?: number; // raw nRF RESETREAS + fault?: number; // latched fault type, 0 = none + reset_cause?: string; // swarmit's friendly label for the last reset + fault_name?: string; // the latched FaultType's name + reset_severity?: string; // crashed | hung | normal, swarmit's own tiering + battery_pct?: number; // 0-100 on this robot's own battery profile + battery_level?: string; // full | ok | low, the bootloader's LED bands + from_ns?: number; // the fault came from the non-secure world + pc?: number; // program counter at the fault + lr?: number; + cfsr?: number; // configurable fault status + sfsr?: number; // secure fault status + last_updated_at?: number; // unix seconds + raw?: string; // hex of the status packet, only on /status + info?: SwarmitDeviceInfo | null; +} + +// The merged per-bot object the UI binds to (controller + swarmit joined by address). +export interface UnifiedBot { + id: string; // hex address, the join key + state: BotState | null; // null: swarmit does not know this bot (no sandbox) + link: LinkState; + position: LH2Position | null; // arena mm + heading: number | null; // degrees + battery: number; // volts + led: RgbLed | null; + deviceType: string; + application: number; + drivable: boolean; // a DBP-speaking image is running (= known to PyDotBot and active) + nav: "drive" | "auto"; // auto = navigating waypoints (firmware AUTO mode) + waypoints: LH2Position[]; // active mission (as reported by the controller) + trail: LH2Position[]; + image: string | null; // firmware image the bot reports running + resetCause: string | null; // why it last booted, swarmit's vocabulary + // How much attention the last reset deserves, straight from swarmit. + // "hung" is its own tier: a sandbox app has no clean exit, so a normal + // completion latches WatchdogTimeout and must not read as a crash. + severity: "crashed" | "hung" | "normal"; + batteryPct: number | null; // served by swarmit; null for a bot it does not know + batteryLevel: string | null; // full | ok | low + swarmit: SwarmitNode | null; // the orchestration record, for the inspector +} + +// GET /controller/connection - how the controller reaches the swarm. +export interface ControllerConnection { + adapter: string; + connection: string; + swarm_id: string; + gw_address: string; +} + +export interface MapSize { + width: number; + height: number; +} + +// A waypoint mission queued locally but not yet sent: bound to the bots that +// were selected when its waypoints were dropped (survives deselection). +export interface PlannedMission { + key: string; // sorted ids joined + ids: string[]; + waypoints: LH2Position[]; +} diff --git a/dotbot/console-web/src/useFleet.test.ts b/dotbot/console-web/src/useFleet.test.ts new file mode 100644 index 00000000..2e5a6405 --- /dev/null +++ b/dotbot/console-web/src/useFleet.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; + +import { PyDotBot, SwarmitNode } from "./types"; +import { deriveLink, deriveState, merge, severityOf } from "./useFleet"; + +const py = (over: Partial = {}): PyDotBot => ({ + address: "badcafe111111111", + application: 0, + status: 0, + ...over, +}); + +const sw = (over: Partial = {}): SwarmitNode => ({ + device: "DotBotV3", + status: "Running", + battery: 3900, + pos_x: 100, + pos_y: 200, + ...over, +}); + +describe("deriveState (the sandbox axis)", () => { + it("reports only what swarmit says, ignoring the control plane", () => { + // The two axes are independent: a bot can be mid-Programming and unheard + // at the same time, and the old single state hid the sandbox in that case. + expect(deriveState(sw({ status: "Programming" }))).toBe("Programming"); + for (const s of ["Running", "Programming", "Bootloader", "Stopping", "Resetting"]) { + expect(deriveState(sw({ status: s }))).toBe(s); + } + }); + + it("has no sandbox state for a bot swarmit does not know", () => { + // Saying "Running" here claimed a sandbox a bare-mode bot does not have. + expect(deriveState(undefined)).toBeNull(); + }); + + it("does not invent a state for a lifecycle value it does not know", () => { + expect(deriveState(sw({ status: "Off" }))).toBeNull(); + }); +}); + +describe("deriveLink (the control-plane axis)", () => { + it("maps PyDotBot's DotBotStatus", () => { + expect(deriveLink(py({ status: 0 }))).toBe("active"); + expect(deriveLink(py({ status: 1 }))).toBe("inactive"); + expect(deriveLink(py({ status: 2 }))).toBe("lost"); + }); + + it("is unknown for a bot the control plane has never seen", () => { + expect(deriveLink(undefined)).toBe("unknown"); + }); +}); + +describe("the two axes stay independent", () => { + it("keeps the sandbox state on a bot the control plane has lost", () => { + const [b] = merge({ aaaa: py({ address: "aaaa", status: 2 }) }, { aaaa: sw({ status: "Programming" }) }); + expect(b.state).toBe("Programming"); + expect(b.link).toBe("lost"); + expect(b.drivable).toBe(false); + }); + + it("drives a bare-mode bot that has no sandbox at all", () => { + const [b] = merge({ aaaa: py({ address: "aaaa", status: 0 }) }, {}); + expect(b.state).toBeNull(); + expect(b.link).toBe("active"); + expect(b.drivable).toBe(true); + }); + + it("will not drive a swarmit bot the control plane cannot reach", () => { + const [b] = merge({}, { aaaa: sw({ status: "Running" }) }); + expect(b.link).toBe("unknown"); + expect(b.drivable).toBe(false); + }); +}); + +describe("merge", () => { + it("unions both planes and sorts by id", () => { + const bots = merge( + { bbbb: py({ address: "bbbb" }) }, + { aaaa: sw(), bbbb: sw() }, + ); + expect(bots.map((b) => b.id)).toEqual(["aaaa", "bbbb"]); + }); + + it("prefers the controller position, falls back to swarmit", () => { + const [a] = merge( + { a: py({ address: "a", lh2_position: { x: 1, y: 2 } }) }, + { a: sw() }, + ); + expect(a.position).toEqual({ x: 1, y: 2 }); + const [b] = merge({}, { b: sw() }); + expect(b.position).toEqual({ x: 100, y: 200 }); + }); + + // swarmit reports (0, 0) for a bot it has never located, and a real fix + // cannot land on the origin - drawing it puts the whole uncalibrated fleet + // in one arena corner and reads as a real cluster. + it("does not treat swarmit's (0, 0) no-fix sentinel as a position", () => { + const [a] = merge({}, { a: sw({ pos_x: 0, pos_y: 0 }) }); + expect(a.position).toBeNull(); + const [b] = merge({}, { b: sw({ pos_x: 0, pos_y: 400 }) }); + expect(b.position).toEqual({ x: 0, y: 400 }); + }); + + it("treats direction -1000 (unknown) as no heading", () => { + const [a] = merge({ a: py({ address: "a", direction: -1000 }) }, {}); + expect(a.heading).toBeNull(); + const [b] = merge({ b: py({ address: "b", direction: 45 }) }, {}); + expect(b.heading).toBe(45); + }); + + it("converts a swarmit-only battery from mV to V", () => { + const [a] = merge({}, { a: sw({ battery: 3900 }) }); + expect(a.battery).toBeCloseTo(3.9); + }); + + it("only an active Running control-plane bot is drivable", () => { + const [a] = merge({ a: py({ address: "a" }) }, {}); + expect(a.drivable).toBe(true); + // swarmit-only (e.g. sitting in the bootloader): never drivable + const [b] = merge({}, { b: sw({ status: "Bootloader" }) }); + expect(b.drivable).toBe(false); + // known to the controller but flashing right now: not drivable + const [c] = merge( + { c: py({ address: "c" }) }, + { c: sw({ status: "Programming" }) }, + ); + expect(c.drivable).toBe(false); + }); + + it("maps firmware AUTO mode to nav=auto", () => { + const [a] = merge({ a: py({ address: "a", mode: 1 }) }, {}); + expect(a.nav).toBe("auto"); + const [b] = merge({ b: py({ address: "b", mode: 0 }) }, {}); + expect(b.nav).toBe("drive"); + }); +}); + +describe("severityOf", () => { + const node = (over: Partial): SwarmitNode => ({ + device: "DotBotV3", + status: "Running", + battery: 3900, + pos_x: 0, + pos_y: 0, + ...over, + }); + + it("renders swarmit's tier rather than re-deriving it", () => { + expect(severityOf(node({ reset_severity: "crashed" }))).toBe("crashed"); + expect(severityOf(node({ reset_severity: "hung" }))).toBe("hung"); + expect(severityOf(node({ reset_severity: "normal" }))).toBe("normal"); + }); + + it("is normal when swarmit said nothing, so a badge needs evidence", () => { + expect(severityOf(undefined)).toBe("normal"); + expect(severityOf(node({}))).toBe("normal"); + expect(severityOf(node({ reset_severity: "something-new" }))).toBe("normal"); + }); +}); + +describe("merge takes the reset label from the server", () => { + it("passes reset_cause through and reports it missing rather than guessing", () => { + const [a] = merge({}, { A: { device: "DotBotV3", status: "Running", battery: 3900, pos_x: 0, pos_y: 0, reset_cause: "stopped" } }); + expect(a.resetCause).toBe("stopped"); + const [b] = merge({}, { B: { device: "DotBotV3", status: "Running", battery: 3900, pos_x: 0, pos_y: 0 } }); + expect(b.resetCause).toBeNull(); + }); +}); diff --git a/dotbot/console-web/src/useFleet.ts b/dotbot/console-web/src/useFleet.ts new file mode 100644 index 00000000..b3809b2a --- /dev/null +++ b/dotbot/console-web/src/useFleet.ts @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { controllerWsUrl, fetchDotBots, fetchMapSize, fetchSwarmitStatus } from "./api"; +import { + BotState, + LinkState, + MapSize, + PyDotBot, + STATE_ORDER, + SwarmitNode, + UnifiedBot, + WsNotification, +} from "./types"; + +const TRAIL_MAX = 200; + +// swarmit tiers the last reset itself (crashed / hung / normal); the console +// styles by that rather than re-deriving the bit tests, so the badge and the +// sentence beside it cannot disagree. +export function severityOf(sw: SwarmitNode | undefined): UnifiedBot["severity"] { + const s = sw?.reset_severity; + return s === "crashed" || s === "hung" ? s : "normal"; +} + +export function deriveState(sw: SwarmitNode | undefined): BotState | null { + if (!sw) return null; + return STATE_ORDER.includes(sw.status as BotState) + ? (sw.status as BotState) + : null; +} + +// Whether the control plane still hears the bot, from PyDotBot alone. +// "unknown" is a bot swarmit reports but PyDotBot has never seen. +export function deriveLink(py: PyDotBot | undefined): LinkState { + if (!py) return "unknown"; + if (py.status === 0) return "active"; + return py.status === 2 ? "lost" : "inactive"; +} + +export function merge( + pyBots: Record, + swNodes: Record, +): UnifiedBot[] { + const ids = new Set([...Object.keys(pyBots), ...Object.keys(swNodes)]); + const out: UnifiedBot[] = []; + for (const id of ids) { + const py = pyBots[id]; + const sw = swNodes[id]; + const state = deriveState(sw); + const link = deriveLink(py); + out.push({ + id, + state, + link, + // swarmit reports (0, 0) for a bot it has never located, and the arena + // never contains the origin, so drawing it would invent a position. + position: + py?.lh2_position ?? + (sw && (sw.pos_x !== 0 || sw.pos_y !== 0) + ? { x: sw.pos_x, y: sw.pos_y } + : null), + heading: + py?.direction !== undefined && py.direction !== -1000 + ? py.direction + : null, + battery: py?.battery ?? (sw ? sw.battery / 1000 : 0), + led: py?.rgb_led ?? null, + deviceType: sw?.device ?? "DotBot", + application: py?.application ?? 0, + // Drivable = a DBP-speaking image is running. The control plane must be + // hearing the bot, and either its sandbox is Running or it has no + // sandbox at all (a bare-mode bot swarmit does not manage). + drivable: link === "active" && (state === null || state === "Running"), + nav: py?.mode === 1 ? "auto" : "drive", + waypoints: py?.waypoints ?? [], + trail: py?.position_history?.slice(-TRAIL_MAX) ?? [], + image: sw?.info?.image_name || null, + resetCause: sw?.reset_cause ?? null, + severity: severityOf(sw), + batteryPct: sw?.battery_pct ?? null, + batteryLevel: sw?.battery_level ?? null, + swarmit: sw ?? null, + }); + } + return out.sort((a, b) => a.id.localeCompare(b.id)); +} + +export function useFleet(): { + bots: UnifiedBot[]; + mapSize: MapSize; + wsUp: boolean; +} { + const pyRef = useRef>({}); + const swRef = useRef>({}); + const [bots, setBots] = useState([]); + const [mapSize, setMapSize] = useState({ width: 2000, height: 2000 }); + const [wsUp, setWsUp] = useState(false); + + const rebuild = useCallback(() => { + setBots(merge(pyRef.current, swRef.current)); + }, []); + + const reloadDotBots = useCallback(async () => { + try { + const list = await fetchDotBots(); + pyRef.current = Object.fromEntries(list.map((b) => [b.address, b])); + rebuild(); + } catch { + /* controller not up yet; ws reconnect loop will retrigger */ + } + }, [rebuild]); + + // Initial data + map size. + useEffect(() => { + reloadDotBots(); + fetchMapSize() + .then(setMapSize) + .catch(() => {}); + }, [reloadDotBots]); + + // Live updates over the controller WebSocket. + useEffect(() => { + let ws: WebSocket | null = null; + let closed = false; + const connect = () => { + ws = new WebSocket(controllerWsUrl()); + ws.onopen = () => { + setWsUp(true); + reloadDotBots(); + }; + ws.onclose = () => { + setWsUp(false); + if (!closed) setTimeout(connect, 1000); + }; + ws.onerror = () => ws?.close(); + ws.onmessage = (ev) => { + let msg: WsNotification; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + if (msg.cmd === 2 && msg.data?.address) { + const bot = pyRef.current[msg.data.address]; + if (!bot) { + reloadDotBots(); + return; + } + const d = msg.data; + if (d.direction !== undefined) bot.direction = d.direction; + if (d.battery !== undefined) bot.battery = d.battery; + if (d.rgb_led !== undefined) bot.rgb_led = d.rgb_led; + if (d.lh2_position !== undefined) { + bot.lh2_position = d.lh2_position; + bot.position_history = [ + ...(bot.position_history ?? []), + d.lh2_position!, + ].slice(-TRAIL_MAX); + } + if (d.position_history !== undefined) + bot.position_history = d.position_history; + if (d.lh2_waypoints !== undefined) bot.waypoints = d.lh2_waypoints; + if (d.waypoints_threshold !== undefined) + bot.waypoints_threshold = d.waypoints_threshold; + rebuild(); + } else { + // RELOAD / NEW_DOTBOT / unknown -> refetch everything. + reloadDotBots(); + } + }; + }; + connect(); + return () => { + closed = true; + ws?.close(); + }; + }, [reloadDotBots, rebuild]); + + // Slow refresh for fields the WS does not push (mode/nav, status, waypoint + // clears): the controller only notifies telemetry deltas, so a bot's + // AUTO -> MANUAL arrival flip is invisible without an occasional refetch. + useEffect(() => { + const t = setInterval(reloadDotBots, 3000); + return () => clearInterval(t); + }, [reloadDotBots]); + + // SwarmIT status poll (read-only orchestration plane), 1 Hz. + useEffect(() => { + const tick = async () => { + try { + swRef.current = await fetchSwarmitStatus(); + } catch { + swRef.current = {}; + } + rebuild(); + }; + tick(); + const t = setInterval(tick, 1000); + return () => clearInterval(t); + }, [rebuild]); + + return { bots, mapSize, wsUp }; +} diff --git a/dotbot/console-web/src/useOrchestration.ts b/dotbot/console-web/src/useOrchestration.ts new file mode 100644 index 00000000..d7c360b5 --- /dev/null +++ b/dotbot/console-web/src/useOrchestration.ts @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { flashStream, swarmitAction, swarmitEventsUrl } from "./api"; +import { FirmwareFile } from "./firmwareFile"; +import { remember } from "./firmwareHistory"; + +export interface LogRow { + key: string; + t: string; // HH:MM:SS + level: "info" | "ok" | "warn" | "err"; + msg: string; +} + +export interface FlashJob { + addr: string; + acked: number; + total: number; + done: boolean; + success?: boolean; +} + +// Orchestration plane: live log feed (swarmit /events SSE) + flash queue +// (driven by /flash/stream chunk events) + the start/stop actions. +// +// No reset: swarmit's /reset takes {locations: {addr: {pos_x, pos_y}}}, one +// per ready device, and the bootloader's handler stores the position but never +// triggers the SoC reset (the line is commented out in the netcore), so there +// is nothing here that could work. +export function useOrchestration(onToast: (msg: string) => void) { + const [logs, setLogs] = useState([]); + const [queue, setQueue] = useState>({}); + const [flashing, setFlashing] = useState(false); + + // Log feed. + useEffect(() => { + let es: EventSource | null = null; + let closed = false; + const connect = () => { + es = new EventSource(swarmitEventsUrl()); + es.onmessage = (e) => { + try { + const ev = JSON.parse(e.data); + if (ev.type !== "log_event") return; + const t = new Date((ev.ts ?? Date.now() / 1000) * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + setLogs((prev) => + [...prev, { key: `${ev.id}`, t, level: ev.level ?? "info", msg: ev.message ?? "" }].slice(-200), + ); + } catch { + /* ignore */ + } + }; + es.onerror = () => { + es?.close(); + if (!closed) setTimeout(connect, 2000); + }; + }; + connect(); + return () => { + closed = true; + es?.close(); + }; + }, []); + + const clearLogs = useCallback(() => setLogs([]), []); + + const act = useCallback( + (action: "start" | "stop", devices?: string[]) => { + const target = devices && devices.length ? `${devices.length} device(s)` : "whole fleet"; + swarmitAction(action, devices) + .then(() => onToast(`${action[0].toUpperCase()}${action.slice(1)} sent · ${target}`)) + .catch(() => onToast(`${action} failed`)); + }, + [onToast], + ); + + const flashingRef = useRef(false); + const flash = useCallback( + (image: FirmwareFile, devices?: string[], startAfter = false) => { + if (flashingRef.current) { + onToast("A flash is already in progress"); + return; + } + flashingRef.current = true; + setFlashing(true); + setQueue({}); + // Recorded at send time, not on success: knowing what was pushed at a bot + // matters most when the flash is what went wrong. + remember(image, Date.now()); + // Only devices whose own device_done said success get started: a partial + // flash must not start the bots it failed on. + const flashed: string[] = []; + flashStream(image.b64, devices, (ev) => { + if (ev.type === "flash_started" && ev.devices) { + setQueue( + Object.fromEntries( + ev.devices.map((a) => [a, { addr: a, acked: 0, total: ev.total_chunks ?? 0, done: false }]), + ), + ); + } else if (ev.type === "chunk" && ev.addr) { + setQueue((q) => ({ + ...q, + [ev.addr!]: { + ...(q[ev.addr!] ?? { addr: ev.addr!, done: false }), + acked: ev.acked ?? 0, + total: ev.total ?? 0, + }, + })); + } else if (ev.type === "device_done" && ev.addr) { + if (ev.success) flashed.push(ev.addr); + setQueue((q) => ({ + ...q, + [ev.addr!]: { + ...(q[ev.addr!] ?? { addr: ev.addr!, acked: 0, total: 0 }), + done: true, + success: ev.success, + }, + })); + } else if (ev.type === "complete") { + onToast(ev.all_success ? "Flash complete" : "Flash finished with failures"); + if (startAfter) { + if (flashed.length) act("start", flashed); + else onToast("Nothing flashed successfully, not starting"); + } + } else if (ev.type === "warning") { + onToast(`Flash warning: ${ev.message ?? "unknown"}`); + } else if (ev.type === "error") { + onToast(`Flash error: ${ev.message ?? "unknown"}`); + } + }, image.name) + .catch((e) => onToast(`Flash stream failed: ${e.message ?? e}`)) + .finally(() => { + flashingRef.current = false; + setFlashing(false); + // Keep the last queue visible briefly, then clear. + setTimeout(() => setQueue({}), 4000); + }); + }, + [act, onToast], + ); + + const jobs = Object.values(queue); + const fleetPct = jobs.length + ? Math.round((jobs.reduce((a, j) => a + j.acked, 0) / Math.max(1, jobs.reduce((a, j) => a + j.total, 0))) * 100) + : 0; + + return { logs, clearLogs, queue, jobs, flashing, fleetPct, act, flash }; +} diff --git a/dotbot/console-web/src/useSmoothPositions.test.ts b/dotbot/console-web/src/useSmoothPositions.test.ts new file mode 100644 index 00000000..492ba7c4 --- /dev/null +++ b/dotbot/console-web/src/useSmoothPositions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_DURATION_MS, + MIN_DURATION_MS, + lerpPos, + nextPosState, + positionAt, +} from "./useSmoothPositions"; + +const MAP_DIAGONAL = Math.hypot(2000, 2000); + +describe("lerpPos", () => { + it("interpolates linearly between two points", () => { + expect(lerpPos({ x: 0, y: 0 }, { x: 100, y: 200 }, 0.5)).toEqual({ x: 50, y: 100 }); + }); +}); + +describe("nextPosState", () => { + it("snaps instantly on first sight of a bot (no prior state)", () => { + const s = nextPosState(undefined, { x: 10, y: 20 }, 1000, MAP_DIAGONAL); + expect(s.duration).toBe(0); + expect(positionAt(s, 1000)).toEqual({ x: 10, y: 20 }); + }); + + it("keeps the previous state when the target has not changed", () => { + const s0 = nextPosState(undefined, { x: 10, y: 20 }, 1000, MAP_DIAGONAL); + const s1 = nextPosState(s0, { x: 10, y: 20 }, 1200, MAP_DIAGONAL); + expect(s1).toBe(s0); + }); + + it("animates from the in-flight interpolated position, not the last target", () => { + // t=0 -> (0,0); update at t=0 sets target (100,0), duration 200ms. + const s0 = nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL); + const s1 = { ...nextPosState(s0, { x: 100, y: 0 }, 0, MAP_DIAGONAL), duration: 200 }; + // halfway through that transition (t=100ms), a new update arrives. + const s2 = nextPosState(s1, { x: 100, y: 50 }, 100, MAP_DIAGONAL); + expect(s2.from).toEqual({ x: 50, y: 0 }); // interpolated, not (100, 0) + expect(s2.to).toEqual({ x: 100, y: 50 }); + }); + + it("uses the observed update interval as the next animation duration, clamped", () => { + const s0 = nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL); + const s1 = nextPosState(s0, { x: 10, y: 0 }, 30, MAP_DIAGONAL); // 30ms gap -> clamped up + expect(s1.duration).toBe(MIN_DURATION_MS); + const s2 = nextPosState(s1, { x: 20, y: 0 }, 30 + 5000, MAP_DIAGONAL); // 5s gap -> clamped down + expect(s2.duration).toBe(MAX_DURATION_MS); + const s3 = nextPosState(s2, { x: 30, y: 0 }, 30 + 5000 + 250, MAP_DIAGONAL); // 250ms gap -> as-is + expect(s3.duration).toBe(250); + }); + + it("treats a large jump as a teleport: instant, no animation", () => { + const s0 = nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL); + const s1 = nextPosState(s0, { x: 1900, y: 1900 }, 100, MAP_DIAGONAL); + expect(s1.duration).toBe(0); + expect(positionAt(s1, 100)).toEqual({ x: 1900, y: 1900 }); + }); +}); + +describe("positionAt", () => { + it("clamps at the target once the duration has elapsed", () => { + const s = nextPosState(nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL), { x: 100, y: 0 }, 0, MAP_DIAGONAL); + const withDuration = { ...s, duration: 200 }; + expect(positionAt(withDuration, 500)).toEqual({ x: 100, y: 0 }); + }); +}); diff --git a/dotbot/console-web/src/useSmoothPositions.ts b/dotbot/console-web/src/useSmoothPositions.ts new file mode 100644 index 00000000..f1c71b04 --- /dev/null +++ b/dotbot/console-web/src/useSmoothPositions.ts @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from "react"; + +import { LH2Position, UnifiedBot } from "./types"; + +// Position updates arrive at whatever rate the source reports them: ~20Hz +// from the simulator, sparser and irregular from real LH2 hardware. A fixed +// CSS transition duration is picked independent of that rate, so it is +// either too long (a fast update interrupts the transition already in +// flight, changing its direction/speed mid-motion) or too short relative to +// the interval between updates (the bot glides for the transition duration, +// then holds still until the next update -- the "saccade" reported on both +// simulated and real bots, on the old frontend too, where there was no +// transition at all and every update snapped instantly). +// +// Using the previous observed update interval as the next animation's +// duration keeps the animation running for roughly the whole gap between +// updates, whatever that gap turns out to be, instead of assuming a rate. +export const MIN_DURATION_MS = 60; +export const MAX_DURATION_MS = 600; + +// A jump bigger than this fraction of the arena diagonal in a single update +// is treated as a teleport (bot re-added, position reset by an operator) +// rather than motion, and rendered instantly instead of animated across the +// whole arena. +export const TELEPORT_FRACTION = 0.35; + +export interface PosState { + from: LH2Position; + to: LH2Position; + t0: number; + duration: number; + lastUpdateAt: number; +} + +export function dist(a: LH2Position, b: LH2Position): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +export function lerpPos(a: LH2Position, b: LH2Position, t: number): LH2Position { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +export function positionAt(state: PosState, now: number): LH2Position { + const t = state.duration > 0 ? Math.min(1, (now - state.t0) / state.duration) : 1; + return lerpPos(state.from, state.to, t); +} + +// Folds one new target position into the previous animation state. `now` +// and `mapDiagonal` are passed in (rather than read from globals) so this +// stays a pure function the hook can be tested through. +export function nextPosState( + prev: PosState | undefined, + target: LH2Position, + now: number, + mapDiagonal: number, +): PosState { + if (!prev) { + return { from: target, to: target, t0: now, duration: 0, lastUpdateAt: now }; + } + if (prev.to.x === target.x && prev.to.y === target.y) return prev; + + const teleport = dist(prev.to, target) > mapDiagonal * TELEPORT_FRACTION; + const interval = now - prev.lastUpdateAt; + const current = positionAt(prev, now); + return { + from: teleport ? target : current, + to: target, + t0: now, + duration: teleport ? 0 : Math.max(MIN_DURATION_MS, Math.min(MAX_DURATION_MS, interval)), + lastUpdateAt: now, + }; +} + +// Smoothed per-bot positions, keyed by bot id. Only bots with a known +// position are present. Re-renders on every animation frame while at least +// one bot is mid-transition; callers read from the returned map instead of +// `bot.position` for the animated glyph, and keep using `bot.position` +// directly for anything that should update instantly (trails, waypoints). +export function useSmoothPositions( + bots: UnifiedBot[], + mapDiagonal: number, +): Map { + const statesRef = useRef>(new Map()); + const [, tick] = useState(0); + + useEffect(() => { + const now = performance.now(); + for (const b of bots) { + if (!b.position) continue; + const prev = statesRef.current.get(b.id); + statesRef.current.set(b.id, nextPosState(prev, b.position, now, mapDiagonal)); + } + }, [bots, mapDiagonal]); + + useEffect(() => { + let raf: number; + const loop = () => { + tick((n) => n + 1); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + const now = performance.now(); + const result = new Map(); + for (const [id, state] of statesRef.current) { + result.set(id, positionAt(state, now)); + } + return result; +} diff --git a/dotbot/console-web/src/viewChrome.tsx b/dotbot/console-web/src/viewChrome.tsx new file mode 100644 index 00000000..b065a73b --- /dev/null +++ b/dotbot/console-web/src/viewChrome.tsx @@ -0,0 +1,280 @@ +import React, { useMemo, useState } from "react"; + +import { BotState, STATE_ORDER, UnifiedBot } from "./types"; + +export const PAGE_SIZE = 50; + +export type SortKey = "id" | "fw" | "image" | "battery" | "state"; + +export interface ViewQuery { + search: string; + stateFilter: BotState | "all"; + sortKey: SortKey; + sortDir: 1 | -1; + page: number; +} + +export function useViewQuery() { + const [q, setQ] = useState({ + search: "", + stateFilter: "all", + sortKey: "id", + sortDir: 1, + page: 0, + }); + return { q, setQ }; +} + +export function applyQuery(bots: UnifiedBot[], q: ViewQuery): { rows: UnifiedBot[]; total: number; pages: number } { + let rows = bots; + if (q.search) { + const s = q.search.toLowerCase(); + rows = rows.filter((b) => b.id.toLowerCase().includes(s)); + } + if (q.stateFilter !== "all") rows = rows.filter((b) => b.state === q.stateFilter); + const dir = q.sortDir; + rows = [...rows].sort((a, b) => { + switch (q.sortKey) { + case "battery": + return dir * (a.battery - b.battery); + case "state": + return dir * stateLabel(a.state).localeCompare(stateLabel(b.state)); + case "fw": + return dir * a.deviceType.localeCompare(b.deviceType); + case "image": + return dir * (a.image ?? "").localeCompare(b.image ?? ""); + default: + return dir * a.id.localeCompare(b.id); + } + }); + const total = rows.length; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const page = Math.min(q.page, pages - 1); + return { rows: rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), total, pages }; +} + +export const FilterBar: React.FC<{ + q: ViewQuery; + setQ: React.Dispatch>; + total: number; +}> = ({ q, setQ, total }) => ( +
+
+ + setQ((p) => ({ ...p, search: e.target.value, page: 0 }))} + placeholder="Search by ID" + style={{ + background: "transparent", + border: "none", + outline: "none", + color: "var(--text)", + fontFamily: "var(--font-mono)", + fontSize: 12, + width: "100%", + }} + /> +
+ +
+ + {total} bot{total === 1 ? "" : "s"} + +
+); + +export const Pagination: React.FC<{ + q: ViewQuery; + setQ: React.Dispatch>; + pages: number; +}> = ({ q, setQ, pages }) => { + const page = Math.min(q.page, pages - 1); + const btn = (enabled: boolean) => + ({ + cursor: enabled ? "pointer" : "default", + opacity: enabled ? 1 : 0.4, + padding: "4px 8px", + borderRadius: 6, + }) as const; + return ( +
+
page > 0 && setQ((p) => ({ ...p, page: page - 1 }))} style={btn(page > 0)}> + ‹ Prev +
+ + {page + 1} / {pages} + +
page < pages - 1 && setQ((p) => ({ ...p, page: page + 1 }))} style={btn(page < pages - 1)}> + Next › +
+
+ ); +}; + +// Shared cell bits. +export const LedDot: React.FC<{ bot: UnifiedBot }> = ({ bot }) => ( + +); + +// Battery. The percentage and the band are computed by swarmit, per robot: +// the v3 pack is a 3.0 V supercapacitor that browns out at 0.6 V, and reading +// it on a naive voltage ratio showed a healthy 2.3 V bot as nearly flat. Those +// numbers are robot facts and a v2 pack differs, so this renders what it is +// told rather than keeping a second scale here. +// +// The fallbacks below only cover a bot swarmit does not know (a control-plane +// only bot) and are deliberately crude - a bar with no band rather than a +// confident wrong number. +/** Pill text for the sandbox axis. A bot swarmit does not manage has none. */ +// Warning mark for a bot whose last reset was not routine. Drawn as an SVG +// triangle rather than the U+26A0 character, which macOS renders as a colour +// emoji and would ignore the tier colour entirely. +// +// Sits over the glyph body rather than beside it: the body colour carries the +// sandbox state, and this has to stay legible when real-scale mode shrinks the +// glyph to a few pixels. +// +// Two tiers, swarmit's: "crashed" is loud because a fault latched, "hung" is +// quiet because a sandbox app's only way to exit is to stop feeding the +// deadman - so a normal completion lands here and must not cry wolf. +export const ResetBadge: React.FC<{ + bot: UnifiedBot; + size?: number; +}> = ({ bot, size = 11 }) => { + if (bot.severity === "normal") return null; + const crashed = bot.severity === "crashed"; + const color = crashed ? "var(--s-Stopping)" : "var(--muted)"; + return ( + + {`Last reset: ${bot.resetCause ?? bot.severity}`} + + + + + ); +}; + +export function stateLabel(state: BotState | null): string { + return state ?? "No sandbox"; +} + +/** Colour for the sandbox axis; muted when there is no sandbox to colour. */ +export function stateColor(state: BotState | null): string { + return state ? `var(--s-${state})` : "var(--muted)"; +} + +export function batteryPct(bot: { batteryPct: number | null; battery: number }): number { + if (bot.batteryPct !== null) return Math.max(0, Math.min(100, bot.batteryPct)); + return Math.max(0, Math.min(100, Math.trunc((bot.battery / 3.0) * 100))); +} + +export function batteryColor(bot: { batteryLevel: string | null }): string { + if (bot.batteryLevel === "full") return "var(--s-Full)"; + if (bot.batteryLevel === "low") return "var(--s-Stopping)"; + if (bot.batteryLevel === "ok") return "var(--s-Running)"; + return "var(--muted)"; // unknown to swarmit: no band to show +} + +export const BatteryCell: React.FC<{ bot: UnifiedBot; width?: number; fill?: boolean }> = ({ + bot, + width = 54, + fill = false, +}) => { + const volts = bot.battery; + const pct = batteryPct(bot); + return ( +
+
+
+
+ {volts.toFixed(2)} V +
+ ); +}; + +export function useQueriedBots(bots: UnifiedBot[], q: ViewQuery) { + return useMemo(() => applyQuery(bots, q), [bots, q]); +} diff --git a/dotbot/console-web/tsconfig.json b/dotbot/console-web/tsconfig.json new file mode 100644 index 00000000..6bfa73af --- /dev/null +++ b/dotbot/console-web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/dotbot/console-web/vite.config.ts b/dotbot/console-web/vite.config.ts new file mode 100644 index 00000000..d53ff6ca --- /dev/null +++ b/dotbot/console-web/vite.config.ts @@ -0,0 +1,36 @@ +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Dev proxies: the console talks same-origin, vite forwards to the two backends. +// /controller -> PyDotBot controller (REST + WS), default :8000 +// /swarmit -> swarmit status server (real or fake), default :8001 +// Override with CONTROLLER_TARGET / SWARMIT_TARGET env vars when the default +// ports are occupied by another controller/swarmit instance. +const controllerTarget = + process.env.CONTROLLER_TARGET ?? "http://localhost:8000"; +const swarmitTarget = process.env.SWARMIT_TARGET ?? "http://localhost:8001"; + +export default defineConfig({ + // Relative asset URLs: the production build is mounted at /console by the + // controller; the dev server stays at /. API paths are absolute either way. + base: "./", + plugins: [react()], + server: { + port: 5173, + proxy: { + "/controller": { + target: controllerTarget, + ws: true, + }, + "/swarmit": { + target: swarmitTarget, + rewrite: (path) => path.replace(/^\/swarmit/, ""), + }, + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/dotbot/controller.py b/dotbot/controller.py index 043862d2..d8927992 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -15,7 +15,6 @@ import queue import time import webbrowser -from binascii import hexlify from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional @@ -30,6 +29,7 @@ from dotbot import ( CONTROLLER_ADAPTER_DEFAULT, + CONTROLLER_HTTP_HOST_DEFAULT, CONTROLLER_HTTP_PORT_DEFAULT, GATEWAY_ADDRESS_DEFAULT, MAP_SIZE_DEFAULT, @@ -39,6 +39,8 @@ SERIAL_BAUDRATE_DEFAULT, SERIAL_PORT_DEFAULT, SIMULATOR_INIT_STATE_DEFAULT, + SWARMIT_URL_DEFAULT, + addr_to_hex, ) from dotbot.adapter import ( DotBotSimulatorAdapter, @@ -69,7 +71,7 @@ PayloadLh2CalibrationHomography, PayloadType, ) -from dotbot.server import api +from dotbot.server import api, default_ui_path # from dotbot.models import ( # DotBotModel, @@ -127,6 +129,7 @@ class ControllerSettings: gw_address: str = GATEWAY_ADDRESS_DEFAULT network_id: str = NETWORK_ID_DEFAULT controller_http_port: int = CONTROLLER_HTTP_PORT_DEFAULT + controller_http_host: str = CONTROLLER_HTTP_HOST_DEFAULT map_size: str = MAP_SIZE_DEFAULT background_map: str = "" headless: bool = False @@ -135,6 +138,7 @@ class ControllerSettings: log_output: str = os.path.join(os.getcwd(), "pydotbot.log") csv_data_output: Optional[str] = None simulator_init_state: str = SIMULATOR_INIT_STATE_DEFAULT + swarmit_url: str = SWARMIT_URL_DEFAULT def lh2_distance(last: DotBotLH2Position, new: DotBotLH2Position) -> float: @@ -267,7 +271,11 @@ async def _open_webbrowser(self): else: writer.close() break - url = f"http://localhost:{self.settings.controller_http_port}/PyDotBot" + ui_path = default_ui_path() + if ui_path is None: + self.logger.warning("No web UI is built, not opening a browser") + return + url = f"http://localhost:{self.settings.controller_http_port}{ui_path}" self.logger.debug("Using frontend URL", url=url) if not self.settings.headless: self.logger.info("Opening webbrowser", url=url) @@ -313,7 +321,7 @@ def handle_received_frame( PayloadType.CMD_RGB_LED, ]: return - source = hexlify(int(frame.header.source).to_bytes(8, "big")).decode() + source = addr_to_hex(int(frame.header.source)) logger = self.logger.bind( source=source, payload_type=PayloadType(frame.packet.payload_type).name, @@ -591,7 +599,7 @@ def send_payload(self, destination: int, payload: Payload): if self.adapter is None: self.logger.warning("Adapter not started") return - dest_str = hexlify(destination.to_bytes(8, "big")).decode() + dest_str = addr_to_hex(destination) if dest_str not in self.dotbots: return self.adapter.send_payload(destination, payload=payload) @@ -664,9 +672,16 @@ def get_dotbots(self, query: DotBotQueryModel) -> List[DotBotModel]: async def web(self): """Starts the web server application.""" logger = LOGGER.bind(context=__name__) + host = self.settings.controller_http_host + if host not in ("127.0.0.1", "localhost", "::1"): + logger.warning( + "Serving the API beyond loopback; it has no authentication, " + "and /swarmit/* reaches the swarmit server from here too", + host=host, + ) config = uvicorn.Config( api, - host="0.0.0.0", + host=host, port=self.settings.controller_http_port, log_level="critical", ) diff --git a/dotbot/controller_app.py b/dotbot/controller_app.py index 117f8377..0ab8ccb3 100644 --- a/dotbot/controller_app.py +++ b/dotbot/controller_app.py @@ -18,10 +18,12 @@ import toml from dotbot import ( + CONTROLLER_HTTP_HOST_DEFAULT, CONTROLLER_HTTP_PORT_DEFAULT, GATEWAY_ADDRESS_DEFAULT, MAP_SIZE_DEFAULT, SIMULATOR_INIT_STATE_DEFAULT, + SWARMIT_URL_DEFAULT, pydotbot_version, ) from dotbot.cli._cfg import from_config @@ -226,6 +228,24 @@ def _maybe_scaffold_sim_state(explicit_init_state): type=click.Path(dir_okay=False), help=f"Path to the simulator initial state .toml file. Defaults to '{SIMULATOR_INIT_STATE_DEFAULT}'.", ) +@click.option( + "--controller-http-host", + type=str, + help=( + "Interface the REST/WS API binds to. Defaults to " + f"'{CONTROLLER_HTTP_HOST_DEFAULT}' (loopback). Use '0.0.0.0' to reach " + "it from another machine - the API is unauthenticated, so only do that " + "on a network you trust." + ), +) +@click.option( + "--swarmit-url", + type=str, + help=( + "Base URL of the swarmit server the controller proxies /swarmit/* " + f"requests to (for the web console). Defaults to '{SWARMIT_URL_DEFAULT}'." + ), +) @click.pass_context def main( ctx, @@ -234,9 +254,11 @@ def main( sim_is_dotbot, gw_address, controller_http_port, + controller_http_host, map_size, background_map, simulator_init_state, + swarmit_url, headless, verbose, log_level, @@ -261,6 +283,7 @@ def main( # legacy `--config-path` fallback that follows. conn = from_config(ctx, "conn", "conn", "run") swarm_id = from_config(ctx, "swarm_id", "swarm_id", "run") + swarmit_url = from_config(ctx, "swarmit_url", "swarmit_url", "run.controller") conn = conn if conn is not None else file_data.get("conn") swarm_id = swarm_id if swarm_id is not None else file_data.get("swarm_id") @@ -293,9 +316,11 @@ def main( cli_args = { "gw_address": gw_address, "controller_http_port": controller_http_port, + "controller_http_host": controller_http_host, "map_size": map_size, "background_map": background_map, "simulator_init_state": simulator_init_state, + "swarmit_url": swarmit_url, "headless": True if headless else None, "verbose": verbose, "log_level": log_level, diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index a9f2f58d..a7224370 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -12,7 +12,6 @@ import random import threading import time -from binascii import hexlify from dataclasses import dataclass from enum import Enum from math import atan2, cos, pi, sin, sqrt @@ -23,7 +22,11 @@ from dotbot_utils.protocol import Frame, Header, Packet from pydantic import BaseModel, Field, model_validator -from dotbot import GATEWAY_ADDRESS_DEFAULT, SIMULATOR_INIT_STATE_DEFAULT +from dotbot import ( + GATEWAY_ADDRESS_DEFAULT, + SIMULATOR_INIT_STATE_DEFAULT, + addr_to_hex, +) from dotbot.logger import LOGGER from dotbot.protocol import ControlModeType, PayloadDotBotAdvertisement, PayloadType @@ -114,7 +117,7 @@ def _fill_mari_pdrs(self): def _random_address() -> str: - return f"{random.getrandbits(64):016x}" + return f"{random.getrandbits(64):016X}" class SimulatedDotBotSettings(BaseModel): @@ -178,7 +181,7 @@ class DotBotSimulator: """Simulator class for the dotbot.""" def __init__(self, settings: SimulatedDotBotSettings, tx_queue: queue.Queue): - self.address = settings.address.lower() + self.address = settings.address.upper() self.pos_x = settings.pos_x self.pos_y = settings.pos_y self.theta = settings.direction * -1 if settings.direction != -1000 else 0 @@ -604,7 +607,7 @@ def rx_frame(self): if frame is None: break with self._lock: - if self.address == hex(frame.header.destination)[2:]: + if self.address == addr_to_hex(int(frame.header.destination)): if frame.payload_type == PayloadType.CMD_MOVE_RAW: self.controller_mode = ControlModeType.MANUAL self.waypoint_index = 0 @@ -828,14 +831,14 @@ def _packet_delivered(self, pdr: int) -> bool: def handle_dotbot_frame(self, frame): """Send bytes to the fake serial, similar to the real gateway.""" - addr = hex(frame.header.source)[2:] + addr = addr_to_hex(int(frame.header.source)) index = self._address_to_index.get(addr, 0) if self._dotbot_modes[index] == SimulatedNetworkMode.MARI: self._mari.schedule_uplink(frame, index) return if not self._packet_delivered(self._network.pdr): self.logger.info( - f"Packet from DotBot {hexlify(int(frame.header.source).to_bytes(8, 'big')).decode()} lost in simulation" + f"Packet from DotBot {addr_to_hex(int(frame.header.source))} lost in simulation" ) return self.on_frame_received(frame) diff --git a/dotbot/joystick.py b/dotbot/joystick.py index cf77ae88..e069f772 100644 --- a/dotbot/joystick.py +++ b/dotbot/joystick.py @@ -47,7 +47,7 @@ def __init__(self, joystick_index, client, dotbot_address, application): """Initialize the joystick controller.""" self.client = client self.dotbots = [] - self.dotbot_address = dotbot_address + self.dotbot_address = dotbot_address.upper() self.application = APPLICATION_TYPE_MAP[application] pygame.init() # pylint: disable=no-member pygame.joystick.init() # joysticks initialization @@ -78,7 +78,9 @@ def selected_dotbot(self): self._logger.info("No active DotBot") return elif _selected_dotbot not in [dotbot["address"] for dotbot in self.dotbots]: - self._logger.info("Active DotBot not available") + self._logger.warning( + "Requested DotBot not available", address=_selected_dotbot + ) return return _selected_dotbot diff --git a/dotbot/keyboard.py b/dotbot/keyboard.py index 428d5956..bf7be82d 100644 --- a/dotbot/keyboard.py +++ b/dotbot/keyboard.py @@ -117,7 +117,7 @@ def __init__(self, client, dotbot_address, application): """Initializes the keyboard controller.""" self.client = client self.dotbots = [] - self.dotbot_address = dotbot_address + self.dotbot_address = dotbot_address.upper() self.application = APPLICATION_TYPE_MAP[application] self.previous_speeds = (0, 0) self.active_keys = [] @@ -135,7 +135,9 @@ def selected_dotbot(self): self._logger.info("No active DotBot") return elif _selected_dotbot not in [dotbot["address"] for dotbot in self.dotbots]: - self._logger.info("Active DotBot not available") + self._logger.warning( + "Requested DotBot not available", address=_selected_dotbot + ) return return _selected_dotbot diff --git a/dotbot/models.py b/dotbot/models.py index 72902653..3399db90 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -88,6 +88,20 @@ class DotBotMapSizeModel(BaseModel): height: int # in mm unit +class DotBotConnectionModel(BaseModel): + """How the controller reaches the swarm, for display in a UI. + + A curated view, not the settings object: the same settings carry + `mqtt_username` / `mqtt_password`, and this is served to any browser that + can reach the controller. + """ + + adapter: str # edge | cloud | dotbot-simulator | sailbot-simulator | serial + connection: str # display form: mqtt(s)://host:port, a device path, or simulator + swarm_id: str # hex network id + gw_address: str + + class DotBotBackgroundMapModel(BaseModel): """Background map model.""" diff --git a/dotbot/sailbot_simulator.py b/dotbot/sailbot_simulator.py index 0e12ef3d..6c36e630 100644 --- a/dotbot/sailbot_simulator.py +++ b/dotbot/sailbot_simulator.py @@ -17,7 +17,7 @@ from dotbot_utils.protocol import Frame, Header, Packet from numpy import clip -from dotbot import GATEWAY_ADDRESS_DEFAULT +from dotbot import GATEWAY_ADDRESS_DEFAULT, addr_to_hex from dotbot.logger import LOGGER from dotbot.protocol import ( ApplicationType, @@ -348,7 +348,7 @@ def decode_serial_input(self, bytes_): return frame = Frame.from_bytes(bytes_) - if self.address == hex(frame.header.destination)[2:]: + if self.address == addr_to_hex(int(frame.header.destination)): if frame.payload_type == PayloadType.CMD_MOVE_RAW: self.rudder_slider = ( frame.packet.payload.left_x - 256 diff --git a/dotbot/server.py b/dotbot/server.py index 0e64a3bd..0271828e 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -14,13 +14,15 @@ FastAPI, HTTPException, Query, + Request, WebSocket, WebSocketDisconnect, ) from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response +from fastapi.responses import Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import TypeAdapter, ValidationError +from starlette.background import BackgroundTask from starlette.middleware.base import BaseHTTPMiddleware from dotbot import pydotbot_version @@ -28,6 +30,7 @@ from dotbot.models import ( MAX_POSITION_HISTORY_SIZE, DotBotBackgroundMapModel, + DotBotConnectionModel, DotBotMapSizeModel, DotBotModel, DotBotMoveRawCommandModel, @@ -291,6 +294,30 @@ async def map_size(): return api.controller.map_size +@api.get( + path="/controller/connection", + response_model=DotBotConnectionModel, + summary="Return how the controller reaches the swarm", + tags=["controller"], +) +async def connection(): + """Connection HTTP GET handler.""" + settings = api.controller.settings + if settings.adapter == "cloud": + scheme = "mqtts" if settings.mqtt_use_tls else "mqtt" + conn = f"{scheme}://{settings.mqtt_host}:{settings.mqtt_port}" + elif settings.adapter in ("dotbot-simulator", "sailbot-simulator"): + conn = "simulator" + else: + conn = settings.port + return DotBotConnectionModel( + adapter=settings.adapter, + connection=conn, + swarm_id=settings.network_id, + gw_address=settings.gw_address, + ) + + @api.get( path="/controller/background_map", response_model=DotBotBackgroundMapModel, @@ -362,6 +389,55 @@ async def ws_dotbots(websocket: WebSocket): LOGGER.debug("WebSocket client disconnected") +# Timeouts for the swarmit proxy: fail fast when the server is down, but +# never time out reads - /events and /flash/stream are long-lived SSE. +SWARMIT_PROXY_TIMEOUT = httpx.Timeout(5.0, read=None) + + +@api.api_route( + path="/swarmit/{path:path}", + methods=["GET", "POST"], + include_in_schema=False, +) +async def swarmit_proxy(path: str, request: Request): + """Forward /swarmit/* to the configured swarmit server (same-origin for + the web console; the streaming body keeps SSE responses live).""" + base = api.controller.settings.swarmit_url.rstrip("/") + client = httpx.AsyncClient(timeout=SWARMIT_PROXY_TIMEOUT) + upstream_request = client.build_request( + method=request.method, + url=f"{base}/{path}", + params=request.query_params, + headers={ + k: v + for k, v in request.headers.items() + if k.lower() in ("content-type", "accept") + }, + content=await request.body(), + ) + try: + upstream = await client.send(upstream_request, stream=True) + except httpx.HTTPError as exc: + await client.aclose() + LOGGER.debug("swarmit server unreachable", url=f"{base}/{path}", error=str(exc)) + return Response(status_code=502, content=b"swarmit server unreachable") + + async def cleanup(): + await upstream.aclose() + await client.aclose() + + return StreamingResponse( + upstream.aiter_raw(), + status_code=upstream.status_code, + headers={ + k: v + for k, v in upstream.headers.items() + if k.lower() in ("content-type", "cache-control") + }, + background=BackgroundTask(cleanup), + ) + + # Mount static files after all routes are defined FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend", "build") if os.path.isdir(FRONTEND_DIR): @@ -375,3 +451,25 @@ async def ws_dotbots(websocket: WebSocket): "frontend: cd dotbot/frontend && npm install && npm run build", FRONTEND_DIR, ) + +# The unified console (map-first PyDotBot + swarmit UI). This is the UI the +# controller opens; the classic frontend stays mounted at /PyDotBot, which is +# where the qrkey demo, the REST demo and the SailBot views live. +CONSOLE_DIR = os.path.join(os.path.dirname(__file__), "console-web", "dist") +if os.path.isdir(CONSOLE_DIR): + api.mount("/console", StaticFiles(directory=CONSOLE_DIR, html=True), name="console") +else: + LOGGER.info( + "Console build not found at %s; /console will be unavailable. " + "Build it with: cd dotbot/console-web && npm install && npm run build", + CONSOLE_DIR, + ) + + +def default_ui_path() -> str | None: + """Path the controller opens on start, or None when no UI is built.""" + if os.path.isdir(CONSOLE_DIR): + return "/console" + if os.path.isdir(FRONTEND_DIR): + return "/PyDotBot" + return None diff --git a/dotbot/tests/conftest.py b/dotbot/tests/conftest.py new file mode 100644 index 00000000..385c49da --- /dev/null +++ b/dotbot/tests/conftest.py @@ -0,0 +1,14 @@ +"""Shared fixtures for the test suite.""" + +import pytest + + +@pytest.fixture(autouse=True) +def never_open_a_browser(monkeypatch): + """Keep the suite from opening real browser windows. + + The controller opens the web UI on start unless `headless` is set, so a + test that drives the full run loop reaches `webbrowser.open` for real and + puts a tab on the developer's screen for every run. + """ + monkeypatch.setattr("webbrowser.open", lambda *args, **kwargs: True) diff --git a/dotbot/tests/test_config.py b/dotbot/tests/test_config.py index b3e88df7..25dc123e 100644 --- a/dotbot/tests/test_config.py +++ b/dotbot/tests/test_config.py @@ -309,3 +309,50 @@ def test_resolve_bad_int_env_raises(): cfg.resolve( "http_port", section="run", environ={"DOTBOT_HTTP_PORT": "x"}, default=8000 ) + + +def test_resolve_nested_section_from_file(): + config = cfg.DotbotConfig( + run=cfg.RunSection( + controller=cfg.ControllerSection(swarmit_url="http://lab:9001") + ) + ) + got = cfg.resolve( + "swarmit_url", + section="run.controller", + config=config, + environ={}, + default="http://localhost:8001", + ) + assert got == "http://lab:9001" + + +def test_resolve_nested_section_env_name(): + got = cfg.resolve( + "swarmit_url", + section="run.controller", + environ={"DOTBOT_RUN_CONTROLLER_SWARMIT_URL": "http://env:9001"}, + default="http://localhost:8001", + ) + assert got == "http://env:9001" + + +def test_resolve_nested_section_shared_env_alias(): + got = cfg.resolve( + "swarmit_url", + section="run.controller", + environ={"DOTBOT_SWARMIT_URL": "http://env:9001"}, + default="http://localhost:8001", + ) + assert got == "http://env:9001" + + +def test_resolve_nested_section_missing_falls_to_default(): + got = cfg.resolve( + "swarmit_url", + section="run.controller", + config=cfg.DotbotConfig(), + environ={}, + default="http://localhost:8001", + ) + assert got == "http://localhost:8001" diff --git a/dotbot/tests/test_controller.py b/dotbot/tests/test_controller.py index 9c0370ab..ba821002 100644 --- a/dotbot/tests/test_controller.py +++ b/dotbot/tests/test_controller.py @@ -9,6 +9,7 @@ from dotbot_utils.protocol import Frame, Header, Packet from dotbot_utils.serial_interface import SerialInterface +from dotbot import addr_to_hex from dotbot.adapter import SerialAdapter from dotbot.controller import Controller, ControllerSettings, gps_distance, lh2_distance from dotbot.models import ( @@ -203,6 +204,7 @@ async def start_simulator(): network_id="0", gw_address="78", controller_http_port=8002, + headless=True, ) controller = Controller(settings) try: @@ -222,6 +224,7 @@ async def start_simulator(): network_id="0", gw_address="78", controller_http_port=8001, + headless=True, ) controller = Controller(settings) try: @@ -261,3 +264,23 @@ def test_lh2_distance(last, new, result): ) def test_gps_distance(last, new, result): assert gps_distance(last, new) == pytest.approx(result) + + +@pytest.mark.parametrize( + "addr,expected", + [ + (0x217B829760EBA3E0, "217B829760EBA3E0"), + (0x0, "0000000000000000"), + (0xFFFFFFFFFFFFFFFF, "FFFFFFFFFFFFFFFF"), + (0xABCDEF, "0000000000ABCDEF"), + ], +) +def test_addr_to_hex_is_uppercase_and_padded(addr, expected): + """Addresses render uppercase: swarmit joins the two planes on this string. + + `binascii.hexlify` returns lowercase, so a plain hexlify here silently + produces a key that never matches the swarm side (nor + DOTBOT_ADDRESS_DEFAULT / GATEWAY_ADDRESS_DEFAULT, both written uppercase). + """ + assert addr_to_hex(addr) == expected + assert addr_to_hex(addr) == addr_to_hex(addr).upper() diff --git a/dotbot/tests/test_controller_app.py b/dotbot/tests/test_controller_app.py index 613a9209..5e2050f8 100644 --- a/dotbot/tests/test_controller_app.py +++ b/dotbot/tests/test_controller_app.py @@ -72,6 +72,51 @@ def test_run_controller_uses_selected_deployment(controller, _asyncio_run, tmp_p assert settings.adapter == "dotbot-simulator" +@pytest.mark.skipif(sys.platform == "win32", reason="Doesn't work on Windows") +@patch("dotbot.controller_app.asyncio.run") +@patch("dotbot.controller_app.Controller") +def test_run_controller_swarmit_url_flag(controller, _asyncio_run): + runner = CliRunner() + result = runner.invoke( + main, ["--conn", "simulator", "--swarmit-url", "http://lab:9001"] + ) + assert result.exit_code == 0, result.output + settings = controller.call_args.args[0] + assert settings.swarmit_url == "http://lab:9001" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Doesn't work on Windows") +@patch("dotbot.controller_app.asyncio.run") +@patch("dotbot.controller_app.Controller") +def test_run_controller_swarmit_url_from_unified_config( + controller, _asyncio_run, tmp_path +): + """`[run.controller] swarmit_url` in dotbot.toml reaches the settings; + without it the built-in default applies.""" + from dotbot.cli.main import cli + + config_file = tmp_path / "dotbot.toml" + config_file.write_text( + """ +conn = "simulator" + +[run.controller] +swarmit_url = "http://lab:9001" +""" + ) + + runner = CliRunner() + result = runner.invoke(cli, ["-c", str(config_file), "run", "controller"]) + assert result.exit_code == 0, result.output + settings = controller.call_args.args[0] + assert settings.swarmit_url == "http://lab:9001" + + result = runner.invoke(main, ["--conn", "simulator"]) + assert result.exit_code == 0, result.output + settings = controller.call_args.args[0] + assert settings.swarmit_url == "http://localhost:8001" + + def test_main_without_conn_errors(): """No `--conn` → a clear error listing the connection forms.""" runner = CliRunner() diff --git a/dotbot/tests/test_dotbot_simulator.py b/dotbot/tests/test_dotbot_simulator.py new file mode 100644 index 00000000..821aac98 --- /dev/null +++ b/dotbot/tests/test_dotbot_simulator.py @@ -0,0 +1,61 @@ +"""Tests for the simulated DotBot's receive path.""" + +import queue + +import pytest +from dotbot_utils.protocol import Frame, Header, Packet + +from dotbot import addr_to_hex +from dotbot.dotbot_simulator import DotBotSimulator, SimulatedDotBotSettings +from dotbot.protocol import PayloadCommandMoveRaw + + +def _bot(address: str) -> DotBotSimulator: + return DotBotSimulator( + SimulatedDotBotSettings(address=address, pos_x=100, pos_y=100), + queue.Queue(), + ) + + +def _move_raw(destination: int) -> Frame: + return Frame( + header=Header(destination=destination, source=0), + packet=Packet().from_payload( + PayloadCommandMoveRaw(left_x=0, left_y=80, right_x=0, right_y=80) + ), + ) + + +def _deliver(bot: DotBotSimulator, frame: Frame) -> None: + """Run one pass of the rx loop over a single frame.""" + bot.queue.put(frame) + bot.queue.put(None) # breaks the loop once the frame is handled + bot.rx_frame() + + +@pytest.mark.parametrize( + "address", + [ + "B0B0F00D33333333", # letters: fails if the two sides disagree on case + "00B0F00D33333333", # leading zero: fails if the address is not padded + "1234567890123456", # digits only: matches under either convention + ], +) +def test_a_command_addressed_to_this_bot_is_applied(address): + bot = _bot(address) + _deliver(bot, _move_raw(int(address, 16))) + assert bot.pwm_left == 80 + assert bot.pwm_right == 80 + + +def test_a_command_for_another_bot_is_ignored(): + bot = _bot("B0B0F00D33333333") + _deliver(bot, _move_raw(0xDEADBEEF22222222)) + assert bot.pwm_left == 0 + assert bot.pwm_right == 0 + + +def test_the_address_rendering_round_trips(): + """The rx path and the index map must render an address the same way.""" + for address in ("B0B0F00D33333333", "00B0F00D33333333", "1234567890123456"): + assert addr_to_hex(int(address, 16)) == address diff --git a/dotbot/tests/test_server.py b/dotbot/tests/test_server.py index b7235681..6fb664ee 100644 --- a/dotbot/tests/test_server.py +++ b/dotbot/tests/test_server.py @@ -6,6 +6,7 @@ from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient +from dotbot.controller import ControllerSettings from dotbot.models import ( DotBotGPSPosition, DotBotLH2Position, @@ -579,6 +580,109 @@ def mock_async_client(*args, **kwargs): assert response.headers["X-Upstream"] == "mock" +class _MockByteStream(httpx.AsyncByteStream): + """Streamable body for MockTransport responses (a plain `content=` body + counts as already consumed, which the streaming proxy rejects).""" + + def __init__(self, chunks): + self._chunks = chunks + + async def __aiter__(self): + for chunk in self._chunks: + yield chunk + + +@pytest.mark.asyncio +async def test_swarmit_proxy_forwards(monkeypatch): + + async def mock_send(request: httpx.Request): + assert request.url == httpx.URL("http://swarmit-host:9001/status") + return httpx.Response( + status_code=200, + stream=_MockByteStream([b'{"response": {}}']), + headers={"Content-Type": "application/json"}, + ) + + transport = httpx.MockTransport(mock_send) + RealAsyncClient = httpx.AsyncClient + + def mock_async_client(*args, **kwargs): + kwargs.pop("transport", None) + return RealAsyncClient(transport=transport, **kwargs) + + import dotbot.server as server_module + + monkeypatch.setattr(server_module.httpx, "AsyncClient", mock_async_client) + api.controller.settings.swarmit_url = "http://swarmit-host:9001" + + client = TestClient(api) + response = client.get("/swarmit/status") + + assert response.status_code == 200 + assert response.content == b'{"response": {}}' + assert response.headers["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_swarmit_proxy_forwards_post_body(monkeypatch): + + async def mock_send(request: httpx.Request): + assert request.url == httpx.URL("http://swarmit-host:9001/start") + assert request.method == "POST" + assert request.content == b'{"devices": []}' + assert request.headers["content-type"] == "application/json" + return httpx.Response( + status_code=200, stream=_MockByteStream([b'{"result": "ok"}']) + ) + + transport = httpx.MockTransport(mock_send) + RealAsyncClient = httpx.AsyncClient + + def mock_async_client(*args, **kwargs): + kwargs.pop("transport", None) + return RealAsyncClient(transport=transport, **kwargs) + + import dotbot.server as server_module + + monkeypatch.setattr(server_module.httpx, "AsyncClient", mock_async_client) + api.controller.settings.swarmit_url = "http://swarmit-host:9001" + + client = TestClient(api) + response = client.post( + "/swarmit/start", + content=b'{"devices": []}', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 200 + assert response.content == b'{"result": "ok"}' + + +@pytest.mark.asyncio +async def test_swarmit_proxy_unreachable(monkeypatch): + + async def mock_send_failed(*args, **kwargs): + raise httpx.ConnectError("connection failed") + + transport = httpx.MockTransport(mock_send_failed) + RealAsyncClient = httpx.AsyncClient + + def mock_async_client(*args, **kwargs): + kwargs.pop("transport", None) + return RealAsyncClient(transport=transport, **kwargs) + + import dotbot.server as server_module + + monkeypatch.setattr(server_module.httpx, "AsyncClient", mock_async_client) + api.controller.settings.swarmit_url = "http://swarmit-host:9001" + + client = TestClient(api) + response = client.get("/swarmit/status") + + assert response.status_code == 502 + assert b"swarmit server unreachable" in response.content + + @pytest.mark.asyncio async def test_reverse_proxy_middleware_connect_error(monkeypatch): @@ -771,3 +875,89 @@ def test_ws_invalid_message_validation_error(): assert isinstance(response["details"], list) api.controller.send_payload.assert_not_called() + + +@pytest.mark.asyncio +async def test_connection_reports_an_mqtt_endpoint(): + api.controller.settings = ControllerSettings( + adapter="cloud", + mqtt_host="argus.example.org", + mqtt_port=8883, + mqtt_use_tls=True, + network_id="A000", + gw_address="0000000000000000", + ) + + result = await client.get("/controller/connection") + + assert result.status_code == 200 + assert result.json() == { + "adapter": "cloud", + "connection": "mqtts://argus.example.org:8883", + "swarm_id": "A000", + "gw_address": "0000000000000000", + } + + +@pytest.mark.asyncio +async def test_connection_never_leaks_the_mqtt_credentials(): + """The route is reachable by any browser that can reach the controller.""" + api.controller.settings = ControllerSettings( + adapter="cloud", + mqtt_host="broker.example.org", + mqtt_username="operator", + mqtt_password="hunter2", + ) + + body = (await client.get("/controller/connection")).text + + assert "operator" not in body + assert "hunter2" not in body + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "adapter,expected", + [ + ("dotbot-simulator", "simulator"), + ("sailbot-simulator", "simulator"), + ("edge", "/dev/ttyACM0"), + ("serial", "/dev/ttyACM0"), + ], +) +async def test_connection_reports_the_non_mqtt_adapters(adapter, expected): + api.controller.settings = ControllerSettings(adapter=adapter, port="/dev/ttyACM0") + + result = await client.get("/controller/connection") + + assert result.json()["connection"] == expected + + +def test_the_controller_opens_the_console_when_it_is_built(tmp_path, monkeypatch): + """The console is the default UI; the classic frontend is the fallback.""" + import dotbot.server as server + + console, classic = tmp_path / "console", tmp_path / "classic" + + monkeypatch.setattr(server, "CONSOLE_DIR", str(console)) + monkeypatch.setattr(server, "FRONTEND_DIR", str(classic)) + assert server.default_ui_path() is None + + classic.mkdir() + assert server.default_ui_path() == "/PyDotBot" + + console.mkdir() + assert server.default_ui_path() == "/console" + + +def test_the_api_binds_loopback_unless_asked_otherwise(): + """The REST/WS API is unauthenticated, so it is not on the LAN by default.""" + from dotbot.controller import ControllerSettings + + default = ControllerSettings(gw_address="78", network_id="0") + assert default.controller_http_host == "127.0.0.1" + + wide = ControllerSettings( + gw_address="78", network_id="0", controller_http_host="0.0.0.0" + ) + assert wide.controller_http_host == "0.0.0.0" diff --git a/pyproject.toml b/pyproject.toml index aa143a45..8ffc8e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,14 @@ include = [ ] exclude = [ "dotbot/frontend/node_modules", + "dotbot/console-web/node_modules", "utils/" ] +# The console build output is gitignored (dist/); artifacts overrides the +# VCS-ignore so the built app ships in the packages. +artifacts = [ + "dotbot/console-web/dist", +] [tool.hatch.metadata] allow-direct-references = true diff --git a/tox.ini b/tox.ini index 193a7eef..1ff7bdd5 100644 --- a/tox.ini +++ b/tox.ini @@ -56,7 +56,9 @@ commands= allowlist_externals= /bin/bash /usr/bin/bash -commands = bash -exc "cd dotbot/frontend && npm run lint" +commands = + bash -exc "cd dotbot/frontend && npm run lint" + bash -exc "cd dotbot/console-web && npm run lint" # pin_code env removed: dotbot/pin_code_ui never existed in the tree — # silent dead config flagged in workspace AGENTS.md. diff --git a/utils/check_wheel_contents.py b/utils/check_wheel_contents.py index f3032472..c1a4d83b 100644 --- a/utils/check_wheel_contents.py +++ b/utils/check_wheel_contents.py @@ -22,6 +22,9 @@ "dotbot/simulator_init_state.toml", # Built React frontend served by the controller's REST app. "dotbot/frontend/build/index.html", + # Built console served by the controller at /console. Its dist/ is + # gitignored, so it only ships through the pyproject `artifacts` override. + "dotbot/console-web/dist/index.html", ) diff --git a/utils/hooks/pydotbot_utils.py b/utils/hooks/pydotbot_utils.py index da126208..d0a97d59 100644 --- a/utils/hooks/pydotbot_utils.py +++ b/utils/hooks/pydotbot_utils.py @@ -23,3 +23,14 @@ def build_frontend(root): print("Building React frontend application...") subprocess.run(shlex.split(NPM_INSTALL_CMD), cwd=frontend_dir, check=True) subprocess.run(shlex.split(NPM_BUILD_CMD), cwd=frontend_dir, check=True) + + +def build_console(root): + """Builds the console web application.""" + console_dir = os.path.join(root, "dotbot", "console-web") + os.makedirs(os.path.join(console_dir, "dist"), exist_ok=True) + + if sys.platform != "win32": + print("Building console web application...") + subprocess.run(shlex.split(NPM_INSTALL_CMD), cwd=console_dir, check=True) + subprocess.run(shlex.split(NPM_BUILD_CMD), cwd=console_dir, check=True) diff --git a/utils/hooks/sdist.py b/utils/hooks/sdist.py index aea0a73a..b0ebfed5 100644 --- a/utils/hooks/sdist.py +++ b/utils/hooks/sdist.py @@ -13,7 +13,7 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface sys.path.append(os.path.dirname(__file__)) -from pydotbot_utils import build_frontend # noqa: E402 +from pydotbot_utils import build_console, build_frontend # noqa: E402 class CustomBuildHook(BuildHookInterface): @@ -24,3 +24,4 @@ def initialize(self, _, __): if os.getenv("SKIP_SDIST_HOOK") is not None: return build_frontend(self.root) + build_console(self.root)