Draft of FleetSuite

This commit is contained in:
Ben Meadors
2026-06-16 14:37:06 -05:00
parent e4f4d1f9e7
commit a4ab067431
48 changed files with 5313 additions and 1942 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "fleetsuite-ui",
"runtimeExecutable": "npm",
"runtimeArgs": [
"--prefix",
"mcp-server/web-ui",
"run",
"dev",
"--",
"--port",
"5199",
"--strictPort"
],
"port": 5199
}
]
}
+41
View File
@@ -10,6 +10,47 @@
"isDefault": true
},
"label": "PlatformIO: Build"
},
{
"label": "FleetSuite",
"detail": "Build the SPA if needed and open the FleetSuite desktop window",
"type": "shell",
"command": "./scripts/fleetsuite.sh",
"options": { "cwd": "${workspaceFolder}/mcp-server" },
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"group": "none"
},
{
"label": "FleetSuite: Dev (HMR)",
"detail": "Run the backend + Vite dev server with hot-reload",
"type": "shell",
"command": "./scripts/fleetsuite.sh --dev",
"options": { "cwd": "${workspaceFolder}/mcp-server" },
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"isBackground": true
},
{
"label": "FleetSuite: Rebuild + Run",
"detail": "Force a fresh SPA build, then open the window",
"type": "shell",
"command": "./scripts/fleetsuite.sh --rebuild",
"options": { "cwd": "${workspaceFolder}/mcp-server" },
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
}
}
]
}
+7
View File
@@ -33,3 +33,10 @@ tests/reproducers/
# UI-tier camera captures + per-test transcripts. Regenerated every run;
# left on disk for human review between runs.
tests/ui_captures/
# Web stack (FleetSuite): node deps + the built SPA Vite emits into the
# package (regenerated via `cd web-ui && npm run build`). The SQLite registry
# lives under .mtlog/ (already ignored above).
web-ui/node_modules/
web-ui/dist/
src/meshtastic_mcp/web/static/
+37 -17
View File
@@ -174,7 +174,7 @@ rather than auto-`sudo`'ing mid-run.
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (web UI tails it) |
| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
## TCP / native-host nodes
@@ -333,31 +333,51 @@ captures just become 1×1 black PNGs.
**Meshtastic debug** section attached on failure with a 200-line firmware
log tail + device-state dump. Open this first on failures.
- `junit.xml` — CI-parseable.
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the TUI.
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the web UI.
- `fwlog.jsonl` — firmware log mirror (`meshtastic.log.line` pubsub → JSONL).
- `flash.log` — tee of all pio / esptool / nrfutil / picotool subprocess
output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`).
### Live TUI
### Web UI (FleetSuite)
A Vue 3 + Tailwind desktop web app replaces the old Textual TUI. It serves a
FastAPI backend (REST + WebSocket) that reuses the library modules and a
SQLite registry of devices and cameras. See [web-ui/README.md](web-ui/README.md).
**One command** (bootstraps the venv, web deps, npm packages, and SPA build on
first run, then launches):
```bash
.venv/bin/meshtastic-mcp-test-tui
.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through
./scripts/fleetsuite.sh # open the native desktop window
./scripts/fleetsuite.sh --browser # serve only → http://127.0.0.1:8765
./scripts/fleetsuite.sh --dev # backend + Vite dev server with hot-reload
./scripts/fleetsuite.sh --rebuild # force a fresh SPA build first
```
Textual-based wrapper over `run-tests.sh` with a live test tree, tier
counters, pytest output pane, firmware-log pane, and a device-status strip.
Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
quit (SIGINT → SIGTERM → SIGKILL escalation).
In **VS Code**: Run Task → **FleetSuite** (or _FleetSuite: Dev (HMR)_). Or do it
by hand:
Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera**
panel. Left side: the latest capture PNG rendered as Unicode half-blocks
(via `rich-pixels`, works in any terminal — no kitty/sixel required).
Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes
— OCR: Nodes 2/2") so you can see every event-injection and its result
as each UI test runs. Requires the `[ui]` extras for image rendering; the
transcript alone works without them.
```bash
.venv/bin/pip install -e '.[web]' # FastAPI + uvicorn + aiosqlite + pywebview
(cd web-ui && npm install && npm run build) # build the SPA into web/static
.venv/bin/meshtastic-mcp-web # opens a native pywebview window
.venv/bin/meshtastic-mcp-web --browser # serve only → http://127.0.0.1:8765
```
Two views: **Fleet** (per-device cards keyed by USB serial that follow a device
across changing ports, with live serial logs, packet/telemetry, test history,
full device control, and an assignable live USB camera feed) and **Test Suite**
(run/stop `run-tests.sh`, live tier counters + test tree, pytest/flash/firmware
log panes, run history). The header shows the firmware branch/SHA/dirty live.
**Native nodes (Docker).** The Fleet view can run Linux-native `meshtasticd`
nodes in Docker (no radio, sim mode) and manage them as TCP devices — run /
stop / restart / remove, live `docker logs`, config/send-text/inject-NodeDB over
`tcp://`. By default it builds the checkout's own image from `Dockerfile`; set
`MESHTASTIC_NATIVE_IMAGE` (e.g. `meshtastic/meshtasticd:latest`) to use a
prebuilt image instead.
For development with HMR, run `scripts/web-dev.sh` (backend + Vite together).
### Slash commands
+13 -13
View File
@@ -17,11 +17,15 @@ test = [
"pytest-timeout>=2.3",
"coverage[toml]>=7",
"pyyaml>=6",
# textual is required by the `meshtastic-mcp-test-tui` script (see
# `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a
# separate `[tui]` extra because v1 expects test operators are the only
# consumers; revisit if install cost pushes back.
"textual>=0.50",
]
# Web stack (FastAPI backend + pywebview desktop window) that replaces the
# Textual TUI. Serves the built Vue SPA in `web/static`. Camera streaming
# reuses the `[ui]` extra (opencv-python-headless).
web = [
"fastapi>=0.110",
"uvicorn[standard]>=0.27",
"aiosqlite>=0.19",
"pywebview>=5.0",
]
# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR
# model alone is ~100 MB and camera hardware is user-supplied.
@@ -32,19 +36,15 @@ ui = [
"numpy>=1.26",
"easyocr>=1.7",
"Pillow>=10.0",
# Renders the latest camera capture as Unicode half-blocks in the TUI
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel
# dependency. Pure Python, tiny.
"rich-pixels>=3.0",
]
ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"]
[project.scripts]
meshtastic-mcp = "meshtastic_mcp.__main__:main"
# Live TUI wrapping run-tests.sh — shells out to the same script the plain
# CLI uses, tails pytest-reportlog for per-test state, and polls the device
# list at startup + post-run (port lock forces it to stay idle during the run).
meshtastic-mcp-test-tui = "meshtastic_mcp.cli.test_tui:main"
# Web stack replacing the Textual TUI: a FastAPI backend (REST + WebSocket)
# that reuses the library modules and serves the Vue SPA, opened in a
# pywebview window (`--browser` to serve only).
meshtastic-mcp-web = "meshtastic_mcp.web.__main__:main"
[build-system]
requires = ["hatchling"]
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Single entrypoint for FleetSuite — the web UI for the Meshtastic test harness.
# Ensures deps, builds the SPA, and launches the app. One command, from anywhere.
#
# ./scripts/fleetsuite.sh # build SPA if needed, open the desktop window
# ./scripts/fleetsuite.sh --browser # serve only → http://127.0.0.1:8765
# ./scripts/fleetsuite.sh --dev # backend + Vite dev server with hot-reload
# ./scripts/fleetsuite.sh --rebuild # force a fresh SPA build first
#
# First run bootstraps the venv + web deps + npm packages automatically.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
DEV=0
BROWSER=0
REBUILD=0
for arg in "$@"; do
case "$arg" in
--dev) DEV=1 ;;
--browser) BROWSER=1 ;;
--rebuild) REBUILD=1 ;;
-h | --help)
sed -n '2,11p' "${BASH_SOURCE[0]}"
exit 0
;;
*)
echo "unknown arg: $arg (try --help)" >&2
exit 2
;;
esac
done
PY="$ROOT/.venv/bin/python"
STATIC="$ROOT/src/meshtastic_mcp/web/static/index.html"
note() { printf '\033[36m[fleetsuite]\033[0m %s\n' "$*"; }
# 1. Python venv + web extra ------------------------------------------------
if [[ ! -x $PY ]]; then
note "creating venv (.venv)…"
python3 -m venv "$ROOT/.venv"
fi
if ! "$PY" -c 'import fastapi, aiosqlite, uvicorn, webview' >/dev/null 2>&1; then
note "installing the [web] extra…"
"$PY" -m pip install --quiet --upgrade pip
"$PY" -m pip install --quiet -e "$ROOT[web]"
fi
# 2. Dev mode: backend + Vite with HMR --------------------------------------
if [[ $DEV == 1 ]]; then
note "dev mode (backend :8765 + Vite HMR)"
exec "$ROOT/scripts/web-dev.sh"
fi
# 3. Frontend deps + production build ----------------------------------------
if ! command -v npm >/dev/null 2>&1; then
echo "npm not found — install Node.js (https://nodejs.org) to build the UI." >&2
exit 1
fi
if [[ ! -d "$ROOT/web-ui/node_modules" ]]; then
note "installing web-ui npm packages…"
(cd "$ROOT/web-ui" && npm install)
fi
if [[ $REBUILD == 1 || ! -f $STATIC ]]; then
note "building the SPA…"
(cd "$ROOT/web-ui" && npm run build)
fi
# 4. Launch ------------------------------------------------------------------
if [[ $BROWSER == 1 ]]; then
note "serving at http://127.0.0.1:8765 (Ctrl-C to stop)"
exec "$ROOT/.venv/bin/meshtastic-mcp-web" --browser
fi
note "opening FleetSuite window…"
exec "$ROOT/.venv/bin/meshtastic-mcp-web"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run the FleetSuite backend (uvicorn, :8765) and the Vite dev server together,
# with HMR. The Vite server proxies /api and /ws to the backend. Ctrl-C stops
# both. For a single-process production run instead, build the SPA once
# (`cd web-ui && npm run build`) and launch `meshtastic-mcp-web`.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
PY="${ROOT}/.venv/bin/python"
if [[ ! -x $PY ]]; then
echo "No venv at .venv — run: python3 -m venv .venv && .venv/bin/pip install -e '.[web,test]'" >&2
exit 1
fi
cleanup() {
trap - INT TERM
[[ -n ${BACK_PID-} ]] && kill "$BACK_PID" 2>/dev/null || true
[[ -n ${UI_PID-} ]] && kill "$UI_PID" 2>/dev/null || true
}
trap cleanup INT TERM EXIT
echo "[web-dev] starting backend on http://127.0.0.1:8765 …"
"$PY" -m uvicorn meshtastic_mcp.web.app:create_app --factory --port 8765 --reload &
BACK_PID=$!
echo "[web-dev] starting Vite dev server …"
(cd web-ui && npm run dev) &
UI_PID=$!
wait
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
"""Phase 2 build-orchestrator logic: queue → build → SHA-keyed cache hit.
Uses an injected fake build function that writes a dummy artifact, so the
queue/cache/dedup logic is exercised without Docker or a real compile.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from meshtastic_mcp.web.db import repo_builds as rb
from meshtastic_mcp.web.db.database import Database
from meshtastic_mcp.web.services import builder
from meshtastic_mcp.web.ws.hub import Hub
def test_build_then_cache_hit(tmp_path, monkeypatch):
monkeypatch.setenv("MESHTASTIC_MCP_ARTIFACT_DIR", str(tmp_path / "artifacts"))
calls = {"n": 0}
def fake_build(env: str, out: Path, log_cb) -> bool:
calls["n"] += 1
out.mkdir(parents=True, exist_ok=True)
(out / f"firmware-{env}-1.0.factory.bin").write_bytes(b"\x00\x01")
(out / f"firmware-{env}-1.0.mt.json").write_text("{}")
log_cb("built")
return True
async def go():
db = Database(path=tmp_path / "registry.db")
await db.connect()
hub = Hub()
hub.bind_loop(asyncio.get_running_loop())
orch = builder.BuildOrchestrator(db, hub, build_fn=fake_build)
# First enqueue → builds.
res = await orch.enqueue(["heltec-v3"], sha="abc123", branch="develop")
assert res and res[0]["status"] == "queued"
await asyncio.gather(*orch._inflight.values())
row = await rb.get(db, "heltec-v3", "abc123")
assert row["status"] == "success", row
assert builder.cached_artifact_dir("abc123", "heltec-v3") is not None
assert calls["n"] == 1
# Second enqueue at the same SHA → cache hit, no rebuild.
res2 = await orch.enqueue(["heltec-v3"], sha="abc123", branch="develop")
assert res2[0].get("cached") is True
assert calls["n"] == 1 # fake_build NOT called again
# A different SHA → builds again.
res3 = await orch.enqueue(["heltec-v3"], sha="def456", branch="develop")
await asyncio.gather(*orch._inflight.values())
assert calls["n"] == 2
assert (await rb.get(db, "heltec-v3", "def456"))["status"] == "success"
await db.close()
asyncio.run(go())
def test_build_failure_recorded(tmp_path, monkeypatch):
monkeypatch.setenv("MESHTASTIC_MCP_ARTIFACT_DIR", str(tmp_path / "artifacts"))
def failing_build(env: str, out: Path, log_cb) -> bool:
log_cb("boom")
return False
async def go():
db = Database(path=tmp_path / "registry.db")
await db.connect()
hub = Hub()
hub.bind_loop(asyncio.get_running_loop())
orch = builder.BuildOrchestrator(db, hub, build_fn=failing_build)
await orch.enqueue(["rak4631"], sha="aaa", branch="develop")
await asyncio.gather(*orch._inflight.values())
row = await rb.get(db, "rak4631", "aaa")
assert row["status"] == "failed"
assert builder.cached_artifact_dir("aaa", "rak4631") is None
await db.close()
asyncio.run(go())
+353
View File
@@ -0,0 +1,353 @@
"""Unit tests for the web stack's SQLite registry + identity reconciliation.
No hardware, no event loop fixtures — each test drives the async helpers via
``asyncio.run`` against a tmpdir-backed Database.
"""
from __future__ import annotations
import asyncio
import pytest
from meshtastic_mcp.web.db import repo_cameras as rc
from meshtastic_mcp.web.db import repo_devices as rd
from meshtastic_mcp.web.db import repo_flash as rf
from meshtastic_mcp.web.db import repo_runs as rr
from meshtastic_mcp.web.db.database import Database
from meshtastic_mcp.web.services import control, identity
def _fresh_db(tmp_path) -> Database:
return Database(path=tmp_path / "registry.db")
def test_port_follow_keeps_one_device_and_persists_friendly_name(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
r1 = await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/tty.usbmodem1111",
vid="0x239a",
pid="0x0029",
role="nrf52",
)
assert r1["_is_new"] and not r1["_port_changed"]
await rd.set_friendly_name(db, "SER1", "rak-bench")
# Same device reappears on a new port.
r2 = await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/tty.usbmodem2222",
vid="0x239a",
pid="0x0029",
role="nrf52",
)
assert r2["_port_changed"] and not r2["_is_new"]
assert r2["current_port"] == "/dev/tty.usbmodem2222"
assert r2["friendly_name"] == "rak-bench" # survived the port change
devs = await rd.list_all(db)
assert len(devs) == 1 # one row, not two
finally:
await db.close()
asyncio.run(go())
def test_offline_marking_keeps_row(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/x",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
newly_offline = await rd.mark_offline_except(db, set())
assert newly_offline == ["SER1"]
row = await rd.get(db, "SER1")
assert row is not None and row["online"] == 0 # row stays, greyed
finally:
await db.close()
asyncio.run(go())
def test_camera_assignment_survives_port_change(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/a",
vid="0x239a",
pid="0x1",
role="nrf52",
)
cid = await rc.add(db, name="cam", device_index="0")
await rc.assign(db, cid, "SER1")
# Device moves ports.
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/b",
vid="0x239a",
pid="0x1",
role="nrf52",
)
cam = await rc.for_device(db, "SER1")
assert cam is not None and cam["device_serial"] == "SER1"
finally:
await db.close()
asyncio.run(go())
def test_role_to_serial_mapping(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="NRF",
current_port="/dev/a",
vid="0x239a",
pid="0x1",
role="nrf52",
)
await rd.upsert_from_discovery(
db,
serial_number="ESP",
current_port="/dev/b",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
nrf = await rd.online_by_role(db, "nrf52")
esp = await rd.online_by_role(db, "esp32s3")
assert nrf["serial_number"] == "NRF"
assert esp["serial_number"] == "ESP"
# mesh-pair nodeid → both roles → both serials resolvable
run_id = await rr.create_run(
db, args=[], seed="s", fw_branch="develop", fw_sha="abc", fw_dirty=False
)
await rr.add_result(
db,
run_id,
nodeid="tests/mesh/test_x.py::test_y[nrf52]",
tier="mesh",
outcome="passed",
duration_s=1.0,
device_serial="NRF",
longrepr=None,
)
hist = await rr.results_for_device(db, "NRF")
assert len(hist) == 1 and hist[0]["fw_sha"] == "abc"
finally:
await db.close()
asyncio.run(go())
def test_identity_helpers():
assert identity.role_for_vid("0x239a") == "nrf52"
assert identity.role_for_vid("0x303A") == "esp32s3" # case-insensitive
assert identity.role_for_vid("0x10c4") == "esp32s3"
assert identity.role_for_vid(None) is None
assert identity.env_for_role("nrf52") == "rak4631"
assert identity.env_for_role("esp32s3") == "heltec-v3"
# Device with a real serial → stable key; blank serial → surrogate.
key, stable = identity.device_key(
{"serial_number": "ABC", "vid": "0x239a", "pid": "1", "port": "/dev/x"}
)
assert key == "ABC" and stable is True
key2, stable2 = identity.device_key(
{"serial_number": None, "vid": "0x10c4", "pid": "0xea60", "port": "/dev/y"}
)
assert key2.startswith("noserial:") and stable2 is False
assert identity.has_stable_id("ABC") and not identity.has_stable_id(key2)
def test_flash_timing_comparison(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/a",
vid="0x239a",
pid="0x1",
role="nrf52",
)
# A slow host rebuild, then a fast direct-artifact flash.
await rf.record(
db,
device_serial="SER1",
env="rak4631",
fw_sha="abc",
from_artifact=False,
duration_s=210.0,
ok=True,
)
await rf.record(
db,
device_serial="SER1",
env="rak4631",
fw_sha="abc",
from_artifact=True,
duration_s=10.0,
ok=True,
)
cmp = await rf.comparison(db, "SER1")
assert cmp["artifact"]["duration_s"] == 10.0
assert cmp["rebuild"]["duration_s"] == 210.0
assert cmp["speedup"] == 21.0
finally:
await db.close()
asyncio.run(go())
def test_env_resolves_from_hw_model_not_just_role(monkeypatch):
"""A Heltec V4 (HELTEC_V4) must resolve to env heltec-v4, NOT the coarse
esp32s3→heltec-v3 role default. Regression for the wrong-variant flash risk
surfaced by real-hardware testing. Stubs the board catalog (no pio needed)."""
import meshtastic_mcp.boards as boards_mod
fake_catalog = [
{"env": "heltec-v3", "hw_model_slug": "HELTEC_V3"},
{"env": "heltec-v4", "hw_model_slug": "HELTEC_V4"},
{"env": "heltec-v4-tft", "hw_model_slug": "HELTEC_V4"}, # variant
{"env": "heltec-wsl-v3", "hw_model_slug": "HELTEC_WSL_V3"},
]
monkeypatch.setattr(boards_mod, "list_boards", lambda *a, **k: fake_catalog)
assert identity.env_for_hw_model("HELTEC_V4") == "heltec-v4" # base, not -tft
assert identity.env_for_hw_model("HELTEC_V3") == "heltec-v3"
assert identity.env_for_hw_model("HELTEC_WSL_V3") == "heltec-wsl-v3"
assert identity.env_for_hw_model("NOT_A_BOARD") is None
assert identity.env_for_hw_model(None) is None
# control.env_for_device prefers the device's resolved env over the role default.
assert (
control.env_for_device({"role": "esp32s3", "env": "heltec-v4"}) == "heltec-v4"
)
# No resolved env → falls back to the role default.
assert control.env_for_device({"role": "esp32s3", "env": None}) == "heltec-v3"
def test_suite_env_overrides_from_connected_boards(tmp_path):
"""The test runner bakes the variant resolved per connected board: an online
Heltec V4 → MESHTASTIC_MCP_ENV_ESP32S3=heltec-v4 (not the heltec-v3 default).
Native (TCP) nodes and un-enriched devices are excluded."""
from meshtastic_mcp.web.services.test_runner import resolve_env_overrides
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
# esp32s3 V4 (enriched), an nrf52 (rak4631), a stale esp32 (no env),
# and a native node — only the first two should produce overrides.
await rd.upsert_from_discovery(
db,
serial_number="V4",
current_port="/dev/a",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
await rd.update_enrichment(db, "V4", node_num=1, env="heltec-v4")
await rd.upsert_from_discovery(
db,
serial_number="NRF",
current_port="/dev/b",
vid="0x239a",
pid="0x1",
role="nrf52",
)
await rd.update_enrichment(db, "NRF", node_num=2, env="rak4631")
await rd.upsert_from_discovery(
db,
serial_number="UNENRICHED",
current_port="/dev/c",
vid="0x303a",
pid="0x1",
role="esp32s3",
) # no env yet → excluded
await rd.upsert_native(db, name="sim", tcp_port=4403, online=True)
rows = await rd.online_with_env(db)
overrides = resolve_env_overrides(rows)
assert overrides["MESHTASTIC_MCP_ENV_ESP32S3"] == "heltec-v4"
assert overrides["MESHTASTIC_MCP_ENV_NRF52"] == "rak4631"
# native nodes never become a flash/bake target
assert not any("native" in v.lower() for v in overrides.values())
finally:
await db.close()
asyncio.run(go())
def test_manual_env_override_survives_enrichment(tmp_path):
"""A user-pinned env must not be clobbered by auto-enrichment; releasing the
pin lets hw_model resolution take over again."""
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="D",
current_port="/dev/a",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
# Auto enrichment resolves (wrongly, say) to heltec-v3.
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
assert (await rd.get(db, "D"))["env"] == "heltec-v3"
# User pins the correct env.
await rd.set_env(db, "D", "heltec-v4", locked=True)
row = await rd.get(db, "D")
assert row["env"] == "heltec-v4" and row["env_locked"] == 1
# A later auto-enrichment must NOT overwrite the pinned env.
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
assert (await rd.get(db, "D"))["env"] == "heltec-v4"
# Releasing the pin lets auto-detect win again.
await rd.set_env(db, "D", None, locked=False)
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
assert (await rd.get(db, "D"))["env"] == "heltec-v3"
finally:
await db.close()
asyncio.run(go())
def test_control_rejected_while_run_active(monkeypatch):
# The central safety property: no connect()-based action while a run holds
# the ports. Simulate an active run and assert every gate raises.
monkeypatch.setattr(
"meshtastic_mcp.web.services.test_runner.is_running", lambda: True
)
with pytest.raises(control.ControlBusy):
control._ensure_idle()
with pytest.raises(control.ControlBusy):
control._ensure_port_free("/dev/whatever")
+48
View File
@@ -0,0 +1,48 @@
# FleetSuite — web UI
Vue 3 + Vite + Tailwind v4 + Pinia single-page app for the Meshtastic test
harness. Replaces the old Textual TUI. It talks to the FastAPI backend in
`../src/meshtastic_mcp/web` over REST + a single `/ws` WebSocket.
## Develop
```bash
# from mcp-server/: runs the backend + this dev server together (HMR)
./scripts/web-dev.sh
```
or manually:
```bash
# terminal 1 — backend
cd mcp-server && .venv/bin/python -m uvicorn meshtastic_mcp.web.app:create_app \
--factory --port 8765 --reload
# terminal 2 — frontend (proxies /api + /ws → :8765)
cd mcp-server/web-ui && npm install && npm run dev
```
## Build (production)
```bash
npm run build # emits into ../src/meshtastic_mcp/web/static (gitignored)
```
Then `meshtastic-mcp-web` serves that build and the API on one port and opens a
pywebview window (`--browser` to serve only).
## Layout
- `stores/` — Pinia stores. `ws.ts` owns the single WebSocket and dispatches
topic-tagged frames; `devices` / `cameras` / `firmware` / `tests` hydrate via
REST and apply live deltas.
- `components/``DeviceCard` (keyed by serial → follows the device across
ports), `CameraFeed` (MJPEG `<img>`), `TestDashboard` (counters/tree/logs),
etc.
- `api/client.ts` — thin REST wrappers (relative URLs; same build works behind
pywebview and the dev proxy).
## macOS camera permission
`cv2.VideoCapture` inherits the launching process's TCC Camera grant. Launched
from a terminal, the first stream prompts for permission. A packaged `.app`
would need `NSCameraUsageDescription`.
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meshtastic FleetSuite</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2258
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "meshtastic-fleetsuite",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.2.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
}
}
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import AppBar from "./components/AppBar.vue";
import DeviceGrid from "./components/DeviceGrid.vue";
import TestDashboard from "./components/TestDashboard.vue";
import { useBuildsStore } from "./stores/builds";
import { useCamerasStore } from "./stores/cameras";
import { useDevicesStore } from "./stores/devices";
import { useFirmwareStore } from "./stores/firmware";
import { useTestsStore } from "./stores/tests";
import { useWsStore } from "./stores/ws";
const tab = ref("fleet");
const ws = useWsStore();
const devices = useDevicesStore();
const cameras = useCamerasStore();
const firmware = useFirmwareStore();
const tests = useTestsStore();
const builds = useBuildsStore();
onMounted(() => {
ws.connect();
devices.init();
cameras.init();
firmware.init();
tests.init();
builds.init();
});
</script>
<template>
<div class="min-h-screen">
<AppBar :tab="tab" @update:tab="(v) => (tab = v)" />
<main>
<DeviceGrid v-show="tab === 'fleet'" />
<TestDashboard v-if="tab === 'tests'" />
</main>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
// Minimal ANSI SGR → HTML converter for log panes. Firmware debug output and
// pytest both emit color codes (e.g. \x1b[34m for DEBUG). We render the common
// foreground colors + bold and DROP every other escape sequence. Text content
// is HTML-escaped first, so the only markup that reaches the DOM is our own
// <span> tags — safe to use with v-html.
const CSI = /\x1b\[([0-9;]*)([A-Za-z])/g;
// Tuned for a dark background (pure black/white pushed toward slate).
const FG: Record<number, string> = {
30: "#64748b",
31: "#f87171",
32: "#34d399",
33: "#fbbf24",
34: "#60a5fa",
35: "#c084fc",
36: "#22d3ee",
37: "#e5e7eb",
90: "#94a3b8",
91: "#fca5a5",
92: "#6ee7b7",
93: "#fde68a",
94: "#93c5fd",
95: "#d8b4fe",
96: "#67e8f9",
97: "#f8fafc",
};
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function span(text: string, color: string | null, bold: boolean): string {
const styles: string[] = [];
if (color) styles.push(`color:${color}`);
if (bold) styles.push("font-weight:600");
const attr = styles.length ? ` style="${styles.join(";")}"` : "";
return `<span${attr}>${escapeHtml(text)}</span>`;
}
const _cache = new Map<string, string>();
export function ansiToHtml(line: string): string {
const hit = _cache.get(line);
if (hit !== undefined) return hit;
let out = "";
let idx = 0;
let color: string | null = null;
let bold = false;
CSI.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = CSI.exec(line)) !== null) {
if (m.index > idx) out += span(line.slice(idx, m.index), color, bold);
idx = CSI.lastIndex;
if (m[2] === "m") {
const codes = m[1] === "" ? [0] : m[1].split(";").map((c) => Number(c));
for (const c of codes) {
if (c === 0) {
color = null;
bold = false;
} else if (c === 1) bold = true;
else if (c === 22) bold = false;
else if (c === 39) color = null;
else if (FG[c] !== undefined) color = FG[c];
}
}
// Non-SGR sequences (cursor moves, clear-line, …) are dropped.
}
if (idx < line.length) out += span(line.slice(idx), color, bold);
// Bound the cache so a long-running session doesn't grow unbounded.
if (_cache.size > 6000) _cache.clear();
_cache.set(line, out);
return out;
}
+29
View File
@@ -0,0 +1,29 @@
// Thin REST wrappers over the FastAPI backend. Relative URLs so the same build
// works behind the pywebview window and the Vite dev proxy.
async function req<T>(method: string, url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: body !== undefined ? { "content-type": "application/json" } : {},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let detail = res.statusText;
try {
detail = (await res.json()).detail ?? detail;
} catch {
/* ignore */
}
throw new Error(`${res.status}: ${detail}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
get: <T>(url: string) => req<T>("GET", url),
post: <T>(url: string, body?: unknown) => req<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => req<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => req<T>("PATCH", url, body ?? {}),
del: <T>(url: string) => req<T>("DELETE", url),
};
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { useWsStore } from "../stores/ws";
import { useDevicesStore } from "../stores/devices";
import FirmwareRef from "./FirmwareRef.vue";
defineProps<{ tab: string }>();
const emit = defineEmits<{ (e: "update:tab", v: string): void }>();
const ws = useWsStore();
const devices = useDevicesStore();
</script>
<template>
<header
class="flex items-center gap-4 px-5 py-3 border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-10"
>
<div class="flex items-center gap-2">
<span class="text-lg font-bold tracking-tight text-slate-100"
>Meshtastic</span
>
<span class="text-lg font-light text-emerald-400">FleetSuite</span>
</div>
<nav class="flex gap-1 ml-4">
<button
v-for="t in ['fleet', 'tests']"
:key="t"
@click="emit('update:tab', t)"
class="px-3 py-1.5 rounded-md text-sm capitalize transition"
:class="
tab === t
? 'bg-emerald-600/20 text-emerald-300 border border-emerald-700'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'
"
>
{{ t === "tests" ? "Test Suite" : "Fleet" }}
</button>
</nav>
<div class="flex-1" />
<span class="text-xs text-slate-500"
>{{ devices.list.filter((d) => d.online).length }} online</span
>
<FirmwareRef />
<span
class="flex items-center gap-1.5 text-xs"
:class="ws.connected ? 'text-emerald-400' : 'text-rose-400'"
>
<span
class="w-2 h-2 rounded-full"
:class="ws.connected ? 'bg-emerald-400' : 'bg-rose-400'"
/>
{{ ws.connected ? "live" : "offline" }}
</span>
</header>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useBuildsStore } from "../stores/builds";
import { useFirmwareStore } from "../stores/firmware";
const builds = useBuildsStore();
const fw = useFirmwareStore();
const STATUS: Record<string, { glyph: string; cls: string }> = {
queued: { glyph: "…", cls: "text-slate-400" },
building: { glyph: "⏳", cls: "text-amber-400 animate-pulse" },
success: { glyph: "✓", cls: "text-emerald-400" },
cached: { glyph: "✓", cls: "text-emerald-400/70" },
failed: { glyph: "✗", cls: "text-rose-400" },
cancelled: { glyph: "∅", cls: "text-slate-500" },
};
</script>
<template>
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-4">
<div class="flex items-center gap-3 mb-3">
<h3 class="text-sm font-semibold text-slate-200">Build Queue</h3>
<span
v-if="!builds.dockerAvailable"
class="text-[11px] px-2 py-0.5 rounded bg-amber-950/40 text-amber-400"
title="Docker not detected — builds fall back to host pio (not parallelized)"
>Docker unavailable host builds</span
>
<div class="flex-1" />
<button
@click="builds.prebuildTracked()"
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50"
:title="'prebuild connected device targets @ ' + (fw.ref.short_sha || '')"
>
prebuild current ref
</button>
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="b in builds.list"
:key="b.id"
class="flex items-center gap-2 text-xs rounded-lg border border-slate-800 px-2.5 py-1.5"
:title="b.error || b.artifact_dir || ''"
>
<span :class="(STATUS[b.status] || STATUS.queued).cls">{{
(STATUS[b.status] || STATUS.queued).glyph
}}</span>
<span class="text-slate-200">{{ b.env }}</span>
<span class="mono text-emerald-300/60">{{ b.fw_sha?.slice(0, 7) }}</span>
<span v-if="b.duration_s" class="text-slate-500"
>{{ b.duration_s.toFixed(0) }}s</span
>
<span v-if="b.cached" class="text-slate-600">cached</span>
</div>
<div v-if="builds.list.length === 0" class="text-xs text-slate-600">
no builds yet "prebuild current ref" builds each connected target in
parallel (Docker) in the background
</div>
</div>
</div>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../api/client";
import type { Camera } from "../types";
const props = defineProps<{ camera?: Camera }>();
const errored = ref(false);
const statusMsg = ref<string | null>(null);
// Cache-bust so reassign / remount restarts the stream.
const nonce = ref(Date.now());
const src = computed(() =>
props.camera
? `/api/cameras/${props.camera.id}/stream.mjpg?t=${nonce.value}`
: "",
);
watch(
() => props.camera?.id,
() => {
errored.value = false;
statusMsg.value = null;
nonce.value = Date.now();
},
);
async function onError() {
errored.value = true;
if (!props.camera) return;
try {
const s = await api.get<{ ok: boolean; error: string | null }>(
`/api/cameras/${props.camera.id}/status`,
);
statusMsg.value = s.ok ? "stream interrupted" : s.error;
} catch {
statusMsg.value = "camera unavailable";
}
}
function retry() {
errored.value = false;
statusMsg.value = null;
nonce.value = Date.now();
}
</script>
<template>
<div
class="relative aspect-video w-full bg-black rounded-md overflow-hidden border border-slate-800"
>
<template v-if="camera && !errored">
<img
:src="src"
class="w-full h-full object-contain"
@error="onError"
alt="camera feed"
/>
<span
class="absolute top-1 left-1 text-[10px] px-1.5 py-0.5 rounded bg-black/60 text-emerald-300"
> {{ camera.name }}</span
>
</template>
<div
v-else-if="camera && errored"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center px-3"
>
<span class="text-rose-400 text-sm"> no signal</span>
<span class="text-xs text-slate-500">{{
statusMsg || "camera produced no frames"
}}</span>
<button
@click="retry"
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
>
retry
</button>
</div>
<div
v-else
class="absolute inset-0 flex items-center justify-center text-xs text-slate-600"
>
no camera assigned
</div>
</div>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref } from "vue";
import { useCamerasStore } from "../stores/cameras";
const cameras = useCamerasStore();
const name = ref("");
const index = ref("0");
const adding = ref(false);
async function add() {
if (!name.value.trim()) return;
adding.value = true;
try {
await cameras.add(name.value, index.value);
name.value = "";
index.value = "0";
} finally {
adding.value = false;
}
}
</script>
<template>
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-4">
<h3 class="text-sm font-semibold text-slate-200 mb-3">USB Cameras</h3>
<div class="flex flex-wrap gap-2 mb-3">
<input
v-model="name"
placeholder="name"
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<input
v-model="index"
placeholder="device index (0)"
class="text-xs w-32 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<button
@click="add"
:disabled="adding"
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
>
add camera
</button>
</div>
<ul class="space-y-1">
<li
v-for="c in cameras.list"
:key="c.id"
class="flex items-center gap-2 text-xs text-slate-400"
>
<span class="text-slate-200">{{ c.name }}</span>
<span class="mono">idx {{ c.device_index }}</span>
<span v-if="c.device_serial" class="text-emerald-400/70"
> {{ c.device_serial }}</span
>
<span v-else class="text-slate-600">unassigned</span>
<button
@click="cameras.remove(c.id)"
class="ml-auto text-rose-400/70 hover:text-rose-300"
>
remove
</button>
</li>
<li v-if="cameras.list.length === 0" class="text-xs text-slate-600">
no cameras yet add one by its OpenCV device index
</li>
</ul>
</div>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useCamerasStore } from "../stores/cameras";
import { useDevicesStore } from "../stores/devices";
import { useFirmwareStore } from "../stores/firmware";
import type { Device } from "../types";
import CameraFeed from "./CameraFeed.vue";
import DeviceControls from "./DeviceControls.vue";
import DeviceSettings from "./DeviceSettings.vue";
import PacketPane from "./PacketPane.vue";
import SerialLogPane from "./SerialLogPane.vue";
import TestResultsPane from "./TestResultsPane.vue";
const props = defineProps<{ device: Device }>();
const devices = useDevicesStore();
const cameras = useCamerasStore();
const fw = useFirmwareStore();
const tab = ref<"serial" | "packets" | "results">("serial");
const editing = ref(false);
const nameDraft = ref("");
const showSettings = ref(false);
const assignedCamera = computed(() =>
cameras.forDevice(props.device.serial_number),
);
const flashedDrift = computed(
() =>
props.device.flashed_fw_sha &&
fw.ref.sha &&
props.device.flashed_fw_sha !== fw.ref.sha,
);
function startEdit() {
nameDraft.value = props.device.friendly_name || "";
editing.value = true;
}
async function saveName() {
editing.value = false;
await devices.setFriendlyName(props.device.serial_number, nameDraft.value);
}
async function onAssign(e: Event) {
const val = (e.target as HTMLSelectElement).value;
// find camera currently assigned and reassign; here we assign the picked
// camera id to this device (or unassign all when "")
const id = val ? Number(val) : null;
// Unassign whatever is currently on this device first if changing.
const current = assignedCamera.value;
if (current && current.id !== id) await cameras.assign(current.id, null);
if (id != null) await cameras.assign(id, props.device.serial_number);
}
</script>
<template>
<div
class="rounded-xl border bg-slate-900/60 p-4 flex flex-col gap-3"
:class="device.online ? 'border-slate-700' : 'border-slate-800 opacity-60'"
>
<!-- header -->
<div class="flex items-start gap-2">
<span
class="mt-1 w-2.5 h-2.5 rounded-full shrink-0"
:class="device.online ? 'bg-emerald-400' : 'bg-slate-600'"
:title="device.online ? 'online' : 'offline'"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<template v-if="editing">
<input
v-model="nameDraft"
@keyup.enter="saveName"
@blur="saveName"
autofocus
class="text-sm bg-slate-800 border border-slate-600 rounded px-1.5 py-0.5 outline-none"
/>
</template>
<template v-else>
<span
class="font-semibold text-slate-100 truncate cursor-pointer hover:text-emerald-300"
@click="startEdit"
:title="'click to rename · ' + device.serial_number"
>
{{ device.friendly_name || device.serial_number }}
</span>
</template>
<span
v-if="device.role"
class="text-[10px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-400 uppercase"
>{{ device.role }}</span
>
<span
v-if="!device.has_stable_id"
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-950/50 text-amber-400"
title="no stable USB serial — won't follow across port changes"
>no-serial</span
>
</div>
<div class="text-xs text-slate-500 mono truncate">
{{ device.current_port || "—" }}
<span v-if="device.node_num" class="text-slate-600"
>· !{{ device.node_num.toString(16) }}</span
>
<span v-if="device.stale" class="text-amber-500/70">· stale</span>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
@click="showSettings = !showSettings"
class="p-1 rounded transition hover:bg-slate-800"
:class="
showSettings ? 'text-emerald-300' : 'text-slate-500 hover:text-slate-300'
"
title="device settings (env override, rename, node config)"
>
<svg
viewBox="0 0 24 24"
class="w-4 h-4"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button
@click="devices.refresh(device.serial_number)"
class="p-1 rounded text-slate-500 hover:text-slate-300 transition hover:bg-slate-800"
title="refresh device info"
>
<svg
viewBox="0 0 24 24"
class="w-4 h-4"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
/>
</svg>
</button>
</div>
</div>
<!-- settings panel (cog) -->
<DeviceSettings v-if="showSettings" :device="device" />
<!-- flashed firmware ref -->
<div class="text-[11px] flex items-center gap-1.5">
<span class="text-slate-500">firmware:</span>
<template v-if="device.flashed_fw_sha">
<span class="mono text-emerald-300/80">{{
device.flashed_fw_sha.slice(0, 7)
}}</span>
<span
v-if="flashedDrift"
class="text-amber-400"
title="device firmware differs from the current checkout"
> behind current ref</span
>
</template>
<span v-else class="text-slate-600">unknown (not flashed via FleetSuite)</span>
</div>
<!-- camera -->
<CameraFeed :camera="assignedCamera" />
<select
:value="assignedCamera?.id ?? ''"
@change="onAssign"
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none"
>
<option value=""> no camera </option>
<option v-for="c in cameras.list" :key="c.id" :value="c.id">
{{ c.name }} (idx {{ c.device_index }})
</option>
</select>
<!-- tabs -->
<div class="flex gap-1 text-xs border-b border-slate-800">
<button
v-for="t in ['serial', 'packets', 'results']"
:key="t"
@click="tab = t as any"
class="px-2 py-1 capitalize -mb-px border-b-2 transition"
:class="
tab === t
? 'border-emerald-500 text-emerald-300'
: 'border-transparent text-slate-500 hover:text-slate-300'
"
>
{{ t }}
</button>
</div>
<SerialLogPane v-if="tab === 'serial'" :serial="device.serial_number" />
<PacketPane v-else-if="tab === 'packets'" :serial="device.serial_number" />
<TestResultsPane v-else :serial="device.serial_number" />
<DeviceControls :device="device" />
</div>
</template>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { useBuildsStore } from "../stores/builds";
import { useFirmwareStore } from "../stores/firmware";
import { useTestsStore } from "../stores/tests";
import type { Device } from "../types";
const props = defineProps<{ device: Device }>();
const tests = useTestsStore();
const builds = useBuildsStore();
const fw = useFirmwareStore();
const nodedbSize = ref(500);
const flashStats = ref<any>(null);
const isNative = computed(() => props.device.role === "native");
const nativeName = computed(() => props.device.serial_number.split(":")[1] ?? "");
async function loadFlashStats() {
if (isNative.value) return; // native nodes aren't flashed
try {
flashStats.value = await api.get(
`/api/devices/${props.device.serial_number}/flash-stats`,
);
} catch {
/* ignore */
}
}
onMounted(loadFlashStats);
// role → default pio env (mirrors the backend identity map).
const ROLE_ENV: Record<string, string> = {
nrf52: "rak4631",
esp32s3: "heltec-v3",
};
// Is there a prebuilt artifact for this device's target at the current ref?
// Prefer the env resolved from hw_model; fall back to the coarse role default.
const flashReady = computed(() => {
const env =
props.device.env ||
(props.device.role ? ROLE_ENV[props.device.role] : undefined);
if (!env) return false;
const b = builds.statusFor(env, fw.ref.sha);
return b?.status === "success" || b?.status === "cached";
});
const busy = ref(false);
const msg = ref<string | null>(null);
const ok = ref(true);
const sendText = ref("");
async function run(label: string, fn: () => Promise<any>) {
busy.value = true;
msg.value = `${label}`;
ok.value = true;
try {
await fn();
msg.value = `${label}`;
ok.value = true;
} catch (e: any) {
msg.value = `${label}: ${e.message}`;
ok.value = false;
} finally {
busy.value = false;
}
}
const base = () => `/api/devices/${props.device.serial_number}`;
const flash = () =>
run("flash", () => api.post(`${base()}/flash`, {})).then(loadFlashStats);
const injectNodeDb = () =>
run(`inject ${nodedbSize.value}-node db`, () =>
api.post(`${base()}/inject-nodedb`, { size: nodedbSize.value }),
);
const reboot = () => run("reboot", () => api.post(`${base()}/reboot`, {}));
const factory = () => {
if (confirm(`Factory-reset ${props.device.friendly_name || props.device.serial_number}?`))
run("factory-reset", () => api.post(`${base()}/factory-reset`, {}));
};
const getConfig = () =>
run("get-config", async () => {
const c = await api.get(`${base()}/config?section=lora`);
msg.value = JSON.stringify(c).slice(0, 120);
});
// Native (Docker meshtasticd) container lifecycle.
const nativeBase = () => `/api/native/${nativeName.value}`;
const startNode = () => run("start", () => api.post(`${nativeBase()}/start`));
const stopNode = () => run("stop", () => api.post(`${nativeBase()}/stop`));
const restartNode = () => run("restart", () => api.post(`${nativeBase()}/restart`));
const actions = computed(() =>
isNative.value
? [
{ label: "Start", fn: startNode },
{ label: "Stop", fn: stopNode },
{ label: "Restart", fn: restartNode },
{ label: "Config", fn: getConfig },
]
: [
{ label: "Flash", fn: flash },
{ label: "Reboot", fn: reboot },
{ label: "Config", fn: getConfig },
{ label: "Factory Reset", fn: factory, danger: true },
],
);
const doSend = () => {
if (!sendText.value.trim()) return;
const text = sendText.value;
run("send-text", () => api.post(`${base()}/send-text`, { text })).then(
() => (sendText.value = ""),
);
};
</script>
<template>
<div class="space-y-2">
<div
v-if="tests.running"
class="text-[11px] text-amber-400/80 bg-amber-950/30 rounded px-2 py-1"
>
device control disabled test run in progress
</div>
<div class="flex flex-wrap gap-1.5">
<button
v-for="b in actions"
:key="b.label"
:disabled="busy || tests.running"
@click="b.fn"
class="text-xs px-2 py-1 rounded border transition disabled:opacity-40 disabled:cursor-not-allowed"
:title="
b.label === 'Flash' && flashReady
? 'prebuilt artifact ready for ' + (fw.ref.short_sha || 'current ref')
: ''
"
:class="[
b.danger
? 'border-rose-800 text-rose-300 hover:bg-rose-950/40'
: 'border-slate-700 text-slate-300 hover:bg-slate-800',
b.label === 'Flash' && flashReady ? 'border-emerald-700 text-emerald-300' : '',
]"
>
{{ b.label === "Flash" && flashReady ? "Flash ✓" : b.label }}
</button>
</div>
<div class="flex gap-1.5">
<input
v-model="sendText"
@keyup.enter="doSend"
:disabled="tests.running"
placeholder="send text…"
class="flex-1 text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700 disabled:opacity-40"
/>
<button
@click="doSend"
:disabled="busy || tests.running"
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
send
</button>
</div>
<!-- inject fake NodeDB -->
<div class="flex gap-1.5 items-center">
<span class="text-[11px] text-slate-500">inject NodeDB</span>
<select
v-model.number="nodedbSize"
:disabled="tests.running"
class="text-xs bg-slate-900 border border-slate-700 rounded px-1.5 py-1 outline-none disabled:opacity-40"
>
<option :value="250">250</option>
<option :value="500">500</option>
<option :value="1000">1000</option>
<option :value="2000">2000</option>
</select>
<span class="text-[11px] text-slate-600">nodes</span>
<button
@click="injectNodeDb"
:disabled="busy || tests.running"
class="text-xs px-2 py-1 rounded border border-sky-800 text-sky-300 hover:bg-sky-950/40 disabled:opacity-40"
title="XModem-push a fresh fake NodeDB fixture, then reboot"
>
inject + reboot
</button>
</div>
<!-- flash timing: direct artifact vs host rebuild -->
<div
v-if="flashStats && (flashStats.artifact || flashStats.rebuild)"
class="text-[11px] text-slate-500"
>
flash:
<span v-if="flashStats.artifact" class="text-emerald-400"
>{{ flashStats.artifact.duration_s }}s artifact</span
>
<span v-if="flashStats.artifact && flashStats.rebuild"> vs </span>
<span v-if="flashStats.rebuild" class="text-slate-400"
>{{ flashStats.rebuild.duration_s }}s rebuild</span
>
<span v-if="flashStats.speedup" class="text-emerald-300">
{{ flashStats.speedup }}× faster</span
>
</div>
<div
v-if="msg"
class="text-[11px] mono truncate"
:class="ok ? 'text-slate-500' : 'text-rose-400'"
:title="msg"
>
{{ msg }}
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useDevicesStore } from "../stores/devices";
import CameraManager from "./CameraManager.vue";
import DeviceCard from "./DeviceCard.vue";
import NativeManager from "./NativeManager.vue";
const devices = useDevicesStore();
</script>
<template>
<div class="p-5 space-y-5">
<div class="grid gap-4 md:grid-cols-2">
<CameraManager />
<NativeManager />
</div>
<div
v-if="devices.list.length === 0"
class="rounded-xl border border-dashed border-slate-700 p-10 text-center text-slate-500"
>
No devices detected. Plug in a Meshtastic board over USB the card will
appear automatically and follow it across ports.
</div>
<div
v-else
class="grid gap-4"
style="grid-template-columns: repeat(auto-fill, minmax(360px, 1fr))"
>
<DeviceCard
v-for="d in devices.list"
:key="d.serial_number"
:device="d"
/>
</div>
</div>
</template>
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { useDevicesStore } from "../stores/devices";
import type { Device } from "../types";
const props = defineProps<{ device: Device }>();
const devices = useDevicesStore();
const name = ref(props.device.friendly_name || "");
const envInput = ref(props.device.env || "");
const envList = ref<string[]>([]);
const cfgPath = ref("");
const cfgValue = ref("");
const busy = ref(false);
const msg = ref<string | null>(null);
const ok = ref(true);
async function loadEnvs() {
try {
// Suggest envs, narrowed to this board's architecture when we can infer it.
const arch =
props.device.role === "nrf52"
? "nrf52840"
: props.device.role === "esp32s3"
? "esp32-s3"
: undefined;
const q = arch ? `?architecture=${arch}` : "";
const boards = await api.get<any[]>(`/api/boards${q}`);
envList.value = boards.map((b) => b.env).filter(Boolean).sort();
} catch {
/* leave datalist empty — free-form input still works */
}
}
onMounted(loadEnvs);
async function act(label: string, fn: () => Promise<any>) {
busy.value = true;
msg.value = `${label}`;
ok.value = true;
try {
await fn();
msg.value = `${label}`;
} catch (e: any) {
msg.value = `${label}: ${e.message}`;
ok.value = false;
} finally {
busy.value = false;
}
}
const saveName = () =>
act("rename", () => devices.setFriendlyName(props.device.serial_number, name.value));
const pinEnv = () =>
envInput.value.trim() &&
act("pin env", () => devices.setEnv(props.device.serial_number, envInput.value.trim()));
const autoEnv = () =>
act("auto-detect env", async () => {
await devices.setEnv(props.device.serial_number, null);
envInput.value = props.device.env || "";
});
const setConfig = () => {
if (!cfgPath.value.trim()) return;
// Coerce "30"→30, "true"→true; leave bare strings (e.g. "US") as-is.
let value: any = cfgValue.value;
try {
value = JSON.parse(cfgValue.value);
} catch {
/* keep string */
}
act("set config", () =>
api.put(`/api/devices/${props.device.serial_number}/config`, {
path: cfgPath.value.trim(),
value,
}),
);
};
</script>
<template>
<div class="rounded-lg border border-slate-700 bg-slate-950/50 p-3 space-y-3 text-xs">
<!-- friendly name -->
<div>
<label class="text-slate-500">Friendly name</label>
<div class="flex gap-1.5 mt-1">
<input
v-model="name"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<button
@click="saveName"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
save
</button>
</div>
</div>
<!-- pio env override -->
<div>
<div class="flex items-center gap-2">
<label class="text-slate-500">PlatformIO env (flash/build target)</label>
<span
class="text-[10px] px-1.5 py-0.5 rounded"
:class="
device.env_locked
? 'bg-amber-950/50 text-amber-400'
: 'bg-slate-800 text-slate-400'
"
>{{ device.env_locked ? "manual" : "auto" }}</span
>
</div>
<div class="flex gap-1.5 mt-1">
<input
v-model="envInput"
list="envlist"
placeholder="e.g. heltec-v4"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<datalist id="envlist">
<option v-for="e in envList" :key="e" :value="e" />
</datalist>
<button
@click="pinEnv"
:disabled="busy"
class="px-2 py-1 rounded border border-emerald-700 text-emerald-300 hover:bg-emerald-700/40 disabled:opacity-40"
>
pin
</button>
<button
@click="autoEnv"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
title="release the override and re-resolve env from hw_model"
>
auto
</button>
</div>
<p class="text-[10px] text-slate-600 mt-1">
current: <span class="mono text-emerald-300/70">{{ device.env || "—" }}</span>
<span v-if="device.hw_model"> · hw {{ device.hw_model }}</span>
</p>
</div>
<!-- generic node config set -->
<div>
<label class="text-slate-500">Set node config (advanced)</label>
<div class="flex gap-1.5 mt-1">
<input
v-model="cfgPath"
placeholder="path e.g. lora.region"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<input
v-model="cfgValue"
placeholder="value e.g. US"
class="w-28 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<button
@click="setConfig"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
set
</button>
</div>
</div>
<div
v-if="msg"
class="mono truncate"
:class="ok ? 'text-slate-500' : 'text-rose-400'"
:title="msg"
>
{{ msg }}
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useFirmwareStore } from "../stores/firmware";
const fw = useFirmwareStore();
</script>
<template>
<div
class="flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800/70 border border-slate-700 text-sm"
:title="fw.ref.subject || ''"
>
<svg
class="w-4 h-4 text-emerald-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="6" cy="6" r="2.5" />
<circle cx="6" cy="18" r="2.5" />
<circle cx="18" cy="9" r="2.5" />
<path d="M6 8.5v7M8.4 7.2A6 6 0 0 1 15.5 9" />
</svg>
<template v-if="fw.ref.available">
<span class="font-semibold text-slate-100">{{
fw.ref.branch || "(detached)"
}}</span>
<span class="mono text-emerald-300">{{ fw.ref.short_sha }}</span>
<span
v-if="fw.ref.dirty"
class="text-amber-400 text-xs"
title="working tree has uncommitted changes"
> dirty</span
>
</template>
<span v-else class="text-slate-500">no git ref</span>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { ansiToHtml } from "../ansi";
const props = defineProps<{ lines: string[]; placeholder?: string }>();
const box = ref<HTMLElement | null>(null);
const pinned = ref(true);
function onScroll() {
const el = box.value;
if (!el) return;
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
watch(
() => props.lines.length,
async () => {
if (!pinned.value) return;
await nextTick();
const el = box.value;
if (el) el.scrollTop = el.scrollHeight;
},
);
</script>
<template>
<div
ref="box"
@scroll="onScroll"
class="mono text-xs leading-relaxed overflow-auto h-full bg-black/40 rounded-md p-2 whitespace-pre-wrap break-all"
>
<span v-if="lines.length === 0" class="text-slate-600">{{
placeholder || "(no output yet)"
}}</span>
<div v-for="(l, i) in lines" :key="i" v-html="ansiToHtml(l)" />
</div>
</template>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
interface NativeInfo {
docker: boolean;
image: string;
nodes: any[];
}
const info = ref<NativeInfo>({ docker: true, image: "", nodes: [] });
const name = ref("");
const port = ref(4403);
const busy = ref(false);
const err = ref<string | null>(null);
async function load() {
info.value = await api.get<NativeInfo>("/api/native");
}
onMounted(load);
async function act(fn: () => Promise<any>) {
busy.value = true;
err.value = null;
try {
await fn();
await load();
} catch (e: any) {
err.value = e.message;
} finally {
busy.value = false;
}
}
const add = () =>
name.value.trim() &&
act(async () => {
await api.post("/api/native", { name: name.value.trim(), tcp_port: port.value });
name.value = "";
port.value = port.value + 1;
});
</script>
<template>
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-4">
<div class="flex items-center gap-3 mb-3">
<h3 class="text-sm font-semibold text-slate-200">Native Nodes (Docker)</h3>
<span
v-if="!info.docker"
class="text-[11px] px-2 py-0.5 rounded bg-amber-950/40 text-amber-400"
>Docker unavailable</span
>
<span class="text-[11px] text-slate-600 mono">{{ info.image }}</span>
</div>
<div class="flex flex-wrap gap-2 mb-3">
<input
v-model="name"
placeholder="node name"
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<input
v-model.number="port"
type="number"
class="text-xs w-24 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
title="host TCP port → container 4403"
/>
<button
@click="add"
:disabled="busy || !info.docker"
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
>
run native node
</button>
<span v-if="err" class="text-xs text-rose-400 self-center">{{ err }}</span>
</div>
<ul class="space-y-1">
<li
v-for="n in info.nodes"
:key="n.serial_number"
class="flex items-center gap-2 text-xs text-slate-400"
>
<span
class="w-2 h-2 rounded-full"
:class="n.online ? 'bg-emerald-400' : 'bg-slate-600'"
/>
<span class="text-slate-200">{{ n.friendly_name }}</span>
<span class="mono">{{ n.current_port }}</span>
<button
v-if="!n.online"
@click="act(() => api.post(`/api/native/${n.friendly_name}/start`))"
class="ml-auto text-emerald-400/80 hover:text-emerald-300"
>
start
</button>
<button
v-else
@click="act(() => api.post(`/api/native/${n.friendly_name}/stop`))"
class="ml-auto text-amber-400/80 hover:text-amber-300"
>
stop
</button>
<button
@click="act(() => api.post(`/api/native/${n.friendly_name}/restart`))"
class="text-sky-400/80 hover:text-sky-300"
>
restart
</button>
<button
@click="act(() => api.del(`/api/native/${n.friendly_name}`))"
class="text-rose-400/70 hover:text-rose-300"
>
remove
</button>
</li>
<li v-if="info.nodes.length === 0" class="text-xs text-slate-600">
no native nodes run meshtasticd in Docker and manage it as a TCP device
</li>
</ul>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
const props = defineProps<{ serial: string }>();
const packets = ref<any[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
const res = await api.get<{ packets: any[] }>(
`/api/devices/${props.serial}/packets?start=-30m&max=100`,
);
packets.value = res.packets.slice().reverse();
} finally {
loading.value = false;
}
}
onMounted(load);
function ts(t: number) {
return new Date(t * 1000).toLocaleTimeString();
}
</script>
<template>
<div class="h-48 overflow-auto bg-black/40 rounded-md p-2">
<div class="flex justify-between items-center mb-1">
<span class="text-xs text-slate-500">last 30 min · packet API</span>
<button
@click="load"
class="text-xs px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
>
{{ loading ? "…" : "refresh" }}
</button>
</div>
<table class="w-full text-xs mono">
<thead class="text-slate-500">
<tr class="text-left">
<th class="font-normal">time</th>
<th class="font-normal">portnum</th>
<th class="font-normal">fromto</th>
<th class="font-normal">snr</th>
</tr>
</thead>
<tbody>
<tr v-for="(p, i) in packets" :key="i" class="border-t border-slate-800/60">
<td class="text-slate-400">{{ ts(p.ts) }}</td>
<td class="text-emerald-300">{{ p.portnum }}</td>
<td class="text-slate-300">{{ p.from_node }}{{ p.to_node }}</td>
<td class="text-slate-400">{{ p.rx_snr ?? "" }}</td>
</tr>
<tr v-if="packets.length === 0">
<td colspan="4" class="text-slate-600 py-2">no packets recorded</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from "vue";
import { useTestsStore } from "../stores/tests";
const tests = useTestsStore();
const args = ref("");
const busy = ref(false);
async function start() {
busy.value = true;
try {
const parsed = args.value.trim() ? args.value.trim().split(/\s+/) : [];
await tests.start(parsed);
} catch (e: any) {
alert(e.message);
} finally {
busy.value = false;
}
}
async function stop() {
busy.value = true;
try {
await tests.stop();
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex items-center gap-2">
<button
v-if="!tests.running"
@click="start"
:disabled="busy"
class="px-4 py-1.5 rounded-md bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium disabled:opacity-40"
>
Run suite
</button>
<button
v-else
@click="stop"
:disabled="busy"
class="px-4 py-1.5 rounded-md bg-rose-600 hover:bg-rose-500 text-white text-sm font-medium disabled:opacity-40"
>
Stop
</button>
<input
v-model="args"
:disabled="tests.running"
placeholder="pytest args (optional, e.g. tests/mesh)"
class="flex-1 text-sm bg-slate-900 border border-slate-700 rounded px-3 py-1.5 outline-none focus:border-emerald-700 disabled:opacity-40"
/>
<div class="text-sm flex items-center gap-3">
<span v-if="tests.running" class="text-amber-400 flex items-center gap-1">
<span class="animate-spin"></span> running
</span>
<span
v-else-if="tests.exitCode !== null"
:class="tests.exitCode === 0 ? 'text-emerald-400' : 'text-rose-400'"
>
exit {{ tests.exitCode }}
</span>
<span class="tabular-nums text-xs text-slate-400">
<span class="text-emerald-400">{{ tests.totals.passed }}</span> ·
<span class="text-rose-400">{{ tests.totals.failed }}</span> ·
<span class="text-slate-500">{{ tests.totals.skipped }}</span>
</span>
</div>
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive } from "vue";
import { useWsStore } from "../stores/ws";
import LogPane from "./LogPane.vue";
const props = defineProps<{ serial: string }>();
const ws = useWsStore();
const lines = reactive<string[]>([]);
const topic = `serial.${props.serial}`;
function onLine(d: any) {
lines.push(d.line);
if (lines.length > 2000) lines.splice(0, lines.length - 2000);
}
onMounted(() => ws.subscribe(topic, onLine));
onUnmounted(() => ws.unsubscribe(topic, onLine));
</script>
<template>
<div class="h-48">
<LogPane :lines="lines" placeholder="opening serial monitor…" />
</div>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useTestsStore } from "../stores/tests";
import BuildQueue from "./BuildQueue.vue";
import LogPane from "./LogPane.vue";
import RunControls from "./RunControls.vue";
import TestTree from "./TestTree.vue";
import TierCounters from "./TierCounters.vue";
const tests = useTestsStore();
const logTab = ref<"pytest" | "flash" | "firmware">("pytest");
const activeLines = computed(() => {
if (logTab.value === "flash") return tests.flash;
if (logTab.value === "firmware") return tests.fwlog;
return tests.stdout;
});
function fmtTime(t: number) {
return new Date(t * 1000).toLocaleString();
}
</script>
<template>
<div class="p-5 flex flex-col gap-4 h-[calc(100vh-57px)]">
<RunControls />
<BuildQueue />
<div class="grid grid-cols-2 gap-4 flex-1 min-h-0">
<!-- left: counters + tree -->
<div class="flex flex-col gap-3 min-h-0">
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-4">
<TierCounters />
</div>
<div
class="rounded-xl border border-slate-700 bg-slate-900/60 p-3 flex-1 min-h-0"
>
<TestTree />
</div>
</div>
<!-- right: log panes -->
<div class="flex flex-col min-h-0">
<div class="flex gap-1 text-xs mb-2">
<button
v-for="t in ['pytest', 'flash', 'firmware']"
:key="t"
@click="logTab = t as any"
class="px-3 py-1 rounded-md capitalize transition"
:class="
logTab === t
? 'bg-slate-700 text-slate-100'
: 'bg-slate-900 text-slate-500 hover:text-slate-300'
"
>
{{ t }}
</button>
</div>
<div class="flex-1 min-h-0">
<LogPane :lines="activeLines" />
</div>
</div>
</div>
<!-- run history -->
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-3">
<div class="text-xs text-slate-500 mb-2">recent runs</div>
<div class="flex gap-3 overflow-x-auto text-xs">
<div
v-for="r in tests.runs"
:key="r.id"
class="shrink-0 rounded-lg border border-slate-800 px-3 py-1.5"
>
<div class="flex items-center gap-2">
<span class="text-emerald-400">{{ r.passed }}</span>
<span class="text-rose-400">{{ r.failed }}</span>
<span class="text-slate-500">{{ r.skipped }}</span>
<span class="mono text-emerald-300/60">{{
r.fw_sha?.slice(0, 7)
}}</span>
</div>
<div class="text-slate-600">{{ fmtTime(r.started_at) }}</div>
</div>
<div v-if="tests.runs.length === 0" class="text-slate-600">
no runs recorded yet
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
const props = defineProps<{ serial: string }>();
const results = ref<any[]>([]);
const OUTCOME_CLASS: Record<string, string> = {
passed: "text-emerald-400",
failed: "text-rose-400",
skipped: "text-slate-500",
};
async function load() {
results.value = await api.get<any[]>(
`/api/devices/${props.serial}/test-results?limit=100`,
);
}
onMounted(load);
</script>
<template>
<div class="h-48 overflow-auto bg-black/40 rounded-md p-2">
<div class="flex justify-between items-center mb-1">
<span class="text-xs text-slate-500">test history</span>
<button
@click="load"
class="text-xs px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
>
refresh
</button>
</div>
<table class="w-full text-xs">
<tbody>
<tr
v-for="(r, i) in results"
:key="i"
class="border-t border-slate-800/60"
>
<td :class="OUTCOME_CLASS[r.outcome] || 'text-slate-400'" class="w-4">
{{
r.outcome === "passed" ? "✓" : r.outcome === "failed" ? "✗" : "⊘"
}}
</td>
<td class="mono text-slate-300 truncate max-w-[14rem]" :title="r.nodeid">
{{ r.nodeid.split("::").pop() }}
</td>
<td class="mono text-emerald-300/70 text-right">{{ r.fw_sha?.slice(0, 7) }}</td>
</tr>
<tr v-if="results.length === 0">
<td colspan="3" class="text-slate-600 py-2">no runs recorded yet</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from "vue";
import { useTestsStore } from "../stores/tests";
import type { TestLeaf } from "../types";
const tests = useTestsStore();
const GLYPH: Record<string, string> = {
passed: "✓",
failed: "✗",
skipped: "⊘",
running: "⟳",
pending: "·",
};
const CLS: Record<string, string> = {
passed: "text-emerald-400",
failed: "text-rose-400",
skipped: "text-slate-500",
running: "text-amber-400 animate-pulse",
pending: "text-slate-600",
};
// Group leaves: tier → file → [leaves]
const grouped = computed(() => {
const out: Record<string, Record<string, TestLeaf[]>> = {};
for (const leaf of Object.values(tests.leaves)) {
(out[leaf.tier] ??= {});
(out[leaf.tier][leaf.file] ??= []).push(leaf);
}
return out;
});
</script>
<template>
<div class="overflow-auto text-xs mono h-full">
<template v-for="tier in tests.tierOrder" :key="tier">
<div v-if="grouped[tier]" class="mb-2">
<div class="text-slate-300 capitalize font-semibold">{{ tier }}</div>
<div v-for="(leaves, file) in grouped[tier]" :key="file" class="ml-2">
<div class="text-slate-500">{{ file }}</div>
<div
v-for="leaf in leaves"
:key="leaf.nodeid"
class="ml-3 flex items-center gap-1.5"
:class="leaf.nodeid === tests.runningNodeId ? 'bg-amber-950/30 rounded' : ''"
>
<span :class="CLS[leaf.outcome]">{{ GLYPH[leaf.outcome] }}</span>
<span class="text-slate-400 truncate">{{ leaf.testname }}</span>
<span
v-if="leaf.duration"
class="text-slate-600 ml-auto pl-2"
>{{ leaf.duration.toFixed(1) }}s</span
>
</div>
</div>
</div>
</template>
<div
v-if="Object.keys(tests.leaves).length === 0"
class="text-slate-600 p-2"
>
no tests collected yet start a run
</div>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useTestsStore } from "../stores/tests";
const tests = useTestsStore();
</script>
<template>
<div class="space-y-1.5">
<div
v-for="t in tests.tierOrder"
:key="t"
class="flex items-center gap-2 text-xs transition-opacity"
:class="tests.tiers[t].total ? '' : 'opacity-45'"
>
<span class="w-24 text-slate-400 capitalize">{{ t }}</span>
<div class="flex-1 h-2.5 rounded-full bg-slate-800 overflow-hidden flex">
<div
class="bg-emerald-500"
:style="{
width:
tests.tiers[t].total
? (tests.tiers[t].passed / tests.tiers[t].total) * 100 + '%'
: '0%',
}"
/>
<div
class="bg-rose-500"
:style="{
width:
tests.tiers[t].total
? (tests.tiers[t].failed / tests.tiers[t].total) * 100 + '%'
: '0%',
}"
/>
<div
class="bg-slate-600"
:style="{
width:
tests.tiers[t].total
? (tests.tiers[t].skipped / tests.tiers[t].total) * 100 + '%'
: '0%',
}"
/>
</div>
<span class="inline-flex items-center gap-1 tabular-nums shrink-0">
<span
class="w-6 text-right"
:class="tests.tiers[t].passed ? 'text-emerald-400' : 'text-slate-600'"
>{{ tests.tiers[t].passed }}</span
>
<span class="text-slate-700">/</span>
<span
class="w-6 text-right"
:class="tests.tiers[t].failed ? 'text-rose-400' : 'text-slate-600'"
>{{ tests.tiers[t].failed }}</span
>
<span class="text-slate-700">/</span>
<span
class="w-6 text-right"
:class="tests.tiers[t].skipped ? 'text-slate-300' : 'text-slate-600'"
>{{ tests.tiers[t].skipped }}</span
>
<span class="w-7 text-right">
<span v-if="tests.tiers[t].running" class="text-amber-400"
>{{ tests.tiers[t].running }}</span
>
</span>
</span>
</div>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import "./style.css";
createApp(App).use(createPinia()).mount("#app");
+69
View File
@@ -0,0 +1,69 @@
import { defineStore } from "pinia";
import { computed, reactive, ref } from "vue";
import { api } from "../api/client";
import { useWsStore } from "./ws";
export interface Build {
id: number;
env: string;
fw_sha: string;
fw_branch: string | null;
status: string; // queued | building | success | failed | cached | cancelled
duration_s: number | null;
artifact_dir: string | null;
error: string | null;
cached?: boolean;
}
export const useBuildsStore = defineStore("builds", () => {
const byId = reactive<Record<number, Build>>({});
const dockerAvailable = ref(true);
const list = computed(() => Object.values(byId).sort((a, b) => b.id - a.id));
async function load() {
const res = await api.get<{ docker: boolean; builds: Build[] }>(
"/api/builds",
);
dockerAvailable.value = res.docker;
for (const b of res.builds) byId[b.id] = b;
}
function init() {
const ws = useWsStore();
ws.subscribe("build.update", (b: Build) => {
if (b && b.id != null) byId[b.id] = b;
});
load();
}
// Latest build row for an (env, sha) — drives the device flash button state.
function statusFor(
env: string,
sha: string | null | undefined,
): Build | undefined {
if (!sha) return undefined;
return Object.values(byId)
.filter((b) => b.env === env && b.fw_sha === sha)
.sort((a, b) => b.id - a.id)[0];
}
async function prebuildTracked() {
await api.post("/api/builds", {});
}
async function enqueue(envs: string[], force = false) {
await api.post("/api/builds", { envs, force });
}
return {
byId,
list,
dockerAvailable,
load,
init,
statusFor,
prebuildTracked,
enqueue,
};
});
+48
View File
@@ -0,0 +1,48 @@
import { defineStore } from "pinia";
import { computed, reactive } from "vue";
import { api } from "../api/client";
import type { Camera } from "../types";
import { useWsStore } from "./ws";
export const useCamerasStore = defineStore("cameras", () => {
const byId = reactive<Record<number, Camera>>({});
const list = computed(() => Object.values(byId).sort((a, b) => a.id - b.id));
function forDevice(serial: string): Camera | undefined {
return Object.values(byId).find((c) => c.device_serial === serial);
}
async function load() {
const cams = await api.get<Camera[]>("/api/cameras");
for (const c of cams) byId[c.id] = c;
}
function init() {
const ws = useWsStore();
ws.subscribe("camera.update", (c: Camera) => {
if (c.deleted) delete byId[c.id];
else byId[c.id] = c;
});
load();
}
async function add(name: string, device_index: string) {
const cam = await api.post<Camera>("/api/cameras", { name, device_index });
byId[cam.id] = cam;
}
async function remove(id: number) {
await api.del(`/api/cameras/${id}`);
delete byId[id];
}
async function assign(id: number, device_serial: string | null) {
const cam = await api.post<Camera>(`/api/cameras/${id}/assign`, {
device_serial,
});
byId[id] = cam;
}
return { byId, list, forDevice, load, init, add, remove, assign };
});
+58
View File
@@ -0,0 +1,58 @@
import { defineStore } from "pinia";
import { computed, reactive } from "vue";
import { api } from "../api/client";
import type { Device } from "../types";
import { useWsStore } from "./ws";
export const useDevicesStore = defineStore("devices", () => {
// Keyed by serial_number — a device.update with a new current_port is a field
// update on the same entry, so the card "follows" the device across ports.
const bySerial = reactive<Record<string, Device>>({});
const list = computed(() =>
Object.values(bySerial).sort((a, b) => {
if (a.online !== b.online) return b.online - a.online;
return (a.friendly_name || a.serial_number).localeCompare(
b.friendly_name || b.serial_number,
);
}),
);
async function load() {
const devices = await api.get<Device[]>("/api/devices");
for (const d of devices) bySerial[d.serial_number] = d;
}
function init() {
const ws = useWsStore();
ws.subscribe("device.update", (d: any) => {
if (d && d.deleted) delete bySerial[d.serial_number];
else if (d) bySerial[d.serial_number] = d;
});
load();
}
async function setFriendlyName(serial: string, name: string) {
const updated = await api.patch<Device>(`/api/devices/${serial}`, {
friendly_name: name,
});
bySerial[serial] = updated;
}
async function refresh(serial: string) {
const res = await api.post<{ device: Device }>(
`/api/devices/${serial}/refresh`,
);
bySerial[serial] = res.device;
}
// Pin a pio env (manual override) or release to auto-detect (env=null).
async function setEnv(serial: string, env: string | null) {
const updated = await api.put<Device>(`/api/devices/${serial}/env`, {
env,
});
bySerial[serial] = updated;
}
return { bySerial, list, load, init, setFriendlyName, refresh, setEnv };
});
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import type { FirmwareRef } from "../types";
import { useWsStore } from "./ws";
export const useFirmwareStore = defineStore("firmware", () => {
const ref_ = ref<FirmwareRef>({ available: false });
async function load() {
ref_.value = await api.get<FirmwareRef>("/api/firmware");
}
function init() {
const ws = useWsStore();
ws.subscribe("firmware.update", (r: FirmwareRef) => {
ref_.value = r;
});
load();
}
return { ref: ref_, load, init };
});
+171
View File
@@ -0,0 +1,171 @@
import { defineStore } from "pinia";
import { computed, reactive, ref } from "vue";
import { api } from "../api/client";
import type { TestLeaf, TestRun } from "../types";
import { useWsStore } from "./ws";
const TIERS = [
"bake",
"unit",
"mesh",
"telemetry",
"monitor",
"fleet",
"admin",
"provisioning",
] as const;
const MAX_LOG = 4000;
function pushBounded(arr: string[], line: string) {
arr.push(line);
if (arr.length > MAX_LOG) arr.splice(0, arr.length - MAX_LOG);
}
export const useTestsStore = defineStore("tests", () => {
const running = ref(false);
const runId = ref<number | null>(null);
const exitCode = ref<number | null>(null);
const runningNodeId = ref<string | null>(null);
const leaves = reactive<Record<string, TestLeaf>>({});
const stdout = reactive<string[]>([]);
const flash = reactive<string[]>([]);
const fwlog = reactive<string[]>([]);
const runs = ref<TestRun[]>([]);
const tiers = computed(() => {
const out: Record<
string,
{
passed: number;
failed: number;
skipped: number;
running: number;
total: number;
}
> = {};
for (const t of TIERS)
out[t] = { passed: 0, failed: 0, skipped: 0, running: 0, total: 0 };
for (const leaf of Object.values(leaves)) {
const t = out[leaf.tier];
if (!t) continue;
t.total++;
if (leaf.outcome === "running") t.running++;
else if (leaf.outcome in t) (t as any)[leaf.outcome]++;
}
return out;
});
const totals = computed(() => {
let passed = 0,
failed = 0,
skipped = 0;
for (const leaf of Object.values(leaves)) {
if (leaf.outcome === "passed") passed++;
else if (leaf.outcome === "failed") failed++;
else if (leaf.outcome === "skipped") skipped++;
}
return { passed, failed, skipped };
});
function reset() {
for (const k of Object.keys(leaves)) delete leaves[k];
stdout.length = 0;
flash.length = 0;
fwlog.length = 0;
exitCode.value = null;
}
function onProgress(d: any) {
switch (d.type) {
case "run_started":
reset();
running.value = true;
runId.value = d.run_id;
break;
case "run_finished":
running.value = false;
exitCode.value = d.exit_code;
runningNodeId.value = null;
loadRuns();
break;
case "register":
if (!leaves[d.nodeid])
leaves[d.nodeid] = {
nodeid: d.nodeid,
tier: d.tier,
file: d.file,
testname: d.testname,
outcome: "pending",
};
break;
case "running":
if (leaves[d.nodeid]) leaves[d.nodeid].outcome = "running";
runningNodeId.value = d.nodeid;
break;
case "outcome":
if (leaves[d.nodeid]) {
leaves[d.nodeid].outcome = d.outcome;
leaves[d.nodeid].duration = d.duration;
}
if (runningNodeId.value === d.nodeid) runningNodeId.value = null;
break;
}
}
function init() {
const ws = useWsStore();
ws.subscribe("test.progress", onProgress);
ws.subscribe("test.stdout", (d: any) =>
pushBounded(
stdout,
d.source === "stderr" ? `[stderr] ${d.line}` : d.line,
),
);
ws.subscribe("test.flash", (d: any) => pushBounded(flash, d.line));
ws.subscribe("fw.log", (d: any) =>
pushBounded(fwlog, `${d.port ?? ""} ${d.line}`.trim()),
);
loadStatus();
loadRuns();
}
async function loadStatus() {
const s = await api.get<any>("/api/tests/status");
running.value = s.running;
runId.value = s.run_id;
exitCode.value = s.exit_code;
}
async function loadRuns() {
runs.value = await api.get<TestRun[]>("/api/tests/runs");
}
async function start(args: string[]) {
await api.post("/api/tests/start", { args });
}
async function stop() {
await api.post("/api/tests/stop");
}
return {
running,
runId,
exitCode,
runningNodeId,
leaves,
stdout,
flash,
fwlog,
runs,
tiers,
totals,
tierOrder: TIERS,
init,
start,
stop,
loadRuns,
};
});
+78
View File
@@ -0,0 +1,78 @@
// Single WebSocket to /ws. Other stores register topic handlers; this store
// owns the socket, (re)subscribes on (re)connect, and dispatches frames.
import { defineStore } from "pinia";
import { ref } from "vue";
type Handler = (data: any) => void;
export const useWsStore = defineStore("ws", () => {
const connected = ref(false);
let socket: WebSocket | null = null;
const handlers = new Map<string, Set<Handler>>();
const subscribed = new Set<string>();
let reconnectTimer: number | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/ws`;
}
function connect() {
if (socket && socket.readyState <= WebSocket.OPEN) return;
socket = new WebSocket(url());
socket.onopen = () => {
connected.value = true;
// Re-subscribe everything after a reconnect.
for (const topic of subscribed) send({ action: "subscribe", topic });
};
socket.onclose = () => {
connected.value = false;
scheduleReconnect();
};
socket.onerror = () => socket?.close();
socket.onmessage = (ev) => {
try {
const frame = JSON.parse(ev.data);
const hs = handlers.get(frame.topic);
if (hs) for (const h of hs) h(frame.data);
} catch {
/* ignore malformed */
}
};
}
function scheduleReconnect() {
if (reconnectTimer != null) return;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connect();
}, 1500);
}
function send(msg: object) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
}
}
function subscribe(topic: string, handler: Handler) {
if (!handlers.has(topic)) handlers.set(topic, new Set());
handlers.get(topic)!.add(handler);
if (!subscribed.has(topic)) {
subscribed.add(topic);
send({ action: "subscribe", topic });
}
}
function unsubscribe(topic: string, handler: Handler) {
handlers.get(topic)?.delete(handler);
if (handlers.get(topic)?.size === 0) {
handlers.delete(topic);
subscribed.delete(topic);
send({ action: "unsubscribe", topic });
}
}
return { connected, connect, subscribe, unsubscribe };
});
+39
View File
@@ -0,0 +1,39 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: #0b0f17;
color: #e5e7eb;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
/* Monospace log panes */
.mono {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
/* Thin scrollbars for the many log panes */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
+66
View File
@@ -0,0 +1,66 @@
export interface Device {
serial_number: string;
node_num: number | null;
friendly_name: string | null;
hw_model: string | null;
vid: string | null;
pid: string | null;
role: string | null;
current_port: string | null;
firmware_version: string | null;
region: string | null;
env: string | null;
env_locked: number;
flashed_fw_branch: string | null;
flashed_fw_sha: string | null;
flashed_at: number | null;
online: number;
first_seen: number;
last_seen: number;
has_stable_id: boolean;
stale: boolean;
}
export interface Camera {
id: number;
name: string;
type: string;
device_index: string | null;
backend: string | null;
enabled: number;
created_at: number;
device_serial: string | null;
assigned_at: number | null;
deleted?: boolean;
}
export interface FirmwareRef {
available: boolean;
branch?: string | null;
sha?: string | null;
short_sha?: string | null;
dirty?: boolean | null;
subject?: string | null;
committed_at?: string | null;
}
export interface TestLeaf {
nodeid: string;
tier: string;
file: string;
testname: string;
outcome: string; // pending | running | passed | failed | skipped
duration?: number | null;
}
export interface TestRun {
id: number;
started_at: number;
finished_at: number | null;
exit_code: number | null;
fw_branch: string | null;
fw_sha: string | null;
passed: number;
failed: number;
skipped: number;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Backend (FastAPI) dev address. The pywebview/production build serves the
// SPA from FastAPI itself, so these proxies only matter under `npm run dev`.
const BACKEND = "http://127.0.0.1:8765";
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
proxy: {
"/api": { target: BACKEND, changeOrigin: true },
"/ws": { target: BACKEND.replace("http", "ws"), ws: true },
},
},
build: {
// Built SPA is served by FastAPI from src/meshtastic_mcp/web/static.
outDir: "../src/meshtastic_mcp/web/static",
emptyOutDir: true,
},
});