mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Change the headset's addresses from the connection pill
The pill says plainly whether the headset is connected and how
("Connected · Tailscale", or the network's name), and never shows a
truncated address. Its dialog now leads with that, then lists the headset's
addresses with what each answered; they can be added, edited, reordered and
removed right there, and a new one is tested straight away. When the Frame
reports a LAN IP on this computer's network that isn't saved, it offers to
add it, so at home the app connects directly rather than over Tailscale.
The connection steps and this computer's network fold away while it's
connected and open when it isn't. Retry now and Set Up Connection only show
when they'd help; the buttons stay in reach when the dialog scrolls.
On the Devices tab the SSH alias, user, port and Forget identity move under
Advanced, and the address kind is worked out from the address. Report a
problem is a speech bubble rather than a warning sign.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
32fbbfe475
commit
cb0a90a9c3
5 files changed
+227
-70
No files matched your search
+210
-61
@@ -71,7 +71,8 @@
|
||||
@media (max-width: 1250px) { .brand b { display: none; } }
|
||||
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
||||
@media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; }
|
||||
.brand, #refreshAll { display: none; } .pill .pt span { display: none; } }
|
||||
.brand, #refreshAll { display: none; }
|
||||
.pill .pt > b, .pill .via { display: none; } .pill .pt > span { font-size: 13px; color: var(--bright); } }
|
||||
nav svg { display: none; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { -webkit-tap-highlight-color: transparent; }
|
||||
@@ -103,7 +104,27 @@
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
|
||||
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conn-now { display: flex; gap: 10px; align-items: baseline; }
|
||||
.conn-now .dot { position: relative; top: -1px; }
|
||||
.conn-now b { color: var(--bright); font-weight: 500; font-size: 15px; }
|
||||
.conn-offer { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border-radius: 4px;
|
||||
background: rgba(26,159,255,.1); border-left: 3px solid var(--blue); font-size: 13px; }
|
||||
.conn-offer .grow { flex: 1 1 260px; }
|
||||
.h3-note { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; }
|
||||
#connAddrs .item { gap: 6px; }
|
||||
#connAddrs .t { font-size: 13.5px; overflow-wrap: anywhere; }
|
||||
.conn-add { grid-template-columns: 2fr 1.2fr auto; }
|
||||
.conn-edit { grid-template-columns: 2fr 1.2fr auto auto; }
|
||||
.addr-add button.small, .dev-name button.small { height: 40px; }
|
||||
.dev-name { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; max-width: 520px; }
|
||||
.adv { margin-top: 18px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 4px; }
|
||||
.adv .dev-form { grid-template-columns: 1.3fr 1fr .6fr; }
|
||||
.conn-more { margin-top: 16px; }
|
||||
.conn-more summary { margin-top: 0; }
|
||||
/* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */
|
||||
#connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px;
|
||||
background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); }
|
||||
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
|
||||
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
@@ -144,8 +165,8 @@
|
||||
.addr .t { overflow-wrap: anywhere; }
|
||||
.addr .s { white-space: normal; }
|
||||
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
||||
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
|
||||
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
|
||||
.found { margin-top: 10px; }
|
||||
|
||||
/* ---- drop anywhere ---- */
|
||||
@@ -460,7 +481,8 @@
|
||||
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
|
||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||
.brand svg { width: 26px; height: 26px; }
|
||||
.brand { display: flex; } #refreshAll { display: inline-flex; } .pill .pt span { display: block; } /* the tabs are at the bottom here */
|
||||
.brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */
|
||||
.pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); }
|
||||
header .chip { height: 30px; padding: 0 10px; }
|
||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
@@ -672,8 +694,8 @@
|
||||
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
||||
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
|
||||
<button class="chip" id="battChip" title="Battery and headset details" popovertarget="devPop" aria-haspopup="dialog" hidden>—</button>
|
||||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
||||
<button id="reportBtn" data-report title="Report a problem or send feedback" aria-label="Report a problem or send feedback">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/><path d="M8 9.5h8M8 12.5h5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||||
@@ -1301,18 +1323,25 @@
|
||||
</dialog>
|
||||
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
|
||||
<h2 id="connTitle">Connection</h2>
|
||||
<div class="sub" id="connWhy"></div>
|
||||
<h3>This computer</h3>
|
||||
<dl class="facts" id="connNet"></dl>
|
||||
<h3>Steps</h3>
|
||||
<ol class="stages" id="connStages"></ol>
|
||||
<h3>Addresses</h3>
|
||||
<table class="probes"><tbody id="connProbes"></tbody></table>
|
||||
<div class="conn-now"><span class="dot" id="connDot"></span><div class="grow"><b id="connState"></b>
|
||||
<div class="sub" id="connWhy"></div></div></div>
|
||||
<div class="conn-offer" id="connOffer" hidden></div>
|
||||
<h3>Addresses <span class="h3-note">Frame Control tries them all at once; the first in the list wins a tie</span></h3>
|
||||
<div class="list" id="connAddrs"></div>
|
||||
<form class="addr-add conn-add" id="connAdd">
|
||||
<input type="text" id="caHost" maxlength="253" placeholder="Add an IP address or host name" aria-label="New address">
|
||||
<input type="text" id="caLabel" maxlength="60" placeholder="Label, e.g. Home" aria-label="Label for the new address">
|
||||
<button class="small" type="submit">Add</button></form>
|
||||
<details class="conn-more" id="connMore"><summary>Connection steps</summary>
|
||||
<ol class="stages" id="connStages"></ol>
|
||||
<h3>This computer</h3>
|
||||
<dl class="facts" id="connNet"></dl>
|
||||
</details>
|
||||
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
|
||||
<button type="button" class="small desk-only" id="connDevices">Manage headsets</button>
|
||||
<button type="button" class="small desk-only" id="connSetup">Set Up Connection…</button>
|
||||
<button type="button" class="small action" id="connRetryBtn">Retry now</button>
|
||||
<button type="button" class="small" id="connClose">Close</button></div>
|
||||
<button type="button" class="small desk-only" id="connDevices">All headsets…</button>
|
||||
<button type="button" class="small desk-only" id="connSetup" hidden>Set Up Connection…</button>
|
||||
<button type="button" class="small" id="connRetryBtn" hidden>Retry now</button>
|
||||
<button type="button" class="small" id="connClose" autofocus>Done</button></div>
|
||||
</dialog>
|
||||
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
|
||||
<form method="dialog" id="addDevForm">
|
||||
@@ -4233,28 +4262,36 @@ function netLabel(s) {
|
||||
}
|
||||
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
|
||||
|
||||
// The pill in the header: always there, and says what's happening right now.
|
||||
// How the headset is reached, in a word: Tailscale, the network's own name, or this network.
|
||||
function routeLabel(s) {
|
||||
if (s.via && s.via.kind === "tailscale") return "Tailscale";
|
||||
const named = s.network && dv.data && dv.data.networks.find(x => x.id === s.network.id && x.name);
|
||||
return named ? named.name : "this network";
|
||||
}
|
||||
// The pill in the header: always there, and says plainly what's happening. Addresses are in its
|
||||
// dialog, where there's room for them; the tooltip has the detail.
|
||||
function renderPill() {
|
||||
const s = link.s;
|
||||
if (!link.live || !s) return false;
|
||||
const dev = s.device ? s.device.name : "Frame";
|
||||
let dot = "wait", line;
|
||||
let dot = "wait", word, via, detail;
|
||||
if (s.phase === "connected") {
|
||||
dot = "on";
|
||||
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
|
||||
dot = "on"; word = "Connected"; via = routeLabel(s);
|
||||
detail = `connected via ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : "");
|
||||
} else if (s.phase === "failed") {
|
||||
dot = "off";
|
||||
dot = "off"; word = "Offline";
|
||||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
|
||||
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
|
||||
via = left != null ? `retrying in ${left} s` : "";
|
||||
detail = s.error ? s.error.message : "not connected";
|
||||
} else {
|
||||
word = "Connecting…";
|
||||
const st = activeStage(s);
|
||||
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
|
||||
line = st ? st.label + (st.id === "find" && probing ? `: ${probing.host}` : st.detail && st.id !== "network" ? `: ${st.detail}` : "…")
|
||||
: "Connecting…";
|
||||
via = st ? st.label.toLowerCase() : "";
|
||||
detail = via || "connecting";
|
||||
}
|
||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
|
||||
$("conn").title = `${dev}: ${line}. Click for details.`;
|
||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
|
||||
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
|
||||
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
|
||||
return true;
|
||||
}
|
||||
window.connPill = renderPill;
|
||||
@@ -4278,17 +4315,27 @@ function renderBanner() {
|
||||
}
|
||||
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, sig: "", phase: null };
|
||||
function renderConnDlg() {
|
||||
const s = link.s;
|
||||
if (!s || !$("connDlg").open) return;
|
||||
$("connTitle").textContent = s.device ? `${s.device.name} (ssh ${s.device.alias})` : "Connection";
|
||||
$("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via && s.via.why ? " (" + s.via.why + ")" : ""}`
|
||||
: s.phase === "failed" ? (s.error ? s.error.message : "Not connected")
|
||||
$("connTitle").textContent = s.device ? s.device.name : "Connection";
|
||||
const connected = s.phase === "connected", failed = s.phase === "failed";
|
||||
$("connDot").className = "dot " + (connected ? "on" : failed ? "off" : "wait");
|
||||
$("connState").textContent = connected ? `Connected via ${routeLabel(s)}` : failed ? "Offline" : "Connecting…";
|
||||
$("connWhy").textContent = connected ? viaText(s.via) + (s.via && s.via.rtt_ms != null ? ` · answered in ${s.via.rtt_ms} ms` : "")
|
||||
: failed ? (s.error ? s.error.message : "Not connected")
|
||||
: `${s.reason || "Connecting"}…`;
|
||||
// Folded while connected; opened when something's wrong. Left as the user set it otherwise.
|
||||
if (s.phase !== cd.phase) { $("connMore").open = !connected; cd.phase = s.phase; }
|
||||
$("connRetryBtn").hidden = !failed;
|
||||
$("connRetryBtn").classList.toggle("action", failed);
|
||||
$("connSetup").hidden = connected;
|
||||
const n = s.network || {}, ts = n.tailscale || {};
|
||||
const facts = [["Network", n.name || "—"], ["Wi-Fi", n.ssid || (n.wifi ? "Wi-Fi (name hidden by the system)" : n.wifi === false ? "Wired" : "—")],
|
||||
["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"],
|
||||
["This computer", n.local_ip || "—"],
|
||||
const facts = [["Network", n.ssid || n.name || "—"],
|
||||
["This computer", n.local_ip || "—"], ["Router", n.gateway || "No default route"],
|
||||
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
|
||||
$("connNet").innerHTML = facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
|
||||
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
|
||||
@@ -4297,14 +4344,108 @@ function renderConnDlg() {
|
||||
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
|
||||
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
|
||||
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
|
||||
$("connProbes").innerHTML = (s.probes || []).map(p => `<tr><td>${esc(p.host)}${p.label ? `<div class="sub">${esc(p.label)}</div>` : ""}</td>
|
||||
<td><span class="tag">${esc(KIND_TAG[p.kind] || p.kind)}</span></td><td class="sub">${esc(p.why || "")}</td>
|
||||
<td class="res-${esc(p.state)}">${esc(p.detail)}${p.ip && p.ip !== p.host ? ` <span class="sub">${esc(p.ip)}</span>` : ""}</td></tr>`).join("")
|
||||
|| `<tr><td class="sub">No addresses tried yet.</td></tr>`;
|
||||
renderConnAddrs();
|
||||
const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||
$("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : "";
|
||||
}
|
||||
|
||||
const PRIVATE_V4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)\d{1,3}\.\d{1,3}(\.\d{1,3})?$/;
|
||||
const sameSubnet = (a, b) => !!a && !!b && a.split(".").slice(0, 3).join(".") === b.split(".").slice(0, 3).join(".");
|
||||
// The Frame's own LAN address, when this computer is on the same network and it isn't saved yet:
|
||||
// saving it lets Frame Control connect directly at home rather than over Tailscale.
|
||||
function lanOffer(s, d) {
|
||||
const ip = state && state.ip, n = s.network || {};
|
||||
if (!d || d.transient || !ip || !PRIVATE_V4.test(ip) || !sameSubnet(ip, n.local_ip)) return null;
|
||||
if (d.addresses.some(a => a.host === ip) || (s.probes || []).some(p => p.ip === ip && p.kind !== "tailscale")) return null;
|
||||
const named = dv.data && dv.data.networks.find(x => x.id === n.id && x.name);
|
||||
return { ip, label: named ? named.name : n.ssid || "" };
|
||||
}
|
||||
function activeDevice() { const s = link.s; return s && s.device ? dv.list.find(x => x.id === s.device.id) : null; }
|
||||
function renderConnAddrs() {
|
||||
const s = link.s, d = activeDevice(), list = $("connAddrs");
|
||||
const offer = s && lanOffer(s, d);
|
||||
$("connOffer").hidden = !offer;
|
||||
if (offer) {
|
||||
const key = offer.ip + offer.label;
|
||||
if ($("connOffer").dataset.key !== key) {
|
||||
$("connOffer").dataset.key = key;
|
||||
$("connOffer").innerHTML = `<div class="grow">The Frame is at <b>${esc(offer.ip)}</b> on this network. Add it for this
|
||||
network? Frame Control will then connect to it directly whenever you're here.</div>
|
||||
<button class="small action" id="connOfferAdd">Add ${esc(offer.ip)}</button>`;
|
||||
$("connOfferAdd").onclick = e => devAction(`Add ${offer.ip}`, { action: "address-add", id: d.id, host: offer.ip,
|
||||
kind: "lan", label: offer.label }, e.currentTarget)
|
||||
.then(res => res && testQuietly(d.id));
|
||||
}
|
||||
} else $("connOffer").dataset.key = "";
|
||||
$("connAdd").hidden = !d || !!d.transient;
|
||||
if (!d) { list.innerHTML = `<div class="sub">Loading…</div>`; cd.sig = ""; return; }
|
||||
if (d.transient) {
|
||||
list.innerHTML = `<div class="sub">This headset isn't set up in Frame Control yet, so ssh's own settings decide where it goes.
|
||||
Set Up Connection adds its addresses here.</div>`;
|
||||
cd.sig = ""; return;
|
||||
}
|
||||
const probes = new Map((s.probes || []).map(p => [p.host, p]));
|
||||
const tested = new Map((((s.tests || {})[d.id] || {}).rows || []).map(r => [r.host, r]));
|
||||
const inUse = s.phase === "connected" && s.via ? s.via.host : null;
|
||||
const sig = JSON.stringify([d.id, d.addresses, [...probes.values(), ...tested.values()].map(p => [p.host, p.state, p.ssh, p.detail]), cd.editing, inUse]);
|
||||
// Leave the rows alone when nothing has changed, and while an address is being edited.
|
||||
if (sig === cd.sig || cd.editing && list.contains(document.activeElement) && document.activeElement.matches("input")) return;
|
||||
cd.sig = sig;
|
||||
const refocus = cd.refocus; // after an edit, back to that row's Edit button
|
||||
cd.refocus = null;
|
||||
list.innerHTML = d.addresses.map((a, i) => {
|
||||
if (cd.editing === a.host) return `<div class="item addr"><div class="addr-edit conn-edit">
|
||||
<input type="text" id="ceHost" value="${esc(a.host)}" maxlength="253" aria-label="Address">
|
||||
<input type="text" id="ceLabel" value="${esc(a.label)}" maxlength="60" placeholder="Label" aria-label="Label">
|
||||
<button class="small action" id="ceSave">Save</button><button class="small" id="ceCancel">Cancel</button></div></div>`;
|
||||
const p = probes.get(a.host), t = tested.get(a.host);
|
||||
const result = a.host === inUse ? `<span class="res-ok">In use${p && p.detail ? " · " + esc(p.detail.toLowerCase()) : ""}</span>`
|
||||
: t ? `<span class="res-${esc(t.ssh || t.state)}">${esc(t.detail)}${t.ssh === "checking" ? " · checking SSH…" : ""}</span>`
|
||||
: p ? `<span class="res-${esc(p.state)}">${esc(p.detail)}</span>`
|
||||
: a.last_ok ? `Worked ${esc(ago(a.last_ok))}` : "Not tried on this network";
|
||||
return `<div class="item addr" data-host="${esc(a.host)}">
|
||||
<div class="grow"><div class="t">${esc(a.host)} <span class="tag">${esc(KIND_TAG[a.kind] || a.kind)}</span>${a.label ? ` <span class="sub">${esc(a.label)}</span>` : ""}</div>
|
||||
<div class="s">${result}</div></div>
|
||||
<button class="small" data-mv="-1" title="Try earlier" aria-label="Move ${esc(a.host)} up"${i ? "" : " disabled"}>↑</button>
|
||||
<button class="small" data-mv="1" title="Try later" aria-label="Move ${esc(a.host)} down"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
|
||||
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`;
|
||||
}).join("") || `<div class="sub">No addresses yet. Add the Frame's IP address below.</div>`;
|
||||
if (refocus) list.querySelector(`[data-host="${CSS.escape(refocus)}"] [data-ed]`)?.focus();
|
||||
list.querySelectorAll("[data-host]").forEach(row => {
|
||||
const host = row.dataset.host;
|
||||
row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () =>
|
||||
devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b));
|
||||
row.querySelector("[data-ed]").onclick = () => { cd.editing = host; renderConnAddrs(); $("ceHost").focus(); };
|
||||
row.querySelector("[data-rm]").onclick = e => {
|
||||
if (host === inUse && !confirm(`${host} is how Frame Control is reaching the headset now. Remove it?`)) return;
|
||||
devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget);
|
||||
};
|
||||
});
|
||||
if ($("ceSave")) {
|
||||
const host = cd.editing, a = d.addresses.find(x => x.host === host);
|
||||
const save = e => {
|
||||
e.preventDefault();
|
||||
const newHost = $("ceHost").value.trim(), label = $("ceLabel").value;
|
||||
cd.editing = null; cd.refocus = newHost || host;
|
||||
devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost, kind: a.kind, label }, $("ceSave"))
|
||||
.then(res => res && testQuietly(d.id));
|
||||
};
|
||||
$("ceSave").onclick = save;
|
||||
[$("ceHost"), $("ceLabel")].forEach(i => i.onkeydown = e => { if (e.key === "Enter") save(e); if (e.key === "Escape") { e.preventDefault(); $("ceCancel").click(); } });
|
||||
$("ceCancel").onclick = () => { cd.editing = null; cd.sig = ""; cd.refocus = host; renderConnAddrs(); };
|
||||
}
|
||||
}
|
||||
$("connAdd").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const d = activeDevice(), host = $("caHost").value.trim();
|
||||
if (!d) return;
|
||||
if (!host) return $("caHost").focus();
|
||||
const res = await devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("caLabel").value });
|
||||
if (res) { $("caHost").value = ""; $("caLabel").value = ""; testQuietly(d.id); }
|
||||
};
|
||||
// Try the addresses (without reconnecting), so a new one shows whether it answers.
|
||||
function testQuietly(id) { api("/api/devices", { action: "test", id }).catch(() => {}); }
|
||||
|
||||
function onConnection(s) {
|
||||
const prev = link.s;
|
||||
link.s = s; link.live = !s.local; link.lastData = Date.now();
|
||||
@@ -4401,7 +4542,9 @@ setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { rend
|
||||
|
||||
$("conn").onclick = () => {
|
||||
if (!link.live) { if (online === false) refresh(); return; }
|
||||
cd.editing = null; cd.sig = ""; cd.phase = null;
|
||||
$("connDlg").showModal(); renderConnDlg();
|
||||
if (!dv.data) loadDevices();
|
||||
};
|
||||
$("offDetails").onclick = () => $("conn").click();
|
||||
$("connClose").onclick = () => $("connDlg").close();
|
||||
@@ -4460,7 +4603,7 @@ async function devAction(label, body, btn) {
|
||||
function deviceStatus(d) {
|
||||
const s = link.s;
|
||||
if (d.active && s) {
|
||||
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
|
||||
return s.phase === "connected" ? ["on", `Connected via ${routeLabel(s)}`]
|
||||
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
|
||||
}
|
||||
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
|
||||
@@ -4474,7 +4617,7 @@ function renderDevices() {
|
||||
const [dot, text] = deviceStatus(d);
|
||||
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
|
||||
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
|
||||
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
|
||||
<div class="s">${esc(text)}</div></div>
|
||||
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
|
||||
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
|
||||
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
|
||||
@@ -4489,6 +4632,7 @@ function renderDevices() {
|
||||
$("netName").disabled = $("netSave").disabled = !(n && n.id);
|
||||
}
|
||||
renderDetail();
|
||||
renderConnDlg(); // the pill's dialog lists the same addresses
|
||||
}
|
||||
$("netSave").onclick = e => {
|
||||
const n = dv.data && dv.data.network;
|
||||
@@ -4526,19 +4670,13 @@ function renderDetail() {
|
||||
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
|
||||
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
|
||||
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
|
||||
<form class="dev-form" id="dvForm">
|
||||
<form class="dev-name" id="dvNameForm">
|
||||
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
|
||||
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
||||
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
||||
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
||||
</form>
|
||||
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
||||
<span class="sub">User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
|
||||
<h3>Addresses <span class="sub" style="text-transform:none; letter-spacing:0; font-weight:400">tried all at once; this order breaks ties</span></h3>
|
||||
<button class="small" type="submit">Rename</button></form>
|
||||
<h3>Addresses <span class="h3-note">tried all at once; the first in the list wins a tie</span></h3>
|
||||
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
|
||||
<form class="addr-add" id="dvAdd">
|
||||
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
|
||||
${kindSel("adKind", "")}
|
||||
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
|
||||
<button class="small" type="submit">Add</button></form>
|
||||
<div class="row" style="margin-top:14px">
|
||||
@@ -4546,22 +4684,33 @@ function renderDetail() {
|
||||
<button class="small" id="dvTs">Find on Tailscale</button>
|
||||
<button class="small" id="dvMdns">Find on this network</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
|
||||
<button class="small danger" id="dvRemove">Remove…</button></div>
|
||||
<button class="small danger" id="dvRemove">Remove headset…</button></div>
|
||||
<div class="found" id="dvFound"></div>
|
||||
<div class="hint">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
||||
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
|
||||
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
|
||||
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
|
||||
$("adKind").value = "";
|
||||
<details class="adv"${dv.adv ? " open" : ""}><summary>Advanced</summary>
|
||||
<form class="dev-form" id="dvForm">
|
||||
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
||||
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
||||
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
||||
</form>
|
||||
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
||||
<span class="sub">In Terminal it's <code>ssh ${esc(d.alias)}</code>. User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
|
||||
<div class="row" style="margin-top:14px"><button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
|
||||
<span class="sub">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
||||
: "not saved yet; the next connection saves it."}</span></div>
|
||||
<div class="hint">An address's kind is worked out from it: <code>.local</code> names are mDNS, <code>100.x</code> and
|
||||
<code>.ts.net</code> are Tailscale, and other IPs are this network. Edit an address to set it yourself.</div>
|
||||
</details>`;
|
||||
el.querySelector(".adv").ontoggle = e => { dv.adv = e.target.open; };
|
||||
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
|
||||
$("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
|
||||
user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget);
|
||||
const saveDev = btn => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
|
||||
user: $("dvUser").value, port: $("dvPort").value }, btn);
|
||||
$("dvSave").onclick = e => saveDev(e.currentTarget);
|
||||
$("dvNameForm").onsubmit = e => { e.preventDefault(); saveDev(e.submitter); };
|
||||
$("dvAdd").onsubmit = e => {
|
||||
e.preventDefault();
|
||||
const host = $("adHost").value.trim();
|
||||
if (!host) return $("adHost").focus();
|
||||
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value, label: $("adLabel").value });
|
||||
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("adLabel").value });
|
||||
};
|
||||
el.querySelectorAll("[data-host]").forEach(row => {
|
||||
const host = row.dataset.host;
|
||||
|
||||
Reference in new issue
Block a user