locadot 2.0.0 → 2.1.0-beta.1

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,74 @@ 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] { 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
+ .sb-signout { margin-bottom: 0; }
227
+ .sb-signout[hidden] { display: none; }
228
+ :root[data-sidebar="collapsed"] .sb-label { display: none; }
229
+ :root[data-sidebar="collapsed"] .sb-head { padding: 0; justify-content: center; }
230
+ :root[data-sidebar="collapsed"] .sb-link { justify-content: center; padding: 0; }
231
+ :root[data-sidebar="collapsed"] .sb-collapse svg { transform: scaleX(-1); }
232
+ :root[data-sidebar="collapsed"] .sb-links { padding-top: 12px; }
233
+ .sb-menu, .brand-mobile { display: none; }
234
+ .btn.sb-menu { display: none; width: 32px; height: 32px; padding: 0; color: var(--muted); }
235
+ .sb-menu svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
236
+ .sb-backdrop { position: fixed; inset: 0; z-index: 6; background: rgba(0, 0, 0, 0.45); }
237
+ .card, details.card { scroll-margin-top: 76px; }
238
+ @media (max-width: 899px) {
239
+ :root, :root[data-sidebar="collapsed"] { --sb-w: 0px; }
240
+ .sidebar { width: 260px; transform: translateX(-100%); box-shadow: none; visibility: hidden; transition: transform 0.2s ease, visibility 0s linear 0.2s; }
241
+ body.sb-open .sidebar { transform: none; box-shadow: var(--shadow-md); visibility: visible; transition: transform 0.2s ease; }
242
+ :root[data-sidebar="collapsed"] .sb-label { display: revert; }
243
+ :root[data-sidebar="collapsed"] .sb-count { display: inline-flex; }
244
+ :root[data-sidebar="collapsed"] .sb-head { padding: 0 20px; justify-content: flex-start; }
245
+ :root[data-sidebar="collapsed"] .sb-link { justify-content: flex-start; padding: 0 12px; }
246
+ .sb-collapse { display: none; }
247
+ .btn.sb-menu { display: inline-flex; }
248
+ .brand-mobile { display: inline-block; }
249
+ span.wordmark.brand-mobile { display: inline; }
250
+ }
251
+
184
252
  /* ---------- page layout ---------- */
185
253
  main {
186
254
  max-width: 1360px;
@@ -387,6 +455,29 @@ body.offline .switch, body.offline #add-submit { opacity: 0.5; pointer-events: n
387
455
  .field input.invalid:focus-visible, .edit-input.invalid:focus-visible { box-shadow: 0 0 0 3px var(--down-bg); }
388
456
  .edit-input { height: 30px; min-width: 0; padding: 0 10px; }
389
457
  input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
458
+ select.input, .field select {
459
+ height: 36px;
460
+ width: 100%;
461
+ padding: 0 30px 0 12px;
462
+ font-family: var(--sans);
463
+ font-size: 13px;
464
+ color: var(--fg);
465
+ background-color: var(--surface);
466
+ border: 1px solid var(--border-strong);
467
+ border-radius: var(--radius-sm);
468
+ box-shadow: var(--shadow-sm);
469
+ appearance: none;
470
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
471
+ background-position: calc(100% - 16px) center, calc(100% - 11px) center;
472
+ background-size: 5px 5px, 5px 5px;
473
+ background-repeat: no-repeat;
474
+ }
475
+ select.input:focus-visible, .field select:focus-visible {
476
+ outline: none;
477
+ border-color: var(--accent);
478
+ box-shadow: 0 0 0 3px var(--accent-bg);
479
+ }
480
+ select.input-sm { height: 28px; font-size: 12px; padding: 0 24px 0 8px; background-position: calc(100% - 10px) center, calc(100% - 6px) center; }
390
481
 
391
482
  /* ---------- add host form ---------- */
392
483
  .form-grid { display: flex; flex-direction: column; gap: 14px; }
@@ -473,9 +564,11 @@ tbody tr:last-child td:last-child { border-bottom-right-radius: var(--radius); }
473
564
  tbody tr:hover .copy-btn, .copy-btn:focus-visible, #table tbody td:hover .copy-btn { opacity: 1; }
474
565
  tr.row-down td:first-child { box-shadow: inset 3px 0 0 var(--down); }
475
566
  .pill { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 12px; }
476
- .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
567
+ .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--muted-dim); }
477
568
  .dot.up { background: var(--up); box-shadow: 0 0 0 3px var(--up-bg); }
478
569
  .dot.down { background: var(--down); box-shadow: 0 0 0 3px var(--down-bg); }
570
+ .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-bg); }
571
+ .dot.accent { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
479
572
  .edit-hint { font-size: 12px; }
480
573
  td.edit-error { color: var(--down); white-space: normal; }
481
574
  td .inline-check + .inline-check { margin-top: 4px; }
@@ -548,6 +641,40 @@ pre.code-block {
548
641
  .cli-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
549
642
  .cli-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
550
643
 
644
+ /* ---------- remote access ---------- */
645
+ .hub-section { padding: 16px 20px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
646
+ .hub-section:first-child { border-top: none; }
647
+ .hub-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
648
+ .hub-url { font-family: var(--mono); font-size: 12.5px; word-break: break-all; color: var(--fg-2); }
649
+ .hub-warn { font-size: 12px; color: var(--warn); background: var(--warn-bg); border-radius: var(--radius-sm); padding: 6px 10px; }
650
+ .hub-actions, .invite-actions { display: flex; gap: 8px; flex-wrap: wrap; }
651
+ .hub-form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
652
+ .hub-form-row .field { flex: 1; min-width: 140px; }
653
+ .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; }
654
+ .invite-result-row { display: flex; align-items: center; gap: 8px; }
655
+ .invite-string { font-family: var(--mono); font-size: 11.5px; word-break: break-all; flex: 1; min-width: 0; }
656
+ .invite-countdown { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
657
+ .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; }
658
+ .host-checks[hidden] { display: none; }
659
+ .host-checks label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--fg-2); cursor: pointer; }
660
+ .peer-list, .invite-list, .remote-list { display: flex; flex-direction: column; }
661
+ .remote-list:not(:empty) { padding: 0 20px 12px; }
662
+ .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; }
663
+ .peer-row:last-child, .invite-row:last-child, .remote-item:last-child { border-bottom: none; }
664
+ .peer-main, .remote-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
665
+ .peer-name { font-weight: 500; font-size: 13px; }
666
+ .peer-controls, .remote-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
667
+ .remote-item { flex-direction: column; align-items: stretch; }
668
+ .remote-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
669
+ .remote-hosts-list { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
670
+ .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); }
671
+ .remote-host-row:first-child { border-top: none; }
672
+ .badge-role { text-transform: capitalize; }
673
+ .remote-card-body { display: flex; flex-direction: column; gap: 14px; }
674
+ .mini-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
675
+ .mini-form input { flex: 1; min-width: 90px; }
676
+ .host-cell .badge-via { margin-left: 4px; }
677
+
551
678
  /* ---------- banners ---------- */
552
679
  .banner {
553
680
  border-radius: var(--radius);
@@ -668,6 +795,66 @@ footer { text-align: center; padding: 8px 20px 32px; color: var(--muted-dim); fo
668
795
  footer a { color: var(--muted); }
669
796
  [hidden] { display: none !important; }
670
797
 
798
+
799
+ /* ---------- pages ---------- */
800
+ .view { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
801
+ .tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
802
+ @media (min-width: 1100px) { .tiles { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
803
+ .tile { display: flex; flex-direction: column; gap: 4px; padding: 16px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); color: var(--fg); min-width: 0; transition: border-color 0.15s ease; }
804
+ .tile:hover { border-color: var(--border-strong); text-decoration: none; }
805
+ .tile-big { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.2; }
806
+ .tile-big.tile-word { font-size: 18px; line-height: 1.8; }
807
+ .tile-big.ok { color: var(--up); }
808
+ .tile-big.bad { color: var(--down); }
809
+ .tile-big.warn { color: var(--warn); }
810
+ .tile .tile-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
811
+ .grid-2 { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; }
812
+ @media (min-width: 1000px) { .grid-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
813
+ .ov-list { display: flex; flex-direction: column; }
814
+ .ov-row { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--border); min-width: 0; }
815
+ .ov-row:last-child { border-bottom: none; }
816
+ .ov-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
817
+ .ov-title { font-family: var(--mono); font-size: 13px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
818
+ #ov-checks .ov-title { font-family: var(--sans); }
819
+ .ov-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
820
+ .ov-side { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-size: 12px; color: var(--muted); }
821
+ .ov-side a.ov-link { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
822
+ .ov-more { padding: 10px 20px; font-size: 12.5px; }
823
+ .logs-full { height: calc(100vh - 290px); min-height: 320px; max-height: none; margin: 0; border-radius: 0; border: 0; border-top: 1px solid var(--border); }
824
+ .logs-short { height: auto; max-height: 220px; margin: 0; border-radius: 0 0 var(--radius) var(--radius); border: 0; }
825
+ .logs-head { flex-wrap: wrap; gap: 10px; }
826
+ .logs-tools { flex-wrap: wrap; gap: 8px; flex: 1; min-width: 0; }
827
+ .logs-tools .input { width: auto; }
828
+ .logs-tools select.input { padding-right: 28px; }
829
+ .logs-tools .input-search { width: 220px; }
830
+ .logs-meta { padding: 8px 20px; font-size: 12px; border-top: 1px solid var(--border); }
831
+ .check-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--fg-2); cursor: pointer; }
832
+ select.input { padding-right: 8px; cursor: pointer; }
833
+ .segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
834
+ .segmented button { border: 0; background: var(--surface); color: var(--muted); font-size: 12.5px; padding: 6px 12px; cursor: pointer; }
835
+ .segmented button + button { border-left: 1px solid var(--border); }
836
+ .segmented button[aria-checked="true"] { background: var(--accent-bg); color: var(--accent-strong); font-weight: 600; }
837
+ .form-row-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 10px; }
838
+ .form-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
839
+ #ports-form .hub-warn { margin-top: 12px; }
840
+ .token-body { display: flex; flex-direction: column; gap: 12px; }
841
+ .token-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
842
+ .token-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; padding: 6px 10px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-xs); }
843
+ .help-list { display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: var(--fg-2); }
844
+ .help-list p { margin: 0; }
845
+ .toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
846
+ .remote-local { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
847
+ .remote-local a { font-family: var(--mono); font-size: 13px; }
848
+ .role-table { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-sm); }
849
+ .role-table div { display: flex; gap: 10px; padding: 8px 12px; font-size: 12.5px; }
850
+ .role-table div + div { border-top: 1px solid var(--border); }
851
+ .role-table strong { width: 56px; flex-shrink: 0; }
852
+ .role-table span { color: var(--muted); }
853
+ .danger-card { border-color: var(--down-bg); }
854
+ .danger-card .card-title { color: var(--down); }
855
+ .sb-dot { margin-left: auto; }
856
+ input.input[type="number"] { font-family: var(--mono); }
857
+
671
858
  /* ---------- responsive ---------- */
672
859
  @media (min-width: 1200px) {
673
860
  th, td { padding-left: 10px; padding-right: 10px; }
@@ -682,18 +869,8 @@ footer a { color: var(--muted); }
682
869
  @media (max-width: 1099px) {
683
870
  .topbar, main { padding-left: 24px; padding-right: 24px; }
684
871
  }
685
- @media (max-width: 1099px) {
686
- .col-primary, .col-secondary { display: contents; }
687
- #hosts-card { order: 1; }
688
- #add-card { order: 2; }
689
- #system-card { order: 3; }
690
- #sharing-card { order: 4; }
691
- #logs-panel { order: 5; }
692
- #cli-card { order: 6; }
693
- }
694
872
  @media (min-width: 700px) and (max-width: 1099px) {
695
- .layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
696
- #hosts-card, #logs-panel, #cli-card { grid-column: 1 / -1; }
873
+ .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
697
874
  }
698
875
  @media (min-width: 641px) and (max-width: 899px), (min-width: 1100px) and (max-width: 1279px) {
699
876
  #table tbody { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -751,7 +928,7 @@ footer a { color: var(--muted); }
751
928
  const clientJs = `
752
929
  (function () {
753
930
  "use strict";
754
- (function () {
931
+ var setTheme = (function () {
755
932
  var btn = document.getElementById("theme-toggle");
756
933
  var order = ["system", "light", "dark"];
757
934
  var mode = document.documentElement.getAttribute("data-theme") || "system";
@@ -767,10 +944,14 @@ const clientJs = `
767
944
  }
768
945
  apply(mode);
769
946
  btn.addEventListener("click", function () { apply(order[(order.indexOf(mode) + 1) % order.length]); });
947
+ return function (next) { apply(next); if (typeof syncThemeSeg === "function") syncThemeSeg(next); };
770
948
  })();
771
949
 
950
+
772
951
  var tokenMeta = document.querySelector('meta[name="locadot-token"]');
773
952
  var TOKEN = tokenMeta ? tokenMeta.getAttribute("content") : "";
953
+ var uiAuthMeta = document.querySelector('meta[name="locadot-ui-auth"]');
954
+ var UI_AUTH = uiAuthMeta ? uiAuthMeta.getAttribute("content") === "on" : false;
774
955
 
775
956
  var versionEl = document.getElementById("version");
776
957
  var uptimeEl = document.getElementById("uptime");
@@ -815,6 +996,8 @@ const clientJs = `
815
996
  var tbody = document.getElementById("tbody");
816
997
  var hostsSkeletonEl = document.getElementById("hosts-skeleton");
817
998
  var hostsCountEl = document.getElementById("hosts-count");
999
+ var sbHostsCountEl = document.getElementById("sb-hosts-count");
1000
+ var sbRemotesCountEl = document.getElementById("sb-remotes-count");
818
1001
  var hostsFilterInput = document.getElementById("hosts-filter");
819
1002
  // As many skeleton rows as hosts last time, so the table doesn't jump when data lands.
820
1003
  try {
@@ -844,8 +1027,49 @@ const clientJs = `
844
1027
 
845
1028
  var toastContainer = document.getElementById("toast-container");
846
1029
 
1030
+ var hubDot = document.getElementById("hub-dot");
1031
+ var hubStatusText = document.getElementById("hub-status-text");
1032
+ var hubUrlRow = document.getElementById("hub-url-row");
1033
+ var hubUrlEl = document.getElementById("hub-url");
1034
+ var hubUrlCopyBtn = document.getElementById("hub-url-copy");
1035
+ var hubLoginRow = document.getElementById("hub-login-row");
1036
+ var hubLoginLink = document.getElementById("hub-login-link");
1037
+ var hubQuickWarning = document.getElementById("hub-quick-warning");
1038
+ var hubErrorRow = document.getElementById("hub-error-row");
1039
+ var hubNamedForm = document.getElementById("hub-named-form");
1040
+ var hubDomainInput = document.getElementById("hub-domain");
1041
+ var hubTunnelNameInput = document.getElementById("hub-tunnel-name");
1042
+ var hubNamedSubmitBtn = document.getElementById("hub-named-submit");
1043
+ var hubQuickBtn = document.getElementById("hub-quick-btn");
1044
+ var hubStopBtn = document.getElementById("hub-stop-btn");
1045
+ var hubShareSection = document.getElementById("hub-share-section");
1046
+ var inviteRoleSelect = document.getElementById("invite-role");
1047
+ var inviteCreateBtn = document.getElementById("invite-create-btn");
1048
+ var inviteHostsWrap = document.getElementById("invite-hosts-wrap");
1049
+ var inviteResultEl = document.getElementById("invite-result");
1050
+ var inviteStringEl = document.getElementById("invite-string");
1051
+ var inviteCopyBtn = document.getElementById("invite-copy-btn");
1052
+ var inviteCountdownEl = document.getElementById("invite-countdown");
1053
+ var peersEmptyEl = document.getElementById("peers-empty");
1054
+ var peersListEl = document.getElementById("peers-list");
1055
+ var invitesEmptyEl = document.getElementById("invites-empty");
1056
+ var invitesListEl = document.getElementById("invites-list");
1057
+
1058
+ var connectForm = document.getElementById("connect-form");
1059
+ var connectStringInput = document.getElementById("connect-string");
1060
+ var connectNameInput = document.getElementById("connect-name");
1061
+ var connectDomainInput = document.getElementById("connect-domain");
1062
+ var hubLocalhostRow = document.getElementById("hub-localhost-row");
1063
+ var hubLocalhostSwitch = document.getElementById("hub-localhost");
1064
+ var connectSubmitBtn = document.getElementById("connect-submit");
1065
+ var connectErrorEl = document.getElementById("connect-error");
1066
+ var remotesEmptyEl = document.getElementById("remotes-empty");
1067
+ var remotesListEl = document.getElementById("remotes-list");
1068
+
847
1069
  var lastStatus = null;
848
1070
  var lastHosts = [];
1071
+ var lastHub = null;
1072
+ var lastRemotes = [];
849
1073
  var rowElements = {};
850
1074
  var editingHost = null;
851
1075
  var caBusy = false;
@@ -854,6 +1078,9 @@ const clientJs = `
854
1078
  var cloudflaredInstalled = false;
855
1079
  var proxyStopped = false;
856
1080
  var pollTimer = null;
1081
+ var remotesPollTimer = null;
1082
+ var inviteTimer = null;
1083
+ var inviteExpiresAt = null;
857
1084
 
858
1085
  function clear(el) {
859
1086
  while (el.firstChild) el.removeChild(el.firstChild);
@@ -948,6 +1175,8 @@ const clientJs = `
948
1175
  }
949
1176
 
