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.
- package/CHANGELOG.md +10 -0
- package/README.md +24 -0
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/remote.js +276 -0
- package/dist/constants/index.js +4 -0
- package/dist/dashboard/api.js +253 -1
- package/dist/dashboard/index.js +23 -2
- package/dist/dashboard/page.js +872 -11
- package/dist/index.js +86 -1
- package/dist/lib/hub-config.js +28 -0
- package/dist/lib/links.js +163 -0
- package/dist/lib/localhost.js +2 -2
- package/dist/lib/remotes.js +272 -0
- package/dist/proxy/hub-tunnel.js +241 -0
- package/dist/proxy/hub.js +280 -0
- package/dist/proxy/remote.js +51 -0
- package/dist/proxy/request.js +4 -2
- package/dist/proxy/router.js +62 -3
- package/dist/server/index.js +43 -3
- package/package.json +1 -1
package/dist/dashboard/page.js
CHANGED
|
@@ -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
|
-
#
|
|
692
|
-
#
|
|
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
|
-
|
|
1380
|
-
targetLine.
|
|
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
|
-
|
|
1448
|
-
|
|
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)
|
|
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 & API"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 17l6-5-6-5M12 19h8"/></svg><span class="sb-label">CLI & 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
|
-
<
|
|
1687
|
-
<
|
|
1688
|
-
<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 — 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>
|