@crewhaus/hangar-ui 0.6.0 → 0.7.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/assets/js/dom.js +55 -12
- package/assets/js/views/channels.js +66 -9
- package/package.json +1 -1
package/assets/js/dom.js
CHANGED
|
@@ -142,21 +142,64 @@ export function toast(message, kind = "error") {
|
|
|
142
142
|
return node;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Copy `text`, falling back to the old selection trick.
|
|
147
|
+
*
|
|
148
|
+
* `navigator.clipboard` is a secure-context API. `127.0.0.1` counts as
|
|
149
|
+
* secure, so on the loopback console it always works — and over
|
|
150
|
+
* `http://192.168.x.x`, which is what `crewhaus hangar --lan` serves, the
|
|
151
|
+
* whole object is `undefined` and every copy button in the console reads
|
|
152
|
+
* "copy failed". That is invisible until someone opens the console from a
|
|
153
|
+
* phone, which is exactly who cannot retype a harness directory by hand.
|
|
154
|
+
*
|
|
155
|
+
* The fallback is `document.execCommand("copy")` over a throwaway textarea:
|
|
156
|
+
* deprecated, still implemented everywhere, and not gated on a secure
|
|
157
|
+
* context. The textarea is positioned off-screen rather than hidden because
|
|
158
|
+
* a `display: none` element cannot hold a selection.
|
|
159
|
+
*/
|
|
160
|
+
async function writeClipboard(text) {
|
|
161
|
+
if (navigator.clipboard !== undefined) {
|
|
149
162
|
try {
|
|
150
|
-
await navigator.clipboard.writeText(
|
|
151
|
-
|
|
152
|
-
btn.classList.add("ok");
|
|
153
|
-
setTimeout(() => {
|
|
154
|
-
btn.textContent = label;
|
|
155
|
-
btn.classList.remove("ok");
|
|
156
|
-
}, 1200);
|
|
163
|
+
await navigator.clipboard.writeText(text);
|
|
164
|
+
return true;
|
|
157
165
|
} catch {
|
|
158
|
-
|
|
166
|
+
// Permission refused, or a non-secure context that still exposes the
|
|
167
|
+
// object. Fall through rather than giving up.
|
|
159
168
|
}
|
|
169
|
+
}
|
|
170
|
+
const scratch = el("textarea");
|
|
171
|
+
scratch.value = text;
|
|
172
|
+
scratch.setAttribute("readonly", "readonly");
|
|
173
|
+
scratch.setAttribute("aria-hidden", "true");
|
|
174
|
+
// Off-screen rather than hidden: a `display: none` element cannot hold a
|
|
175
|
+
// selection, and `opacity: 0` alone would still scroll the page.
|
|
176
|
+
scratch.style.position = "fixed";
|
|
177
|
+
scratch.style.top = "-1000px";
|
|
178
|
+
scratch.style.opacity = "0";
|
|
179
|
+
document.body.appendChild(scratch);
|
|
180
|
+
try {
|
|
181
|
+
scratch.select();
|
|
182
|
+
scratch.setSelectionRange(0, text.length);
|
|
183
|
+
return document.execCommand("copy");
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
} finally {
|
|
187
|
+
scratch.remove();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Click-to-copy button. Works on the loopback console and over a LAN
|
|
192
|
+
* address alike — see {@link writeClipboard}. */
|
|
193
|
+
export function copyBtn(value, label = "copy") {
|
|
194
|
+
const btn = el("button", { class: "btn btn-ghost btn-copy", type: "button", text: label });
|
|
195
|
+
btn.addEventListener("click", async () => {
|
|
196
|
+
const copied = await writeClipboard(value);
|
|
197
|
+
btn.textContent = copied ? "copied" : "copy failed";
|
|
198
|
+
if (copied) btn.classList.add("ok");
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
btn.textContent = label;
|
|
201
|
+
btn.classList.remove("ok");
|
|
202
|
+
}, 1200);
|
|
160
203
|
});
|
|
161
204
|
return btn;
|
|
162
205
|
}
|
|
@@ -28,6 +28,51 @@ import { api } from "../api.js";
|
|
|
28
28
|
import { clear, collapsible, dot, el, jsonPre, numberedCode, skeleton, toast } from "../dom.js";
|
|
29
29
|
import { m3, m3Card, m3Empty } from "./creds.js";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* True when `hostname` names this machine — the browser-side half of the
|
|
33
|
+
* CLI's `isLoopbackHost`, deliberately re-implemented rather than imported:
|
|
34
|
+
* the console's JS is hand-written browser ES modules with no build step and
|
|
35
|
+
* no imports out of `assets/`.
|
|
36
|
+
*
|
|
37
|
+
* `location.hostname` hands IPv6 literals over WITHOUT their brackets, so
|
|
38
|
+
* `::1` arrives bare; both spellings are accepted. An empty hostname (a
|
|
39
|
+
* `file://` document) is NOT loopback — falling through to the honest note
|
|
40
|
+
* is the safe direction to be wrong in.
|
|
41
|
+
*/
|
|
42
|
+
export function isLoopbackHostname(hostname) {
|
|
43
|
+
const host = typeof hostname === "string" ? hostname.trim().toLowerCase() : "";
|
|
44
|
+
if (host === "") return false;
|
|
45
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
46
|
+
if (bare === "localhost") return true;
|
|
47
|
+
if (bare === "::1" || bare === "0:0:0:0:0:0:0:1") return true;
|
|
48
|
+
// The whole of 127.0.0.0/8 — a daemon on 127.0.0.2 is every bit as local.
|
|
49
|
+
const octets = bare.split(".");
|
|
50
|
+
if (octets.length !== 4) return false;
|
|
51
|
+
if (!octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)) return false;
|
|
52
|
+
return octets[0] === "127";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether to OFFER the daemon's dashboard as a link, or just name it.
|
|
57
|
+
*
|
|
58
|
+
* The daemon's control-UI port is loopback by design — `service-setup`
|
|
59
|
+
* never tunnels it and it carries no auth — so the address the server sends
|
|
60
|
+
* is only usable by a browser sitting at that machine. Since
|
|
61
|
+
* `crewhaus hangar --lan` puts this console on a phone, "usable" is now a
|
|
62
|
+
* question with two answers, and only the browser can tell which: it knows
|
|
63
|
+
* the host it reached the console on.
|
|
64
|
+
*
|
|
65
|
+
* A link that quietly goes nowhere is worse than a line of text that says
|
|
66
|
+
* where the thing is, so a remote viewer gets the address and the reason
|
|
67
|
+
* instead of a dead anchor.
|
|
68
|
+
*/
|
|
69
|
+
export function dashboardReach(dashboardUrl, viewerHostname) {
|
|
70
|
+
if (typeof dashboardUrl !== "string" || dashboardUrl === "") return null;
|
|
71
|
+
return isLoopbackHostname(viewerHostname)
|
|
72
|
+
? { reachable: true, url: dashboardUrl }
|
|
73
|
+
: { reachable: false, url: dashboardUrl };
|
|
74
|
+
}
|
|
75
|
+
|
|
31
76
|
export async function renderChannels(root, ctx) {
|
|
32
77
|
clear(root).appendChild(skeleton(5));
|
|
33
78
|
const [channelsAnswer, gatewayAnswer] = await Promise.all([
|
|
@@ -431,16 +476,28 @@ function gatewayCard(body) {
|
|
|
431
476
|
: null,
|
|
432
477
|
]),
|
|
433
478
|
];
|
|
434
|
-
|
|
479
|
+
const reach = dashboardReach(body.dashboardUrl, window.location.hostname);
|
|
480
|
+
if (reach !== null) {
|
|
435
481
|
nodes.push(
|
|
436
|
-
el(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
482
|
+
el(
|
|
483
|
+
"p",
|
|
484
|
+
null,
|
|
485
|
+
reach.reachable
|
|
486
|
+
? [
|
|
487
|
+
el("a", {
|
|
488
|
+
href: reach.url,
|
|
489
|
+
target: "_blank",
|
|
490
|
+
rel: "noreferrer",
|
|
491
|
+
text: "open the daemon's own dashboard",
|
|
492
|
+
}),
|
|
493
|
+
]
|
|
494
|
+
: [
|
|
495
|
+
el("span", {
|
|
496
|
+
class: "muted",
|
|
497
|
+
text: `the daemon's own dashboard is at ${reach.url} — it binds loopback and is not tunnelled, so it opens only from the machine running the daemon, not from here`,
|
|
498
|
+
}),
|
|
499
|
+
],
|
|
500
|
+
),
|
|
444
501
|
);
|
|
445
502
|
}
|
|
446
503
|
if (body.status !== null && body.status !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/hangar-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Hangar web console's static UI — zero-build, dependency-free browser ES modules embedded as text assets for the single-binary CLI to hand to the hangar server",
|
|
6
6
|
"main": "dist/index.js",
|