locadot 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,3619 +2,26 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderPage = renderPage;
4
4
  const escape_1 = require("./escape");
5
+ const theme_1 = require("./ui/theme");
6
+ const core_1 = require("./ui/core");
7
+ const shell_1 = require("./ui/shell");
8
+ const hosts_1 = require("./ui/hosts");
9
+ const remote_1 = require("./ui/remote");
10
+ const system_1 = require("./ui/system");
5
11
  // All data on the page is populated client-side via /api/status + /api/hosts
6
12
  // (and mutated via /api/*) and rendered with DOM APIs (never innerHTML), so
7
13
  // nothing server-supplied besides the per-response nonce and the auth token
8
14
  // is interpolated into this markup.
9
- const lightVars = `
10
- --bg: #f6f7f9;
11
- --surface: #ffffff;
12
- --surface-2: #f4f5f7;
13
- --surface-3: #eceef1;
14
- --border: #e5e7eb;
15
- --border-strong: #d4d8de;
16
- --fg: #111318;
17
- --fg-2: #3a3f47;
18
- --muted: #6b7280;
19
- --muted-dim: #9ca3af;
20
- --accent: #2563eb;
21
- --accent-strong: #1d4ed8;
22
- --accent-bg: rgba(37, 99, 235, 0.09);
23
- --accent-fg: #ffffff;
24
- --up: #16a34a;
25
- --up-bg: rgba(22, 163, 74, 0.1);
26
- --down: #dc2626;
27
- --down-bg: rgba(220, 38, 38, 0.08);
28
- --warn: #d97706;
29
- --warn-bg: rgba(217, 119, 6, 0.1);
30
- --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05);
31
- --shadow-md: 0 12px 32px rgba(16, 24, 40, 0.14);
32
- color-scheme: light;
33
- `;
34
- const css = `
35
- :root {
36
- --bg: #0a0b0d;
37
- --surface: #121417;
38
- --surface-2: #181b1f;
39
- --surface-3: #1f2329;
40
- --border: #23272e;
41
- --border-strong: #2f343c;
42
- --fg: #ededef;
43
- --fg-2: #c9cdd3;
44
- --muted: #8a9099;
45
- --muted-dim: #5c626c;
46
- --accent: #3b82f6;
47
- --accent-strong: #60a5fa;
48
- --accent-bg: rgba(59, 130, 246, 0.14);
49
- --accent-fg: #ffffff;
50
- --up: #22c55e;
51
- --up-bg: rgba(34, 197, 94, 0.14);
52
- --down: #ef4444;
53
- --down-bg: rgba(239, 68, 68, 0.14);
54
- --warn: #f59e0b;
55
- --warn-bg: rgba(245, 158, 11, 0.14);
56
- --focus: var(--accent-strong);
57
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
58
- --shadow-md: 0 12px 32px rgba(0, 0, 0, 0.45);
59
- --radius: 12px;
60
- --radius-sm: 8px;
61
- --radius-xs: 6px;
62
- --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
63
- color-scheme: dark;
64
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, "Helvetica Neue", sans-serif;
65
- }
66
- @media (prefers-color-scheme: light) {
67
- :root:not([data-theme="dark"]) {${lightVars}}
68
- }
69
- :root[data-theme="light"] {${lightVars}}
70
- * { box-sizing: border-box; }
71
- html, body { height: 100%; }
72
- body {
73
- margin: 0;
74
- font-family: var(--sans);
75
- font-size: 14px;
76
- color: var(--fg);
77
- line-height: 1.5;
78
- background: var(--bg);
79
- min-height: 100vh;
80
- -webkit-font-smoothing: antialiased;
81
- }
82
- .mono { font-family: var(--mono); }
83
- a { color: var(--accent); text-decoration: none; }
84
- a:hover, a:focus-visible { text-decoration: underline; }
85
- button, input { font-family: inherit; }
86
- *:focus-visible {
87
- outline: 2px solid var(--focus);
88
- outline-offset: 2px;
89
- border-radius: 4px;
90
- }
91
- .visually-hidden {
92
- position: absolute;
93
- width: 1px; height: 1px;
94
- padding: 0; margin: -1px;
95
- overflow: hidden;
96
- clip: rect(0, 0, 0, 0);
97
- white-space: nowrap;
98
- border: 0;
99
- }
100
- .clip-helper { position: absolute; left: -9999px; top: -9999px; }
101
- .muted { color: var(--muted); }
102
- .dim { color: var(--muted-dim); }
103
- .label {
104
- font-size: 12px;
105
- color: var(--muted);
106
- font-weight: 500;
107
- letter-spacing: 0.01em;
108
- }
109
-
110
- /* ---------- top bar ---------- */
111
- header {
112
- position: sticky;
113
- top: 0;
114
- z-index: 5;
115
- background: var(--surface);
116
- border-bottom: 1px solid var(--border);
117
- }
118
- .topbar {
119
- max-width: 1360px;
120
- margin: 0 auto;
121
- padding: 0 32px;
122
- height: 56px;
123
- display: flex;
124
- align-items: center;
125
- justify-content: space-between;
126
- gap: 16px;
127
- }
128
- .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
129
- .logo {
130
- width: 24px; height: 24px;
131
- border-radius: 7px;
132
- background: var(--accent);
133
- position: relative;
134
- flex-shrink: 0;
135
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
136
- }
137
- .logo::after {
138
- content: "";
139
- position: absolute;
140
- left: 8px; top: 8px;
141
- width: 8px; height: 8px;
142
- border-radius: 50%;
143
- background: #fff;
144
- }
145
- .wordmark {
146
- font-size: 15px;
147
- font-weight: 600;
148
- margin: 0;
149
- letter-spacing: -0.01em;
150
- color: var(--fg);
151
- }
152
- #version { font-size: 12px; color: var(--muted); }
153
- .header-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; }
154
- .btn.theme-toggle { width: 28px; height: 28px; padding: 0; border-radius: 999px; color: var(--muted); flex-shrink: 0; }
155
- .btn.theme-toggle:hover { color: var(--fg); }
156
- .theme-toggle svg { width: 15px; height: 15px; flex-shrink: 0; display: none; }
157
- .theme-toggle[data-mode="system"] .i-system, .theme-toggle[data-mode="light"] .i-light, .theme-toggle[data-mode="dark"] .i-dark { display: block; }
158
- #updated { color: var(--muted-dim); font-size: 12px; }
159
- .chip {
160
- display: inline-flex;
161
- align-items: center;
162
- gap: 6px;
163
- height: 26px;
164
- padding: 0 10px;
165
- border-radius: 999px;
166
- border: 1px solid var(--border);
167
- background: var(--surface-2);
168
- font-size: 12px;
169
- color: var(--muted);
170
- white-space: nowrap;
171
- }
172
- .chip:empty { display: none; }
173
- .chip-live { color: var(--fg-2); font-weight: 500; }
174
- .live-dot {
175
- display: inline-block;
176
- width: 7px; height: 7px;
177
- border-radius: 50%;
178
- background: var(--muted-dim);
179
- }
180
- .live-dot.up { background: var(--up); box-shadow: 0 0 0 3px var(--up-bg); }
181
- .live-dot.down { background: var(--down); box-shadow: 0 0 0 3px var(--down-bg); }
182
- body.offline .live-label { color: var(--down); }
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
-
252
- /* ---------- page layout ---------- */
253
- main {
254
- max-width: 1360px;
255
- margin: 0 auto;
256
- padding: 32px 32px 48px;
257
- display: flex;
258
- flex-direction: column;
259
- gap: 24px;
260
- }
261
- .page-head { display: flex; flex-direction: column; gap: 4px; }
262
- .page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin: 0; line-height: 1.25; }
263
- .page-sub { margin: 0; color: var(--muted); font-size: 14px; }
264
- .layout {
265
- display: grid;
266
- grid-template-columns: minmax(0, 1fr);
267
- gap: 24px;
268
- align-items: start;
269
- }
270
- .col-primary, .col-secondary { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
271
- @media (min-width: 1100px) {
272
- .layout { grid-template-columns: minmax(0, 1fr) 340px; }
273
- }
274
- @media (min-width: 1400px) {
275
- .layout { grid-template-columns: minmax(0, 1fr) 372px; }
276
- }
277
-
278
- /* ---------- cards ---------- */
279
- .card {
280
- background: var(--surface);
281
- border: 1px solid var(--border);
282
- border-radius: var(--radius);
283
- box-shadow: var(--shadow-sm);
284
- }
285
- .card-head {
286
- display: flex;
287
- align-items: center;
288
- justify-content: space-between;
289
- gap: 12px;
290
- flex-wrap: wrap;
291
- padding: 16px 20px;
292
- border-bottom: 1px solid var(--border);
293
- }
294
- .card-title-wrap { display: flex; align-items: center; gap: 10px; min-width: 0; }
295
- .card-title { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: -0.01em; }
296
- .card-desc { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
297
- .card-tools { display: flex; align-items: center; gap: 8px; }
298
- .card-body { padding: 20px; }
299
- .card-body-tight { padding: 8px 0; }
300
-
301
- /* ---------- badges ---------- */
302
- .badge {
303
- display: inline-flex;
304
- align-items: center;
305
- gap: 5px;
306
- height: 20px;
307
- padding: 0 7px;
308
- border-radius: 999px;
309
- font-size: 11px;
310
- font-weight: 500;
311
- line-height: 1;
312
- letter-spacing: 0.01em;
313
- white-space: nowrap;
314
- border: 1px solid var(--border-strong);
315
- background: var(--surface-2);
316
- color: var(--fg-2);
317
- }
318
- .badge-warn { color: var(--warn); border-color: transparent; background: var(--warn-bg); }
319
- .badge-cors, .badge-accent { color: var(--accent); border-color: transparent; background: var(--accent-bg); }
320
- .badge-up { color: var(--up); border-color: transparent; background: var(--up-bg); }
321
- .badge-down { color: var(--down); border-color: transparent; background: var(--down-bg); }
322
- .badge-count { font-family: var(--mono); color: var(--muted); }
323
- .badges { display: flex; flex-wrap: wrap; gap: 4px; }
324
- td.badges { display: table-cell; }
325
- td.badges .badge + .badge { margin-left: 4px; }
326
-
327
- /* ---------- buttons ---------- */
328
- .btn {
329
- position: relative;
330
- display: inline-flex;
331
- align-items: center;
332
- justify-content: center;
333
- gap: 6px;
334
- height: 32px;
335
- padding: 0 12px;
336
- font-size: 13px;
337
- font-weight: 500;
338
- line-height: 1;
339
- border-radius: var(--radius-sm);
340
- border: 1px solid var(--border-strong);
341
- background: var(--surface);
342
- color: var(--fg);
343
- cursor: pointer;
344
- white-space: nowrap;
345
- box-shadow: var(--shadow-sm);
346
- transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease, color 0.15s ease;
347
- }
348
- .btn:hover:not(:disabled) { background: var(--surface-2); border-color: var(--muted-dim); }
349
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
350
- .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
351
- .btn-sm.btn-icon { width: 28px; padding: 0; color: var(--muted); }
352
- .btn-icon svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
353
- .btn-icon.is-set { color: var(--accent); border-color: transparent; background: var(--accent-bg); }
354
- .btn-block { width: 100%; }
355
- .btn-primary { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
356
- .btn-primary:hover:not(:disabled) { background: var(--accent-strong); border-color: var(--accent-strong); }
357
- .btn-danger { border-color: var(--down); color: var(--down); background: transparent; }
358
- .btn-danger:hover:not(:disabled) { background: var(--down-bg); border-color: var(--down); }
359
- .modal-foot .btn-danger { background: var(--down); border-color: var(--down); color: #fff; }
360
- .modal-foot .btn-danger:hover:not(:disabled) { background: var(--down); filter: brightness(0.92); }
361
- .btn-ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--muted); }
362
- .btn-ghost:hover:not(:disabled) { background: var(--surface-2); border-color: transparent; color: var(--fg); }
363
- .btn-ghost-danger { background: transparent; border-color: transparent; box-shadow: none; color: var(--muted); }
364
- .btn-ghost-danger:hover:not(:disabled) { color: var(--down); background: var(--down-bg); border-color: transparent; }
365
- .row-actions { display: flex; gap: 6px; justify-content: flex-end; }
366
- .row-actions .btn-danger:not(:hover):not(:focus-visible) { border-color: var(--border-strong); color: var(--muted); }
367
- .btn.busy { opacity: 0.7; cursor: wait; }
368
- .btn.busy, #ca-toggle.busy, #startup-toggle.busy { color: transparent !important; pointer-events: none; }
369
- .btn.busy::after {
370
- content: "";
371
- position: absolute;
372
- width: 13px; height: 13px;
373
- top: 50%; left: 50%;
374
- margin: -6.5px 0 0 -6.5px;
375
- border-radius: 50%;
376
- border: 2px solid rgba(127, 127, 127, 0.35);
377
- border-top-color: var(--accent-strong);
378
- animation: spin 0.7s linear infinite;
379
- }
380
- .btn-primary.busy::after { border-color: rgba(255, 255, 255, 0.35); border-top-color: #fff; }
381
- .copy-btn {
382
- display: inline-flex;
383
- align-items: center;
384
- height: 22px;
385
- padding: 0 8px;
386
- font-size: 11px;
387
- font-weight: 500;
388
- border-radius: var(--radius-xs);
389
- border: 1px solid var(--border);
390
- background: var(--surface);
391
- color: var(--muted);
392
- cursor: pointer;
393
- white-space: nowrap;
394
- transition: color 0.15s ease, border-color 0.15s ease;
395
- }
396
- .copy-btn:hover { color: var(--fg); border-color: var(--muted-dim); }
397
-
398
- /* ---------- switch ---------- */
399
- .switch {
400
- position: relative;
401
- width: 36px;
402
- height: 20px;
403
- border-radius: 999px;
404
- border: 1px solid transparent;
405
- background: var(--border-strong);
406
- cursor: pointer;
407
- flex-shrink: 0;
408
- padding: 0;
409
- transition: background 0.15s ease, border-color 0.15s ease;
410
- }
411
- .switch .switch-knob {
412
- position: absolute;
413
- top: 2px; left: 2px;
414
- width: 14px; height: 14px;
415
- border-radius: 50%;
416
- background: #fff;
417
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
418
- transition: transform 0.15s ease, background 0.15s ease;
419
- }
420
- .switch.on { background: var(--accent); }
421
- .switch.on .switch-knob { transform: translateX(16px); }
422
- .switch.busy { opacity: 0.6; cursor: wait; }
423
- .switch:disabled { cursor: not-allowed; opacity: 0.5; }
424
- .switch.busy .switch-knob { opacity: 0; }
425
- .switch.busy::after {
426
- content: "";
427
- position: absolute;
428
- top: 2px; left: 10px;
429
- width: 14px; height: 14px;
430
- border-radius: 50%;
431
- border: 2px solid var(--border-strong);
432
- border-top-color: var(--accent);
433
- animation: spin 0.7s linear infinite;
434
- }
435
- body.offline .switch, body.offline #add-submit { opacity: 0.5; pointer-events: none; }
436
-
437
- /* ---------- inputs ---------- */
438
- .input, .field input[type="text"], .edit-input {
439
- height: 36px;
440
- width: 100%;
441
- padding: 0 12px;
442
- font-family: var(--mono);
443
- font-size: 13px;
444
- color: var(--fg);
445
- background: var(--surface);
446
- border: 1px solid var(--border-strong);
447
- border-radius: var(--radius-sm);
448
- box-shadow: var(--shadow-sm);
449
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
450
- }
451
- .input::placeholder, .field input::placeholder { color: var(--muted-dim); }
452
- .input:focus-visible, .field input[type="text"]:focus-visible, .edit-input:focus-visible {
453
- outline: none;
454
- border-color: var(--accent);
455
- box-shadow: 0 0 0 3px var(--accent-bg);
456
- }
457
- .input-sm { height: 30px; font-size: 12px; padding: 0 10px; font-family: var(--sans); }
458
- .input-search { width: 200px; max-width: 100%; }
459
- .field input.invalid, .edit-input.invalid { border-color: var(--down); }
460
- .field input.invalid:focus-visible, .edit-input.invalid:focus-visible { box-shadow: 0 0 0 3px var(--down-bg); }
461
- .edit-input { height: 30px; min-width: 0; padding: 0 10px; }
462
- input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
463
- select.input, .field select {
464
- height: 36px;
465
- width: 100%;
466
- padding: 0 30px 0 12px;
467
- font-family: var(--sans);
468
- font-size: 13px;
469
- color: var(--fg);
470
- background-color: var(--surface);
471
- border: 1px solid var(--border-strong);
472
- border-radius: var(--radius-sm);
473
- box-shadow: var(--shadow-sm);
474
- appearance: none;
475
- background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
476
- background-position: calc(100% - 16px) center, calc(100% - 11px) center;
477
- background-size: 5px 5px, 5px 5px;
478
- background-repeat: no-repeat;
479
- }
480
- select.input:focus-visible, .field select:focus-visible {
481
- outline: none;
482
- border-color: var(--accent);
483
- box-shadow: 0 0 0 3px var(--accent-bg);
484
- }
485
- select.input-sm { height: 28px; font-size: 12px; padding: 0 24px 0 8px; background-position: calc(100% - 10px) center, calc(100% - 6px) center; }
486
-
487
- /* ---------- add host form ---------- */
488
- .form-grid { display: flex; flex-direction: column; gap: 14px; }
489
- .field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
490
- .field label { font-size: 12.5px; color: var(--fg-2); font-weight: 500; }
491
- .form-checks { display: flex; flex-wrap: wrap; gap: 8px 18px; padding-top: 2px; }
492
- .field-checkbox { flex-direction: row; align-items: center; gap: 8px; }
493
- .field-checkbox label { font-size: 13px; color: var(--fg-2); font-weight: 400; cursor: pointer; }
494
- .field-error {
495
- font-size: 13px;
496
- color: var(--down);
497
- background: var(--down-bg);
498
- border: 1px solid var(--down);
499
- border-radius: var(--radius-sm);
500
- padding: 8px 12px;
501
- margin-top: 14px;
502
- display: flex;
503
- flex-wrap: wrap;
504
- align-items: center;
505
- gap: 8px;
506
- }
507
-
508
- /* ---------- system list ---------- */
509
- .stat-list { display: flex; flex-direction: column; }
510
- .stat-row {
511
- display: flex;
512
- align-items: flex-start;
513
- justify-content: space-between;
514
- gap: 16px;
515
- padding: 14px 20px;
516
- border-bottom: 1px solid var(--border);
517
- }
518
- .stat-row:last-child { border-bottom: none; }
519
- .stat-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
520
- .stat-side { flex-shrink: 0; padding-top: 2px; }
521
- .tile-value { font-size: 14px; font-weight: 600; color: var(--fg); }
522
- .tile-value.ok { color: var(--up); }
523
- .tile-value.warn { color: var(--warn); }
524
- .tile-sub { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
525
- .tile-sub.mono { font-size: 11.5px; }
526
- .tile-hint { font-size: 12px; color: var(--warn); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
527
- .tile-hint code { overflow-wrap: anywhere; }
528
- .tile-actions { margin-top: 10px; }
529
-
530
- /* ---------- hosts table ---------- */
531
- .table-wrap { overflow-x: auto; }
532
- table { width: 100%; border-collapse: collapse; min-width: 720px; }
533
- th, td {
534
- text-align: left;
535
- padding: 12px 12px;
536
- border-bottom: 1px solid var(--border);
537
- font-size: 13px;
538
- white-space: nowrap;
539
- vertical-align: middle;
540
- }
541
- th:first-child, td:first-child { padding-left: 20px; }
542
- th:last-child, td:last-child { padding-right: 20px; }
543
- th {
544
- color: var(--muted);
545
- font-weight: 500;
546
- font-size: 11.5px;
547
- letter-spacing: 0.04em;
548
- text-transform: uppercase;
549
- background: var(--surface-2);
550
- padding-top: 9px;
551
- padding-bottom: 9px;
552
- }
553
- th.num, td.num { text-align: right; }
554
- th.actions { text-align: right; }
555
- tbody tr { transition: background 0.15s ease; }
556
- tbody tr:hover { background: var(--surface-2); }
557
- tbody tr:last-child td { border-bottom: none; }
558
- tbody tr:last-child td:first-child { border-bottom-left-radius: var(--radius); }
559
- tbody tr:last-child td:last-child { border-bottom-right-radius: var(--radius); }
560
- #table th:nth-child(2), #table td.arrow { display: none; }
561
- #table tbody td { padding-top: 10px; padding-bottom: 10px; }
562
- .host-cell { display: flex; align-items: center; gap: 8px; font-weight: 500; }
563
- .host-cell a { color: var(--fg); }
564
- .host-cell a:hover { color: var(--accent); }
565
- .host-target, .traffic { font-size: 12px; color: var(--muted); margin-top: 3px; font-family: var(--mono); }
566
- .host-target { max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
567
- .host-td .edit-input { margin-top: 6px; width: 100%; min-width: 150px; }
568
- .host-cell .copy-btn, .tunnel-status .copy-btn { opacity: 0; transition: opacity 0.12s ease; }
569
- tbody tr:hover .copy-btn, .copy-btn:focus-visible, #table tbody td:hover .copy-btn { opacity: 1; }
570
- tr.row-down td:first-child { box-shadow: inset 3px 0 0 var(--down); }
571
- .pill { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 12px; }
572
- .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--muted-dim); }
573
- .dot.up { background: var(--up); box-shadow: 0 0 0 3px var(--up-bg); }
574
- .dot.down { background: var(--down); box-shadow: 0 0 0 3px var(--down-bg); }
575
- .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-bg); }
576
- .dot.accent { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
577
- .edit-hint { font-size: 12px; }
578
- td.edit-error { color: var(--down); white-space: normal; }
579
- td .inline-check + .inline-check { margin-top: 4px; }
580
- .inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
581
- .tunnel-cell { white-space: normal; }
582
- .tunnel-status { display: flex; align-items: center; gap: 6px; max-width: 240px; }
583
- .tunnel-login { display: flex; flex-direction: column; gap: 2px; font-size: 12px; white-space: nowrap; }
584
- .tunnel-login a { font-weight: 500; }
585
- .tunnel-status a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; display: inline-block; vertical-align: middle; font-size: 12px; }
586
- .tunnel-error { color: var(--down); cursor: help; }
587
- .tunnel-error-text {
588
- color: var(--down);
589
- cursor: help;
590
- overflow: hidden;
591
- text-overflow: ellipsis;
592
- white-space: nowrap;
593
- max-width: 170px;
594
- display: inline-block;
595
- vertical-align: middle;
596
- font-size: 12px;
597
- }
598
- .spinner {
599
- display: inline-block;
600
- width: 12px; height: 12px;
601
- border-radius: 50%;
602
- border: 2px solid var(--border-strong);
603
- border-top-color: var(--accent);
604
- animation: spin 0.7s linear infinite;
605
- }
606
- @keyframes spin { to { transform: rotate(360deg); } }
607
-
608
- .empty {
609
- padding: 40px 24px;
610
- color: var(--muted);
611
- text-align: center;
612
- font-size: 13.5px;
613
- display: flex;
614
- flex-direction: column;
615
- align-items: center;
616
- gap: 12px;
617
- }
618
- .empty p { margin: 0; }
619
- .empty-icon {
620
- width: 40px; height: 40px;
621
- border-radius: 10px;
622
- border: 1px dashed var(--border-strong);
623
- background: var(--surface-2);
624
- display: flex;
625
- align-items: center;
626
- justify-content: center;
627
- color: var(--muted-dim);
628
- font-size: 18px;
629
- }
630
- #hosts-error .empty-icon { color: var(--down); border-color: var(--down); background: var(--down-bg); }
631
- .empty pre { display: inline-block; text-align: left; }
632
-
633
- pre.code-block {
634
- background: var(--surface-2);
635
- border: 1px solid var(--border);
636
- border-radius: var(--radius-sm);
637
- padding: 10px 12px;
638
- overflow-x: auto;
639
- white-space: pre-wrap;
640
- word-break: break-all;
641
- font-family: var(--mono);
642
- font-size: 12px;
643
- line-height: 1.55;
644
- color: var(--fg-2);
645
- margin: 0;
646
- }
647
- .cli-list { display: flex; flex-direction: column; gap: 16px; }
648
- .cli-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
649
- .cli-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
650
-
651
- /* ---------- remote access ---------- */
652
- .hub-section { padding: 16px 20px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
653
- .hub-section:first-child { border-top: none; }
654
- .hub-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
655
- .hub-url { font-family: var(--mono); font-size: 12.5px; word-break: break-all; color: var(--fg-2); }
656
- .hub-warn { font-size: 12px; color: var(--warn); background: var(--warn-bg); border-radius: var(--radius-sm); padding: 6px 10px; }
657
- .hub-actions, .invite-actions { display: flex; gap: 8px; flex-wrap: wrap; }
658
- .hub-form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
659
- .hub-form-row .field { flex: 1; min-width: 140px; }
660
- .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; }
661
- .invite-result-row { display: flex; align-items: center; gap: 8px; }
662
- .invite-string { font-family: var(--mono); font-size: 11.5px; word-break: break-all; flex: 1; min-width: 0; }
663
- .invite-countdown { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
664
- .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; }
665
- .host-checks[hidden] { display: none; }
666
- .host-checks label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--fg-2); cursor: pointer; }
667
- .peer-list, .invite-list, .remote-list { display: flex; flex-direction: column; }
668
- .remote-list:not(:empty) { padding: 0 20px 12px; }
669
- .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; }
670
- .peer-row:last-child, .invite-row:last-child, .remote-item:last-child { border-bottom: none; }
671
- .peer-main, .remote-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
672
- .peer-name { font-weight: 500; font-size: 13px; }
673
- .peer-controls, .remote-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
674
- .remote-item { flex-direction: column; align-items: stretch; }
675
- .remote-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
676
- .remote-hosts-list { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
677
- .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); }
678
- .remote-host-row:first-child { border-top: none; }
679
- .badge-role { text-transform: capitalize; }
680
- .remote-card-body { display: flex; flex-direction: column; gap: 14px; }
681
- .mini-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
682
- .mini-form input { flex: 1; min-width: 90px; }
683
- .host-cell .badge-via { margin-left: 4px; }
684
-
685
- /* ---------- banners ---------- */
686
- .banner {
687
- border-radius: var(--radius);
688
- padding: 12px 16px;
689
- font-size: 13.5px;
690
- border: 1px solid var(--down);
691
- background: var(--down-bg);
692
- color: var(--fg);
693
- }
694
- .banner-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
695
- .banner .btn { background: var(--surface); }
696
-
697
- /* ---------- logs ---------- */
698
- details.card summary {
699
- cursor: pointer;
700
- list-style: none;
701
- padding: 16px 20px;
702
- display: flex;
703
- align-items: center;
704
- justify-content: space-between;
705
- gap: 10px;
706
- border-radius: var(--radius);
707
- }
708
- details.card summary::-webkit-details-marker { display: none; }
709
- details.card summary .card-title { display: flex; align-items: center; gap: 8px; }
710
- details.card summary .card-title::before {
711
- content: "";
712
- width: 6px; height: 6px;
713
- border-right: 1.5px solid var(--muted);
714
- border-bottom: 1.5px solid var(--muted);
715
- transform: rotate(-45deg);
716
- transition: transform 0.15s ease;
717
- margin-right: 2px;
718
- }
719
- details.card[open] summary .card-title::before { transform: rotate(45deg); }
720
- details.card[open] summary { border-bottom: 1px solid var(--border); border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
721
- .logs-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; }
722
- .logs-actions { display: flex; gap: 8px; }
723
- .logs-box {
724
- height: 280px;
725
- overflow-y: auto;
726
- background: var(--surface-2);
727
- border: 1px solid var(--border);
728
- border-radius: var(--radius-sm);
729
- padding: 12px 14px;
730
- font-family: var(--mono);
731
- font-size: 12px;
732
- line-height: 1.55;
733
- color: var(--fg-2);
734
- white-space: pre-wrap;
735
- word-break: break-all;
736
- margin: 0;
737
- }
738
-
739
- /* ---------- allow dialog ---------- */
740
- #allow-dialog {
741
- width: min(520px, calc(100vw - 32px));
742
- padding: 0;
743
- border: 1px solid var(--border);
744
- border-radius: var(--radius);
745
- background: var(--surface);
746
- color: var(--fg);
747
- box-shadow: var(--shadow-md);
748
- }
749
- #allow-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
750
- .modal-head {
751
- display: flex;
752
- align-items: flex-start;
753
- justify-content: space-between;
754
- gap: 12px;
755
- padding: 16px 20px;
756
- border-bottom: 1px solid var(--border);
757
- }
758
- .modal-title { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: -0.01em; }
759
- .modal-sub { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
760
- .modal-close { width: 28px; height: 28px; padding: 0; border-radius: 999px; flex-shrink: 0; }
761
- .modal-close svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
762
- .modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
763
- .modal-desc { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; }
764
- .modal-warn {
765
- font-size: 12.5px;
766
- color: var(--warn);
767
- background: var(--warn-bg);
768
- border: 1px solid var(--warn);
769
- border-radius: var(--radius-sm);
770
- padding: 8px 12px;
771
- line-height: 1.5;
772
- }
773
- .allow-list { display: flex; flex-direction: column; gap: 6px; max-height: 220px; overflow-y: auto; }
774
- .allow-row {
775
- display: flex;
776
- align-items: center;
777
- justify-content: space-between;
778
- gap: 10px;
779
- padding: 6px 10px;
780
- border: 1px solid var(--border);
781
- border-radius: var(--radius-sm);
782
- background: var(--surface-2);
783
- }
784
- .allow-row span { font-size: 13px; word-break: break-all; }
785
- .allow-remove {
786
- display: inline-flex;
787
- align-items: center;
788
- justify-content: center;
789
- width: 22px;
790
- height: 22px;
791
- padding: 0;
792
- font-size: 15px;
793
- line-height: 1;
794
- border-radius: var(--radius-xs);
795
- border: 1px solid transparent;
796
- background: transparent;
797
- color: var(--muted);
798
- cursor: pointer;
799
- flex-shrink: 0;
800
- }
801
- .allow-remove:hover { color: var(--down); background: var(--down-bg); border-color: transparent; }
802
- .allow-empty { font-size: 13px; padding: 4px 0; }
803
- .allow-add-row { display: flex; gap: 8px; }
804
- .allow-add-row .input { flex: 1; }
805
- .allow-add-row .btn { height: auto; align-self: stretch; padding: 0 14px; }
806
- .modal-hint { margin: 0; font-size: 12px; color: var(--muted-dim); }
807
- .modal-foot {
808
- display: flex;
809
- justify-content: flex-end;
810
- gap: 8px;
811
- padding: 16px 20px;
812
- border-top: 1px solid var(--border);
813
- }
814
-
815
- /* ---------- confirm / prompt / share dialogs ---------- */
816
- #confirm-dialog, #prompt-dialog, #share-dialog {
817
- padding: 0;
818
- border: 1px solid var(--border);
819
- border-radius: var(--radius);
820
- background: var(--surface);
821
- color: var(--fg);
822
- box-shadow: var(--shadow-md);
823
- }
824
- #confirm-dialog::backdrop, #prompt-dialog::backdrop, #share-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
825
- #confirm-dialog { width: min(420px, calc(100vw - 32px)); }
826
- #prompt-dialog { width: min(420px, calc(100vw - 32px)); }
827
- #share-dialog { width: min(480px, calc(100vw - 32px)); }
828
- .share-options { display: flex; flex-direction: column; gap: 10px; }
829
- .option-card {
830
- display: flex;
831
- align-items: flex-start;
832
- gap: 12px;
833
- width: 100%;
834
- text-align: left;
835
- padding: 14px;
836
- border: 1px solid var(--border);
837
- border-radius: var(--radius);
838
- background: var(--surface-2);
839
- color: inherit;
840
- font: inherit;
841
- cursor: pointer;
842
- }
843
- .option-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
844
- .option-card[aria-checked="true"] { border-color: var(--accent); background: var(--accent-bg); }
845
- .option-dot {
846
- width: 16px;
847
- height: 16px;
848
- margin-top: 2px;
849
- flex-shrink: 0;
850
- border-radius: 999px;
851
- border: 1.5px solid var(--border-strong);
852
- background: var(--surface);
853
- position: relative;
854
- }
855
- .option-card[aria-checked="true"] .option-dot { border-color: var(--accent); }
856
- .option-card[aria-checked="true"] .option-dot::after {
857
- content: "";
858
- position: absolute;
859
- top: 3px;
860
- left: 3px;
861
- width: 8px;
862
- height: 8px;
863
- border-radius: 999px;
864
- background: var(--accent);
865
- }
866
- .option-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
867
- .option-title { font-size: 13.5px; font-weight: 600; }
868
- .option-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
869
- .option-extra { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
870
- .option-extra .field-error { margin-top: 4px; }
871
-
872
- /* ---------- toasts ---------- */
873
- .toast-container {
874
- position: fixed;
875
- bottom: 20px;
876
- right: 20px;
877
- z-index: 50;
878
- display: flex;
879
- flex-direction: column;
880
- gap: 8px;
881
- max-width: 360px;
882
- }
883
- .toast {
884
- background: var(--surface);
885
- border: 1px solid var(--border-strong);
886
- border-left: 3px solid var(--border-strong);
887
- border-radius: var(--radius-sm);
888
- padding: 10px 14px;
889
- font-size: 13px;
890
- box-shadow: var(--shadow-md);
891
- opacity: 0;
892
- transform: translateY(8px);
893
- transition: opacity 0.2s ease, transform 0.2s ease;
894
- }
895
- .toast.show { opacity: 1; transform: translateY(0); }
896
- .toast-success { border-left-color: var(--up); }
897
- .toast-error { border-left-color: var(--down); }
898
- .toast-warn { border-left-color: var(--warn); }
899
- .toast-hint { margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
900
- .toast-hint code { font-size: 11.5px; color: var(--fg); }
901
-
902
- /* ---------- skeleton / boot ---------- */
903
- @keyframes skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
904
- body.boot .tile-value,
905
- body.boot .tile-sub,
906
- body.boot #version,
907
- body.boot #uptime,
908
- body.boot #pid {
909
- color: transparent !important;
910
- display: inline-block;
911
- min-width: 70px;
912
- border-radius: 4px;
913
- background: linear-gradient(90deg, var(--surface-2) 25%, var(--border-strong) 37%, var(--surface-2) 63%);
914
- background-size: 400% 100%;
915
- animation: skeleton-shimmer 1.4s ease infinite;
916
- }
917
- body.boot #updated { visibility: hidden; }
918
- body.boot #stop-proxy-btn, body.offline #stop-proxy-btn { display: none; }
919
- .skel-bar {
920
- display: block;
921
- width: 80%;
922
- height: 14px;
923
- border-radius: 4px;
924
- background: linear-gradient(90deg, var(--surface-2) 25%, var(--border-strong) 37%, var(--surface-2) 63%);
925
- background-size: 400% 100%;
926
- animation: skeleton-shimmer 1.4s ease infinite;
927
- }
928
- #hosts-skeleton table { min-width: 0; }
929
- .skeleton-row td { border-bottom: 1px solid var(--border); padding: 17px 20px; }
930
- .skeleton-row:nth-child(1) .skel-bar { width: 92%; }
931
- .skeleton-row:nth-child(2) .skel-bar { width: 70%; }
932
- .skeleton-row:nth-child(3) .skel-bar { width: 82%; }
933
-
934
- footer { text-align: center; padding: 8px 20px 32px; color: var(--muted-dim); font-size: 12px; }
935
- footer a { color: var(--muted); }
936
- [hidden] { display: none !important; }
937
-
938
-
939
- /* ---------- pages ---------- */
940
- .view { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
941
- .tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
942
- @media (min-width: 1100px) { .tiles { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
943
- .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; }
944
- .tile:hover { border-color: var(--border-strong); text-decoration: none; }
945
- .tile-big { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.2; }
946
- .tile-big.tile-word { font-size: 18px; line-height: 1.8; }
947
- .tile-big.ok { color: var(--up); }
948
- .tile-big.bad { color: var(--down); }
949
- .tile-big.warn { color: var(--warn); }
950
- .tile .tile-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
951
- .grid-2 { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; }
952
- @media (min-width: 1000px) { .grid-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
953
- .ov-list { display: flex; flex-direction: column; }
954
- .ov-row { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--border); min-width: 0; }
955
- .ov-row:last-child { border-bottom: none; }
956
- .ov-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
957
- .ov-title { font-family: var(--mono); font-size: 13px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
958
- #ov-checks .ov-title { font-family: var(--sans); }
959
- .ov-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
960
- .ov-side { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-size: 12px; color: var(--muted); }
961
- .ov-side a.ov-link { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
962
- .ov-more { padding: 10px 20px; font-size: 12.5px; }
963
- .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); }
964
- .logs-short { height: auto; max-height: 220px; margin: 0; border-radius: 0 0 var(--radius) var(--radius); border: 0; }
965
- .logs-head { flex-wrap: wrap; gap: 10px; }
966
- .logs-tools { flex-wrap: wrap; gap: 8px; flex: 1; min-width: 0; }
967
- .logs-tools .input { width: auto; }
968
- .logs-tools select.input { padding-right: 28px; }
969
- .logs-tools .input-search { width: 220px; }
970
- .logs-meta { padding: 8px 20px; font-size: 12px; border-top: 1px solid var(--border); }
971
- .check-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--fg-2); cursor: pointer; }
972
- select.input { padding-right: 8px; cursor: pointer; }
973
- .segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
974
- .segmented button { border: 0; background: var(--surface); color: var(--muted); font-size: 12.5px; padding: 6px 12px; cursor: pointer; }
975
- .segmented button + button { border-left: 1px solid var(--border); }
976
- .segmented button[aria-checked="true"] { background: var(--accent-bg); color: var(--accent-strong); font-weight: 600; }
977
- .form-row-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 10px; }
978
- .form-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
979
- #ports-form .hub-warn { margin-top: 12px; }
980
- .token-body { display: flex; flex-direction: column; gap: 12px; }
981
- .token-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
982
- .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); }
983
- .help-list { display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: var(--fg-2); }
984
- .help-list p { margin: 0; }
985
- .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); }
986
- .remote-local { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
987
- .remote-local a { font-family: var(--mono); font-size: 13px; }
988
- .role-table { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-sm); }
989
- .role-table div { display: flex; gap: 10px; padding: 8px 12px; font-size: 12.5px; }
990
- .role-table div + div { border-top: 1px solid var(--border); }
991
- .role-table strong { width: 56px; flex-shrink: 0; }
992
- .role-table span { color: var(--muted); }
993
- .danger-card { border-color: var(--down-bg); }
994
- .danger-card .card-title { color: var(--down); }
995
- .sb-dot { margin-left: auto; }
996
- input.input[type="number"] { font-family: var(--mono); }
997
-
998
- /* ---------- responsive ---------- */
999
- @media (min-width: 1200px) {
1000
- th, td { padding-left: 10px; padding-right: 10px; }
1001
- .tunnel-status .copy-btn { display: none; }
1002
- .tunnel-status a { max-width: 150px; }
1003
- .host-target { max-width: 200px; }
1004
- }
1005
- @media (min-width: 1200px) and (max-width: 1399px) {
1006
- .tunnel-status a { max-width: 110px; }
1007
- .host-target { max-width: 170px; }
1008
- }
1009
- @media (max-width: 1099px) {
1010
- .topbar, main { padding-left: 24px; padding-right: 24px; }
1011
- }
1012
- @media (min-width: 700px) and (max-width: 1099px) {
1013
- .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
1014
- }
1015
- @media (min-width: 641px) and (max-width: 899px), (min-width: 1100px) and (max-width: 1279px) {
1016
- #table tbody { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); }
1017
- #table tbody tr:nth-child(odd) { border-right: 1px solid var(--border); }
1018
- #table tbody tr:nth-last-child(2):nth-child(odd) { border-bottom: none; }
1019
- }
1020
- @media (max-width: 899px), (min-width: 1100px) and (max-width: 1279px) {
1021
- #table { min-width: 0; }
1022
- #table thead { display: none; }
1023
- #table, #table tbody, #table tr { display: block; width: 100%; }
1024
- #table tr { padding: 14px 20px; border-bottom: 1px solid var(--border); }
1025
- #table tr:last-child { border-bottom: none; }
1026
- #table td { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 5px 0; border: none; white-space: normal; text-align: right; min-width: 0; }
1027
- #table td::before { content: attr(data-label); color: var(--muted); font-size: 12px; flex-shrink: 0; text-align: left; }
1028
- #table td[data-label=""]::before { content: none; }
1029
- #table td[data-label=""]:first-child { padding-bottom: 8px; justify-content: flex-start; font-weight: 600; }
1030
- #table td.host-td, #table td.status-td { flex-direction: column; align-items: flex-start; gap: 0; }
1031
- #table td.status-td { align-items: flex-end; }
1032
- #table td.status-td::before { align-self: flex-start; margin-bottom: 4px; }
1033
- #table td.host-td .edit-input { margin-top: 8px; }
1034
- .host-target { max-width: 100%; }
1035
- .host-cell .copy-btn, .tunnel-status .copy-btn { opacity: 1; }
1036
- #table td[data-label=""] { justify-content: flex-end; padding-top: 10px; }
1037
- #table .edit-input { min-width: 0; }
1038
- tr.row-down td:first-child { box-shadow: none; }
1039
- tr.row-down { box-shadow: inset 3px 0 0 var(--down); }
1040
- .tunnel-status, .tunnel-status a, .tunnel-error-text { max-width: 100%; min-width: 0; }
1041
- .tunnel-status { justify-content: flex-end; flex: 0 1 auto; }
1042
- .tunnel-status a { flex: 0 1 auto; }
1043
- }
1044
- @media (max-width: 640px) {
1045
- #pid, #updated, #version { display: none; }
1046
- .topbar { height: 52px; padding: 0 16px; }
1047
- main { padding: 20px 16px 40px; gap: 20px; }
1048
- .col-primary, .col-secondary, .layout { gap: 16px; }
1049
- .page-title { font-size: 20px; }
1050
- .card-head, .card-body, .stat-row, .logs-body, details.card summary { padding-left: 16px; padding-right: 16px; }
1051
- #table tr { padding-left: 16px; padding-right: 16px; }
1052
- .skeleton-row td { padding-left: 16px; padding-right: 16px; }
1053
- .input-search { width: 100%; }
1054
- .card-tools { width: 100%; }
1055
- .row-actions { flex-wrap: wrap; }
1056
- .toast-container { left: 12px; right: 12px; bottom: 12px; max-width: none; }
1057
- }
1058
-
1059
- @media (prefers-reduced-motion: reduce) {
1060
- *, *::before, *::after {
1061
- animation-duration: 0.001ms !important;
1062
- animation-iteration-count: 1 !important;
1063
- transition-duration: 0.001ms !important;
1064
- scroll-behavior: auto !important;
1065
- }
1066
- }
1067
- `;
1068
- const clientJs = `
1069
- (function () {
1070
- "use strict";
1071
- var setTheme = (function () {
1072
- var btn = document.getElementById("theme-toggle");
1073
- var order = ["system", "light", "dark"];
1074
- var mode = document.documentElement.getAttribute("data-theme") || "system";
1075
- function apply(next) {
1076
- mode = next;
1077
- if (mode === "system") document.documentElement.removeAttribute("data-theme");
1078
- else document.documentElement.setAttribute("data-theme", mode);
1079
- try { if (mode === "system") localStorage.removeItem("locadot.theme"); else localStorage.setItem("locadot.theme", mode); } catch (e) {}
1080
- var label = "Theme: " + mode;
1081
- btn.setAttribute("data-mode", mode);
1082
- btn.title = label;
1083
- btn.setAttribute("aria-label", label + " (click to change)");
1084
- }
1085
- apply(mode);
1086
- btn.addEventListener("click", function () { apply(order[(order.indexOf(mode) + 1) % order.length]); });
1087
- return function (next) { apply(next); if (typeof syncThemeSeg === "function") syncThemeSeg(next); };
1088
- })();
1089
-
1090
-
1091
- var tokenMeta = document.querySelector('meta[name="locadot-token"]');
1092
- var TOKEN = tokenMeta ? tokenMeta.getAttribute("content") : "";
1093
- var uiAuthMeta = document.querySelector('meta[name="locadot-ui-auth"]');
1094
- var UI_AUTH = uiAuthMeta ? uiAuthMeta.getAttribute("content") === "on" : false;
1095
-
1096
- var versionEl = document.getElementById("version");
1097
- var uptimeEl = document.getElementById("uptime");
1098
- var pidEl = document.getElementById("pid");
1099
- var liveDot = document.getElementById("live-dot");
1100
- var liveLabel = document.getElementById("live-label");
1101
- var hostsErrorEl = document.getElementById("hosts-error");
1102
- var updatedEl = document.getElementById("updated");
1103
-
1104
- var rootValueEl = document.getElementById("tile-root-value");
1105
- var portsValueEl = document.getElementById("tile-ports-value");
1106
- var portsHintEl = document.getElementById("tile-ports-hint");
1107
-
1108
- var caValueEl = document.getElementById("tile-ca-value");
1109
- var caToggle = document.getElementById("ca-toggle");
1110
-
1111
- var startupValueEl = document.getElementById("tile-startup-value");
1112
- var startupMethodEl = document.getElementById("tile-startup-method");
1113
- var startupToggle = document.getElementById("startup-toggle");
1114
-
1115
- var proxyPortsEl = document.getElementById("tile-proxy-ports");
1116
- var proxyBindEl = document.getElementById("tile-proxy-bind");
1117
- var proxyStateDirEl = document.getElementById("tile-proxy-statedir");
1118
- var proxyPlatformEl = document.getElementById("tile-proxy-platform");
1119
- var stopProxyBtn = document.getElementById("stop-proxy-btn");
1120
- var stoppedBanner = document.getElementById("stopped-banner");
1121
-
1122
- var cfValueEl = document.getElementById("tile-cloudflared-value");
1123
- var cfSubEl = document.getElementById("tile-cloudflared-sub");
1124
- var cfInstallBtn = document.getElementById("cloudflared-install-btn");
1125
-
1126
- var addForm = document.getElementById("add-form");
1127
- var addHostInput = document.getElementById("add-host");
1128
- var addTargetInput = document.getElementById("add-target");
1129
- var addInsecureInput = document.getElementById("add-insecure");
1130
- var addCorsInput = document.getElementById("add-cors");
1131
- var addSubmitBtn = document.getElementById("add-submit");
1132
- var addErrorEl = document.getElementById("add-error");
1133
-
1134
- var emptyEl = document.getElementById("empty");
1135
- var tableWrap = document.getElementById("table-wrap");
1136
- var tbody = document.getElementById("tbody");
1137
- var hostsSkeletonEl = document.getElementById("hosts-skeleton");
1138
- var hostsCountEl = document.getElementById("hosts-count");
1139
- var sbHostsCountEl = document.getElementById("sb-hosts-count");
1140
- var sbRemotesCountEl = document.getElementById("sb-remotes-count");
1141
- var hostsFilterInput = document.getElementById("hosts-filter");
1142
- // As many skeleton rows as hosts last time, so the table doesn't jump when data lands.
1143
- try {
1144
- var skelBody = hostsSkeletonEl.querySelector("tbody");
1145
- var lastCount = Math.max(1, Math.min(8, parseInt(localStorage.getItem("locadot.hostCount") || "3", 10) || 3));
1146
- while (skelBody.children.length > lastCount) skelBody.removeChild(skelBody.lastElementChild);
1147
- while (skelBody.children.length < lastCount) skelBody.appendChild(skelBody.firstElementChild.cloneNode(true));
1148
- } catch (e) {}
1149
- var hostsBooted = false;
1150
-
1151
- var loadErrorBanner = document.getElementById("load-error-banner");
1152
- var loadErrorText = document.getElementById("load-error-text");
1153
- var loadErrorRetry = document.getElementById("load-error-retry");
1154
- var isDown = false;
1155
-
1156
- var logsPanel = document.getElementById("logs-panel");
1157
- var logsBox = document.getElementById("logs-box");
1158
- var logsRefreshBtn = document.getElementById("logs-refresh");
1159
- var logsClearBtn = document.getElementById("logs-clear");
1160
-
1161
- var cliAddPre = document.getElementById("cli-add-pre");
1162
- var cliAddCopy = document.getElementById("cli-add-copy");
1163
- var cliCurlPre = document.getElementById("cli-curl-pre");
1164
- var cliCurlCopy = document.getElementById("cli-curl-copy");
1165
- var cliTunnelPre = document.getElementById("cli-tunnel-pre");
1166
- var cliTunnelCopy = document.getElementById("cli-tunnel-copy");
1167
-
1168
- var toastContainer = document.getElementById("toast-container");
1169
-
1170
- var hubDot = document.getElementById("hub-dot");
1171
- var hubStatusText = document.getElementById("hub-status-text");
1172
- var hubUrlRow = document.getElementById("hub-url-row");
1173
- var hubUrlEl = document.getElementById("hub-url");
1174
- var hubUrlCopyBtn = document.getElementById("hub-url-copy");
1175
- var hubLoginRow = document.getElementById("hub-login-row");
1176
- var hubLoginLink = document.getElementById("hub-login-link");
1177
- var hubQuickWarning = document.getElementById("hub-quick-warning");
1178
- var hubErrorRow = document.getElementById("hub-error-row");
1179
- var hubNamedForm = document.getElementById("hub-named-form");
1180
- var hubDomainInput = document.getElementById("hub-domain");
1181
- var hubTunnelNameInput = document.getElementById("hub-tunnel-name");
1182
- var hubNamedSubmitBtn = document.getElementById("hub-named-submit");
1183
- var hubQuickBtn = document.getElementById("hub-quick-btn");
1184
- var hubStopBtn = document.getElementById("hub-stop-btn");
1185
- var hubShareSection = document.getElementById("hub-share-section");
1186
- var inviteRoleSelect = document.getElementById("invite-role");
1187
- var inviteCreateBtn = document.getElementById("invite-create-btn");
1188
- var inviteHostsWrap = document.getElementById("invite-hosts-wrap");
1189
- var inviteResultEl = document.getElementById("invite-result");
1190
- var inviteStringEl = document.getElementById("invite-string");
1191
- var inviteCopyBtn = document.getElementById("invite-copy-btn");
1192
- var inviteCountdownEl = document.getElementById("invite-countdown");
1193
- var peersEmptyEl = document.getElementById("peers-empty");
1194
- var peersListEl = document.getElementById("peers-list");
1195
- var invitesEmptyEl = document.getElementById("invites-empty");
1196
- var invitesListEl = document.getElementById("invites-list");
1197
-
1198
- var connectForm = document.getElementById("connect-form");
1199
- var connectStringInput = document.getElementById("connect-string");
1200
- var connectNameInput = document.getElementById("connect-name");
1201
- var connectDomainInput = document.getElementById("connect-domain");
1202
- var hubLocalhostRow = document.getElementById("hub-localhost-row");
1203
- var hubLocalhostSwitch = document.getElementById("hub-localhost");
1204
- var hubPanelRow = document.getElementById("hub-panel-row");
1205
- var hubPanelSwitch = document.getElementById("hub-panel");
1206
- var hubPanelSub = document.getElementById("hub-panel-sub");
1207
- var hubPanelUrl = "";
1208
- var connectSubmitBtn = document.getElementById("connect-submit");
1209
- var connectErrorEl = document.getElementById("connect-error");
1210
- var remotesEmptyEl = document.getElementById("remotes-empty");
1211
- var remotesListEl = document.getElementById("remotes-list");
1212
-
1213
- var lastStatus = null;
1214
- var lastHosts = [];
1215
- var lastHub = null;
1216
- var lastRemotes = [];
1217
- var rowElements = {};
1218
- var editingHost = null;
1219
- var caBusy = false;
1220
- var startupBusy = false;
1221
- var cfInstallBusy = false;
1222
- var cloudflaredInstalled = false;
1223
- var cloudflareLoggedIn = false;
1224
- var proxyStopped = false;
1225
- var pollTimer = null;
1226
- var remotesPollTimer = null;
1227
- var inviteTimer = null;
1228
- var inviteExpiresAt = null;
1229
-
1230
- function clear(el) {
1231
- while (el.firstChild) el.removeChild(el.firstChild);
1232
- }
1233
-
1234
- function fmtRelative(iso) {
1235
- if (!iso) return "never";
1236
- var diffMs = Date.now() - new Date(iso).getTime();
1237
- var s = Math.max(0, Math.round(diffMs / 1000));
1238
- if (s < 60) return s + "s ago";
1239
- var m = Math.round(s / 60);
1240
- if (m < 60) return m + "m ago";
1241
- var h = Math.round(m / 60);
1242
- if (h < 24) return h + "h ago";
1243
- var d = Math.round(h / 24);
1244
- return d + "d ago";
1245
- }
1246
-
1247
- function fmtUptime(sec) {
1248
- sec = Math.max(0, Math.floor(sec));
1249
- var h = Math.floor(sec / 3600);
1250
- var m = Math.floor((sec % 3600) / 60);
1251
- var s = sec % 60;
1252
- return h + "h " + m + "m " + s + "s";
1253
- }
1254
-
1255
- function copyText(text, btn) {
1256
- function done(ok) {
1257
- if (!btn) return;
1258
- var prev = btn.getAttribute("data-original") || btn.textContent;
1259
- btn.setAttribute("data-original", prev);
1260
- btn.textContent = ok ? "Copied" : "Failed";
1261
- setTimeout(function () { btn.textContent = prev; }, 1500);
1262
- }
1263
- if (navigator.clipboard && navigator.clipboard.writeText) {
1264
- navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
1265
- return;
1266
- }
1267
- try {
1268
- var ta = document.createElement("textarea");
1269
- ta.className = "clip-helper";
1270
- ta.value = text;
1271
- document.body.appendChild(ta);
1272
- ta.select();
1273
- document.execCommand("copy");
1274
- document.body.removeChild(ta);
1275
- done(true);
1276
- } catch (e) {
1277
- done(false);
1278
- }
1279
- }
1280
-
1281
- function makeCopyButton(getText, label) {
1282
- var btn = document.createElement("button");
1283
- btn.type = "button";
1284
- btn.className = "copy-btn";
1285
- btn.textContent = label || "Copy";
1286
- btn.setAttribute("aria-label", "Copy to clipboard");
1287
- btn.addEventListener("click", function () { copyText(getText(), btn); });
1288
- return btn;
1289
- }
1290
-
1291
- function showToast(message, kind, hint) {
1292
- var el = document.createElement("div");
1293
- el.className = "toast toast-" + (kind || "info");
1294
- var row = document.createElement("div");
1295
- row.textContent = message;
1296
- el.appendChild(row);
1297
- if (hint) {
1298
- var hintRow = document.createElement("div");
1299
- hintRow.className = "toast-hint";
1300
- var code = document.createElement("code");
1301
- code.className = "mono";
1302
- code.textContent = hint;
1303
- hintRow.appendChild(code);
1304
- hintRow.appendChild(makeCopyButton(function () { return hint; }));
1305
- el.appendChild(hintRow);
1306
- }
1307
- toastContainer.appendChild(el);
1308
- requestAnimationFrame(function () { el.classList.add("show"); });
1309
- var timeout = hint ? 9000 : 4500;
1310
- setTimeout(function () {
1311
- el.classList.remove("show");
1312
- setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
1313
- }, timeout);
1314
- }
1315
-
1316
- function apiError(err, context) {
1317
- var message = err && err.message ? err.message : String(err);
1318
- if (context) message = context + ": " + message;
1319
- showToast(message, "error", err && err.hint ? err.hint : undefined);
1320
- }
1321
-
1322
- function parseJsonOrThrow(r) {
1323
- // The dashboard session ran out (or the password was set elsewhere): back to the sign-in page.
1324
- if (r.status === 401) location.reload();
1325
- return r.json().catch(function () { return {}; }).then(function (body) {
1326
- if (!r.ok) {
1327
- var err = new Error((body && body.error) || ("Request failed (" + r.status + ")"));
1328
- err.hint = body && body.hint;
1329
- throw err;
1330
- }
1331
- return body;
1332
- });
1333
- }
1334
-
1335
- function apiFetch(url, opts) {
1336
- opts = opts || {};
1337
- var method = opts.method || "GET";
1338
- var headers = {};
1339
- if (method !== "GET") {
1340
- headers["X-Locadot-Token"] = TOKEN;
1341
- headers["Content-Type"] = "application/json";
1342
- }
1343
- opts.headers = headers;
1344
- return fetch(url, opts).then(parseJsonOrThrow);
1345
- }
1346
-
1347
- // ---------- header + system tiles ----------
1348
-
1349
- function hideHostsSkeleton() {
1350
- if (!hostsBooted) {
1351
- hostsBooted = true;
1352
- hostsSkeletonEl.hidden = true;
1353
- }
1354
- }
1355
-
1356
- function showLoadError(err) {
1357
- isDown = true;
1358
- var reason = (err && err.message) ? err.message : "connection failed";
1359
- loadErrorText.textContent = "Can't reach the locadot proxy (" + reason + "). Start it with locadot start; this page retries every 5 s.";
1360
- loadErrorBanner.hidden = false;
1361
- if (!lastStatus) hostsErrorEl.hidden = false;
1362
- }
1363
-
1364
- function hideLoadError() {
1365
- isDown = false;
1366
- loadErrorBanner.hidden = true;
1367
- hostsErrorEl.hidden = true;
1368
- liveLabel.textContent = "live";
1369
- document.body.classList.remove("offline");
1370
- }
1371
-
1372
- loadErrorRetry.addEventListener("click", function () {
1373
- loadErrorRetry.disabled = true;
1374
- loadErrorRetry.classList.add("busy");
1375
- refreshAll().then(function () {
1376
- loadErrorRetry.disabled = false;
1377
- loadErrorRetry.classList.remove("busy");
1378
- });
1379
- });
1380
-
1381
- function renderHeader(status) {
1382
- document.body.classList.remove("boot");
1383
- versionEl.textContent = "v" + status.proxy.version;
1384
- uptimeEl.textContent = "uptime " + fmtUptime(status.uptimeSec);
1385
- pidEl.textContent = "pid " + status.proxy.pid;
1386
- updatedEl.textContent = "Updated " + new Date().toLocaleTimeString();
1387
- liveDot.classList.remove("down");
1388
- liveDot.classList.add("up");
1389
- }
1390
-
1391
- function markDown() {
1392
- liveDot.classList.remove("up");
1393
- liveDot.classList.add("down");
1394
- liveLabel.textContent = "offline";
1395
- document.body.classList.add("offline");
1396
- }
1397
-
1398
- function renderSystemTiles(status) {
1399
- var sys = status.system;
1400
- var proxy = status.proxy;
1401
-
1402
- rootValueEl.textContent = sys.isRoot ? "Elevated" : "User";
1403
- rootValueEl.className = "tile-value " + (sys.isRoot ? "ok" : "");
1404
- portsValueEl.textContent = "Ports <1024: " + (sys.canBindPrivileged ? "allowed" : "blocked");
1405
- clear(portsHintEl);
1406
- if (!sys.canBindPrivileged && sys.platform === "linux") {
1407
- var hintText = "sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80";
1408
- var code = document.createElement("code");
1409
- code.className = "mono";
1410
- code.textContent = hintText;
1411
- portsHintEl.appendChild(code);
1412
- portsHintEl.appendChild(makeCopyButton(function () { return hintText; }));
1413
- portsHintEl.hidden = false;
1414
- } else {
1415
- portsHintEl.hidden = true;
1416
- }
1417
-
1418
- var caTrusted = sys.caTrusted;
1419
- caValueEl.textContent = caTrusted === true ? "Trusted" : caTrusted === false ? "Not trusted" : "Unknown";
1420
- caValueEl.className = "tile-value " + (caTrusted === true ? "ok" : caTrusted === false ? "warn" : "");
1421
- if (!caBusy) {
1422
- caToggle.setAttribute("aria-checked", String(caTrusted === true));
1423
- caToggle.classList.toggle("on", caTrusted === true);
1424
- }
1425
-
1426
- startupValueEl.textContent = sys.startup.enabled ? "Enabled" : "Disabled";
1427
- startupValueEl.className = "tile-value " + (sys.startup.enabled ? "ok" : "");
1428
- startupMethodEl.textContent = sys.startup.method ? ("via " + sys.startup.method) : "";
1429
- if (!startupBusy) {
1430
- startupToggle.setAttribute("aria-checked", String(sys.startup.enabled));
1431
- startupToggle.classList.toggle("on", sys.startup.enabled);
1432
- }
1433
-
1434
- proxyPortsEl.textContent = "http :" + proxy.httpPort + " \\u00b7 https :" + proxy.httpsPort;
1435
- proxyBindEl.textContent = "bind " + proxy.bind.join(", ");
1436
- proxyStateDirEl.textContent = proxy.stateDir;
1437
- proxyPlatformEl.textContent = sys.platform;
1438
-
1439
- var cf = sys.cloudflared || { installed: false };
1440
- cloudflaredInstalled = !!cf.installed;
1441
- cloudflareLoggedIn = !!cf.loggedIn;
1442
- if (cf.installed) {
1443
- cfValueEl.textContent = "Installed";
1444
- cfValueEl.className = "tile-value ok";
1445
- cfSubEl.textContent = [cf.version, cf.path].filter(Boolean).join(" \\u00b7 ");
1446
- if (!cfInstallBusy) cfInstallBtn.hidden = true;
1447
- } else {
1448
- cfValueEl.textContent = "Not installed";
1449
- cfValueEl.className = "tile-value warn";
1450
- cfSubEl.textContent = "";
1451
- if (!cfInstallBusy) cfInstallBtn.hidden = false;
1452
- }
1453
- }
1454
-
1455
- function renderCli(status) {
1456
- var stateDir = status.proxy.stateDir;
1457
- var port = status.proxy.httpPort;
1458
- var urlBase = "http://localhost" + (port !== 80 ? ":" + port : "");
1459
- var curl = "curl -X POST " + urlBase + "/api/hosts" +
1460
- ' -H "X-Locadot-Token: $(cat ' + stateDir + '/.locadot-token)"' +
1461
- " -H 'Content-Type: application/json'" +
1462
- ' -d \\'{"host":"app.localhost","target":"3000"}\\'';
1463
- cliCurlPre.textContent = curl;
1464
- }
1465
-
1466
- // ---------- remote access: hub (sender) ----------
1467
-
1468
- function fmtIn(iso) {
1469
- if (!iso) return "";
1470
- var ms = new Date(iso).getTime() - Date.now();
1471
- if (ms <= 0) return "expired";
1472
- var s = Math.round(ms / 1000);
1473
- if (s < 60) return "in " + s + "s";
1474
- var m = Math.round(s / 60);
1475
- return "in " + m + "m";
1476
- }
1477
-
1478
- function renderPeers(peers) {
1479
- peersEmptyEl.hidden = peers.length !== 0;
1480
- clear(peersListEl);
1481
- peers.forEach(function (peer) {
1482
- var row = document.createElement("div");
1483
- row.className = "peer-row";
1484
-
1485
- var main = document.createElement("div");
1486
- main.className = "peer-main";
1487
- var name = document.createElement("div");
1488
- name.className = "peer-name";
1489
- name.textContent = peer.name;
1490
- main.appendChild(name);
1491
- var sub = document.createElement("div");
1492
- sub.className = "muted";
1493
- sub.textContent = "last seen " + fmtRelative(peer.lastSeen);
1494
- main.appendChild(sub);
1495
- row.appendChild(main);
1496
-
1497
- var controls = document.createElement("div");
1498
- controls.className = "peer-controls";
1499
- var roleSelect = document.createElement("select");
1500
- roleSelect.className = "input input-sm";
1501
- roleSelect.setAttribute("aria-label", "Role for " + peer.name);
1502
- ["viewer", "editor", "admin"].forEach(function (r) {
1503
- var opt = document.createElement("option");
1504
- opt.value = r;
1505
- opt.textContent = r.charAt(0).toUpperCase() + r.slice(1);
1506
- if (r === peer.role) opt.selected = true;
1507
- roleSelect.appendChild(opt);
1508
- });
1509
- roleSelect.addEventListener("change", function () {
1510
- var prev = peer.role;
1511
- roleSelect.disabled = true;
1512
- apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "PUT", body: JSON.stringify({ role: roleSelect.value, hosts: peer.hosts }) })
1513
- .then(function () { showToast("Updated role for " + peer.name, "success"); return loadHub(); })
1514
- .catch(function (err) { apiError(err, "Couldn't update role"); roleSelect.value = prev; roleSelect.disabled = false; });
1515
- });
1516
- controls.appendChild(roleSelect);
1517
-
1518
- var revokeBtn = document.createElement("button");
1519
- revokeBtn.type = "button";
1520
- revokeBtn.className = "btn btn-sm btn-danger";
1521
- revokeBtn.textContent = "Revoke";
1522
- revokeBtn.addEventListener("click", function () {
1523
- confirmDialog({ title: "Revoke " + peer.name + "?", message: "This peer will lose access immediately.", confirmLabel: "Revoke", danger: true }).then(function (ok) {
1524
- if (!ok) return;
1525
- revokeBtn.disabled = true;
1526
- apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
1527
- .then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
1528
- .catch(function (err) { apiError(err, "Couldn't revoke peer"); revokeBtn.disabled = false; });
1529
- });
1530
- });
1531
- controls.appendChild(revokeBtn);
1532
- row.appendChild(controls);
1533
- peersListEl.appendChild(row);
1534
- });
1535
- }
1536
-
1537
- function renderInvites(invites) {
1538
- invitesEmptyEl.hidden = invites.length !== 0;
1539
- clear(invitesListEl);
1540
- invites.forEach(function (invite) {
1541
- var row = document.createElement("div");
1542
- row.className = "invite-row";
1543
- var main = document.createElement("div");
1544
- main.className = "peer-main";
1545
- var roleLine = document.createElement("div");
1546
- roleLine.className = "peer-name";
1547
- roleLine.textContent = invite.role.charAt(0).toUpperCase() + invite.role.slice(1);
1548
- main.appendChild(roleLine);
1549
- var sub = document.createElement("div");
1550
- sub.className = "muted";
1551
- sub.textContent = "expires " + fmtIn(invite.expiresAt);
1552
- main.appendChild(sub);
1553
- row.appendChild(main);
1554
-
1555
- var revokeBtn = document.createElement("button");
1556
- revokeBtn.type = "button";
1557
- revokeBtn.className = "btn btn-sm btn-ghost-danger";
1558
- revokeBtn.textContent = "\\u00d7";
1559
- revokeBtn.setAttribute("aria-label", "Revoke invite");
1560
- revokeBtn.addEventListener("click", function () {
1561
- revokeBtn.disabled = true;
1562
- apiFetch("/api/invites/" + encodeURIComponent(invite.id), { method: "DELETE" })
1563
- .then(function () { return loadHub(); })
1564
- .catch(function (err) { apiError(err, "Couldn't revoke invite"); revokeBtn.disabled = false; });
1565
- });
1566
- row.appendChild(revokeBtn);
1567
- invitesListEl.appendChild(row);
1568
- });
1569
- }
1570
-
1571
- // Re-rendered on every poll, so remember what the user unticked.
1572
- var inviteUnchecked = {};
1573
- function renderInviteHostChecks() {
1574
- Array.prototype.forEach.call(inviteHostsWrap.querySelectorAll("input"), function (cb) {
1575
- if (cb.checked) delete inviteUnchecked[cb.value];
1576
- else inviteUnchecked[cb.value] = true;
1577
- });
1578
- clear(inviteHostsWrap);
1579
- lastHosts.forEach(function (row) {
1580
- if (row.remote) return;
1581
- var label = document.createElement("label");
1582
- var cb = document.createElement("input");
1583
- cb.type = "checkbox";
1584
- cb.value = row.host;
1585
- cb.checked = !inviteUnchecked[row.host];
1586
- label.appendChild(cb);
1587
- label.appendChild(document.createTextNode(row.host));
1588
- inviteHostsWrap.appendChild(label);
1589
- });
1590
- }
1591
-
1592
- function updateInviteHostsVisibility() {
1593
- var isViewer = inviteRoleSelect.value === "viewer";
1594
- inviteHostsWrap.hidden = !isViewer;
1595
- if (isViewer) renderInviteHostChecks();
1596
- }
1597
- inviteRoleSelect.addEventListener("change", updateInviteHostsVisibility);
1598
-
1599
- function updateInviteCountdown() {
1600
- if (!inviteExpiresAt) return;
1601
- var remaining = Math.max(0, Math.round((inviteExpiresAt - Date.now()) / 1000));
1602
- if (remaining <= 0) {
1603
- inviteCountdownEl.textContent = "expired";
1604
- if (inviteTimer) { clearInterval(inviteTimer); inviteTimer = null; }
1605
- return;
1606
- }
1607
- var m = Math.floor(remaining / 60);
1608
- var s = remaining % 60;
1609
- inviteCountdownEl.textContent = m + ":" + (s < 10 ? "0" : "") + s;
1610
- }
1611
-
1612
- function showInviteResult(data) {
1613
- inviteStringEl.textContent = data.string;
1614
- inviteResultEl.hidden = false;
1615
- inviteExpiresAt = new Date(data.expiresAt).getTime();
1616
- if (inviteTimer) clearInterval(inviteTimer);
1617
- updateInviteCountdown();
1618
- inviteTimer = setInterval(updateInviteCountdown, 1000);
1619
- }
1620
-
1621
- inviteCreateBtn.addEventListener("click", function () {
1622
- var role = inviteRoleSelect.value;
1623
- var hosts;
1624
- if (role === "viewer") {
1625
- hosts = Array.prototype.slice.call(inviteHostsWrap.querySelectorAll("input:checked")).map(function (cb) { return cb.value; });
1626
- }
1627
- inviteCreateBtn.disabled = true;
1628
- inviteCreateBtn.classList.add("busy");
1629
- apiFetch("/api/invites", { method: "POST", body: JSON.stringify({ role: role, hosts: hosts }) })
1630
- .then(function (data) {
1631
- showInviteResult(data);
1632
- return loadHub();
1633
- })
1634
- .catch(function (err) { apiError(err, "Couldn't create pairing link"); })
1635
- .then(function () { inviteCreateBtn.disabled = false; inviteCreateBtn.classList.remove("busy"); });
1636
- });
1637
-
1638
- inviteCopyBtn.addEventListener("click", function () { copyText(inviteStringEl.textContent, inviteCopyBtn); });
1639
-
1640
- function renderHub(data) {
1641
- lastHub = data.hub;
1642
- var hub = data.hub;
1643
- var status = hub.status;
1644
-
1645
- var dotClass = status === "up" ? "up" : status === "starting" ? "warn" : status === "login" ? "accent" : status === "error" ? "down" : "";
1646
- hubDot.className = "dot" + (dotClass ? " " + dotClass : "");
1647
- var labels = { off: "Off", starting: "Starting\\u2026", login: "Login required", up: "Up", error: "Error" };
1648
- hubStatusText.textContent = labels[status] || status;
1649
-
1650
- hubUrlRow.hidden = !(status === "up" && hub.url);
1651
- if (status === "up" && hub.url) hubUrlEl.textContent = hub.url;
1652
-
1653
- hubLoginRow.hidden = !(status === "login" && hub.loginUrl);
1654
- if (status === "login" && hub.loginUrl) hubLoginLink.href = hub.loginUrl;
1655
-
1656
- hubQuickWarning.hidden = !(status === "up" && hub.mode === "quick");
1657
-
1658
- hubLocalhostRow.hidden = !data.config;
1659
- setSwitch(hubLocalhostSwitch, data.localhost !== false);
1660
- hubPanelUrl = status === "up" && hub.url ? hub.url : "";
1661
- hubPanelRow.hidden = !data.config;
1662
- renderHubPanel(data.panel === true);
1663
-
1664
- hubErrorRow.hidden = !(status === "error" && hub.error);
1665
- if (status === "error" && hub.error) hubErrorRow.textContent = hub.error;
1666
-
1667
- var running = status === "starting" || status === "login" || status === "up";
1668
- hubNamedForm.hidden = status === "up";
1669
- hubQuickBtn.hidden = status === "up";
1670
- hubStopBtn.hidden = !running;
1671
-
1672
- if (data.config && data.config.mode === "named" && data.config.domain && !hubDomainInput.value) {
1673
- hubDomainInput.value = data.config.domain;
1674
- }
1675
-
1676
- hubShareSection.hidden = status !== "up";
1677
- if (status === "up") updateInviteHostsVisibility();
1678
-
1679
- renderPeers(data.peers);
1680
- renderInvites(data.invites);
1681
- }
1682
-
1683
- function loadHub() {
1684
- return fetch("/api/hub").then(parseJsonOrThrow).then(renderHub).catch(function (err) { apiError(err, "Couldn't load remote access"); });
1685
- }
1686
-
1687
- hubUrlCopyBtn.addEventListener("click", function () { copyText(hubUrlEl.textContent, hubUrlCopyBtn); });
1688
-
1689
- hubNamedForm.addEventListener("submit", function (e) {
1690
- e.preventDefault();
1691
- var domain = hubDomainInput.value.trim();
1692
- if (!domain) { hubDomainInput.focus(); return; }
1693
- var tunnel = hubTunnelNameInput.value.trim();
1694
- hubNamedSubmitBtn.disabled = true;
1695
- hubNamedSubmitBtn.classList.add("busy");
1696
- apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "named", domain: domain, tunnel: tunnel || undefined }) })
1697
- .then(function () { showToast("Setting up your domain\\u2026", "success"); return loadHub(); })
1698
- .catch(function (err) { apiError(err, "Couldn't start setup"); })
1699
- .then(function () { hubNamedSubmitBtn.disabled = false; hubNamedSubmitBtn.classList.remove("busy"); });
1700
- });
1701
-
1702
- hubQuickBtn.addEventListener("click", function () {
1703
- hubQuickBtn.disabled = true;
1704
- hubQuickBtn.classList.add("busy");
1705
- apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "quick" }) })
1706
- .then(function () { showToast("Starting quick tunnel\\u2026", "success"); return loadHub(); })
1707
- .catch(function (err) { apiError(err, "Couldn't start quick tunnel"); })
1708
- .then(function () { hubQuickBtn.disabled = false; hubQuickBtn.classList.remove("busy"); });
1709
- });
1710
-
1711
- hubStopBtn.addEventListener("click", function () {
1712
- confirmDialog({ title: "Stop remote access?", message: "Connected peers will be disconnected.", confirmLabel: "Stop remote access", danger: true }).then(function (ok) {
1713
- if (!ok) return;
1714
- hubStopBtn.disabled = true;
1715
- hubStopBtn.classList.add("busy");
1716
- apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
1717
- .then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
1718
- .catch(function (err) { apiError(err, "Couldn't stop remote access"); })
1719
- .then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
1720
- });
1721
- });
1722
-
1723
- // ---------- remote access: connected machines (receiver) ----------
1724
-
1725
- function showConnectError(message) {
1726
- connectErrorEl.textContent = message;
1727
- connectErrorEl.hidden = false;
1728
- }
1729
- function hideConnectError() {
1730
- connectErrorEl.hidden = true;
1731
- }
1732
-
1733
- function renderRemotes(remotes) {
1734
- lastRemotes = remotes;
1735
- remotesEmptyEl.hidden = remotes.length !== 0;
1736
- sbRemotesCountEl.textContent = String(remotes.length);
1737
- sbRemotesCountEl.hidden = remotes.length === 0;
1738
- clear(remotesListEl);
1739
- remotes.forEach(function (remote) {
1740
- var item = document.createElement("div");
1741
- item.className = "remote-item";
1742
-
1743
- var head = document.createElement("div");
1744
- head.className = "remote-head";
1745
- var main = document.createElement("div");
1746
- main.className = "remote-main";
1747
- var nameLine = document.createElement("div");
1748
- nameLine.className = "peer-name";
1749
- nameLine.textContent = remote.name;
1750
- main.appendChild(nameLine);
1751
- var sub = document.createElement("div");
1752
- sub.className = "muted";
1753
- sub.textContent = (remote.sender && remote.sender.hostname) || "";
1754
- main.appendChild(sub);
1755
- head.appendChild(main);
1756
-
1757
- var badges = document.createElement("div");
1758
- badges.className = "badges";
1759
- var roleBadge = document.createElement("span");
1760
- roleBadge.className = "badge badge-role badge-accent";
1761
- roleBadge.textContent = remote.role;
1762
- badges.appendChild(roleBadge);
1763
- var statusBadge = document.createElement("span");
1764
- statusBadge.className = "badge " + (remote.status === "ok" ? "badge-up" : "badge-down");
1765
- statusBadge.textContent = remote.status === "ok" ? "connected" : "error";
1766
- if (remote.status !== "ok" && remote.error) statusBadge.title = remote.error;
1767
- badges.appendChild(statusBadge);
1768
- head.appendChild(badges);
1769
- item.appendChild(head);
1770
-
1771
- var urlRow = document.createElement("div");
1772
- urlRow.className = "hub-row";
1773
- var urlText = document.createElement("span");
1774
- urlText.className = "hub-url";
1775
- urlText.textContent = remote.url;
1776
- urlRow.appendChild(urlText);
1777
- var urlEditBtn = document.createElement("button");
1778
- urlEditBtn.type = "button";
1779
- urlEditBtn.className = "btn btn-sm btn-ghost";
1780
- urlEditBtn.textContent = "Update URL";
1781
- urlEditBtn.addEventListener("click", function () {
1782
- promptDialog({ title: "Update URL", message: "New URL for " + remote.name, label: "URL", value: remote.url, confirmLabel: "Save" }).then(function (next) {
1783
- if (!next || !next.trim() || next.trim() === remote.url) return;
1784
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
1785
- .then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
1786
- .catch(function (err) { apiError(err, "Couldn't update URL"); });
1787
- });
1788
- });
1789
- urlRow.appendChild(urlEditBtn);
1790
- item.appendChild(urlRow);
1791
- if (remote.role === "admin") item.appendChild(remoteLocalRow(remote));
1792
-
1793
- var actions = document.createElement("div");
1794
- actions.className = "remote-controls";
1795
- var syncBtn = document.createElement("button");
1796
- syncBtn.type = "button";
1797
- syncBtn.className = "btn btn-sm";
1798
- syncBtn.textContent = "Sync";
1799
- syncBtn.addEventListener("click", function () {
1800
- syncBtn.disabled = true;
1801
- syncBtn.classList.add("busy");
1802
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/sync", { method: "POST" })
1803
- .then(function () { showToast("Synced " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1804
- .catch(function (err) { apiError(err, "Couldn't sync " + remote.name); })
1805
- .then(function () { syncBtn.disabled = false; syncBtn.classList.remove("busy"); });
1806
- });
1807
- actions.appendChild(syncBtn);
1808
- var disconnectBtn = document.createElement("button");
1809
- disconnectBtn.type = "button";
1810
- disconnectBtn.className = "btn btn-sm btn-danger";
1811
- disconnectBtn.textContent = "Disconnect";
1812
- disconnectBtn.addEventListener("click", function () {
1813
- confirmDialog({ title: "Disconnect from " + remote.name + "?", message: "This removes its hosts too.", confirmLabel: "Disconnect", danger: true }).then(function (ok) {
1814
- if (!ok) return;
1815
- disconnectBtn.disabled = true;
1816
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
1817
- .then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1818
- .catch(function (err) { apiError(err, "Couldn't disconnect"); disconnectBtn.disabled = false; });
1819
- });
1820
- });
1821
- actions.appendChild(disconnectBtn);
1822
- item.appendChild(actions);
1823
-
1824
- var hostsWrap = document.createElement("div");
1825
- hostsWrap.className = "remote-hosts-list";
1826
- if (remote.available === null) {
1827
- var errLine = document.createElement("div");
1828
- errLine.className = "dim";
1829
- errLine.textContent = "Couldn't load hosts: " + (remote.error || "unknown error");
1830
- hostsWrap.appendChild(errLine);
1831
- } else {
1832
- (remote.available || []).forEach(function (h) {
1833
- var hostRow = document.createElement("div");
1834
- hostRow.className = "remote-host-row";
1835
- var hostMain = document.createElement("div");
1836
- hostMain.className = "mono";
1837
- var aliases = (remote.mapped || []).filter(function (m) { return m.host === h.host; }).map(function (m) { return m.local; });
1838
- hostMain.textContent = h.host + (aliases.length ? " \\u2192 " + aliases.join(", ") : "");
1839
- hostRow.appendChild(hostMain);
1840
-
1841
- if (!aliases.length) {
1842
- var aliasForm = document.createElement("div");
1843
- aliasForm.className = "mini-form";
1844
- var aliasInput = document.createElement("input");
1845
- aliasInput.type = "text";
1846
- aliasInput.className = "input input-sm";
1847
- aliasInput.placeholder = h.host;
1848
- aliasInput.setAttribute("aria-label", "Local alias for " + h.host);
1849
- var aliasBtn = document.createElement("button");
1850
- aliasBtn.type = "button";
1851
- aliasBtn.className = "btn btn-sm";
1852
- aliasBtn.textContent = "Add alias";
1853
- aliasBtn.addEventListener("click", function () {
1854
- var local = aliasInput.value.trim();
1855
- if (!local) { aliasInput.focus(); return; }
1856
- aliasBtn.disabled = true;
1857
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/aliases", { method: "POST", body: JSON.stringify({ host: h.host, local: local }) })
1858
- .then(function () { showToast("Added alias " + local, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1859
- .catch(function (err) { apiError(err, "Couldn't add alias"); aliasBtn.disabled = false; });
1860
- });
1861
- aliasForm.appendChild(aliasInput);
1862
- aliasForm.appendChild(aliasBtn);
1863
- hostRow.appendChild(aliasForm);
1864
- } else if (remote.role === "admin") {
1865
- var delBtn = document.createElement("button");
1866
- delBtn.type = "button";
1867
- delBtn.className = "btn btn-sm btn-danger";
1868
- delBtn.textContent = "Delete";
1869
- delBtn.addEventListener("click", function () {
1870
- confirmDialog({ title: "Delete " + h.host + " on " + remote.name + "?", message: "This can't be undone.", confirmLabel: "Delete", danger: true }).then(function (ok) {
1871
- if (!ok) return;
1872
- delBtn.disabled = true;
1873
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
1874
- .then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1875
- .catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
1876
- });
1877
- });
1878
- hostRow.appendChild(delBtn);
1879
- }
1880
- hostsWrap.appendChild(hostRow);
1881
- });
1882
- }
1883
- item.appendChild(hostsWrap);
1884
-
1885
- if (remote.role === "editor" || remote.role === "admin") {
1886
- var addHostForm = document.createElement("div");
1887
- addHostForm.className = "mini-form";
1888
- var addHostIn = document.createElement("input");
1889
- addHostIn.type = "text";
1890
- addHostIn.className = "input input-sm";
1891
- addHostIn.placeholder = "host";
1892
- addHostIn.setAttribute("aria-label", "New host on " + remote.name);
1893
- var addTargetIn = document.createElement("input");
1894
- addTargetIn.type = "text";
1895
- addTargetIn.className = "input input-sm";
1896
- addTargetIn.placeholder = "target";
1897
- addTargetIn.setAttribute("aria-label", "Target for new host on " + remote.name);
1898
- var addHostBtn = document.createElement("button");
1899
- addHostBtn.type = "button";
1900
- addHostBtn.className = "btn btn-sm";
1901
- addHostBtn.textContent = "Add host on " + remote.name;
1902
- addHostBtn.addEventListener("click", function () {
1903
- var h = addHostIn.value.trim();
1904
- var t = addTargetIn.value.trim();
1905
- if (!h || !t) return;
1906
- addHostBtn.disabled = true;
1907
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts", { method: "POST", body: JSON.stringify({ host: h, target: t }) })
1908
- .then(function () {
1909
- showToast("Added " + h + " on " + remote.name, "success");
1910
- addHostIn.value = "";
1911
- addTargetIn.value = "";
1912
- return Promise.all([loadRemotes(), loadHosts()]);
1913
- })
1914
- .catch(function (err) { apiError(err, "Couldn't add host"); })
1915
- .then(function () { addHostBtn.disabled = false; });
1916
- });
1917
- addHostForm.appendChild(addHostIn);
1918
- addHostForm.appendChild(addTargetIn);
1919
- addHostForm.appendChild(addHostBtn);
1920
- item.appendChild(addHostForm);
1921
- }
1922
-
1923
- remotesListEl.appendChild(item);
1924
- });
1925
- }
1926
-
1927
- function loadRemotes() {
1928
- return fetch("/api/remotes").then(parseJsonOrThrow).then(function (data) { renderRemotes(data.remotes || []); }).catch(function (err) { apiError(err, "Couldn't load remotes"); });
1929
- }
1930
-
1931
- connectForm.addEventListener("submit", function (e) {
1932
- e.preventDefault();
1933
- hideConnectError();
1934
- var value = connectStringInput.value.trim();
1935
- if (!value) { showConnectError("Paste a pairing link first."); return; }
1936
- var name = connectNameInput.value.trim();
1937
- var domain = connectDomainInput.value.trim().toLowerCase();
1938
- connectSubmitBtn.disabled = true;
1939
- connectSubmitBtn.classList.add("busy");
1940
- apiFetch("/api/remotes", { method: "POST", body: JSON.stringify({ string: value, name: name || undefined, domain: domain || undefined }) })
1941
- .then(function (data) {
1942
- var r = data.remote;
1943
- showToast("Connected to " + r.name, "success", r.domain && r.role === "admin" ? "Its dashboard: " + localUrl(r.domain) : (domain && r.role !== "admin" ? "Domain ignored: you're " + r.role + ", not admin." : undefined));
1944
- connectForm.reset();
1945
- return Promise.all([loadRemotes(), loadHosts()]);
1946
- })
1947
- .catch(function (err) { showConnectError(err.message); })
1948
- .then(function () { connectSubmitBtn.disabled = false; connectSubmitBtn.classList.remove("busy"); });
1949
- });
1950
-
1951
- function localUrl(domain, port) {
1952
- return location.protocol + "//" + (port ? port + "." : "") + domain + ".localhost" + (location.port ? ":" + location.port : "") + "/";
1953
- }
1954
-
1955
- function saveDomain(remote, domain) {
1956
- return apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ domain: domain }) })
1957
- .then(function (data) {
1958
- var next = data && data.remote ? data.remote.domain : null;
1959
- showToast(next ? "Localhost of " + remote.name + " is " + next + ".localhost" : "Removed the localhost domain", "success");
1960
- return loadRemotes();
1961
- })
1962
- .catch(function (err) { apiError(err, "Couldn't set the domain"); });
1963
- }
1964
-
1965
- /** Admin remotes: link to the sender's whole localhost, and an inline editor for the domain. */
1966
- function remoteLocalRow(remote) {
1967
- var row = document.createElement("div");
1968
- row.className = "hub-row remote-local";
1969
- if (remote.localhost === false) {
1970
- var off = document.createElement("span");
1971
- off.className = "dim";
1972
- off.textContent = "Localhost access is off on " + ((remote.sender && remote.sender.hostname) || remote.name) + ".";
1973
- row.appendChild(off);
1974
- return row;
1975
- }
1976
- var label = document.createElement("span");
1977
- label.className = "label";
1978
- label.textContent = "Localhost";
1979
- row.appendChild(label);
1980
-
1981
- var form = document.createElement("form");
1982
- form.className = "mini-form";
1983
- form.hidden = Boolean(remote.domain);
1984
- var input = document.createElement("input");
1985
- input.type = "text";
1986
- input.className = "input input-sm";
1987
- input.value = remote.domain || "";
1988
- input.placeholder = "office";
1989
- input.setAttribute("aria-label", "Localhost domain for " + remote.name);
1990
- var save = document.createElement("button");
1991
- save.type = "submit";
1992
- save.className = "btn btn-sm btn-primary";
1993
- save.textContent = "Save";
1994
- var random = document.createElement("button");
1995
- random.type = "button";
1996
- random.className = "btn btn-sm";
1997
- random.textContent = "Random";
1998
- random.addEventListener("click", function () { saveDomain(remote, "random"); });
1999
- form.appendChild(input);
2000
- form.appendChild(save);
2001
- form.appendChild(random);
2002
- form.addEventListener("submit", function (e) {
2003
- e.preventDefault();
2004
- var value = input.value.trim().toLowerCase();
2005
- if (!value) { input.focus(); return; }
2006
- saveDomain(remote, value);
2007
- });
2008
-
2009
- if (remote.domain) {
2010
- var link = document.createElement("a");
2011
- link.href = localUrl(remote.domain);
2012
- link.target = "_blank";
2013
- link.rel = "noopener noreferrer";
2014
- link.textContent = remote.domain + ".localhost";
2015
- link.title = "Its dashboard. Any port: " + localUrl(remote.domain, "<port>");
2016
- row.appendChild(link);
2017
- row.appendChild(makeCopyButton(function () { return localUrl(remote.domain); }));
2018
- var change = document.createElement("button");
2019
- change.type = "button";
2020
- change.className = "btn btn-sm btn-ghost";
2021
- change.textContent = "Change";
2022
- change.addEventListener("click", function () { form.hidden = !form.hidden; if (!form.hidden) input.focus(); });
2023
- row.appendChild(change);
2024
- var remove = document.createElement("button");
2025
- remove.type = "button";
2026
- remove.className = "btn btn-sm btn-ghost";
2027
- remove.textContent = "Remove";
2028
- remove.addEventListener("click", function () { saveDomain(remote, null); });
2029
- row.appendChild(remove);
2030
- }
2031
- row.appendChild(form);
2032
- return row;
2033
- }
2034
-
2035
- function renderHubPanel(on) {
2036
- setSwitch(hubPanelSwitch, on);
2037
- // Turning it on needs a password; turning it off never does.
2038
- hubPanelSwitch.disabled = !on && !UI_AUTH;
2039
- if (!UI_AUTH && !on) hubPanelSub.textContent = "Set a dashboard password in Settings first. Visitors sign in with it.";
2040
- else if (on) hubPanelSub.textContent = "Open " + (hubPanelUrl || "the public URL") + " in any browser and sign in with the dashboard password.";
2041
- else hubPanelSub.textContent = "Open this dashboard from any browser at the public URL, after signing in with the dashboard password.";
2042
- }
2043
-
2044
- hubPanelSwitch.addEventListener("click", function () {
2045
- var next = hubPanelSwitch.getAttribute("aria-checked") !== "true";
2046
- hubPanelSwitch.disabled = true;
2047
- apiFetch("/api/hub/panel", { method: "PUT", body: JSON.stringify({ enabled: next }) })
2048
- .then(function () {
2049
- renderHubPanel(next);
2050
- showToast(next ? "The dashboard is shared at the public URL" : "The dashboard is no longer shared", "success");
2051
- })
2052
- .catch(function (err) {
2053
- apiError(err, "Couldn't change dashboard sharing");
2054
- renderHubPanel(!next);
2055
- });
2056
- });
2057
-
2058
- hubLocalhostSwitch.addEventListener("click", function () {
2059
- var next = hubLocalhostSwitch.getAttribute("aria-checked") !== "true";
2060
- hubLocalhostSwitch.disabled = true;
2061
- apiFetch("/api/hub/localhost", { method: "PUT", body: JSON.stringify({ enabled: next }) })
2062
- .then(function () {
2063
- setSwitch(hubLocalhostSwitch, next);
2064
- showToast(next ? "Admins can open this dashboard and any port" : "Localhost access is off", "success");
2065
- })
2066
- .catch(function (err) { apiError(err, "Couldn't change localhost access"); })
2067
- .then(function () { hubLocalhostSwitch.disabled = false; });
2068
- });
2069
-
2070
- function refreshHubAndRemotes() {
2071
- if (proxyStopped) return Promise.resolve();
2072
- return Promise.all([loadHub(), loadRemotes()]).then(function () { renderOverview(); });
2073
- }
2074
-
2075
- caToggle.addEventListener("click", function () {
2076
- if (caBusy || !lastStatus) return;
2077
- var next = !(lastStatus.system.caTrusted === true);
2078
- caBusy = true;
2079
- caToggle.disabled = true;
2080
- caToggle.classList.add("busy");
2081
- apiFetch("/api/trust", { method: "POST", body: JSON.stringify({ trusted: next }) })
2082
- .then(function () {
2083
- showToast("CA trust setting updated", "success");
2084
- return refreshAll();
2085
- })
2086
- .catch(function (err) { apiError(err, "Couldn't update CA trust"); })
2087
- .then(function () {
2088
- caBusy = false;
2089
- caToggle.disabled = false;
2090
- caToggle.classList.remove("busy");
2091
- });
2092
- });
2093
-
2094
- startupToggle.addEventListener("click", function () {
2095
- if (startupBusy || !lastStatus) return;
2096
- var next = !lastStatus.system.startup.enabled;
2097
- startupBusy = true;
2098
- startupToggle.disabled = true;
2099
- startupToggle.classList.add("busy");
2100
- apiFetch("/api/startup", { method: "POST", body: JSON.stringify({ enabled: next }) })
2101
- .then(function () {
2102
- showToast("Startup setting updated", "success");
2103
- return refreshAll();
2104
- })
2105
- .catch(function (err) { apiError(err, "Couldn't update start at boot"); })
2106
- .then(function () {
2107
- startupBusy = false;
2108
- startupToggle.disabled = false;
2109
- startupToggle.classList.remove("busy");
2110
- });
2111
- });
2112
-
2113
- stopProxyBtn.addEventListener("click", function () {
2114
- if (proxyStopped) return;
2115
- confirmDialog({ title: "Stop the locadot proxy?", message: "Run \\"locadot start\\" to bring it back up.", confirmLabel: "Stop proxy", danger: true }).then(function (ok) {
2116
- if (!ok) return;
2117
- stopProxyBtn.disabled = true;
2118
- stopProxyBtn.classList.add("busy");
2119
- apiFetch("/api/proxy/stop", { method: "POST" })
2120
- .then(function () {
2121
- proxyStopped = true;
2122
- clear(stoppedBanner);
2123
- stoppedBanner.appendChild(document.createTextNode("Proxy stopped. Run "));
2124
- var code = document.createElement("code");
2125
- code.className = "mono";
2126
- code.textContent = "locadot start";
2127
- stoppedBanner.appendChild(code);
2128
- stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
2129
- stoppedBanner.hidden = false;
2130
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
2131
- if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
2132
- showToast("Proxy stopped", "warn");
2133
- })
2134
- .catch(function (err) {
2135
- apiError(err, "Couldn't stop the proxy");
2136
- stopProxyBtn.disabled = false;
2137
- stopProxyBtn.classList.remove("busy");
2138
- });
2139
- });
2140
- });
2141
-
2142
- cfInstallBtn.addEventListener("click", function () {
2143
- if (cfInstallBusy) return;
2144
- cfInstallBusy = true;
2145
- cfInstallBtn.disabled = true;
2146
- cfInstallBtn.classList.add("busy");
2147
- var prevText = cfInstallBtn.textContent;
2148
- cfInstallBtn.textContent = "Installing\\u2026";
2149
- apiFetch("/api/cloudflared/install", { method: "POST", body: JSON.stringify({}) })
2150
- .then(function () {
2151
- showToast("cloudflared installed", "success");
2152
- return refreshAll();
2153
- })
2154
- .catch(function (err) { apiError(err, "Couldn't install cloudflared"); })
2155
- .then(function () {
2156
- cfInstallBusy = false;
2157
- cfInstallBtn.disabled = false;
2158
- cfInstallBtn.classList.remove("busy");
2159
- cfInstallBtn.textContent = prevText;
2160
- });
2161
- });
2162
-
2163
- // ---------- add host form ----------
2164
-
2165
- function showAddError(message, hint) {
2166
- clear(addErrorEl);
2167
- var text = document.createElement("span");
2168
- text.textContent = message;
2169
- addErrorEl.appendChild(text);
2170
- if (hint) {
2171
- var code = document.createElement("code");
2172
- code.className = "mono";
2173
- code.textContent = hint;
2174
- addErrorEl.appendChild(code);
2175
- addErrorEl.appendChild(makeCopyButton(function () { return hint; }));
2176
- }
2177
- addErrorEl.hidden = false;
2178
- }
2179
-
2180
- function hideAddError() {
2181
- addErrorEl.hidden = true;
2182
- clear(addErrorEl);
2183
- }
2184
-
2185
- function markFieldInvalid(el, invalid) {
2186
- el.classList.toggle("invalid", !!invalid);
2187
- el.setAttribute("aria-invalid", invalid ? "true" : "false");
2188
- }
2189
-
2190
- addHostInput.addEventListener("input", function () {
2191
- if (addHostInput.value.trim()) markFieldInvalid(addHostInput, false);
2192
- hideAddError();
2193
- });
2194
- addTargetInput.addEventListener("input", function () {
2195
- if (addTargetInput.value.trim()) markFieldInvalid(addTargetInput, false);
2196
- hideAddError();
2197
- });
2198
-
2199
- addForm.addEventListener("focusout", function (e) {
2200
- if (e.relatedTarget && addForm.contains(e.relatedTarget)) return;
2201
- hideAddError();
2202
- markFieldInvalid(addHostInput, false);
2203
- markFieldInvalid(addTargetInput, false);
2204
- });
2205
-
2206
- addForm.addEventListener("submit", function (e) {
2207
- e.preventDefault();
2208
- hideAddError();
2209
- var hostVal = addHostInput.value.trim();
2210
- var targetVal = addTargetInput.value.trim();
2211
- if (!hostVal || !targetVal) {
2212
- markFieldInvalid(addHostInput, !hostVal);
2213
- markFieldInvalid(addTargetInput, !targetVal);
2214
- showAddError("Host and target are required.");
2215
- (!hostVal ? addHostInput : addTargetInput).focus();
2216
- return;
2217
- }
2218
- markFieldInvalid(addHostInput, false);
2219
- markFieldInvalid(addTargetInput, false);
2220
- if (hostVal.indexOf(".") === -1) hostVal = hostVal + ".localhost";
2221
- var insecureVal = !!addInsecureInput.checked;
2222
- var corsVal = !!addCorsInput.checked;
2223
- addSubmitBtn.disabled = true;
2224
- addSubmitBtn.classList.add("busy");
2225
- apiFetch("/api/hosts", { method: "POST", body: JSON.stringify({ host: hostVal, target: targetVal, insecure: insecureVal, cors: corsVal }) })
2226
- .then(function () {
2227
- addForm.reset();
2228
- applyHostDefaults();
2229
- showToast("Added " + hostVal, "success");
2230
- return loadHosts();
2231
- })
2232
- .catch(function (err) {
2233
- showAddError(err.message, err.hint);
2234
- })
2235
- .then(function () {
2236
- addSubmitBtn.disabled = false;
2237
- addSubmitBtn.classList.remove("busy");
2238
- });
2239
- });
2240
-
2241
- // ---------- hosts table ----------
2242
-
2243
- function statusPill(row) {
2244
- var wrap = document.createElement("span");
2245
- wrap.className = "pill";
2246
- var dot = document.createElement("span");
2247
- dot.className = "dot " + (row.probe.up ? "up" : "down");
2248
- wrap.appendChild(dot);
2249
- var text = document.createElement("span");
2250
- if (row.probe.up) {
2251
- var statusPart = row.probe.status !== undefined ? String(row.probe.status) : "?";
2252
- var msPart = typeof row.probe.ms === "number" ? Math.round(row.probe.ms) + "ms" : "-";
2253
- text.textContent = statusPart + " \\u00b7 " + msPart;
2254
- } else {
2255
- text.textContent = row.probe.error || "down";
2256
- }
2257
- wrap.appendChild(text);
2258
- return wrap;
2259
- }
2260
-
2261
- function makeCell(content, cls) {
2262
- var td = document.createElement("td");
2263
- if (cls) td.className = cls;
2264
- if (content !== undefined && content !== null) td.textContent = content;
2265
- return td;
2266
- }
2267
-
2268
- function tunnelInfo(row) {
2269
- return row.tunnel || { enabled: false, status: "off" };
2270
- }
2271
-
2272
- function tunnelCell(row) {
2273
- var td = document.createElement("td");
2274
- td.className = "tunnel-cell";
2275
- var t = tunnelInfo(row);
2276
-
2277
- var statusWrap = document.createElement("div");
2278
- statusWrap.className = "tunnel-status";
2279
- if (t.status === "up" && t.url) {
2280
- var link = document.createElement("a");
2281
- link.href = t.url;
2282
- link.target = "_blank";
2283
- link.rel = "noopener noreferrer";
2284
- link.className = "mono";
2285
- link.textContent = t.url.replace("https://", "");
2286
- link.title = t.url;
2287
- statusWrap.appendChild(link);
2288
- if (t.mode === "custom") {
2289
- var modeBadge = document.createElement("span");
2290
- modeBadge.className = "badge badge-accent";
2291
- modeBadge.textContent = "custom";
2292
- statusWrap.appendChild(modeBadge);
2293
- }
2294
- statusWrap.appendChild(makeCopyButton(function () { return t.url; }));
2295
- } else if (t.status === "starting") {
2296
- var spin = document.createElement("span");
2297
- spin.className = "spinner";
2298
- statusWrap.appendChild(spin);
2299
- statusWrap.appendChild(document.createTextNode("Starting\\u2026"));
2300
- } else if (t.status === "login") {
2301
- var loginDot = document.createElement("span");
2302
- loginDot.className = "dot warn";
2303
- statusWrap.appendChild(loginDot);
2304
- var loginText = document.createElement("span");
2305
- loginText.className = "tunnel-login";
2306
- var loginLabel = document.createElement("span");
2307
- loginLabel.textContent = "Waiting for Cloudflare login";
2308
- loginText.appendChild(loginLabel);
2309
- if (t.loginUrl) {
2310
- var loginLink = document.createElement("a");
2311
- loginLink.href = t.loginUrl;
2312
- loginLink.target = "_blank";
2313
- loginLink.rel = "noopener noreferrer";
2314
- loginLink.textContent = "Log in to Cloudflare \u2197";
2315
- loginText.appendChild(loginLink);
2316
- }
2317
- statusWrap.appendChild(loginText);
2318
- } else if (t.status === "error") {
2319
- var errDot = document.createElement("span");
2320
- errDot.className = "dot down";
2321
- statusWrap.appendChild(errDot);
2322
- var errText = document.createElement("span");
2323
- errText.className = "tunnel-error-text";
2324
- errText.textContent = "Error: " + (t.error || "tunnel failed to start");
2325
- errText.title = t.error || "Tunnel error";
2326
- statusWrap.appendChild(errText);
2327
- } else {
2328
- var dash = document.createElement("span");
2329
- dash.className = "dim";
2330
- dash.textContent = "\\u2014";
2331
- statusWrap.appendChild(dash);
2332
- }
2333
- td.appendChild(statusWrap);
2334
- return td;
2335
- }
2336
-
2337
- function shareButton(row) {
2338
- var t = tunnelInfo(row);
2339
- var isOn = t.enabled || t.status === "up" || t.status === "starting" || t.status === "login";
2340
- var isRetry = !isOn && t.status === "error";
2341
- var shareBtn = document.createElement("button");
2342
- shareBtn.type = "button";
2343
- shareBtn.className = "btn btn-sm" + (isOn ? " btn-danger" : "");
2344
- shareBtn.textContent = isOn ? "Unshare" : (isRetry ? "Retry" : "Share");
2345
- shareBtn.title = isOn ? "Stop the public tunnel" : "Share on a public URL";
2346
- if (!cloudflaredInstalled && !isOn) {
2347
- shareBtn.disabled = true;
2348
- shareBtn.title = "Install cloudflared first";
2349
- }
2350
- shareBtn.addEventListener("click", function () {
2351
- if (isOn) {
2352
- confirmDialog({ title: "Stop sharing " + row.host + "?", message: "The public URL for " + row.host + " stops working.", confirmLabel: "Stop sharing", danger: true }).then(function (ok) {
2353
- if (!ok) return;
2354
- shareBtn.disabled = true;
2355
- shareBtn.classList.add("busy");
2356
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ tunnel: false }) })
2357
- .then(function () {
2358
- showToast("Stopping tunnel for " + row.host, "success");
2359
- return loadHosts();
2360
- })
2361
- .catch(function (err) {
2362
- apiError(err, "Couldn't change sharing for " + row.host);
2363
- shareBtn.disabled = false;
2364
- shareBtn.classList.remove("busy");
2365
- });
2366
- });
2367
- } else {
2368
- openShareDialog(row);
2369
- }
2370
- });
2371
- return shareBtn;
2372
- }
2373
-
2374
- // ---------- share dialog ----------
2375
-
2376
- var shareDialogEl = document.getElementById("share-dialog");
2377
- var shareHostEl = document.getElementById("share-host");
2378
- var shareCloseBtn = document.getElementById("share-close");
2379
- var shareCancelBtn = document.getElementById("share-cancel");
2380
- var shareSubmitBtn = document.getElementById("share-submit");
2381
- var shareOptionRandom = document.getElementById("share-option-random");
2382
- var shareOptionCustom = document.getElementById("share-option-custom");
2383
- var shareCustomExtra = document.getElementById("share-custom-extra");
2384
- var shareDomainInput = document.getElementById("share-domain-input");
2385
- var shareDomainError = document.getElementById("share-domain-error");
2386
- var shareDomainHint = document.getElementById("share-domain-hint");
2387
- var shareErrorEl = document.getElementById("share-error");
2388
- var shareRow = null;
2389
- var shareMode = "random";
2390
- var HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/;
2391
-
2392
- function normalizeHostname(value) {
2393
- return String(value || "").trim().toLowerCase().replace(/^https?:\\/\\//, "").replace(/\\/$/, "");
2394
- }
2395
-
2396
- function validateHostname(raw) {
2397
- var value = normalizeHostname(raw);
2398
- if (!value) return "Enter a hostname.";
2399
- if (value.slice(-10) === ".localhost") return "Hostname can't be a .localhost address.";
2400
- if (!HOSTNAME_RE.test(value)) return "Enter a valid hostname, e.g. app.example.com.";
2401
- return null;
2402
- }
2403
-
2404
- function setShareMode(mode) {
2405
- shareMode = mode;
2406
- shareOptionRandom.setAttribute("aria-checked", String(mode === "random"));
2407
- shareOptionCustom.setAttribute("aria-checked", String(mode === "custom"));
2408
- shareCustomExtra.hidden = mode !== "custom";
2409
- shareDomainHint.textContent = cloudflareLoggedIn
2410
- ? "The DNS record is created or updated for you."
2411
- : "You'll be asked to log in to Cloudflare once; the login link appears on the row.";
2412
- shareDomainError.hidden = true;
2413
- shareDomainError.textContent = "";
2414
- shareErrorEl.hidden = true;
2415
- shareErrorEl.textContent = "";
2416
- if (mode === "custom") setTimeout(function () { shareDomainInput.focus(); }, 0);
2417
- }
2418
-
2419
- function closeShareDialog() {
2420
- shareRow = null;
2421
- shareDialogEl.close();
2422
- }
2423
-
2424
- function openShareDialog(row) {
2425
- shareRow = row;
2426
- shareHostEl.textContent = row.host;
2427
- shareErrorEl.hidden = true;
2428
- shareErrorEl.textContent = "";
2429
- shareDomainError.hidden = true;
2430
- shareDomainError.textContent = "";
2431
- var hasDomain = typeof row.tunnelDomain === "string" && !!row.tunnelDomain;
2432
- shareDomainInput.value = hasDomain ? row.tunnelDomain : "";
2433
- setShareMode(hasDomain ? "custom" : "random");
2434
- shareSubmitBtn.disabled = false;
2435
- shareSubmitBtn.classList.remove("busy");
2436
- shareCancelBtn.disabled = false;
2437
- shareDialogEl.showModal();
2438
- }
2439
-
2440
- shareOptionRandom.addEventListener("click", function () { setShareMode("random"); });
2441
- shareOptionCustom.addEventListener("click", function () { setShareMode("custom"); });
2442
- shareOptionRandom.addEventListener("keydown", function (e) {
2443
- if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); setShareMode("custom"); shareOptionCustom.focus(); }
2444
- });
2445
- shareOptionCustom.addEventListener("keydown", function (e) {
2446
- if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); setShareMode("random"); shareOptionRandom.focus(); }
2447
- });
2448
- shareCancelBtn.addEventListener("click", closeShareDialog);
2449
- shareCloseBtn.addEventListener("click", closeShareDialog);
2450
- shareDialogEl.addEventListener("click", function (e) {
2451
- if (e.target === shareDialogEl) closeShareDialog();
2452
- });
2453
- shareDomainInput.addEventListener("keydown", function (e) {
2454
- if (e.key === "Enter") { e.preventDefault(); shareSubmitBtn.click(); }
2455
- });
2456
-
2457
- shareSubmitBtn.addEventListener("click", function () {
2458
- if (!shareRow) return;
2459
- var row = shareRow;
2460
- var body = { tunnel: true };
2461
- if (shareMode === "custom") {
2462
- var err = validateHostname(shareDomainInput.value);
2463
- if (err) {
2464
- shareDomainError.textContent = err;
2465
- shareDomainError.hidden = false;
2466
- return;
2467
- }
2468
- body.domain = normalizeHostname(shareDomainInput.value);
2469
- }
2470
- shareSubmitBtn.disabled = true;
2471
- shareSubmitBtn.classList.add("busy");
2472
- shareCancelBtn.disabled = true;
2473
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify(body) })
2474
- .then(function () {
2475
- showToast("Starting tunnel for " + row.host, "success");
2476
- closeShareDialog();
2477
- return loadHosts();
2478
- })
2479
- .catch(function (err2) {
2480
- shareErrorEl.textContent = (err2 && err2.message) ? err2.message : String(err2);
2481
- shareErrorEl.hidden = false;
2482
- })
2483
- .then(function () {
2484
- shareSubmitBtn.disabled = false;
2485
- shareSubmitBtn.classList.remove("busy");
2486
- shareCancelBtn.disabled = false;
2487
- });
2488
- });
2489
-
2490
- var allowDialog = document.getElementById("allow-dialog");
2491
- var allowHostEl = document.getElementById("allow-host");
2492
- var allowWarnEl = document.getElementById("allow-warn");
2493
- var allowListEl = document.getElementById("allow-list");
2494
- var allowInput = document.getElementById("allow-input");
2495
- var allowAddBtn = document.getElementById("allow-add");
2496
- var allowErrorEl = document.getElementById("allow-error");
2497
- var allowCancelBtn = document.getElementById("allow-cancel");
2498
- var allowSaveBtn = document.getElementById("allow-save");
2499
- var allowCloseBtn = document.getElementById("allow-close");
2500
- var allowOriginal = [];
2501
- var allowCurrent = [];
2502
- var allowRow = null;
2503
-
2504
- function allowListsEqual(a, b) {
2505
- if (a.length !== b.length) return false;
2506
- for (var i = 0; i < a.length; i++) {
2507
- if (a[i] !== b[i]) return false;
2508
- }
2509
- return true;
2510
- }
2511
-
2512
- function updateAllowSave() {
2513
- allowSaveBtn.disabled = allowListsEqual(allowCurrent, allowOriginal);
2514
- }
2515
-
2516
- function renderAllowList() {
2517
- clear(allowListEl);
2518
- if (!allowCurrent.length) {
2519
- var empty = document.createElement("div");
2520
- empty.className = "dim allow-empty";
2521
- empty.textContent = "No internal addresses. Shared visitors reach public hosts only.";
2522
- allowListEl.appendChild(empty);
2523
- return;
2524
- }
2525
- allowCurrent.forEach(function (entry) {
2526
- var entryRow = document.createElement("div");
2527
- entryRow.className = "allow-row";
2528
- var text = document.createElement("span");
2529
- text.className = "mono";
2530
- text.textContent = entry;
2531
- entryRow.appendChild(text);
2532
- var removeBtn = document.createElement("button");
2533
- removeBtn.type = "button";
2534
- removeBtn.className = "allow-remove";
2535
- removeBtn.textContent = "\\u00d7";
2536
- removeBtn.setAttribute("aria-label", "Remove " + entry);
2537
- removeBtn.addEventListener("click", function () {
2538
- allowCurrent = allowCurrent.filter(function (item) { return item !== entry; });
2539
- renderAllowList();
2540
- updateAllowSave();
2541
- });
2542
- entryRow.appendChild(removeBtn);
2543
- allowListEl.appendChild(entryRow);
2544
- });
2545
- }
2546
-
2547
- function allowAddEntry() {
2548
- var value = allowInput.value.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\\/\\//, "").replace(/\\/$/, "");
2549
- allowErrorEl.hidden = true;
2550
- allowErrorEl.textContent = "";
2551
- if (!value || /\\s/.test(value)) {
2552
- allowErrorEl.textContent = "Enter a host or host:port, e.g. localhost:3000.";
2553
- allowErrorEl.hidden = false;
2554
- return;
2555
- }
2556
- if (allowCurrent.indexOf(value) !== -1) {
2557
- allowErrorEl.textContent = value + " is already in the list.";
2558
- allowErrorEl.hidden = false;
2559
- return;
2560
- }
2561
- allowCurrent.push(value);
2562
- allowInput.value = "";
2563
- renderAllowList();
2564
- updateAllowSave();
2565
- }
2566
-
2567
- function closeAllowDialog() {
2568
- allowDialog.close();
2569
- }
2570
-
2571
- function openAllowDialog(row) {
2572
- allowRow = row;
2573
- allowOriginal = (row.allow || []).slice();
2574
- allowCurrent = allowOriginal.slice();
2575
- allowHostEl.textContent = row.host;
2576
- allowWarnEl.hidden = !!row.cors;
2577
- allowErrorEl.hidden = true;
2578
- allowErrorEl.textContent = "";
2579
- allowInput.value = "";
2580
- renderAllowList();
2581
- allowSaveBtn.disabled = true;
2582
- allowSaveBtn.classList.remove("busy");
2583
- allowCancelBtn.disabled = false;
2584
- allowDialog.showModal();
2585
- setTimeout(function () { allowInput.focus(); }, 0);
2586
- }
2587
-
2588
- allowAddBtn.addEventListener("click", allowAddEntry);
2589
- allowInput.addEventListener("keydown", function (e) {
2590
- if (e.key === "Enter") { e.preventDefault(); allowAddEntry(); }
2591
- });
2592
- allowCancelBtn.addEventListener("click", closeAllowDialog);
2593
- allowCloseBtn.addEventListener("click", closeAllowDialog);
2594
- allowDialog.addEventListener("click", function (e) {
2595
- if (e.target === allowDialog) closeAllowDialog();
2596
- });
2597
-
2598
- allowSaveBtn.addEventListener("click", function () {
2599
- if (!allowRow) return;
2600
- var host = allowRow.host;
2601
- allowSaveBtn.disabled = true;
2602
- allowSaveBtn.classList.add("busy");
2603
- allowCancelBtn.disabled = true;
2604
- apiFetch("/api/hosts/" + encodeURIComponent(host), { method: "PUT", body: JSON.stringify({ allow: allowCurrent }) })
2605
- .then(function () {
2606
- showToast("Saved internal access for " + host, "success");
2607
- closeAllowDialog();
2608
- return loadHosts();
2609
- })
2610
- .catch(function (err) {
2611
- allowErrorEl.textContent = err && err.message ? err.message : String(err);
2612
- allowErrorEl.hidden = false;
2613
- allowSaveBtn.disabled = false;
2614
- allowSaveBtn.classList.remove("busy");
2615
- allowCancelBtn.disabled = false;
2616
- });
2617
- });
2618
-
2619
- // ---------- confirm / prompt dialogs ----------
2620
-
2621
- var confirmDialogEl = document.getElementById("confirm-dialog");
2622
- var confirmTitleEl = document.getElementById("confirm-title");
2623
- var confirmMessageEl = document.getElementById("confirm-message");
2624
- var confirmCancelBtn = document.getElementById("confirm-cancel");
2625
- var confirmOkBtn = document.getElementById("confirm-ok");
2626
- var confirmCloseBtn = document.getElementById("confirm-close");
2627
- var confirmResolve = null;
2628
-
2629
- function closeConfirmDialog(result) {
2630
- var resolve = confirmResolve;
2631
- confirmResolve = null;
2632
- confirmDialogEl.close();
2633
- if (resolve) resolve(result);
2634
- }
2635
-
2636
- function confirmDialog(opts) {
2637
- opts = opts || {};
2638
- return new Promise(function (resolve) {
2639
- confirmResolve = resolve;
2640
- confirmTitleEl.textContent = opts.title || "Are you sure?";
2641
- confirmMessageEl.textContent = opts.message || "";
2642
- confirmOkBtn.textContent = opts.confirmLabel || "OK";
2643
- confirmOkBtn.className = "btn " + (opts.danger ? "btn-danger" : "btn-primary");
2644
- confirmDialogEl.showModal();
2645
- setTimeout(function () { confirmOkBtn.focus(); }, 0);
2646
- });
2647
- }
2648
-
2649
- confirmCancelBtn.addEventListener("click", function () { closeConfirmDialog(false); });
2650
- confirmCloseBtn.addEventListener("click", function () { closeConfirmDialog(false); });
2651
- confirmOkBtn.addEventListener("click", function () { closeConfirmDialog(true); });
2652
- confirmDialogEl.addEventListener("click", function (e) {
2653
- if (e.target === confirmDialogEl) closeConfirmDialog(false);
2654
- });
2655
- confirmDialogEl.addEventListener("close", function () {
2656
- if (confirmResolve) { var resolve = confirmResolve; confirmResolve = null; resolve(false); }
2657
- });
2658
-
2659
- var promptDialogEl = document.getElementById("prompt-dialog");
2660
- var promptTitleEl = document.getElementById("prompt-title");
2661
- var promptMessageEl = document.getElementById("prompt-message");
2662
- var promptLabelEl = document.getElementById("prompt-label");
2663
- var promptInput = document.getElementById("prompt-input");
2664
- var promptErrorEl = document.getElementById("prompt-error");
2665
- var promptCancelBtn = document.getElementById("prompt-cancel");
2666
- var promptConfirmBtn = document.getElementById("prompt-confirm");
2667
- var promptCloseBtn = document.getElementById("prompt-close");
2668
- var promptResolve = null;
2669
- var promptValidate = null;
2670
-
2671
- function closePromptDialog(result) {
2672
- var resolve = promptResolve;
2673
- promptResolve = null;
2674
- promptValidate = null;
2675
- promptDialogEl.close();
2676
- if (resolve) resolve(result);
2677
- }
2678
-
2679
- function promptDialog(opts) {
2680
- opts = opts || {};
2681
- return new Promise(function (resolve) {
2682
- promptResolve = resolve;
2683
- promptValidate = typeof opts.validate === "function" ? opts.validate : null;
2684
- promptTitleEl.textContent = opts.title || "";
2685
- promptMessageEl.textContent = opts.message || "";
2686
- promptMessageEl.hidden = !opts.message;
2687
- promptLabelEl.textContent = opts.label || "";
2688
- promptLabelEl.hidden = !opts.label;
2689
- promptInput.placeholder = opts.placeholder || "";
2690
- promptInput.value = opts.value || "";
2691
- promptConfirmBtn.textContent = opts.confirmLabel || "OK";
2692
- promptErrorEl.hidden = true;
2693
- promptErrorEl.textContent = "";
2694
- promptDialogEl.showModal();
2695
- setTimeout(function () { promptInput.focus(); promptInput.select(); }, 0);
2696
- });
2697
- }
2698
-
2699
- function submitPromptDialog() {
2700
- var value = promptInput.value;
2701
- if (promptValidate) {
2702
- var err = promptValidate(value);
2703
- if (err) {
2704
- promptErrorEl.textContent = err;
2705
- promptErrorEl.hidden = false;
2706
- return;
2707
- }
2708
- }
2709
- closePromptDialog(value);
2710
- }
2711
-
2712
- promptCancelBtn.addEventListener("click", function () { closePromptDialog(null); });
2713
- promptCloseBtn.addEventListener("click", function () { closePromptDialog(null); });
2714
- promptConfirmBtn.addEventListener("click", submitPromptDialog);
2715
- promptInput.addEventListener("keydown", function (e) {
2716
- if (e.key === "Enter") { e.preventDefault(); submitPromptDialog(); }
2717
- });
2718
- promptDialogEl.addEventListener("click", function (e) {
2719
- if (e.target === promptDialogEl) closePromptDialog(null);
2720
- });
2721
- promptDialogEl.addEventListener("close", function () {
2722
- if (promptResolve) { var resolve = promptResolve; promptResolve = null; resolve(null); }
2723
- });
2724
-
2725
- function labelCells(tr, labels) {
2726
- for (var i = 0; i < tr.children.length; i++) tr.children[i].setAttribute("data-label", labels[i] || "");
2727
- }
2728
-
2729
- function buildDisplayRow(tr, row) {
2730
- clear(tr);
2731
- tr.classList.toggle("row-down", !row.probe.up);
2732
-
2733
- var hostTd = document.createElement("td");
2734
- hostTd.className = "host-td mono";
2735
- var hostWrap = document.createElement("span");
2736
- hostWrap.className = "host-cell";
2737
- var href = (row.urls && (row.urls.https || row.urls.http)) || "";
2738
- var a = document.createElement("a");
2739
- if (/^https?:\\/\\//.test(href)) a.href = href;
2740
- a.target = "_blank";
2741
- a.rel = "noopener noreferrer";
2742
- a.textContent = row.host;
2743
- hostWrap.appendChild(a);
2744
- hostWrap.appendChild(makeCopyButton(function () { return href || row.host; }));
2745
- if (row.remote) {
2746
- var viaBadge = document.createElement("span");
2747
- viaBadge.className = "badge badge-via";
2748
- viaBadge.textContent = "via " + row.remote.name;
2749
- viaBadge.title = "Imported from remote \\"" + row.remote.name + "\\"";
2750
- hostWrap.appendChild(viaBadge);
2751
- }
2752
- hostTd.appendChild(hostWrap);
2753
- var targetLine = document.createElement("div");
2754
- targetLine.className = "host-target mono";
2755
- var targetText = row.remote ? (row.remote.name + ": " + row.remote.host) : row.target;
2756
- targetLine.textContent = targetText;
2757
- targetLine.title = targetText;
2758
- hostTd.appendChild(targetLine);
2759
- tr.appendChild(hostTd);
2760
-
2761
- tr.appendChild(makeCell("\\u2192", "arrow"));
2762
-
2763
- var optionsTd = document.createElement("td");
2764
- var flags = [];
2765
- if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified", "badge-warn"]);
2766
- if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
2767
- if (row.allow && row.allow.length) flags.push(["allow " + row.allow.length, row.allow.join(", "), "badge-accent"]);
2768
- if (flags.length) {
2769
- optionsTd.className = "badges";
2770
- flags.forEach(function (flag) {
2771
- var badge = document.createElement("span");
2772
- badge.className = "badge " + flag[2];
2773
- badge.textContent = flag[0];
2774
- badge.title = flag[1];
2775
- optionsTd.appendChild(badge);
2776
- });
2777
- } else {
2778
- optionsTd.className = "dim";
2779
- optionsTd.textContent = "\\u2014";
2780
- }
2781
- tr.appendChild(optionsTd);
2782
-
2783
- var statusTd = document.createElement("td");
2784
- statusTd.className = "status-td";
2785
- statusTd.appendChild(statusPill(row));
2786
- var st = row.stats || { hits: 0, errors: 0 };
2787
- var traffic = st.hits + " hits";
2788
- if (st.errors) traffic += " · " + st.errors + " err";
2789
- if (typeof st.avgMs === "number") traffic += " · " + Math.round(st.avgMs) + "ms";
2790
- var trafficLine = document.createElement("div");
2791
- trafficLine.className = "traffic";
2792
- trafficLine.textContent = traffic;
2793
- trafficLine.title = "Last access: " + (st.lastAccess ? fmtRelative(st.lastAccess) : "never");
2794
- statusTd.appendChild(trafficLine);
2795
- tr.appendChild(statusTd);
2796
-
2797
- tr.appendChild(tunnelCell(row));
2798
-
2799
- var actionsTd = document.createElement("td");
2800
- var actionsWrap = document.createElement("div");
2801
- actionsWrap.className = "row-actions";
2802
- actionsTd.appendChild(actionsWrap);
2803
- var accessBtn = document.createElement("button");
2804
- accessBtn.type = "button";
2805
- var allowCount = row.allow ? row.allow.length : 0;
2806
- accessBtn.className = "btn btn-sm btn-icon" + (allowCount ? " is-set" : "");
2807
- accessBtn.title = allowCount ? "Internal access: " + row.allow.join(", ") : "Internal access: let shared visitors reach addresses such as localhost:3000";
2808
- accessBtn.setAttribute("aria-label", "Internal access for " + row.host);
2809
- var accessSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2810
- accessSvg.setAttribute("viewBox", "0 0 24 24");
2811
- accessSvg.setAttribute("aria-hidden", "true");
2812
- var accessPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
2813
- accessPath.setAttribute("d", "M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6l7-3zM9.5 12l1.8 1.8L15 10");
2814
- accessSvg.appendChild(accessPath);
2815
- accessBtn.appendChild(accessSvg);
2816
- accessBtn.addEventListener("click", function () { openAllowDialog(row); });
2817
- var editBtn = document.createElement("button");
2818
- editBtn.type = "button";
2819
- editBtn.className = "btn btn-sm";
2820
- editBtn.textContent = "Edit";
2821
- editBtn.addEventListener("click", function () { enterEditMode(row); });
2822
- var removeBtn = document.createElement("button");
2823
- removeBtn.type = "button";
2824
- removeBtn.className = "btn btn-sm btn-danger";
2825
- removeBtn.textContent = "Remove";
2826
- removeBtn.addEventListener("click", function () {
2827
- (prefs.confirm ? confirmDialog({ title: "Remove host " + row.host + "?", message: "This can't be undone.", confirmLabel: "Remove host", danger: true }) : Promise.resolve(true)).then(function (ok) {
2828
- if (!ok) return;
2829
- removeBtn.disabled = true;
2830
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
2831
- .then(function () {
2832
- showToast("Removed " + row.host, "success");
2833
- return loadHosts();
2834
- })
2835
- .catch(function (err) {
2836
- apiError(err, "Couldn't remove " + row.host);
2837
- removeBtn.disabled = false;
2838
- });
2839
- });
2840
- });
2841
- if (!row.remote) {
2842
- actionsWrap.appendChild(shareButton(row));
2843
- actionsWrap.appendChild(accessBtn);
2844
- actionsWrap.appendChild(editBtn);
2845
- }
2846
- actionsWrap.appendChild(removeBtn);
2847
- tr.appendChild(actionsTd);
2848
- labelCells(tr, ["", "", "Options", "Status", "Public URL", ""]);
2849
- }
2850
-
2851
- function enterEditMode(row) {
2852
- editingHost = row.host;
2853
- var tr = rowElements[row.host];
2854
- if (!tr) return;
2855
- clear(tr);
2856
-
2857
- var hostTd = makeCell(null, "host-td mono");
2858
- var hostName = document.createElement("div");
2859
- hostName.className = "host-cell";
2860
- hostName.textContent = row.host;
2861
- hostTd.appendChild(hostName);
2862
- tr.appendChild(hostTd);
2863
- tr.appendChild(makeCell("\\u2192", "arrow"));
2864
-
2865
- var targetInput = document.createElement("input");
2866
- targetInput.type = "text";
2867
- targetInput.className = "edit-input mono";
2868
- targetInput.value = row.target;
2869
- targetInput.title = row.target;
2870
- targetInput.setAttribute("aria-label", "Target for " + row.host);
2871
- hostTd.appendChild(targetInput);
2872
-
2873
- var insecureTd = document.createElement("td");
2874
- var insecureLabel = document.createElement("label");
2875
- insecureLabel.className = "inline-check";
2876
- var insecureInput = document.createElement("input");
2877
- insecureInput.type = "checkbox";
2878
- insecureInput.checked = !!row.insecure;
2879
- insecureLabel.appendChild(insecureInput);
2880
- insecureLabel.appendChild(document.createTextNode("insecure"));
2881
- insecureTd.appendChild(insecureLabel);
2882
- var corsLabel = document.createElement("label");
2883
- corsLabel.className = "inline-check";
2884
- var corsInput = document.createElement("input");
2885
- corsInput.type = "checkbox";
2886
- corsInput.checked = !!row.cors;
2887
- corsLabel.appendChild(corsInput);
2888
- corsLabel.appendChild(document.createTextNode("cors"));
2889
- insecureTd.appendChild(corsLabel);
2890
- tr.appendChild(insecureTd);
2891
-
2892
- var hintTd = makeCell("Enter to save \\u00b7 Esc to cancel", "dim edit-hint");
2893
- hintTd.colSpan = 2;
2894
- tr.appendChild(hintTd);
2895
-
2896
- var actionsTd = document.createElement("td");
2897
- var actionsWrap = document.createElement("div");
2898
- actionsWrap.className = "row-actions";
2899
- actionsTd.appendChild(actionsWrap);
2900
- var saveBtn = document.createElement("button");
2901
- saveBtn.type = "button";
2902
- saveBtn.className = "btn btn-sm btn-primary";
2903
- saveBtn.textContent = "Save";
2904
- var cancelBtn = document.createElement("button");
2905
- cancelBtn.type = "button";
2906
- cancelBtn.className = "btn btn-sm btn-ghost";
2907
- cancelBtn.textContent = "Cancel";
2908
-
2909
- saveBtn.addEventListener("click", function () {
2910
- var newTarget = targetInput.value.trim();
2911
- if (!newTarget) {
2912
- targetInput.classList.add("invalid");
2913
- targetInput.setAttribute("aria-invalid", "true");
2914
- targetInput.focus();
2915
- return;
2916
- }
2917
- saveBtn.disabled = true;
2918
- saveBtn.classList.add("busy");
2919
- cancelBtn.disabled = true;
2920
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), {
2921
- method: "PUT",
2922
- body: JSON.stringify({ target: newTarget, insecure: !!insecureInput.checked, cors: !!corsInput.checked })
2923
- })
2924
- .then(function () {
2925
- editingHost = null;
2926
- showToast("Updated " + row.host, "success");
2927
- return loadHosts();
2928
- })
2929
- .catch(function (err) {
2930
- hintTd.textContent = "Couldn't save: " + (err && err.message ? err.message : err) + ". Fix it and press Enter to retry.";
2931
- hintTd.className = "edit-hint edit-error";
2932
- saveBtn.disabled = false;
2933
- saveBtn.classList.remove("busy");
2934
- cancelBtn.disabled = false;
2935
- });
2936
- });
2937
-
2938
- cancelBtn.addEventListener("click", function () {
2939
- editingHost = null;
2940
- buildDisplayRow(tr, row);
2941
- });
2942
-
2943
- targetInput.addEventListener("keydown", function (e) {
2944
- if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
2945
- if (e.key === "Escape") { e.preventDefault(); cancelBtn.click(); }
2946
- });
2947
- setTimeout(function () { targetInput.focus(); targetInput.select(); }, 0);
2948
-
2949
- actionsWrap.appendChild(saveBtn);
2950
- actionsWrap.appendChild(cancelBtn);
2951
- tr.appendChild(actionsTd);
2952
- labelCells(tr, ["", "", "Options", "", ""]);
2953
- }
2954
-
2955
- function renderHosts(hosts) {
2956
- try { localStorage.setItem("locadot.hostCount", String(hosts.length || 1)); } catch (e) {}
2957
- hideHostsSkeleton();
2958
- lastHosts = hosts;
2959
- if (inviteRoleSelect.value === "viewer" && !inviteHostsWrap.hidden) renderInviteHostChecks();
2960
- emptyEl.hidden = hosts.length !== 0;
2961
- tableWrap.hidden = hosts.length === 0;
2962
-
2963
- var seen = {};
2964
- hosts.forEach(function (row) {
2965
- seen[row.host] = true;
2966
- if (editingHost === row.host) return;
2967
- var tr = rowElements[row.host];
2968
- if (!tr) {
2969
- tr = document.createElement("tr");
2970
- rowElements[row.host] = tr;
2971
- }
2972
- buildDisplayRow(tr, row);
2973
- tbody.appendChild(tr);
2974
- });
2975
- Object.keys(rowElements).forEach(function (host) {
2976
- if (!seen[host]) {
2977
- var tr = rowElements[host];
2978
- if (tr && tr.parentNode) tr.parentNode.removeChild(tr);
2979
- delete rowElements[host];
2980
- if (editingHost === host) editingHost = null;
2981
- }
2982
- });
2983
- hostsCountEl.textContent = String(hosts.length);
2984
- hostsCountEl.hidden = false;
2985
- sbHostsCountEl.textContent = String(hosts.length);
2986
- sbHostsCountEl.hidden = false;
2987
- applyHostsFilter();
2988
- }
2989
-
2990
- function applyHostsFilter() {
2991
- var q = (hostsFilterInput.value || "").trim().toLowerCase();
2992
- Object.keys(rowElements).forEach(function (host) {
2993
- var tr = rowElements[host];
2994
- var target = "";
2995
- for (var i = 0; i < lastHosts.length; i++) if (lastHosts[i].host === host) { target = lastHosts[i].target || ""; break; }
2996
- tr.hidden = !!q && (host + " " + target).toLowerCase().indexOf(q) === -1;
2997
- });
2998
- }
2999
- hostsFilterInput.addEventListener("input", applyHostsFilter);
3000
-
3001
- function loadHosts() {
3002
- return fetch("/api/hosts").then(parseJsonOrThrow).then(renderHosts);
3003
- }
3004
-
3005
- // ---------- logs ----------
3006
-
3007
- logsRefreshBtn.addEventListener("click", loadLogs);
3008
- logsClearBtn.addEventListener("click", function () {
3009
- confirmDialog({ title: "Clear logs?", message: "This can't be undone.", confirmLabel: "Clear logs", danger: true }).then(function (ok) {
3010
- if (!ok) return;
3011
- apiFetch("/api/logs/clear", { method: "POST" })
3012
- .then(function () {
3013
- showToast("Logs cleared", "success");
3014
- return loadLogs();
3015
- })
3016
- .catch(function (err) { apiError(err, "Couldn't clear logs"); });
3017
- });
3018
- });
3019
-
3020
- cliAddCopy.addEventListener("click", function () { copyText(cliAddPre.textContent, cliAddCopy); });
3021
- cliCurlCopy.addEventListener("click", function () { copyText(cliCurlPre.textContent, cliCurlCopy); });
3022
- cliTunnelCopy.addEventListener("click", function () { copyText(cliTunnelPre.textContent, cliTunnelCopy); });
3023
-
3024
- // ---------- polling ----------
3025
-
3026
- function refreshAll() {
3027
- if (proxyStopped) return Promise.resolve();
3028
- return Promise.all([
3029
- fetch("/api/status").then(parseJsonOrThrow),
3030
- fetch("/api/hosts").then(parseJsonOrThrow)
3031
- ]).then(function (results) {
3032
- lastStatus = results[0];
3033
- renderHeader(lastStatus);
3034
- renderSystemTiles(lastStatus);
3035
- renderCli(lastStatus);
3036
- renderHosts(results[1]);
3037
- hideLoadError();
3038
- }).catch(function (err) {
3039
- document.body.classList.remove("boot");
3040
- hideHostsSkeleton();
3041
- markDown();
3042
- showLoadError(err);
3043
- });
3044
- }
3045
-
3046
- document.addEventListener("visibilitychange", function () {
3047
- if (!document.hidden && !proxyStopped) {
3048
- refreshAll().then(tickViews);
3049
- refreshHubAndRemotes();
3050
- }
3051
- });
3052
-
3053
-
3054
- remotesPollTimer = setInterval(function () {
3055
- if (!document.hidden && !proxyStopped) refreshHubAndRemotes();
3056
- }, 15000);
3057
-
3058
- // ---------- preferences (per browser) ----------
3059
-
3060
- var PREF_DEFAULTS = { refresh: 5000, start: "overview", confirm: true, insecure: false, cors: false };
3061
- var prefs = (function () {
3062
- var out = {};
3063
- var saved = {};
3064
- try { saved = JSON.parse(localStorage.getItem("locadot.prefs") || "{}") || {}; } catch (e) {}
3065
- Object.keys(PREF_DEFAULTS).forEach(function (k) { out[k] = saved[k] !== undefined ? saved[k] : PREF_DEFAULTS[k]; });
3066
- return out;
3067
- })();
3068
- function savePrefs() {
3069
- try { localStorage.setItem("locadot.prefs", JSON.stringify(prefs)); } catch (e) {}
3070
- }
3071
-
3072
- function applyHostDefaults() {
3073
- addInsecureInput.checked = !!prefs.insecure;
3074
- addCorsInput.checked = !!prefs.cors;
3075
- }
3076
- applyHostDefaults();
3077
-
3078
- function setSwitch(el, on) {
3079
- el.setAttribute("aria-checked", String(on));
3080
- el.classList.toggle("on", on);
3081
- }
3082
-
3083
- // ---------- sidebar + routing ----------
3084
-
3085
- var VIEWS = ["overview", "hosts", "sharing", "remote", "machines", "logs", "settings"];
3086
- var TITLES = { overview: "Overview", hosts: "Hosts", sharing: "Public sharing", remote: "Remote access", machines: "Connected machines", logs: "Logs", settings: "Settings" };
3087
- var rootEl = document.documentElement;
3088
- var sidebarEl = document.getElementById("sidebar");
3089
- var collapseBtn = document.getElementById("sb-collapse");
3090
- var menuBtn = document.getElementById("sb-menu");
3091
- var backdropEl = document.getElementById("sb-backdrop");
3092
- var navLinks = Array.prototype.slice.call(sidebarEl.querySelectorAll("a.sb-link"));
3093
- var viewEls = {};
3094
- Array.prototype.forEach.call(document.querySelectorAll("section.view"), function (el) { viewEls[el.getAttribute("data-view")] = el; });
3095
- var drawerMq = window.matchMedia("(max-width: 899px)");
3096
- var currentView = null;
3097
-
3098
- function isCollapsed() { return rootEl.getAttribute("data-sidebar") === "collapsed"; }
3099
- function setCollapsed(collapsed, persist) {
3100
- if (collapsed) rootEl.setAttribute("data-sidebar", "collapsed");
3101
- else rootEl.removeAttribute("data-sidebar");
3102
- if (persist) { try { localStorage.setItem("locadot.sidebar", collapsed ? "collapsed" : "expanded"); } catch (e) {} }
3103
- collapseBtn.setAttribute("aria-expanded", String(!collapsed));
3104
- collapseBtn.title = collapsed ? "Expand sidebar" : "Collapse sidebar";
3105
- collapseBtn.querySelector(".sb-label").textContent = collapsed ? "Expand" : "Collapse";
3106
- setSwitch(prefSidebar, collapsed);
3107
- }
3108
- collapseBtn.addEventListener("click", function () { setCollapsed(!isCollapsed(), true); });
3109
-
3110
- function setDrawer(open) {
3111
- document.body.classList.toggle("sb-open", open);
3112
- backdropEl.hidden = !open;
3113
- menuBtn.setAttribute("aria-expanded", String(open));
3114
- menuBtn.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
3115
- if (open && navLinks[0]) navLinks[0].focus();
3116
- }
3117
- menuBtn.addEventListener("click", function () { setDrawer(!document.body.classList.contains("sb-open")); });
3118
- backdropEl.addEventListener("click", function () { setDrawer(false); });
3119
- document.addEventListener("keydown", function (e) {
3120
- if (e.key === "Escape" && document.body.classList.contains("sb-open")) { setDrawer(false); menuBtn.focus(); }
3121
- });
3122
- if (drawerMq.addEventListener) drawerMq.addEventListener("change", function () { if (!drawerMq.matches) setDrawer(false); });
3123
-
3124
- function viewFromHash() {
3125
- var name = (location.hash || "").replace(/^#\\/?/, "");
3126
- return VIEWS.indexOf(name) !== -1 ? name : null;
3127
- }
3128
-
3129
- function showView(name) {
3130
- if (VIEWS.indexOf(name) === -1) name = "overview";
3131
- var changed = currentView !== name;
3132
- currentView = name;
3133
- VIEWS.forEach(function (v) { if (viewEls[v]) viewEls[v].hidden = v !== name; });
3134
- navLinks.forEach(function (a) {
3135
- if (a.getAttribute("data-view") === name) a.setAttribute("aria-current", "page");
3136
- else a.removeAttribute("aria-current");
3137
- });
3138
- document.title = TITLES[name] + " · locadot";
3139
- try { localStorage.setItem("locadot.lastView", name); } catch (e) {}
3140
- if (drawerMq.matches) setDrawer(false);
3141
- if (changed) {
3142
- window.scrollTo(0, 0);
3143
- onViewEnter(name);
3144
- }
3145
- }
3146
-
3147
- function onViewEnter(name) {
3148
- if (name === "logs") loadLogs();
3149
- if (name === "overview") { renderOverview(); loadOverviewLogs(); }
3150
- if (name === "sharing") renderShareList();
3151
- if (name === "settings") loadSettings();
3152
- if (name === "remote" || name === "machines") refreshHubAndRemotes();
3153
- }
3154
-
3155
- window.addEventListener("hashchange", function () { showView(viewFromHash() || "overview"); });
3156
-
3157
- // ---------- overview ----------
3158
-
3159
- var ovHostsEl = document.getElementById("ov-hosts");
3160
- var ovHostsSubEl = document.getElementById("ov-hosts-sub");
3161
- var ovDownEl = document.getElementById("ov-down");
3162
- var ovHitsEl = document.getElementById("ov-hits");
3163
- var ovHitsSubEl = document.getElementById("ov-hits-sub");
3164
- var ovSharesEl = document.getElementById("ov-shares");
3165
- var ovSharesSubEl = document.getElementById("ov-shares-sub");
3166
- var ovHubEl = document.getElementById("ov-hub");
3167
- var ovHubSubEl = document.getElementById("ov-hub-sub");
3168
- var ovRemotesEl = document.getElementById("ov-remotes");
3169
- var ovRemotesSubEl = document.getElementById("ov-remotes-sub");
3170
- var ovHostList = document.getElementById("ov-host-list");
3171
- var ovHostEmpty = document.getElementById("ov-host-empty");
3172
- var ovChecks = document.getElementById("ov-checks");
3173
- var ovLogs = document.getElementById("ov-logs");
3174
- var sbSharesCountEl = document.getElementById("sb-shares-count");
3175
- var sbHubDot = document.getElementById("sb-hub-dot");
3176
-
3177
- function isShared(row) {
3178
- var t = tunnelInfo(row);
3179
- return t.enabled || t.status === "up" || t.status === "starting";
3180
- }
3181
-
3182
- function ovRow(title, sub, side, dotClass, href) {
3183
- var row = document.createElement(href ? "a" : "div");
3184
- row.className = "ov-row";
3185
- if (href) { row.href = href; row.style.color = "inherit"; row.style.textDecoration = "none"; }
3186
- var dot = document.createElement("span");
3187
- dot.className = "dot" + (dotClass ? " " + dotClass : "");
3188
- row.appendChild(dot);
3189
- var main = document.createElement("div");
3190
- main.className = "ov-main";
3191
- var t = document.createElement("span");
3192
- t.className = "ov-title";
3193
- t.textContent = title;
3194
- main.appendChild(t);
3195
- if (sub) {
3196
- var s = document.createElement("span");
3197
- s.className = "ov-sub";
3198
- s.textContent = sub;
3199
- main.appendChild(s);
3200
- }
3201
- row.appendChild(main);
3202
- var sideEl = document.createElement("div");
3203
- sideEl.className = "ov-side";
3204
- if (typeof side === "string") sideEl.textContent = side;
3205
- else if (side) sideEl.appendChild(side);
3206
- row.appendChild(sideEl);
3207
- return row;
3208
- }
3209
-
3210
- function fmtNum(n) { return n >= 10000 ? Math.round(n / 1000) + "k" : String(n); }
3211
-
3212
- function renderOverview() {
3213
- var hosts = lastHosts || [];
3214
- var up = 0, hits = 0, errors = 0, shared = 0, remoteCount = 0;
3215
- hosts.forEach(function (h) {
3216
- if (h.probe && h.probe.up) up++;
3217
- if (h.stats) { hits += h.stats.hits || 0; errors += h.stats.errors || 0; }
3218
- if (isShared(h)) shared++;
3219
- if (h.remote) remoteCount++;
3220
- });
3221
- var down = hosts.length - up;
3222
- if (lastStatus) {
3223
- ovHostsEl.textContent = String(hosts.length);
3224
- ovHostsSubEl.textContent = up + " up" + (remoteCount ? " · " + remoteCount + " from other machines" : "");
3225
- ovDownEl.textContent = String(down);
3226
- ovDownEl.className = "tile-big" + (down ? " bad" : " ok");
3227
- ovHitsEl.textContent = fmtNum(hits);
3228
- ovHitsSubEl.textContent = errors ? errors + " errors since start" : "since the proxy started";
3229
- ovSharesEl.textContent = String(shared);
3230
- ovSharesSubEl.textContent = cloudflaredInstalled ? (shared ? "public right now" : "nothing public") : "cloudflared not installed";
3231
- }
3232
- sbSharesCountEl.textContent = String(shared);
3233
- sbSharesCountEl.hidden = shared === 0;
3234
-
3235
- var hub = lastHub;
3236
- var hubLabels = { off: "Off", starting: "Starting", login: "Login needed", up: "On", error: "Error" };
3237
- if (hub) {
3238
- ovHubEl.textContent = hubLabels[hub.status] || hub.status;
3239
- ovHubEl.className = "tile-big tile-word" + (hub.status === "up" ? " ok" : hub.status === "error" ? " bad" : hub.status === "off" ? "" : " warn");
3240
- ovHubSubEl.textContent = hub.url ? hub.url.replace(/^https?:\\/\\//, "") : (hub.status === "off" ? "not shared" : "");
3241
- sbHubDot.hidden = hub.status === "off";
3242
- sbHubDot.className = "dot sb-dot " + (hub.status === "up" ? "up" : hub.status === "error" ? "down" : "warn");
3243
- }
3244
- var remotes = lastRemotes || [];
3245
- ovRemotesEl.textContent = String(remotes.length);
3246
- ovRemotesSubEl.textContent = remotes.length ? remotes.map(function (r) { return r.name; }).join(", ") : "none";
3247
-
3248
- clear(ovHostList);
3249
- ovHostEmpty.hidden = hosts.length !== 0 || !lastStatus;
3250
- var sorted = hosts.slice().sort(function (a, b) {
3251
- var au = a.probe && a.probe.up ? 1 : 0, bu = b.probe && b.probe.up ? 1 : 0;
3252
- return au - bu || a.host.localeCompare(b.host);
3253
- });
3254
- sorted.slice(0, 8).forEach(function (h) {
3255
- var p = h.probe || {};
3256
- var side = p.up ? ((p.status || "") + (p.ms !== undefined ? " · " + p.ms + "ms" : "")) : (p.error || "down");
3257
- var sub = h.remote ? "via " + h.remote.name : h.target;
3258
- ovHostList.appendChild(ovRow(h.host, sub, side, p.up ? "up" : "down", "#/hosts"));
3259
- });
3260
- if (sorted.length > 8) {
3261
- var more = document.createElement("a");
3262
- more.className = "ov-more";
3263
- more.href = "#/hosts";
3264
- more.textContent = "View all " + sorted.length + " hosts";
3265
- ovHostList.appendChild(more);
3266
- }
3267
-
3268
- clear(ovChecks);
3269
- if (lastStatus) {
3270
- var sys = lastStatus.system, proxy = lastStatus.proxy;
3271
- ovChecks.appendChild(ovRow("Running", "pid " + proxy.pid + " · uptime " + fmtUptime(lastStatus.uptimeSec), "http :" + proxy.httpPort + " · https :" + proxy.httpsPort, "up"));
3272
- 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"));
3273
- ovChecks.appendChild(ovRow("Start at boot", sys.startup.method ? "via " + sys.startup.method : "", sys.startup.enabled ? "Enabled" : "Disabled", sys.startup.enabled ? "up" : "", "#/settings"));
3274
- var cf = sys.cloudflared || {};
3275
- 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"));
3276
- }
3277
- }
3278
-
3279
- function loadOverviewLogs() {
3280
- return fetch("/api/logs?lines=12").then(parseJsonOrThrow).then(function (data) {
3281
- var lines = (data && data.lines) || [];
3282
- ovLogs.textContent = lines.length ? lines.join("\\n") : "No log lines yet.";
3283
- ovLogs.scrollTop = ovLogs.scrollHeight;
3284
- }).catch(function () {});
3285
- }
3286
-
3287
- // ---------- public sharing page ----------
3288
-
3289
- var shareListEl = document.getElementById("share-list");
3290
- var shareEmptyEl = document.getElementById("share-empty");
3291
- var shareCountEl = document.getElementById("share-count");
3292
-
3293
- function renderShareList() {
3294
- var hosts = (lastHosts || []).filter(function (h) { return !h.remote; });
3295
- clear(shareListEl);
3296
- shareEmptyEl.hidden = hosts.length !== 0;
3297
- var shared = hosts.filter(isShared).length;
3298
- shareCountEl.textContent = shared + " public";
3299
- shareCountEl.hidden = hosts.length === 0;
3300
- hosts.slice().sort(function (a, b) { return (isShared(b) ? 1 : 0) - (isShared(a) ? 1 : 0) || a.host.localeCompare(b.host); }).forEach(function (h) {
3301
- var t = tunnelInfo(h);
3302
- var side = document.createElement("div");
3303
- side.className = "ov-side";
3304
- if (t.status === "up" && t.url) {
3305
- var a = document.createElement("a");
3306
- a.className = "ov-link";
3307
- a.href = t.url;
3308
- a.target = "_blank";
3309
- a.rel = "noopener noreferrer";
3310
- a.textContent = t.url.replace(/^https:\\/\\//, "");
3311
- side.appendChild(a);
3312
- side.appendChild(makeCopyButton(function () { return t.url; }));
3313
- }
3314
- side.appendChild(shareButton(h));
3315
- var sub = t.status === "up" ? "public" : t.status === "starting" ? "starting tunnel…" : t.status === "error" ? ("error: " + (t.error || "tunnel failed")) : h.target;
3316
- var dot = t.status === "up" ? "up" : t.status === "starting" ? "warn" : t.status === "error" ? "down" : "";
3317
- shareListEl.appendChild(ovRow(h.host, sub, side, dot));
3318
- });
3319
- }
3320
-
3321
- // ---------- logs page ----------
3322
-
3323
- var logsFilter = document.getElementById("logs-filter");
3324
- var logsLevel = document.getElementById("logs-level");
3325
- var logsLines = document.getElementById("logs-lines");
3326
- var logsAuto = document.getElementById("logs-auto");
3327
- var logsDownload = document.getElementById("logs-download");
3328
- var logsMeta = document.getElementById("logs-meta");
3329
- var lastLogLines = [];
3330
- var LEVELS = { error: 0, warn: 1, info: 2 };
3331
-
3332
- function lineLevel(line) {
3333
- var m = /\\[(error|warn|info|debug|verbose|silly)\\]/i.exec(line);
3334
- return m ? m[1].toLowerCase() : "info";
3335
- }
3336
-
3337
- function renderLogs() {
3338
- var q = (logsFilter.value || "").trim().toLowerCase();
3339
- var lvl = logsLevel.value;
3340
- var shown = lastLogLines.filter(function (line) {
3341
- if (q && line.toLowerCase().indexOf(q) === -1) return false;
3342
- if (lvl) {
3343
- var l = LEVELS[lineLevel(line)];
3344
- if (l === undefined || l > LEVELS[lvl]) return false;
3345
- }
3346
- return true;
3347
- });
3348
- var atBottom = logsBox.scrollHeight - logsBox.scrollTop - logsBox.clientHeight < 40;
3349
- logsBox.textContent = shown.length ? shown.join("\\n") : (lastLogLines.length ? "No lines match." : "No log lines yet.");
3350
- if (atBottom) logsBox.scrollTop = logsBox.scrollHeight;
3351
- logsMeta.textContent = (shown.length === lastLogLines.length ? lastLogLines.length + " lines" : shown.length + " of " + lastLogLines.length + " lines") + " · updated " + new Date().toLocaleTimeString();
3352
- }
3353
-
3354
- function loadLogs() {
3355
- return fetch("/api/logs?lines=" + encodeURIComponent(logsLines.value)).then(parseJsonOrThrow).then(function (data) {
3356
- lastLogLines = (data && data.lines) || [];
3357
- renderLogs();
3358
- }).catch(function (err) { apiError(err, "Couldn't load logs"); });
3359
- }
3360
-
3361
- logsFilter.addEventListener("input", renderLogs);
3362
- logsLevel.addEventListener("change", renderLogs);
3363
- logsLines.addEventListener("change", loadLogs);
3364
- logsDownload.addEventListener("click", function () {
3365
- var blob = new Blob([lastLogLines.join("\\n") + "\\n"], { type: "text/plain" });
3366
- var a = document.createElement("a");
3367
- a.href = URL.createObjectURL(blob);
3368
- a.download = "locadot-" + new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-") + ".log";
3369
- document.body.appendChild(a);
3370
- a.click();
3371
- setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
3372
- });
3373
-
3374
- // ---------- settings page ----------
3375
-
3376
- var portsForm = document.getElementById("ports-form");
3377
- var httpPortInput = document.getElementById("set-http-port");
3378
- var httpsPortInput = document.getElementById("set-https-port");
3379
- var portsNote = document.getElementById("ports-note");
3380
- var portsError = document.getElementById("ports-error");
3381
- var portsSave = document.getElementById("ports-save");
3382
- var restartBtn = document.getElementById("restart-proxy-btn");
3383
- var restartNote = document.getElementById("restart-note");
3384
- var setBind = document.getElementById("set-bind");
3385
- var setBindNote = document.getElementById("set-bind-note");
3386
- var setHome = document.getElementById("set-home");
3387
- var setLogLevel = document.getElementById("set-loglevel");
3388
- var prefTheme = document.getElementById("pref-theme");
3389
- var prefRefresh = document.getElementById("pref-refresh");
3390
- var prefStart = document.getElementById("pref-start");
3391
- var prefSidebar = document.getElementById("pref-sidebar");
3392
- var prefConfirm = document.getElementById("pref-confirm");
3393
- var prefInsecure = document.getElementById("pref-insecure");
3394
- var prefCors = document.getElementById("pref-cors");
3395
- var tokenValue = document.getElementById("token-value");
3396
- var tokenReveal = document.getElementById("token-reveal");
3397
- var tokenCopy = document.getElementById("token-copy");
3398
- var dangerClearLogs = document.getElementById("danger-clear-logs");
3399
- var settingsLoaded = null;
3400
-
3401
- function renderSettings(s) {
3402
- settingsLoaded = s;
3403
- if (s.uiAuth) renderUiAuth(s.uiAuth.enabled);
3404
- if (document.activeElement !== httpPortInput) httpPortInput.value = String((s.saved && s.saved.httpPort) || s.httpPort);
3405
- if (document.activeElement !== httpsPortInput) httpsPortInput.value = String((s.saved && s.saved.httpsPort) || s.httpsPort);
3406
- var notes = ["Running on http :" + s.httpPort + " and https :" + s.httpsPort + "."];
3407
- if (s.env && s.env.httpPort) notes.push("LOCADOT_HTTP_PORT is set and overrides the saved HTTP port.");
3408
- if (s.env && s.env.httpsPort) notes.push("LOCADOT_HTTPS_PORT is set and overrides the saved HTTPS port.");
3409
- portsNote.textContent = notes.join(" ");
3410
- restartNote.hidden = !s.restartRequired;
3411
- setBind.textContent = (s.bind || []).join(", ");
3412
- setBindNote.textContent = s.env && s.env.bind ? "Set by LOCADOT_BIND." : "Loopback only. Set LOCADOT_BIND to change it.";
3413
- setHome.textContent = s.stateDir || "";
3414
- setLogLevel.textContent = s.logLevel || "info";
3415
- }
3416
-
3417
- function loadSettings() {
3418
- return fetch("/api/settings").then(parseJsonOrThrow).then(renderSettings).catch(function (err) { apiError(err, "Couldn't load settings"); });
3419
- }
3420
-
3421
- portsForm.addEventListener("submit", function (e) {
3422
- e.preventDefault();
3423
- portsError.hidden = true;
3424
- var h = parseInt(httpPortInput.value, 10), hs = parseInt(httpsPortInput.value, 10);
3425
- var bad = function (p) { return !(p >= 1 && p <= 65535); };
3426
- if (bad(h) || bad(hs) || h === hs) {
3427
- portsError.textContent = h === hs ? "HTTP and HTTPS need different ports." : "Ports must be between 1 and 65535.";
3428
- portsError.hidden = false;
3429
- return;
3430
- }
3431
- portsSave.disabled = true;
3432
- portsSave.classList.add("busy");
3433
- apiFetch("/api/settings", { method: "PUT", body: JSON.stringify({ httpPort: h, httpsPort: hs }) })
3434
- .then(function (s) {
3435
- renderSettings(s);
3436
- showToast(s.restartRequired ? "Ports saved. Restart to apply." : "Ports saved", s.warning ? "warn" : "success", s.warning);
3437
- })
3438
- .catch(function (err) { portsError.textContent = err.message; portsError.hidden = false; })
3439
- .then(function () { portsSave.disabled = false; portsSave.classList.remove("busy"); });
3440
- });
3441
-
3442
- restartBtn.addEventListener("click", function () {
3443
- confirmDialog({ title: "Restart the proxy?", message: "Hosts are unavailable for a few seconds.", confirmLabel: "Restart", danger: true }).then(function (ok) {
3444
- if (!ok) return;
3445
- var s = settingsLoaded;
3446
- var nextPort = s ? ((s.env && s.env.httpPort) ? s.httpPort : ((s.saved && s.saved.httpPort) || s.httpPort)) : null;
3447
- restartBtn.disabled = true;
3448
- restartBtn.classList.add("busy");
3449
- apiFetch("/api/proxy/restart", { method: "POST" })
3450
- .then(function () {
3451
- showToast("Restarting proxy…", "success");
3452
- var base = location.protocol + "//" + location.hostname;
3453
- var samePort = !nextPort || String(nextPort) === (location.port || "80");
3454
- var tries = 0;
3455
- var wait = setInterval(function () {
3456
- tries++;
3457
- if (!samePort) {
3458
- var go = function () { clearInterval(wait); setTimeout(function () { location.href = base + ":" + nextPort + "/#/settings"; }, 1500); };
3459
- if (tries > 20) return go();
3460
- fetch("/healthz", { cache: "no-store" }).catch(go);
3461
- return;
3462
- }
3463
- if (tries < 3) return;
3464
- fetch("/healthz", { cache: "no-store" }).then(function (r) {
3465
- if (!r.ok) return;
3466
- clearInterval(wait);
3467
- location.reload();
3468
- }).catch(function () {});
3469
- if (tries > 40) { clearInterval(wait); restartBtn.disabled = false; restartBtn.classList.remove("busy"); }
3470
- }, 500);
3471
- })
3472
- .catch(function (err) {
3473
- apiError(err, "Couldn't restart the proxy");
3474
- restartBtn.disabled = false;
3475
- restartBtn.classList.remove("busy");
3476
- });
3477
- });
3478
- });
3479
-
3480
- document.getElementById("set-home-copy").addEventListener("click", function (e) { copyText(setHome.textContent, e.currentTarget); });
3481
-
3482
- function syncThemeSeg(mode) {
3483
- Array.prototype.forEach.call(prefTheme.querySelectorAll("button"), function (b) {
3484
- b.setAttribute("aria-checked", String(b.getAttribute("data-value") === mode));
3485
- });
3486
- }
3487
- prefTheme.addEventListener("click", function (e) {
3488
- var b = e.target.closest("button[data-value]");
3489
- if (b) setTheme(b.getAttribute("data-value"));
3490
- });
3491
- syncThemeSeg(document.documentElement.getAttribute("data-theme") || "system");
3492
- document.getElementById("theme-toggle").addEventListener("click", function () {
3493
- syncThemeSeg(document.documentElement.getAttribute("data-theme") || "system");
3494
- });
3495
-
3496
- prefRefresh.value = String(prefs.refresh);
3497
- if (prefRefresh.value !== String(prefs.refresh)) prefRefresh.value = "5000";
3498
- prefRefresh.addEventListener("change", function () {
3499
- prefs.refresh = parseInt(prefRefresh.value, 10) || 0;
3500
- savePrefs();
3501
- schedulePoll();
3502
- showToast(prefs.refresh ? "Refreshing every " + prefs.refresh / 1000 + "s" : "Auto-refresh paused", "success");
3503
- });
3504
- prefStart.value = prefs.start;
3505
- prefStart.addEventListener("change", function () { prefs.start = prefStart.value; savePrefs(); });
3506
- prefSidebar.addEventListener("click", function () { setCollapsed(!isCollapsed(), true); });
3507
-
3508
- function bindPrefSwitch(el, key, after) {
3509
- setSwitch(el, !!prefs[key]);
3510
- el.addEventListener("click", function () {
3511
- prefs[key] = !prefs[key];
3512
- savePrefs();
3513
- setSwitch(el, prefs[key]);
3514
- if (after) after();
3515
- });
3516
- }
3517
- bindPrefSwitch(prefConfirm, "confirm");
3518
- bindPrefSwitch(prefInsecure, "insecure", applyHostDefaults);
3519
- bindPrefSwitch(prefCors, "cors", applyHostDefaults);
3520
-
3521
- var tokenShown = false;
3522
- tokenReveal.addEventListener("click", function () {
3523
- tokenShown = !tokenShown;
3524
- tokenValue.textContent = tokenShown ? TOKEN : "••••••••••••••••";
3525
- tokenReveal.textContent = tokenShown ? "Hide" : "Show";
3526
- });
3527
- tokenCopy.addEventListener("click", function () { copyText(TOKEN, tokenCopy); });
3528
- dangerClearLogs.addEventListener("click", function () { logsClearBtn.click(); });
3529
-
3530
- // ---------- dashboard password ----------
3531
-
3532
- var uiAuthForm = document.getElementById("ui-auth-form");
3533
- var uiAuthBadge = document.getElementById("ui-auth-badge");
3534
- var uiAuthCurrentField = document.getElementById("ui-auth-current-field");
3535
- var uiAuthCurrent = document.getElementById("ui-auth-current");
3536
- var uiAuthNew = document.getElementById("ui-auth-new");
3537
- var uiAuthRepeat = document.getElementById("ui-auth-repeat");
3538
- var uiAuthError = document.getElementById("ui-auth-error");
3539
- var uiAuthSave = document.getElementById("ui-auth-save");
3540
- var uiAuthRemove = document.getElementById("ui-auth-remove");
3541
- var signOutBtn = document.getElementById("sb-signout");
3542
-
3543
- function renderUiAuth(enabled) {
3544
- UI_AUTH = enabled;
3545
- uiAuthBadge.textContent = enabled ? "On" : "Off";
3546
- uiAuthBadge.className = "badge " + (enabled ? "badge-up" : "badge-count");
3547
- uiAuthCurrentField.hidden = !enabled;
3548
- uiAuthSave.textContent = enabled ? "Change password" : "Set password";
3549
- uiAuthRemove.hidden = !enabled;
3550
- signOutBtn.hidden = !enabled;
3551
- renderHubPanel(enabled && hubPanelSwitch.getAttribute("aria-checked") === "true");
3552
- }
3553
- renderUiAuth(UI_AUTH);
3554
-
3555
- function uiAuthFail(message) {
3556
- uiAuthError.textContent = message;
3557
- uiAuthError.hidden = false;
3558
- }
3559
-
3560
- function sendUiAuth(body, done) {
3561
- uiAuthError.hidden = true;
3562
- uiAuthSave.disabled = uiAuthRemove.disabled = true;
3563
- apiFetch("/api/settings/ui-password", { method: "PUT", body: JSON.stringify(body) })
3564
- .then(function (res) {
3565
- uiAuthCurrent.value = uiAuthNew.value = uiAuthRepeat.value = "";
3566
- renderUiAuth(res.uiAuth.enabled);
3567
- showToast(done, "success");
3568
- })
3569
- .catch(function (err) { uiAuthFail(err.message + (err.hint ? " " + err.hint : "")); })
3570
- .then(function () { uiAuthSave.disabled = uiAuthRemove.disabled = false; });
3571
- }
3572
-
3573
- uiAuthForm.addEventListener("submit", function (e) {
3574
- e.preventDefault();
3575
- if (uiAuthNew.value.length < 8) return uiAuthFail("The password must be at least 8 characters.");
3576
- if (uiAuthNew.value !== uiAuthRepeat.value) return uiAuthFail("The passwords don't match.");
3577
- if (UI_AUTH && !uiAuthCurrent.value) return uiAuthFail("Enter the current password.");
3578
- var wasOn = UI_AUTH;
3579
- sendUiAuth({ password: uiAuthNew.value, current: uiAuthCurrent.value }, wasOn ? "Password changed. Other sessions are signed out." : "The dashboard now asks for this password");
3580
- });
3581
-
3582
- uiAuthRemove.addEventListener("click", function () {
3583
- if (!uiAuthCurrent.value) return uiAuthFail("Enter the current password to remove it.");
3584
- sendUiAuth({ enabled: false, current: uiAuthCurrent.value }, "Password removed");
3585
- });
3586
-
3587
- signOutBtn.addEventListener("click", function () {
3588
- fetch("/logout", { method: "POST" }).then(function () { location.reload(); });
3589
- });
3590
-
3591
- // ---------- polling ----------
3592
-
3593
- function tickViews() {
3594
- if (currentView === "overview") { renderOverview(); loadOverviewLogs(); }
3595
- else renderOverview();
3596
- if (currentView === "sharing") renderShareList();
3597
- if (currentView === "logs" && logsAuto.checked) loadLogs();
3598
- }
3599
-
3600
- function schedulePoll() {
3601
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
3602
- if (!prefs.refresh || proxyStopped) return;
3603
- pollTimer = setInterval(function () {
3604
- if (!document.hidden && !proxyStopped) refreshAll().then(tickViews);
3605
- }, prefs.refresh);
3606
- }
3607
-
3608
- setCollapsed(isCollapsed(), false);
3609
- var lastViewSaved = null;
3610
- try { lastViewSaved = localStorage.getItem("locadot.lastView"); } catch (e) {}
3611
- showView(viewFromHash() || (prefs.start === "last" ? lastViewSaved : prefs.start) || "overview");
3612
- schedulePoll();
3613
-
3614
- refreshAll().then(tickViews);
3615
- refreshHubAndRemotes();
3616
- })();
3617
- `;
15
+ // One script scope, so the order matters: helpers first, the polling loop last.
16
+ const clientJs = [
17
+ "\n",
18
+ core_1.jsPrelude, shell_1.jsHeader, remote_1.jsHub, remote_1.jsReceiver, hosts_1.jsAddHost, hosts_1.jsHostsTable, hosts_1.jsShare, hosts_1.jsProtect, shell_1.jsDialogs, hosts_1.jsHostRows,
19
+ system_1.jsLogs, core_1.jsPolling, shell_1.jsPrefs, shell_1.jsRouting, system_1.jsOverview, hosts_1.jsSharingPage, system_1.jsLogsPage, system_1.jsSettings, system_1.jsUiPassword, core_1.jsPollingLoop,
20
+ ].join("");
21
+ const body = [
22
+ shell_1.shellTop, system_1.overviewView, hosts_1.hostsView, remote_1.remoteView, system_1.logsView, system_1.settingsView, shell_1.shellBottom,
23
+ hosts_1.allowDialog, shell_1.dialogsMarkup, hosts_1.hostDialogs, shell_1.toastMarkup,
24
+ ].join("");
3618
25
  function renderPage(nonce, token, uiAuth = false) {
3619
26
  // The nonce and token are server-generated but still escaped before landing
3620
27
  // in HTML attributes, defensively.
@@ -3624,687 +31,15 @@ function renderPage(nonce, token, uiAuth = false) {
3624
31
  <html lang="en">
3625
32
  <head>
3626
33
  <meta charset="utf-8">
3627
- <meta name="viewport" content="width=device-width, initial-scale=1">
34
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
3628
35
  <meta name="color-scheme" content="dark light">
3629
36
  <meta name="locadot-token" content="${safeToken}">
3630
37
  <meta name="locadot-ui-auth" content="${uiAuth ? "on" : "off"}">
3631
38
  <title>locadot dashboard</title>
3632
- <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>
3633
- <style nonce="${safeNonce}">${css}</style>
39
+ <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 < 1100)) document.documentElement.setAttribute("data-sidebar", "collapsed"); } catch (e) {}</script>
40
+ <style nonce="${safeNonce}">${theme_1.css}${hosts_1.css}${remote_1.css}${system_1.css}</style>
3634
41
  </head>
