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.
- package/CHANGELOG.md +24 -0
- package/README.md +17 -1
- package/dist/cli/commands/hosts.js +1 -1
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/protect.js +76 -0
- package/dist/cli/commands/tunnel.js +12 -1
- package/dist/cli/commands/ui.js +6 -3
- package/dist/constants/index.js +1 -0
- package/dist/dashboard/api.js +11 -1
- package/dist/dashboard/index.js +2 -0
- package/dist/dashboard/page.js +20 -4285
- package/dist/dashboard/ui/core.js +803 -0
- package/dist/dashboard/ui/hosts.js +1522 -0
- package/dist/dashboard/ui/remote.js +958 -0
- package/dist/dashboard/ui/shell.js +509 -0
- package/dist/dashboard/ui/system.js +764 -0
- package/dist/dashboard/ui/theme.js +1710 -0
- package/dist/index.js +11 -0
- package/dist/lib/host-auth.js +123 -0
- package/dist/lib/hosts.js +53 -0
- package/dist/proxy/cloudflare-logins.js +197 -0
- package/dist/proxy/protect.js +215 -0
- package/dist/proxy/router.js +5 -0
- package/dist/proxy/tunnel.js +16 -11
- package/package.json +1 -1
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Remote access (sharing this machine) and connected machines: one page, two tabs. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.css = exports.remoteView = exports.jsReceiver = exports.jsHub = void 0;
|
|
5
|
+
exports.jsHub = ` // ---------- remote access: hub (sender) ----------
|
|
6
|
+
|
|
7
|
+
var remoteInviteActionBtn = document.getElementById("remote-invite-action");
|
|
8
|
+
var remoteConnectActionBtn = document.getElementById("remote-connect-action");
|
|
9
|
+
var hubSetupSection = document.getElementById("hub-setup-section");
|
|
10
|
+
var inviteSectionOpen = false;
|
|
11
|
+
|
|
12
|
+
var remoteTabsEl = document.querySelector('.tabs[data-tabs="remote"]');
|
|
13
|
+
if (remoteTabsEl) initTabs(remoteTabsEl, { onChange: function () { updatePageActions(); } });
|
|
14
|
+
|
|
15
|
+
function updatePageActions() {
|
|
16
|
+
var tab = currentTab("remote");
|
|
17
|
+
var up = !!(lastHub && lastHub.status === "up");
|
|
18
|
+
remoteInviteActionBtn.hidden = !(tab === "hub" && up);
|
|
19
|
+
remoteConnectActionBtn.hidden = tab !== "machines";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
remoteInviteActionBtn.addEventListener("click", function () {
|
|
23
|
+
inviteSectionOpen = !inviteSectionOpen;
|
|
24
|
+
hubShareSection.hidden = !(lastHub && lastHub.status === "up" && inviteSectionOpen);
|
|
25
|
+
if (!hubShareSection.hidden) {
|
|
26
|
+
updateInviteHostsVisibility();
|
|
27
|
+
hubShareSection.scrollIntoView({ behavior: motionOk() ? "smooth" : "auto", block: "nearest" });
|
|
28
|
+
inviteRoleSelect.focus();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function fmtIn(iso) {
|
|
33
|
+
if (!iso) return "";
|
|
34
|
+
var ms = new Date(iso).getTime() - Date.now();
|
|
35
|
+
if (ms <= 0) return "expired";
|
|
36
|
+
var s = Math.round(ms / 1000);
|
|
37
|
+
if (s < 60) return "in " + s + "s";
|
|
38
|
+
var m = Math.round(s / 60);
|
|
39
|
+
return "in " + m + "m";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderPeers(peers) {
|
|
43
|
+
peersEmptyEl.hidden = peers.length !== 0;
|
|
44
|
+
clear(peersListEl);
|
|
45
|
+
peers.forEach(function (peer, i) {
|
|
46
|
+
var row = document.createElement("div");
|
|
47
|
+
row.className = "peer-row";
|
|
48
|
+
|
|
49
|
+
var main = document.createElement("div");
|
|
50
|
+
main.className = "peer-main";
|
|
51
|
+
var name = document.createElement("div");
|
|
52
|
+
name.className = "peer-name";
|
|
53
|
+
name.textContent = peer.name;
|
|
54
|
+
main.appendChild(name);
|
|
55
|
+
var sub = document.createElement("div");
|
|
56
|
+
sub.className = "muted";
|
|
57
|
+
sub.textContent = "last seen " + fmtRelative(peer.lastSeen);
|
|
58
|
+
main.appendChild(sub);
|
|
59
|
+
row.appendChild(main);
|
|
60
|
+
|
|
61
|
+
var meta = document.createElement("div");
|
|
62
|
+
meta.className = "peer-controls";
|
|
63
|
+
var roleBadge = document.createElement("span");
|
|
64
|
+
roleBadge.className = "badge badge-accent badge-role";
|
|
65
|
+
roleBadge.textContent = peer.role;
|
|
66
|
+
meta.appendChild(roleBadge);
|
|
67
|
+
|
|
68
|
+
function setRole(role) {
|
|
69
|
+
if (role === peer.role) return;
|
|
70
|
+
apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "PUT", body: JSON.stringify({ role: role, hosts: peer.hosts }) })
|
|
71
|
+
.then(function () { showToast("Updated role for " + peer.name, "success"); return loadHub(); })
|
|
72
|
+
.catch(function (err) { apiError(err, "Couldn't update role"); });
|
|
73
|
+
}
|
|
74
|
+
function revoke() {
|
|
75
|
+
confirmDialog({ title: "Revoke " + peer.name + "?", message: "This peer will lose access immediately.", confirmLabel: "Revoke", danger: true }).then(function (ok) {
|
|
76
|
+
if (!ok) return;
|
|
77
|
+
apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
|
|
78
|
+
.then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
|
|
79
|
+
.catch(function (err) { apiError(err, "Couldn't revoke peer"); });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
meta.appendChild(makeMoreButton(function () {
|
|
83
|
+
return ["viewer", "editor", "admin"].map(function (r) {
|
|
84
|
+
return { label: "Set as " + r.charAt(0).toUpperCase() + r.slice(1), disabled: r === peer.role, onSelect: function () { setRole(r); } };
|
|
85
|
+
}).concat(["-", { label: "Revoke", icon: ICONS.trash, danger: true, onSelect: revoke }]);
|
|
86
|
+
}, { label: "Actions for " + peer.name, small: true }));
|
|
87
|
+
row.appendChild(meta);
|
|
88
|
+
peersListEl.appendChild(row);
|
|
89
|
+
animateIn(row, i);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderInvites(invites) {
|
|
94
|
+
invitesEmptyEl.hidden = invites.length !== 0;
|
|
95
|
+
clear(invitesListEl);
|
|
96
|
+
invites.forEach(function (invite, i) {
|
|
97
|
+
var row = document.createElement("div");
|
|
98
|
+
row.className = "invite-row";
|
|
99
|
+
var main = document.createElement("div");
|
|
100
|
+
main.className = "peer-main";
|
|
101
|
+
var roleLine = document.createElement("div");
|
|
102
|
+
roleLine.className = "peer-name";
|
|
103
|
+
roleLine.textContent = invite.role.charAt(0).toUpperCase() + invite.role.slice(1);
|
|
104
|
+
main.appendChild(roleLine);
|
|
105
|
+
var sub = document.createElement("div");
|
|
106
|
+
sub.className = "muted";
|
|
107
|
+
sub.textContent = "expires " + fmtIn(invite.expiresAt);
|
|
108
|
+
main.appendChild(sub);
|
|
109
|
+
row.appendChild(main);
|
|
110
|
+
|
|
111
|
+
var revokeBtn = document.createElement("button");
|
|
112
|
+
revokeBtn.type = "button";
|
|
113
|
+
revokeBtn.className = "btn btn-ghost btn-icon btn-sm";
|
|
114
|
+
revokeBtn.setAttribute("aria-label", "Revoke invite");
|
|
115
|
+
revokeBtn.setAttribute("data-tip", "Revoke");
|
|
116
|
+
revokeBtn.appendChild(svgIcon(ICONS.close));
|
|
117
|
+
revokeBtn.addEventListener("click", function () {
|
|
118
|
+
revokeBtn.disabled = true;
|
|
119
|
+
apiFetch("/api/invites/" + encodeURIComponent(invite.id), { method: "DELETE" })
|
|
120
|
+
.then(function () { return loadHub(); })
|
|
121
|
+
.catch(function (err) { apiError(err, "Couldn't revoke invite"); revokeBtn.disabled = false; });
|
|
122
|
+
});
|
|
123
|
+
row.appendChild(revokeBtn);
|
|
124
|
+
invitesListEl.appendChild(row);
|
|
125
|
+
animateIn(row, i);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Re-rendered on every poll, so remember what the user unticked.
|
|
130
|
+
var inviteUnchecked = {};
|
|
131
|
+
function renderInviteHostChecks() {
|
|
132
|
+
Array.prototype.forEach.call(inviteHostsWrap.querySelectorAll("input"), function (cb) {
|
|
133
|
+
if (cb.checked) delete inviteUnchecked[cb.value];
|
|
134
|
+
else inviteUnchecked[cb.value] = true;
|
|
135
|
+
});
|
|
136
|
+
clear(inviteHostsWrap);
|
|
137
|
+
lastHosts.forEach(function (row) {
|
|
138
|
+
if (row.remote) return;
|
|
139
|
+
var label = document.createElement("label");
|
|
140
|
+
var cb = document.createElement("input");
|
|
141
|
+
cb.type = "checkbox";
|
|
142
|
+
cb.value = row.host;
|
|
143
|
+
cb.checked = !inviteUnchecked[row.host];
|
|
144
|
+
label.appendChild(cb);
|
|
145
|
+
label.appendChild(document.createTextNode(row.host));
|
|
146
|
+
inviteHostsWrap.appendChild(label);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function updateInviteHostsVisibility() {
|
|
151
|
+
var isViewer = inviteRoleSelect.value === "viewer";
|
|
152
|
+
inviteHostsWrap.hidden = !isViewer;
|
|
153
|
+
if (isViewer) renderInviteHostChecks();
|
|
154
|
+
}
|
|
155
|
+
inviteRoleSelect.addEventListener("change", updateInviteHostsVisibility);
|
|
156
|
+
|
|
157
|
+
function updateInviteCountdown() {
|
|
158
|
+
if (!inviteExpiresAt) return;
|
|
159
|
+
var remaining = Math.max(0, Math.round((inviteExpiresAt - Date.now()) / 1000));
|
|
160
|
+
if (remaining <= 0) {
|
|
161
|
+
inviteCountdownEl.textContent = "expired";
|
|
162
|
+
if (inviteTimer) { clearInterval(inviteTimer); inviteTimer = null; }
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
var m = Math.floor(remaining / 60);
|
|
166
|
+
var s = remaining % 60;
|
|
167
|
+
inviteCountdownEl.textContent = m + ":" + (s < 10 ? "0" : "") + s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function showInviteResult(data) {
|
|
171
|
+
inviteStringEl.textContent = data.string;
|
|
172
|
+
inviteResultEl.hidden = false;
|
|
173
|
+
inviteExpiresAt = new Date(data.expiresAt).getTime();
|
|
174
|
+
if (inviteTimer) clearInterval(inviteTimer);
|
|
175
|
+
updateInviteCountdown();
|
|
176
|
+
inviteTimer = setInterval(updateInviteCountdown, 1000);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
inviteCreateBtn.addEventListener("click", function () {
|
|
180
|
+
var role = inviteRoleSelect.value;
|
|
181
|
+
var hosts;
|
|
182
|
+
if (role === "viewer") {
|
|
183
|
+
hosts = Array.prototype.slice.call(inviteHostsWrap.querySelectorAll("input:checked")).map(function (cb) { return cb.value; });
|
|
184
|
+
}
|
|
185
|
+
inviteCreateBtn.disabled = true;
|
|
186
|
+
inviteCreateBtn.classList.add("busy");
|
|
187
|
+
apiFetch("/api/invites", { method: "POST", body: JSON.stringify({ role: role, hosts: hosts }) })
|
|
188
|
+
.then(function (data) {
|
|
189
|
+
showInviteResult(data);
|
|
190
|
+
return loadHub();
|
|
191
|
+
})
|
|
192
|
+
.catch(function (err) { apiError(err, "Couldn't create pairing link"); })
|
|
193
|
+
.then(function () { inviteCreateBtn.disabled = false; inviteCreateBtn.classList.remove("busy"); });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
inviteCopyBtn.addEventListener("click", function () { copyText(inviteStringEl.textContent, inviteCopyBtn); });
|
|
197
|
+
|
|
198
|
+
function renderHub(data) {
|
|
199
|
+
lastHub = data.hub;
|
|
200
|
+
var hub = data.hub;
|
|
201
|
+
var status = hub.status;
|
|
202
|
+
var up = status === "up";
|
|
203
|
+
|
|
204
|
+
var dotClass = status === "up" ? "up" : status === "starting" ? "warn" : status === "login" ? "accent" : status === "error" ? "down" : "";
|
|
205
|
+
hubDot.className = "dot" + (dotClass ? " " + dotClass : "");
|
|
206
|
+
var labels = { off: "Off", starting: "Starting\\u2026", login: "Login required", up: "Up", error: "Error" };
|
|
207
|
+
hubStatusText.textContent = labels[status] || status;
|
|
208
|
+
|
|
209
|
+
hubUrlRow.hidden = !(up && hub.url);
|
|
210
|
+
if (up && hub.url) hubUrlEl.textContent = hub.url;
|
|
211
|
+
|
|
212
|
+
hubLoginRow.hidden = !(status === "login" && hub.loginUrl);
|
|
213
|
+
if (status === "login" && hub.loginUrl) hubLoginLink.href = hub.loginUrl;
|
|
214
|
+
|
|
215
|
+
hubQuickWarning.hidden = !(up && hub.mode === "quick");
|
|
216
|
+
|
|
217
|
+
hubLocalhostRow.hidden = !data.config;
|
|
218
|
+
setSwitch(hubLocalhostSwitch, data.localhost !== false);
|
|
219
|
+
hubPanelUrl = up && hub.url ? hub.url : "";
|
|
220
|
+
hubPanelRow.hidden = !data.config;
|
|
221
|
+
renderHubPanel(data.panel === true);
|
|
222
|
+
|
|
223
|
+
hubErrorRow.hidden = !(status === "error" && hub.error);
|
|
224
|
+
if (status === "error" && hub.error) hubErrorRow.textContent = hub.error;
|
|
225
|
+
|
|
226
|
+
var running = status === "starting" || status === "login" || up;
|
|
227
|
+
hubSetupSection.hidden = up;
|
|
228
|
+
hubNamedForm.hidden = up;
|
|
229
|
+
hubQuickBtn.hidden = up;
|
|
230
|
+
hubStopBtn.hidden = !running;
|
|
231
|
+
|
|
232
|
+
if (data.config && data.config.mode === "named" && data.config.domain && !hubDomainInput.value) {
|
|
233
|
+
hubDomainInput.value = data.config.domain;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
hubShareSection.hidden = !(up && inviteSectionOpen);
|
|
237
|
+
if (up) updateInviteHostsVisibility();
|
|
238
|
+
|
|
239
|
+
renderPeers(data.peers);
|
|
240
|
+
renderInvites(data.invites);
|
|
241
|
+
updatePageActions();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function loadHub() {
|
|
245
|
+
return fetch("/api/hub").then(parseJsonOrThrow).then(renderHub).catch(function (err) { apiError(err, "Couldn't load remote access"); });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
hubUrlCopyBtn.addEventListener("click", function () { copyText(hubUrlEl.textContent, hubUrlCopyBtn); });
|
|
249
|
+
|
|
250
|
+
hubNamedForm.addEventListener("submit", function (e) {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
var domain = hubDomainInput.value.trim();
|
|
253
|
+
if (!domain) { hubDomainInput.focus(); return; }
|
|
254
|
+
var tunnel = hubTunnelNameInput.value.trim();
|
|
255
|
+
hubNamedSubmitBtn.disabled = true;
|
|
256
|
+
hubNamedSubmitBtn.classList.add("busy");
|
|
257
|
+
apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "named", domain: domain, tunnel: tunnel || undefined }) })
|
|
258
|
+
.then(function () { showToast("Setting up your domain\\u2026", "success"); return loadHub(); })
|
|
259
|
+
.catch(function (err) { apiError(err, "Couldn't start setup"); })
|
|
260
|
+
.then(function () { hubNamedSubmitBtn.disabled = false; hubNamedSubmitBtn.classList.remove("busy"); });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
hubQuickBtn.addEventListener("click", function () {
|
|
264
|
+
hubQuickBtn.disabled = true;
|
|
265
|
+
hubQuickBtn.classList.add("busy");
|
|
266
|
+
apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "quick" }) })
|
|
267
|
+
.then(function () { showToast("Starting quick tunnel\\u2026", "success"); return loadHub(); })
|
|
268
|
+
.catch(function (err) { apiError(err, "Couldn't start quick tunnel"); })
|
|
269
|
+
.then(function () { hubQuickBtn.disabled = false; hubQuickBtn.classList.remove("busy"); });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
hubStopBtn.addEventListener("click", function () {
|
|
273
|
+
confirmDialog({ title: "Stop remote access?", message: "Connected peers will be disconnected.", confirmLabel: "Stop remote access", danger: true }).then(function (ok) {
|
|
274
|
+
if (!ok) return;
|
|
275
|
+
hubStopBtn.disabled = true;
|
|
276
|
+
hubStopBtn.classList.add("busy");
|
|
277
|
+
apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
|
|
278
|
+
.then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
|
|
279
|
+
.catch(function (err) { apiError(err, "Couldn't stop remote access"); })
|
|
280
|
+
.then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
`;
|
|
285
|
+
exports.jsReceiver = ` // ---------- remote access: connected machines (receiver) ----------
|
|
286
|
+
|
|
287
|
+
remoteConnectActionBtn.addEventListener("click", function () {
|
|
288
|
+
connectStringInput.scrollIntoView({ behavior: motionOk() ? "smooth" : "auto", block: "center" });
|
|
289
|
+
connectStringInput.focus();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
function showConnectError(message) {
|
|
293
|
+
connectErrorEl.textContent = message;
|
|
294
|
+
connectErrorEl.hidden = false;
|
|
295
|
+
}
|
|
296
|
+
function hideConnectError() {
|
|
297
|
+
connectErrorEl.hidden = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function renderRemotes(remotes) {
|
|
301
|
+
lastRemotes = remotes;
|
|
302
|
+
remotesEmptyEl.hidden = remotes.length !== 0;
|
|
303
|
+
sbRemotesCountEl.textContent = String(remotes.length);
|
|
304
|
+
sbRemotesCountEl.hidden = remotes.length === 0;
|
|
305
|
+
clear(remotesListEl);
|
|
306
|
+
remotes.forEach(function (remote, i) {
|
|
307
|
+
var item = document.createElement("div");
|
|
308
|
+
item.className = "remote-item";
|
|
309
|
+
|
|
310
|
+
var head = document.createElement("div");
|
|
311
|
+
head.className = "remote-head";
|
|
312
|
+
var main = document.createElement("div");
|
|
313
|
+
main.className = "remote-main";
|
|
314
|
+
var nameLine = document.createElement("div");
|
|
315
|
+
nameLine.className = "peer-name";
|
|
316
|
+
nameLine.textContent = remote.name;
|
|
317
|
+
main.appendChild(nameLine);
|
|
318
|
+
var sub = document.createElement("div");
|
|
319
|
+
sub.className = "muted";
|
|
320
|
+
sub.textContent = (remote.sender && remote.sender.hostname) || "";
|
|
321
|
+
main.appendChild(sub);
|
|
322
|
+
head.appendChild(main);
|
|
323
|
+
|
|
324
|
+
var controls = document.createElement("div");
|
|
325
|
+
controls.className = "remote-controls";
|
|
326
|
+
var roleBadge = document.createElement("span");
|
|
327
|
+
roleBadge.className = "badge badge-role badge-accent";
|
|
328
|
+
roleBadge.textContent = remote.role;
|
|
329
|
+
controls.appendChild(roleBadge);
|
|
330
|
+
var statusBadge = document.createElement("span");
|
|
331
|
+
statusBadge.className = "badge badge-dot " + (remote.status === "ok" ? "badge-up" : "badge-down");
|
|
332
|
+
statusBadge.textContent = remote.status === "ok" ? "connected" : "error";
|
|
333
|
+
if (remote.status !== "ok" && remote.error) statusBadge.title = remote.error;
|
|
334
|
+
controls.appendChild(statusBadge);
|
|
335
|
+
|
|
336
|
+
var syncBtn = document.createElement("button");
|
|
337
|
+
syncBtn.type = "button";
|
|
338
|
+
syncBtn.className = "btn btn-ghost btn-icon btn-sm";
|
|
339
|
+
syncBtn.setAttribute("aria-label", "Sync " + remote.name);
|
|
340
|
+
syncBtn.setAttribute("data-tip", "Sync");
|
|
341
|
+
syncBtn.appendChild(svgIcon(ICONS.refresh));
|
|
342
|
+
syncBtn.addEventListener("click", function () {
|
|
343
|
+
syncBtn.disabled = true;
|
|
344
|
+
syncBtn.classList.add("busy");
|
|
345
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/sync", { method: "POST" })
|
|
346
|
+
.then(function () { showToast("Synced " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
|
|
347
|
+
.catch(function (err) { apiError(err, "Couldn't sync " + remote.name); })
|
|
348
|
+
.then(function () { syncBtn.disabled = false; syncBtn.classList.remove("busy"); });
|
|
349
|
+
});
|
|
350
|
+
controls.appendChild(syncBtn);
|
|
351
|
+
|
|
352
|
+
controls.appendChild(makeMoreButton(function () {
|
|
353
|
+
return [
|
|
354
|
+
{
|
|
355
|
+
label: "Update URL", icon: ICONS.edit, onSelect: function () {
|
|
356
|
+
promptDialog({ title: "Update URL", message: "New URL for " + remote.name, label: "URL", value: remote.url, confirmLabel: "Save" }).then(function (next) {
|
|
357
|
+
if (!next || !next.trim() || next.trim() === remote.url) return;
|
|
358
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
|
|
359
|
+
.then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
|
|
360
|
+
.catch(function (err) { apiError(err, "Couldn't update URL"); });
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
"-",
|
|
365
|
+
{
|
|
366
|
+
label: "Disconnect", icon: ICONS.trash, danger: true, onSelect: function () {
|
|
367
|
+
confirmDialog({ title: "Disconnect from " + remote.name + "?", message: "This removes its hosts too.", confirmLabel: "Disconnect", danger: true }).then(function (ok) {
|
|
368
|
+
if (!ok) return;
|
|
369
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
|
|
370
|
+
.then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
|
|
371
|
+
.catch(function (err) { apiError(err, "Couldn't disconnect"); });
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
];
|
|
376
|
+
}, { label: "Actions for " + remote.name, small: true }));
|
|
377
|
+
head.appendChild(controls);
|
|
378
|
+
item.appendChild(head);
|
|
379
|
+
|
|
380
|
+
var urlRow = document.createElement("div");
|
|
381
|
+
urlRow.className = "hub-row";
|
|
382
|
+
var urlText = document.createElement("span");
|
|
383
|
+
urlText.className = "hub-url";
|
|
384
|
+
urlText.textContent = remote.url;
|
|
385
|
+
urlRow.appendChild(urlText);
|
|
386
|
+
item.appendChild(urlRow);
|
|
387
|
+
if (remote.role === "admin") item.appendChild(remoteLocalRow(remote));
|
|
388
|
+
|
|
389
|
+
var hostsLabel = document.createElement("div");
|
|
390
|
+
hostsLabel.className = "label";
|
|
391
|
+
hostsLabel.textContent = "Shared hosts";
|
|
392
|
+
item.appendChild(hostsLabel);
|
|
393
|
+
|
|
394
|
+
var hostsWrap = document.createElement("div");
|
|
395
|
+
hostsWrap.className = "remote-hosts-list";
|
|
396
|
+
if (remote.available === null) {
|
|
397
|
+
var errLine = document.createElement("div");
|
|
398
|
+
errLine.className = "dim";
|
|
399
|
+
errLine.textContent = "Couldn't load hosts: " + (remote.error || "unknown error");
|
|
400
|
+
hostsWrap.appendChild(errLine);
|
|
401
|
+
} else if (!(remote.available || []).length) {
|
|
402
|
+
var noneLine = document.createElement("div");
|
|
403
|
+
noneLine.className = "dim";
|
|
404
|
+
noneLine.textContent = "No hosts shared yet.";
|
|
405
|
+
hostsWrap.appendChild(noneLine);
|
|
406
|
+
} else {
|
|
407
|
+
(remote.available || []).forEach(function (h) {
|
|
408
|
+
var hostRow = document.createElement("div");
|
|
409
|
+
hostRow.className = "remote-host-row";
|
|
410
|
+
var hostMain = document.createElement("div");
|
|
411
|
+
hostMain.className = "mono";
|
|
412
|
+
var aliases = (remote.mapped || []).filter(function (m) { return m.host === h.host; }).map(function (m) { return m.local; });
|
|
413
|
+
hostMain.appendChild(document.createTextNode(h.host + (aliases.length ? " \\u2192 " : "")));
|
|
414
|
+
aliases.forEach(function (aliasName, idx) {
|
|
415
|
+
if (idx) hostMain.appendChild(document.createTextNode(", "));
|
|
416
|
+
var aliasRow = lastHosts.filter(function (r) { return r.host === aliasName; })[0];
|
|
417
|
+
var aliasHref = aliasRow && aliasRow.urls && (aliasRow.urls.https || aliasRow.urls.http);
|
|
418
|
+
if (aliasHref) {
|
|
419
|
+
var link = document.createElement("a");
|
|
420
|
+
link.href = aliasHref;
|
|
421
|
+
link.target = "_blank";
|
|
422
|
+
link.rel = "noopener noreferrer";
|
|
423
|
+
link.textContent = aliasName;
|
|
424
|
+
hostMain.appendChild(link);
|
|
425
|
+
} else {
|
|
426
|
+
hostMain.appendChild(document.createTextNode(aliasName));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
hostRow.appendChild(hostMain);
|
|
430
|
+
|
|
431
|
+
if (!aliases.length) {
|
|
432
|
+
var aliasForm = document.createElement("div");
|
|
433
|
+
aliasForm.className = "mini-form";
|
|
434
|
+
var aliasInput = document.createElement("input");
|
|
435
|
+
aliasInput.type = "text";
|
|
436
|
+
aliasInput.className = "input input-sm";
|
|
437
|
+
aliasInput.placeholder = h.host;
|
|
438
|
+
aliasInput.setAttribute("aria-label", "Local alias for " + h.host);
|
|
439
|
+
var aliasBtn = document.createElement("button");
|
|
440
|
+
aliasBtn.type = "button";
|
|
441
|
+
aliasBtn.className = "btn btn-sm";
|
|
442
|
+
aliasBtn.textContent = "Add alias";
|
|
443
|
+
aliasBtn.addEventListener("click", function () {
|
|
444
|
+
var local = aliasInput.value.trim();
|
|
445
|
+
if (!local) { aliasInput.focus(); return; }
|
|
446
|
+
aliasBtn.disabled = true;
|
|
447
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/aliases", { method: "POST", body: JSON.stringify({ host: h.host, local: local }) })
|
|
448
|
+
.then(function () { showToast("Added alias " + local, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
|
|
449
|
+
.catch(function (err) { apiError(err, "Couldn't add alias"); aliasBtn.disabled = false; });
|
|
450
|
+
});
|
|
451
|
+
aliasForm.appendChild(aliasInput);
|
|
452
|
+
aliasForm.appendChild(aliasBtn);
|
|
453
|
+
hostRow.appendChild(aliasForm);
|
|
454
|
+
} else if (remote.role === "admin") {
|
|
455
|
+
var delBtn = document.createElement("button");
|
|
456
|
+
delBtn.type = "button";
|
|
457
|
+
delBtn.className = "btn btn-sm btn-danger";
|
|
458
|
+
delBtn.textContent = "Delete";
|
|
459
|
+
delBtn.addEventListener("click", function () {
|
|
460
|
+
confirmDialog({ title: "Delete " + h.host + " on " + remote.name + "?", message: "This can't be undone.", confirmLabel: "Delete", danger: true }).then(function (ok) {
|
|
461
|
+
if (!ok) return;
|
|
462
|
+
delBtn.disabled = true;
|
|
463
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
|
|
464
|
+
.then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
|
|
465
|
+
.catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
hostRow.appendChild(delBtn);
|
|
469
|
+
}
|
|
470
|
+
hostsWrap.appendChild(hostRow);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
item.appendChild(hostsWrap);
|
|
474
|
+
|
|
475
|
+
if (remote.role === "editor" || remote.role === "admin") {
|
|
476
|
+
var addHostForm = document.createElement("div");
|
|
477
|
+
addHostForm.className = "mini-form";
|
|
478
|
+
var addHostIn = document.createElement("input");
|
|
479
|
+
addHostIn.type = "text";
|
|
480
|
+
addHostIn.className = "input input-sm";
|
|
481
|
+
addHostIn.placeholder = "host";
|
|
482
|
+
addHostIn.setAttribute("aria-label", "New host on " + remote.name);
|
|
483
|
+
var addTargetIn = document.createElement("input");
|
|
484
|
+
addTargetIn.type = "text";
|
|
485
|
+
addTargetIn.className = "input input-sm";
|
|
486
|
+
addTargetIn.placeholder = "target";
|
|
487
|
+
addTargetIn.setAttribute("aria-label", "Target for new host on " + remote.name);
|
|
488
|
+
var addHostBtn = document.createElement("button");
|
|
489
|
+
addHostBtn.type = "button";
|
|
490
|
+
addHostBtn.className = "btn btn-sm";
|
|
491
|
+
addHostBtn.textContent = "Add host on " + remote.name;
|
|
492
|
+
addHostBtn.addEventListener("click", function () {
|
|
493
|
+
var h = addHostIn.value.trim();
|
|
494
|
+
var t = addTargetIn.value.trim();
|
|
495
|
+
if (!h || !t) return;
|
|
496
|
+
addHostBtn.disabled = true;
|
|
497
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts", { method: "POST", body: JSON.stringify({ host: h, target: t }) })
|
|
498
|
+
.then(function () {
|
|
499
|
+
showToast("Added " + h + " on " + remote.name, "success");
|
|
500
|
+
addHostIn.value = "";
|
|
501
|
+
addTargetIn.value = "";
|
|
502
|
+
return Promise.all([loadRemotes(), loadHosts()]);
|
|
503
|
+
})
|
|
504
|
+
.catch(function (err) { apiError(err, "Couldn't add host"); })
|
|
505
|
+
.then(function () { addHostBtn.disabled = false; });
|
|
506
|
+
});
|
|
507
|
+
addHostForm.appendChild(addHostIn);
|
|
508
|
+
addHostForm.appendChild(addTargetIn);
|
|
509
|
+
addHostForm.appendChild(addHostBtn);
|
|
510
|
+
item.appendChild(addHostForm);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
remotesListEl.appendChild(item);
|
|
514
|
+
animateIn(item, i);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function loadRemotes() {
|
|
519
|
+
return fetch("/api/remotes").then(parseJsonOrThrow).then(function (data) { renderRemotes(data.remotes || []); }).catch(function (err) { apiError(err, "Couldn't load remotes"); });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
connectForm.addEventListener("submit", function (e) {
|
|
523
|
+
e.preventDefault();
|
|
524
|
+
hideConnectError();
|
|
525
|
+
var value = connectStringInput.value.trim();
|
|
526
|
+
if (!value) { showConnectError("Paste a pairing link first."); return; }
|
|
527
|
+
var name = connectNameInput.value.trim();
|
|
528
|
+
var domain = connectDomainInput.value.trim().toLowerCase();
|
|
529
|
+
connectSubmitBtn.disabled = true;
|
|
530
|
+
connectSubmitBtn.classList.add("busy");
|
|
531
|
+
apiFetch("/api/remotes", { method: "POST", body: JSON.stringify({ string: value, name: name || undefined, domain: domain || undefined }) })
|
|
532
|
+
.then(function (data) {
|
|
533
|
+
var r = data.remote;
|
|
534
|
+
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));
|
|
535
|
+
connectForm.reset();
|
|
536
|
+
return Promise.all([loadRemotes(), loadHosts()]);
|
|
537
|
+
})
|
|
538
|
+
.catch(function (err) { showConnectError(err.message); })
|
|
539
|
+
.then(function () { connectSubmitBtn.disabled = false; connectSubmitBtn.classList.remove("busy"); });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
function localUrl(domain, port) {
|
|
543
|
+
return location.protocol + "//" + (port ? port + "." : "") + domain + ".localhost" + (location.port ? ":" + location.port : "") + "/";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function saveDomain(remote, domain) {
|
|
547
|
+
return apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ domain: domain }) })
|
|
548
|
+
.then(function (data) {
|
|
549
|
+
var next = data && data.remote ? data.remote.domain : null;
|
|
550
|
+
showToast(next ? "Localhost of " + remote.name + " is " + next + ".localhost" : "Removed the localhost domain", "success");
|
|
551
|
+
return loadRemotes();
|
|
552
|
+
})
|
|
553
|
+
.catch(function (err) { apiError(err, "Couldn't set the domain"); });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Admin remotes: link to the sender's whole localhost, and an inline editor for the domain. */
|
|
557
|
+
function remoteLocalRow(remote) {
|
|
558
|
+
var row = document.createElement("div");
|
|
559
|
+
row.className = "hub-row remote-local";
|
|
560
|
+
if (remote.localhost === false) {
|
|
561
|
+
var off = document.createElement("span");
|
|
562
|
+
off.className = "dim";
|
|
563
|
+
off.textContent = "Localhost access is off on " + ((remote.sender && remote.sender.hostname) || remote.name) + ".";
|
|
564
|
+
row.appendChild(off);
|
|
565
|
+
return row;
|
|
566
|
+
}
|
|
567
|
+
var label = document.createElement("span");
|
|
568
|
+
label.className = "label";
|
|
569
|
+
label.textContent = "Localhost";
|
|
570
|
+
row.appendChild(label);
|
|
571
|
+
|
|
572
|
+
var form = document.createElement("form");
|
|
573
|
+
form.className = "mini-form";
|
|
574
|
+
form.hidden = Boolean(remote.domain);
|
|
575
|
+
var input = document.createElement("input");
|
|
576
|
+
input.type = "text";
|
|
577
|
+
input.className = "input input-sm";
|
|
578
|
+
input.value = remote.domain || "";
|
|
579
|
+
input.placeholder = "office";
|
|
580
|
+
input.setAttribute("aria-label", "Localhost domain for " + remote.name);
|
|
581
|
+
var save = document.createElement("button");
|
|
582
|
+
save.type = "submit";
|
|
583
|
+
save.className = "btn btn-sm btn-primary";
|
|
584
|
+
save.textContent = "Save";
|
|
585
|
+
var random = document.createElement("button");
|
|
586
|
+
random.type = "button";
|
|
587
|
+
random.className = "btn btn-sm";
|
|
588
|
+
random.textContent = "Random";
|
|
589
|
+
random.addEventListener("click", function () { saveDomain(remote, "random"); });
|
|
590
|
+
form.appendChild(input);
|
|
591
|
+
form.appendChild(save);
|
|
592
|
+
form.appendChild(random);
|
|
593
|
+
form.addEventListener("submit", function (e) {
|
|
594
|
+
e.preventDefault();
|
|
595
|
+
var value = input.value.trim().toLowerCase();
|
|
596
|
+
if (!value) { input.focus(); return; }
|
|
597
|
+
saveDomain(remote, value);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
if (remote.domain) {
|
|
601
|
+
var link = document.createElement("a");
|
|
602
|
+
link.href = localUrl(remote.domain);
|
|
603
|
+
link.target = "_blank";
|
|
604
|
+
link.rel = "noopener noreferrer";
|
|
605
|
+
link.textContent = remote.domain + ".localhost";
|
|
606
|
+
link.title = "Its dashboard. Any port: " + localUrl(remote.domain, "<port>");
|
|
607
|
+
row.appendChild(link);
|
|
608
|
+
row.appendChild(makeCopyButton(function () { return localUrl(remote.domain); }));
|
|
609
|
+
var change = document.createElement("button");
|
|
610
|
+
change.type = "button";
|
|
611
|
+
change.className = "btn btn-sm btn-ghost";
|
|
612
|
+
change.textContent = "Change";
|
|
613
|
+
change.addEventListener("click", function () { form.hidden = !form.hidden; if (!form.hidden) input.focus(); });
|
|
614
|
+
row.appendChild(change);
|
|
615
|
+
var remove = document.createElement("button");
|
|
616
|
+
remove.type = "button";
|
|
617
|
+
remove.className = "btn btn-sm btn-ghost";
|
|
618
|
+
remove.textContent = "Remove";
|
|
619
|
+
remove.addEventListener("click", function () { saveDomain(remote, null); });
|
|
620
|
+
row.appendChild(remove);
|
|
621
|
+
}
|
|
622
|
+
row.appendChild(form);
|
|
623
|
+
return row;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function renderHubPanel(on) {
|
|
627
|
+
setSwitch(hubPanelSwitch, on);
|
|
628
|
+
// Turning it on needs a password; turning it off never does.
|
|
629
|
+
hubPanelSwitch.disabled = !on && !UI_AUTH;
|
|
630
|
+
if (!UI_AUTH && !on) hubPanelSub.textContent = "Set a dashboard password in Settings first. Visitors sign in with it.";
|
|
631
|
+
else if (on) hubPanelSub.textContent = "Open " + (hubPanelUrl || "the public URL") + " in any browser and sign in with the dashboard password.";
|
|
632
|
+
else hubPanelSub.textContent = "Open this dashboard from any browser at the public URL, after signing in with the dashboard password.";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
hubPanelSwitch.addEventListener("click", function () {
|
|
636
|
+
var next = hubPanelSwitch.getAttribute("aria-checked") !== "true";
|
|
637
|
+
hubPanelSwitch.disabled = true;
|
|
638
|
+
apiFetch("/api/hub/panel", { method: "PUT", body: JSON.stringify({ enabled: next }) })
|
|
639
|
+
.then(function () {
|
|
640
|
+
renderHubPanel(next);
|
|
641
|
+
showToast(next ? "The dashboard is shared at the public URL" : "The dashboard is no longer shared", "success");
|
|
642
|
+
})
|
|
643
|
+
.catch(function (err) {
|
|
644
|
+
apiError(err, "Couldn't change dashboard sharing");
|
|
645
|
+
renderHubPanel(!next);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
hubLocalhostSwitch.addEventListener("click", function () {
|
|
650
|
+
var next = hubLocalhostSwitch.getAttribute("aria-checked") !== "true";
|
|
651
|
+
hubLocalhostSwitch.disabled = true;
|
|
652
|
+
apiFetch("/api/hub/localhost", { method: "PUT", body: JSON.stringify({ enabled: next }) })
|
|
653
|
+
.then(function () {
|
|
654
|
+
setSwitch(hubLocalhostSwitch, next);
|
|
655
|
+
showToast(next ? "Admins can open this dashboard and any port" : "Localhost access is off", "success");
|
|
656
|
+
})
|
|
657
|
+
.catch(function (err) { apiError(err, "Couldn't change localhost access"); })
|
|
658
|
+
.then(function () { hubLocalhostSwitch.disabled = false; });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
function refreshHubAndRemotes() {
|
|
662
|
+
if (proxyStopped) return Promise.resolve();
|
|
663
|
+
return Promise.all([loadHub(), loadRemotes()]).then(function () { renderOverview(); });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
caToggle.addEventListener("click", function () {
|
|
667
|
+
if (caBusy || !lastStatus) return;
|
|
668
|
+
var next = !(lastStatus.system.caTrusted === true);
|
|
669
|
+
caBusy = true;
|
|
670
|
+
caToggle.disabled = true;
|
|
671
|
+
caToggle.classList.add("busy");
|
|
672
|
+
apiFetch("/api/trust", { method: "POST", body: JSON.stringify({ trusted: next }) })
|
|
673
|
+
.then(function () {
|
|
674
|
+
showToast("CA trust setting updated", "success");
|
|
675
|
+
return refreshAll();
|
|
676
|
+
})
|
|
677
|
+
.catch(function (err) { apiError(err, "Couldn't update CA trust"); })
|
|
678
|
+
.then(function () {
|
|
679
|
+
caBusy = false;
|
|
680
|
+
caToggle.disabled = false;
|
|
681
|
+
caToggle.classList.remove("busy");
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
startupToggle.addEventListener("click", function () {
|
|
686
|
+
if (startupBusy || !lastStatus) return;
|
|
687
|
+
var next = !lastStatus.system.startup.enabled;
|
|
688
|
+
startupBusy = true;
|
|
689
|
+
startupToggle.disabled = true;
|
|
690
|
+
startupToggle.classList.add("busy");
|
|
691
|
+
apiFetch("/api/startup", { method: "POST", body: JSON.stringify({ enabled: next }) })
|
|
692
|
+
.then(function () {
|
|
693
|
+
showToast("Startup setting updated", "success");
|
|
694
|
+
return refreshAll();
|
|
695
|
+
})
|
|
696
|
+
.catch(function (err) { apiError(err, "Couldn't update start at boot"); })
|
|
697
|
+
.then(function () {
|
|
698
|
+
startupBusy = false;
|
|
699
|
+
startupToggle.disabled = false;
|
|
700
|
+
startupToggle.classList.remove("busy");
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
stopProxyBtn.addEventListener("click", function () {
|
|
705
|
+
if (proxyStopped) return;
|
|
706
|
+
confirmDialog({ title: "Stop the locadot proxy?", message: "Run \\"locadot start\\" to bring it back up.", confirmLabel: "Stop proxy", danger: true }).then(function (ok) {
|
|
707
|
+
if (!ok) return;
|
|
708
|
+
stopProxyBtn.disabled = true;
|
|
709
|
+
stopProxyBtn.classList.add("busy");
|
|
710
|
+
apiFetch("/api/proxy/stop", { method: "POST" })
|
|
711
|
+
.then(function () {
|
|
712
|
+
proxyStopped = true;
|
|
713
|
+
clear(stoppedBanner);
|
|
714
|
+
stoppedBanner.appendChild(document.createTextNode("Proxy stopped. Run "));
|
|
715
|
+
var code = document.createElement("code");
|
|
716
|
+
code.className = "mono";
|
|
717
|
+
code.textContent = "locadot start";
|
|
718
|
+
stoppedBanner.appendChild(code);
|
|
719
|
+
stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
|
|
720
|
+
stoppedBanner.hidden = false;
|
|
721
|
+
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
722
|
+
if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
|
|
723
|
+
showToast("Proxy stopped", "warn");
|
|
724
|
+
})
|
|
725
|
+
.catch(function (err) {
|
|
726
|
+
apiError(err, "Couldn't stop the proxy");
|
|
727
|
+
stopProxyBtn.disabled = false;
|
|
728
|
+
stopProxyBtn.classList.remove("busy");
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
cfInstallBtn.addEventListener("click", function () {
|
|
734
|
+
if (cfInstallBusy) return;
|
|
735
|
+
cfInstallBusy = true;
|
|
736
|
+
cfInstallBtn.disabled = true;
|
|
737
|
+
cfInstallBtn.classList.add("busy");
|
|
738
|
+
var prevText = cfInstallBtn.textContent;
|
|
739
|
+
cfInstallBtn.textContent = "Installing\\u2026";
|
|
740
|
+
apiFetch("/api/cloudflared/install", { method: "POST", body: JSON.stringify({}) })
|
|
741
|
+
.then(function () {
|
|
742
|
+
showToast("cloudflared installed", "success");
|
|
743
|
+
return refreshAll();
|
|
744
|
+
})
|
|
745
|
+
.catch(function (err) { apiError(err, "Couldn't install cloudflared"); })
|
|
746
|
+
.then(function () {
|
|
747
|
+
cfInstallBusy = false;
|
|
748
|
+
cfInstallBtn.disabled = false;
|
|
749
|
+
cfInstallBtn.classList.remove("busy");
|
|
750
|
+
cfInstallBtn.textContent = prevText;
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
`;
|
|
755
|
+
exports.remoteView = ` <section class="view" data-view="remote" aria-label="Remote" hidden>
|
|
756
|
+
<div class="page-head">
|
|
757
|
+
<h2 class="page-title">Remote</h2>
|
|
758
|
+
<p class="page-sub">Share this machine's hosts with other locadots, or connect to one that's sharing.</p>
|
|
759
|
+
<div class="page-actions">
|
|
760
|
+
<button type="button" id="remote-invite-action" class="btn btn-primary" hidden>Create pairing link</button>
|
|
761
|
+
<button type="button" id="remote-connect-action" class="btn btn-primary" hidden>Connect a machine</button>
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<div class="tabs" role="tablist" data-tabs="remote" aria-label="Remote sections">
|
|
766
|
+
<button type="button" role="tab" data-tab="hub">This machine</button>
|
|
767
|
+
<button type="button" role="tab" data-tab="machines">Connected machines</button>
|
|
768
|
+
</div>
|
|
769
|
+
|
|
770
|
+
<div data-tab-panel="hub">
|
|
771
|
+
<div class="layout">
|
|
772
|
+
<div class="col-primary">
|
|
773
|
+
<section id="hub-card" class="card" aria-label="This machine">
|
|
774
|
+
<div class="card-head">
|
|
775
|
+
<div class="card-title-wrap">
|
|
776
|
+
<h3 class="card-title">This machine</h3>
|
|
777
|
+
<p class="card-desc">Share its hosts with other locadots through a Cloudflare hostname.</p>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
|
|
781
|
+
<div class="hub-section">
|
|
782
|
+
<div class="hub-hero-top">
|
|
783
|
+
<span class="pill"><span id="hub-dot" class="dot"></span><span id="hub-status-text" class="hub-status-text">Off</span></span>
|
|
784
|
+
<button type="button" id="hub-stop-btn" class="btn btn-sm btn-danger" hidden>Stop</button>
|
|
785
|
+
</div>
|
|
786
|
+
<div id="hub-url-row" class="hub-row" hidden>
|
|
787
|
+
<span id="hub-url" class="hub-url"></span>
|
|
788
|
+
<button type="button" id="hub-url-copy" class="copy-btn">Copy</button>
|
|
789
|
+
</div>
|
|
790
|
+
<div id="hub-login-row" class="hub-row" hidden>
|
|
791
|
+
<a id="hub-login-link" href="#" target="_blank" rel="noopener noreferrer">Log in to Cloudflare</a>
|
|
792
|
+
</div>
|
|
793
|
+
<div id="hub-quick-warning" class="hub-warn" hidden>URL changes when locadot restarts — receivers must update it.</div>
|
|
794
|
+
<div id="hub-error-row" class="hub-warn" hidden></div>
|
|
795
|
+
</div>
|
|
796
|
+
|
|
797
|
+
<div id="hub-setup-section" class="hub-section" hidden>
|
|
798
|
+
<div class="label">Turn on remote access</div>
|
|
799
|
+
<div class="setup-grid">
|
|
800
|
+
<div class="setup-card">
|
|
801
|
+
<div class="setup-card-title">Use my domain</div>
|
|
802
|
+
<p class="setup-card-desc">Your own hostname on a domain in your Cloudflare account. Stable URL.</p>
|
|
803
|
+
<form id="hub-named-form" class="hub-form-row" novalidate>
|
|
804
|
+
<div class="field">
|
|
805
|
+
<label for="hub-domain">Domain</label>
|
|
806
|
+
<input type="text" id="hub-domain" placeholder="hub.example.com" autocomplete="off">
|
|
807
|
+
</div>
|
|
808
|
+
<div class="field">
|
|
809
|
+
<label for="hub-tunnel-name">Tunnel name</label>
|
|
810
|
+
<input type="text" id="hub-tunnel-name" placeholder="locadot (optional)" autocomplete="off">
|
|
811
|
+
</div>
|
|
812
|
+
<button type="submit" id="hub-named-submit" class="btn btn-sm btn-primary">Use my domain</button>
|
|
813
|
+
</form>
|
|
814
|
+
</div>
|
|
815
|
+
<div class="setup-card">
|
|
816
|
+
<div class="setup-card-title">Quick tunnel</div>
|
|
817
|
+
<p class="setup-card-desc">Free, no account needed. The URL changes each time it restarts.</p>
|
|
818
|
+
<button type="button" id="hub-quick-btn" class="btn btn-sm">Use quick tunnel</button>
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
|
|
823
|
+
<div class="hub-section">
|
|
824
|
+
<div id="hub-localhost-row" class="toggle-row" hidden>
|
|
825
|
+
<div>
|
|
826
|
+
<div class="label">Admins can open this dashboard and any port</div>
|
|
827
|
+
<div class="tile-sub">Admin peers get this dashboard at <span class="mono"><domain>.localhost</span> and this machine's localhost at <span class="mono"><port>.<domain>.localhost</span> on their side.</div>
|
|
828
|
+
</div>
|
|
829
|
+
<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>
|
|
830
|
+
</div>
|
|
831
|
+
<div id="hub-panel-row" class="toggle-row" hidden>
|
|
832
|
+
<div>
|
|
833
|
+
<div class="label">Share this dashboard</div>
|
|
834
|
+
<div id="hub-panel-sub" class="tile-sub"></div>
|
|
835
|
+
</div>
|
|
836
|
+
<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>
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
<div class="hub-section">
|
|
841
|
+
<div class="label">Peers</div>
|
|
842
|
+
<div id="peers-empty" class="dim" hidden>No peers yet.</div>
|
|
843
|
+
<div id="peers-list" class="peer-list"></div>
|
|
844
|
+
</div>
|
|
845
|
+
|
|
846
|
+
<div class="hub-section">
|
|
847
|
+
<div class="label">Pending invites</div>
|
|
848
|
+
<div id="hub-share-section" class="invite-create" hidden>
|
|
849
|
+
<div class="hub-form-row">
|
|
850
|
+
<div class="field">
|
|
851
|
+
<label for="invite-role">Role</label>
|
|
852
|
+
<select id="invite-role" class="input input-sm">
|
|
853
|
+
<option value="viewer">Viewer</option>
|
|
854
|
+
<option value="editor">Editor</option>
|
|
855
|
+
<option value="admin">Admin</option>
|
|
856
|
+
</select>
|
|
857
|
+
</div>
|
|
858
|
+
<button type="button" id="invite-create-btn" class="btn btn-sm btn-primary">Create pairing link</button>
|
|
859
|
+
</div>
|
|
860
|
+
<div id="invite-hosts-wrap" class="host-checks" hidden></div>
|
|
861
|
+
<div id="invite-result" class="invite-result" hidden>
|
|
862
|
+
<div class="invite-result-row">
|
|
863
|
+
<span id="invite-string" class="invite-string mono"></span>
|
|
864
|
+
<button type="button" id="invite-copy-btn" class="copy-btn">Copy</button>
|
|
865
|
+
</div>
|
|
866
|
+
<div id="invite-countdown" class="invite-countdown"></div>
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
<div id="invites-empty" class="dim" hidden>No pending invites.</div>
|
|
870
|
+
<div id="invites-list" class="invite-list"></div>
|
|
871
|
+
</div>
|
|
872
|
+
</section>
|
|
873
|
+
</div>
|
|
874
|
+
<aside class="col-secondary">
|
|
875
|
+
<details class="card" open>
|
|
876
|
+
<summary><div class="card-title-wrap"><h3 class="card-title">How it works</h3></div></summary>
|
|
877
|
+
<div class="card-body help-list">
|
|
878
|
+
<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>
|
|
879
|
+
<p>2. Create a pairing link. It works once and expires after 5 minutes.</p>
|
|
880
|
+
<p>3. The other machine pastes it under <a href="#/remote/machines">Connected machines</a>.</p>
|
|
881
|
+
<div class="role-table">
|
|
882
|
+
<div><strong>Viewer</strong><span>browse the hosts you pick</span></div>
|
|
883
|
+
<div><strong>Editor</strong><span>also add and change hosts here</span></div>
|
|
884
|
+
<div><strong>Admin</strong><span>also delete hosts, change sharing, and open this dashboard and any port on this machine</span></div>
|
|
885
|
+
</div>
|
|
886
|
+
<p class="dim">Editors can reach anything this machine can. Change or revoke a role any time under Peers.</p>
|
|
887
|
+
</div>
|
|
888
|
+
</details>
|
|
889
|
+
</aside>
|
|
890
|
+
</div>
|
|
891
|
+
</div>
|
|
892
|
+
|
|
893
|
+
<div data-tab-panel="machines">
|
|
894
|
+
<section id="connect-card" class="card" aria-label="Connect a machine">
|
|
895
|
+
<div class="card-head">
|
|
896
|
+
<div class="card-title-wrap">
|
|
897
|
+
<h3 class="card-title">Connect a machine</h3>
|
|
898
|
+
<p class="card-desc">Paste a one-time pairing link from another locadot.</p>
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
<div class="hub-section">
|
|
902
|
+
<form id="connect-form" class="hub-form-row" novalidate>
|
|
903
|
+
<div class="field">
|
|
904
|
+
<label for="connect-string">Pairing link</label>
|
|
905
|
+
<input type="text" id="connect-string" placeholder="https://hub.example.com/#lnk_..." autocomplete="off">
|
|
906
|
+
</div>
|
|
907
|
+
<div class="field">
|
|
908
|
+
<label for="connect-name">Name (optional)</label>
|
|
909
|
+
<input type="text" id="connect-name" placeholder="my-mac" autocomplete="off">
|
|
910
|
+
</div>
|
|
911
|
+
<div class="field">
|
|
912
|
+
<label for="connect-domain">Localhost domain (admins)</label>
|
|
913
|
+
<input type="text" id="connect-domain" placeholder="auto: two random words" autocomplete="off">
|
|
914
|
+
</div>
|
|
915
|
+
<button type="submit" id="connect-submit" class="btn btn-sm btn-primary">Connect</button>
|
|
916
|
+
</form>
|
|
917
|
+
<div id="connect-error" class="field-error" role="alert" hidden></div>
|
|
918
|
+
</div>
|
|
919
|
+
</section>
|
|
920
|
+
|
|
921
|
+
<section id="remotes-card" class="card" aria-label="Connected machines">
|
|
922
|
+
<div class="card-head">
|
|
923
|
+
<div class="card-title-wrap"><h3 class="card-title">Connected machines</h3></div>
|
|
924
|
+
</div>
|
|
925
|
+
<div class="hub-section">
|
|
926
|
+
<div id="remotes-empty" class="empty" hidden>
|
|
927
|
+
<span class="empty-icon" aria-hidden="true">~</span>
|
|
928
|
+
<p class="empty-title">Not connected to anything yet</p>
|
|
929
|
+
<p class="empty-desc">Paste a pairing link above to connect to another locadot.</p>
|
|
930
|
+
</div>
|
|
931
|
+
<div id="remotes-list" class="remote-list"></div>
|
|
932
|
+
</div>
|
|
933
|
+
</section>
|
|
934
|
+
</div>
|
|
935
|
+
</section>
|
|
936
|
+
|
|
937
|
+
`;
|
|
938
|
+
/** Styles for these pages, appended after theme.ts. */
|
|
939
|
+
exports.css = `
|
|
940
|
+
.hub-hero-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
941
|
+
.hub-status-text { font-size: 15px; font-weight: 600; }
|
|
942
|
+
#hub-dot { width: 10px; height: 10px; }
|
|
943
|
+
.setup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; }
|
|
944
|
+
.setup-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); padding: 14px; display: flex; flex-direction: column; gap: 8px; }
|
|
945
|
+
.setup-card-title { font-size: 13.5px; font-weight: 600; }
|
|
946
|
+
.setup-card-desc { margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.45; }
|
|
947
|
+
.setup-card .hub-form-row { margin-top: 2px; flex-direction: column; align-items: stretch; }
|
|
948
|
+
.setup-card .hub-form-row .field { min-width: 0; }
|
|
949
|
+
.setup-card .hub-form-row > .btn { align-self: flex-start; }
|
|
950
|
+
[data-view="remote"] .card-title { white-space: nowrap; }
|
|
951
|
+
.setup-card > .btn { align-self: flex-start; }
|
|
952
|
+
.invite-create { display: flex; flex-direction: column; gap: 10px; padding-bottom: 4px; }
|
|
953
|
+
.remote-host-row a { color: var(--accent); text-decoration: none; }
|
|
954
|
+
.remote-host-row a:hover { text-decoration: underline; }
|
|
955
|
+
@media (max-width: 640px) {
|
|
956
|
+
.setup-grid { grid-template-columns: minmax(0, 1fr); }
|
|
957
|
+
}
|
|
958
|
+
`;
|