950
1177
  function parseJsonOrThrow(r) {
1178
+ // The dashboard session ran out (or the password was set elsewhere): back to the sign-in page.
1179
+ if (r.status === 401) location.reload();
951
1180
  return r.json().catch(function () { return {}; }).then(function (body) {
952
1181
  if (!r.ok) {
953
1182
  var err = new Error((body && body.error) || ("Request failed (" + r.status + ")"));
@@ -1088,6 +1317,580 @@ const clientJs = `
1088
1317
  cliCurlPre.textContent = curl;
1089
1318
  }
1090
1319
 
1320
+ // ---------- remote access: hub (sender) ----------
1321
+
1322
+ function fmtIn(iso) {
1323
+ if (!iso) return "";
1324
+ var ms = new Date(iso).getTime() - Date.now();
1325
+ if (ms <= 0) return "expired";
1326
+ var s = Math.round(ms / 1000);
1327
+ if (s < 60) return "in " + s + "s";
1328
+ var m = Math.round(s / 60);
1329
+ return "in " + m + "m";
1330
+ }
1331
+
1332
+ function renderPeers(peers) {
1333
+ peersEmptyEl.hidden = peers.length !== 0;
1334
+ clear(peersListEl);
1335
+ peers.forEach(function (peer) {
1336
+ var row = document.createElement("div");
1337
+ row.className = "peer-row";
1338
+
1339
+ var main = document.createElement("div");
1340
+ main.className = "peer-main";
1341
+ var name = document.createElement("div");
1342
+ name.className = "peer-name";
1343
+ name.textContent = peer.name;
1344
+ main.appendChild(name);
1345
+ var sub = document.createElement("div");
1346
+ sub.className = "muted";
1347
+ sub.textContent = "last seen " + fmtRelative(peer.lastSeen);
1348
+ main.appendChild(sub);
1349
+ row.appendChild(main);
1350
+
1351
+ var controls = document.createElement("div");
1352
+ controls.className = "peer-controls";
1353
+ var roleSelect = document.createElement("select");
1354
+ roleSelect.className = "input input-sm";
1355
+ roleSelect.setAttribute("aria-label", "Role for " + peer.name);
1356
+ ["viewer", "editor", "admin"].forEach(function (r) {
1357
+ var opt = document.createElement("option");
1358
+ opt.value = r;
1359
+ opt.textContent = r.charAt(0).toUpperCase() + r.slice(1);
1360
+ if (r === peer.role) opt.selected = true;
1361
+ roleSelect.appendChild(opt);
1362
+ });
1363
+ roleSelect.addEventListener("change", function () {
1364
+ var prev = peer.role;
1365
+ roleSelect.disabled = true;
1366
+ apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "PUT", body: JSON.stringify({ role: roleSelect.value, hosts: peer.hosts }) })
1367
+ .then(function () { showToast("Updated role for " + peer.name, "success"); return loadHub(); })
1368
+ .catch(function (err) { apiError(err, "Couldn't update role"); roleSelect.value = prev; roleSelect.disabled = false; });
1369
+ });
1370
+ controls.appendChild(roleSelect);
1371
+
1372
+ var revokeBtn = document.createElement("button");
1373
+ revokeBtn.type = "button";
1374
+ revokeBtn.className = "btn btn-sm btn-danger";
1375
+ revokeBtn.textContent = "Revoke";
1376
+ revokeBtn.addEventListener("click", function () {
1377
+ if (!window.confirm("Revoke " + peer.name + "?")) return;
1378
+ revokeBtn.disabled = true;
1379
+ apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
1380
+ .then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
1381
+ .catch(function (err) { apiError(err, "Couldn't revoke peer"); revokeBtn.disabled = false; });
1382
+ });
1383
+ controls.appendChild(revokeBtn);
1384
+ row.appendChild(controls);
1385
+ peersListEl.appendChild(row);
1386
+ });
1387
+ }
1388
+
1389
+ function renderInvites(invites) {
1390
+ invitesEmptyEl.hidden = invites.length !== 0;
1391
+ clear(invitesListEl);
1392
+ invites.forEach(function (invite) {
1393
+ var row = document.createElement("div");
1394
+ row.className = "invite-row";
1395
+ var main = document.createElement("div");
1396
+ main.className = "peer-main";
1397
+ var roleLine = document.createElement("div");
1398
+ roleLine.className = "peer-name";
1399
+ roleLine.textContent = invite.role.charAt(0).toUpperCase() + invite.role.slice(1);
1400
+ main.appendChild(roleLine);
1401
+ var sub = document.createElement("div");
1402
+ sub.className = "muted";
1403
+ sub.textContent = "expires " + fmtIn(invite.expiresAt);
1404
+ main.appendChild(sub);
1405
+ row.appendChild(main);
1406
+
1407
+ var revokeBtn = document.createElement("button");
1408
+ revokeBtn.type = "button";
1409
+ revokeBtn.className = "btn btn-sm btn-ghost-danger";
1410
+ revokeBtn.textContent = "\\u00d7";
1411
+ revokeBtn.setAttribute("aria-label", "Revoke invite");
1412
+ revokeBtn.addEventListener("click", function () {
1413
+ revokeBtn.disabled = true;
1414
+ apiFetch("/api/invites/" + encodeURIComponent(invite.id), { method: "DELETE" })
1415
+ .then(function () { return loadHub(); })
1416
+ .catch(function (err) { apiError(err, "Couldn't revoke invite"); revokeBtn.disabled = false; });
1417
+ });
1418
+ row.appendChild(revokeBtn);
1419
+ invitesListEl.appendChild(row);
1420
+ });
1421
+ }
1422
+
1423
+ // Re-rendered on every poll, so remember what the user unticked.
1424
+ var inviteUnchecked = {};
1425
+ function renderInviteHostChecks() {
1426
+ Array.prototype.forEach.call(inviteHostsWrap.querySelectorAll("input"), function (cb) {
1427
+ if (cb.checked) delete inviteUnchecked[cb.value];
1428
+ else inviteUnchecked[cb.value] = true;
1429
+ });
1430
+ clear(inviteHostsWrap);
1431
+ lastHosts.forEach(function (row) {
1432
+ if (row.remote) return;
1433
+ var label = document.createElement("label");
1434
+ var cb = document.createElement("input");
1435
+ cb.type = "checkbox";
1436
+ cb.value = row.host;
1437
+ cb.checked = !inviteUnchecked[row.host];
1438
+ label.appendChild(cb);
1439
+ label.appendChild(document.createTextNode(row.host));
1440
+ inviteHostsWrap.appendChild(label);
1441
+ });
1442
+ }
1443
+
1444
+ function updateInviteHostsVisibility() {
1445
+ var isViewer = inviteRoleSelect.value === "viewer";
1446
+ inviteHostsWrap.hidden = !isViewer;
1447
+ if (isViewer) renderInviteHostChecks();
1448
+ }
1449
+ inviteRoleSelect.addEventListener("change", updateInviteHostsVisibility);
1450
+
1451
+ function updateInviteCountdown() {
1452
+ if (!inviteExpiresAt) return;
1453
+ var remaining = Math.max(0, Math.round((inviteExpiresAt - Date.now()) / 1000));
1454
+ if (remaining <= 0) {
1455
+ inviteCountdownEl.textContent = "expired";
1456
+ if (inviteTimer) { clearInterval(inviteTimer); inviteTimer = null; }
1457
+ return;
1458
+ }
1459
+ var m = Math.floor(remaining / 60);
1460
+ var s = remaining % 60;
1461
+ inviteCountdownEl.textContent = m + ":" + (s < 10 ? "0" : "") + s;
1462
+ }
1463
+
1464
+ function showInviteResult(data) {
1465
+ inviteStringEl.textContent = data.string;
1466
+ inviteResultEl.hidden = false;
1467
+ inviteExpiresAt = new Date(data.expiresAt).getTime();
1468
+ if (inviteTimer) clearInterval(inviteTimer);
1469
+ updateInviteCountdown();
1470
+ inviteTimer = setInterval(updateInviteCountdown, 1000);
1471
+ }
1472
+
1473
+ inviteCreateBtn.addEventListener("click", function () {
1474
+ var role = inviteRoleSelect.value;
1475
+ var hosts;
1476
+ if (role === "viewer") {
1477
+ hosts = Array.prototype.slice.call(inviteHostsWrap.querySelectorAll("input:checked")).map(function (cb) { return cb.value; });
1478
+ }
1479
+ inviteCreateBtn.disabled = true;
1480
+ inviteCreateBtn.classList.add("busy");
1481
+ apiFetch("/api/invites", { method: "POST", body: JSON.stringify({ role: role, hosts: hosts }) })
1482
+ .then(function (data) {
1483
+ showInviteResult(data);
1484
+ return loadHub();
1485
+ })
1486
+ .catch(function (err) { apiError(err, "Couldn't create pairing link"); })
1487
+ .then(function () { inviteCreateBtn.disabled = false; inviteCreateBtn.classList.remove("busy"); });
1488
+ });
1489
+
1490
+ inviteCopyBtn.addEventListener("click", function () { copyText(inviteStringEl.textContent, inviteCopyBtn); });
1491
+
1492
+ function renderHub(data) {
1493
+ lastHub = data.hub;
1494
+ var hub = data.hub;
1495
+ var status = hub.status;
1496
+
1497
+ var dotClass = status === "up" ? "up" : status === "starting" ? "warn" : status === "login" ? "accent" : status === "error" ? "down" : "";
1498
+ hubDot.className = "dot" + (dotClass ? " " + dotClass : "");
1499
+ var labels = { off: "Off", starting: "Starting\\u2026", login: "Login required", up: "Up", error: "Error" };
1500
+ hubStatusText.textContent = labels[status] || status;
1501
+
1502
+ hubUrlRow.hidden = !(status === "up" && hub.url);
1503
+ if (status === "up" && hub.url) hubUrlEl.textContent = hub.url;
1504
+
1505
+ hubLoginRow.hidden = !(status === "login" && hub.loginUrl);
1506
+ if (status === "login" && hub.loginUrl) hubLoginLink.href = hub.loginUrl;
1507
+
1508
+ hubQuickWarning.hidden = !(status === "up" && hub.mode === "quick");
1509
+
1510
+ hubLocalhostRow.hidden = !data.config;
1511
+ setSwitch(hubLocalhostSwitch, data.localhost !== false);
1512
+
1513
+ hubErrorRow.hidden = !(status === "error" && hub.error);
1514
+ if (status === "error" && hub.error) hubErrorRow.textContent = hub.error;
1515
+
1516
+ var running = status === "starting" || status === "login" || status === "up";
1517
+ hubNamedForm.hidden = status === "up";
1518
+ hubQuickBtn.hidden = status === "up";
1519
+ hubStopBtn.hidden = !running;
1520
+
1521
+ if (data.config && data.config.mode === "named" && data.config.domain && !hubDomainInput.value) {
1522
+ hubDomainInput.value = data.config.domain;
1523
+ }
1524
+
1525
+ hubShareSection.hidden = status !== "up";
1526
+ if (status === "up") updateInviteHostsVisibility();
1527
+
1528
+ renderPeers(data.peers);
1529
+ renderInvites(data.invites);
1530
+ }
1531
+
1532
+ function loadHub() {
1533
+ return fetch("/api/hub").then(parseJsonOrThrow).then(renderHub).catch(function (err) { apiError(err, "Couldn't load remote access"); });
1534
+ }
1535
+
1536
+ hubUrlCopyBtn.addEventListener("click", function () { copyText(hubUrlEl.textContent, hubUrlCopyBtn); });
1537
+
1538
+ hubNamedForm.addEventListener("submit", function (e) {
1539
+ e.preventDefault();
1540
+ var domain = hubDomainInput.value.trim();
1541
+ if (!domain) { hubDomainInput.focus(); return; }
1542
+ var tunnel = hubTunnelNameInput.value.trim();
1543
+ hubNamedSubmitBtn.disabled = true;
1544
+ hubNamedSubmitBtn.classList.add("busy");
1545
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "named", domain: domain, tunnel: tunnel || undefined }) })
1546
+ .then(function () { showToast("Setting up your domain\\u2026", "success"); return loadHub(); })
1547
+ .catch(function (err) { apiError(err, "Couldn't start setup"); })
1548
+ .then(function () { hubNamedSubmitBtn.disabled = false; hubNamedSubmitBtn.classList.remove("busy"); });
1549
+ });
1550
+
1551
+ hubQuickBtn.addEventListener("click", function () {
1552
+ hubQuickBtn.disabled = true;
1553
+ hubQuickBtn.classList.add("busy");
1554
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "quick" }) })
1555
+ .then(function () { showToast("Starting quick tunnel\\u2026", "success"); return loadHub(); })
1556
+ .catch(function (err) { apiError(err, "Couldn't start quick tunnel"); })
1557
+ .then(function () { hubQuickBtn.disabled = false; hubQuickBtn.classList.remove("busy"); });
1558
+ });
1559
+
1560
+ hubStopBtn.addEventListener("click", function () {
1561
+ if (!window.confirm("Stop remote access? Connected peers will be disconnected.")) return;
1562
+ hubStopBtn.disabled = true;
1563
+ hubStopBtn.classList.add("busy");
1564
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
1565
+ .then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
1566
+ .catch(function (err) { apiError(err, "Couldn't stop remote access"); })
1567
+ .then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
1568
+ });
1569
+
1570
+ // ---------- remote access: connected machines (receiver) ----------
1571
+
1572
+ function showConnectError(message) {
1573
+ connectErrorEl.textContent = message;
1574
+ connectErrorEl.hidden = false;
1575
+ }
1576
+ function hideConnectError() {
1577
+ connectErrorEl.hidden = true;
1578
+ }
1579
+
1580
+ function renderRemotes(remotes) {
1581
+ lastRemotes = remotes;
1582
+ remotesEmptyEl.hidden = remotes.length !== 0;
1583
+ sbRemotesCountEl.textContent = String(remotes.length);
1584
+ sbRemotesCountEl.hidden = remotes.length === 0;
1585
+ clear(remotesListEl);
1586
+ remotes.forEach(function (remote) {
1587
+ var item = document.createElement("div");
1588
+ item.className = "remote-item";
1589
+
1590
+ var head = document.createElement("div");
1591
+ head.className = "remote-head";
1592
+ var main = document.createElement("div");
1593
+ main.className = "remote-main";
1594
+ var nameLine = document.createElement("div");
1595
+ nameLine.className = "peer-name";
1596
+ nameLine.textContent = remote.name;
1597
+ main.appendChild(nameLine);
1598
+ var sub = document.createElement("div");
1599
+ sub.className = "muted";
1600
+ sub.textContent = (remote.sender && remote.sender.hostname) || "";
1601
+ main.appendChild(sub);
1602
+ head.appendChild(main);
1603
+
1604
+ var badges = document.createElement("div");
1605
+ badges.className = "badges";
1606
+ var roleBadge = document.createElement("span");
1607
+ roleBadge.className = "badge badge-role badge-accent";
1608
+ roleBadge.textContent = remote.role;
1609
+ badges.appendChild(roleBadge);
1610
+ var statusBadge = document.createElement("span");
1611
+ statusBadge.className = "badge " + (remote.status === "ok" ? "badge-up" : "badge-down");
1612
+ statusBadge.textContent = remote.status === "ok" ? "connected" : "error";
1613
+ if (remote.status !== "ok" && remote.error) statusBadge.title = remote.error;
1614
+ badges.appendChild(statusBadge);
1615
+ head.appendChild(badges);
1616
+ item.appendChild(head);
1617
+
1618
+ var urlRow = document.createElement("div");
1619
+ urlRow.className = "hub-row";
1620
+ var urlText = document.createElement("span");
1621
+ urlText.className = "hub-url";
1622
+ urlText.textContent = remote.url;
1623
+ urlRow.appendChild(urlText);
1624
+ var urlEditBtn = document.createElement("button");
1625
+ urlEditBtn.type = "button";
1626
+ urlEditBtn.className = "btn btn-sm btn-ghost";
1627
+ urlEditBtn.textContent = "Update URL";
1628
+ urlEditBtn.addEventListener("click", function () {
1629
+ var next = window.prompt("New URL for " + remote.name, remote.url);
1630
+ if (!next || !next.trim() || next.trim() === remote.url) return;
1631
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
1632
+ .then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
1633
+ .catch(function (err) { apiError(err, "Couldn't update URL"); });
1634
+ });
1635
+ urlRow.appendChild(urlEditBtn);
1636
+ item.appendChild(urlRow);
1637
+ if (remote.role === "admin") item.appendChild(remoteLocalRow(remote));
1638
+
1639
+ var actions = document.createElement("div");
1640
+ actions.className = "remote-controls";
1641
+ var syncBtn = document.createElement("button");
1642
+ syncBtn.type = "button";
1643
+ syncBtn.className = "btn btn-sm";
1644
+ syncBtn.textContent = "Sync";
1645
+ syncBtn.addEventListener("click", function () {
1646
+ syncBtn.disabled = true;
1647
+ syncBtn.classList.add("busy");
1648
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/sync", { method: "POST" })
1649
+ .then(function () { showToast("Synced " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1650
+ .catch(function (err) { apiError(err, "Couldn't sync " + remote.name); })
1651
+ .then(function () { syncBtn.disabled = false; syncBtn.classList.remove("busy"); });
1652
+ });
1653
+ actions.appendChild(syncBtn);
1654
+ var disconnectBtn = document.createElement("button");
1655
+ disconnectBtn.type = "button";
1656
+ disconnectBtn.className = "btn btn-sm btn-danger";
1657
+ disconnectBtn.textContent = "Disconnect";
1658
+ disconnectBtn.addEventListener("click", function () {
1659
+ if (!window.confirm("Disconnect from " + remote.name + "? This removes its hosts too.")) return;
1660
+ disconnectBtn.disabled = true;
1661
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
1662
+ .then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1663
+ .catch(function (err) { apiError(err, "Couldn't disconnect"); disconnectBtn.disabled = false; });
1664
+ });
1665
+ actions.appendChild(disconnectBtn);
1666
+ item.appendChild(actions);
1667
+
1668
+ var hostsWrap = document.createElement("div");
1669
+ hostsWrap.className = "remote-hosts-list";
1670
+ if (remote.available === null) {
1671
+ var errLine = document.createElement("div");
1672
+ errLine.className = "dim";
1673
+ errLine.textContent = "Couldn't load hosts: " + (remote.error || "unknown error");
1674
+ hostsWrap.appendChild(errLine);
1675
+ } else {
1676
+ (remote.available || []).forEach(function (h) {
1677
+ var hostRow = document.createElement("div");
1678
+ hostRow.className = "remote-host-row";
1679
+ var hostMain = document.createElement("div");
1680
+ hostMain.className = "mono";
1681
+ var aliases = (remote.mapped || []).filter(function (m) { return m.host === h.host; }).map(function (m) { return m.local; });
1682
+ hostMain.textContent = h.host + (aliases.length ? " \\u2192 " + aliases.join(", ") : "");
1683
+ hostRow.appendChild(hostMain);
1684
+
1685
+ if (!aliases.length) {
1686
+ var aliasForm = document.createElement("div");
1687
+ aliasForm.className = "mini-form";
1688
+ var aliasInput = document.createElement("input");
1689
+ aliasInput.type = "text";
1690
+ aliasInput.className = "input input-sm";
1691
+ aliasInput.placeholder = h.host;
1692
+ aliasInput.setAttribute("aria-label", "Local alias for " + h.host);
1693
+ var aliasBtn = document.createElement("button");
1694
+ aliasBtn.type = "button";
1695
+ aliasBtn.className = "btn btn-sm";
1696
+ aliasBtn.textContent = "Add alias";
1697
+ aliasBtn.addEventListener("click", function () {
1698
+ var local = aliasInput.value.trim();
1699
+ if (!local) { aliasInput.focus(); return; }
1700
+ aliasBtn.disabled = true;
1701
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/aliases", { method: "POST", body: JSON.stringify({ host: h.host, local: local }) })
1702
+ .then(function () { showToast("Added alias " + local, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1703
+ .catch(function (err) { apiError(err, "Couldn't add alias"); aliasBtn.disabled = false; });
1704
+ });
1705
+ aliasForm.appendChild(aliasInput);
1706
+ aliasForm.appendChild(aliasBtn);
1707
+ hostRow.appendChild(aliasForm);
1708
+ } else if (remote.role === "admin") {
1709
+ var delBtn = document.createElement("button");
1710
+ delBtn.type = "button";
1711
+ delBtn.className = "btn btn-sm btn-danger";
1712
+ delBtn.textContent = "Delete";
1713
+ delBtn.addEventListener("click", function () {
1714
+ if (!window.confirm("Delete " + h.host + " on " + remote.name + "?")) return;
1715
+ delBtn.disabled = true;
1716
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
1717
+ .then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1718
+ .catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
1719
+ });
1720
+ hostRow.appendChild(delBtn);
1721
+ }
1722
+ hostsWrap.appendChild(hostRow);
1723
+ });
1724
+ }
1725
+ item.appendChild(hostsWrap);
1726
+
1727
+ if (remote.role === "editor" || remote.role === "admin") {
1728
+ var addHostForm = document.createElement("div");
1729
+ addHostForm.className = "mini-form";
1730
+ var addHostIn = document.createElement("input");
1731
+ addHostIn.type = "text";
1732
+ addHostIn.className = "input input-sm";
1733
+ addHostIn.placeholder = "host";
1734
+ addHostIn.setAttribute("aria-label", "New host on " + remote.name);
1735
+ var addTargetIn = document.createElement("input");
1736
+ addTargetIn.type = "text";
1737
+ addTargetIn.className = "input input-sm";
1738
+ addTargetIn.placeholder = "target";
1739
+ addTargetIn.setAttribute("aria-label", "Target for new host on " + remote.name);
1740
+ var addHostBtn = document.createElement("button");
1741
+ addHostBtn.type = "button";
1742
+ addHostBtn.className = "btn btn-sm";
1743
+ addHostBtn.textContent = "Add host on " + remote.name;
1744
+ addHostBtn.addEventListener("click", function () {
1745
+ var h = addHostIn.value.trim();
1746
+ var t = addTargetIn.value.trim();
1747
+ if (!h || !t) return;
1748
+ addHostBtn.disabled = true;
1749
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts", { method: "POST", body: JSON.stringify({ host: h, target: t }) })
1750
+ .then(function () {
1751
+ showToast("Added " + h + " on " + remote.name, "success");
1752
+ addHostIn.value = "";
1753
+ addTargetIn.value = "";
1754
+ return Promise.all([loadRemotes(), loadHosts()]);
1755
+ })
1756
+ .catch(function (err) { apiError(err, "Couldn't add host"); })
1757
+ .then(function () { addHostBtn.disabled = false; });
1758
+ });
1759
+ addHostForm.appendChild(addHostIn);
1760
+ addHostForm.appendChild(addTargetIn);
1761
+ addHostForm.appendChild(addHostBtn);
1762
+ item.appendChild(addHostForm);
1763
+ }
1764
+
1765
+ remotesListEl.appendChild(item);
1766
+ });
1767
+ }
1768
+
1769
+ function loadRemotes() {
1770
+ return fetch("/api/remotes").then(parseJsonOrThrow).then(function (data) { renderRemotes(data.remotes || []); }).catch(function (err) { apiError(err, "Couldn't load remotes"); });
1771
+ }
1772
+
1773
+ connectForm.addEventListener("submit", function (e) {
1774
+ e.preventDefault();
1775
+ hideConnectError();
1776
+ var value = connectStringInput.value.trim();
1777
+ if (!value) { showConnectError("Paste a pairing link first."); return; }
1778
+ var name = connectNameInput.value.trim();
1779
+ var domain = connectDomainInput.value.trim().toLowerCase();
1780
+ connectSubmitBtn.disabled = true;
1781
+ connectSubmitBtn.classList.add("busy");
1782
+ apiFetch("/api/remotes", { method: "POST", body: JSON.stringify({ string: value, name: name || undefined, domain: domain || undefined }) })
1783
+ .then(function (data) {
1784
+ var r = data.remote;
1785
+ showToast("Connected to " + r.name, "success", r.domain && r.role === "admin" ? "Its localhost: " + localUrl(r.domain) : (domain && r.role !== "admin" ? "Domain ignored: you're " + r.role + ", not admin." : undefined));
1786
+ connectForm.reset();
1787
+ return Promise.all([loadRemotes(), loadHosts()]);
1788
+ })
1789
+ .catch(function (err) { showConnectError(err.message); })
1790
+ .then(function () { connectSubmitBtn.disabled = false; connectSubmitBtn.classList.remove("busy"); });
1791
+ });
1792
+
1793
+ function localUrl(domain, port) {
1794
+ return location.protocol + "//" + (port ? port + "." : "") + domain + ".localhost" + (location.port ? ":" + location.port : "") + "/";
1795
+ }
1796
+
1797
+ function saveDomain(remote, domain) {
1798
+ return apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ domain: domain }) })
1799
+ .then(function (data) {
1800
+ var next = data && data.remote ? data.remote.domain : null;
1801
+ showToast(next ? "Localhost of " + remote.name + " is " + next + ".localhost" : "Removed the localhost domain", "success");
1802
+ return loadRemotes();
1803
+ })
1804
+ .catch(function (err) { apiError(err, "Couldn't set the domain"); });
1805
+ }
1806
+
1807
+ /** Admin remotes: link to the sender's whole localhost, and an inline editor for the domain. */
1808
+ function remoteLocalRow(remote) {
1809
+ var row = document.createElement("div");
1810
+ row.className = "hub-row remote-local";
1811
+ if (remote.localhost === false) {
1812
+ var off = document.createElement("span");
1813
+ off.className = "dim";
1814
+ off.textContent = "Localhost access is off on " + ((remote.sender && remote.sender.hostname) || remote.name) + ".";
1815
+ row.appendChild(off);
1816
+ return row;
1817
+ }
1818
+ var label = document.createElement("span");
1819
+ label.className = "label";
1820
+ label.textContent = "Localhost";
1821
+ row.appendChild(label);
1822
+
1823
+ var form = document.createElement("form");
1824
+ form.className = "mini-form";
1825
+ form.hidden = Boolean(remote.domain);
1826
+ var input = document.createElement("input");
1827
+ input.type = "text";
1828
+ input.className = "input input-sm";
1829
+ input.value = remote.domain || "";
1830
+ input.placeholder = "office";
1831
+ input.setAttribute("aria-label", "Localhost domain for " + remote.name);
1832
+ var save = document.createElement("button");
1833
+ save.type = "submit";
1834
+ save.className = "btn btn-sm btn-primary";
1835
+ save.textContent = "Save";
1836
+ var random = document.createElement("button");
1837
+ random.type = "button";
1838
+ random.className = "btn btn-sm";
1839
+ random.textContent = "Random";
1840
+ random.addEventListener("click", function () { saveDomain(remote, "random"); });
1841
+ form.appendChild(input);
1842
+ form.appendChild(save);
1843
+ form.appendChild(random);
1844
+ form.addEventListener("submit", function (e) {
1845
+ e.preventDefault();
1846
+ var value = input.value.trim().toLowerCase();
1847
+ if (!value) { input.focus(); return; }
1848
+ saveDomain(remote, value);
1849
+ });
1850
+
1851
+ if (remote.domain) {
1852
+ var link = document.createElement("a");
1853
+ link.href = localUrl(remote.domain);
1854
+ link.target = "_blank";
1855
+ link.rel = "noopener noreferrer";
1856
+ link.textContent = remote.domain + ".localhost";
1857
+ link.title = "Any port: " + localUrl(remote.domain, "<port>");
1858
+ row.appendChild(link);
1859
+ row.appendChild(makeCopyButton(function () { return localUrl(remote.domain); }));
1860
+ var change = document.createElement("button");
1861
+ change.type = "button";
1862
+ change.className = "btn btn-sm btn-ghost";
1863
+ change.textContent = "Change";
1864
+ change.addEventListener("click", function () { form.hidden = !form.hidden; if (!form.hidden) input.focus(); });
1865
+ row.appendChild(change);
1866
+ var remove = document.createElement("button");
1867
+ remove.type = "button";
1868
+ remove.className = "btn btn-sm btn-ghost";
1869
+ remove.textContent = "Remove";
1870
+ remove.addEventListener("click", function () { saveDomain(remote, null); });
1871
+ row.appendChild(remove);
1872
+ }
1873
+ row.appendChild(form);
1874
+ return row;
1875
+ }
1876
+
1877
+ hubLocalhostSwitch.addEventListener("click", function () {
1878
+ var next = hubLocalhostSwitch.getAttribute("aria-checked") !== "true";
1879
+ hubLocalhostSwitch.disabled = true;
1880
+ apiFetch("/api/hub/localhost", { method: "PUT", body: JSON.stringify({ enabled: next }) })
1881
+ .then(function () {
1882
+ setSwitch(hubLocalhostSwitch, next);
1883
+ showToast(next ? "Admins can open any port on this machine" : "Localhost access is off", "success");
1884
+ })
1885
+ .catch(function (err) { apiError(err, "Couldn't change localhost access"); })
1886
+ .then(function () { hubLocalhostSwitch.disabled = false; });
1887
+ });
1888
+
1889
+ function refreshHubAndRemotes() {
1890
+ if (proxyStopped) return Promise.resolve();
1891
+ return Promise.all([loadHub(), loadRemotes()]).then(function () { renderOverview(); });
1892
+ }
1893
+
1091
1894
  caToggle.addEventListener("click", function () {
1092
1895
  if (caBusy || !lastStatus) return;
1093
1896
  var next = !(lastStatus.system.caTrusted === true);
@@ -1143,6 +1946,7 @@ const clientJs = `
1143
1946
  stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
1144
1947
  stoppedBanner.hidden = false;
1145
1948
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
1949
+ if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
1146
1950
  showToast("Proxy stopped", "warn");
1147
1951
  })
1148
1952
  .catch(function (err) {
@@ -1238,6 +2042,7 @@ const clientJs = `
1238
2042
  apiFetch("/api/hosts", { method: "POST", body: JSON.stringify({ host: hostVal, target: targetVal, insecure: insecureVal, cors: corsVal }) })
1239
2043
  .then(function () {
1240
2044
  addForm.reset();
2045
+ applyHostDefaults();
1241
2046
  showToast("Added " + hostVal, "success");
1242
2047
  return loadHosts();
1243
2048
  })
@@ -1373,11 +2178,19 @@ const clientJs = `
1373
2178
  a.textContent = row.host;
1374
2179
  hostWrap.appendChild(a);
1375
2180
  hostWrap.appendChild(makeCopyButton(function () { return href || row.host; }));
2181
+ if (row.remote) {
2182
+ var viaBadge = document.createElement("span");
2183
+ viaBadge.className = "badge badge-via";
2184
+ viaBadge.textContent = "via " + row.remote.name;
2185
+ viaBadge.title = "Imported from remote \\"" + row.remote.name + "\\"";
2186
+ hostWrap.appendChild(viaBadge);
2187
+ }
1376
2188
  hostTd.appendChild(hostWrap);
1377
2189
  var targetLine = document.createElement("div");
1378
2190
  targetLine.className = "host-target mono";
1379
- targetLine.textContent = row.target;
1380
- targetLine.title = row.target;
2191
+ var targetText = row.remote ? (row.remote.name + ": " + row.remote.host) : row.target;
2192
+ targetLine.textContent = targetText;
2193
+ targetLine.title = targetText;
1381
2194
  hostTd.appendChild(targetLine);
1382
2195
  tr.appendChild(hostTd);
1383
2196
 
@@ -1432,7 +2245,7 @@ const clientJs = `
1432
2245
  removeBtn.className = "btn btn-sm btn-danger";
1433
2246
  removeBtn.textContent = "Remove";
1434
2247
  removeBtn.addEventListener("click", function () {
1435
- if (!window.confirm("Remove host " + row.host + "?")) return;
2248
+ if (prefs.confirm && !window.confirm("Remove host " + row.host + "?")) return;
1436
2249
  removeBtn.disabled = true;
1437
2250
  apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
1438
2251
  .then(function () {
@@ -1444,8 +2257,10 @@ const clientJs = `
1444
2257
  removeBtn.disabled = false;
1445
2258
  });
1446
2259
  });
1447
- actionsWrap.appendChild(shareButton(row));
1448
- actionsWrap.appendChild(editBtn);
2260
+ if (!row.remote) {
2261
+ actionsWrap.appendChild(shareButton(row));
2262
+ actionsWrap.appendChild(editBtn);
2263
+ }
1449
2264
  actionsWrap.appendChild(removeBtn);
1450
2265
  tr.appendChild(actionsTd);
1451
2266
  labelCells(tr, ["", "", "Options", "Status", "Public URL", ""]);
@@ -1559,6 +2374,7 @@ const clientJs = `
1559
2374
  try { localStorage.setItem("locadot.hostCount", String(hosts.length || 1)); } catch (e) {}
1560
2375
  hideHostsSkeleton();
1561
2376
  lastHosts = hosts;
2377
+ if (inviteRoleSelect.value === "viewer" && !inviteHostsWrap.hidden) renderInviteHostChecks();
1562
2378
  emptyEl.hidden = hosts.length !== 0;
1563
2379
  tableWrap.hidden = hosts.length === 0;
1564
2380
 
@@ -1584,6 +2400,8 @@ const clientJs = `
1584
2400
  });
1585
2401
  hostsCountEl.textContent = String(hosts.length);
1586
2402
  hostsCountEl.hidden = false;
2403
+ sbHostsCountEl.textContent = String(hosts.length);
2404
+ sbHostsCountEl.hidden = false;
1587
2405
  applyHostsFilter();
1588
2406
  }
1589
2407
 
@@ -1604,17 +2422,6 @@ const clientJs = `
1604
2422
 
1605
2423
  // ---------- logs ----------
1606
2424
 
1607
- function loadLogs() {
1608
- return fetch("/api/logs?lines=200").then(parseJsonOrThrow).then(function (data) {
1609
- var lines = (data && data.lines) || [];
1610
- logsBox.textContent = lines.length ? lines.join("\\n") : "No log lines yet.";
1611
- logsBox.scrollTop = logsBox.scrollHeight;
1612
- }).catch(function (err) { apiError(err, "Couldn't load logs"); });
1613
- }
1614
-
1615
- logsPanel.addEventListener("toggle", function () {
1616
- if (logsPanel.open) loadLogs();
1617
- });
1618
2425
  logsRefreshBtn.addEventListener("click", loadLogs);
1619
2426
  logsClearBtn.addEventListener("click", function () {
1620
2427
  if (!window.confirm("Clear logs?")) return;
@@ -1653,17 +2460,575 @@ const clientJs = `
1653
2460
  }
1654
2461
 
1655
2462
  document.addEventListener("visibilitychange", function () {
1656
- if (!document.hidden && !proxyStopped) refreshAll();
2463
+ if (!document.hidden && !proxyStopped) {
2464
+ refreshAll().then(tickViews);
2465
+ refreshHubAndRemotes();
2466
+ }
2467
+ });
2468
+
2469
+
2470
+ remotesPollTimer = setInterval(function () {
2471
+ if (!document.hidden && !proxyStopped) refreshHubAndRemotes();
2472
+ }, 15000);
2473
+
2474
+ // ---------- preferences (per browser) ----------
2475
+
2476
+ var PREF_DEFAULTS = { refresh: 5000, start: "overview", confirm: true, insecure: false, cors: false };
2477
+ var prefs = (function () {
2478
+ var out = {};
2479
+ var saved = {};
2480
+ try { saved = JSON.parse(localStorage.getItem("locadot.prefs") || "{}") || {}; } catch (e) {}
2481
+ Object.keys(PREF_DEFAULTS).forEach(function (k) { out[k] = saved[k] !== undefined ? saved[k] : PREF_DEFAULTS[k]; });
2482
+ return out;
2483
+ })();
2484
+ function savePrefs() {
2485
+ try { localStorage.setItem("locadot.prefs", JSON.stringify(prefs)); } catch (e) {}
2486
+ }
2487
+
2488
+ function applyHostDefaults() {
2489
+ addInsecureInput.checked = !!prefs.insecure;
2490
+ addCorsInput.checked = !!prefs.cors;
2491
+ }
2492
+ applyHostDefaults();
2493
+
2494
+ function setSwitch(el, on) {
2495
+ el.setAttribute("aria-checked", String(on));
2496
+ el.classList.toggle("on", on);
2497
+ }
2498
+
2499
+ // ---------- sidebar + routing ----------
2500
+
2501
+ var VIEWS = ["overview", "hosts", "sharing", "remote", "machines", "logs", "settings"];
2502
+ var TITLES = { overview: "Overview", hosts: "Hosts", sharing: "Public sharing", remote: "Remote access", machines: "Connected machines", logs: "Logs", settings: "Settings" };
2503
+ var rootEl = document.documentElement;
2504
+ var sidebarEl = document.getElementById("sidebar");
2505
+ var collapseBtn = document.getElementById("sb-collapse");
2506
+ var menuBtn = document.getElementById("sb-menu");
2507
+ var backdropEl = document.getElementById("sb-backdrop");
2508
+ var navLinks = Array.prototype.slice.call(sidebarEl.querySelectorAll("a.sb-link"));
2509
+ var viewEls = {};
2510
+ Array.prototype.forEach.call(document.querySelectorAll("section.view"), function (el) { viewEls[el.getAttribute("data-view")] = el; });
2511
+ var drawerMq = window.matchMedia("(max-width: 899px)");
2512
+ var currentView = null;
2513
+
2514
+ function isCollapsed() { return rootEl.getAttribute("data-sidebar") === "collapsed"; }
2515
+ function setCollapsed(collapsed, persist) {
2516
+ if (collapsed) rootEl.setAttribute("data-sidebar", "collapsed");
2517
+ else rootEl.removeAttribute("data-sidebar");
2518
+ if (persist) { try { localStorage.setItem("locadot.sidebar", collapsed ? "collapsed" : "expanded"); } catch (e) {} }
2519
+ collapseBtn.setAttribute("aria-expanded", String(!collapsed));
2520
+ collapseBtn.title = collapsed ? "Expand sidebar" : "Collapse sidebar";
2521
+ collapseBtn.querySelector(".sb-label").textContent = collapsed ? "Expand" : "Collapse";
2522
+ setSwitch(prefSidebar, collapsed);
2523
+ }
2524
+ collapseBtn.addEventListener("click", function () { setCollapsed(!isCollapsed(), true); });
2525
+
2526
+ function setDrawer(open) {
2527
+ document.body.classList.toggle("sb-open", open);
2528
+ backdropEl.hidden = !open;
2529
+ menuBtn.setAttribute("aria-expanded", String(open));
2530
+ menuBtn.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
2531
+ if (open && navLinks[0]) navLinks[0].focus();
2532
+ }
2533
+ menuBtn.addEventListener("click", function () { setDrawer(!document.body.classList.contains("sb-open")); });
2534
+ backdropEl.addEventListener("click", function () { setDrawer(false); });
2535
+ document.addEventListener("keydown", function (e) {
2536
+ if (e.key === "Escape" && document.body.classList.contains("sb-open")) { setDrawer(false); menuBtn.focus(); }
1657
2537
  });
