Skip to content

Switch nodes from a banner instead of a separate page - #75

Merged
Purple10101 merged 8 commits into
mainfrom
20260825-mdns-fleet-banner
Aug 31, 2026
Merged

Purple10101 merged 8 commits into
mainfrom
20260825-mdns-fleet-banner

Conversation

@Purple10101

Copy link
Copy Markdown
Collaborator

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:

[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, forms and redirects behave normally. Tabs are absolute even for the node you are on, so a click always moves you off owl.local 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 a card page depending on the node count. All of that is gone. owl.local and ret*.local now serve the same Home. The shared alias is a way in; the banner is what moves you from there.

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.

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/peers and /healthz stay, since the banner is built from them.

retina.local is 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 /healthz answers 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 .nav and sets flex-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.local or ret*.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 shared owl.local and no _owl-node._tcp to discover peers through: a banner that can only ever show itself.

After both merge, retina_gui_version in owl-os versions.yml needs bumping to a tag of this work, or nodes keep running the previous GUI.

Purple10101 and others added 8 commits August 21, 2026 17:50
… 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>
@Purple10101
Purple10101 merged commit e911b78 into main Aug 31, 2026
4 of 5 checks passed
@Purple10101
Purple10101 deleted the 20260825-mdns-fleet-banner branch September 6, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant