locadot 2.0.0 → 2.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -181,6 +181,72 @@ header {
181
181
  .live-dot.down { background: var(--down); box-shadow: 0 0 0 3px var(--down-bg); }
182
182
  body.offline .live-label { color: var(--down); }
183
183
 
184
+ /* ---------- sidebar ---------- */
185
+ :root { --sb-w: 232px; }
186
+ :root[data-sidebar="collapsed"] { --sb-w: 64px; }
187
+ .sidebar {
188
+ position: fixed;
189
+ top: 0; bottom: 0; left: 0;
190
+ z-index: 7;
191
+ width: var(--sb-w);
192
+ display: flex;
193
+ flex-direction: column;
194
+ background: var(--surface);
195
+ border-right: 1px solid var(--border);
196
+ overflow: hidden;
197
+ transition: width 0.18s ease, transform 0.2s ease;
198
+ }
199
+ .shell { margin-left: var(--sb-w); min-height: 100vh; transition: margin-left 0.18s ease; }
200
+ .sb-head { height: 56px; display: flex; align-items: center; gap: 10px; padding: 0 20px; border-bottom: 1px solid var(--border); flex-shrink: 0; white-space: nowrap; }
201
+ .sb-links { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px 10px; display: flex; flex-direction: column; gap: 2px; }
202
+ .sb-section { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-dim); padding: 14px 12px 6px; white-space: nowrap; }
203
+ .sb-link {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 12px;
207
+ height: 36px;
208
+ padding: 0 12px;
209
+ border: 0;
210
+ border-radius: var(--radius-sm);
211
+ background: none;
212
+ color: var(--muted);
213
+ font-size: 13.5px;
214
+ font-weight: 500;
215
+ white-space: nowrap;
216
+ cursor: pointer;
217
+ text-align: left;
218
+ width: 100%;
219
+ }
220
+ .sb-link:hover { background: var(--surface-2); color: var(--fg); text-decoration: none; }
221
+ .sb-link[aria-current="true"] { background: var(--accent-bg); color: var(--accent-strong); }
222
+ .sb-link svg { width: 18px; height: 18px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
223
+ .sb-count { margin-left: auto; font-size: 11px; font-weight: 600; min-width: 20px; height: 18px; padding: 0 6px; border-radius: 999px; background: var(--surface-3); color: var(--fg-2); display: inline-flex; align-items: center; justify-content: center; }
224
+ .sb-collapse { margin: 8px 10px 12px; width: auto; flex-shrink: 0; }
225
+ .sb-collapse svg { transition: transform 0.18s ease; }
226
+ :root[data-sidebar="collapsed"] .sb-label { display: none; }
227
+ :root[data-sidebar="collapsed"] .sb-head { padding: 0; justify-content: center; }
228
+ :root[data-sidebar="collapsed"] .sb-link { justify-content: center; padding: 0; }
229
+ :root[data-sidebar="collapsed"] .sb-collapse svg { transform: scaleX(-1); }
230
+ :root[data-sidebar="collapsed"] .sb-links { padding-top: 12px; }
231
+ .sb-menu, .brand-mobile { display: none; }
232
+ .btn.sb-menu { display: none; width: 32px; height: 32px; padding: 0; color: var(--muted); }
233
+ .sb-menu svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
234
+ .sb-backdrop { position: fixed; inset: 0; z-index: 6; background: rgba(0, 0, 0, 0.45); }
235
+ .card, details.card { scroll-margin-top: 76px; }
236
+ @media (max-width: 899px) {
237
+ :root, :root[data-sidebar="collapsed"] { --sb-w: 0px; }
238
+ .sidebar { width: 260px; transform: translateX(-100%); box-shadow: none; visibility: hidden; transition: transform 0.2s ease, visibility 0s linear 0.2s; }
239
+ body.sb-open .sidebar { transform: none; box-shadow: var(--shadow-md); visibility: visible; transition: transform 0.2s ease; }
240
+ :root[data-sidebar="collapsed"] .sb-label { display: revert; }
241
+ :root[data-sidebar="collapsed"] .sb-count { display: inline-flex; }
242
+ :root[data-sidebar="collapsed"] .sb-head { padding: 0 20px; justify-content: flex-start; }
243
+ :root[data-sidebar="collapsed"] .sb-link { justify-content: flex-start; padding: 0 12px; }
244
+ .sb-collapse { display: none; }
245
+ .btn.sb-menu { display: inline-flex; }
246
+ .brand-mobile { display: inline-block; }
247
+ span.wordmark.brand-mobile { display: inline; }
248
+ }
249
+
184
250
  /* ---------- page layout ---------- */
185
251
  main {
186
252
  max-width: 1360px;
@@ -387,6 +453,29 @@ body.offline .switch, body.offline #add-submit { opacity: 0.5; pointer-events: n
387
453
  .field input.invalid:focus-visible, .edit-input.invalid:focus-visible { box-shadow: 0 0 0 3px var(--down-bg); }
388
454
  .edit-input { height: 30px; min-width: 0; padding: 0 10px; }
389
455
  input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
456
+ select.input, .field select {
457
+ height: 36px;
458
+ width: 100%;
459
+ padding: 0 30px 0 12px;
460
+ font-family: var(--sans);
461
+ font-size: 13px;
462
+ color: var(--fg);
463
+ background-color: var(--surface);
464
+ border: 1px solid var(--border-strong);
465
+ border-radius: var(--radius-sm);
466
+ box-shadow: var(--shadow-sm);
467
+ appearance: none;
468
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
469
+ background-position: calc(100% - 16px) center, calc(100% - 11px) center;
470
+ background-size: 5px 5px, 5px 5px;
471
+ background-repeat: no-repeat;
472
+ }
473
+ select.input:focus-visible, .field select:focus-visible {
474
+ outline: none;
475
+ border-color: var(--accent);
476
+ box-shadow: 0 0 0 3px var(--accent-bg);
477
+ }
478
+ select.input-sm { height: 28px; font-size: 12px; padding: 0 24px 0 8px; background-position: calc(100% - 10px) center, calc(100% - 6px) center; }
390
479
 
391
480
  /* ---------- add host form ---------- */
392
481
  .form-grid { display: flex; flex-direction: column; gap: 14px; }
@@ -476,6 +565,8 @@ tr.row-down td:first-child { box-shadow: inset 3px 0 0 var(--down); }
476
565
  .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
477
566
  .dot.up { background: var(--up); box-shadow: 0 0 0 3px var(--up-bg); }
478
567
  .dot.down { background: var(--down); box-shadow: 0 0 0 3px var(--down-bg); }
568
+ .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-bg); }
569
+ .dot.accent { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
479
570
  .edit-hint { font-size: 12px; }
480
571
  td.edit-error { color: var(--down); white-space: normal; }
481
572
  td .inline-check + .inline-check { margin-top: 4px; }
@@ -548,6 +639,40 @@ pre.code-block {
548
639
  .cli-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
549
640
  .cli-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
550
641
 
642
+ /* ---------- remote access ---------- */
643
+ .hub-section { padding: 16px 20px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
644
+ .hub-section:first-child { border-top: none; }
645
+ .hub-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
646
+ .hub-url { font-family: var(--mono); font-size: 12.5px; word-break: break-all; color: var(--fg-2); }
647
+ .hub-warn { font-size: 12px; color: var(--warn); background: var(--warn-bg); border-radius: var(--radius-sm); padding: 6px 10px; }
648
+ .hub-actions, .invite-actions { display: flex; gap: 8px; flex-wrap: wrap; }
649
+ .hub-form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
650
+ .hub-form-row .field { flex: 1; min-width: 140px; }
651
+ .invite-result { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
652
+ .invite-result-row { display: flex; align-items: center; gap: 8px; }
653
+ .invite-string { font-family: var(--mono); font-size: 11.5px; word-break: break-all; flex: 1; min-width: 0; }
654
+ .invite-countdown { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
655
+ .host-checks { display: flex; flex-direction: column; gap: 6px; max-height: 140px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; }
656
+ .host-checks[hidden] { display: none; }
657
+ .host-checks label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--fg-2); cursor: pointer; }
658
+ .peer-list, .invite-list, .remote-list { display: flex; flex-direction: column; }
659
+ .remote-list:not(:empty) { padding: 0 20px 12px; }
660
+ .peer-row, .invite-row, .remote-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
661
+ .peer-row:last-child, .invite-row:last-child, .remote-item:last-child { border-bottom: none; }
662
+ .peer-main, .remote-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
663
+ .peer-name { font-weight: 500; font-size: 13px; }
664
+ .peer-controls, .remote-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
665
+ .remote-item { flex-direction: column; align-items: stretch; }
666
+ .remote-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
667
+ .remote-hosts-list { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
668
+ .remote-host-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; padding: 6px 0; border-top: 1px solid var(--border); }
669
+ .remote-host-row:first-child { border-top: none; }
670
+ .badge-role { text-transform: capitalize; }
671
+ .remote-card-body { display: flex; flex-direction: column; gap: 14px; }
672
+ .mini-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
673
+ .mini-form input { flex: 1; min-width: 90px; }
674
+ .host-cell .badge-via { margin-left: 4px; }
675
+
551
676
  /* ---------- banners ---------- */
552
677
  .banner {
553
678
  border-radius: var(--radius);
@@ -688,8 +813,10 @@ footer a { color: var(--muted); }
688
813
  #add-card { order: 2; }
689
814
  #system-card { order: 3; }
690
815
  #sharing-card { order: 4; }
691
- #logs-panel { order: 5; }
692
- #cli-card { order: 6; }
816
+ #hub-card { order: 5; }
817
+ #remotes-card { order: 6; }
818
+ #logs-panel { order: 7; }
819
+ #cli-card { order: 8; }
693
820
  }
694
821
  @media (min-width: 700px) and (max-width: 1099px) {
695
822
  .layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -769,6 +896,85 @@ const clientJs = `
769
896
  btn.addEventListener("click", function () { apply(order[(order.indexOf(mode) + 1) % order.length]); });
770
897
  })();
771
898
 
899
+ (function () {
900
+ var root = document.documentElement;
901
+ var sidebar = document.getElementById("sidebar");
902
+ var collapseBtn = document.getElementById("sb-collapse");
903
+ var menuBtn = document.getElementById("sb-menu");
904
+ var backdrop = document.getElementById("sb-backdrop");
905
+ var links = Array.prototype.slice.call(sidebar.querySelectorAll("a.sb-link"));
906
+ var drawer = window.matchMedia("(max-width: 899px)");
907
+
908
+ function syncCollapse() {
909
+ var collapsed = root.getAttribute("data-sidebar") === "collapsed";
910
+ collapseBtn.setAttribute("aria-expanded", String(!collapsed));
911
+ collapseBtn.title = collapsed ? "Expand sidebar" : "Collapse sidebar";
912
+ collapseBtn.querySelector(".sb-label").textContent = collapsed ? "Expand" : "Collapse";
913
+ }
914
+ syncCollapse();
915
+ collapseBtn.addEventListener("click", function () {
916
+ var collapse = root.getAttribute("data-sidebar") !== "collapsed";
917
+ if (collapse) root.setAttribute("data-sidebar", "collapsed");
918
+ else root.removeAttribute("data-sidebar");
919
+ try { localStorage.setItem("locadot.sidebar", collapse ? "collapsed" : "expanded"); } catch (e) {}
920
+ syncCollapse();
921
+ });
922
+
923
+ function setDrawer(open) {
924
+ document.body.classList.toggle("sb-open", open);
925
+ backdrop.hidden = !open;
926
+ menuBtn.setAttribute("aria-expanded", String(open));
927
+ menuBtn.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
928
+ if (open && links[0]) links[0].focus();
929
+ }
930
+ menuBtn.addEventListener("click", function () { setDrawer(!document.body.classList.contains("sb-open")); });
931
+ backdrop.addEventListener("click", function () { setDrawer(false); });
932
+ document.addEventListener("keydown", function (e) {
933
+ if (e.key === "Escape" && document.body.classList.contains("sb-open")) { setDrawer(false); menuBtn.focus(); }
934
+ });
935
+ var onDrawerChange = function () { if (!drawer.matches) setDrawer(false); };
936
+ if (drawer.addEventListener) drawer.addEventListener("change", onDrawerChange);
937
+
938
+ function setActive(id) {
939
+ links.forEach(function (a) {
940
+ if (a.getAttribute("href") === "#" + id) a.setAttribute("aria-current", "true");
941
+ else a.removeAttribute("aria-current");
942
+ });
943
+ }
944
+ links.forEach(function (a) {
945
+ a.addEventListener("click", function (e) {
946
+ var target = document.getElementById(a.getAttribute("href").slice(1));
947
+ if (!target) return;
948
+ e.preventDefault();
949
+ if (target.tagName === "DETAILS") target.open = true;
950
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
951
+ setActive(target.id);
952
+ if (drawer.matches) setDrawer(false);
953
+ });
954
+ });
955
+
956
+ // The section nearest the top of the viewport wins; a click sets it immediately.
957
+ var visible = {};
958
+ if ("IntersectionObserver" in window) {
959
+ var io = new IntersectionObserver(function (entries) {
960
+ entries.forEach(function (en) { visible[en.target.id] = en.isIntersecting ? en.boundingClientRect.top : undefined; });
961
+ var best = null;
962
+ links.forEach(function (a) {
963
+ var id = a.getAttribute("href").slice(1);
964
+ var el = document.getElementById(id);
965
+ if (visible[id] === undefined || !el) return;
966
+ var top = el.getBoundingClientRect().top;
967
+ if (best === null || Math.abs(top - 76) < Math.abs(best.top - 76)) best = { id: id, top: top };
968
+ });
969
+ if (best) setActive(best.id);
970
+ }, { rootMargin: "-64px 0px -40% 0px" });
971
+ links.forEach(function (a) {
972
+ var el = document.getElementById(a.getAttribute("href").slice(1));
973
+ if (el) io.observe(el);
974
+ });
975
+ }
976
+ })();
977
+
772
978
  var tokenMeta = document.querySelector('meta[name="locadot-token"]');
773
979
  var TOKEN = tokenMeta ? tokenMeta.getAttribute("content") : "";
774
980
 
@@ -815,6 +1021,8 @@ const clientJs = `
815
1021
  var tbody = document.getElementById("tbody");
816
1022
  var hostsSkeletonEl = document.getElementById("hosts-skeleton");
817
1023
  var hostsCountEl = document.getElementById("hosts-count");
1024
+ var sbHostsCountEl = document.getElementById("sb-hosts-count");
1025
+ var sbRemotesCountEl = document.getElementById("sb-remotes-count");
818
1026
  var hostsFilterInput = document.getElementById("hosts-filter");
819
1027
  // As many skeleton rows as hosts last time, so the table doesn't jump when data lands.
820
1028
  try {
@@ -844,8 +1052,46 @@ const clientJs = `
844
1052
 
845
1053
  var toastContainer = document.getElementById("toast-container");
846
1054
 
1055
+ var hubDot = document.getElementById("hub-dot");
1056
+ var hubStatusText = document.getElementById("hub-status-text");
1057
+ var hubUrlRow = document.getElementById("hub-url-row");
1058
+ var hubUrlEl = document.getElementById("hub-url");
1059
+ var hubUrlCopyBtn = document.getElementById("hub-url-copy");
1060
+ var hubLoginRow = document.getElementById("hub-login-row");
1061
+ var hubLoginLink = document.getElementById("hub-login-link");
1062
+ var hubQuickWarning = document.getElementById("hub-quick-warning");
1063
+ var hubErrorRow = document.getElementById("hub-error-row");
1064
+ var hubNamedForm = document.getElementById("hub-named-form");
1065
+ var hubDomainInput = document.getElementById("hub-domain");
1066
+ var hubTunnelNameInput = document.getElementById("hub-tunnel-name");
1067
+ var hubNamedSubmitBtn = document.getElementById("hub-named-submit");
1068
+ var hubQuickBtn = document.getElementById("hub-quick-btn");
1069
+ var hubStopBtn = document.getElementById("hub-stop-btn");
1070
+ var hubShareSection = document.getElementById("hub-share-section");
1071
+ var inviteRoleSelect = document.getElementById("invite-role");
1072
+ var inviteCreateBtn = document.getElementById("invite-create-btn");
1073
+ var inviteHostsWrap = document.getElementById("invite-hosts-wrap");
1074
+ var inviteResultEl = document.getElementById("invite-result");
1075
+ var inviteStringEl = document.getElementById("invite-string");
1076
+ var inviteCopyBtn = document.getElementById("invite-copy-btn");
1077
+ var inviteCountdownEl = document.getElementById("invite-countdown");
1078
+ var peersEmptyEl = document.getElementById("peers-empty");
1079
+ var peersListEl = document.getElementById("peers-list");
1080
+ var invitesEmptyEl = document.getElementById("invites-empty");
1081
+ var invitesListEl = document.getElementById("invites-list");
1082
+
1083
+ var connectForm = document.getElementById("connect-form");
1084
+ var connectStringInput = document.getElementById("connect-string");
1085
+ var connectNameInput = document.getElementById("connect-name");
1086
+ var connectSubmitBtn = document.getElementById("connect-submit");
1087
+ var connectErrorEl = document.getElementById("connect-error");
1088
+ var remotesEmptyEl = document.getElementById("remotes-empty");
1089
+ var remotesListEl = document.getElementById("remotes-list");
1090
+
847
1091
  var lastStatus = null;
848
1092
  var lastHosts = [];
1093
+ var lastHub = null;
1094
+ var lastRemotes = [];
849
1095
  var rowElements = {};
850
1096
  var editingHost = null;
851
1097
  var caBusy = false;
@@ -854,6 +1100,9 @@ const clientJs = `
854
1100
  var cloudflaredInstalled = false;
855
1101
  var proxyStopped = false;
856
1102
  var pollTimer = null;
1103
+ var remotesPollTimer = null;
1104
+ var inviteTimer = null;
1105
+ var inviteExpiresAt = null;
857
1106
 
858
1107
  function clear(el) {
859
1108
  while (el.firstChild) el.removeChild(el.firstChild);
@@ -1088,6 +1337,478 @@ const clientJs = `
1088
1337
  cliCurlPre.textContent = curl;
1089
1338
  }
1090
1339
 
1340
+ // ---------- remote access: hub (sender) ----------
1341
+
1342
+ function fmtIn(iso) {
1343
+ if (!iso) return "";
1344
+ var ms = new Date(iso).getTime() - Date.now();
1345
+ if (ms <= 0) return "expired";
1346
+ var s = Math.round(ms / 1000);
1347
+ if (s < 60) return "in " + s + "s";
1348
+ var m = Math.round(s / 60);
1349
+ return "in " + m + "m";
1350
+ }
1351
+
1352
+ function renderPeers(peers) {
1353
+ peersEmptyEl.hidden = peers.length !== 0;
1354
+ clear(peersListEl);
1355
+ peers.forEach(function (peer) {
1356
+ var row = document.createElement("div");
1357
+ row.className = "peer-row";
1358
+
1359
+ var main = document.createElement("div");
1360
+ main.className = "peer-main";
1361
+ var name = document.createElement("div");
1362
+ name.className = "peer-name";
1363
+ name.textContent = peer.name;
1364
+ main.appendChild(name);
1365
+ var sub = document.createElement("div");
1366
+ sub.className = "muted";
1367
+ sub.textContent = "last seen " + fmtRelative(peer.lastSeen);
1368
+ main.appendChild(sub);
1369
+ row.appendChild(main);
1370
+
1371
+ var controls = document.createElement("div");
1372
+ controls.className = "peer-controls";
1373
+ var roleSelect = document.createElement("select");
1374
+ roleSelect.className = "input input-sm";
1375
+ roleSelect.setAttribute("aria-label", "Role for " + peer.name);
1376
+ ["viewer", "editor", "admin"].forEach(function (r) {
1377
+ var opt = document.createElement("option");
1378
+ opt.value = r;
1379
+ opt.textContent = r.charAt(0).toUpperCase() + r.slice(1);
1380
+ if (r === peer.role) opt.selected = true;
1381
+ roleSelect.appendChild(opt);
1382
+ });
1383
+ roleSelect.addEventListener("change", function () {
1384
+ var prev = peer.role;
1385
+ roleSelect.disabled = true;
1386
+ apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "PUT", body: JSON.stringify({ role: roleSelect.value, hosts: peer.hosts }) })
1387
+ .then(function () { showToast("Updated role for " + peer.name, "success"); return loadHub(); })
1388
+ .catch(function (err) { apiError(err, "Couldn't update role"); roleSelect.value = prev; roleSelect.disabled = false; });
1389
+ });
1390
+ controls.appendChild(roleSelect);
1391
+
1392
+ var revokeBtn = document.createElement("button");
1393
+ revokeBtn.type = "button";
1394
+ revokeBtn.className = "btn btn-sm btn-danger";
1395
+ revokeBtn.textContent = "Revoke";
1396
+ revokeBtn.addEventListener("click", function () {
1397
+ if (!window.confirm("Revoke " + peer.name + "?")) return;
1398
+ revokeBtn.disabled = true;
1399
+ apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
1400
+ .then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
1401
+ .catch(function (err) { apiError(err, "Couldn't revoke peer"); revokeBtn.disabled = false; });
1402
+ });
1403
+ controls.appendChild(revokeBtn);
1404
+ row.appendChild(controls);
1405
+ peersListEl.appendChild(row);
1406
+ });
1407
+ }
1408
+
1409
+ function renderInvites(invites) {
1410
+ invitesEmptyEl.hidden = invites.length !== 0;
1411
+ clear(invitesListEl);
1412
+ invites.forEach(function (invite) {
1413
+ var row = document.createElement("div");
1414
+ row.className = "invite-row";
1415
+ var main = document.createElement("div");
1416
+ main.className = "peer-main";
1417
+ var roleLine = document.createElement("div");
1418
+ roleLine.className = "peer-name";
1419
+ roleLine.textContent = invite.role.charAt(0).toUpperCase() + invite.role.slice(1);
1420
+ main.appendChild(roleLine);
1421
+ var sub = document.createElement("div");
1422
+ sub.className = "muted";
1423
+ sub.textContent = "expires " + fmtIn(invite.expiresAt);
1424
+ main.appendChild(sub);
1425
+ row.appendChild(main);
1426
+
1427
+ var revokeBtn = document.createElement("button");
1428
+ revokeBtn.type = "button";
1429
+ revokeBtn.className = "btn btn-sm btn-ghost-danger";
1430
+ revokeBtn.textContent = "\\u00d7";
1431
+ revokeBtn.setAttribute("aria-label", "Revoke invite");
1432
+ revokeBtn.addEventListener("click", function () {
1433
+ revokeBtn.disabled = true;
1434
+ apiFetch("/api/invites/" + encodeURIComponent(invite.id), { method: "DELETE" })
1435
+ .then(function () { return loadHub(); })
1436
+ .catch(function (err) { apiError(err, "Couldn't revoke invite"); revokeBtn.disabled = false; });
1437
+ });
1438
+ row.appendChild(revokeBtn);
1439
+ invitesListEl.appendChild(row);
1440
+ });
1441
+ }
1442
+
1443
+ // Re-rendered on every poll, so remember what the user unticked.
1444
+ var inviteUnchecked = {};
1445
+ function renderInviteHostChecks() {
1446
+ Array.prototype.forEach.call(inviteHostsWrap.querySelectorAll("input"), function (cb) {
1447
+ if (cb.checked) delete inviteUnchecked[cb.value];
1448
+ else inviteUnchecked[cb.value] = true;
1449
+ });
1450
+ clear(inviteHostsWrap);
1451
+ lastHosts.forEach(function (row) {
1452
+ if (row.remote) return;
1453
+ var label = document.createElement("label");
1454
+ var cb = document.createElement("input");
1455
+ cb.type = "checkbox";
1456
+ cb.value = row.host;
1457
+ cb.checked = !inviteUnchecked[row.host];
1458
+ label.appendChild(cb);
1459
+ label.appendChild(document.createTextNode(row.host));
1460
+ inviteHostsWrap.appendChild(label);
1461
+ });
1462
+ }
1463
+
1464
+ function updateInviteHostsVisibility() {
1465
+ var isViewer = inviteRoleSelect.value === "viewer";
1466
+ inviteHostsWrap.hidden = !isViewer;
1467
+ if (isViewer) renderInviteHostChecks();
1468
+ }
1469
+ inviteRoleSelect.addEventListener("change", updateInviteHostsVisibility);
1470
+
1471
+ function updateInviteCountdown() {
1472
+ if (!inviteExpiresAt) return;
1473
+ var remaining = Math.max(0, Math.round((inviteExpiresAt - Date.now()) / 1000));
1474
+ if (remaining <= 0) {
1475
+ inviteCountdownEl.textContent = "expired";
1476
+ if (inviteTimer) { clearInterval(inviteTimer); inviteTimer = null; }
1477
+ return;
1478
+ }
1479
+ var m = Math.floor(remaining / 60);
1480
+ var s = remaining % 60;
1481
+ inviteCountdownEl.textContent = m + ":" + (s < 10 ? "0" : "") + s;
1482
+ }
1483
+
1484
+ function showInviteResult(data) {
1485
+ inviteStringEl.textContent = data.string;
1486
+ inviteResultEl.hidden = false;
1487
+ inviteExpiresAt = new Date(data.expiresAt).getTime();
1488
+ if (inviteTimer) clearInterval(inviteTimer);
1489
+ updateInviteCountdown();
1490
+ inviteTimer = setInterval(updateInviteCountdown, 1000);
1491
+ }
1492
+
1493
+ inviteCreateBtn.addEventListener("click", function () {
1494
+ var role = inviteRoleSelect.value;
1495
+ var hosts;
1496
+ if (role === "viewer") {
1497
+ hosts = Array.prototype.slice.call(inviteHostsWrap.querySelectorAll("input:checked")).map(function (cb) { return cb.value; });
1498
+ }
1499
+ inviteCreateBtn.disabled = true;
1500
+ inviteCreateBtn.classList.add("busy");
1501
+ apiFetch("/api/invites", { method: "POST", body: JSON.stringify({ role: role, hosts: hosts }) })
1502
+ .then(function (data) {
1503
+ showInviteResult(data);
1504
+ return loadHub();
1505
+ })
1506
+ .catch(function (err) { apiError(err, "Couldn't create pairing link"); })
1507
+ .then(function () { inviteCreateBtn.disabled = false; inviteCreateBtn.classList.remove("busy"); });
1508
+ });
1509
+
1510
+ inviteCopyBtn.addEventListener("click", function () { copyText(inviteStringEl.textContent, inviteCopyBtn); });
1511
+
1512
+ function renderHub(data) {
1513
+ lastHub = data.hub;
1514
+ var hub = data.hub;
1515
+ var status = hub.status;
1516
+
1517
+ var dotClass = status === "up" ? "up" : status === "starting" ? "warn" : status === "login" ? "accent" : status === "error" ? "down" : "";
1518
+ hubDot.className = "dot" + (dotClass ? " " + dotClass : "");
1519
+ var labels = { off: "Off", starting: "Starting\\u2026", login: "Login required", up: "Up", error: "Error" };
1520
+ hubStatusText.textContent = labels[status] || status;
1521
+
1522
+ hubUrlRow.hidden = !(status === "up" && hub.url);
1523
+ if (status === "up" && hub.url) hubUrlEl.textContent = hub.url;
1524
+
1525
+ hubLoginRow.hidden = !(status === "login" && hub.loginUrl);
1526
+ if (status === "login" && hub.loginUrl) hubLoginLink.href = hub.loginUrl;
1527
+
1528
+ hubQuickWarning.hidden = !(status === "up" && hub.mode === "quick");
1529
+
1530
+ hubErrorRow.hidden = !(status === "error" && hub.error);
1531
+ if (status === "error" && hub.error) hubErrorRow.textContent = hub.error;
1532
+
1533
+ var running = status === "starting" || status === "login" || status === "up";
1534
+ hubNamedForm.hidden = status === "up";
1535
+ hubQuickBtn.hidden = status === "up";
1536
+ hubStopBtn.hidden = !running;
1537
+
1538
+ if (data.config && data.config.mode === "named" && data.config.domain && !hubDomainInput.value) {
1539
+ hubDomainInput.value = data.config.domain;
1540
+ }
1541
+
1542
+ hubShareSection.hidden = status !== "up";
1543
+ if (status === "up") updateInviteHostsVisibility();
1544
+
1545
+ renderPeers(data.peers);
1546
+ renderInvites(data.invites);
1547
+ }
1548
+
1549
+ function loadHub() {
1550
+ return fetch("/api/hub").then(parseJsonOrThrow).then(renderHub).catch(function (err) { apiError(err, "Couldn't load remote access"); });
1551
+ }
1552
+
1553
+ hubUrlCopyBtn.addEventListener("click", function () { copyText(hubUrlEl.textContent, hubUrlCopyBtn); });
1554
+
1555
+ hubNamedForm.addEventListener("submit", function (e) {
1556
+ e.preventDefault();
1557
+ var domain = hubDomainInput.value.trim();
1558
+ if (!domain) { hubDomainInput.focus(); return; }
1559
+ var tunnel = hubTunnelNameInput.value.trim();
1560
+ hubNamedSubmitBtn.disabled = true;
1561
+ hubNamedSubmitBtn.classList.add("busy");
1562
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "named", domain: domain, tunnel: tunnel || undefined }) })
1563
+ .then(function () { showToast("Setting up your domain\\u2026", "success"); return loadHub(); })
1564
+ .catch(function (err) { apiError(err, "Couldn't start setup"); })
1565
+ .then(function () { hubNamedSubmitBtn.disabled = false; hubNamedSubmitBtn.classList.remove("busy"); });
1566
+ });
1567
+
1568
+ hubQuickBtn.addEventListener("click", function () {
1569
+ hubQuickBtn.disabled = true;
1570
+ hubQuickBtn.classList.add("busy");
1571
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "quick" }) })
1572
+ .then(function () { showToast("Starting quick tunnel\\u2026", "success"); return loadHub(); })
1573
+ .catch(function (err) { apiError(err, "Couldn't start quick tunnel"); })
1574
+ .then(function () { hubQuickBtn.disabled = false; hubQuickBtn.classList.remove("busy"); });
1575
+ });
1576
+
1577
+ hubStopBtn.addEventListener("click", function () {
1578
+ if (!window.confirm("Stop remote access? Connected peers will be disconnected.")) return;
1579
+ hubStopBtn.disabled = true;
1580
+ hubStopBtn.classList.add("busy");
1581
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
1582
+ .then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
1583
+ .catch(function (err) { apiError(err, "Couldn't stop remote access"); })
1584
+ .then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
1585
+ });
1586
+
1587
+ // ---------- remote access: connected machines (receiver) ----------
1588
+
1589
+ function showConnectError(message) {
1590
+ connectErrorEl.textContent = message;
1591
+ connectErrorEl.hidden = false;
1592
+ }
1593
+ function hideConnectError() {
1594
+ connectErrorEl.hidden = true;
1595
+ }
1596
+
1597
+ function renderRemotes(remotes) {
1598
+ lastRemotes = remotes;
1599
+ remotesEmptyEl.hidden = remotes.length !== 0;
1600
+ sbRemotesCountEl.textContent = String(remotes.length);
1601
+ sbRemotesCountEl.hidden = remotes.length === 0;
1602
+ clear(remotesListEl);
1603
+ remotes.forEach(function (remote) {
1604
+ var item = document.createElement("div");
1605
+ item.className = "remote-item";
1606
+
1607
+ var head = document.createElement("div");
1608
+ head.className = "remote-head";
1609
+ var main = document.createElement("div");
1610
+ main.className = "remote-main";
1611
+ var nameLine = document.createElement("div");
1612
+ nameLine.className = "peer-name";
1613
+ nameLine.textContent = remote.name;
1614
+ main.appendChild(nameLine);
1615
+ var sub = document.createElement("div");
1616
+ sub.className = "muted";
1617
+ sub.textContent = (remote.sender && remote.sender.hostname) || "";
1618
+ main.appendChild(sub);
1619
+ head.appendChild(main);
1620
+
1621
+ var badges = document.createElement("div");
1622
+ badges.className = "badges";
1623
+ var roleBadge = document.createElement("span");
1624
+ roleBadge.className = "badge badge-role badge-accent";
1625
+ roleBadge.textContent = remote.role;
1626
+ badges.appendChild(roleBadge);
1627
+ var statusBadge = document.createElement("span");
1628
+ statusBadge.className = "badge " + (remote.status === "ok" ? "badge-up" : "badge-down");
1629
+ statusBadge.textContent = remote.status === "ok" ? "connected" : "error";
1630
+ if (remote.status !== "ok" && remote.error) statusBadge.title = remote.error;
1631
+ badges.appendChild(statusBadge);
1632
+ head.appendChild(badges);
1633
+ item.appendChild(head);
1634
+
1635
+ var urlRow = document.createElement("div");
1636
+ urlRow.className = "hub-row";
1637
+ var urlText = document.createElement("span");
1638
+ urlText.className = "hub-url";
1639
+ urlText.textContent = remote.url;
1640
+ urlRow.appendChild(urlText);
1641
+ var urlEditBtn = document.createElement("button");
1642
+ urlEditBtn.type = "button";
1643
+ urlEditBtn.className = "btn btn-sm btn-ghost";
1644
+ urlEditBtn.textContent = "Update URL";
1645
+ urlEditBtn.addEventListener("click", function () {
1646
+ var next = window.prompt("New URL for " + remote.name, remote.url);
1647
+ if (!next || !next.trim() || next.trim() === remote.url) return;
1648
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
1649
+ .then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
1650
+ .catch(function (err) { apiError(err, "Couldn't update URL"); });
1651
+ });
1652
+ urlRow.appendChild(urlEditBtn);
1653
+ item.appendChild(urlRow);
1654
+
1655
+ var actions = document.createElement("div");
1656
+ actions.className = "remote-controls";
1657
+ var syncBtn = document.createElement("button");
1658
+ syncBtn.type = "button";
1659
+ syncBtn.className = "btn btn-sm";
1660
+ syncBtn.textContent = "Sync";
1661
+ syncBtn.addEventListener("click", function () {
1662
+ syncBtn.disabled = true;
1663
+ syncBtn.classList.add("busy");
1664
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/sync", { method: "POST" })
1665
+ .then(function () { showToast("Synced " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1666
+ .catch(function (err) { apiError(err, "Couldn't sync " + remote.name); })
1667
+ .then(function () { syncBtn.disabled = false; syncBtn.classList.remove("busy"); });
1668
+ });
1669
+ actions.appendChild(syncBtn);
1670
+ var disconnectBtn = document.createElement("button");
1671
+ disconnectBtn.type = "button";
1672
+ disconnectBtn.className = "btn btn-sm btn-danger";
1673
+ disconnectBtn.textContent = "Disconnect";
1674
+ disconnectBtn.addEventListener("click", function () {
1675
+ if (!window.confirm("Disconnect from " + remote.name + "? This removes its hosts too.")) return;
1676
+ disconnectBtn.disabled = true;
1677
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
1678
+ .then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1679
+ .catch(function (err) { apiError(err, "Couldn't disconnect"); disconnectBtn.disabled = false; });
1680
+ });
1681
+ actions.appendChild(disconnectBtn);
1682
+ item.appendChild(actions);
1683
+
1684
+ var hostsWrap = document.createElement("div");
1685
+ hostsWrap.className = "remote-hosts-list";
1686
+ if (remote.available === null) {
1687
+ var errLine = document.createElement("div");
1688
+ errLine.className = "dim";
1689
+ errLine.textContent = "Couldn't load hosts: " + (remote.error || "unknown error");
1690
+ hostsWrap.appendChild(errLine);
1691
+ } else {
1692
+ (remote.available || []).forEach(function (h) {
1693
+ var hostRow = document.createElement("div");
1694
+ hostRow.className = "remote-host-row";
1695
+ var hostMain = document.createElement("div");
1696
+ hostMain.className = "mono";
1697
+ var aliases = (remote.mapped || []).filter(function (m) { return m.host === h.host; }).map(function (m) { return m.local; });
1698
+ hostMain.textContent = h.host + (aliases.length ? " \\u2192 " + aliases.join(", ") : "");
1699
+ hostRow.appendChild(hostMain);
1700
+
1701
+ if (!aliases.length) {
1702
+ var aliasForm = document.createElement("div");
1703
+ aliasForm.className = "mini-form";
1704
+ var aliasInput = document.createElement("input");
1705
+ aliasInput.type = "text";
1706
+ aliasInput.className = "input input-sm";
1707
+ aliasInput.placeholder = h.host;
1708
+ aliasInput.setAttribute("aria-label", "Local alias for " + h.host);
1709
+ var aliasBtn = document.createElement("button");
1710
+ aliasBtn.type = "button";
1711
+ aliasBtn.className = "btn btn-sm";
1712
+ aliasBtn.textContent = "Add alias";
1713
+ aliasBtn.addEventListener("click", function () {
1714
+ var local = aliasInput.value.trim();
1715
+ if (!local) { aliasInput.focus(); return; }
1716
+ aliasBtn.disabled = true;
1717
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/aliases", { method: "POST", body: JSON.stringify({ host: h.host, local: local }) })
1718
+ .then(function () { showToast("Added alias " + local, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1719
+ .catch(function (err) { apiError(err, "Couldn't add alias"); aliasBtn.disabled = false; });
1720
+ });
1721
+ aliasForm.appendChild(aliasInput);
1722
+ aliasForm.appendChild(aliasBtn);
1723
+ hostRow.appendChild(aliasForm);
1724
+ } else if (remote.role === "admin") {
1725
+ var delBtn = document.createElement("button");
1726
+ delBtn.type = "button";
1727
+ delBtn.className = "btn btn-sm btn-danger";
1728
+ delBtn.textContent = "Delete";
1729
+ delBtn.addEventListener("click", function () {
1730
+ if (!window.confirm("Delete " + h.host + " on " + remote.name + "?")) return;
1731
+ delBtn.disabled = true;
1732
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
1733
+ .then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1734
+ .catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
1735
+ });
1736
+ hostRow.appendChild(delBtn);
1737
+ }
1738
+ hostsWrap.appendChild(hostRow);
1739
+ });
1740
+ }
1741
+ item.appendChild(hostsWrap);
1742
+
1743
+ if (remote.role === "editor" || remote.role === "admin") {
1744
+ var addHostForm = document.createElement("div");
1745
+ addHostForm.className = "mini-form";
1746
+ var addHostIn = document.createElement("input");
1747
+ addHostIn.type = "text";
1748
+ addHostIn.className = "input input-sm";
1749
+ addHostIn.placeholder = "host";
1750
+ addHostIn.setAttribute("aria-label", "New host on " + remote.name);
1751
+ var addTargetIn = document.createElement("input");
1752
+ addTargetIn.type = "text";
1753
+ addTargetIn.className = "input input-sm";
1754
+ addTargetIn.placeholder = "target";
1755
+ addTargetIn.setAttribute("aria-label", "Target for new host on " + remote.name);
1756
+ var addHostBtn = document.createElement("button");
1757
+ addHostBtn.type = "button";
1758
+ addHostBtn.className = "btn btn-sm";
1759
+ addHostBtn.textContent = "Add host on " + remote.name;
1760
+ addHostBtn.addEventListener("click", function () {
1761
+ var h = addHostIn.value.trim();
1762
+ var t = addTargetIn.value.trim();
1763
+ if (!h || !t) return;
1764
+ addHostBtn.disabled = true;
1765
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts", { method: "POST", body: JSON.stringify({ host: h, target: t }) })
1766
+ .then(function () {
1767
+ showToast("Added " + h + " on " + remote.name, "success");
1768
+ addHostIn.value = "";
1769
+ addTargetIn.value = "";
1770
+ return Promise.all([loadRemotes(), loadHosts()]);
1771
+ })
1772
+ .catch(function (err) { apiError(err, "Couldn't add host"); })
1773
+ .then(function () { addHostBtn.disabled = false; });
1774
+ });
1775
+ addHostForm.appendChild(addHostIn);
1776
+ addHostForm.appendChild(addTargetIn);
1777
+ addHostForm.appendChild(addHostBtn);
1778
+ item.appendChild(addHostForm);
1779
+ }
1780
+
1781
+ remotesListEl.appendChild(item);
1782
+ });
1783
+ }
1784
+
1785
+ function loadRemotes() {
1786
+ return fetch("/api/remotes").then(parseJsonOrThrow).then(function (data) { renderRemotes(data.remotes || []); }).catch(function (err) { apiError(err, "Couldn't load remotes"); });
1787
+ }
1788
+
1789
+ connectForm.addEventListener("submit", function (e) {
1790
+ e.preventDefault();
1791
+ hideConnectError();
1792
+ var value = connectStringInput.value.trim();
1793
+ if (!value) { showConnectError("Paste a pairing link first."); return; }
1794
+ var name = connectNameInput.value.trim();
1795
+ connectSubmitBtn.disabled = true;
1796
+ connectSubmitBtn.classList.add("busy");
1797
+ apiFetch("/api/remotes", { method: "POST", body: JSON.stringify({ string: value, name: name || undefined }) })
1798
+ .then(function (data) {
1799
+ showToast("Connected to " + data.remote.name, "success");
1800
+ connectForm.reset();
1801
+ return Promise.all([loadRemotes(), loadHosts()]);
1802
+ })
1803
+ .catch(function (err) { showConnectError(err.message); })
1804
+ .then(function () { connectSubmitBtn.disabled = false; connectSubmitBtn.classList.remove("busy"); });
1805
+ });
1806
+
1807
+ function refreshHubAndRemotes() {
1808
+ if (proxyStopped) return Promise.resolve();
1809
+ return Promise.all([loadHub(), loadRemotes()]);
1810
+ }
1811
+
1091
1812
  caToggle.addEventListener("click", function () {
1092
1813
  if (caBusy || !lastStatus) return;
1093
1814
  var next = !(lastStatus.system.caTrusted === true);
@@ -1143,6 +1864,7 @@ const clientJs = `
1143
1864
  stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
1144
1865
  stoppedBanner.hidden = false;
1145
1866
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
1867
+ if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
1146
1868
  showToast("Proxy stopped", "warn");
1147
1869
  })
1148
1870
  .catch(function (err) {
@@ -1373,11 +2095,19 @@ const clientJs = `
1373
2095
  a.textContent = row.host;
1374
2096
  hostWrap.appendChild(a);
1375
2097
  hostWrap.appendChild(makeCopyButton(function () { return href || row.host; }));
2098
+ if (row.remote) {
2099
+ var viaBadge = document.createElement("span");
2100
+ viaBadge.className = "badge badge-via";
2101
+ viaBadge.textContent = "via " + row.remote.name;
2102
+ viaBadge.title = "Imported from remote \\"" + row.remote.name + "\\"";
2103
+ hostWrap.appendChild(viaBadge);
2104
+ }
1376
2105
  hostTd.appendChild(hostWrap);
1377
2106
  var targetLine = document.createElement("div");
1378
2107
  targetLine.className = "host-target mono";
1379
- targetLine.textContent = row.target;
1380
- targetLine.title = row.target;
2108
+ var targetText = row.remote ? (row.remote.name + ": " + row.remote.host) : row.target;
2109
+ targetLine.textContent = targetText;
2110
+ targetLine.title = targetText;
1381
2111
  hostTd.appendChild(targetLine);
1382
2112
  tr.appendChild(hostTd);
1383
2113
 
@@ -1444,8 +2174,10 @@ const clientJs = `
1444
2174
  removeBtn.disabled = false;
1445
2175
  });
1446
2176
  });
1447
- actionsWrap.appendChild(shareButton(row));
1448
- actionsWrap.appendChild(editBtn);
2177
+ if (!row.remote) {
2178
+ actionsWrap.appendChild(shareButton(row));
2179
+ actionsWrap.appendChild(editBtn);
2180
+ }
1449
2181
  actionsWrap.appendChild(removeBtn);
1450
2182
  tr.appendChild(actionsTd);
1451
2183
  labelCells(tr, ["", "", "Options", "Status", "Public URL", ""]);
@@ -1559,6 +2291,7 @@ const clientJs = `
1559
2291
  try { localStorage.setItem("locadot.hostCount", String(hosts.length || 1)); } catch (e) {}
1560
2292
  hideHostsSkeleton();
1561
2293
  lastHosts = hosts;
2294
+ if (inviteRoleSelect.value === "viewer" && !inviteHostsWrap.hidden) renderInviteHostChecks();
1562
2295
  emptyEl.hidden = hosts.length !== 0;
1563
2296
  tableWrap.hidden = hosts.length === 0;
1564
2297
 
@@ -1584,6 +2317,8 @@ const clientJs = `
1584
2317
  });
1585
2318
  hostsCountEl.textContent = String(hosts.length);
1586
2319
  hostsCountEl.hidden = false;
2320
+ sbHostsCountEl.textContent = String(hosts.length);
2321
+ sbHostsCountEl.hidden = false;
1587
2322
  applyHostsFilter();
1588
2323
  }
1589
2324
 
@@ -1653,14 +2388,22 @@ const clientJs = `
1653
2388
  }
1654
2389
 
1655
2390
  document.addEventListener("visibilitychange", function () {
1656
- if (!document.hidden && !proxyStopped) refreshAll();
2391
+ if (!document.hidden && !proxyStopped) {
2392
+ refreshAll();
2393
+ refreshHubAndRemotes();
2394
+ }
1657
2395
  });
1658
2396
 
1659
2397
  pollTimer = setInterval(function () {
1660
2398
  if (!document.hidden && !proxyStopped) refreshAll();
1661
2399
  }, 5000);
1662
2400
 
2401
+ remotesPollTimer = setInterval(function () {
2402
+ if (!document.hidden && !proxyStopped) refreshHubAndRemotes();
2403
+ }, 15000);
2404
+
1663
2405
  refreshAll();
2406
+ refreshHubAndRemotes();
1664
2407
  })();
1665
2408
  `;
1666
2409
  function renderPage(nonce, token) {
@@ -1676,16 +2419,38 @@ function renderPage(nonce, token) {
1676
2419
  <meta name="color-scheme" content="dark light">
1677
2420
  <meta name="locadot-token" content="${safeToken}">
1678
2421
  <title>locadot dashboard</title>
1679
- <script nonce="${safeNonce}">try { var t = localStorage.getItem("locadot.theme"); if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t); } catch (e) {}</script>
2422
+ <script nonce="${safeNonce}">try { var t = localStorage.getItem("locadot.theme"); if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t); var s = localStorage.getItem("locadot.sidebar"); if (s === "collapsed" || (s !== "expanded" && window.innerWidth < 1280)) document.documentElement.setAttribute("data-sidebar", "collapsed"); } catch (e) {}</script>
1680
2423
  <style nonce="${safeNonce}">${css}</style>
1681
2424
  </head>
1682
2425
  <body class="boot">
2426
+ <nav id="sidebar" class="sidebar" aria-label="Sections">
2427
+ <div class="sb-head">
2428
+ <span class="logo" aria-hidden="true"></span>
2429
+ <h1 class="wordmark sb-label">locadot</h1>
2430
+ <span id="version" class="muted mono sb-label"></span>
2431
+ </div>
2432
+ <div class="sb-links">
2433
+ <a class="sb-link" href="#hosts-card" title="Hosts"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/><path d="M7 7h.01M7 17h.01"/></svg><span class="sb-label">Hosts</span><span id="sb-hosts-count" class="sb-count sb-label" hidden></span></a>
2434
+ <a class="sb-link" href="#add-card" title="Add host"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/></svg><span class="sb-label">Add host</span></a>
2435
+ <a class="sb-link" href="#system-card" title="System"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg><span class="sb-label">System</span></a>
2436
+ <a class="sb-link" href="#sharing-card" title="Public sharing"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg><span class="sb-label">Public sharing</span></a>
2437
+ <div class="sb-section sb-label">Remote</div>
2438
+ <a class="sb-link" href="#hub-card" title="Remote access"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01"/></svg><span class="sb-label">Remote access</span></a>
2439
+ <a class="sb-link" href="#remotes-card" title="Connected machines"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="4" width="9" height="7" rx="1.5"/><rect x="13" y="13" width="9" height="7" rx="1.5"/><path d="M6.5 11v4.5h6.5M17.5 13V8.5H11"/></svg><span class="sb-label">Connected machines</span><span id="sb-remotes-count" class="sb-count sb-label" hidden></span></a>
2440
+ <div class="sb-section sb-label">Tools</div>
2441
+ <a class="sb-link" href="#logs-panel" title="Logs"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span class="sb-label">Logs</span></a>
2442
+ <a class="sb-link" href="#cli-card" title="CLI &amp; API"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 17l6-5-6-5M12 19h8"/></svg><span class="sb-label">CLI &amp; API</span></a>
2443
+ </div>
2444
+ <button type="button" id="sb-collapse" class="sb-link sb-collapse" aria-controls="sidebar" aria-expanded="true" title="Collapse sidebar"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16M15 10l-2 2 2 2"/></svg><span class="sb-label">Collapse</span></button>
2445
+ </nav>
2446
+ <div id="sb-backdrop" class="sb-backdrop" hidden></div>
2447
+ <div class="shell">
1683
2448
  <header>