2538
+ if (drawerMq.addEventListener) drawerMq.addEventListener("change", function () { if (!drawerMq.matches) setDrawer(false); });
2539
+
2540
+ function viewFromHash() {
2541
+ var name = (location.hash || "").replace(/^#\\/?/, "");
2542
+ return VIEWS.indexOf(name) !== -1 ? name : null;
2543
+ }
2544
+
2545
+ function showView(name) {
2546
+ if (VIEWS.indexOf(name) === -1) name = "overview";
2547
+ var changed = currentView !== name;
2548
+ currentView = name;
2549
+ VIEWS.forEach(function (v) { if (viewEls[v]) viewEls[v].hidden = v !== name; });
2550
+ navLinks.forEach(function (a) {
2551
+ if (a.getAttribute("data-view") === name) a.setAttribute("aria-current", "page");
2552
+ else a.removeAttribute("aria-current");
2553
+ });
2554
+ document.title = TITLES[name] + " · locadot";
2555
+ try { localStorage.setItem("locadot.lastView", name); } catch (e) {}
2556
+ if (drawerMq.matches) setDrawer(false);
2557
+ if (changed) {
2558
+ window.scrollTo(0, 0);
2559
+ onViewEnter(name);
2560
+ }
2561
+ }
2562
+
2563
+ function onViewEnter(name) {
2564
+ if (name === "logs") loadLogs();
2565
+ if (name === "overview") { renderOverview(); loadOverviewLogs(); }
2566
+ if (name === "sharing") renderShareList();
2567
+ if (name === "settings") loadSettings();
2568
+ if (name === "remote" || name === "machines") refreshHubAndRemotes();
2569
+ }
2570
+
2571
+ window.addEventListener("hashchange", function () { showView(viewFromHash() || "overview"); });
2572
+
2573
+ // ---------- overview ----------
2574
+
2575
+ var ovHostsEl = document.getElementById("ov-hosts");
2576
+ var ovHostsSubEl = document.getElementById("ov-hosts-sub");
2577
+ var ovDownEl = document.getElementById("ov-down");
2578
+ var ovHitsEl = document.getElementById("ov-hits");
2579
+ var ovHitsSubEl = document.getElementById("ov-hits-sub");
2580
+ var ovSharesEl = document.getElementById("ov-shares");
2581
+ var ovSharesSubEl = document.getElementById("ov-shares-sub");
2582
+ var ovHubEl = document.getElementById("ov-hub");
2583
+ var ovHubSubEl = document.getElementById("ov-hub-sub");
2584
+ var ovRemotesEl = document.getElementById("ov-remotes");
2585
+ var ovRemotesSubEl = document.getElementById("ov-remotes-sub");
2586
+ var ovHostList = document.getElementById("ov-host-list");
2587
+ var ovHostEmpty = document.getElementById("ov-host-empty");
2588
+ var ovChecks = document.getElementById("ov-checks");
2589
+ var ovLogs = document.getElementById("ov-logs");
2590
+ var sbSharesCountEl = document.getElementById("sb-shares-count");
2591
+ var sbHubDot = document.getElementById("sb-hub-dot");
2592
+
2593
+ function isShared(row) {
2594
+ var t = tunnelInfo(row);
2595
+ return t.enabled || t.status === "up" || t.status === "starting";
2596
+ }
2597
+
2598
+ function ovRow(title, sub, side, dotClass, href) {
2599
+ var row = document.createElement(href ? "a" : "div");
2600
+ row.className = "ov-row";
2601
+ if (href) { row.href = href; row.style.color = "inherit"; row.style.textDecoration = "none"; }
2602
+ var dot = document.createElement("span");
2603
+ dot.className = "dot" + (dotClass ? " " + dotClass : "");
2604
+ row.appendChild(dot);
2605
+ var main = document.createElement("div");
2606
+ main.className = "ov-main";
2607
+ var t = document.createElement("span");
2608
+ t.className = "ov-title";
2609
+ t.textContent = title;
2610
+ main.appendChild(t);
2611
+ if (sub) {
2612
+ var s = document.createElement("span");
2613
+ s.className = "ov-sub";
2614
+ s.textContent = sub;
2615
+ main.appendChild(s);
2616
+ }
2617
+ row.appendChild(main);
2618
+ var sideEl = document.createElement("div");
2619
+ sideEl.className = "ov-side";
2620
+ if (typeof side === "string") sideEl.textContent = side;
2621
+ else if (side) sideEl.appendChild(side);
2622
+ row.appendChild(sideEl);
2623
+ return row;
2624
+ }
2625
+
2626
+ function fmtNum(n) { return n >= 10000 ? Math.round(n / 1000) + "k" : String(n); }
2627
+
2628
+ function renderOverview() {
2629
+ var hosts = lastHosts || [];
2630
+ var up = 0, hits = 0, errors = 0, shared = 0, remoteCount = 0;
2631
+ hosts.forEach(function (h) {
2632
+ if (h.probe && h.probe.up) up++;
2633
+ if (h.stats) { hits += h.stats.hits || 0; errors += h.stats.errors || 0; }
2634
+ if (isShared(h)) shared++;
2635
+ if (h.remote) remoteCount++;
2636
+ });
2637
+ var down = hosts.length - up;
2638
+ if (lastStatus) {
2639
+ ovHostsEl.textContent = String(hosts.length);
2640
+ ovHostsSubEl.textContent = up + " up" + (remoteCount ? " · " + remoteCount + " from other machines" : "");
2641
+ ovDownEl.textContent = String(down);
2642
+ ovDownEl.className = "tile-big" + (down ? " bad" : " ok");
2643
+ ovHitsEl.textContent = fmtNum(hits);
2644
+ ovHitsSubEl.textContent = errors ? errors + " errors since start" : "since the proxy started";
2645
+ ovSharesEl.textContent = String(shared);
2646
+ ovSharesSubEl.textContent = cloudflaredInstalled ? (shared ? "public right now" : "nothing public") : "cloudflared not installed";
2647
+ }
2648
+ sbSharesCountEl.textContent = String(shared);
2649
+ sbSharesCountEl.hidden = shared === 0;
2650
+
2651
+ var hub = lastHub;
2652
+ var hubLabels = { off: "Off", starting: "Starting", login: "Login needed", up: "On", error: "Error" };
2653
+ if (hub) {
2654
+ ovHubEl.textContent = hubLabels[hub.status] || hub.status;
2655
+ ovHubEl.className = "tile-big tile-word" + (hub.status === "up" ? " ok" : hub.status === "error" ? " bad" : hub.status === "off" ? "" : " warn");
2656
+ ovHubSubEl.textContent = hub.url ? hub.url.replace(/^https?:\\/\\//, "") : (hub.status === "off" ? "not shared" : "");
2657
+ sbHubDot.hidden = hub.status === "off";
2658
+ sbHubDot.className = "dot sb-dot " + (hub.status === "up" ? "up" : hub.status === "error" ? "down" : "warn");
2659
+ }
2660
+ var remotes = lastRemotes || [];
2661
+ ovRemotesEl.textContent = String(remotes.length);
2662
+ ovRemotesSubEl.textContent = remotes.length ? remotes.map(function (r) { return r.name; }).join(", ") : "none";
2663
+
2664
+ clear(ovHostList);
2665
+ ovHostEmpty.hidden = hosts.length !== 0 || !lastStatus;
2666
+ var sorted = hosts.slice().sort(function (a, b) {
2667
+ var au = a.probe && a.probe.up ? 1 : 0, bu = b.probe && b.probe.up ? 1 : 0;
2668
+ return au - bu || a.host.localeCompare(b.host);
2669
+ });
2670
+ sorted.slice(0, 8).forEach(function (h) {
2671
+ var p = h.probe || {};
2672
+ var side = p.up ? ((p.status || "") + (p.ms !== undefined ? " · " + p.ms + "ms" : "")) : (p.error || "down");
2673
+ var sub = h.remote ? "via " + h.remote.name : h.target;
2674
+ ovHostList.appendChild(ovRow(h.host, sub, side, p.up ? "up" : "down", "#/hosts"));
2675
+ });
2676
+ if (sorted.length > 8) {
2677
+ var more = document.createElement("a");
2678
+ more.className = "ov-more";
2679
+ more.href = "#/hosts";
2680
+ more.textContent = "View all " + sorted.length + " hosts";
2681
+ ovHostList.appendChild(more);
2682
+ }
2683
+
2684
+ clear(ovChecks);
2685
+ if (lastStatus) {
2686
+ var sys = lastStatus.system, proxy = lastStatus.proxy;
2687
+ ovChecks.appendChild(ovRow("Running", "pid " + proxy.pid + " · uptime " + fmtUptime(lastStatus.uptimeSec), "http :" + proxy.httpPort + " · https :" + proxy.httpsPort, "up"));
2688
+ ovChecks.appendChild(ovRow("HTTPS certificates", sys.caTrusted === true ? "local CA is trusted" : "browsers will warn until the CA is trusted", sys.caTrusted === true ? "Trusted" : "Not trusted", sys.caTrusted === true ? "up" : "warn", "#/settings"));
2689
+ ovChecks.appendChild(ovRow("Start at boot", sys.startup.method ? "via " + sys.startup.method : "", sys.startup.enabled ? "Enabled" : "Disabled", sys.startup.enabled ? "up" : "", "#/settings"));
2690
+ var cf = sys.cloudflared || {};
2691
+ ovChecks.appendChild(ovRow("cloudflared", cf.installed ? (cf.version || cf.path || "") : "needed for public sharing and remote access", cf.installed ? "Installed" : "Missing", cf.installed ? "up" : "warn", "#/sharing"));
2692
+ }
2693
+ }
2694
+
2695
+ function loadOverviewLogs() {
2696
+ return fetch("/api/logs?lines=12").then(parseJsonOrThrow).then(function (data) {
2697
+ var lines = (data && data.lines) || [];
2698
+ ovLogs.textContent = lines.length ? lines.join("\\n") : "No log lines yet.";
2699
+ ovLogs.scrollTop = ovLogs.scrollHeight;
2700
+ }).catch(function () {});
2701
+ }
2702
+
2703
+ // ---------- public sharing page ----------
2704
+
2705
+ var shareListEl = document.getElementById("share-list");
2706
+ var shareEmptyEl = document.getElementById("share-empty");
2707
+ var shareCountEl = document.getElementById("share-count");
2708
+
2709
+ function renderShareList() {
2710
+ var hosts = (lastHosts || []).filter(function (h) { return !h.remote; });
2711
+ clear(shareListEl);
2712
+ shareEmptyEl.hidden = hosts.length !== 0;
2713
+ var shared = hosts.filter(isShared).length;
2714
+ shareCountEl.textContent = shared + " public";
2715
+ shareCountEl.hidden = hosts.length === 0;
2716
+ hosts.slice().sort(function (a, b) { return (isShared(b) ? 1 : 0) - (isShared(a) ? 1 : 0) || a.host.localeCompare(b.host); }).forEach(function (h) {
2717
+ var t = tunnelInfo(h);
2718
+ var side = document.createElement("div");
2719
+ side.className = "ov-side";
2720
+ if (t.status === "up" && t.url) {
2721
+ var a = document.createElement("a");
2722
+ a.className = "ov-link";
2723
+ a.href = t.url;
2724
+ a.target = "_blank";
2725
+ a.rel = "noopener noreferrer";
2726
+ a.textContent = t.url.replace(/^https:\\/\\//, "");
2727
+ side.appendChild(a);
2728
+ side.appendChild(makeCopyButton(function () { return t.url; }));
2729
+ }
2730
+ side.appendChild(shareButton(h));
2731
+ var sub = t.status === "up" ? "public" : t.status === "starting" ? "starting tunnel…" : t.status === "error" ? ("error: " + (t.error || "tunnel failed")) : h.target;
2732
+ var dot = t.status === "up" ? "up" : t.status === "starting" ? "warn" : t.status === "error" ? "down" : "";
2733
+ shareListEl.appendChild(ovRow(h.host, sub, side, dot));
2734
+ });
2735
+ }
1658
2736
 
1659
- pollTimer = setInterval(function () {
1660
- if (!document.hidden && !proxyStopped) refreshAll();
1661
- }, 5000);
2737
+ // ---------- logs page ----------
1662
2738
 
1663
- refreshAll();
2739
+ var logsFilter = document.getElementById("logs-filter");
2740
+ var logsLevel = document.getElementById("logs-level");
2741
+ var logsLines = document.getElementById("logs-lines");
2742
+ var logsAuto = document.getElementById("logs-auto");
2743
+ var logsDownload = document.getElementById("logs-download");
2744
+ var logsMeta = document.getElementById("logs-meta");
2745
+ var lastLogLines = [];
2746
+ var LEVELS = { error: 0, warn: 1, info: 2 };
2747
+
2748
+ function lineLevel(line) {
2749
+ var m = /\\[(error|warn|info|debug|verbose|silly)\\]/i.exec(line);
2750
+ return m ? m[1].toLowerCase() : "info";
2751
+ }
2752
+
2753
+ function renderLogs() {
2754
+ var q = (logsFilter.value || "").trim().toLowerCase();
2755
+ var lvl = logsLevel.value;
2756
+ var shown = lastLogLines.filter(function (line) {
2757
+ if (q && line.toLowerCase().indexOf(q) === -1) return false;
2758
+ if (lvl) {
2759
+ var l = LEVELS[lineLevel(line)];
2760
+ if (l === undefined || l > LEVELS[lvl]) return false;
2761
+ }
2762
+ return true;
2763
+ });
2764
+ var atBottom = logsBox.scrollHeight - logsBox.scrollTop - logsBox.clientHeight < 40;
2765
+ logsBox.textContent = shown.length ? shown.join("\\n") : (lastLogLines.length ? "No lines match." : "No log lines yet.");
2766
+ if (atBottom) logsBox.scrollTop = logsBox.scrollHeight;
2767
+ logsMeta.textContent = (shown.length === lastLogLines.length ? lastLogLines.length + " lines" : shown.length + " of " + lastLogLines.length + " lines") + " · updated " + new Date().toLocaleTimeString();
2768
+ }
2769
+
2770
+ function loadLogs() {
2771
+ return fetch("/api/logs?lines=" + encodeURIComponent(logsLines.value)).then(parseJsonOrThrow).then(function (data) {
2772
+ lastLogLines = (data && data.lines) || [];
2773
+ renderLogs();
2774
+ }).catch(function (err) { apiError(err, "Couldn't load logs"); });
2775
+ }
2776
+
2777
+ logsFilter.addEventListener("input", renderLogs);
2778
+ logsLevel.addEventListener("change", renderLogs);
2779
+ logsLines.addEventListener("change", loadLogs);
2780
+ logsDownload.addEventListener("click", function () {
2781
+ var blob = new Blob([lastLogLines.join("\\n") + "\\n"], { type: "text/plain" });
2782
+ var a = document.createElement("a");
2783
+ a.href = URL.createObjectURL(blob);
2784
+ a.download = "locadot-" + new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-") + ".log";
2785
+ document.body.appendChild(a);
2786
+ a.click();
2787
+ setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
2788
+ });
2789
+
2790
+ // ---------- settings page ----------
2791
+
2792
+ var portsForm = document.getElementById("ports-form");
2793
+ var httpPortInput = document.getElementById("set-http-port");
2794
+ var httpsPortInput = document.getElementById("set-https-port");
2795
+ var portsNote = document.getElementById("ports-note");
2796
+ var portsError = document.getElementById("ports-error");
2797
+ var portsSave = document.getElementById("ports-save");
2798
+ var restartBtn = document.getElementById("restart-proxy-btn");
2799
+ var restartNote = document.getElementById("restart-note");
2800
+ var setBind = document.getElementById("set-bind");
2801
+ var setBindNote = document.getElementById("set-bind-note");
2802
+ var setHome = document.getElementById("set-home");
2803
+ var setLogLevel = document.getElementById("set-loglevel");
2804
+ var prefTheme = document.getElementById("pref-theme");
2805
+ var prefRefresh = document.getElementById("pref-refresh");
2806
+ var prefStart = document.getElementById("pref-start");
2807
+ var prefSidebar = document.getElementById("pref-sidebar");
2808
+ var prefConfirm = document.getElementById("pref-confirm");
2809
+ var prefInsecure = document.getElementById("pref-insecure");
2810
+ var prefCors = document.getElementById("pref-cors");
2811
+ var tokenValue = document.getElementById("token-value");
2812
+ var tokenReveal = document.getElementById("token-reveal");
2813
+ var tokenCopy = document.getElementById("token-copy");
2814
+ var dangerClearLogs = document.getElementById("danger-clear-logs");
2815
+ var settingsLoaded = null;
2816
+
2817
+ function renderSettings(s) {
2818
+ settingsLoaded = s;
2819
+ if (s.uiAuth) renderUiAuth(s.uiAuth.enabled);
2820
+ if (document.activeElement !== httpPortInput) httpPortInput.value = String((s.saved && s.saved.httpPort) || s.httpPort);
2821
+ if (document.activeElement !== httpsPortInput) httpsPortInput.value = String((s.saved && s.saved.httpsPort) || s.httpsPort);
2822
+ var notes = ["Running on http :" + s.httpPort + " and https :" + s.httpsPort + "."];
2823
+ if (s.env && s.env.httpPort) notes.push("LOCADOT_HTTP_PORT is set and overrides the saved HTTP port.");
2824
+ if (s.env && s.env.httpsPort) notes.push("LOCADOT_HTTPS_PORT is set and overrides the saved HTTPS port.");
2825
+ portsNote.textContent = notes.join(" ");
2826
+ restartNote.hidden = !s.restartRequired;
2827
+ setBind.textContent = (s.bind || []).join(", ");
2828
+ setBindNote.textContent = s.env && s.env.bind ? "Set by LOCADOT_BIND." : "Loopback only. Set LOCADOT_BIND to change it.";
2829
+ setHome.textContent = s.stateDir || "";
2830
+ setLogLevel.textContent = s.logLevel || "info";
2831
+ }
2832
+
2833
+ function loadSettings() {
2834
+ return fetch("/api/settings").then(parseJsonOrThrow).then(renderSettings).catch(function (err) { apiError(err, "Couldn't load settings"); });
2835
+ }
2836
+
2837
+ portsForm.addEventListener("submit", function (e) {
2838
+ e.preventDefault();
2839
+ portsError.hidden = true;
2840
+ var h = parseInt(httpPortInput.value, 10), hs = parseInt(httpsPortInput.value, 10);
2841
+ var bad = function (p) { return !(p >= 1 && p <= 65535); };
2842
+ if (bad(h) || bad(hs) || h === hs) {
2843
+ portsError.textContent = h === hs ? "HTTP and HTTPS need different ports." : "Ports must be between 1 and 65535.";
2844
+ portsError.hidden = false;
2845
+ return;
2846
+ }
2847
+ portsSave.disabled = true;
2848
+ portsSave.classList.add("busy");
2849
+ apiFetch("/api/settings", { method: "PUT", body: JSON.stringify({ httpPort: h, httpsPort: hs }) })
2850
+ .then(function (s) {
2851
+ renderSettings(s);
2852
+ showToast(s.restartRequired ? "Ports saved. Restart to apply." : "Ports saved", s.warning ? "warn" : "success", s.warning);
2853
+ })
2854
+ .catch(function (err) { portsError.textContent = err.message; portsError.hidden = false; })
2855
+ .then(function () { portsSave.disabled = false; portsSave.classList.remove("busy"); });
2856
+ });
2857
+
2858
+ restartBtn.addEventListener("click", function () {
2859
+ if (!window.confirm("Restart the proxy? Hosts are unavailable for a few seconds.")) return;
2860
+ var s = settingsLoaded;
2861
+ var nextPort = s ? ((s.env && s.env.httpPort) ? s.httpPort : ((s.saved && s.saved.httpPort) || s.httpPort)) : null;
2862
+ restartBtn.disabled = true;
2863
+ restartBtn.classList.add("busy");
2864
+ apiFetch("/api/proxy/restart", { method: "POST" })
2865
+ .then(function () {
2866
+ showToast("Restarting proxy…", "success");
2867
+ var base = location.protocol + "//" + location.hostname;
2868
+ var samePort = !nextPort || String(nextPort) === (location.port || "80");
2869
+ var tries = 0;
2870
+ var wait = setInterval(function () {
2871
+ tries++;
2872
+ if (!samePort) {
2873
+ var go = function () { clearInterval(wait); setTimeout(function () { location.href = base + ":" + nextPort + "/#/settings"; }, 1500); };
2874
+ if (tries > 20) return go();
2875
+ fetch("/healthz", { cache: "no-store" }).catch(go);
2876
+ return;
2877
+ }
2878
+ if (tries < 3) return;
2879
+ fetch("/healthz", { cache: "no-store" }).then(function (r) {
2880
+ if (!r.ok) return;
2881
+ clearInterval(wait);
2882
+ location.reload();
2883
+ }).catch(function () {});
2884
+ if (tries > 40) { clearInterval(wait); restartBtn.disabled = false; restartBtn.classList.remove("busy"); }
2885
+ }, 500);
2886
+ })
2887
+ .catch(function (err) {
2888
+ apiError(err, "Couldn't restart the proxy");
2889
+ restartBtn.disabled = false;
2890
+ restartBtn.classList.remove("busy");
2891
+ });
2892
+ });
2893
+
2894
+ document.getElementById("set-home-copy").addEventListener("click", function (e) { copyText(setHome.textContent, e.currentTarget); });
2895
+
2896
+ function syncThemeSeg(mode) {
2897
+ Array.prototype.forEach.call(prefTheme.querySelectorAll("button"), function (b) {
2898
+ b.setAttribute("aria-checked", String(b.getAttribute("data-value") === mode));
2899
+ });
2900
+ }
2901
+ prefTheme.addEventListener("click", function (e) {
2902
+ var b = e.target.closest("button[data-value]");
2903
+ if (b) setTheme(b.getAttribute("data-value"));
2904
+ });
2905
+ syncThemeSeg(document.documentElement.getAttribute("data-theme") || "system");
2906
+ document.getElementById("theme-toggle").addEventListener("click", function () {
2907
+ syncThemeSeg(document.documentElement.getAttribute("data-theme") || "system");
2908
+ });
2909
+
2910
+ prefRefresh.value = String(prefs.refresh);
2911
+ if (prefRefresh.value !== String(prefs.refresh)) prefRefresh.value = "5000";
2912
+ prefRefresh.addEventListener("change", function () {
2913
+ prefs.refresh = parseInt(prefRefresh.value, 10) || 0;
2914
+ savePrefs();
2915
+ schedulePoll();
2916
+ showToast(prefs.refresh ? "Refreshing every " + prefs.refresh / 1000 + "s" : "Auto-refresh paused", "success");
2917
+ });
2918
+ prefStart.value = prefs.start;
2919
+ prefStart.addEventListener("change", function () { prefs.start = prefStart.value; savePrefs(); });
2920
+ prefSidebar.addEventListener("click", function () { setCollapsed(!isCollapsed(), true); });
2921
+
2922
+ function bindPrefSwitch(el, key, after) {
2923
+ setSwitch(el, !!prefs[key]);
2924
+ el.addEventListener("click", function () {
2925
+ prefs[key] = !prefs[key];
2926
+ savePrefs();
2927
+ setSwitch(el, prefs[key]);
2928
+ if (after) after();
2929
+ });
2930
+ }
2931
+ bindPrefSwitch(prefConfirm, "confirm");
2932
+ bindPrefSwitch(prefInsecure, "insecure", applyHostDefaults);
2933
+ bindPrefSwitch(prefCors, "cors", applyHostDefaults);
2934
+
2935
+ var tokenShown = false;
2936
+ tokenReveal.addEventListener("click", function () {
2937
+ tokenShown = !tokenShown;
2938
+ tokenValue.textContent = tokenShown ? TOKEN : "••••••••••••••••";
2939
+ tokenReveal.textContent = tokenShown ? "Hide" : "Show";
2940
+ });
2941
+ tokenCopy.addEventListener("click", function () { copyText(TOKEN, tokenCopy); });
2942
+ dangerClearLogs.addEventListener("click", function () { logsClearBtn.click(); });
2943
+
2944
+ // ---------- dashboard password ----------
2945
+
2946
+ var uiAuthForm = document.getElementById("ui-auth-form");
2947
+ var uiAuthBadge = document.getElementById("ui-auth-badge");
2948
+ var uiAuthCurrentField = document.getElementById("ui-auth-current-field");
2949
+ var uiAuthCurrent = document.getElementById("ui-auth-current");
2950
+ var uiAuthNew = document.getElementById("ui-auth-new");
2951
+ var uiAuthRepeat = document.getElementById("ui-auth-repeat");
2952
+ var uiAuthError = document.getElementById("ui-auth-error");
2953
+ var uiAuthSave = document.getElementById("ui-auth-save");
2954
+ var uiAuthRemove = document.getElementById("ui-auth-remove");
2955
+ var signOutBtn = document.getElementById("sb-signout");
2956
+
2957
+ function renderUiAuth(enabled) {
2958
+ UI_AUTH = enabled;
2959
+ uiAuthBadge.textContent = enabled ? "On" : "Off";
2960
+ uiAuthBadge.className = "badge " + (enabled ? "badge-up" : "badge-count");
2961
+ uiAuthCurrentField.hidden = !enabled;
2962
+ uiAuthSave.textContent = enabled ? "Change password" : "Set password";
2963
+ uiAuthRemove.hidden = !enabled;
2964
+ signOutBtn.hidden = !enabled;
2965
+ }
2966
+ renderUiAuth(UI_AUTH);
2967
+
2968
+ function uiAuthFail(message) {
2969
+ uiAuthError.textContent = message;
2970
+ uiAuthError.hidden = false;
2971
+ }
2972
+
2973
+ function sendUiAuth(body, done) {
2974
+ uiAuthError.hidden = true;
2975
+ uiAuthSave.disabled = uiAuthRemove.disabled = true;
2976
+ apiFetch("/api/settings/ui-password", { method: "PUT", body: JSON.stringify(body) })
2977
+ .then(function (res) {
2978
+ uiAuthCurrent.value = uiAuthNew.value = uiAuthRepeat.value = "";
2979
+ renderUiAuth(res.uiAuth.enabled);
2980
+ showToast(done, "success");
2981
+ })
2982
+ .catch(function (err) { uiAuthFail(err.message + (err.hint ? " " + err.hint : "")); })
2983
+ .then(function () { uiAuthSave.disabled = uiAuthRemove.disabled = false; });
2984
+ }
2985
+
2986
+ uiAuthForm.addEventListener("submit", function (e) {
2987
+ e.preventDefault();
2988
+ if (uiAuthNew.value.length < 8) return uiAuthFail("The password must be at least 8 characters.");
2989
+ if (uiAuthNew.value !== uiAuthRepeat.value) return uiAuthFail("The passwords don't match.");
2990
+ if (UI_AUTH && !uiAuthCurrent.value) return uiAuthFail("Enter the current password.");
2991
+ var wasOn = UI_AUTH;
2992
+ sendUiAuth({ password: uiAuthNew.value, current: uiAuthCurrent.value }, wasOn ? "Password changed. Other sessions are signed out." : "The dashboard now asks for this password");
2993
+ });
2994
+
2995
+ uiAuthRemove.addEventListener("click", function () {
2996
+ if (!uiAuthCurrent.value) return uiAuthFail("Enter the current password to remove it.");
2997
+ sendUiAuth({ enabled: false, current: uiAuthCurrent.value }, "Password removed");
2998
+ });
2999
+
3000
+ signOutBtn.addEventListener("click", function () {
3001
+ fetch("/logout", { method: "POST" }).then(function () { location.reload(); });
3002
+ });
3003
+
3004
+ // ---------- polling ----------
3005
+
3006
+ function tickViews() {
3007
+ if (currentView === "overview") { renderOverview(); loadOverviewLogs(); }
3008
+ else renderOverview();
3009
+ if (currentView === "sharing") renderShareList();
3010
+ if (currentView === "logs" && logsAuto.checked) loadLogs();
3011
+ }
3012
+
3013
+ function schedulePoll() {
3014
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
3015
+ if (!prefs.refresh || proxyStopped) return;
3016
+ pollTimer = setInterval(function () {
3017
+ if (!document.hidden && !proxyStopped) refreshAll().then(tickViews);
3018
+ }, prefs.refresh);
3019
+ }
3020
+
3021
+ setCollapsed(isCollapsed(), false);
3022
+ var lastViewSaved = null;
3023
+ try { lastViewSaved = localStorage.getItem("locadot.lastView"); } catch (e) {}
3024
+ showView(viewFromHash() || (prefs.start === "last" ? lastViewSaved : prefs.start) || "overview");
3025
+ schedulePoll();
3026
+
3027
+ refreshAll().then(tickViews);
3028
+ refreshHubAndRemotes();
1664
3029
  })();
1665
3030
  `;
1666
- function renderPage(nonce, token) {
3031
+ function renderPage(nonce, token, uiAuth = false) {
1667
3032
  // The nonce and token are server-generated but still escaped before landing
1668
3033
  // in HTML attributes, defensively.
1669
3034
  const safeNonce = (0, escape_1.escapeHtml)(nonce);
@@ -1675,17 +3040,40 @@ function renderPage(nonce, token) {
1675
3040
  <meta name="viewport" content="width=device-width, initial-scale=1">
1676
3041
  <meta name="color-scheme" content="dark light">
1677
3042
  <meta name="locadot-token" content="${safeToken}">
3043
+ <meta name="locadot-ui-auth" content="${uiAuth ? "on" : "off"}">
1678
3044
  <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>
3045
+ <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
3046
  <style nonce="${safeNonce}">${css}</style>
1681
3047
  </head>
1682
3048
  <body class="boot">
3049
+ <nav id="sidebar" class="sidebar" aria-label="Sections">
3050
+ <div class="sb-head">
3051
+ <span class="logo" aria-hidden="true"></span>
3052
+ <h1 class="wordmark sb-label">locadot</h1>
3053
+ <span id="version" class="muted mono sb-label"></span>
3054
+ </div>
3055
+ <div class="sb-links">
3056
+ <a class="sb-link" href="#/overview" data-view="overview" title="Overview"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg><span class="sb-label">Overview</span></a>
3057
+ <a class="sb-link" href="#/hosts" data-view="hosts" 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>
3058
+ <a class="sb-link" href="#/sharing" data-view="sharing" 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><span id="sb-shares-count" class="sb-count sb-label" hidden></span></a>
3059
+ <div class="sb-section sb-label">Remote</div>
3060
+ <a class="sb-link" href="#/remote" data-view="remote" 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><span id="sb-hub-dot" class="dot sb-dot" hidden></span></a>
3061
+ <a class="sb-link" href="#/machines" data-view="machines" 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>
3062
+ <div class="sb-section sb-label">System</div>
3063
+ <a class="sb-link" href="#/logs" data-view="logs" 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>
3064
+ <a class="sb-link" href="#/settings" data-view="settings" title="Settings"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span class="sb-label">Settings</span></a>
3065
+ </div>
3066
+ <button type="button" id="sb-signout" class="sb-link sb-collapse sb-signout" title="Sign out" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg><span class="sb-label">Sign out</span></button>
3067
+ <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>
3068
+ </nav>
3069
+ <div id="sb-backdrop" class="sb-backdrop" hidden></div>
3070
+ <div class="shell">
1683
3071
  <header>
1684
3072
  <div class="topbar">
1685
3073
  <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>
3074
+ <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>
3075
+ <span class="logo brand-mobile" aria-hidden="true"></span>
3076
+ <span class="wordmark brand-mobile">locadot</span>
1689
3077
  </div>
1690
3078
  <div class="header-meta">
1691
3079
  <span class="chip chip-live"><span id="live-dot" class="live-dot"></span> <span id="live-label" class="live-label">live</span></span>
@@ -1709,13 +3097,56 @@ function renderPage(nonce, token) {
1709
3097
  </div>
1710
3098
  </div>
1711
3099
 
1712
- <div class="page-head">
1713
- <h2 class="page-title">Dashboard</h2>
1714
- <p class="page-sub">Local hostnames proxied to your dev servers, with optional public sharing.</p>
1715
- </div>
1716
-
1717
- <div class="layout">
1718
- <div class="col-primary">
3100
+ <section class="view" data-view="overview" aria-label="Overview">
3101
+ <div class="page-head">
3102
+ <h2 class="page-title">Overview</h2>
3103
+ <p class="page-sub">Everything locadot is doing on this machine at a glance.</p>
3104
+ </div>
3105
+ <div class="tiles">
3106
+ <a class="tile" href="#/hosts"><span class="label">Hosts</span><span id="ov-hosts" class="tile-big">—</span><span id="ov-hosts-sub" class="tile-sub"></span></a>
3107
+ <a class="tile" href="#/hosts"><span class="label">Down</span><span id="ov-down" class="tile-big">—</span><span class="tile-sub">targets not answering</span></a>
3108
+ <a class="tile" href="#/hosts"><span class="label">Requests</span><span id="ov-hits" class="tile-big">—</span><span id="ov-hits-sub" class="tile-sub">since the proxy started</span></a>
3109
+ <a class="tile" href="#/sharing"><span class="label">Public shares</span><span id="ov-shares" class="tile-big">—</span><span id="ov-shares-sub" class="tile-sub"></span></a>
3110
+ <a class="tile" href="#/remote"><span class="label">Remote access</span><span id="ov-hub" class="tile-big tile-word">—</span><span id="ov-hub-sub" class="tile-sub"></span></a>
3111
+ <a class="tile" href="#/machines"><span class="label">Connected machines</span><span id="ov-remotes" class="tile-big">—</span><span id="ov-remotes-sub" class="tile-sub"></span></a>
3112
+ </div>
3113
+ <div class="grid-2">
3114
+ <section class="card" aria-label="Host health">
3115
+ <div class="card-head">
3116
+ <div class="card-title-wrap"><h3 class="card-title">Host health</h3></div>
3117
+ <div class="card-tools"><a class="btn btn-sm" href="#/hosts">Manage hosts</a></div>
3118
+ </div>
3119
+ <div id="ov-host-list" class="ov-list"></div>
3120
+ <div id="ov-host-empty" class="empty" hidden>
3121
+ <span class="empty-icon" aria-hidden="true">+</span>
3122
+ <p>No hosts yet.</p>
3123
+ <a class="btn btn-sm btn-primary" href="#/hosts">Add your first host</a>
3124
+ </div>
3125
+ </section>
3126
+ <section class="card" aria-label="Proxy status">
3127
+ <div class="card-head">
3128
+ <div class="card-title-wrap"><h3 class="card-title">Proxy</h3></div>
3129
+ <div class="card-tools"><a class="btn btn-sm" href="#/settings">Settings</a></div>
3130
+ </div>
3131
+ <div id="ov-checks" class="ov-list"></div>
3132
+ </section>
3133
+ </div>
3134
+ <section class="card" aria-label="Recent activity">
3135
+ <div class="card-head">
3136
+ <div class="card-title-wrap"><h3 class="card-title">Recent activity</h3></div>
3137
+ <div class="card-tools"><a class="btn btn-sm" href="#/logs">All logs</a></div>
3138
+ </div>
3139
+ <pre id="ov-logs" class="logs-box logs-short" aria-live="off"></pre>
3140
+ </section>
3141
+ </section>
3142
+
3143
+ <section class="view" data-view="hosts" aria-label="Hosts" hidden>
3144
+ <div class="page-head">
3145
+ <h2 class="page-title">Hosts</h2>
3146
+ <p class="page-sub">Local hostnames and the dev servers they proxy to.</p>
3147
+ </div>
3148
+ <div class="layout">
3149
+ <div class="col-primary">
1719
3150
  <section id="hosts-card" class="card" aria-label="Registered hosts">
1720
3151
  <div class="card-head">
1721
3152
  <div class="card-title-wrap">
@@ -1760,40 +3191,8 @@ function renderPage(nonce, token) {
1760
3191
  </table>
1761
3192
  </div>
1762
3193
  </section>
1763
-
1764
- <details id="logs-panel" class="card">
1765
- <summary><span class="card-title">Logs</span><span class="card-desc">last 200 lines</span></summary>
1766
- <div class="logs-body">
1767
- <div class="logs-actions">
1768
- <button type="button" id="logs-refresh" class="btn btn-sm">Refresh</button>
1769
- <button type="button" id="logs-clear" class="btn btn-sm btn-danger">Clear</button>
1770
- </div>
1771
- <pre id="logs-box" class="logs-box" aria-live="off"></pre>
1772
- </div>
1773
- </details>
1774
-
1775
- <section id="cli-card" class="card" aria-label="Use from CLI or scripts">
1776
- <div class="card-head">
1777
- <div class="card-title-wrap"><h3 class="card-title">CLI &amp; API</h3></div>
1778
- </div>
1779
- <div class="card-body cli-list">
1780
- <div class="cli-item">
1781
- <div class="cli-item-head"><span class="label">Add a host</span><button type="button" id="cli-add-copy" class="copy-btn">Copy</button></div>
1782
- <pre id="cli-add-pre" class="code-block mono">locadot add --host app.localhost --port 3000</pre>
1783
- </div>
1784
- <div class="cli-item">
1785
- <div class="cli-item-head"><span class="label">HTTP API (scripts, AI agents)</span><button type="button" id="cli-curl-copy" class="copy-btn">Copy</button></div>
1786
- <pre id="cli-curl-pre" class="code-block mono">curl -X POST http://localhost/api/hosts -H "X-Locadot-Token: $(locadot token)" -H 'Content-Type: application/json' -d '{"host":"app.localhost","target":"3000"}'</pre>
1787
- </div>
1788
- <div class="cli-item">
1789
- <div class="cli-item-head"><span class="label">Share publicly</span><button type="button" id="cli-tunnel-copy" class="copy-btn">Copy</button></div>
1790
- <pre id="cli-tunnel-pre" class="code-block mono">locadot tunnel --host app.localhost</pre>
1791
- </div>
1792
- </div>
1793
- </section>
1794
- </div>
1795
-
1796
- <aside class="col-secondary">
3194
+ </div>
3195
+ <aside class="col-secondary">
1797
3196
  <section id="add-card" class="card" aria-label="Add proxy host">
1798
3197
  <div class="card-head">
1799
3198
  <div class="card-title-wrap"><h3 class="card-title">Add host</h3></div>
@@ -1823,16 +3222,311 @@ function renderPage(nonce, token) {
1823
3222
  <div id="add-error" class="field-error" role="alert" hidden></div>
1824
3223
  </form>
1825
3224
  </section>
3225
+ </aside>
3226
+ </div>
3227
+ </section>
1826
3228
 
1827
- <section id="system-card" class="card" aria-label="System status">
1828
- <div class="card-head">
1829
- <div class="card-title-wrap"><h3 class="card-title">System</h3></div>
1830
- <div class="card-tools">
1831
- <button type="button" id="stop-proxy-btn" class="btn btn-sm btn-ghost-danger">Stop proxy</button>
3229
+ <section class="view" data-view="sharing" aria-label="Public sharing" hidden>
3230
+ <div class="page-head">
3231
+ <h2 class="page-title">Public sharing</h2>
3232
+ <p class="page-sub">Put a single host on a public https://*.trycloudflare.com URL. Anyone with the link can reach it.</p>
3233
+ </div>
3234
+ <div class="layout">
3235
+ <div class="col-primary">
3236
+ <section id="share-card" class="card" aria-label="Hosts you can share">
3237
+ <div class="card-head">
3238
+ <div class="card-title-wrap"><h3 class="card-title">Hosts</h3><span id="share-count" class="badge badge-count" hidden></span></div>
3239
+ </div>
3240
+ <div id="share-list" class="ov-list"></div>
3241
+ <div id="share-empty" class="empty" hidden>
3242
+ <span class="empty-icon" aria-hidden="true">+</span>
3243
+ <p>Add a host first, then share it from here.</p>
1832
3244
  </div>
3245
+ </section>
3246
+ </div>
3247
+ <aside class="col-secondary">
3248
+ <section id="sharing-card" class="card" aria-label="Public sharing">
3249
+ <div class="card-head">
3250
+ <div class="card-title-wrap"><h3 class="card-title">Cloudflare Tunnel</h3></div>
1833
3251
  </div>
1834
3252
  <div class="stat-list">
1835
3253
  <div class="stat-row">
3254
+ <div class="stat-main">
3255
+ <div class="label">Cloudflare Tunnel</div>
3256
+ <div id="tile-cloudflared-value" class="tile-value">—</div>
3257
+ <div id="tile-cloudflared-sub" class="tile-sub mono"></div>
3258
+ <div class="tile-sub">Share a host on a public https://*.trycloudflare.com URL with the Share button. No Cloudflare account needed.</div>
3259
+ <div class="tile-actions">
3260
+ <button type="button" id="cloudflared-install-btn" class="btn btn-sm btn-primary" hidden>Install cloudflared</button>
3261
+ </div>
3262
+ </div>
3263
+ </div>
3264
+ </div>
3265
+ </section>
3266
+ </aside>
3267
+ </div>
3268
+ </section>
3269
+
3270
+ <section class="view" data-view="remote" aria-label="Remote access" hidden>
3271
+ <div class="page-head">
3272
+ <h2 class="page-title">Remote access</h2>
3273
+ <p class="page-sub">Let other locadots use this machine's hosts through a Cloudflare hostname.</p>
3274
+ </div>
3275
+ <div class="layout">
3276
+ <div class="col-primary">
3277
+ <section id="hub-card" class="card" aria-label="Remote access">
3278
+ <div class="card-head">
3279
+ <div class="card-title-wrap"><h3 class="card-title">Remote access</h3></div>
3280
+ </div>
3281
+ <div class="hub-section">
3282
+ <div class="hub-row">
3283
+ <span class="pill"><span id="hub-dot" class="dot"></span><span id="hub-status-text">Off</span></span>
3284
+ </div>
3285
+ <div id="hub-url-row" class="hub-row" hidden>
3286
+ <span id="hub-url" class="hub-url"></span>
3287
+ <button type="button" id="hub-url-copy" class="copy-btn">Copy</button>
3288
+ </div>
3289
+ <div id="hub-login-row" class="hub-row" hidden>
3290
+ <a id="hub-login-link" href="#" target="_blank" rel="noopener noreferrer">Log in to Cloudflare</a>
3291
+ </div>
3292
+ <div id="hub-quick-warning" class="hub-warn" hidden>URL changes when locadot restarts &mdash; receivers must update it.</div>
3293
+ <div id="hub-error-row" class="hub-warn" hidden></div>
3294
+
3295
+ <form id="hub-named-form" class="hub-form-row" novalidate>
3296
+ <div class="field">
3297
+ <label for="hub-domain">Domain</label>
3298
+ <input type="text" id="hub-domain" placeholder="hub.example.com" autocomplete="off">
3299
+ </div>
3300
+ <div class="field">
3301
+ <label for="hub-tunnel-name">Tunnel name</label>
3302
+ <input type="text" id="hub-tunnel-name" placeholder="locadot (optional)" autocomplete="off">
3303
+ </div>
3304
+ <button type="submit" id="hub-named-submit" class="btn btn-sm btn-primary">Use my domain</button>
3305
+ </form>
3306
+ <div class="hub-actions">
3307
+ <button type="button" id="hub-quick-btn" class="btn btn-sm">Use quick tunnel</button>
3308
+ <button type="button" id="hub-stop-btn" class="btn btn-sm btn-danger" hidden>Stop</button>
3309
+ </div>
3310
+ <div id="hub-localhost-row" class="toggle-row" hidden>
3311
+ <div>
3312
+ <div class="label">Admins can open any port</div>
3313
+ <div class="tile-sub">Admin peers reach this machine's localhost at <span class="mono">&lt;port&gt;.&lt;domain&gt;.localhost</span> on their side.</div>
3314
+ </div>
3315
+ <button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open any port on this machine"><span class="switch-knob"></span></button>
3316
+ </div>
3317
+ </div>
3318
+
3319
+ <div id="hub-share-section" class="hub-section" hidden>
3320
+ <div class="label">Create pairing link</div>
3321
+ <div class="hub-form-row">
3322
+ <div class="field">
3323
+ <label for="invite-role">Role</label>
3324
+ <select id="invite-role" class="input input-sm">
3325
+ <option value="viewer">Viewer</option>
3326
+ <option value="editor">Editor</option>
3327
+ <option value="admin">Admin</option>
3328
+ </select>
3329
+ </div>
3330
+ <button type="button" id="invite-create-btn" class="btn btn-sm btn-primary">Create pairing link</button>
3331
+ </div>
3332
+ <div id="invite-hosts-wrap" class="host-checks" hidden></div>
3333
+ <div id="invite-result" class="invite-result" hidden>
3334
+ <div class="invite-result-row">
3335
+ <span id="invite-string" class="invite-string mono"></span>
3336
+ <button type="button" id="invite-copy-btn" class="copy-btn">Copy</button>
3337
+ </div>
3338
+ <div id="invite-countdown" class="invite-countdown"></div>
3339
+ </div>
3340
+ </div>
3341
+
3342
+ <div class="hub-section">
3343
+ <div class="label">Peers</div>
3344
+ <div id="peers-empty" class="dim" hidden>No peers yet.</div>
3345
+ <div id="peers-list" class="peer-list"></div>
3346
+ </div>
3347
+ <div class="hub-section">
3348
+ <div class="label">Pending invites</div>
3349
+ <div id="invites-empty" class="dim" hidden>No pending invites.</div>
3350
+ <div id="invites-list" class="invite-list"></div>
3351
+ </div>
3352
+ </section>
3353
+ </div>
3354
+ <aside class="col-secondary">
3355
+ <section class="card" aria-label="Roles">
3356
+ <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">How it works</h3></div></div>
3357
+ <div class="card-body help-list">
3358
+ <p>1. Turn on a tunnel: your own domain keeps the same address; a quick tunnel is free but its URL changes on restart.</p>
3359
+ <p>2. Create a pairing link. It works once and expires after 5 minutes.</p>
3360
+ <p>3. The other machine pastes it under <a href="#/machines">Connected machines</a>.</p>
3361
+ <div class="role-table">
3362
+ <div><strong>Viewer</strong><span>browse the hosts you pick</span></div>
3363
+ <div><strong>Editor</strong><span>also add and change hosts here</span></div>
3364
+ <div><strong>Admin</strong><span>also delete hosts, change sharing, and open any port on this machine</span></div>
3365
+ </div>
3366
+ <p class="dim">Editors can reach anything this machine can. Change or revoke a role any time under Peers.</p>
3367
+ </div>
3368
+ </section>
3369
+ </aside>
3370
+ </div>
3371
+ </section>
3372
+
3373
+ <section class="view" data-view="machines" aria-label="Connected machines" hidden>
3374
+ <div class="page-head">
3375
+ <h2 class="page-title">Connected machines</h2>
3376
+ <p class="page-sub">Other locadots you've connected to. Their hosts show up in your Hosts list.</p>
3377
+ </div>
3378
+
3379
+ <section id="remotes-card" class="card" aria-label="Connected machines">
3380
+ <div class="card-head">
3381
+ <div class="card-title-wrap"><h3 class="card-title">Connected machines</h3></div>
3382
+ </div>
3383
+ <div class="hub-section">
3384
+ <form id="connect-form" class="hub-form-row" novalidate>
3385
+ <div class="field">
3386
+ <label for="connect-string">Pairing link</label>
3387
+ <input type="text" id="connect-string" placeholder="https://hub.example.com/#lnk_..." autocomplete="off">
3388
+ </div>
3389
+ <div class="field">
3390
+ <label for="connect-name">Name (optional)</label>
3391
+ <input type="text" id="connect-name" placeholder="my-mac" autocomplete="off">
3392
+ </div>
3393
+ <div class="field">
3394
+ <label for="connect-domain">Localhost domain (admins)</label>
3395
+ <input type="text" id="connect-domain" placeholder="auto: two random words" autocomplete="off">
3396
+ </div>
3397
+ <button type="submit" id="connect-submit" class="btn btn-sm btn-primary">Connect</button>
3398
+ </form>
3399
+ <div id="connect-error" class="field-error" role="alert" hidden></div>
3400
+ </div>
3401
+ <div id="remotes-empty" class="empty" hidden>
3402
+ <span class="empty-icon" aria-hidden="true">~</span>
3403
+ <p>Not connected to anything yet. Paste a pairing link above.</p>
3404
+ </div>
3405
+ <div id="remotes-list" class="remote-list"></div>
3406
+ </section>
3407
+ </section>
3408
+
3409
+ <section class="view" data-view="logs" aria-label="Logs" hidden>
3410
+ <div class="page-head">
3411
+ <h2 class="page-title">Logs</h2>
3412
+ <p class="page-sub">The proxy's log file.</p>
3413
+ </div>
3414
+ <section id="logs-panel" class="card" aria-label="Logs">
3415
+ <div class="card-head logs-head">
3416
+ <div class="card-tools logs-tools">
3417
+ <input type="search" id="logs-filter" class="input input-sm input-search" placeholder="Filter lines" aria-label="Filter log lines" autocomplete="off">
3418
+ <select id="logs-level" class="input input-sm" aria-label="Level">
3419
+ <option value="">All levels</option>
3420
+ <option value="error">Errors</option>
3421
+ <option value="warn">Warnings+</option>
3422
+ <option value="info">Info+</option>
3423
+ </select>
3424
+ <select id="logs-lines" class="input input-sm" aria-label="Lines">
3425
+ <option value="200">Last 200</option>
3426
+ <option value="500">Last 500</option>
3427
+ <option value="1000">Last 1000</option>
3428
+ </select>
3429
+ <label class="check-inline"><input type="checkbox" id="logs-auto" checked> Live</label>
3430
+ </div>
3431
+ <div class="card-tools">
3432
+ <button type="button" id="logs-refresh" class="btn btn-sm">Refresh</button>
3433
+ <button type="button" id="logs-download" class="btn btn-sm">Download</button>
3434
+ <button type="button" id="logs-clear" class="btn btn-sm btn-danger">Clear</button>
3435
+ </div>
3436
+ </div>
3437
+ <pre id="logs-box" class="logs-box logs-full" aria-live="off"></pre>
3438
+ <div id="logs-meta" class="logs-meta dim"></div>
3439
+ </section>
3440
+ </section>
3441
+
3442
+ <section class="view" data-view="settings" aria-label="Settings" hidden>
3443
+ <div class="page-head">
3444
+ <h2 class="page-title">Settings</h2>
3445
+ <p class="page-sub">Proxy, security and dashboard preferences.</p>
3446
+ </div>
3447
+ <div class="layout">
3448
+ <div class="col-primary">
3449
+ <section id="proxy-settings-card" class="card" aria-label="Proxy settings">
3450
+ <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">Proxy</h3></div></div>
3451
+ <form id="ports-form" class="card-body" novalidate>
3452
+ <div class="form-row-2">
3453
+ <div class="field"><label for="set-http-port">HTTP port</label><input type="number" id="set-http-port" class="input" min="1" max="65535" required></div>
3454
+ <div class="field"><label for="set-https-port">HTTPS port</label><input type="number" id="set-https-port" class="input" min="1" max="65535" required></div>
3455
+ </div>
3456
+ <div id="ports-note" class="tile-sub"></div>
3457
+ <div id="ports-error" class="field-error" role="alert" hidden></div>
3458
+ <div class="form-actions">
3459
+ <button type="submit" id="ports-save" class="btn btn-sm btn-primary">Save ports</button>
3460
+ <button type="button" id="restart-proxy-btn" class="btn btn-sm">Restart proxy</button>
3461
+ </div>
3462
+ <div id="restart-note" class="hub-warn" hidden>Saved. Restart the proxy to use the new ports.</div>
3463
+ </form>
3464
+ <div class="stat-list">
3465
+ <div class="stat-row"><div class="stat-main"><div class="label">Listening on</div><div id="set-bind" class="tile-value mono">—</div><div id="set-bind-note" class="tile-sub"></div></div></div>
3466
+ <div class="stat-row"><div class="stat-main"><div class="label">State directory</div><div id="set-home" class="tile-value mono">—</div></div><div class="stat-side"><button type="button" id="set-home-copy" class="copy-btn">Copy</button></div></div>
3467
+ <div class="stat-row"><div class="stat-main"><div class="label">Log level</div><div id="set-loglevel" class="tile-value mono">—</div><div class="tile-sub">Set LOCADOT_LOG_LEVEL before starting to change it.</div></div></div>
3468
+ </div>
3469
+ </section>
3470
+
3471
+ <section class="card" aria-label="Dashboard preferences">
3472
+ <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">Dashboard</h3></div></div>
3473
+ <div class="stat-list">
3474
+ <div class="stat-row"><div class="stat-main"><div class="label">Theme</div><div class="tile-sub">Follow the system, or pick one.</div></div>
3475
+ <div class="stat-side"><div id="pref-theme" class="segmented" role="radiogroup" aria-label="Theme">
3476
+ <button type="button" role="radio" data-value="system">System</button><button type="button" role="radio" data-value="light">Light</button><button type="button" role="radio" data-value="dark">Dark</button>
3477
+ </div></div></div>
3478
+ <div class="stat-row"><div class="stat-main"><div class="label">Refresh every</div><div class="tile-sub">How often hosts and status are polled.</div></div>
3479
+ <div class="stat-side"><select id="pref-refresh" class="input input-sm" aria-label="Refresh interval">
3480
+ <option value="2000">2 seconds</option><option value="5000">5 seconds</option><option value="10000">10 seconds</option><option value="30000">30 seconds</option><option value="0">Paused</option>
3481
+ </select></div></div>
3482
+ <div class="stat-row"><div class="stat-main"><div class="label">Open on</div><div class="tile-sub">The page shown when the dashboard opens.</div></div>
3483
+ <div class="stat-side"><select id="pref-start" class="input input-sm" aria-label="Start page">
3484
+ <option value="overview">Overview</option><option value="hosts">Hosts</option><option value="last">Last visited</option>
3485
+ </select></div></div>
3486
+ <div class="stat-row"><div class="stat-main"><div class="label">Collapsed sidebar</div><div class="tile-sub">Show only icons in the sidebar.</div></div>
3487
+ <div class="stat-side"><button type="button" id="pref-sidebar" class="switch" role="switch" aria-checked="false" aria-label="Collapsed sidebar"><span class="switch-knob"></span></button></div></div>
3488
+ <div class="stat-row"><div class="stat-main"><div class="label">Ask before removing</div><div class="tile-sub">Confirm before a host is removed.</div></div>
3489
+ <div class="stat-side"><button type="button" id="pref-confirm" class="switch" role="switch" aria-checked="true" aria-label="Ask before removing"><span class="switch-knob"></span></button></div></div>
3490
+ </div>
3491
+ </section>
3492
+
3493
+ <section class="card" aria-label="New host defaults">
3494
+ <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">New host defaults</h3></div></div>
3495
+ <div class="stat-list">
3496
+ <div class="stat-row"><div class="stat-main"><div class="label">Insecure TLS</div><div class="tile-sub">Accept self-signed certificates on https targets.</div></div>
3497
+ <div class="stat-side"><button type="button" id="pref-insecure" class="switch" role="switch" aria-checked="false" aria-label="Insecure TLS by default"><span class="switch-knob"></span></button></div></div>
3498
+ <div class="stat-row"><div class="stat-main"><div class="label">Bypass CORS</div><div class="tile-sub">Send the target's own Origin and allow any origin.</div></div>
3499
+ <div class="stat-side"><button type="button" id="pref-cors" class="switch" role="switch" aria-checked="false" aria-label="Bypass CORS by default"><span class="switch-knob"></span></button></div></div>
3500
+ </div>
3501
+ </section>
3502
+
3503
+ <section id="cli-card" class="card" aria-label="Use from CLI or scripts">
3504
+ <div class="card-head">
3505
+ <div class="card-title-wrap"><h3 class="card-title">CLI &amp; API</h3></div>
3506
+ </div>
3507
+ <div class="card-body cli-list">
3508
+ <div class="cli-item">
3509
+ <div class="cli-item-head"><span class="label">Add a host</span><button type="button" id="cli-add-copy" class="copy-btn">Copy</button></div>
3510
+ <pre id="cli-add-pre" class="code-block mono">locadot add --host app.localhost --port 3000</pre>
3511
+ </div>
3512
+ <div class="cli-item">
3513
+ <div class="cli-item-head"><span class="label">HTTP API (scripts, AI agents)</span><button type="button" id="cli-curl-copy" class="copy-btn">Copy</button></div>
3514
+ <pre id="cli-curl-pre" class="code-block mono">curl -X POST http://localhost/api/hosts -H "X-Locadot-Token: $(locadot token)" -H 'Content-Type: application/json' -d '{"host":"app.localhost","target":"3000"}'</pre>
3515
+ </div>
3516
+ <div class="cli-item">
3517
+ <div class="cli-item-head"><span class="label">Share publicly</span><button type="button" id="cli-tunnel-copy" class="copy-btn">Copy</button></div>
3518
+ <pre id="cli-tunnel-pre" class="code-block mono">locadot tunnel --host app.localhost</pre>
3519
+ </div>
3520
+ </div>
3521
+ </section>
3522
+ </div>
3523
+ <aside class="col-secondary">
3524
+ <section id="system-card" class="card" aria-label="System status">
3525
+ <div class="card-head">
3526
+ <div class="card-title-wrap"><h3 class="card-title">System &amp; security</h3></div>
3527
+ </div>
3528
+ <div class="stat-list">
3529
+ <div class="stat-row" hidden>
1836
3530
  <div class="stat-main">
1837
3531
  <div class="label">Proxy</div>
1838
3532
  <div id="tile-proxy-ports" class="tile-value mono">—</div>
@@ -1875,31 +3569,48 @@ function renderPage(nonce, token) {
1875
3569
  </div>
1876
3570
  </section>
1877
3571
 
1878
- <section id="sharing-card" class="card" aria-label="Public sharing">
1879
- <div class="card-head">
1880
- <div class="card-title-wrap"><h3 class="card-title">Sharing</h3></div>
1881
- </div>
1882
- <div class="stat-list">
1883
- <div class="stat-row">
1884
- <div class="stat-main">
1885
- <div class="label">Cloudflare Tunnel</div>
1886
- <div id="tile-cloudflared-value" class="tile-value">—</div>
1887
- <div id="tile-cloudflared-sub" class="tile-sub mono"></div>
1888
- <div class="tile-sub">Share a host on a public https://*.trycloudflare.com URL with the Share button. No Cloudflare account needed.</div>
1889
- <div class="tile-actions">
1890
- <button type="button" id="cloudflared-install-btn" class="btn btn-sm btn-primary" hidden>Install cloudflared</button>
1891
- </div>
3572
+ <section id="ui-auth-card" class="card" aria-label="Dashboard password">
3573
+ <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">Dashboard password</h3><span id="ui-auth-badge" class="badge">Off</span></div></div>
3574
+ <form id="ui-auth-form" class="card-body token-body" novalidate>
3575
+ <div id="ui-auth-note" class="tile-sub">Ask for a password before the dashboard opens. Only the dashboard is protected: your hosts and scripts using the API token work as before. Forgot it? Run <code class="mono">locadot ui:password</code> in a terminal.</div>
3576
+ <div id="ui-auth-current-field" class="field" hidden><label for="ui-auth-current">Current password</label><input type="password" id="ui-auth-current" class="input" autocomplete="current-password"></div>
3577
+ <div class="form-row-2">
3578
+ <div class="field"><label for="ui-auth-new">New password</label><input type="password" id="ui-auth-new" class="input" autocomplete="new-password" minlength="8" placeholder="8+ characters"></div>
3579
+ <div class="field"><label for="ui-auth-repeat">Repeat it</label><input type="password" id="ui-auth-repeat" class="input" autocomplete="new-password"></div>
1892
3580
  </div>
3581
+ <div id="ui-auth-error" class="field-error" role="alert" hidden></div>
3582
+ <div class="form-actions">
3583
+ <button type="submit" id="ui-auth-save" class="btn btn-sm btn-primary">Set password</button>
3584
+ <button type="button" id="ui-auth-remove" class="btn btn-sm btn-danger" hidden>Remove password</button>
3585
+ </div>
3586
+ </form>
3587
+ </section>
3588
+
3589
+ <section class="card" aria-label="API token">
3590
+ <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">API token</h3></div></div>
3591
+ <div class="card-body token-body">
3592
+ <div class="tile-sub">Scripts send it as <code class="mono">X-Locadot-Token</code>. It changes every time the proxy starts.</div>
3593
+ <div class="token-row"><code id="token-value" class="mono token-value">••••••••••••••••</code>
3594
+ <button type="button" id="token-reveal" class="copy-btn">Show</button>
3595
+ <button type="button" id="token-copy" class="copy-btn">Copy</button></div>
1893
3596
  </div>
1894
- </div>
1895
- </section>
3597
+ </section>
1896
3598
 
1897
- </aside>
1898
- </div>
3599
+ <section class="card danger-card" aria-label="Danger zone">
3600
+ <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">Danger zone</h3></div></div>
3601
+ <div class="stat-list">
3602
+ <div class="stat-row"><div class="stat-main"><div class="label">Clear logs</div><div class="tile-sub">Empty the log file.</div></div><div class="stat-side"><button type="button" id="danger-clear-logs" class="btn btn-sm btn-danger">Clear</button></div></div>
3603
+ <div class="stat-row"><div class="stat-main"><div class="label">Stop proxy</div><div class="tile-sub">All hosts stop working until <code class="mono">locadot start</code>.</div></div><div class="stat-side"><button type="button" id="stop-proxy-btn" class="btn btn-sm btn-danger">Stop</button></div></div>
3604
+ </div>
3605
+ </section>
3606
+ </aside>
3607
+ </div>
3608
+ </section>
1899
3609
  </main>
1900
3610
  <footer>
1901
3611
  <a href="https://github.com/avinashid/locadot" target="_blank" rel="noopener noreferrer">github.com/avinashid/locadot</a>
1902
3612
  </footer>
3613
+ </div>
1903
3614
  <div id="toast-container" class="toast-container" aria-live="polite"></div>
1904
3615
  <script nonce="${safeNonce}">${clientJs}</script>
1905
3616
  </body>