feat(webconfig): add a terminal CLI tab to the portal
Design prototype, driven entirely by the mock backend — nothing here runs on-device yet. The portal's form batch is deliberately allowlisted (WebConfigKeys.h), which leaves everything the serial console can do unreachable from a browser. This adds a fifth tab holding a real terminal: monospace white-on-black in either colour scheme, autocomplete over the full ~270-command surface, in-session history, and a confirmation step for pasted command sequences. Autocomplete goes past the flasher's <datalist>: rows carry descriptions, Tab extends to the longest shared prefix before committing to a match, and once `set <key> ` is complete it switches to completing the VALUE — enums from the command table, broker presets from /api/presets, packet-type names per CSV segment. The table is generated from a key list rather than written out per slot, so mqttN.* tracks active_slots instead of being duplicated six times. Pasting several lines never mangles the prompt: the lines are parsed (comments, blank lines and pasted `>` prompts stripped), listed back numbered, and run only after an explicit confirm. Commands that restart, erase, reflash or move the node off its network get the same confirmation singly. History is memory-only — `set wifi.pwd` and `password` pass through it. /api/cli mirrors the config-save contract (202 + reqid, poll for results) for the same reason: commands run on the node's main loop, not in the request. The one difference is that results stream, so a long sequence fills the window as it executes rather than landing all at once. The tab is hidden in setup mode, where the portal authenticates by proximity and no admin password exists yet.
This commit is contained in:
1 parent
6961492f21
commit
d148d0c62a
2 files changed
+940
-1
No files matched your search
@@ -9,6 +9,11 @@ pending -> done result polling, aggregate-success reboot gating, secret masking
|
||||
So the browser drives the actual portal JS (wizard, save/poll/reqid, effective
|
||||
value handling, reboot overlay, stats, scan) against realistic responses.
|
||||
|
||||
/api/cli is the CLI terminal's backend and has no firmware counterpart yet: it
|
||||
is the proposed contract (202 + reqid, streamed per-command results) executed
|
||||
against a CommonCLI-shaped interpreter, so the terminal UI can be designed
|
||||
against realistic single- and multi-line replies before any of it goes on-device.
|
||||
|
||||
It does NOT run the C++ handlers (that's what test/ gtest covers) or the
|
||||
AsyncTCP transport — it's a frontend + contract harness.
|
||||
|
||||
@@ -122,6 +127,7 @@ class State:
|
||||
self.start = time.time()
|
||||
self.session = None # cookie token when logged in (LAN mode)
|
||||
self.batch = {"state": "idle"}
|
||||
self.cli = {"state": "idle"} # deferred CLI sequence, see /api/cli
|
||||
self.scan_started = None
|
||||
|
||||
# ---- auth -------------------------------------------------------------
|
||||
@@ -200,6 +206,15 @@ def apply_set(cfg, key, val):
|
||||
ADMIN_PASSWORD = val
|
||||
return True, "OK"
|
||||
|
||||
if key in ("freq", "bw", "sf", "cr"):
|
||||
# single-component radio setters, reachable from the CLI but not from
|
||||
# the form batch (which always sends the whole `radio` combo)
|
||||
try:
|
||||
cfg["radio"][key] = int(val) if key in ("sf", "cr") else float(val)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
return True, "OK - reboot to apply"
|
||||
|
||||
if key == "radio":
|
||||
try:
|
||||
f, bw, sf, cr = val.split(",")
|
||||
@@ -340,6 +355,134 @@ def is_secret_key(key):
|
||||
return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI command execution (backs /api/cli), mirroring CommonCLI enough to give
|
||||
# the terminal UI realistic single- and multi-line replies.
|
||||
#
|
||||
# The portal's `set` batch is allowlisted (WebConfigKeys.h) because it is driven
|
||||
# by form fields; the CLI is deliberately NOT, since its whole point is reaching
|
||||
# the same surface the serial console reaches. Auth is the boundary — exactly
|
||||
# as it is for the serial console and for remote admin over the mesh.
|
||||
# ---------------------------------------------------------------------------
|
||||
CLI_MAX_CMDS = 64 # per POSTed sequence
|
||||
CLI_CMD_SECS = 0.25 # simulated per-command execution time
|
||||
|
||||
# Commands the device answers but that have no config-key equivalent.
|
||||
GETTERS = {
|
||||
"freq": lambda c: "%.3f" % c["radio"]["freq"],
|
||||
"bw": lambda c: "%.2f" % c["radio"]["bw"],
|
||||
"sf": lambda c: str(c["radio"]["sf"]),
|
||||
"cr": lambda c: str(c["radio"]["cr"]),
|
||||
"public.key": lambda c: "a1b2c3d4" * 8,
|
||||
"wifi.status": lambda c: (
|
||||
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
|
||||
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
|
||||
"mqtt.status": lambda c: cli_mqtt_status(c),
|
||||
"mqtt.presets": lambda c: "\n".join(
|
||||
"%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd)
|
||||
for i, (n, nd) in enumerate(PRESETS)),
|
||||
}
|
||||
|
||||
|
||||
def cli_mqtt_status(cfg):
|
||||
lines = []
|
||||
for i, s in enumerate(cfg["mqtt"]["slots"][:ST.active_slots]):
|
||||
if s["preset"] == "none":
|
||||
lines.append("slot %d: unconfigured" % (i + 1))
|
||||
else:
|
||||
lines.append("slot %d: %-16s connected tx=%d err=0"
|
||||
% (i + 1, s["preset"], 100 + int(time.time() - ST.start)))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cli_get(cfg, key):
|
||||
if key in GETTERS:
|
||||
return True, GETTERS[key](cfg)
|
||||
if is_secret_key(key):
|
||||
# The serial console prints these; the portal is reachable over the LAN,
|
||||
# so it masks them the same way /api/config does.
|
||||
return True, SENTINEL if cli_read_key(cfg, key) else "(not set)"
|
||||
val = cli_read_key(cfg, key)
|
||||
if val is None:
|
||||
return False, "Error: unknown config key '%s'" % key
|
||||
return True, str(val)
|
||||
|
||||
|
||||
def cli_read_key(cfg, key):
|
||||
"""Current value of a `set` key, or None when the key is unknown."""
|
||||
m = re.match(r"^mqtt([1-6])\.(\w+)$", key)
|
||||
if m:
|
||||
slot = cfg["mqtt"]["slots"][int(m.group(1)) - 1]
|
||||
return slot.get(m.group(2))
|
||||
for table in (BOOL_KEYS, INT_KEYS, FLOAT_KEYS, STR_KEYS, SECRET_STR_KEYS):
|
||||
if key in table:
|
||||
sec, f = table[key]
|
||||
v = cfg[sec][f]
|
||||
return ("on" if v else "off") if key in BOOL_KEYS else v
|
||||
# keys apply_set() special-cases, so they appear in none of the tables above
|
||||
r = cfg["radio"]
|
||||
return {
|
||||
"name": r["name"], "lat": r["lat"], "lon": r["lon"],
|
||||
"radio": "%.3f,%.2f,%d,%d" % (r["freq"], r["bw"], r["sf"], r["cr"]),
|
||||
"bw": r["bw"], "sf": r["sf"], "cr": r["cr"],
|
||||
"mqtt.iata": cfg["mqtt"]["iata"], "mqtt.owner": cfg["mqtt"]["owner"],
|
||||
}.get(key)
|
||||
|
||||
|
||||
def run_cli(cfg, line):
|
||||
"""Execute one command line. Returns (ok, reply); reply may be multi-line."""
|
||||
cmd = line.strip()
|
||||
if cmd == "":
|
||||
return True, ""
|
||||
if cmd == "ver":
|
||||
return True, "v1.7.1-mock (observer)"
|
||||
if cmd == "board":
|
||||
return True, "Heltec V3 (mock)"
|
||||
if cmd == "clock":
|
||||
return True, time.strftime("%d/%m/%Y %H:%M:%S", time.gmtime()) + " UTC"
|
||||
if cmd == "advert":
|
||||
return True, "OK - Advert sent (zero hop)"
|
||||
if cmd == "advert.zerohop":
|
||||
return True, "OK - Advert sent (zero hop)"
|
||||
if cmd in ("reboot", "clkreboot"):
|
||||
return True, "OK - rebooting"
|
||||
if cmd in ("poweroff", "shutdown"):
|
||||
return True, "OK - powering off"
|
||||
if cmd == "erase":
|
||||
return True, "OK - filesystem erased, rebooting"
|
||||
if cmd == "memory":
|
||||
return True, ("heap free: 142000\nheap min: 118000\n"
|
||||
"largest block: 96000\npsram free: 3980000")
|
||||
if cmd == "neighbors":
|
||||
return True, ("d4e5f60718 -71 dBm snr 9.5 2m ago\n"
|
||||
"1122334455 -94 dBm snr 2.0 14m ago")
|
||||
if cmd == "clear stats":
|
||||
return True, "OK - stats cleared"
|
||||
if cmd.startswith("stats-"):
|
||||
return True, "recv=512 sent=88 rx_err=3 airtime=41s"
|
||||
if cmd == "log":
|
||||
return True, "packet log: 128 entries, 14 KB"
|
||||
if cmd.startswith("log "):
|
||||
return True, "OK"
|
||||
if cmd.startswith("password "):
|
||||
global ADMIN_PASSWORD
|
||||
ADMIN_PASSWORD = cmd[9:]
|
||||
return True, "OK - password changed"
|
||||
if cmd.startswith("time "):
|
||||
return True, "OK - clock set"
|
||||
if cmd.startswith("get "):
|
||||
return cli_get(cfg, cmd[4:].strip())
|
||||
if cmd.startswith("set "):
|
||||
rest = cmd[4:].strip()
|
||||
key, _, val = rest.partition(" ")
|
||||
if not key:
|
||||
return False, "Error: set what?"
|
||||
if cli_read_key(cfg, key) is None and not re.match(r"^mqtt[1-6]\.", key):
|
||||
return False, "Error: unknown config key '%s'" % key
|
||||
return apply_set(cfg, key, val.strip())
|
||||
return False, "Error: unknown command '%s'" % cmd[:40]
|
||||
|
||||
|
||||
def valid_reqid(reqid):
|
||||
return isinstance(reqid, str) and bool(re.fullmatch(r"[0-9A-Fa-f]{16}", reqid))
|
||||
|
||||
@@ -393,6 +536,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_result()
|
||||
if path == "/api/cli/result":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._cli_result()
|
||||
if path == "/api/stats":
|
||||
if self._need_auth():
|
||||
return
|
||||
@@ -415,6 +562,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_post()
|
||||
if path == "/api/cli":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._cli_post()
|
||||
if path == "/api/reboot":
|
||||
if self._need_auth():
|
||||
return
|
||||
@@ -526,6 +677,84 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"reboot": b["reboot"] and b["all_ok"], "results": b["results"],
|
||||
})
|
||||
|
||||
# ---- CLI ---------------------------------------------------------------
|
||||
# Same 202 + reqid + poll shape as /api/config, for the same reason: the
|
||||
# commands have to run on the main loop, not the web server's task. The
|
||||
# difference is that results stream -- a pasted sequence fills the terminal
|
||||
# command by command instead of appearing all at once at the end.
|
||||
def _cli_post(self):
|
||||
raw = self._read_body()
|
||||
if len(raw) > 8192:
|
||||
return self._json(413, {"error": "body too large"})
|
||||
try:
|
||||
body = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return self._json(400, {"error": "bad json"})
|
||||
reqid = body.get("reqid", "")
|
||||
if not valid_reqid(reqid):
|
||||
return self._json(400, {"error": "bad reqid"})
|
||||
cmds = body.get("cmds")
|
||||
if not isinstance(cmds, list) or not cmds:
|
||||
return self._json(400, {"error": "no commands"})
|
||||
if len(cmds) > CLI_MAX_CMDS:
|
||||
return self._json(413, {"error": "too many commands (max %d)" % CLI_MAX_CMDS})
|
||||
cmds = [str(c).replace("\r", "").replace("\n", "").strip() for c in cmds]
|
||||
cmds = [c for c in cmds if c]
|
||||
if not cmds:
|
||||
return self._json(400, {"error": "no commands"})
|
||||
for c in cmds:
|
||||
if len(c) > BATCH_CMD_SIZE - 1:
|
||||
return self._json(400, {"error": "command too long", "cmd": c[:32]})
|
||||
|
||||
with ST.lock:
|
||||
self._cli_advance(ST.cli)
|
||||
if ST.cli.get("state") != "idle" and ST.cli.get("reqid") == reqid:
|
||||
return self._json(202, {"state": ST.cli["state"], "reqid": reqid,
|
||||
"total": len(ST.cli["cmds"])})
|
||||
if ST.cli.get("state") == "running":
|
||||
return self._json(409, {"error": "busy", "reqid": ST.cli.get("reqid", "")})
|
||||
ST.cli = {"state": "running", "reqid": reqid, "cmds": cmds, "results": [],
|
||||
"next_at": time.time() + CLI_CMD_SECS}
|
||||
return self._json(202, {"state": "running", "reqid": reqid, "total": len(cmds)})
|
||||
|
||||
@staticmethod
|
||||
def _cli_advance(job):
|
||||
"""Run whichever queued commands are now due. Execution belongs to the
|
||||
node's loop, not to the client's polling — a client that walks away must
|
||||
not leave the executor claimed forever."""
|
||||
now = time.time()
|
||||
while (job.get("state") == "running" and len(job["results"]) < len(job["cmds"])
|
||||
and now >= job["next_at"]):
|
||||
cmd = job["cmds"][len(job["results"])]
|
||||
ok, reply = run_cli(ST.cfg, cmd)
|
||||
job["results"].append({"cmd": cmd, "ok": ok, "reply": reply})
|
||||
job["next_at"] = now + CLI_CMD_SECS
|
||||
if job.get("state") == "running" and len(job["results"]) == len(job["cmds"]):
|
||||
job["state"] = "done" # stays readable until the next POST
|
||||
|
||||
def _cli_result(self):
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
reqid = query.get("reqid", [""])[0]
|
||||
if not valid_reqid(reqid):
|
||||
return self._json(400, {"error": "bad reqid"})
|
||||
# `from` lets the client ask only for results it has not rendered yet,
|
||||
# so a long sequence isn't re-sent on every poll.
|
||||
try:
|
||||
frm = max(0, int(query.get("from", ["0"])[0]))
|
||||
except ValueError:
|
||||
frm = 0
|
||||
with ST.lock:
|
||||
j = ST.cli
|
||||
if j.get("state") == "idle":
|
||||
return self._json(200, {"state": "idle", "reqid": reqid})
|
||||
if j.get("reqid") != reqid:
|
||||
return self._json(404, {"error": "unknown request"})
|
||||
self._cli_advance(j) # one command per CLI_CMD_SECS
|
||||
return self._json(200, {
|
||||
"state": j["state"], "reqid": reqid, "total": len(j["cmds"]),
|
||||
"from": frm, "results": j["results"][frm:],
|
||||
})
|
||||
|
||||
def _scan(self):
|
||||
rescan = "rescan=1" in self.path
|
||||
now = time.time()
|
||||
|
||||
+711
-1
@@ -118,6 +118,85 @@ canvas{width:100%;height:56px;display:block}
|
||||
.kv b{font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis}
|
||||
.hide{display:none!important}
|
||||
.note{font-size:12.5px;color:var(--mut);background:var(--chip);border-radius:8px;padding:9px 11px;margin-bottom:13px}
|
||||
|
||||
/* ---------------- CLI terminal ----------------
|
||||
Deliberately not themed: a console reads as a console in either colour
|
||||
scheme, and the reply colours below are tuned against this one background. */
|
||||
.term{--tf:#d7e0ea;--tdim:#6d7c8f;--tacc:#58a6ff;--tgrn:#57c26e;--terr:#ff7b72;--twarn:#e3b341;
|
||||
background:#0b0e12;color:var(--tf);border:1px solid #202832;border-radius:12px;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
|
||||
font-size:13px;line-height:1.5;display:flex;flex-direction:column;overflow:hidden;
|
||||
/* generous by design: cliFit() clamps this down to the room actually left
|
||||
below the tabs, so the terminal fills the screen without scrolling it */
|
||||
height:78vh;min-height:240px}
|
||||
/* >=16px stops iOS zooming the page when the prompt takes focus (same reason the
|
||||
form inputs are 16px) — and on a phone 16px mono is the readable size anyway. */
|
||||
@media(pointer:coarse){.term{font-size:16px}}
|
||||
/* body's bottom padding reserves room for the save bar so it can't cover the
|
||||
last card. The CLI tab sizes itself around the save bar instead (cliFit), so
|
||||
the reservation there would only add dead space below the terminal. */
|
||||
body.tab-cli{padding-bottom:0}
|
||||
.term-hd{display:flex;align-items:center;gap:8px;flex:none;padding:6px 10px;
|
||||
background:#11161d;border-bottom:1px solid #202832;font-size:.82em;color:var(--tdim)}
|
||||
.term-hd b{font-weight:500;color:var(--tgrn);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.term-hd .sp{flex:1}
|
||||
.term-hd button{flex:none;background:none;border:1px solid #2a333f;border-radius:5px;
|
||||
color:var(--tdim);font:inherit;padding:1px 8px;cursor:pointer}
|
||||
.term-hd button:hover{color:var(--tf);border-color:#3d4a5a}
|
||||
.term-out{flex:1;overflow-y:auto;overflow-x:hidden;padding:10px;-webkit-overflow-scrolling:touch}
|
||||
.term-out>div{white-space:pre-wrap;word-break:break-word}
|
||||
.term-out .cmd{color:#fff}
|
||||
.term-out .cmd:before{content:"> ";color:var(--tgrn)}
|
||||
.term-out .rep{color:#a9b7c6}
|
||||
.term-out .err{color:var(--terr)}
|
||||
.term-out .sys{color:var(--tdim)}
|
||||
.term-out .gap{height:.55em}
|
||||
/* zero-height rail: the suggestion list hangs off it and overlays the output
|
||||
instead of reflowing it, so the line you are typing never moves */
|
||||
.term-anchor{position:relative;height:0;flex:none;z-index:2}
|
||||
/* --sugmax is set from the terminal's own height (see cliFit) so the list can
|
||||
never be taller than the box it hangs inside and lose rows off the top */
|
||||
.term-sug{position:absolute;left:0;right:0;bottom:0;max-height:var(--sugmax,44vh);overflow-y:auto;
|
||||
background:#0e131a;border-top:1px solid #202832;box-shadow:0 -10px 24px rgba(0,0,0,.55)}
|
||||
.sg{display:flex;gap:10px;align-items:baseline;padding:5px 10px;cursor:pointer}
|
||||
.sg b{flex:none;font-weight:500;color:#cfe3ff;white-space:pre}
|
||||
.sg b u{color:var(--tacc);font-weight:700;text-decoration:none}
|
||||
.sg i{min-width:0;font-style:normal;font-size:.84em;color:var(--tdim);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.sg.on{background:#1e2b3a;box-shadow:inset 3px 0 0 var(--tacc)}
|
||||
.sg.on b{color:#fff}
|
||||
.sg.on i{color:#8b9bad}
|
||||
.term-line{display:flex;align-items:center;gap:8px;flex:none;padding:8px 10px;
|
||||
background:#0e131a;border-top:1px solid #202832}
|
||||
.term-ps{flex:none;color:var(--tgrn)}
|
||||
#term-in{flex:1;min-width:0;padding:0;border:0;border-radius:0;background:none;color:#fff;
|
||||
font:inherit;outline:none;caret-color:var(--tgrn)}
|
||||
#term-in:focus{border:0;box-shadow:none}
|
||||
#term-in::placeholder{color:#495767}
|
||||
#term-in:disabled{color:var(--tdim)}
|
||||
.term-go{flex:none;padding:2px 10px;border:1px solid #2a333f;border-radius:6px;
|
||||
background:none;color:var(--tdim);font:inherit;cursor:pointer}
|
||||
.term-go:hover{color:var(--tgrn);border-color:#3d4a5a}
|
||||
.term-go:disabled{opacity:.4;cursor:default}
|
||||
/* pasted-sequence confirmation, rendered in the scrollback rather than as a
|
||||
modal so it reads as part of the session (and behaves on a phone) */
|
||||
.term-cfm{border:1px solid #2a333f;border-left:3px solid var(--twarn);border-radius:8px;
|
||||
background:#12181f;padding:8px 10px;margin:6px 0}
|
||||
.term-cfm .h{color:var(--twarn);margin-bottom:5px}
|
||||
/* --n is the width of the "12 " gutter: hang the wrap so a long command's
|
||||
continuation lines up under the command, not under its number */
|
||||
.term-cfm .ln{color:#a9b7c6;white-space:pre-wrap;word-break:break-word;
|
||||
padding-left:var(--n,3ch);text-indent:calc(-1 * var(--n,3ch))}
|
||||
.term-cfm .ln s{text-decoration:none;color:var(--tdim)}
|
||||
.term-cfm .w{color:var(--twarn);margin-top:5px;font-size:.9em}
|
||||
.term-cfm .btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||||
.term-cfm button{border:1px solid #2a333f;border-radius:6px;background:#1b2530;color:var(--tf);
|
||||
font:inherit;padding:4px 12px;cursor:pointer}
|
||||
.term-cfm button.go{background:#1d3b28;border-color:#2f6b45;color:#8fe0a5}
|
||||
.term-cfm button:disabled{opacity:.45;cursor:default}
|
||||
.cli-tip{font-size:12px;color:var(--mut);margin-top:9px;display:flex;gap:12px;flex-wrap:wrap}
|
||||
.cli-tip kbd{font:inherit;font-family:ui-monospace,Menlo,monospace;background:var(--chip);
|
||||
border-radius:4px;padding:0 4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -249,6 +328,7 @@ canvas{width:100%;height:56px;display:block}
|
||||
<button data-t="mqtt">MQTT</button>
|
||||
<button data-t="wifi">WiFi</button>
|
||||
<button data-t="stats">Stats</button>
|
||||
<button data-t="cli">CLI</button>
|
||||
</div>
|
||||
|
||||
<div id="t-radio">
|
||||
@@ -410,6 +490,31 @@ canvas{width:100%;height:56px;display:block}
|
||||
<div id="stat-slots" style="font-size:13.5px;color:var(--mut)">No data yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="t-cli" class="hide">
|
||||
<div class="card" style="padding:12px">
|
||||
<div class="term" id="term">
|
||||
<div class="term-hd">
|
||||
<b id="term-host">meshcore</b><span class="sp"></span>
|
||||
<button type="button" onmousedown="event.preventDefault()" onclick="cliHelp()">help</button>
|
||||
<button type="button" onmousedown="event.preventDefault()" onclick="cliClear()">clear</button>
|
||||
</div>
|
||||
<div class="term-out" id="term-out"></div>
|
||||
<div class="term-anchor"><div class="term-sug hide" id="term-sug"></div></div>
|
||||
<div class="term-line">
|
||||
<span class="term-ps">></span>
|
||||
<input type="text" id="term-in" autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||
spellcheck="false" enterkeyhint="go" placeholder="type a command">
|
||||
<button class="term-go" type="button" onmousedown="event.preventDefault()"
|
||||
onclick="cliSubmit()">run</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-tip">
|
||||
<span><kbd>Tab</kbd> complete</span><span><kbd>↑</kbd><kbd>↓</kbd> history</span>
|
||||
<span><kbd>Esc</kbd> dismiss</span><span>paste multiple lines to run a sequence</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -961,13 +1066,20 @@ function loadConfigSoft(){ // re-sync accepted values without clobbering chips/r
|
||||
/* ---------- tabs / app ---------- */
|
||||
function enterApp(){
|
||||
show("#v-app");
|
||||
// Setup mode authenticates by proximity — there is no admin password yet. The
|
||||
// form batch is allowlisted (WebConfigKeys.h) so that is safe there, but the
|
||||
// CLI is the entire command surface, `erase` and `set prv.key` included, so
|
||||
// it stays behind a real login.
|
||||
$('#tabs button[data-t="cli"]').classList.toggle("hide",st.mode==="setup");
|
||||
loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")});
|
||||
}
|
||||
$("#tabs").addEventListener("click",function(ev){
|
||||
var b=ev.target.closest("button");if(!b)return;
|
||||
$$("#tabs button").forEach(function(x){x.classList.toggle("on",x===b)});
|
||||
["radio","mqtt","wifi","stats"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
|
||||
["radio","mqtt","wifi","stats","cli"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
|
||||
if(b.dataset.t==="stats")startStats();else stopStats();
|
||||
document.body.classList.toggle("tab-cli",b.dataset.t==="cli");
|
||||
if(b.dataset.t==="cli")enterCli();
|
||||
});
|
||||
|
||||
/* ---------- stats ---------- */
|
||||
@@ -1285,6 +1397,604 @@ function showReboot(msg,reconnect,title){
|
||||
},1000);
|
||||
}
|
||||
|
||||
/* ---------- CLI: command reference ----------
|
||||
Drives autocomplete only — the node remains the authority on what it accepts.
|
||||
A trailing space marks a command that takes an argument, so accepting the
|
||||
completion leaves the cursor ready for the value. */
|
||||
var CLI_MAX=64; // commands per submitted sequence
|
||||
// [command, description]
|
||||
var CLI_VERBS=[
|
||||
["ver","Firmware version"],
|
||||
["board","Board and hardware info"],
|
||||
["clock","Show the device clock (UTC)"],
|
||||
["clock sync","Set the device clock from this browser"],
|
||||
["time ","Set the clock {epoch-seconds}"],
|
||||
["region","Show the configured region"],
|
||||
["memory","Heap and PSRAM free/min/largest block"],
|
||||
["neighbors","Nodes heard recently, with RSSI and age"],
|
||||
["neighbor.remove ","Drop one neighbour {64-hex-char-key}"],
|
||||
["advert","Send an advert now (flooded)"],
|
||||
["advert.zerohop","Send an advert neighbours will not repeat"],
|
||||
["tempradio ","Try radio params without saving {freq,bw,sf,cr}"],
|
||||
["clear stats","Reset the packet and radio counters"],
|
||||
["stats-core","Core counters (recv, sent, airtime)"],
|
||||
["stats-packets","Per-packet-type counters"],
|
||||
["stats-radio","Radio counters (RSSI, SNR, noise)"],
|
||||
["stats-radio-diag","Extended radio diagnostics"],
|
||||
["log","Show the packet log summary"],
|
||||
["log start","Start packet logging to the filesystem"],
|
||||
["log stop","Stop packet logging"],
|
||||
["log erase","Delete the stored packet logs"],
|
||||
["sensor list","List attached sensors"],
|
||||
["sensor get ","Read one sensor {index}"],
|
||||
["sensor set ","Write one sensor {index} {value}"],
|
||||
["gps on","Power up the GPS"],
|
||||
["gps off","Power down the GPS"],
|
||||
["gps sync","Set the clock and location from the GPS"],
|
||||
["gps setloc","Copy the current GPS fix into lat/lon"],
|
||||
["gps advert none","Do not include GPS position in adverts"],
|
||||
["gps advert share","Advertise the live GPS position"],
|
||||
["gps advert prefs","Advertise the stored lat/lon"],
|
||||
["powersaving","Show the power-saving mode"],
|
||||
["powersaving on","Enable power saving"],
|
||||
["powersaving off","Disable power saving"],
|
||||
["password ","Change the admin password {new-password}"],
|
||||
["alert test","Send a test alert on the configured channel"],
|
||||
["ota check","Check for a newer build (does not flash)"],
|
||||
["ota update","Download and flash the newer build, then reboot"],
|
||||
["start ota","Raise the manual firmware-upload AP"],
|
||||
["start webconfig","Start this portal on the LAN"],
|
||||
["start webconfig ap","Start this portal on its own setup AP"],
|
||||
["stop webconfig","Stop this portal"],
|
||||
["reboot","Restart the node"],
|
||||
["clkreboot","Restart the node, preserving the clock"],
|
||||
["poweroff","Power the node off"],
|
||||
["erase","Erase the filesystem — settings and identity"]
|
||||
];
|
||||
/* [key, description, mode, values]
|
||||
mode: 0 = get and set, 1 = get only, 2 = set only
|
||||
values: enum offered as value completions after `set <key> ` */
|
||||
var CLI_KEYS=[
|
||||
["name","Node name",0],
|
||||
["lat","Advert latitude",0],
|
||||
["lon","Advert longitude",0],
|
||||
["public.key","This node's public key",1],
|
||||
["prv.key","Restore an identity {64-hex-char-key}",2],
|
||||
["role","Node role",1],
|
||||
["radio","Radio parameters {freq,bw,sf,cr}",0],
|
||||
["freq","Frequency in MHz",0],
|
||||
["tx","TX power in dBm",0],
|
||||
["af","Airtime factor",0],
|
||||
["dutycycle","Duty cycle percent (writes airtime factor)",0],
|
||||
["cad","Listen before transmit",0,"on|off"],
|
||||
["radio.rxgain","SX126x RX boosted gain",0,"on|off"],
|
||||
["radio.fem.rxgain","Front-end module RX gain",0],
|
||||
["radio.watchdog","Restart the radio if silent this long {0-120 min}",0],
|
||||
["int.thresh","Interference threshold",0],
|
||||
["agc.reset.interval","AGC reset interval in seconds",0],
|
||||
["rxdelay","RX delay base",0],
|
||||
["txdelay","TX delay factor {0-2}",0],
|
||||
["direct.txdelay","TX delay factor for direct packets {0-2}",0],
|
||||
["repeat","Forward mesh traffic",0,"on|off"],
|
||||
["multi.acks","Extra ACKs to send",0],
|
||||
["allow.read.only","Allow read-only remote access",0,"on|off"],
|
||||
["advert.interval","Local advert interval in minutes (0 = off)",0],
|
||||
["flood.advert.interval","Flood advert interval in hours (0 = off)",0],
|
||||
["flood.max","Max flood hops {0-64}",0],
|
||||
["flood.max.advert","Max advert hops {0-64}",0],
|
||||
["flood.max.unscoped","Max hops for unscoped floods {0-64}",0],
|
||||
["loop.detect","Drop floods already carrying this node's hash",0,"off|minimal|moderate|strict"],
|
||||
["path.hash.mode","Path hash mode {0|1|2}",0],
|
||||
["owner.info","Owner info text (| becomes a newline)",0],
|
||||
["guest.password","Guest password",0],
|
||||
["adc.multiplier","Battery ADC multiplier",0],
|
||||
["wifi.ssid","WiFi network name",0],
|
||||
["wifi.pwd","WiFi password",0],
|
||||
["wifi.powersave","WiFi power-save mode",0,"none|min|max"],
|
||||
["wifi.status","WiFi connection, IP, RSSI and uptime",1],
|
||||
["mqtt.origin","Observer name in published messages",0],
|
||||
["mqtt.iata","IATA region code used in topic paths",0],
|
||||
["mqtt.status","Per-slot connection status",1],
|
||||
["mqtt.stats","Publish/queue counters",1],
|
||||
["mqtt.presets","Available broker presets",1],
|
||||
["mqtt.config.valid","Whether the MQTT config is usable",1],
|
||||
["mqtt.packets","Publish packet messages",0,"on|off"],
|
||||
["mqtt.raw","Also publish full raw frames",0,"on|off"],
|
||||
["mqtt.rx","Publish packets heard over the air",0,"on|off"],
|
||||
["mqtt.tx","Publish packets this node sends",0,"off|on|advert"],
|
||||
["mqtt.interval","Status publish interval {1-60 min}",0],
|
||||
["mqtt.neighbors","Publish the neighbour table (PSRAM boards)",0,"on|off"],
|
||||
["mqtt.neighbors.interval","Neighbour publish interval {12-336 hours}",0],
|
||||
["mqtt.owner","Owner public key {64-hex-char-key}",0],
|
||||
["mqtt.email","Owner email address",0],
|
||||
["mqtt.ntp","NTP server (none clears it)",0],
|
||||
["mqtt.ntp.diag","Last NTP sync result",1],
|
||||
["timezone","POSIX timezone string",0],
|
||||
["timezone.offset","UTC offset in hours {-12 to 14}",0],
|
||||
["snmp","SNMP agent (restart required)",0,"on|off"],
|
||||
["snmp.community","SNMP community string",0],
|
||||
["alert","Alert channel",0,"on|off"],
|
||||
["alert.psk","Alert channel pre-shared key",0],
|
||||
["alert.hashtag","Alert channel hashtag",0],
|
||||
["alert.region","Alert region filter",0],
|
||||
["alert.interval","Minimum minutes between alerts",0],
|
||||
["alert.mqtt","Send alerts to MQTT",0,"on|off"],
|
||||
["alert.wifi","Alert on WiFi problems",0,"on|off"],
|
||||
["bridge.enabled","Serial packet bridge",0,"on|off"],
|
||||
["bridge.source","Packets the bridge carries",0,"rx|tx"],
|
||||
["bridge.baud","Bridge serial baud rate",0],
|
||||
["bridge.delay","Bridge send delay",0],
|
||||
["bridge.channel","Bridge channel",0],
|
||||
["bridge.secret","Bridge shared secret",0]
|
||||
];
|
||||
// Per-slot keys, expanded across the slots this board actually runs.
|
||||
// [field, description, values]
|
||||
var CLI_SLOT=[
|
||||
["preset","preset",1],
|
||||
["server","custom broker hostname"],
|
||||
["port","broker port {1-65535}"],
|
||||
["username","username"],
|
||||
["password","password"],
|
||||
["token","token (required by some presets)"],
|
||||
["topic","custom topic template, e.g. {iata}/{device}/{type}"],
|
||||
["audience","JWT audience — enables Ed25519 auth, blank clears"],
|
||||
["filter","packet types to publish: all, none, or a CSV of names/0-15"]
|
||||
];
|
||||
var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,trace,multipart,control,raw_custom".split(",");
|
||||
|
||||
var cli={built:0,tbl:[],hist:[],hix:-1,draft:"",sug:[],sel:-1,busy:false,shown:false};
|
||||
|
||||
// Rebuilt when the slot count changes: `active_slots` decides how many
|
||||
// mqttN.* keys actually exist on this board.
|
||||
function cliTable(){
|
||||
if(cli.built===st.nslots)return cli.tbl;
|
||||
var t=CLI_VERBS.slice();
|
||||
CLI_KEYS.forEach(function(k){
|
||||
var vals=k[3]?" {"+k[3]+"}":"";
|
||||
if(k[2]!==2)t.push(["get "+k[0],k[1]]);
|
||||
if(k[2]!==1)t.push(["set "+k[0]+" ",k[1]+vals]);
|
||||
});
|
||||
for(var n=1;n<=st.nslots;n++){
|
||||
CLI_SLOT.forEach(function(f){
|
||||
t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]);
|
||||
t.push(["set mqtt"+n+"."+f[0]+" ","Slot "+n+" "+f[1]]);
|
||||
});
|
||||
}
|
||||
cli.tbl=t;cli.built=st.nslots;
|
||||
return t;
|
||||
}
|
||||
// Value completions for `set <key> `. Returns null when the key has no enum,
|
||||
// which is also how the command list is suppressed once a value is being typed.
|
||||
function cliEnum(key){
|
||||
var m=key.match(/^mqtt[1-9]\.(\w+)$/);
|
||||
if(m){
|
||||
if(m[1]==="preset")return st.presets.map(function(p){return p.name}).concat(["custom","none"]);
|
||||
if(m[1]==="filter")return ["all","none"].concat(CLI_TYPES);
|
||||
return null;
|
||||
}
|
||||
for(var i=0;i<CLI_KEYS.length;i++){
|
||||
if(CLI_KEYS[i][0]===key)return CLI_KEYS[i][3]?CLI_KEYS[i][3].split("|"):null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- CLI: scrollback ---------- */
|
||||
function cliEcho(cls,text){
|
||||
var out=$("#term-out");
|
||||
// Keep the view pinned to the newest line only when it already was — a
|
||||
// sequence still running must not yank the scrollback out from under someone
|
||||
// reading further up.
|
||||
var atEnd=out.scrollTop+out.clientHeight>=out.scrollHeight-24;
|
||||
var d=document.createElement("div");
|
||||
d.className=cls;d.textContent=text;
|
||||
out.appendChild(d);
|
||||
while(out.childNodes.length>400)out.removeChild(out.firstChild);
|
||||
if(atEnd)out.scrollTop=out.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
function cliGap(){var d=cliEcho("gap","");return d}
|
||||
function cliClear(){$("#term-out").innerHTML="";cliBanner();$("#term-in").focus()}
|
||||
function cliBanner(){
|
||||
cliEcho("sys","MeshCore · "+($("#h-sub").textContent||""));
|
||||
cliEcho("sys","Type help for a summary, or a prefix and Tab to complete.");
|
||||
}
|
||||
function cliHelp(){
|
||||
cliGap();
|
||||
cliEcho("cmd","help");
|
||||
cliEcho("rep",
|
||||
"ver / board / clock identity, firmware and time\n"+
|
||||
"get <key> read any setting\n"+
|
||||
"set <key> <value> change any setting\n"+
|
||||
"advert send an advert now\n"+
|
||||
"neighbors nodes heard recently\n"+
|
||||
"memory / stats-core health counters\n"+
|
||||
"get mqtt.status per-slot broker connections\n"+
|
||||
"reboot restart the node");
|
||||
cliEcho("sys","Tab completes any prefix ("+cliTable().length+" commands known). "+
|
||||
"Paste several lines to run them in sequence. clear empties this window.");
|
||||
$("#term-in").focus();
|
||||
}
|
||||
|
||||
/* ---------- CLI: autocomplete ---------- */
|
||||
function cliSugOpen(){return !$("#term-sug").classList.contains("hide")}
|
||||
function cliHideSug(){$("#term-sug").classList.add("hide");$("#term-sug").innerHTML="";cli.sug=[];cli.sel=-1}
|
||||
function cliLoose(hay,q){
|
||||
var parts=q.split(/\s+/);
|
||||
for(var i=0;i<parts.length;i++){if(parts[i]&&hay.indexOf(parts[i])<0)return false}
|
||||
return true;
|
||||
}
|
||||
// Match state for the current input: {list, pre} where `pre` is the number of
|
||||
// leading characters that matched literally (underlined in the row).
|
||||
function cliMatch(raw){
|
||||
var s=raw.replace(/^\s+/,"");
|
||||
if(!s)return{list:[],pre:0};
|
||||
var vs=cliValueMatch(s);
|
||||
if(vs)return vs;
|
||||
var lo=s.toLowerCase(),pre=[],loose=[];
|
||||
cliTable().forEach(function(e){
|
||||
var t=e[0].toLowerCase();
|
||||
if(t.indexOf(lo)===0)pre.push(e);
|
||||
else if(loose.length<40&&cliLoose(t,lo))loose.push(e);
|
||||
});
|
||||
if(pre.length)return{list:pre.slice(0,60),pre:s.length};
|
||||
return{list:loose.slice(0,40),pre:0};
|
||||
}
|
||||
// Once `set <key> ` is complete, complete the VALUE. Keys without an enum
|
||||
// return an empty list so the command rows don't reappear under a typed value.
|
||||
function cliValueMatch(s){
|
||||
var m=s.match(/^set\s+(\S+)\s+(.*)$/);
|
||||
if(!m)return null;
|
||||
var vals=cliEnum(m[1]);
|
||||
if(!vals)return{list:[],pre:0};
|
||||
var head=s.slice(0,s.length-m[2].length),typed=m[2];
|
||||
// filter lists are CSV: complete the segment after the last comma
|
||||
var cut=typed.lastIndexOf(",");
|
||||
if(cut>=0){head+=typed.slice(0,cut+1);typed=typed.slice(cut+1)}
|
||||
var lo=typed.toLowerCase(),out=[];
|
||||
vals.forEach(function(v){
|
||||
if(v.toLowerCase().indexOf(lo)===0)out.push([head+v,cliValueNote(m[1],v)]);
|
||||
});
|
||||
return{list:out,pre:head.length+typed.length};
|
||||
}
|
||||
// The value is already the row's label, so the second column only earns its
|
||||
// place when it says something the value doesn't.
|
||||
function cliValueNote(key,val){
|
||||
if(/^mqtt[1-9]\.preset$/.test(key)){
|
||||
for(var i=0;i<st.presets.length;i++){
|
||||
if(st.presets[i].name!==val)continue;
|
||||
if(st.presets[i].needs==="token")return"needs a token";
|
||||
if(st.presets[i].needs==="userpass")return"needs a username and password";
|
||||
return"";
|
||||
}
|
||||
return val==="none"?"leave this slot unused":(val==="custom"?"configure the broker by hand":"");
|
||||
}
|
||||
if(/^mqtt[1-9]\.filter$/.test(key)){
|
||||
var n=CLI_TYPES.indexOf(val);
|
||||
return n<0?"":"packet type "+(val==="raw_custom"?15:n);
|
||||
}
|
||||
return"";
|
||||
}
|
||||
function cliSug(){
|
||||
var m=cliMatch($("#term-in").value);
|
||||
cli.sug=m.list;cli.sel=-1;
|
||||
var box=$("#term-sug");
|
||||
if(!m.list.length){cliHideSug();return}
|
||||
var h="";
|
||||
m.list.forEach(function(e,i){
|
||||
var head=esc(e[0].slice(0,m.pre)),tail=esc(e[0].slice(m.pre));
|
||||
h+='<div class="sg" data-i="'+i+'"><b>'+(m.pre?"<u>"+head+"</u>":head)+tail+"</b>"+
|
||||
(e[1]?'<i>'+esc(e[1])+"</i>":"")+"</div>";
|
||||
});
|
||||
box.innerHTML=h;box.classList.remove("hide");box.scrollTop=0;
|
||||
}
|
||||
function cliPaint(){
|
||||
$$("#term-sug .sg").forEach(function(el,i){
|
||||
var on=i===cli.sel;el.classList.toggle("on",on);
|
||||
if(on&&el.scrollIntoView)el.scrollIntoView({block:"nearest"});
|
||||
});
|
||||
}
|
||||
function cliMove(d){
|
||||
if(!cli.sug.length)return;
|
||||
cli.sel=cli.sel<0?(d>0?0:cli.sug.length-1):(cli.sel+d+cli.sug.length)%cli.sug.length;
|
||||
cliPaint();
|
||||
}
|
||||
function cliAccept(i){
|
||||
var e=cli.sug[i];if(!e)return;
|
||||
var inp=$("#term-in");
|
||||
inp.value=e[0];inp.focus();
|
||||
cli.hix=-1;
|
||||
cliSug(); // re-filter: a command that takes an argument now offers values
|
||||
}
|
||||
function cliTabKey(){
|
||||
if(!cli.sug.length){cliSug();if(!cli.sug.length)return}
|
||||
if(cli.sel>=0){cliAccept(cli.sel);return}
|
||||
// shell behaviour: extend to the longest prefix every match shares before
|
||||
// committing to any single one
|
||||
var cur=$("#term-in").value.replace(/^\s+/,""),lcp=cli.sug[0][0];
|
||||
cli.sug.forEach(function(e){
|
||||
var n=0;while(n<lcp.length&&n<e[0].length&&lcp.charAt(n)===e[0].charAt(n))n++;
|
||||
lcp=lcp.slice(0,n);
|
||||
});
|
||||
if(lcp.length>cur.length){$("#term-in").value=lcp;cliSug();return}
|
||||
cliAccept(0);
|
||||
}
|
||||
$("#term-sug").addEventListener("mousedown",function(ev){ev.preventDefault()});
|
||||
$("#term-sug").addEventListener("click",function(ev){
|
||||
var r=ev.target.closest(".sg");if(!r)return;
|
||||
cliAccept(+r.dataset.i);
|
||||
});
|
||||
|
||||
/* ---------- CLI: history ----------
|
||||
In memory for the session only, never localStorage: `set wifi.pwd …` and
|
||||
`password …` pass through here and must not outlive the tab. */
|
||||
function cliPush(line){
|
||||
if(cli.hist[cli.hist.length-1]!==line)cli.hist.push(line);
|
||||
if(cli.hist.length>60)cli.hist.shift();
|
||||
cli.hix=-1;
|
||||
}
|
||||
function cliHistMove(d){
|
||||
var inp=$("#term-in");
|
||||
if(!cli.hist.length)return;
|
||||
if(cli.hix<0){cli.draft=inp.value;cli.hix=cli.hist.length}
|
||||
cli.hix+=d;
|
||||
if(cli.hix<0)cli.hix=0;
|
||||
if(cli.hix>=cli.hist.length){cli.hix=-1;inp.value=cli.draft}
|
||||
else inp.value=cli.hist[cli.hix];
|
||||
cliHideSug();
|
||||
var n=inp.value.length;
|
||||
try{inp.setSelectionRange(n,n)}catch(e){}
|
||||
}
|
||||
|
||||
/* ---------- CLI: input ---------- */
|
||||
$("#term-in").addEventListener("input",function(){cli.hix=-1;cliSug()});
|
||||
$("#term-in").addEventListener("blur",function(){setTimeout(cliHideSug,120)});
|
||||
$("#term-in").addEventListener("focus",function(){cliFit()});
|
||||
$("#term-in").addEventListener("keydown",function(ev){
|
||||
var k=ev.key;
|
||||
if(k==="Tab"){ev.preventDefault();cliTabKey();return}
|
||||
if(k==="ArrowUp"||k==="ArrowDown"){
|
||||
var d=k==="ArrowDown"?1:-1;
|
||||
ev.preventDefault();
|
||||
// The list owns the arrows while it is open; history takes them back once
|
||||
// it is dismissed, which is what Esc is for.
|
||||
if(cliSugOpen())cliMove(d);else cliHistMove(d);
|
||||
return;
|
||||
}
|
||||
if(k==="Enter"){
|
||||
ev.preventDefault();
|
||||
// Enter runs what is typed, unless a suggestion was deliberately selected
|
||||
// with the arrows — then it accepts, and a second Enter runs.
|
||||
if(cliSugOpen()&&cli.sel>=0){cliAccept(cli.sel);return}
|
||||
cliSubmit();return;
|
||||
}
|
||||
if(k==="Escape"){
|
||||
if(cliSugOpen()){cliHideSug();return}
|
||||
this.value="";cli.hix=-1;return;
|
||||
}
|
||||
if((k==="l"||k==="L")&&ev.ctrlKey){ev.preventDefault();cliClear()}
|
||||
});
|
||||
// Multi-line paste: a pasted list is a sequence to confirm, not a line to
|
||||
// mangle. (A single-line paste falls through to the browser's own insert.)
|
||||
$("#term-in").addEventListener("paste",function(ev){
|
||||
var cb=ev.clipboardData||window.clipboardData;
|
||||
if(!cb)return;
|
||||
var text=cb.getData("text")||"";
|
||||
if(!/[\r\n]/.test(text))return;
|
||||
ev.preventDefault();
|
||||
var cmds=cliParse(text);
|
||||
// Whatever was already typed joins the first pasted line verbatim — trimming
|
||||
// it would eat the space in a half-typed "set " and silently fuse the words.
|
||||
var carry=this.value;
|
||||
if(carry.trim()&&cmds.length){cmds[0]=carry+cmds[0];this.value=""}
|
||||
if(!cmds.length){cliEcho("sys","Nothing to run — that paste held no commands.");return}
|
||||
if(cmds.length===1){this.value=cmds[0];cliSug();return}
|
||||
cliHideSug();
|
||||
cliConfirm(cmds);
|
||||
});
|
||||
function cliParse(text){
|
||||
var out=[];
|
||||
text.split(/\r\n|\r|\n/).forEach(function(raw){
|
||||
var l=raw.trim();
|
||||
if(!l||l.charAt(0)==="#")return; // blanks and comments
|
||||
l=l.replace(/^(?:[>$]|meshcore\s*[>$#])\s+/,""); // tolerate a pasted transcript
|
||||
if(l)out.push(l);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- CLI: confirmation ----------
|
||||
Rendered into the scrollback rather than as a modal: it reads as part of the
|
||||
session, and on a phone it can't end up behind the keyboard. */
|
||||
var CLI_RISK=[
|
||||
[/^erase$/,"erases the filesystem — stored settings and this node's identity"],
|
||||
[/^(reboot|clkreboot)$/,"restarts the node"],
|
||||
[/^(poweroff|shutdown)$/,"powers the node off"],
|
||||
[/^ota update$/,"downloads and flashes new firmware, then reboots"],
|
||||
[/^start ota$/,"drops this portal and raises the firmware-upload AP"],
|
||||
[/^stop webconfig$/,"stops this portal"],
|
||||
[/^set wifi\.(ssid|pwd)\b/,"changes WiFi — this page will drop"],
|
||||
[/^set radio\b/,"changes radio parameters — a wrong value takes this node off the air"],
|
||||
[/^set freq\b/,"changes the frequency — a wrong value takes this node off the air"],
|
||||
[/^password\b/,"changes the admin password"],
|
||||
[/^set prv\.key\b/,"replaces this node's identity"]
|
||||
];
|
||||
function cliRisks(cmds){
|
||||
var seen={},out=[];
|
||||
cmds.forEach(function(c){
|
||||
CLI_RISK.forEach(function(r){
|
||||
if(r[0].test(c)&&!seen[r[1]]){seen[r[1]]=1;out.push(r[1])}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function cliConfirm(cmds){
|
||||
var over=cmds.length>CLI_MAX;
|
||||
if(over)cmds=cmds.slice(0,CLI_MAX);
|
||||
var risks=cliRisks(cmds),one=cmds.length===1;
|
||||
var out=$("#term-out"),box=document.createElement("div");
|
||||
box.className="term-cfm";
|
||||
var h=document.createElement("div");h.className="h";
|
||||
h.textContent=one?"Run this command?":("Run these "+cmds.length+" commands in order?");
|
||||
box.appendChild(h);
|
||||
var w=String(cmds.length).length;
|
||||
box.style.setProperty("--n",(w+2)+"ch");
|
||||
cmds.forEach(function(c,i){
|
||||
var d=document.createElement("div");d.className="ln";
|
||||
var n=document.createElement("s");
|
||||
n.textContent=(new Array(w-String(i+1).length+1)).join(" ")+(i+1)+" ";
|
||||
d.appendChild(n);d.appendChild(document.createTextNode(c));
|
||||
box.appendChild(d);
|
||||
});
|
||||
if(over){
|
||||
var t=document.createElement("div");t.className="w";
|
||||
t.textContent="Only the first "+CLI_MAX+" lines are included — the rest were dropped.";
|
||||
box.appendChild(t);
|
||||
}
|
||||
if(risks.length){
|
||||
var r=document.createElement("div");r.className="w";
|
||||
r.textContent="⚠ This "+risks.join("; and ")+".";
|
||||
box.appendChild(r);
|
||||
}
|
||||
var btns=document.createElement("div");btns.className="btns";
|
||||
var go=document.createElement("button");go.className="go";
|
||||
go.textContent=one?"Run":("Run "+cmds.length+" commands");
|
||||
var no=document.createElement("button");no.textContent="Cancel";
|
||||
btns.appendChild(go);btns.appendChild(no);box.appendChild(btns);
|
||||
function settle(text){
|
||||
btns.remove();
|
||||
var s=document.createElement("div");s.className="w";s.textContent=text;box.appendChild(s);
|
||||
}
|
||||
go.onmousedown=no.onmousedown=function(ev){ev.preventDefault()};
|
||||
go.onclick=function(){settle(one?"Running…":"Running "+cmds.length+" commands…");cliRun(cmds)};
|
||||
no.onclick=function(){settle("Cancelled — nothing was sent.");$("#term-in").focus()};
|
||||
out.appendChild(box);out.scrollTop=out.scrollHeight;
|
||||
$("#term-in").blur(); // the sequence needs an answer before more typing
|
||||
}
|
||||
|
||||
/* ---------- CLI: run ----------
|
||||
Same 202 + reqid + poll contract as a config save, and for the same reason:
|
||||
the commands run on the node's main loop, not inside the request. Results
|
||||
stream back, so a long sequence fills the window as it executes. */
|
||||
function cliSubmit(){
|
||||
if(cli.busy)return;
|
||||
var inp=$("#term-in"),line=inp.value.trim();
|
||||
cliHideSug();
|
||||
if(!line)return;
|
||||
cliPush(line);inp.value="";
|
||||
if(line==="clear"||line==="cls"){cliClear();return}
|
||||
if(line==="help"||line==="?"){cliHelp();return}
|
||||
if(cliRisks([line]).length){cliConfirm([line]);return}
|
||||
cliRun([line]);
|
||||
}
|
||||
function cliBusy(on){
|
||||
cli.busy=on;
|
||||
$("#term-in").disabled=on;
|
||||
$(".term-go").disabled=on;
|
||||
if(!on&&window.matchMedia("(pointer:fine)").matches)$("#term-in").focus();
|
||||
}
|
||||
function cliRun(cmds){
|
||||
cliBusy(true);
|
||||
cliGap();
|
||||
var reqid=mkReqId();
|
||||
var status=cliEcho("sys",cmds.length>1?"running 0/"+cmds.length+"…":"…");
|
||||
post("/api/cli",{reqid:reqid,cmds:cmds}).then(function(){
|
||||
cliPoll(reqid,cmds,0,status,0,0);
|
||||
}).catch(function(e){
|
||||
if(e.message==="auth"){cliBusy(false);status.remove();return}
|
||||
if(e.status===409&&e.reqid!==reqid){cliEnd(status,"Another sequence is still running — retry shortly.");return}
|
||||
if(e.status===400||e.status===413){cliEnd(status,e.message||"Rejected by the node.");return}
|
||||
// Ambiguous: the request may have landed even though the reply was lost.
|
||||
// Poll for it rather than reporting a failure that did not happen.
|
||||
cliPoll(reqid,cmds,0,status,0,0);
|
||||
});
|
||||
}
|
||||
function cliEnd(status,msg){
|
||||
cliBusy(false);
|
||||
status.className="err";status.textContent=msg;
|
||||
$("#term-out").scrollTop=$("#term-out").scrollHeight;
|
||||
}
|
||||
function cliPoll(reqid,cmds,from,status,errs,idles){
|
||||
api("/api/cli/result?reqid="+encodeURIComponent(reqid)+"&from="+from,{timeout:5000})
|
||||
.then(function(r){
|
||||
if(r.state!=="idle"&&r.reqid!==reqid){cliEnd(status,"Lost track of this sequence — reload to check the node's state.");return}
|
||||
if(r.state==="idle"){
|
||||
// the POST may still be in flight after a connection blip
|
||||
if(idles<5){setTimeout(function(){cliPoll(reqid,cmds,from,status,errs,idles+1)},400);return}
|
||||
cliEnd(status,"The node never received the command.");return;
|
||||
}
|
||||
(r.results||[]).forEach(function(x){
|
||||
cliEcho("cmd",x.cmd);
|
||||
// `ok` is advisory; the node's own OK/Error convention is authoritative
|
||||
var ok=(x.ok!=null)?x.ok:!/^\s*(err|error)\b/i.test(x.reply||"");
|
||||
if(x.reply)cliEcho(ok?"rep":"err",x.reply);
|
||||
});
|
||||
from+=(r.results||[]).length;
|
||||
if(r.state!=="done"){
|
||||
if(cmds.length>1)status.textContent="running "+from+"/"+cmds.length+"…";
|
||||
setTimeout(function(){cliPoll(reqid,cmds,from,status,0,0)},250);
|
||||
return;
|
||||
}
|
||||
status.remove();
|
||||
cliBusy(false);
|
||||
cliAfter(cmds);
|
||||
}).catch(function(e){
|
||||
if(e.message==="auth"){cliBusy(false);status.remove();return}
|
||||
if(e.status===400||e.status===404){cliEnd(status,"The node no longer has a result for this sequence.");return}
|
||||
if(errs<20){setTimeout(function(){cliPoll(reqid,cmds,from,status,errs+1,idles)},700);return}
|
||||
cliEnd(status,"Lost the connection while the sequence was running — reload to check the node's state.");
|
||||
});
|
||||
}
|
||||
// A CLI `set` writes the same prefs the forms edit, so re-read them or the
|
||||
// other tabs keep showing stale values.
|
||||
function cliAfter(cmds){
|
||||
var touched=false,restart=null;
|
||||
cmds.forEach(function(c){
|
||||
if(/^(set|password)\s/.test(c))touched=true;
|
||||
if(/^(reboot|clkreboot)$/.test(c))restart=restart||"reboot";
|
||||
if(/^erase$/.test(c))restart=restart||"erase";
|
||||
if(/^(poweroff|shutdown)$/.test(c))restart="off";
|
||||
});
|
||||
if(touched)loadConfigSoft();
|
||||
if(restart==="off")showReboot("The node is powering off. Reconnect power to bring it back.",false,"Powering off…");
|
||||
else if(restart)showReboot("The node is restarting. This page will try to reconnect automatically.",true);
|
||||
}
|
||||
|
||||
/* ---------- CLI: fit ----------
|
||||
dvh/vh both ignore the on-screen keyboard, so on a phone the prompt can end
|
||||
up underneath it. visualViewport is the only thing that reports the space
|
||||
actually left, so size the terminal from that while the CLI tab is open. */
|
||||
function cliFit(){
|
||||
var vv=window.visualViewport,t=$("#term");
|
||||
if(!t||$("#t-cli").classList.contains("hide"))return;
|
||||
t.style.height=""; // back to the stylesheet's height
|
||||
if(!vv)return;
|
||||
var base=t.offsetHeight;
|
||||
// everything still owed below the terminal — the shortcut row, the card's
|
||||
// padding and margin, main's padding — plus the save bar when it is up
|
||||
var extra=$("main").getBoundingClientRect().bottom-t.getBoundingClientRect().bottom+6;
|
||||
var sb=$("#savebar");if(sb.classList.contains("show"))extra+=sb.offsetHeight;
|
||||
var top=t.getBoundingClientRect().top-vv.offsetTop;
|
||||
var avail=Math.round(vv.height-top-extra);
|
||||
// Only ever shrink. Growing to fill the viewport would lengthen the page,
|
||||
// which lets it scroll, which frees more room — a loop that never settles.
|
||||
if(avail<base)t.style.height=Math.max(180,avail)+"px";
|
||||
t.style.setProperty("--sugmax",Math.round(t.offsetHeight*0.62)+"px");
|
||||
var out=$("#term-out");out.scrollTop=out.scrollHeight;
|
||||
}
|
||||
if(window.visualViewport){
|
||||
window.visualViewport.addEventListener("resize",cliFit);
|
||||
window.visualViewport.addEventListener("scroll",cliFit);
|
||||
}
|
||||
function enterCli(){
|
||||
$("#term-host").textContent="meshcore@"+($("#h-name").textContent||"node");
|
||||
if(!cli.shown){cli.shown=true;cliBanner()}
|
||||
window.scrollTo(0,0); // the terminal is the whole tab; show all of it
|
||||
cliFit();
|
||||
// Don't pop the keyboard just because the tab was opened — on a phone that
|
||||
// hides most of the window the operator came here to read.
|
||||
if(window.matchMedia("(pointer:fine)").matches)$("#term-in").focus({preventScroll:true});
|
||||
}
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user