From 83d553efb868e1230df17909c6d2c61176312c71 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Fri, 21 Aug 2026 17:50:31 +0100 Subject: [PATCH 1/6] 20260821 - Find the other nodes on the network, and land on the right one A LAN with more than one node had no way to address either of them. Every node ships with hostname "owl", so Avahi decided by boot race who got owl.local and who got owl-2.local, and the answer changed on every reboot. owl-os now gives each node a permanent name of its own, ret.local, and publishes owl.local as a shared record that every node answers. This is the half that decides what you get when you arrive at owl.local: one node redirect to that node's own ret.local several a page listing every node found, each linking to its own name The redirect is the point of the one-node case. It puts ret.local in the URL bar on the operator's first visit, so the bookmark they end up with is the stable one, and the day a second node arrives and owl.local becomes a list instead, that bookmark still goes where it always went. This is the only place in the design that consults how many nodes exist. Everything on the wire is identical either way. ## Discovery Peers come from a DNS-SD browse of _owl-node._tcp, which every node advertises. Liveness does not: a node counts as present only when it answers /healthz. RFC 6762 gives service PTR records a 75-minute TTL and a node yanked from the wall sends no goodbye, so the browse alone would keep a dead node on the page, with a card leading nowhere, for the rest of the afternoon. Two consecutive failures are required before one drops off, so a marginal link cannot flip the page between its one-node and many-node forms on every refresh. The browse stream is replaced every 60s. `avahi-browse -r` resolves a service once, when it first sees it, and never again, so a node renamed through the GUI announced its new TXT record and no other node noticed. Verified against two nodes: the new name was on the wire and visible to a hand-run browse while the fleet page showed the old one indefinitely. ## Names ret4c844c20 is stable and unreadable, so nodes carry an operator-assigned label, stored on /data and advertised in the TXT record. Nothing is addressed by it, so a rename cannot break a bookmark or an SSH config. ## Also The fleet routes are exempt from the calibration hold. Whoever is browsing owl.local is asking about the fleet and is not necessarily the person who started a calibration on whichever node answered, and /healthz is how every other node decides this one still exists. The WiFi provisioning copy told the user to confirm retina.local loads. That name is retired, and it was wrong with more than one node anyway; it now names the node's own address. Co-Authored-By: Claude Opus 5 --- src/app.py | 20 +++ src/mdns_peers.py | 338 +++++++++++++++++++++++++++++++++++++++ src/node_name.py | 100 ++++++++++++ src/routes/fleet.py | 113 +++++++++++++ src/routes/home.py | 35 +++- src/services.py | 26 +++ templates/base.html | 6 + templates/config.html | 10 +- templates/fleet.html | 125 +++++++++++++++ templates/index.html | 64 ++++++++ tests/test_fleet.py | 206 ++++++++++++++++++++++++ tests/test_mdns_peers.py | 221 +++++++++++++++++++++++++ tests/test_node_name.py | 158 ++++++++++++++++++ 13 files changed, 1418 insertions(+), 4 deletions(-) create mode 100644 src/mdns_peers.py create mode 100644 src/node_name.py create mode 100644 src/routes/fleet.py create mode 100644 templates/fleet.html create mode 100644 tests/test_fleet.py create mode 100644 tests/test_mdns_peers.py create mode 100644 tests/test_node_name.py diff --git a/src/app.py b/src/app.py index 445bc2f..cb64c20 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(): @@ -138,12 +144,16 @@ def inject_globals(): 'node_id': get_node_id(), 'owl_os_version': owl_os_version, 'retina_node_version': retina_node_version, + # Drives the Nodes tab, which is only worth showing once there is more + # than one node to switch between. Reading an in-memory list. + 'fleet_count': peers.count(), } # 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 +171,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 +183,15 @@ def inject_globals(): '/calibrate', # status, cancel, apply '/static', '/favicon', + # The fleet routes belong to whoever is browsing owl.local, 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. + '/fleet', + '/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/fleet.py b/src/routes/fleet.py new file mode 100644 index 0000000..6e7f13d --- /dev/null +++ b/src/routes/fleet.py @@ -0,0 +1,113 @@ +"""The fleet landing page, and the endpoint peers use to probe this node. + +`owl.local` is answered by every node at once (see owl-mdns-alias in owl-os), so +the browser lands on whichever one replied first. That is deliberate and it does +not matter which: every node serves this same page, listing every node it can +see, and each card links to that node's own permanent `ret*.local` address. +""" + +from flask import Blueprint, jsonify, redirect, render_template + +bp = Blueprint("fleet", __name__) + +# The names every node answers to jointly, as opposed to its own ret*.local. +# A request that arrived on one of these is someone looking for "a node", not +# for this node, which is what makes it safe to send them somewhere else. +SHARED_ALIAS_HOSTS = ("owl.local", "owl") + + +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 — both are plain mDNS. The + address is shown on the card as a fallback for when that is not true, and + as something to check a card against. + """ + return f"http://{peer['hostname'] or peer['node_id'] + '.local'}/" + + +def peer_view(peer): + """The card's worth of a peer, 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 is_entry_point(host): + """Did this request arrive on the shared alias rather than a node's name?""" + return (host or "").split(":")[0].lower() in SHARED_ALIAS_HOSTS + + +def entry_point_response(): + """What `owl.local` should do right now, or None to serve the node UI. + + The single place the node count changes anything. Everything else — the + names published, the service advertised, the records on the wire — is + identical whether there is one node on the network or ten. + + With one node there is no list worth showing, so the browser is sent + straight on to that node's own address. Doing it as a redirect rather than + just rendering the node's page means the operator sees `ret4c844c20.local` + in the URL bar and bookmarks *that*, so the day a second node arrives and + owl.local starts showing a list instead, the bookmark they already have + still goes where it always went. + """ + from app import peers, read_node_id + + if peers.count() > 1: + return render_fleet() + + node_id = read_node_id() + if not node_id or node_id == "Unknown": + # No name to send them to. Serving this node's page is a better answer + # than redirecting to something that will not resolve. + return None + return redirect(f"http://{node_id}.local/", code=302) + + +def render_fleet(): + from app import peers + + return render_template("fleet.html", + nodes=[peer_view(p) for p in peers.peers()], + active_page="fleet") + + +@bp.route("/fleet") +def fleet(): + """Every node on this network, one card each. + + Always reachable by this path, whatever the node count and whichever name + was used to get here — the count only decides what `/` does. + """ + return render_fleet() + + +@bp.route("/api/fleet/peers") +def fleet_peers(): + """The same list as JSON, so the page can refresh without a reload.""" + 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 the fleet page 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 08dec3d..d519434 100644 --- a/src/routes/home.py +++ b/src/routes/home.py @@ -1,5 +1,7 @@ -from flask import Blueprint, redirect, render_template, request +from flask import Blueprint, jsonify, redirect, render_template, request +from node_name import MAX_LENGTH as NAME_MAX_LENGTH +from routes.fleet import entry_point_response, is_entry_point from routes.mode import get_current_mode bp = Blueprint('home', __name__) @@ -8,7 +10,17 @@ @bp.route("/") def index(): """Home page with node ID, services, and SSH keys.""" - from app import config_mgr, device_state, get_node_id, mender, ssh_keys, telemetry_status + from app import config_mgr, device_state, get_node_id, mender, node_name, ssh_keys, telemetry_status + + # Someone who typed owl.local is asking for "a node", and on a network with + # several of them the honest answer is the list, not whichever one happened + # to win the race to answer. Checked before the setup wizard: a visitor + # looking for the fleet should not be dropped into one node's first-run + # wizard just because that node is the one that replied. + if is_entry_point(request.host): + response = entry_point_response() + if response is not None: + return response if device_state.is_setup_wizard_in_progress(): return redirect('/set-up') @@ -52,9 +64,28 @@ def index(): tx_name=tx_name, rx_name=rx_name, telemetry=telemetry, + node_name=node_name.get(), + node_name_max_length=NAME_MAX_LENGTH, mode=get_current_mode()) +@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()}) + + @bp.route("/eula") def eula(): """Display EULA page.""" 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/templates/base.html b/templates/base.html index c8bfbe5..8021e24 100644 --- a/templates/base.html +++ b/templates/base.html @@ -32,6 +32,12 @@ diff --git a/templates/config.html b/templates/config.html index 090f7d0..546cc08 100644 --- a/templates/config.html +++ b/templates/config.html @@ -654,6 +654,12 @@ {% block scripts %} +{% endblock %} diff --git a/templates/index.html b/templates/index.html index d90ea60..22f1917 100644 --- a/templates/index.html +++ b/templates/index.html @@ -262,16 +262,80 @@
Welcome to OWL-OS
{% endif %} + {# The name this node is called on the fleet page. Purely a label: nothing + addresses the node by it, so renaming cannot break a bookmark or an SSH + config. The id below it is the part that never changes. #} +
+

This node

+
+
+
Name
+
+
+ Shown on the Nodes page. Leave it empty to be listed by + identifier. +
+
+ + +
+
+
{{ node_id }}.local
+
+
{% endblock %} {% block scripts %} + {% if mode == 'spectrum' %} - {% if mode == 'spectrum' %} -{% endblock %} diff --git a/templates/setup.html b/templates/setup.html index 225dcd4..370a485 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -1,4 +1,7 @@ {% extends "base.html" %} +{# Imported again here: base.html's import lives in its own namespace and is + not inherited by child templates, and the bar is used inside a block below. #} +{% from "_fleet_bar.html" import fleet_bar %} {% block title %}OWL-OS Setup{% endblock %} {% block head %} @@ -6,13 +9,30 @@ {% endblock %} -{# Override the full navbar/footer — wizard has its own chrome #} +{# The wizard has its own chrome and its own full-height layout, so it takes + neither of base.html's rows in their usual slots. The fleet bar is included + inside .wiz below instead; the child row is gone entirely and stays gone. + + That split is deliberate. Home and Config must not be reachable on a node + that is mid-setup, and the redirects enforcing that are untouched. But being + unable to leave *this node* is a different thing: without the fleet bar, a + node part-way through setup becomes a dead end in every other node's banner, + and an abandoned wizard keeps it that way until the 24h timeout in + device_state clears it. Leaving for another node is not wandering out of + setup. #} {% block navbar %}{% endblock %} +{% block subnav %}{% endblock %} {% block footer %}{% endblock %} {% block content %}
+ {# First child of .wiz on purpose. .wiz is a 100vh flex column and .wiz-body + is flex:1, so the bar takes its natural height and the body shrinks to + fit. Placed outside .wiz it would push a full-height element down and + overflow the viewport. #} + {{ fleet_bar(fleet_nodes, node_id, active_page, show_brand=false) }} + {# ── Wizard header: brand left, step indicator right ── #}
diff --git a/templates/summary.html b/templates/summary.html new file mode 100644 index 0000000..88811a0 --- /dev/null +++ b/templates/summary.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% set active_page = 'summary' %} +{% block title %}Summary - OWL-OS{% endblock %} + +{# Fleet scope, not node scope: Home and Config below would be about whichever + node happened to serve this page, which is not what the reader is asking + about here. #} +{% block subnav %}{% endblock %} + +{% block content %} +
+ +
+
+

Summary

+
About this system
+
+
+ +
+

Your nodes

+
+
+ Every node on this network appears in the bar at the top of the + page. Select one to see its data and change its settings. The bar + stays with you, so you can move between nodes at any point. +
+
+ Each node also has a permanent address of its own, shown under + Config, that always reaches that node and nothing else. It is + worth bookmarking. The shared address, owl.local, is answered by + whichever node replies first, so it is a way in rather than a way + to reach one node in particular. +
+
+
+ + {# The caveat the old node-list page carried. The banner has nowhere to put + it, and it is the difference between "that node is off" and "that node is + fine and this page cannot see it", which is not a distinction the + software can make for the reader. #} +
+

If a node is missing

+
+
+ Nodes find each other by broadcasting on the local network. Some + networks block that, including guest WiFi and access points with + client isolation turned on, and a node on the far side of one + will not appear above even though it is running normally. +
+
+ A node that is missing can still be reached directly by its + address, which your router will list among its connected + devices. +
+
+
+ +
+{% endblock %} diff --git a/tests/test_fleet.py b/tests/test_fleet.py index f76eb27..f0d2efa 100644 --- a/tests/test_fleet.py +++ b/tests/test_fleet.py @@ -1,13 +1,13 @@ -"""Tests for the fleet landing page and the owl.local mode switch. +"""Tests for the fleet banner. -The switch is the only place in the whole design where the node count changes -behaviour — everything published on the wire is identical whether there is one -node or ten — so it is worth pinning down precisely. +The banner is a shared component rendered on every page, so most of these go +through real routes rather than calling the helper directly: what matters is +that it survives base.html inheritance and the blocks pages override. """ import pytest -from routes.fleet import is_entry_point, node_url +from routes.fleet import banner_nodes, node_url, peer_view class FakePeers: @@ -29,6 +29,10 @@ def node(node_id, address="192.168.1.57", friendly="", is_self=False): "port": "80", "is_self": is_self} +SELF = "ret7dd2cb0d" # the node_id the app_client fixture writes +OTHER = "ret4c844c20" + + @pytest.fixture def fleet(monkeypatch): """Give the running app a peer directory the test controls.""" @@ -40,136 +44,162 @@ def set_nodes(*nodes): return set_nodes -# ── Recognising the shared alias ─────────────────────────────── +# ── Which tabs get drawn ─────────────────────────────────────── -@pytest.mark.parametrize("host", ["owl.local", "OWL.LOCAL", "owl.local:80", "owl"]) -def test_the_shared_alias_is_recognised(host): - assert is_entry_point(host) is True +def test_a_tab_per_discovered_node(app_client, fleet): + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + body = app_client.get("/").data.decode() + assert f'href="http://{SELF}.local/"' in body + assert f'href="http://{OTHER}.local/"' in body -@pytest.mark.parametrize("host", ["ret7dd2cb0d.local", "192.168.1.57", - "localhost:80", "owl.example.com", ""]) -def test_a_node_s_own_address_is_not_the_shared_alias(host): - assert is_entry_point(host) is False +def test_this_node_appears_even_before_discovery_has_run(app_client, fleet): + """A browse takes a second to populate and can come back empty on a network + that blocks multicast. Neither is a reason to draw a banner with no tabs.""" + fleet() + nodes = banner_nodes() + assert [n["node_id"] for n in nodes] == [SELF] + assert nodes[0]["is_self"] is True -# ── The mode switch ──────────────────────────────────────────── +def test_this_node_is_not_duplicated_once_discovery_finds_it(app_client, fleet): + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + assert [n["node_id"] for n in banner_nodes()] == [SELF, OTHER] -def test_one_node_redirects_owl_local_to_that_node(app_client, fleet): - """So the operator learns and bookmarks the name that will not change.""" - fleet(node("ret7dd2cb0d", is_self=True)) - response = app_client.get("/", headers={"Host": "owl.local"}) - assert response.status_code == 302 - assert response.headers["Location"] == "http://ret7dd2cb0d.local/" +def test_a_friendly_name_labels_the_tab(app_client, fleet): + fleet(node(SELF, is_self=True), + node(OTHER, "192.168.1.58", friendly="Boston Rooftop")) + body = app_client.get("/").data.decode() + assert "Boston Rooftop" in body -def test_no_nodes_discovered_yet_still_redirects(app_client, fleet): - """Nothing found is the same situation as only ourselves: no list to show.""" - fleet() - response = app_client.get("/", headers={"Host": "owl.local"}) - assert response.status_code == 302 - assert response.headers["Location"] == "http://ret7dd2cb0d.local/" +def test_tabs_are_absolute_so_a_click_leaves_the_shared_alias(app_client, fleet): + """owl.local is answered by any node, so it is not worth landing on.""" + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + body = app_client.get("/", headers={"Host": "owl.local"}).data.decode() + assert f'href="http://{SELF}.local/"' in body + assert 'href="/"' not in body.split('")[0] -def test_two_nodes_makes_owl_local_the_landing_page(app_client, fleet): - fleet(node("ret7dd2cb0d", is_self=True), node("ret4c844c20", "192.168.1.58")) - response = app_client.get("/", headers={"Host": "owl.local"}) - assert response.status_code == 200 - body = response.data.decode() - assert "ret4c844c20" in body - assert "http://ret4c844c20.local/" in body +def test_the_serving_node_is_the_active_tab(app_client, fleet): + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + tabs = app_client.get("/").data.decode() \ + .split('")[0] -def test_a_node_s_own_name_always_serves_that_node(app_client, fleet): - """Even with a fleet: this host means "this node", not "any node".""" - fleet(node("ret7dd2cb0d", is_self=True), node("ret4c844c20", "192.168.1.58")) - response = app_client.get("/", headers={"Host": "ret7dd2cb0d.local"}) - assert response.status_code == 200 - assert "Nodes" not in response.data.decode()[:200] + # Exactly one tab is active, and it is the node serving this page. + assert tabs.count("nav-tab active") == 1 + active_tab = tabs.split("nav-tab active")[1].split("
")[0] + assert f"http://{SELF}.local/" in active_tab + assert OTHER not in active_tab -def test_reaching_a_node_by_address_serves_that_node(app_client, fleet): - fleet(node("ret7dd2cb0d", is_self=True), node("ret4c844c20", "192.168.1.58")) - response = app_client.get("/", headers={"Host": "192.168.1.57"}) - assert response.status_code == 200 +# ── Both external links ──────────────────────────────────────── +def test_the_banner_carries_both_outbound_links(app_client, fleet): + fleet(node(SELF, is_self=True)) + body = app_client.get("/").data.decode() + assert "https://map.retina.fm" in body and "Server" in body + assert "https://dash.retina.fm" in body and "Retina Dashboard" in body -def test_without_a_node_id_it_serves_rather_than_redirecting(app_client_no_node_id, - monkeypatch): - """There is no name to send them to, so a redirect could only loop.""" - import app as app_module - monkeypatch.setattr(app_module, "peers", FakePeers()) - response = app_client_no_node_id.get("/", headers={"Host": "owl.local"}) - assert response.status_code != 302 +# ── The child row ────────────────────────────────────────────── +def test_the_child_row_is_present_on_a_node_page(app_client, fleet): + fleet(node(SELF, is_self=True)) + body = app_client.get("/").data.decode() + assert 'class="subnav"' in body + assert ">Home" in body and ">Config" in body -# ── The page itself ──────────────────────────────────────────── -def test_fleet_page_is_reachable_directly_whatever_the_count(app_client, fleet): - fleet(node("ret7dd2cb0d", is_self=True)) - assert app_client.get("/fleet").status_code == 200 +def test_the_child_row_is_absent_on_summary(app_client, fleet): + """Summary is fleet scope. Home and Config below it would be about + whichever node happened to serve the page, which is not what is being + asked about there.""" + fleet(node(SELF, is_self=True)) + body = app_client.get("/summary").data.decode() + assert 'class="subnav"' not in body -def test_the_card_links_to_the_node_s_own_mdns_name(): - assert node_url(node("ret4c844c20")) == "http://ret4c844c20.local/" +def test_summary_renders_and_marks_its_own_tab(app_client, fleet): + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + body = app_client.get("/summary").data.decode() + assert "

Summary

" in body + tabs = body.split('")[0] + # Summary is active, and no node tab is. + assert tabs.count("nav-tab active") == 1 + assert 'href="/summary"' in tabs -def test_the_card_falls_back_to_the_node_id_when_the_hostname_is_missing(): - peer = node("ret4c844c20") - peer["hostname"] = "" - assert node_url(peer) == "http://ret4c844c20.local/" +# ── The setup wizard ─────────────────────────────────────────── +def test_the_wizard_keeps_the_fleet_bar(app_client, fleet): + """Without it, a node part-way through setup is a dead end in every other + node's banner, and an abandoned wizard keeps it that way for 24h.""" + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + body = app_client.get("/set-up").data.decode() + assert f'href="http://{OTHER}.local/"' in body, "cannot leave this node" -def test_this_node_is_labelled_on_the_page(app_client, fleet): - fleet(node("ret7dd2cb0d", is_self=True), node("ret4c844c20", "192.168.1.58")) - assert "This node" in app_client.get("/fleet").data.decode() +def test_the_wizard_does_not_get_the_child_row(app_client, fleet): + """Home and Config stay unreachable mid-setup. That lock is the point.""" + fleet(node(SELF, is_self=True)) + body = app_client.get("/set-up").data.decode() + assert 'class="subnav"' not in body + + +# ── owl.local no longer behaves differently ──────────────────── + +@pytest.mark.parametrize("host", ["owl.local", f"{SELF}.local", "192.168.1.57"]) +def test_every_host_serves_the_same_home_page(app_client, fleet, host): + """The mode switch is gone: the shared alias is a way in, not a mode.""" + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + response = app_client.get("/", headers={"Host": host}) + assert response.status_code == 200 + assert "

Home

" in response.data.decode() -def test_a_friendly_name_is_shown_with_the_id_beneath_it(app_client, fleet): - fleet(node("ret7dd2cb0d", is_self=True), - node("ret4c844c20", "192.168.1.58", friendly="Boston Rooftop")) - body = app_client.get("/fleet").data.decode() - assert "Boston Rooftop" in body - assert "ret4c844c20" in body, "the id stays visible; it is the stable one" +def test_one_node_no_longer_redirects(app_client, fleet): + fleet(node(SELF, is_self=True)) + assert app_client.get("/", headers={"Host": "owl.local"}).status_code == 200 -def test_every_card_shows_an_address(app_client, fleet): - """The operator's check against a rogue advertiser, and their way in when - the browser cannot resolve .local names.""" - fleet(node("ret7dd2cb0d", "10.0.0.1", is_self=True), - node("ret4c844c20", "10.0.0.2")) - body = app_client.get("/fleet").data.decode() - assert "10.0.0.1" in body - assert "10.0.0.2" in body +def test_the_old_fleet_page_is_gone(app_client, fleet): + """The banner lists every node; a page doing the same would drift.""" + fleet(node(SELF, is_self=True)) + assert app_client.get("/fleet").status_code == 404 + + +# ── What the banner is built from ────────────────────────────── def test_peers_are_available_as_json(app_client, fleet): - fleet(node("ret4c844c20", "192.168.1.58", friendly="Roof")) + fleet(node(OTHER, "192.168.1.58", friendly="Roof")) payload = app_client.get("/api/fleet/peers").get_json() assert payload["nodes"] == [{ - "node_id": "ret4c844c20", + "node_id": OTHER, "name": "Roof", "has_friendly_name": True, "address": "192.168.1.58", - "hostname": "ret4c844c20.local", - "url": "http://ret4c844c20.local/", + "hostname": f"{OTHER}.local", + "url": f"http://{OTHER}.local/", "is_self": False, }] -def test_an_unnamed_node_is_listed_by_its_id(app_client, fleet): - fleet(node("ret4c844c20")) - entry = app_client.get("/api/fleet/peers").get_json()["nodes"][0] - assert entry["name"] == "ret4c844c20" - assert entry["has_friendly_name"] is False +def test_an_unnamed_node_is_labelled_by_its_id(): + view = peer_view(node(OTHER)) + assert view["name"] == OTHER + assert view["has_friendly_name"] is False -# ── Liveness endpoint ────────────────────────────────────────── +def test_the_url_falls_back_to_the_node_id_without_a_hostname(): + peer = node(OTHER) + peer["hostname"] = "" + assert node_url(peer) == f"http://{OTHER}.local/" + def test_healthz_answers_with_this_node_s_id(app_client): - payload = app_client.get("/healthz").get_json() - assert payload == {"ok": True, "node_id": "ret7dd2cb0d"} + assert app_client.get("/healthz").get_json() == {"ok": True, "node_id": SELF} # ── Interaction with a running calibration ───────────────────── @@ -183,24 +213,21 @@ def test_a_calibrating_node_still_answers_its_peers(app_client, monkeypatch): assert app_client.get("/healthz").status_code == 200 -def test_a_calibrating_node_still_serves_the_fleet_page(app_client, fleet, - monkeypatch): - """Whoever is browsing owl.local is asking about the fleet, and is not - necessarily the person who started a calibration on this particular node.""" +def test_a_calibrating_node_still_serves_summary(app_client, fleet, monkeypatch): + """Whoever is reading Summary is asking about the fleet, and is not + necessarily the person who started a calibration on this node.""" import app as app_module - fleet(node("ret7dd2cb0d", is_self=True), node("ret4c844c20", "192.168.1.58")) + fleet(node(SELF, is_self=True)) monkeypatch.setattr(app_module.calibrator, "is_running", lambda: True) - response = app_client.get("/fleet") - assert response.status_code == 200 - assert "ret4c844c20" in response.data.decode() + assert app_client.get("/summary").status_code == 200 def test_a_calibration_still_holds_the_node_s_own_pages(app_client, monkeypatch): - """The exemption above must not have opened up the rest of the interface.""" + """The exemptions above must not have opened up the rest of the interface.""" import app as app_module monkeypatch.setattr(app_module.calibrator, "is_running", lambda: True) - response = app_client.get("/", headers={"Host": "ret7dd2cb0d.local"}) + response = app_client.get("/", headers={"Host": f"{SELF}.local"}) assert response.status_code == 302 assert response.headers["Location"].endswith("/config") From b841d82e1361770acdb79b1cf805aadbf162f387 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Thu, 27 Aug 2026 12:55:33 +0100 Subject: [PATCH 4/6] 20260827 - Mark node tabs with the antenna, and hand Summary over blank Two design notes. A node tab now carries the same antenna mark the node cards and the "listening on" row use, so a tab reads as a node at a glance rather than as another section of the page. Summary deliberately has none: it is not a node, and the mark is what separates the two without needing a label to explain it. The icon sits a step back from the label in weight so it reads as a bullet rather than as part of the name. Summary is now empty. It was carrying prose I wrote about how the banner works and what to do when a node is missing, which is the UX and setup teams' call to make rather than a developer's. Leaving a placeholder there would only have to be argued out of the way later, so the page renders the banner and nothing else, with a comment marking where content goes. A test pins it blank, so anything added there later is a deliberate act rather than a drift. The tab assertions now parse the tab strip instead of matching an exact class string, which is what broke them when the icon added a class. ## One thing that moved out and has nowhere to go The blanked prose included the caveat the old node-list page carried: that mDNS discovery is not authoritative, and a node behind client isolation or an unbridged segment is missing from the banner while running perfectly well. Nothing in the UI says that now. Worth somewhere eventually; it is not a developer's decision where. Co-Authored-By: Claude Opus 5 --- static/common.css | 10 +++++++ templates/_fleet_bar.html | 15 ++++++++-- templates/summary.html | 59 +++++++------------------------------- tests/test_fleet.py | 60 +++++++++++++++++++++++++++++++-------- 4 files changed, 82 insertions(+), 62 deletions(-) diff --git a/static/common.css b/static/common.css index 62193e1..a0a821c 100644 --- a/static/common.css +++ b/static/common.css @@ -141,7 +141,17 @@ 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; } diff --git a/templates/_fleet_bar.html b/templates/_fleet_bar.html index 935f0db..c109db1 100644 --- a/templates/_fleet_bar.html +++ b/templates/_fleet_bar.html @@ -34,10 +34,21 @@ {% endif %} diff --git a/templates/summary.html b/templates/summary.html index 88811a0..7e0c12e 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -3,58 +3,21 @@ {% block title %}Summary - OWL-OS{% endblock %} {# Fleet scope, not node scope: Home and Config below would be about whichever - node happened to serve this page, which is not what the reader is asking - about here. #} + node happened to serve this page, which is not what is being asked about + here. #} {% block subnav %}{% endblock %} {% block content %} -
- -
-
-

Summary

-
About this system
-
-
+{# + Deliberately empty. -
-

Your nodes

-
-
- Every node on this network appears in the bar at the top of the - page. Select one to see its data and change its settings. The bar - stays with you, so you can move between nodes at any point. -
-
- Each node also has a permanent address of its own, shown under - Config, that always reaches that node and nothing else. It is - worth bookmarking. The shared address, owl.local, is answered by - whichever node replies first, so it is a way in rather than a way - to reach one node in particular. -
-
-
- - {# The caveat the old node-list page carried. The banner has nowhere to put - it, and it is the difference between "that node is off" and "that node is - fine and this page cannot see it", which is not a distinction the - software can make for the reader. #} -
-

If a node is missing

-
-
- Nodes find each other by broadcasting on the local network. Some - networks block that, including guest WiFi and access points with - client isolation turned on, and a node on the far side of one - will not appear above even though it is running normally. -
-
- A node that is missing can still be reached directly by its - address, which your router will list among its connected - devices. -
-
-
+ This page is the fleet-scope slot in the banner and is left as a blank + slate for the UX and setup teams to design into. It renders the banner and + nothing else on purpose, so anything that appears here later is theirs + rather than a developer's placeholder that has to be argued out of the way. + Add page content inside
below. +#} +
{% endblock %} diff --git a/tests/test_fleet.py b/tests/test_fleet.py index f0d2efa..cf0be4a 100644 --- a/tests/test_fleet.py +++ b/tests/test_fleet.py @@ -33,6 +33,22 @@ def node(node_id, address="192.168.1.57", friendly="", is_self=False): OTHER = "ret4c844c20" +def tab_strip(body): + """The parent row's tabs, as a list of raw fragments. + + Parsed rather than string-matched so that adding a class or an icon to a + tab does not silently break every assertion about which one is active. + """ + strip = body.split('")[0] + return ["")[0]] + assert len(live) == 1, f"expected exactly one active tab, got {len(live)}" + return live[0] + + @pytest.fixture def fleet(monkeypatch): """Give the running app a peer directory the test controls.""" @@ -84,14 +100,27 @@ def test_tabs_are_absolute_so_a_click_leaves_the_shared_alias(app_client, fleet) def test_the_serving_node_is_the_active_tab(app_client, fleet): fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) - tabs = app_client.get("/").data.decode() \ - .split('")[0] + tab = active_tab(app_client.get("/").data.decode()) + assert f"http://{SELF}.local/" in tab + assert OTHER not in tab + + +def test_node_tabs_carry_the_node_mark(app_client, fleet): + """So a tab reads as a node at a glance, not another section of the page.""" + fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) + tabs = tab_strip(app_client.get("/").data.decode()) + node_tabs = [t for t in tabs if ".local/" in t] + assert len(node_tabs) == 2 + assert all("nav-tab-icon" in t for t in node_tabs) - # Exactly one tab is active, and it is the node serving this page. - assert tabs.count("nav-tab active") == 1 - active_tab = tabs.split("nav-tab active")[1].split("")[0] - assert f"http://{SELF}.local/" in active_tab - assert OTHER not in active_tab + +def test_the_summary_tab_has_no_node_mark(app_client, fleet): + """It is not a node, and the mark is what separates the two at a glance.""" + fleet(node(SELF, is_self=True)) + summary = [t for t in tab_strip(app_client.get("/").data.decode()) + if 'href="/summary"' in t] + assert len(summary) == 1 + assert "nav-tab-icon" not in summary[0] # ── Both external links ──────────────────────────────────────── @@ -123,12 +152,19 @@ def test_the_child_row_is_absent_on_summary(app_client, fleet): def test_summary_renders_and_marks_its_own_tab(app_client, fleet): fleet(node(SELF, is_self=True), node(OTHER, "192.168.1.58")) - body = app_client.get("/summary").data.decode() - assert "

Summary

" in body - tabs = body.split('")[0] + response = app_client.get("/summary") + assert response.status_code == 200 # Summary is active, and no node tab is. - assert tabs.count("nav-tab active") == 1 - assert 'href="/summary"' in tabs + assert 'href="/summary"' in active_tab(response.data.decode()) + + +def test_summary_is_deliberately_blank(app_client, fleet): + """Held empty for the UX and setup teams to design into. If this fails, + something has been added here that should have been theirs to decide.""" + fleet(node(SELF, is_self=True)) + body = app_client.get("/summary").data.decode() + content = body.split('
')[1].split("
")[0] + assert content.strip() == "", f"summary page is no longer blank: {content[:200]!r}" # ── The setup wizard ─────────────────────────────────────────── From 88c2886049f3208ea6291e497d56fb24e2b90834 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Mon, 31 Aug 2026 15:17:03 +0100 Subject: [PATCH 5/6] 20260831 - Retire retina.local from the README The last place retina.local was still advertised as an address. Nothing has published it since the mdns_identity role replaced avahi-alias-retina, so the line was telling people to use a name that no longer resolves. Replaced with what a node actually answers to: its own ret.local, and the shared owl.local that every node on the network answers. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 209a28c1830b9aec8ea4bd132f46b0562c4d1663 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Mon, 31 Aug 2026 15:58:01 +0100 Subject: [PATCH 6/6] 20260831 - Stop the banner folding once a fleet outgrows one row Bootstrap also defines .nav, and sets flex-wrap:wrap on it. Our rule never overrode that, so the banner folded onto a second row as soon as the tab strip grew: at six nodes on a 1400px screen the Retina Dashboard button dropped to a line of its own. On a phone four of the six nodes were simply off-screen with nothing to say so. Six nodes is not a stress case. Ticket 86cba42d8 lists eight boards. The strip now scrolls instead of folding. min-width:0 is the part that makes it work at all: without it a flex item cannot shrink below its content width, so overflow-x never engages and the row just gets wider. Deliberately overflow-x: auto rather than a hidden-scrollbar trick, because with an ordinary fleet there is no overflow and therefore no scrollbar, and when there is one it is the only thing telling the operator more nodes are off to the right. Below the app's 600px breakpoint the row is allowed to wrap again. Holding nowrap there was worse than the original bug: the brand and both outbound buttons do not fit alone, so the tab strip was squeezed to zero width and every node tab vanished. Wrapping gives the strip its own row, where it still scrolls. The footer already wraps at this width. Verified the standard case is untouched: with two nodes at 1400px the banner renders byte-identical with and without this change. Co-Authored-By: Claude Opus 5 --- static/common.css | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/static/common.css b/static/common.css index a0a821c..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;