diff --git a/README.md b/README.md index 91fecf8..82db4ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # retina-gui -Python-based web GUI baked into owl-os and deployed to every Retina node. Served locally at `owl.local` and `retina.local` on port 80. +Python-based web GUI baked into owl-os and deployed to every Retina node. Served on port 80 at the node's own `ret.local`, and at the shared `owl.local`, which every node on the network answers. ## Features diff --git a/src/app.py b/src/app.py index 445bc2f..299d27f 100644 --- a/src/app.py +++ b/src/app.py @@ -33,6 +33,9 @@ device_state, mender, network_mgr, + node_name, + peers, + read_node_id, retina_tracker_client, ssh_keys, telemetry_status, @@ -114,6 +117,9 @@ # globally). if "pytest" not in sys.modules: tracker_capture.start() + # Same reasoning: the peer directory owns a browse thread and a probe + # thread, and the probe thread makes real HTTP requests to other nodes. + peers.start() def get_node_id(): @@ -133,17 +139,26 @@ def get_node_id(): # Inject common template variables (navbar, footer) @app.context_processor def inject_globals(): + # Imported here rather than at module scope: the route modules are + # deliberately imported at the bottom of this file, after the app exists. + from routes.fleet import banner_nodes + owl_os_version, retina_node_version = mender.get_versions() return { 'node_id': get_node_id(), 'owl_os_version': owl_os_version, 'retina_node_version': retina_node_version, + # One banner tab per node, on every page. An in-memory list, copied + # and sorted — cheap enough to do per render, and it has to be here + # rather than per-route because the banner is in base.html. + 'fleet_nodes': banner_nodes(), } # Register blueprints from routes.calibrate import bp as calibrate_bp from routes.config import bp as config_bp +from routes.fleet import bp as fleet_bp from routes.home import bp as home_bp from routes.mender_routes import bp as mender_bp from routes.mode import bp as mode_bp @@ -161,6 +176,7 @@ def inject_globals(): app.register_blueprint(network_bp) app.register_blueprint(calibrate_bp) app.register_blueprint(tracker_preview_bp) +app.register_blueprint(fleet_bp) # Paths that must keep working while a run holds the GUI: the config page @@ -172,6 +188,15 @@ def inject_globals(): '/calibrate', # status, cancel, apply '/static', '/favicon', + # Fleet-scope routes belong to whoever is browsing, who may not be anywhere + # near this node and is not the person who started a calibration on it. + # Holding them on this node's config page would be answering a question + # about the fleet with a page about one node — and /healthz is how every + # other node decides whether this one still exists, so a redirect there + # would make a calibrating node look unreachable to its peers. + '/summary', + '/api/fleet', + '/healthz', ) diff --git a/src/mdns_peers.py b/src/mdns_peers.py new file mode 100644 index 0000000..af3f38b --- /dev/null +++ b/src/mdns_peers.py @@ -0,0 +1,338 @@ +"""Discovery of the other owl nodes on this LAN. + +Each node advertises `_owl-node._tcp` over DNS-SD (the service file is written +at boot by owl-mdns-identity, in owl-os). This module keeps a live picture of +who is out there, which is what the fleet landing page renders and what decides +whether `owl.local` shows that page at all. + +## Why a service type rather than looking for host names + +DNS-SD is multi-instance by design: every node advertising the same service type +is the normal case, so there is nothing to collide over — unlike host names, +where two nodes wanting one name is a conflict Avahi has to arbitrate. The SRV +record it generates points at a host *name* rather than an address, so it also +cannot go stale the way an address record can. + +## Why liveness is probed rather than read out of the mDNS cache + +Appearing in a browse is not evidence a node is reachable. RFC 6762 gives +service PTR records a 75-minute TTL (only SRV and A records get the 120-second +one), and a node powered off at the wall sends no goodbye packet. So a node +that has been unplugged keeps showing up in the browse list for over an hour, +long after it stopped answering. + +That matters here specifically because the count drives the landing page. Left +to the cache, a fleet that went from two nodes back to one would keep showing a +two-card page — with one card leading nowhere — for the rest of the afternoon. + +So a node counts as present only when it answers an HTTP probe. Two consecutive +failures are required before it drops off, so that a marginal WiFi link cannot +flip the page between its one-node and many-node forms on every refresh. +""" + +import json +import os +import re +import shutil +import subprocess +import threading +import time + +import requests as http_requests + +SERVICE_TYPE = "_owl-node._tcp" + +# Long enough that a node rebooting does not vanish from the page, short enough +# that one genuinely gone is cleared while the operator is still looking at it. +PROBE_INTERVAL_SECONDS = 20 +PROBE_TIMEOUT_SECONDS = 2 +# Consecutive failures before a peer is treated as gone. See the module +# docstring — this is the hysteresis that stops the page flapping. +FAILURES_BEFORE_GONE = 2 + +# How long one `avahi-browse` invocation is allowed to run before it is +# replaced by a fresh one. +# +# The stream is the fast path: it reports a node appearing or going away the +# moment it happens. What it will not report is a change to an *existing* +# node — `avahi-browse -r` resolves each service once, when it first sees it, +# and never resolves it again. So a node being renamed through the GUI updates +# its own TXT record and announces it, every other node hears the announcement +# at the Avahi layer, and not one of them notices, because their browser +# already considers that service resolved. Observed exactly that way: the new +# name was on the wire and visible to `avahi-browse` run by hand, while the +# fleet page kept showing the old one indefinitely. +# +# Restarting the browser re-resolves everything, so a rename lands within this +# interval. Cheap — one short-lived process a minute — and it keeps the +# instant add/remove path rather than replacing it with polling. +BROWSE_RESTART_SECONDS = 60 + +# avahi-browse escapes non-printables in the instance name as a backslash and +# three decimal digits. +_ESCAPE = re.compile(r"\\(\d{3})") +# TXT records arrive as a run of double-quoted strings on the end of the line. +_TXT = re.compile(r'"((?:[^"\\]|\\.)*)"') + + +def _unescape(value): + return _ESCAPE.sub(lambda m: chr(int(m.group(1))), value) + + +def parse_txt(blob): + """Pull `key=value` pairs out of avahi-browse's quoted TXT field.""" + pairs = {} + for entry in _TXT.findall(blob or ""): + entry = entry.replace('\\"', '"').replace("\\\\", "\\") + key, _, value = entry.partition("=") + if key: + pairs[key] = value + return pairs + + +def parse_line(line): + """Turn one line of `avahi-browse -p` output into a dict, or None. + + Only resolved (`=`) and removal (`-`) events carry anything useful. The + `+` announcement that precedes a resolve tells us a name exists but not + where it is, so it is ignored — the `=` for the same name follows. + """ + # maxsplit keeps a TXT value containing a semicolon in one piece; every + # field before the TXT blob is semicolon-free. + fields = line.rstrip("\n").split(";", 9) + if not fields or fields[0] not in ("=", "-"): + return None + if fields[0] == "-": + if len(fields) < 4: + return None + return {"event": "remove", "interface": fields[1], + "protocol": fields[2], "name": _unescape(fields[3])} + if len(fields) < 9: + return None + txt = parse_txt(fields[9] if len(fields) > 9 else "") + return { + "event": "resolve", + "interface": fields[1], + "protocol": fields[2], + "name": _unescape(fields[3]), + "hostname": fields[6], + "address": fields[7], + "port": fields[8], + "node_id": txt.get("node_id") or _unescape(fields[3]), + "friendly_name": txt.get("name") or "", + } + + +class PeerDirectory: + """Live view of the owl nodes on this LAN, including this one. + + Owns two threads, so exactly one of these may exist per process — it is + constructed in services.py for that reason. + """ + + def __init__(self, own_node_id_fn, dev_mode=False, fixture_path=None): + self._own_node_id_fn = own_node_id_fn + self._dev_mode = dev_mode + self._fixture_path = fixture_path + self._lock = threading.Lock() + # name -> peer dict. Keyed on the DNS-SD instance name because that is + # the only field a removal event carries. + self._peers = {} + self._started = False + + # ── Lifecycle ────────────────────────────────────────────── + + def start(self): + if self._started: + return + self._started = True + threading.Thread(target=self._browse_forever, daemon=True, + name="mdns-browse").start() + threading.Thread(target=self._probe_forever, daemon=True, + name="mdns-probe").start() + + # ── What the routes read ─────────────────────────────────── + + def peers(self): + """Every node believed present, this one first, then by name. + + Sorted so the page does not reshuffle between refreshes. + """ + with self._lock: + live = [dict(p) for p in self._peers.values() if p["alive"]] + live.sort(key=lambda p: (not p["is_self"], + (p["friendly_name"] or "").lower(), + p["node_id"])) + return live + + def count(self): + """How many nodes are present, including this one.""" + return len(self.peers()) + + # ── Browsing ─────────────────────────────────────────────── + + def _browse_forever(self): + while True: + try: + self._browse_once() + except Exception as e: # noqa: BLE001 - a browse must never kill the thread + print(f"mdns_peers: browse failed: {e}", flush=True) + # Reached both on the ordinary restart above and when avahi is + # unavailable. Short enough not to leave a real gap in the fast + # path, long enough that a daemon which is down is not spun on. + time.sleep(5) + + def _browse_once(self): + if self._fixture_path: + self._load_fixture() + time.sleep(PROBE_INTERVAL_SECONDS) + return + if not shutil.which("avahi-browse"): + # Ordinary in dev; on a node it means the image is missing + # avahi-utils, which is worth saying out loud once per retry. + print("mdns_peers: avahi-browse not installed", flush=True) + time.sleep(60) + return + + # -p parsable, -r resolve to address and TXT, -k skip the service-type + # database lookup, -f keep trying rather than exiting when the daemon + # is briefly unavailable. Deliberately not -l: this node's own + # advertisement is wanted, so it can be shown as "this node". + process = subprocess.Popen( + ["avahi-browse", "-p", "-r", "-k", "-f", SERVICE_TYPE], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + # Ends the stream from the outside: iterating stdout blocks, so the + # deadline cannot be enforced from within this loop. + deadline = threading.Timer(BROWSE_RESTART_SECONDS, process.terminate) + deadline.daemon = True + deadline.start() + try: + for line in process.stdout: + event = parse_line(line) + if event: + self._apply(event) + finally: + deadline.cancel() + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + def _apply(self, event): + own = self._own_node_id_fn() + with self._lock: + if event["event"] == "remove": + peer = self._peers.get(event["name"]) + if not peer: + return + peer["sources"].discard((event["interface"], event["protocol"])) + # A node on both Ethernet and WiFi produces one removal per + # interface. It has only really gone when the last one goes. + if not peer["sources"]: + del self._peers[event["name"]] + return + + peer = self._peers.setdefault(event["name"], { + "node_id": event["node_id"], + "friendly_name": event["friendly_name"], + "hostname": event["hostname"], + "address": event["address"], + "port": event["port"], + "sources": set(), + # Assumed present on first sight. The prober demotes it if that + # turns out to be wrong, which is the right way round: a node + # that just appeared is almost always real. + "alive": True, + "failures": 0, + "is_self": event["node_id"] == own, + }) + peer["sources"].add((event["interface"], event["protocol"])) + peer["node_id"] = event["node_id"] + peer["friendly_name"] = event["friendly_name"] + peer["hostname"] = event["hostname"] + peer["is_self"] = event["node_id"] == own + # Prefer IPv4: it is what every client here can reach, and it is + # what the card offers as the by-address fallback. + if event["protocol"] == "IPv4" or not peer["address"]: + peer["address"] = event["address"] + peer["port"] = event["port"] + + def _load_fixture(self): + """Dev-only: read the peer list from a JSON file instead of the LAN.""" + try: + with open(self._fixture_path) as f: + entries = json.load(f) + except (OSError, ValueError) as e: + print(f"mdns_peers: fixture unreadable: {e}", flush=True) + return + own = self._own_node_id_fn() + with self._lock: + self._peers = {} + for entry in entries: + node_id = entry.get("node_id", "") + self._peers[node_id] = { + "node_id": node_id, + "friendly_name": entry.get("name", ""), + "hostname": entry.get("hostname", f"{node_id}.local"), + "address": entry.get("address", ""), + "port": entry.get("port", "80"), + "sources": {("fixture", "IPv4")}, + "alive": entry.get("alive", True), + "failures": 0, + "is_self": node_id == own, + } + + # ── Probing ──────────────────────────────────────────────── + + def _probe_forever(self): + while True: + time.sleep(PROBE_INTERVAL_SECONDS) + try: + self._probe_once() + except Exception as e: # noqa: BLE001 - never kill the thread + print(f"mdns_peers: probe failed: {e}", flush=True) + + def _probe_once(self): + if self._fixture_path: + return + with self._lock: + targets = [(name, p["address"], p["is_self"]) + for name, p in self._peers.items()] + + for name, address, is_self in targets: + # No point probing ourselves over the network to find out we are + # up: this process is what would be answering. + reachable = True if is_self else self._reachable(address) + with self._lock: + peer = self._peers.get(name) + if not peer: + continue + if reachable: + peer["failures"] = 0 + peer["alive"] = True + else: + peer["failures"] += 1 + if peer["failures"] >= FAILURES_BEFORE_GONE: + peer["alive"] = False + + @staticmethod + def _reachable(address): + if not address: + return False + try: + http_requests.get(f"http://{address}/healthz", + timeout=PROBE_TIMEOUT_SECONDS) + except http_requests.RequestException: + return False + # Any answer at all means something is serving. Deliberately not a + # 200 check: a node mid-calibration redirects most GETs, and one + # returning 500 is still a node the operator should be able to reach + # and look at. + return True + + +def peer_directory_from_env(own_node_id_fn, dev_mode): + """Build the directory, honouring the dev fixture escape hatch.""" + fixture = os.environ.get("MDNS_PEERS_FIXTURE") if dev_mode else None + return PeerDirectory(own_node_id_fn, dev_mode=dev_mode, fixture_path=fixture) diff --git a/src/node_name.py b/src/node_name.py new file mode 100644 index 0000000..ee5e1a6 --- /dev/null +++ b/src/node_name.py @@ -0,0 +1,100 @@ +"""The node's friendly name — what an operator calls it, rather than its id. + +`ret4c844c20` is stable and unambiguous, which is exactly why it is the mDNS +name, but a fleet page listing several of them is not something anyone can +navigate. This is the label shown on the card instead, with the id underneath. + +Stored on /data so it survives an OS update, alongside the SSH keys and the +telemetry node_ref cache, and advertised to the other nodes in the +`_owl-node._tcp` TXT record. + +Rewriting that advertisement is deliberately delegated to owl-mdns-identity +(owl-os) rather than done here. It is the same script that writes the file at +boot, so there is one place that knows the format and one place doing the XML +escaping; running it again is how a rename reaches the network. avahi-daemon +watches /etc/avahi/services and reloads on its own, so nothing has to be +restarted and the new name is live within a second or two. +""" + +import os +import re +import subprocess +import tempfile + +# Kept short because it has to fit on a card and inside a TXT record, and +# because a name long enough to need scrolling is not doing its job. +MAX_LENGTH = 48 + +# Anything printable, but nothing that could break out of the line-oriented +# name file or the XML the identity script builds around it. Control +# characters, newlines and angle brackets are all refused here rather than +# escaped, because there is no legitimate node name that needs them — the +# escaping in owl-mdns-identity is the second line of defence, for a file +# edited by hand over SSH. +_FORBIDDEN = re.compile(r'[\x00-\x1f\x7f<>&"\']') + +IDENTITY_SCRIPT = "/usr/local/sbin/owl-mdns-identity" + + +class NodeName: + """Reads and writes the operator-assigned name for this node.""" + + def __init__(self, name_file, dev_mode=False, + identity_script=IDENTITY_SCRIPT): + self.name_file = name_file + self.data_dir = os.path.dirname(name_file) + self.dev_mode = dev_mode + self.identity_script = identity_script + + def get(self): + """The current name, or "" when the operator has not set one.""" + try: + with open(self.name_file) as f: + return f.read().strip() + except OSError: + return "" + + @staticmethod + def validate(name): + """Return (ok, error). An empty name is valid — it means "unset".""" + name = (name or "").strip() + if len(name) > MAX_LENGTH: + return False, f"Name must be {MAX_LENGTH} characters or fewer" + if _FORBIDDEN.search(name): + return False, "Name cannot contain control characters or < > & \" '" + return True, None + + def set(self, name): + """Store the name and re-advertise it. Returns (ok, error).""" + name = (name or "").strip() + ok, error = self.validate(name) + if not ok: + return False, error + + try: + os.makedirs(self.data_dir, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=self.data_dir) + with os.fdopen(fd, "w") as f: + f.write(name) + os.chmod(tmp_path, 0o644) + os.rename(tmp_path, self.name_file) + except OSError as e: + return False, f"Could not save the name: {e}" + + self._republish() + return True, None + + def _republish(self): + """Ask owl-mdns-identity to rewrite the DNS-SD advertisement. + + Best-effort. The name is already saved at this point, and the boot run + of the same script will pick it up regardless, so a failure here delays + the rename reaching other nodes rather than losing it. + """ + if self.dev_mode or not os.path.exists(self.identity_script): + return + try: + subprocess.run([self.identity_script], capture_output=True, + timeout=30, check=False) + except (OSError, subprocess.SubprocessError) as e: + print(f"node_name: could not re-advertise: {e}", flush=True) diff --git a/src/routes/config.py b/src/routes/config.py index 7f8b774..18cbb70 100644 --- a/src/routes/config.py +++ b/src/routes/config.py @@ -11,6 +11,7 @@ Tar1090Config, ) from form_utils import schema_to_form_fields +from node_name import MAX_LENGTH as NAME_MAX_LENGTH bp = Blueprint('config', __name__) @@ -40,7 +41,7 @@ def _check_wizard_not_active(): @bp.route("/config") def config_page(): """Configuration page with all settings.""" - from app import DEV_MODE, config_mgr, device_state, ssh_keys + from app import DEV_MODE, config_mgr, device_state, node_name, ssh_keys config = config_mgr.load_merged_config() retina_installed = config_mgr.is_retina_node_installed() or DEV_MODE or request.args.get('demo') == '1' @@ -68,6 +69,8 @@ def config_page(): tar1090_fields=tar1090_fields, retina_tracker_fields=retina_tracker_fields, towers_cache=device_state.get_towers_cache(), + node_name=node_name.get(), + node_name_max_length=NAME_MAX_LENGTH, ssh_keys=ssh_keys.get_keys()) @@ -99,6 +102,23 @@ def delete_key(): return redirect(url_for("config.config_page")) +@bp.route("/node-name", methods=["POST"]) +def set_node_name(): + """Rename this node. + + The name is only a label — it is what the fleet page shows on this node's + card instead of ret4c844c20, and it reaches the other nodes through the + DNS-SD TXT record. Nothing addresses the node by it, so a rename cannot + break a bookmark or an SSH config. + """ + from app import node_name + + ok, error = node_name.set(request.form.get("name", "")) + if not ok: + return jsonify({"ok": False, "error": error}), 400 + return jsonify({"ok": True, "name": node_name.get()}) + + # The three ADS-B source boxes are one YAML value (host,port,protocol), so they # only mean anything as a set: complete, or empty because adsb.lol is feeding # tar1090 instead. Neither of those is what the form produces on its own, hence @@ -215,7 +235,7 @@ def save_config(): all_errors.update(ConfigManager.format_validation_errors(e, 'retina_tracker')) if all_errors: - from app import DEV_MODE, device_state, ssh_keys + from app import DEV_MODE, device_state, node_name, ssh_keys return render_template("config.html", retina_installed=config_mgr.is_retina_node_installed() or DEV_MODE or request.args.get('demo') == '1', capture_fields=schema_to_form_fields(CaptureFormConfig, capture_flat), @@ -225,6 +245,8 @@ def save_config(): retina_tracker_fields=schema_to_form_fields(RetinaTrackerConfig, retina_tracker_data), towers_cache=device_state.get_towers_cache(), config_errors=all_errors, + node_name=node_name.get(), + node_name_max_length=NAME_MAX_LENGTH, ssh_keys=ssh_keys.get_keys()) capture_nested = ConfigManager.unflatten_capture_from_form(capture_flat) diff --git a/src/routes/fleet.py b/src/routes/fleet.py new file mode 100644 index 0000000..e01b1f1 --- /dev/null +++ b/src/routes/fleet.py @@ -0,0 +1,98 @@ +"""Fleet data for the banner, and the endpoint peers use to probe this node. + +Every node on the network gets a tab in the banner at the top of every page +(see `templates/_fleet_bar.html`), and each tab is a plain link to that node's +own `ret.local`. There is no shell and no frame: the node you click +serves you its own pages, banner included, with itself marked active. + +That is why nothing here decides *what* `owl.local` shows. It is answered by +whichever node replies first, and that node serves its own Home exactly as it +would under its own name. The only thing the shared alias costs you is that the +URL is ambiguous until you click a tab, which is why every tab is absolute. +""" + +from flask import Blueprint, jsonify, render_template + +bp = Blueprint("fleet", __name__) + + +def node_url(peer): + """Where to send a browser for this node. + + Its own mDNS name rather than its address: it is stable, it is what the + operator should learn to use, and any client that resolved owl.local to get + here can necessarily resolve a ret*.local too, since both are plain mDNS. + """ + return f"http://{peer['hostname'] or peer['node_id'] + '.local'}/" + + +def peer_view(peer): + """One node's worth of banner tab, without the internal bookkeeping.""" + return { + "node_id": peer["node_id"], + "name": peer["friendly_name"] or peer["node_id"], + "has_friendly_name": bool(peer["friendly_name"]), + "address": peer["address"], + "hostname": peer["hostname"], + "url": node_url(peer), + "is_self": peer["is_self"], + } + + +def banner_nodes(): + """The tabs to draw, guaranteed to include this node. + + Discovery is a background browse that takes a second or two to populate, + and it can come back empty on a network that blocks multicast. Neither is a + reason to render a banner with no tabs at all: this node is self-evidently + present, whatever mDNS believes. So if the peer list does not already carry + us, put us at the front. + """ + from app import peers, read_node_id + + nodes = [peer_view(p) for p in peers.peers()] + if any(n["is_self"] for n in nodes): + return nodes + + node_id = read_node_id() + return [{ + "node_id": node_id, + "name": node_id, + "has_friendly_name": False, + "address": "", + "hostname": f"{node_id}.local", + "url": f"http://{node_id}.local/", + "is_self": True, + }] + nodes + + +@bp.route("/summary") +def summary(): + """Fleet-scope information page. + + Placeholder content for now. Deliberately not a node list: the banner above + it already is one, and two views of the same thing would drift apart. + """ + return render_template("summary.html", active_page="summary") + + +@bp.route("/api/fleet/peers") +def fleet_peers(): + """The discovered nodes as JSON.""" + from app import peers + + return jsonify({"nodes": [peer_view(p) for p in peers.peers()]}) + + +@bp.route("/healthz") +def healthz(): + """Liveness, for the other nodes' probes. + + Deliberately trivial and dependency-free. It answers "is there a retina-gui + serving on this address", which is the only question a peer needs to ask, + not whether the radar is healthy, which is what the node's own page is for. + Anything heavier here would make a busy node look absent. + """ + from app import read_node_id + + return jsonify({"ok": True, "node_id": read_node_id()}) diff --git a/src/routes/home.py b/src/routes/home.py index 52c5ace..e947ea8 100644 --- a/src/routes/home.py +++ b/src/routes/home.py @@ -7,7 +7,12 @@ @bp.route("/") def index(): - """Home page with node ID, services, and SSH keys.""" + """Home page with node ID, services, and SSH keys. + + Host-agnostic. Arriving on owl.local and arriving on this node's own + ret.local produce the same page: the shared alias is just a way in, + and the banner is what moves you between nodes from there. + """ from app import config_mgr, device_state, get_node_id, mender, ssh_keys, telemetry_status if device_state.is_setup_wizard_in_progress(): diff --git a/src/services.py b/src/services.py index b4aa96a..baa39dc 100644 --- a/src/services.py +++ b/src/services.py @@ -34,8 +34,10 @@ from calibrator import Calibrator from config_manager import ConfigManager from device_state import DeviceState +from mdns_peers import peer_directory_from_env from mender import MenderClient from network_manager import NetworkManager +from node_name import NodeName from retina_tracker_client import RetinaTrackerClient from ssh_keys import SSHKeyManager from telemetry_status import TelemetryStatus @@ -116,6 +118,30 @@ node_ref_cache_path=os.path.join(DATA_DIR, "telemetry-node-ref"), ) +node_name = NodeName(os.path.join(DATA_DIR, "node-name"), dev_mode=DEV_MODE) + + +def read_node_id(): + """The Mender node_id, or 'Unknown' if it cannot be read. + + Also this node's mDNS host name: owl-mdns-identity derives ret*.local from + exactly this value, so `f"{read_node_id()}.local"` is the address other + machines reach us on. app.get_node_id() wraps this to add logging; nothing + that runs off the request path should use that one, since it needs the + Flask app object. + """ + try: + with open(NODE_ID_FILE) as f: + return f.read().strip() or "Unknown" + except OSError: + return "Unknown" + + +# Owns a browse thread and a probe thread, so like the clients above it must +# exist once per process. start() is called from app.py, not here, so that +# importing this module under pytest does not spawn anything. +peers = peer_directory_from_env(read_node_id, DEV_MODE) + def config_change_guard(): """Refuse a config apply while Auto-Calibrate is using the SDR. diff --git a/static/common.css b/static/common.css index ddb0afe..700d225 100644 --- a/static/common.css +++ b/static/common.css @@ -131,8 +131,41 @@ h1, h2, h3, h4, h5, h6 { position: sticky; top: 0; z-index: 5; + /* Bootstrap also defines .nav, and sets flex-wrap:wrap on it. Left to that, + the banner folds onto a second row once the tab strip grows: at six nodes + on a 1400px screen the Retina Dashboard button dropped to its own line. + The tab strip scrolls instead (see .nav-tabs). */ + flex-wrap: nowrap; +} +/* The brand and the two outbound buttons keep their size; the tab strip is the + only part that gives, so a long fleet never squashes the furniture around it. */ +.nav > a { flex-shrink: 0; } +.nav-tabs { + display: flex; + gap: 2px; + align-self: stretch; + align-items: center; + border-bottom: none; + /* Scroll rather than clip once there are more nodes than fit. min-width:0 + is what lets a flex item shrink below its content width at all; without + it overflow-x never engages and the tabs just push the row wider. + Deliberately `auto`, not a hidden-scrollbar trick: with a normal fleet + there is no overflow and so no scrollbar, and when there is one it is + the only affordance saying more nodes are off to the right. */ + overflow-x: auto; + min-width: 0; +} +/* Individual tabs never compress; the strip scrolls past them instead. */ +.nav-tabs > .nav-tab { flex-shrink: 0; } + +/* Below the app's narrow breakpoint there is not room for the brand, the tab + strip and both outbound buttons on one line, and holding nowrap there + squeezes the tab strip to nothing: every node tab disappears rather than + scrolling. Wrapping is the right answer at this width (the footer already + wraps), so the strip gets its own row and still scrolls within it. */ +@media (max-width: 600px) { + .nav { flex-wrap: wrap; } } -.nav-tabs { display: flex; gap: 2px; align-self: stretch; align-items: center; border-bottom: none; } .nav-tab { padding: 7px 14px; border-radius: 8px; @@ -141,10 +174,32 @@ h1, h2, h3, h4, h5, h6 { color: var(--ink-3); text-decoration: none; transition: background .12s, color .12s; + /* inline-flex so a tab can carry an icon beside its label. Harmless for + the text-only tabs: with one child there is nothing for gap to act on. */ + display: inline-flex; + align-items: center; + gap: 7px; } +/* Held a step back from the label so the mark reads as a bullet rather than + as part of the name, and does not compete with it on the active tab. */ +.nav-tab-icon { color: var(--ink-3); flex-shrink: 0; } +.nav-tab.active .nav-tab-icon, +.nav-tab:hover .nav-tab-icon { color: var(--ink-2); } .nav-tab:hover { color: var(--ink); background: var(--surface-2); } .nav-tab.active { color: var(--ink); background: var(--surface-2); } .nav-spacer { flex: 1; } + +/* Child row of the banner: which page of the node named in the row above. + Set apart from .nav by a lighter ground and no border of its own, so the two + rows read as one banner with a hierarchy rather than as two navbars. */ +.subnav { + display: flex; + align-items: center; + gap: 2px; + padding: 8px 28px; + border-bottom: 1px solid var(--line); + background: var(--surface-2); +} /* ── Spectrum mode overrides ──────────────────── */ .main.spectrum-mode { max-width: 1200px; } .spectrum-section { display: flex; flex-direction: column; flex: 1; } diff --git a/templates/_fleet_bar.html b/templates/_fleet_bar.html new file mode 100644 index 0000000..c109db1 --- /dev/null +++ b/templates/_fleet_bar.html @@ -0,0 +1,65 @@ +{# ── Fleet bar: the parent row ────────────────────────────────────────────── + Every node on the network, always on screen, one click apart. + + A macro rather than a plain include because the wizard needs it without the + brand block, and Jinja does not pass `{% with %}` locals into an included + template — they live in the compiled function, not the context dict, so the + include silently sees nothing. Macro parameters are explicit and cannot fail + that way. + + Node tabs are absolute links to each node's own ret.local, the one + for this node included. Clicking any of them therefore lands on a concrete + node address rather than on owl.local, which is answered by whichever node + replies first and is not worth bookmarking. + + The active tab is always this node, since the page is served by the node you + are looking at. Nothing to track: compare against the node_id passed in. +#} +{% macro fleet_bar(nodes, node_id, active_page, show_brand=true) %} + +{% endmacro %} diff --git a/templates/base.html b/templates/base.html index c8bfbe5..1f08d12 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,3 +1,4 @@ +{% from "_fleet_bar.html" import fleet_bar %} @@ -16,29 +17,17 @@
- {% block navbar %} - + {# Parent row: which node (or the fleet-wide Summary). #} + {% block navbar %}{{ fleet_bar(fleet_nodes, node_id, active_page) }}{% endblock %} + + {# Child row: which page of the node named above. Blanked by pages that are + not scoped to a single node — Summary — and by the setup wizard, where + Home and Config are deliberately unreachable until setup finishes. #} + {% block subnav %} + {% endblock %} {% block content %}{% endblock %} diff --git a/templates/config.html b/templates/config.html index 4c933d4..f8f3079 100644 --- a/templates/config.html +++ b/templates/config.html @@ -94,6 +94,7 @@

Radar

tar1090 Retina Tracker

Administration

+ This node SSH access Cloud services Setup wizard @@ -389,6 +390,51 @@

Retina Tracker

{# ── SSH access ── #} + {# ── This node ── + Deliberately outside #configForm, which closes above: like the rest + of Administration, this saves on its own rather than on Apply. #} +
+
+

This node

+ What this node is called, and the address that always reaches it. +
+
+
+
+ + + Shown on the Nodes page when there is more than one node + on the network. Leave it empty to be listed by + identifier. This is only a label, so changing it never + affects how the node is reached. + +
+
+
+ + +
+
+
+
+
+
+ + + This node's own address. It comes from the hardware and + never changes, so it is the one to bookmark. owl.local is + shared by every node on the network and may reach any of them. + +
+
+
http://{{ node_id }}.local
+
+
+
+
+

SSH access

@@ -663,6 +709,52 @@ {% block scripts %}