diff --git a/docs/devices.md b/docs/devices.md index 1d691b3..ac8e9e5 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -78,9 +78,10 @@ can add, edit, reorder and remove them without leaving the page you're on. When the headset reports a LAN IP on the same network as this computer and that IP isn't saved, it offers to add it. The offer puts the address first in the list, so on that network it wins over the Tailscale name; away from home the Tailscale -name still leads. A new or edited address is tested straight away. When a tested -address ranks above the one in use, **Use now** reconnects through it; otherwise -the change takes effect the next time Frame Control connects. +name still leads. A new or edited address is tested straight away. When an address +that would be tried before the one in use passes the test, it gets **Use now**, +which reconnects; the reconnect picks the first address that answers, which is +that one. Otherwise the change takes effect the next time Frame Control connects. ## Networks diff --git a/tests/test_link.py b/tests/test_link.py index 782306e..85d1802 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -304,6 +304,19 @@ class Connecting(unittest.TestCase): self.assertEqual(self.link.active_device()["alias"], "frame-bare") self.assertEqual(self.routes[-1][0], "frame-bare") + def test_devices_view_ranks_addresses_for_the_current_network(self): + # The page offers Use now only on the address a reconnect would pick: rank says which. + d = self.device("frame.tail1234.ts.net") + self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True) + self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0) + self.link.state["network"] = {"id": "n-home", "name": "Home", "tailscale": {"up": True}} + view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"]) + self.assertEqual({a["host"]: a["rank"] for a in view["addresses"]}, + {"192.168.1.40": 1, "frame.tail1234.ts.net": 0}) # only Tailscale worked here so far + self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) + view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"]) + self.assertEqual([a["rank"] for a in view["addresses"]], [0, 1]) # now the user's order decides + def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self): d = self.device("localhost") self.link.override = self.link.session_alias = "frame-t" diff --git a/ui/frame_link.py b/ui/frame_link.py index b890b52..46a01b5 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -1017,6 +1017,8 @@ def devices_view(link): snap = link.reg.snapshot() active = link.active_device() names = {nid: link.reg.network_name(dict(n, id=nid)) for nid, n in snap["networks"].items()} + net = link.state["network"] or {} + tailscale_up = bool((net.get("tailscale") or {}).get("up")) devices = [] bare = link.bare(link.session_alias) if link.session_alias and not link.reg.by_alias(link.session_alias) else None for extra in ([active] if active.get("transient") and not active.get("none") else []) + \ @@ -1027,8 +1029,12 @@ def devices_view(link): view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")} view["active"] = d["id"] == active["id"] view["pinned"] = frame_devices.pinned(d["id"]) - view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]]) - for a in d["addresses"]] + # rank: where the next connection on this network tries it (0 first), so the page can + # tell which address a reconnect would pick. + ranks = {a["host"]: i for i, (a, _) in + enumerate(frame_devices.order_addresses(d["addresses"], net.get("id"), tailscale_up))} + view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]], + rank=ranks[a["host"]]) for a in d["addresses"]] devices.append(view) return {"devices": devices, "active": active["id"], "network": link.state["network"], "networks": [dict(n, id=nid, display=names[nid]) for nid, n in snap["networks"].items()], diff --git a/ui/index.html b/ui/index.html index 36ba526..efe4229 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4324,7 +4324,7 @@ window.connBanner = renderBanner; // The pill's dialog: how it's connected, and the headset's addresses, which can be added, // edited and reordered right here. The steps it took stay folded away while all is well. -const cd = { editing: null, editDev: null, sig: "", phase: null }; +const cd = { editing: null, editDev: null, session: 0, sig: "", phase: null }; function renderConnDlg() { const s = link.s; if (!s || !$("connDlg").open) return; @@ -4406,7 +4406,12 @@ function renderConnAddrs() { const refocus = cd.refocus ? [cd.refocus, "[data-ed]"] : held ? [held.dataset.host, ["[data-use]", "[data-mv='-1']", "[data-mv='1']", "[data-ed]", "[data-rm]"].find(q => document.activeElement.matches(q))] : null; cd.refocus = null; - const usedAt = d.addresses.findIndex(a => a.host === inUse); + // A reconnect picks the first-ranked address that answers: offer it when that's a tested + // address other than the one in use. + const used = d.addresses.find(a => a.host === inUse); + const best = used && d.addresses.filter(a => (tested.get(a.host) || {}).ssh === "ok") + .reduce((x, a) => !x || a.rank < x.rank ? a : x, null); + const switchTo = best && best.rank < used.rank ? best.host : null; list.innerHTML = d.addresses.map((a, i) => { if (cd.editing === a.host && cd.editDev === d.id) return `