Switch nodes from a banner instead of a separate page - #75
Merged
Merged
Conversation
… 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<node_id>.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<node_id>.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<node_id>.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 <noreply@anthropic.com>
Renaming sat on the home page, which was the wrong home for it twice over. The home page is a status view, and everything else that saves on its own rather than with the config form already lives under Administration on the config page, next to SSH access and Cloud services. It is now the first entry in that group, deliberately outside #configForm so there is no implication that Apply changes has anything to do with it. The section also shows the node's own ret<node_id>.local, which is the address worth bookmarking: owl.local is shared by every node and may reach any of them. Rebuilt on the real field markup while moving it. The first version invented cfg-field and hint classes that do not exist in the stylesheet; it now uses the cfg-row / label-col / help / cfg-readonly pattern the rest of the page uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # src/routes/config.py
Design A put the fleet behind a Nodes tab: to move between nodes you opened the
tab, read a page of cards, and clicked through, landing somewhere with no
memory of the fleet. Three actions, and the fleet invisible the rest of the
time.
The banner puts every node on screen on every page, one click apart:
[Summary] [reta2415d60] [ret3773656d] [Server] fleet scope
[Home] [Config] the selected node
No iframe and no shell. Each node tab is a plain link to that node's own
ret<node_id>.local, and that node serves its own pages with the same banner,
itself marked active. Real URLs, working Back button, no chromeless mode to
maintain, and forms and redirects behave exactly as they always did. Switching
costs a page load, which on a LAN is not perceptible.
Tabs are absolute even for the node you are already on, so a click always moves
you off owl.local and onto an address that means one specific node.
## owl.local stops being a mode
`/` used to read the Host header and either redirect to ret*.local or render
the card page depending on how many nodes were visible. All of that is gone:
owl.local and ret*.local now serve the same Home. The shared alias is a way in,
and the banner is what moves you from there. is_entry_point() and
entry_point_response() go with it.
## The setup wizard
setup.html blanked the navbar entirely, which is what made the wizard
inescapable. Under a banner that turns any node mid-setup into a dead end in
every other node's tab strip, and an abandoned wizard holds it there until the
24h timeout in device_state clears it.
So the lock is split by scope. The parent row renders in the wizard, because
leaving for another node is not wandering out of setup. The child row does not,
because Home and Config genuinely should not be reachable on a node that is
mid-setup; both redirects enforcing that are untouched.
It goes in as the first child of .wiz, which is a 100vh flex column with
.wiz-body on flex:1, so the body shrinks to fit and no CSS changes are needed.
Outside .wiz it would have pushed a full-height element down and overflowed.
## A macro, not an include
The bar is a macro 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 rather than the context dict, so the include silently
renders the default. Caught by seeing "OWL-OS" stacked directly above "OWL-OS
Setup" for the same node.
## Removed
The card page, its route, and the Nodes tab. The banner lists every node, so a
page doing the same would only drift. /api/fleet/peers and /healthz stay, since
the banner is built from them, and mdns_peers is untouched.
The card page also carried the by-IP fallback and the honest note that
discovery can hide a node that is up and well. The banner has nowhere to put
either, so both moved to Summary rather than being lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<node_id>.local, and the shared owl.local that every node on the network answers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
With more than one node on a site, moving between them meant opening a Nodes tab, reading a page of cards and clicking through, landing somewhere with no memory of the fleet. Three actions, and the fleet invisible the rest of the time.
What this does
Every node is on screen on every page, one click apart:
No iframe and no shell. Each node tab is a plain link to that node's own
ret<node_id>.local, and that node serves its own pages with the same banner, itself marked active. Real URLs, working Back button, no chromeless mode to maintain, forms and redirects behave normally. Tabs are absolute even for the node you are on, so a click always moves you offowl.localonto an address that means one specific node.owl.local stops being a mode
/used to read the Host header and either redirect toret*.localor render a card page depending on the node count. All of that is gone.owl.localandret*.localnow serve the same Home. The shared alias is a way in; the banner is what moves you from there.The setup wizard
setup.htmlblanked the navbar entirely, which is what made the wizard inescapable. Under a banner that turns any node mid-setup into a dead end in every other node's tab strip, and an abandoned wizard holds it there until the 24h timeout.The lock is split by scope. The parent row renders in the wizard, because leaving for another node is not wandering out of setup. The child row does not, because Home and Config genuinely should not be reachable mid-setup. Both redirects enforcing that are untouched.
Removed
The card page, its route, and the Nodes tab. The banner lists every node, so a page doing the same would only drift.
/api/fleet/peersand/healthzstay, since the banner is built from them.retina.localis retired from the README. Nothing has published it since the owl-os change, so the line was pointing at a name that no longer resolves.Verified
667 tests pass. Deployed and exercised on four nodes: drop-off clears a tab in 25s, recovery under 5s, a node mid-wizard stays reachable and escapable, renames propagate within the 60s browse bound, and
/healthzanswers in 4 to 9ms on a node at load average 4 with four blah2 containers running, against a 2s probe timeout.Banner layout tested at 2 and 6 nodes, at 1400px and 390px. The 6-node case found a real bug (Bootstrap also defines
.navand setsflex-wrap: wrap), fixed in the last commit, with the standard 2-node desktop view proved byte-identical before and after.Not verified
No client device has resolved
owl.localorret*.local. All testing was node to node over Linux and avahi, and the design rests on client resolvers coping with multiple A records for one name.MUST LAND WITH
offworldlabs/owl-os#53, which ships the mDNS layer. Merged without it, every node gets a banner with no
ret*.local, no sharedowl.localand no_owl-node._tcpto discover peers through: a banner that can only ever show itself.After both merge,
retina_gui_versionin owl-osversions.ymlneeds bumping to a tag of this work, or nodes keep running the previous GUI.