3635
- <body class="boot">
3636
- <nav id="sidebar" class="sidebar" aria-label="Sections">
3637
- <div class="sb-head">
3638
- <span class="logo" aria-hidden="true"></span>
3639
- <h1 class="wordmark sb-label">locadot</h1>
3640
- <span id="version" class="muted mono sb-label"></span>
3641
- </div>
3642
- <div class="sb-links">
3643
- <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>
3644
- <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>
3645
- <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>
3646
- <div class="sb-section sb-label">Remote</div>
3647
- <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>
3648
- <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>
3649
- <div class="sb-section sb-label">System</div>
3650
- <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>
3651
- <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>
3652
- </div>
3653
- <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>
3654
- <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>
3655
- </nav>
3656
- <div id="sb-backdrop" class="sb-backdrop" hidden></div>
3657
- <div class="shell">
3658
- <header>
3659
- <div class="topbar">
3660
- <div class="brand">
3661
- <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>
3662
- <span class="logo brand-mobile" aria-hidden="true"></span>
3663
- <span class="wordmark brand-mobile">locadot</span>
3664
- </div>
3665
- <div class="header-meta">
3666
- <span class="chip chip-live"><span id="live-dot" class="live-dot"></span> <span id="live-label" class="live-label">live</span></span>
3667
- <span id="uptime" class="chip"></span>
3668
- <span id="pid" class="chip"></span>
3669
- <span id="updated"></span>
3670
- <button type="button" id="theme-toggle" class="btn theme-toggle" data-mode="system" title="Theme: system" aria-label="Theme: system (click to change)">
3671
- <svg class="i-system" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
3672
- <svg class="i-light" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
3673
- <svg class="i-dark" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
3674
- </button>
3675
- </div>
3676
- </div>
3677
- </header>
3678
- <main>
3679
- <div id="stopped-banner" class="banner" role="status" hidden></div>
3680
- <div id="load-error-banner" class="banner" role="alert" hidden>
3681
- <div class="banner-row">
3682
- <span id="load-error-text"></span>
3683
- <button type="button" id="load-error-retry" class="btn btn-sm">Retry</button>
3684
- </div>
3685
- </div>
3686
-
3687
- <section class="view" data-view="overview" aria-label="Overview">
3688
- <div class="page-head">
3689
- <h2 class="page-title">Overview</h2>
3690
- <p class="page-sub">Everything locadot is doing on this machine at a glance.</p>
3691
- </div>
3692
- <div class="tiles">
3693
- <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>
3694
- <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>
3695
- <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>
3696
- <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>
3697
- <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>
3698
- <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>
3699
- </div>
3700
- <div class="grid-2">
3701
- <section class="card" aria-label="Host health">
3702
- <div class="card-head">
3703
- <div class="card-title-wrap"><h3 class="card-title">Host health</h3></div>
3704
- <div class="card-tools"><a class="btn btn-sm" href="#/hosts">Manage hosts</a></div>
3705
- </div>
3706
- <div id="ov-host-list" class="ov-list"></div>
3707
- <div id="ov-host-empty" class="empty" hidden>
3708
- <span class="empty-icon" aria-hidden="true">+</span>
3709
- <p>No hosts yet.</p>
3710
- <a class="btn btn-sm btn-primary" href="#/hosts">Add your first host</a>
3711
- </div>
3712
- </section>
3713
- <section class="card" aria-label="Proxy status">
3714
- <div class="card-head">
3715
- <div class="card-title-wrap"><h3 class="card-title">Proxy</h3></div>
3716
- <div class="card-tools"><a class="btn btn-sm" href="#/settings">Settings</a></div>
3717
- </div>
3718
- <div id="ov-checks" class="ov-list"></div>
3719
- </section>
3720
- </div>
3721
- <section class="card" aria-label="Recent activity">
3722
- <div class="card-head">
3723
- <div class="card-title-wrap"><h3 class="card-title">Recent activity</h3></div>
3724
- <div class="card-tools"><a class="btn btn-sm" href="#/logs">All logs</a></div>
3725
- </div>
3726
- <pre id="ov-logs" class="logs-box logs-short" aria-live="off"></pre>
3727
- </section>
3728
- </section>
3729
-
3730
- <section class="view" data-view="hosts" aria-label="Hosts" hidden>
3731
- <div class="page-head">
3732
- <h2 class="page-title">Hosts</h2>
3733
- <p class="page-sub">Local hostnames and the dev servers they proxy to.</p>
3734
- </div>
3735
- <div class="layout">
3736
- <div class="col-primary">
3737
- <section id="hosts-card" class="card" aria-label="Registered hosts">
3738
- <div class="card-head">
3739
- <div class="card-title-wrap">
3740
- <h3 class="card-title">Hosts</h3>
3741
- <span id="hosts-count" class="badge badge-count" hidden></span>
3742
- </div>
3743
- <div class="card-tools">
3744
- <input type="search" id="hosts-filter" class="input input-sm input-search" placeholder="Filter hosts" aria-label="Filter hosts" autocomplete="off">
3745
- </div>
3746
- </div>
3747
- <div id="hosts-skeleton" class="table-wrap" aria-hidden="true">
3748
- <table>
3749
- <tbody>
3750
- <tr class="skeleton-row"><td colspan="6"><span class="skel-bar"></span></td></tr>
3751
- <tr class="skeleton-row"><td colspan="6"><span class="skel-bar"></span></td></tr>
3752
- <tr class="skeleton-row"><td colspan="6"><span class="skel-bar"></span></td></tr>
3753
- </tbody>
3754
- </table>
3755
- </div>
3756
- <div id="hosts-error" class="empty" hidden>
3757
- <span class="empty-icon" aria-hidden="true">!</span>
3758
- <p>Hosts couldn't be loaded while the proxy is unreachable.</p>
3759
- </div>
3760
- <div id="empty" class="empty" hidden>
3761
- <span class="empty-icon" aria-hidden="true">+</span>
3762
- <p>No hosts registered yet. Add one from the panel, or from the CLI:</p>
3763
- <pre class="code-block">locadot add --host app.localhost --port 3000</pre>
3764
- </div>
3765
- <div id="table-wrap" class="table-wrap" hidden>
3766
- <table id="table">
3767
- <thead>
3768
- <tr>
3769
- <th scope="col">Host</th>
3770
- <th scope="col" aria-label="Flow"></th>
3771
- <th scope="col">Options</th>
3772
- <th scope="col">Status</th>
3773
- <th scope="col">Public URL</th>
3774
- <th scope="col" class="actions">Actions</th>
3775
- </tr>
3776
- </thead>
3777
- <tbody id="tbody"></tbody>
3778
- </table>
3779
- </div>
3780
- </section>
3781
- </div>
3782
- <aside class="col-secondary">
3783
- <section id="add-card" class="card" aria-label="Add proxy host">
3784
- <div class="card-head">
3785
- <div class="card-title-wrap"><h3 class="card-title">Add host</h3></div>
3786
- </div>
3787
- <form id="add-form" class="card-body" novalidate>
3788
- <div class="form-grid">
3789
- <div class="field">
3790
- <label for="add-host">Host</label>
3791
- <input type="text" id="add-host" name="host" placeholder="app.localhost" autocomplete="off" aria-describedby="add-error" required>
3792
- </div>
3793
- <div class="field">
3794
- <label for="add-target">Target</label>
3795
- <input type="text" id="add-target" name="target" placeholder="3000, 127.0.0.1:8080, https://&hellip;" autocomplete="off" aria-describedby="add-error" required>
3796
- </div>
3797
- <div class="form-checks">
3798
- <div class="field field-checkbox">
3799
- <input type="checkbox" id="add-insecure" name="insecure">
3800
- <label for="add-insecure">Insecure TLS</label>
3801
- </div>
3802
- <div class="field field-checkbox" title="Send Origin/Referer as the target's own and let any origin call this host">
3803
- <input type="checkbox" id="add-cors" name="cors">
3804
- <label for="add-cors">Bypass CORS</label>
3805
- </div>
3806
- </div>
3807
- <button type="submit" id="add-submit" class="btn btn-primary btn-block">Add proxy</button>
3808
- </div>
3809
- <div id="add-error" class="field-error" role="alert" hidden></div>
3810
- </form>
3811
- </section>
3812
- </aside>
3813
- </div>
3814
- </section>
3815
-
3816
- <section class="view" data-view="sharing" aria-label="Public sharing" hidden>
3817
- <div class="page-head">
3818
- <h2 class="page-title">Public sharing</h2>
3819
- <p class="page-sub">Put a single host on a public https://*.trycloudflare.com URL. Anyone with the link can reach it.</p>
3820
- </div>
3821
- <div class="layout">
3822
- <div class="col-primary">
3823
- <section id="share-card" class="card" aria-label="Hosts you can share">
3824
- <div class="card-head">
3825
- <div class="card-title-wrap"><h3 class="card-title">Hosts</h3><span id="share-count" class="badge badge-count" hidden></span></div>
3826
- </div>
3827
- <div id="share-list" class="ov-list"></div>
3828
- <div id="share-empty" class="empty" hidden>
3829
- <span class="empty-icon" aria-hidden="true">+</span>
3830
- <p>Add a host first, then share it from here.</p>
3831
- </div>
3832
- </section>
3833
- </div>
3834
- <aside class="col-secondary">
3835
- <section id="sharing-card" class="card" aria-label="Public sharing">
3836
- <div class="card-head">
3837
- <div class="card-title-wrap"><h3 class="card-title">Cloudflare Tunnel</h3></div>
3838
- </div>
3839
- <div class="stat-list">
3840
- <div class="stat-row">
3841
- <div class="stat-main">
3842
- <div class="label">Cloudflare Tunnel</div>
3843
- <div id="tile-cloudflared-value" class="tile-value">—</div>
3844
- <div id="tile-cloudflared-sub" class="tile-sub mono"></div>
3845
- <div class="tile-sub">Share a host on a public https://*.trycloudflare.com URL with the Share button. No Cloudflare account needed.</div>
3846
- <div class="tile-actions">
3847
- <button type="button" id="cloudflared-install-btn" class="btn btn-sm btn-primary" hidden>Install cloudflared</button>
3848
- </div>
3849
- </div>
3850
- </div>
3851
- </div>
3852
- </section>
3853
- </aside>
3854
- </div>
3855
- </section>
3856
-
3857
- <section class="view" data-view="remote" aria-label="Remote access" hidden>
3858
- <div class="page-head">
3859
- <h2 class="page-title">Remote access</h2>
3860
- <p class="page-sub">Let other locadots use this machine's hosts through a Cloudflare hostname.</p>
3861
- </div>
3862
- <div class="layout">
3863
- <div class="col-primary">
3864
- <section id="hub-card" class="card" aria-label="Remote access">
3865
- <div class="card-head">
3866
- <div class="card-title-wrap"><h3 class="card-title">Remote access</h3></div>
3867
- </div>
3868
- <div class="hub-section">
3869
- <div class="hub-row">
3870
- <span class="pill"><span id="hub-dot" class="dot"></span><span id="hub-status-text">Off</span></span>
3871
- </div>
3872
- <div id="hub-url-row" class="hub-row" hidden>
3873
- <span id="hub-url" class="hub-url"></span>
3874
- <button type="button" id="hub-url-copy" class="copy-btn">Copy</button>
3875
- </div>
3876
- <div id="hub-login-row" class="hub-row" hidden>
3877
- <a id="hub-login-link" href="#" target="_blank" rel="noopener noreferrer">Log in to Cloudflare</a>
3878
- </div>
3879
- <div id="hub-quick-warning" class="hub-warn" hidden>URL changes when locadot restarts &mdash; receivers must update it.</div>
3880
- <div id="hub-error-row" class="hub-warn" hidden></div>
3881
-
3882
- <form id="hub-named-form" class="hub-form-row" novalidate>
3883
- <div class="field">
3884
- <label for="hub-domain">Domain</label>
3885
- <input type="text" id="hub-domain" placeholder="hub.example.com" autocomplete="off">
3886
- </div>
3887
- <div class="field">
3888
- <label for="hub-tunnel-name">Tunnel name</label>
3889
- <input type="text" id="hub-tunnel-name" placeholder="locadot (optional)" autocomplete="off">
3890
- </div>
3891
- <button type="submit" id="hub-named-submit" class="btn btn-sm btn-primary">Use my domain</button>
3892
- </form>
3893
- <div class="hub-actions">
3894
- <button type="button" id="hub-quick-btn" class="btn btn-sm">Use quick tunnel</button>
3895
- <button type="button" id="hub-stop-btn" class="btn btn-sm btn-danger" hidden>Stop</button>
3896
- </div>
3897
- <div id="hub-localhost-row" class="toggle-row" hidden>
3898
- <div>
3899
- <div class="label">Admins can open this dashboard and any port</div>
3900
- <div class="tile-sub">Admin peers get this dashboard at <span class="mono">&lt;domain&gt;.localhost</span> and this machine's localhost at <span class="mono">&lt;port&gt;.&lt;domain&gt;.localhost</span> on their side.</div>
3901
- </div>
3902
- <button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open this dashboard and any port on this machine"><span class="switch-knob"></span></button>
3903
- </div>
3904
- <div id="hub-panel-row" class="toggle-row" hidden>
3905
- <div>
3906
- <div class="label">Share this dashboard</div>
3907
- <div id="hub-panel-sub" class="tile-sub"></div>
3908
- </div>
3909
- <button type="button" id="hub-panel" class="switch" role="switch" aria-checked="false" aria-label="Share this dashboard at the public URL, behind the dashboard password"><span class="switch-knob"></span></button>
3910
- </div>
3911
- </div>
3912
-
3913
- <div id="hub-share-section" class="hub-section" hidden>
3914
- <div class="label">Create pairing link</div>
3915
- <div class="hub-form-row">
3916
- <div class="field">
3917
- <label for="invite-role">Role</label>
3918
- <select id="invite-role" class="input input-sm">
3919
- <option value="viewer">Viewer</option>
3920
- <option value="editor">Editor</option>
3921
- <option value="admin">Admin</option>
3922
- </select>
3923
- </div>
3924
- <button type="button" id="invite-create-btn" class="btn btn-sm btn-primary">Create pairing link</button>
3925
- </div>
3926
- <div id="invite-hosts-wrap" class="host-checks" hidden></div>
3927
- <div id="invite-result" class="invite-result" hidden>
3928
- <div class="invite-result-row">
3929
- <span id="invite-string" class="invite-string mono"></span>
3930
- <button type="button" id="invite-copy-btn" class="copy-btn">Copy</button>
3931
- </div>
3932
- <div id="invite-countdown" class="invite-countdown"></div>
3933
- </div>
3934
- </div>
3935
-
3936
- <div class="hub-section">
3937
- <div class="label">Peers</div>
3938
- <div id="peers-empty" class="dim" hidden>No peers yet.</div>
3939
- <div id="peers-list" class="peer-list"></div>
3940
- </div>
3941
- <div class="hub-section">
3942
- <div class="label">Pending invites</div>
3943
- <div id="invites-empty" class="dim" hidden>No pending invites.</div>
3944
- <div id="invites-list" class="invite-list"></div>
3945
- </div>
3946
- </section>
3947
- </div>
3948
- <aside class="col-secondary">
3949
- <section class="card" aria-label="Roles">
3950
- <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">How it works</h3></div></div>
3951
- <div class="card-body help-list">
3952
- <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>
3953
- <p>2. Create a pairing link. It works once and expires after 5 minutes.</p>
3954
- <p>3. The other machine pastes it under <a href="#/machines">Connected machines</a>.</p>
3955
- <div class="role-table">
3956
- <div><strong>Viewer</strong><span>browse the hosts you pick</span></div>
3957
- <div><strong>Editor</strong><span>also add and change hosts here</span></div>
3958
- <div><strong>Admin</strong><span>also delete hosts, change sharing, and open this dashboard and any port on this machine</span></div>
3959
- </div>
3960
- <p class="dim">Editors can reach anything this machine can. Change or revoke a role any time under Peers.</p>
3961
- </div>
3962
- </section>
3963
- </aside>
3964
- </div>
3965
- </section>
3966
-
3967
- <section class="view" data-view="machines" aria-label="Connected machines" hidden>
3968
- <div class="page-head">
3969
- <h2 class="page-title">Connected machines</h2>
3970
- <p class="page-sub">Other locadots you've connected to. Their hosts show up in your Hosts list.</p>
3971
- </div>
3972
-
3973
- <section id="remotes-card" class="card" aria-label="Connected machines">
3974
- <div class="card-head">
3975
- <div class="card-title-wrap"><h3 class="card-title">Connected machines</h3></div>
3976
- </div>
3977
- <div class="hub-section">
3978
- <form id="connect-form" class="hub-form-row" novalidate>
3979
- <div class="field">
3980
- <label for="connect-string">Pairing link</label>
3981
- <input type="text" id="connect-string" placeholder="https://hub.example.com/#lnk_..." autocomplete="off">
3982
- </div>
3983
- <div class="field">
3984
- <label for="connect-name">Name (optional)</label>
3985
- <input type="text" id="connect-name" placeholder="my-mac" autocomplete="off">
3986
- </div>
3987
- <div class="field">
3988
- <label for="connect-domain">Localhost domain (admins)</label>
3989
- <input type="text" id="connect-domain" placeholder="auto: two random words" autocomplete="off">
3990
- </div>
3991
- <button type="submit" id="connect-submit" class="btn btn-sm btn-primary">Connect</button>
3992
- </form>
3993
- <div id="connect-error" class="field-error" role="alert" hidden></div>
3994
- </div>
3995
- <div id="remotes-empty" class="empty" hidden>
3996
- <span class="empty-icon" aria-hidden="true">~</span>
3997
- <p>Not connected to anything yet. Paste a pairing link above.</p>
3998
- </div>
3999
- <div id="remotes-list" class="remote-list"></div>
4000
- </section>
4001
- </section>
4002
-
4003
- <section class="view" data-view="logs" aria-label="Logs" hidden>
4004
- <div class="page-head">
4005
- <h2 class="page-title">Logs</h2>
4006
- <p class="page-sub">The proxy's log file.</p>
4007
- </div>
4008
- <section id="logs-panel" class="card" aria-label="Logs">
4009
- <div class="card-head logs-head">
4010
- <div class="card-tools logs-tools">
4011
- <input type="search" id="logs-filter" class="input input-sm input-search" placeholder="Filter lines" aria-label="Filter log lines" autocomplete="off">
4012
- <select id="logs-level" class="input input-sm" aria-label="Level">
4013
- <option value="">All levels</option>
4014
- <option value="error">Errors</option>
4015
- <option value="warn">Warnings+</option>
4016
- <option value="info">Info+</option>
4017
- </select>
4018
- <select id="logs-lines" class="input input-sm" aria-label="Lines">
4019
- <option value="200">Last 200</option>
4020
- <option value="500">Last 500</option>
4021
- <option value="1000">Last 1000</option>
4022
- </select>
4023
- <label class="check-inline"><input type="checkbox" id="logs-auto" checked> Live</label>
4024
- </div>
4025
- <div class="card-tools">
4026
- <button type="button" id="logs-refresh" class="btn btn-sm">Refresh</button>
4027
- <button type="button" id="logs-download" class="btn btn-sm">Download</button>
4028
- <button type="button" id="logs-clear" class="btn btn-sm btn-danger">Clear</button>
4029
- </div>
4030
- </div>
4031
- <pre id="logs-box" class="logs-box logs-full" aria-live="off"></pre>
4032
- <div id="logs-meta" class="logs-meta dim"></div>
4033
- </section>
4034
- </section>
4035
-
4036
- <section class="view" data-view="settings" aria-label="Settings" hidden>
4037
- <div class="page-head">
4038
- <h2 class="page-title">Settings</h2>
4039
- <p class="page-sub">Proxy, security and dashboard preferences.</p>
4040
- </div>
4041
- <div class="layout">
4042
- <div class="col-primary">
4043
- <section id="proxy-settings-card" class="card" aria-label="Proxy settings">
4044
- <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">Proxy</h3></div></div>
4045
- <form id="ports-form" class="card-body" novalidate>
4046
- <div class="form-row-2">
4047
- <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>
4048
- <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>
4049
- </div>
4050
- <div id="ports-note" class="tile-sub"></div>
4051
- <div id="ports-error" class="field-error" role="alert" hidden></div>
4052
- <div class="form-actions">
4053
- <button type="submit" id="ports-save" class="btn btn-sm btn-primary">Save ports</button>
4054
- <button type="button" id="restart-proxy-btn" class="btn btn-sm">Restart proxy</button>
4055
- </div>
4056
- <div id="restart-note" class="hub-warn" hidden>Saved. Restart the proxy to use the new ports.</div>
4057
- </form>
4058
- <div class="stat-list">
4059
- <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>
4060
- <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>
4061
- <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>
4062
- </div>
4063
- </section>
4064
-
4065
- <section class="card" aria-label="Dashboard preferences">
4066
- <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">Dashboard</h3></div></div>
4067
- <div class="stat-list">
4068
- <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>
4069
- <div class="stat-side"><div id="pref-theme" class="segmented" role="radiogroup" aria-label="Theme">
4070
- <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>
4071
- </div></div></div>
4072
- <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>
4073
- <div class="stat-side"><select id="pref-refresh" class="input input-sm" aria-label="Refresh interval">
4074
- <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>
4075
- </select></div></div>
4076
- <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>
4077
- <div class="stat-side"><select id="pref-start" class="input input-sm" aria-label="Start page">
4078
- <option value="overview">Overview</option><option value="hosts">Hosts</option><option value="last">Last visited</option>
4079
- </select></div></div>
4080
- <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>
4081
- <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>
4082
- <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>
4083
- <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>
4084
- </div>
4085
- </section>
4086
-
4087
- <section class="card" aria-label="New host defaults">
4088
- <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">New host defaults</h3></div></div>
4089
- <div class="stat-list">
4090
- <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>
4091
- <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>
4092
- <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>
4093
- <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>
4094
- </div>
4095
- </section>
4096
-
4097
- <section id="cli-card" class="card" aria-label="Use from CLI or scripts">
4098
- <div class="card-head">
4099
- <div class="card-title-wrap"><h3 class="card-title">CLI &amp; API</h3></div>
4100
- </div>
4101
- <div class="card-body cli-list">
4102
- <div class="cli-item">
4103
- <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>
4104
- <pre id="cli-add-pre" class="code-block mono">locadot add --host app.localhost --port 3000</pre>
4105
- </div>
4106
- <div class="cli-item">
4107
- <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>
4108
- <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>
4109
- </div>
4110
- <div class="cli-item">
4111
- <div class="cli-item-head"><span class="label">Share publicly</span><button type="button" id="cli-tunnel-copy" class="copy-btn">Copy</button></div>
4112
- <pre id="cli-tunnel-pre" class="code-block mono">locadot tunnel --host app.localhost</pre>
4113
- </div>
4114
- </div>
4115
- </section>
4116
- </div>
4117
- <aside class="col-secondary">
4118
- <section id="system-card" class="card" aria-label="System status">
4119
- <div class="card-head">
4120
- <div class="card-title-wrap"><h3 class="card-title">System &amp; security</h3></div>
4121
- </div>
4122
- <div class="stat-list">
4123
- <div class="stat-row" hidden>
4124
- <div class="stat-main">
4125
- <div class="label">Proxy</div>
4126
- <div id="tile-proxy-ports" class="tile-value mono">—</div>
4127
- <div id="tile-proxy-bind" class="tile-sub mono"></div>
4128
- <div id="tile-proxy-statedir" class="tile-sub mono"></div>
4129
- <div id="tile-proxy-platform" class="tile-sub"></div>
4130
- </div>
4131
- </div>
4132
- <div class="stat-row">
4133
- <div class="stat-main">
4134
- <div class="label">Access</div>
4135
- <div id="tile-root-value" class="tile-value">—</div>
4136
- <div id="tile-ports-value" class="tile-sub"></div>
4137
- <div id="tile-ports-hint" class="tile-hint" hidden></div>
4138
- </div>
4139
- </div>
4140
- <div class="stat-row">
4141
- <div class="stat-main">
4142
- <div class="label">CA trust</div>
4143
- <div id="tile-ca-value" class="tile-value">—</div>
4144
- </div>
4145
- <div class="stat-side">
4146
- <button type="button" id="ca-toggle" class="switch" role="switch" aria-checked="false" aria-label="Toggle local CA trust">
4147
- <span class="switch-knob"></span>
4148
- </button>
4149
- </div>
4150
- </div>
4151
- <div class="stat-row">
4152
- <div class="stat-main">
4153
- <div class="label">Start at boot</div>
4154
- <div id="tile-startup-value" class="tile-value">—</div>
4155
- <div id="tile-startup-method" class="tile-sub"></div>
4156
- </div>
4157
- <div class="stat-side">
4158
- <button type="button" id="startup-toggle" class="switch" role="switch" aria-checked="false" aria-label="Toggle start at boot">
4159
- <span class="switch-knob"></span>
4160
- </button>
4161
- </div>
4162
- </div>
4163
- </div>
4164
- </section>
4165
-
4166
- <section id="ui-auth-card" class="card" aria-label="Dashboard password">
4167
- <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>
4168
- <form id="ui-auth-form" class="card-body token-body" novalidate>
4169
- <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>
4170
- <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>
4171
- <div class="form-row-2">
4172
- <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>
4173
- <div class="field"><label for="ui-auth-repeat">Repeat it</label><input type="password" id="ui-auth-repeat" class="input" autocomplete="new-password"></div>
4174
- </div>
4175
- <div id="ui-auth-error" class="field-error" role="alert" hidden></div>
4176
- <div class="form-actions">
4177
- <button type="submit" id="ui-auth-save" class="btn btn-sm btn-primary">Set password</button>
4178
- <button type="button" id="ui-auth-remove" class="btn btn-sm btn-danger" hidden>Remove password</button>
4179
- </div>
4180
- </form>
4181
- </section>
4182
-
4183
- <section class="card" aria-label="API token">
4184
- <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">API token</h3></div></div>
4185
- <div class="card-body token-body">
4186
- <div class="tile-sub">Scripts send it as <code class="mono">X-Locadot-Token</code>. It changes every time the proxy starts.</div>
4187
- <div class="token-row"><code id="token-value" class="mono token-value">••••••••••••••••</code>
4188
- <button type="button" id="token-reveal" class="copy-btn">Show</button>
4189
- <button type="button" id="token-copy" class="copy-btn">Copy</button></div>
4190
- </div>
4191
- </section>
4192
-
4193
- <section class="card danger-card" aria-label="Danger zone">
4194
- <div class="card-head"><div class="card-title-wrap"><h3 class="card-title">Danger zone</h3></div></div>
4195
- <div class="stat-list">
4196
- <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>
4197
- <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>
4198
- </div>
4199
- </section>
4200
- </aside>
4201
- </div>
4202
- </section>
4203
- </main>
4204
- <footer>
4205
- <a href="https://github.com/avinashid/locadot" target="_blank" rel="noopener noreferrer">github.com/avinashid/locadot</a>
4206
- </footer>
4207
- </div>
4208
- <dialog id="allow-dialog">
4209
- <div class="modal-head">
4210
- <div>
4211
- <h3 class="modal-title">Internal access</h3>
4212
- <p id="allow-host" class="modal-sub mono"></p>
4213
- </div>
4214
- <button type="button" id="allow-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4215
- </div>
4216
- <div class="modal-body">
4217
- <p class="modal-desc">People who open this mapping through a tunnel or remote access can only reach public hosts. Allow the internal addresses this app calls, such as its API on localhost.</p>
4218
- <div id="allow-warn" class="modal-warn" hidden>Bypass CORS is off for this mapping. These addresses take effect once it's on, because the page reaches them through locadot's pass-through.</div>
4219
- <div id="allow-list" class="allow-list"></div>
4220
- <div class="allow-add-row">
4221
- <input type="text" id="allow-input" class="input" placeholder="localhost:3000" aria-label="Address" autocomplete="off">
4222
- <button type="button" id="allow-add" class="btn btn-sm">Add</button>
4223
- </div>
4224
- <p class="modal-hint">host:port, or a host alone for any port. locadot's own ports are always blocked.</p>
4225
- <div id="allow-error" class="field-error" role="alert" hidden></div>
4226
- </div>
4227
- <div class="modal-foot">
4228
- <button type="button" id="allow-cancel" class="btn">Cancel</button>
4229
- <button type="button" id="allow-save" class="btn btn-primary" disabled>Save changes</button>
4230
- </div>
4231
- </dialog>
4232
- <dialog id="confirm-dialog">
4233
- <div class="modal-head">
4234
- <div>
4235
- <h3 class="modal-title" id="confirm-title"></h3>
4236
- </div>
4237
- <button type="button" id="confirm-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4238
- </div>
4239
- <div class="modal-body">
4240
- <p class="modal-desc" id="confirm-message"></p>
4241
- </div>
4242
- <div class="modal-foot">
4243
- <button type="button" id="confirm-cancel" class="btn">Cancel</button>
4244
- <button type="button" id="confirm-ok" class="btn btn-primary">OK</button>
4245
- </div>
4246
- </dialog>
4247
- <dialog id="prompt-dialog">
4248
- <div class="modal-head">
4249
- <div>
4250
- <h3 class="modal-title" id="prompt-title"></h3>
4251
- </div>
4252
- <button type="button" id="prompt-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4253
- </div>
4254
- <div class="modal-body">
4255
- <p class="modal-desc" id="prompt-message"></p>
4256
- <div class="field">
4257
- <label id="prompt-label" for="prompt-input"></label>
4258
- <input type="text" id="prompt-input" class="input" autocomplete="off">
4259
- </div>
4260
- <div id="prompt-error" class="field-error" role="alert" hidden></div>
4261
- </div>
4262
- <div class="modal-foot">
4263
- <button type="button" id="prompt-cancel" class="btn">Cancel</button>
4264
- <button type="button" id="prompt-confirm" class="btn btn-primary">OK</button>
4265
- </div>
4266
- </dialog>
4267
- <dialog id="share-dialog">
4268
- <div class="modal-head">
4269
- <div>
4270
- <h3 class="modal-title">Share publicly</h3>
4271
- <p id="share-host" class="modal-sub mono"></p>
4272
- </div>
4273
- <button type="button" id="share-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4274
- </div>
4275
- <div class="modal-body">
4276
- <p class="modal-desc">Anyone with the link will be able to reach this host, without signing in.</p>
4277
- <div id="share-options" class="share-options" role="radiogroup" aria-label="Sharing method">
4278
- <button type="button" id="share-option-random" class="option-card" role="radio" aria-checked="false">
4279
- <span class="option-dot" aria-hidden="true"></span>
4280
- <span class="option-body">
4281
- <span class="option-title">Random URL</span>
4282
- <span class="option-desc">https://&lt;random&gt;.trycloudflare.com &middot; No account needed. Changes each time sharing restarts.</span>
4283
- </span>
4284
- </button>
4285
- <button type="button" id="share-option-custom" class="option-card" role="radio" aria-checked="false">
4286
- <span class="option-dot" aria-hidden="true"></span>
4287
- <span class="option-body">
4288
- <span class="option-title">Custom domain</span>
4289
- <span class="option-desc">Your own hostname on a domain in your Cloudflare account. Stable URL.</span>
4290
- <span id="share-custom-extra" class="option-extra" hidden>
4291
- <label for="share-domain-input" class="dim">Hostname</label>
4292
- <input type="text" id="share-domain-input" class="input mono" placeholder="app.example.com" autocomplete="off">
4293
- <div id="share-domain-error" class="field-error" role="alert" hidden></div>
4294
- <p id="share-domain-hint" class="modal-hint"></p>
4295
- </span>
4296
- </span>
4297
- </button>
4298
- </div>
4299
- <div id="share-error" class="field-error" role="alert" hidden></div>
4300
- </div>
4301
- <div class="modal-foot">
4302
- <button type="button" id="share-cancel" class="btn">Cancel</button>
4303
- <button type="button" id="share-submit" class="btn btn-primary">Start sharing</button>
4304
- </div>
4305
- </dialog>
4306
- <div id="toast-container" class="toast-container" aria-live="polite"></div>
4307
- <script nonce="${safeNonce}">${clientJs}</script>
42
+ ${body}<script nonce="${safeNonce}">${clientJs}</script>
4308
43
  </body>
4309
44
  </html>
4310
45
  `;