1684
2449
  <div class="topbar">
1685
2450
  <div class="brand">
1686
- <span class="logo" aria-hidden="true"></span>
1687
- <h1 class="wordmark">locadot</h1>
1688
- <span id="version" class="muted mono"></span>
2451
+ <button type="button" id="sb-menu" class="btn sb-menu" aria-controls="sidebar" aria-expanded="false" aria-label="Open navigation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg></button>
2452
+ <span class="logo brand-mobile" aria-hidden="true"></span>
2453
+ <span class="wordmark brand-mobile">locadot</span>
1689
2454
  </div>
1690
2455
  <div class="header-meta">
1691
2456
  <span class="chip chip-live"><span id="live-dot" class="live-dot"></span> <span id="live-label" class="live-label">live</span></span>
@@ -1761,6 +2526,101 @@ function renderPage(nonce, token) {
1761
2526
  </div>
1762
2527
  </section>
1763
2528
 
2529
+ <section id="hub-card" class="card" aria-label="Remote access">
2530
+ <div class="card-head">
2531
+ <div class="card-title-wrap"><h3 class="card-title">Remote access</h3></div>
2532
+ </div>
2533
+ <div class="hub-section">
2534
+ <div class="hub-row">
2535
+ <span class="pill"><span id="hub-dot" class="dot"></span><span id="hub-status-text">Off</span></span>
2536
+ </div>
2537
+ <div id="hub-url-row" class="hub-row" hidden>
2538
+ <span id="hub-url" class="hub-url"></span>
2539
+ <button type="button" id="hub-url-copy" class="copy-btn">Copy</button>
2540
+ </div>
2541
+ <div id="hub-login-row" class="hub-row" hidden>
2542
+ <a id="hub-login-link" href="#" target="_blank" rel="noopener noreferrer">Log in to Cloudflare</a>
2543
+ </div>
2544
+ <div id="hub-quick-warning" class="hub-warn" hidden>URL changes when locadot restarts &mdash; receivers must update it.</div>
2545
+ <div id="hub-error-row" class="hub-warn" hidden></div>
2546
+
2547
+ <form id="hub-named-form" class="hub-form-row" novalidate>
2548
+ <div class="field">
2549
+ <label for="hub-domain">Domain</label>
2550
+ <input type="text" id="hub-domain" placeholder="hub.example.com" autocomplete="off">
2551
+ </div>
2552
+ <div class="field">
2553
+ <label for="hub-tunnel-name">Tunnel name</label>
2554
+ <input type="text" id="hub-tunnel-name" placeholder="locadot (optional)" autocomplete="off">
2555
+ </div>
2556
+ <button type="submit" id="hub-named-submit" class="btn btn-sm btn-primary">Use my domain</button>
2557
+ </form>
2558
+ <div class="hub-actions">
2559
+ <button type="button" id="hub-quick-btn" class="btn btn-sm">Use quick tunnel</button>
2560
+ <button type="button" id="hub-stop-btn" class="btn btn-sm btn-danger" hidden>Stop</button>
2561
+ </div>
2562
+ </div>
2563
+
2564
+ <div id="hub-share-section" class="hub-section" hidden>
2565
+ <div class="label">Create pairing link</div>
2566
+ <div class="hub-form-row">
2567
+ <div class="field">
2568
+ <label for="invite-role">Role</label>
2569
+ <select id="invite-role" class="input input-sm">
2570
+ <option value="viewer">Viewer</option>
2571
+ <option value="editor">Editor</option>
2572
+ <option value="admin">Admin</option>
2573
+ </select>
2574
+ </div>
2575
+ <button type="button" id="invite-create-btn" class="btn btn-sm btn-primary">Create pairing link</button>
2576
+ </div>
2577
+ <div id="invite-hosts-wrap" class="host-checks" hidden></div>
2578
+ <div id="invite-result" class="invite-result" hidden>
2579
+ <div class="invite-result-row">
2580
+ <span id="invite-string" class="invite-string mono"></span>
2581
+ <button type="button" id="invite-copy-btn" class="copy-btn">Copy</button>
2582
+ </div>
2583
+ <div id="invite-countdown" class="invite-countdown"></div>
2584
+ </div>
2585
+ </div>
2586
+
2587
+ <div class="hub-section">
2588
+ <div class="label">Peers</div>
2589
+ <div id="peers-empty" class="dim" hidden>No peers yet.</div>
2590
+ <div id="peers-list" class="peer-list"></div>
2591
+ </div>
2592
+ <div class="hub-section">
2593
+ <div class="label">Pending invites</div>
2594
+ <div id="invites-empty" class="dim" hidden>No pending invites.</div>
2595
+ <div id="invites-list" class="invite-list"></div>
2596
+ </div>
2597
+ </section>
2598
+
2599
+ <section id="remotes-card" class="card" aria-label="Connected machines">
2600
+ <div class="card-head">
2601
+ <div class="card-title-wrap"><h3 class="card-title">Connected machines</h3></div>
2602
+ </div>
2603
+ <div class="hub-section">
2604
+ <form id="connect-form" class="hub-form-row" novalidate>
2605
+ <div class="field">
2606
+ <label for="connect-string">Pairing link</label>
2607
+ <input type="text" id="connect-string" placeholder="https://hub.example.com/#lnk_..." autocomplete="off">
2608
+ </div>
2609
+ <div class="field">
2610
+ <label for="connect-name">Name (optional)</label>
2611
+ <input type="text" id="connect-name" placeholder="my-mac" autocomplete="off">
2612
+ </div>
2613
+ <button type="submit" id="connect-submit" class="btn btn-sm btn-primary">Connect</button>
2614
+ </form>
2615
+ <div id="connect-error" class="field-error" role="alert" hidden></div>
2616
+ </div>
2617
+ <div id="remotes-empty" class="empty" hidden>
2618
+ <span class="empty-icon" aria-hidden="true">~</span>
2619
+ <p>Not connected to anything yet. Paste a pairing link above.</p>
2620
+ </div>
2621
+ <div id="remotes-list" class="remote-list"></div>
2622
+ </section>
2623
+
1764
2624
  <details id="logs-panel" class="card">
1765
2625
  <summary><span class="card-title">Logs</span><span class="card-desc">last 200 lines</span></summary>
1766
2626
  <div class="logs-body">
@@ -1900,6 +2760,7 @@ function renderPage(nonce, token) {
1900
2760
  <footer>
1901
2761
  <a href="https://github.com/avinashid/locadot" target="_blank" rel="noopener noreferrer">github.com/avinashid/locadot</a>
1902
2762
  </footer>
2763
+ </div>
1903
2764
  <div id="toast-container" class="toast-container" aria-live="polite"></div>
1904
2765
  <script nonce="${safeNonce}">${clientJs}</script>
1905
2766
  </body>