Draft of FleetSuite
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+41
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
Executable
+77
@@ -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"
|
||||
Executable
+32
@@ -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
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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`.
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+2258
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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">from→to</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>
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
+1
-1
Submodule protobufs updated: 1df6c11542...3625166717
Reference in New Issue
Block a user