landfall 0.4.2 → 0.4.4
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/README.md +18 -4
- package/dist/cli.mjs +176 -154
- package/dist/index.html +205 -40
- package/package.json +1 -1
package/dist/index.html
CHANGED
|
@@ -76,6 +76,15 @@ nav h3 span { font-weight: 500; letter-spacing: 0; }
|
|
|
76
76
|
.srv .n { font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
|
77
77
|
.srv .t { color: var(--dim); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
78
78
|
.nav-none { color: var(--faint); font-size: var(--fs-sm); padding: 6px 10px; }
|
|
79
|
+
.srv .t .live { color: var(--ok); }
|
|
80
|
+
/* The open server's sections, under it in the list. */
|
|
81
|
+
.sections { display: grid; gap: 1px; margin: 2px 0 8px 14px; padding-left: 6px; border-left: 1px solid var(--line); }
|
|
82
|
+
.sections button { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; border: 0; background: none; padding: 4px 10px; color: var(--dim); }
|
|
83
|
+
.sections button:hover { background: var(--bg); color: var(--text); }
|
|
84
|
+
.sections button.on { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
|
|
85
|
+
.sections .badge { margin-left: auto; }
|
|
86
|
+
.head h1 .crumb { font-weight: 400; color: var(--dim); }
|
|
87
|
+
.tunnel-url { font-family: var(--mono); font-size: var(--fs-sm); }
|
|
79
88
|
.star { color: var(--warn); font-size: var(--fs-xs); }
|
|
80
89
|
main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: inline-size; }
|
|
81
90
|
.show-sm { display: none !important; }
|
|
@@ -92,7 +101,8 @@ main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: in
|
|
|
92
101
|
.head h1 { margin: 0; font-size: 22px; }
|
|
93
102
|
.head .sub { color: var(--dim); font-size: var(--fs); margin-top: 2px; }
|
|
94
103
|
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
|
|
95
|
-
|
|
104
|
+
/* Phones only: elsewhere the sections sit under the server in the list. */
|
|
105
|
+
.tabs { display: none; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
|
|
96
106
|
.tabs::-webkit-scrollbar { display: none; }
|
|
97
107
|
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 12px; color: var(--dim); flex: none; }
|
|
98
108
|
.tabs button:hover { color: var(--text); }
|
|
@@ -250,6 +260,7 @@ body.dragging::after {
|
|
|
250
260
|
.project button { padding: 5px 9px; font-size: var(--fs); white-space: nowrap; }
|
|
251
261
|
nav.servers { display: none; }
|
|
252
262
|
.server-pick { display: block; padding: 10px 16px 0; }
|
|
263
|
+
.tabs { display: flex; position: sticky; top: -14px; z-index: 2; background: var(--bg); }
|
|
253
264
|
main { padding: 14px 16px 40px; }
|
|
254
265
|
.project .path { display: none; }
|
|
255
266
|
aside.job { inset: 52px 0 0 0; width: 100vw; }
|
|
@@ -454,11 +465,13 @@ window.addEventListener("hashchange", route);
|
|
|
454
465
|
|
|
455
466
|
// ---- loading -------------------------------------------------------------
|
|
456
467
|
|
|
457
|
-
const STORE = { sites: "overview", facts: "facts", updates: "updates", firewall: "firewall", history: "history", settings: "settings" };
|
|
458
|
-
const URLS = { sites: "", facts: "/facts", updates: "/updates", firewall: "/firewall", history: "/history", settings: "/config" };
|
|
468
|
+
const STORE = { sites: "overview", tunnels: "overview", traefik: "settings", facts: "facts", updates: "updates", firewall: "firewall", history: "history", settings: "settings" };
|
|
469
|
+
const URLS = { sites: "", tunnels: "", traefik: "/config", facts: "/facts", updates: "/updates", firewall: "/firewall", history: "/history", settings: "/config" };
|
|
470
|
+
// A server's sections, in the list and as tabs on phones; keys 1-7 switch between them.
|
|
471
|
+
const SECTIONS = [["sites", "Sites"], ["tunnels", "Tunnels"], ["traefik", "Traefik"], ["updates", "Updates"], ["firewall", "Firewall"], ["history", "History"], ["settings", "Settings"]];
|
|
459
472
|
|
|
460
473
|
// The overview and facts drive the header, so every tab needs them.
|
|
461
|
-
const needs = (tab) => (tab === "
|
|
474
|
+
const needs = (tab) => (STORE[tab] === "overview" ? ["sites", "facts"] : ["sites", "facts", tab]);
|
|
462
475
|
function load(name, tab, force = false) {
|
|
463
476
|
for (const t of needs(tab)) {
|
|
464
477
|
const store = S[STORE[t]];
|
|
@@ -512,6 +525,8 @@ function stream(j) {
|
|
|
512
525
|
j.lines.length = Math.min(j.lines.length, from);
|
|
513
526
|
j.lines.push(...lines);
|
|
514
527
|
if (S.job === j.info.id) appendOutput(j);
|
|
528
|
+
// A tunnel's address shows in the lists once it is up.
|
|
529
|
+
if (isTunnel(j.info) && !j.url && (j.url = tunnelUrl(j))) render();
|
|
515
530
|
};
|
|
516
531
|
es.addEventListener("done", (e) => {
|
|
517
532
|
es.close();
|
|
@@ -564,6 +579,19 @@ function showJob(id) {
|
|
|
564
579
|
$("#job .job-out")?.focus({ preventScroll: true });
|
|
565
580
|
}
|
|
566
581
|
const runningJobs = () => [...S.jobs.values()].filter((j) => j.info.code === undefined);
|
|
582
|
+
const isTunnel = (info) => info.argv[0] === "tunnel" || (info.argv[0] === "traefik" && info.argv[1] === "dashboard");
|
|
583
|
+
/** Tunnels the UI opened to server n that are still open. */
|
|
584
|
+
const openTunnels = (n) => runningJobs().filter((j) => j.info.server === n && isTunnel(j.info));
|
|
585
|
+
const tunnelUrl = (j) => j.lines.map((l) => l.replace(ANSI, "").match(/http:\/\/localhost:\d+\S*/)?.[0]).find(Boolean);
|
|
586
|
+
const closeTunnel = (j) => http(`/api/jobs/${j.info.id}/stop`, {}).catch((e) => toast(e.message));
|
|
587
|
+
/** Open a tunnel with argv, or, when it is open, its address and a way to close it. */
|
|
588
|
+
function tunnelBtn(n, argv, label = "Open tunnel") {
|
|
589
|
+
const j = openTunnels(n).find((t) => t.info.argv.join(" ") === argv.join(" "));
|
|
590
|
+
if (!j) return btn(label, () => run(argv, { server: n }), "sm", `lf ${argv.join(" ")}: until you close it`);
|
|
591
|
+
const url = tunnelUrl(j);
|
|
592
|
+
return `${url ? `<a class="tunnel-url" href="${esc(url)}" target="_blank" rel="noopener">${esc(url.replace("http://", ""))}</a>` : `<span class="spin" role="img" aria-label="opening"></span>`}
|
|
593
|
+
${btn("Output", () => showJob(j.info.id), "sm ghost")}${btn("Close", () => closeTunnel(j), "sm danger", "Ends the tunnel")}`;
|
|
594
|
+
}
|
|
567
595
|
|
|
568
596
|
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
569
597
|
function lineClass(l) {
|
|
@@ -583,8 +611,13 @@ function appendOutput(j) {
|
|
|
583
611
|
const frag = document.createDocumentFragment();
|
|
584
612
|
for (const l of j.lines.slice(out.textContent ? shown : 0)) {
|
|
585
613
|
const div = document.createElement("div");
|
|
586
|
-
|
|
587
|
-
|
|
614
|
+
const text = l.replace(ANSI, "") || " ";
|
|
615
|
+
// Addresses (a tunnel's localhost one, a deployed site) open in a new tab.
|
|
616
|
+
for (const [i, part] of text.split(/(https?:\/\/[^\s)]+)/).entries()) {
|
|
617
|
+
if (!(i % 2)) div.append(part);
|
|
618
|
+
else div.append(Object.assign(document.createElement("a"), { href: part, textContent: part, target: "_blank", rel: "noopener" }));
|
|
619
|
+
}
|
|
620
|
+
const c = lineClass(text);
|
|
588
621
|
if (c) div.className = c;
|
|
589
622
|
frag.append(div);
|
|
590
623
|
}
|
|
@@ -719,6 +752,11 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
|
|
|
719
752
|
(first || (danger ? $("#dlg-cancel", dlg) : submit)).focus();
|
|
720
753
|
}
|
|
721
754
|
const invalid = (field, error) => ({ field, error });
|
|
755
|
+
/** What is wrong with a login (lf newLogin's rules), as [field, message]. */
|
|
756
|
+
const loginError = (user, password) =>
|
|
757
|
+
!/^[A-Za-z0-9._@-]{1,64}$/.test(user) ? ["user", "Only letters, digits and . _ @ -"]
|
|
758
|
+
: password.length < 8 ? ["password", "The password needs at least 8 characters"]
|
|
759
|
+
: null;
|
|
722
760
|
/**
|
|
723
761
|
* Passwords here are for servers and sites, not for this page: a masked text field, so browsers
|
|
724
762
|
* and password managers do not offer to save them. Browsers without text masking get a password field.
|
|
@@ -780,6 +818,7 @@ function setupWizard(base) {
|
|
|
780
818
|
{ name: "image", label: "Image", value: saved.traefikImage ?? S.state.defaultImage },
|
|
781
819
|
{ name: "email", label: "Let's Encrypt e-mail (optional)", value: saved.email ?? "" },
|
|
782
820
|
{ name: "dashboard", label: "Dashboard domain (optional)", value: "", placeholder: "traefik.example.com", hint: "Behind basic auth; the password is shown after setup." },
|
|
821
|
+
{ name: "http3", label: "HTTP/3 (QUIC on 443/udp)", type: "check", value: saved.http3 !== false, hint: "Browsers that can't use it fall back to HTTP/2." },
|
|
783
822
|
],
|
|
784
823
|
},
|
|
785
824
|
{ name: "firewall", label: "Enable ufw: deny incoming except ssh, 80 and 443", type: "check", value: true, hint: "Skipped automatically if it would block ports in use." },
|
|
@@ -794,6 +833,7 @@ function setupWizard(base) {
|
|
|
794
833
|
if (v.image.trim()) f.push(`--image=${v.image.trim()}`);
|
|
795
834
|
if (v.email.trim()) f.push(`--email=${v.email.trim()}`);
|
|
796
835
|
if (v.dashboard.trim()) f.push(`--dashboard=${v.dashboard.trim()}`);
|
|
836
|
+
f.push(v.http3 ? "--http3" : "--no-http3");
|
|
797
837
|
}
|
|
798
838
|
if (!v.firewall) f.push("--no-firewall");
|
|
799
839
|
if (!v.harden) f.push("--no-harden");
|
|
@@ -878,7 +918,7 @@ function describe(d) {
|
|
|
878
918
|
d.kind === "compose" ? `Compose project <code>${esc(d.compose ?? "compose.yaml")}</code>: built and started on the server; x-landfall picks the routed service`
|
|
879
919
|
: d.kind === "image" ? `Docker image <code>${esc(d.project?.image ?? "")}</code> (from .landfall.json)`
|
|
880
920
|
: d.kind === "dockerfile" ? `Dockerfile: built on the server, served on port ${esc(d.port)}`
|
|
881
|
-
: d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.spa ? " (single-page app)" : ""}${built}`
|
|
921
|
+
: d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.tool ? ` (where ${esc(d.tool)} builds the site)` : ""}${d.spa ? " (single-page app)" : ""}${built}`
|
|
882
922
|
: d.build ? `Static site, built with <code>${esc(d.build)}</code> first`
|
|
883
923
|
: `<span style="color:var(--warn)">No compose.yaml, Dockerfile, index.html or build script here.</span>`;
|
|
884
924
|
const before = d.project?.server ? ` <span class="dim">· deployed before to ${esc(d.project.server)}</span>` : "";
|
|
@@ -909,11 +949,15 @@ function deployDialog(det, o = {}) {
|
|
|
909
949
|
...(det.kind === "static"
|
|
910
950
|
? [
|
|
911
951
|
{ name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
|
|
952
|
+
{
|
|
953
|
+
name: "dir", label: "Folder to publish", value: det.dir ?? ".", placeholder: ".",
|
|
954
|
+
hint: det.tool ? `Detected: ${det.tool} writes the site to ${det.dir}/ (it need not exist before the build).` : "Relative to the project; it needs an index.html after the build.",
|
|
955
|
+
},
|
|
912
956
|
{ name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
|
|
913
957
|
]
|
|
914
958
|
: []),
|
|
915
|
-
{ name: "
|
|
916
|
-
{ name: "
|
|
959
|
+
{ name: "authPassword", label: "Login password (optional, at least 8 characters)", type: "password", hint: "Visitors log in with the user below and this password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
|
|
960
|
+
{ name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
|
|
917
961
|
],
|
|
918
962
|
ok: "Deploy",
|
|
919
963
|
onOk: (v) => {
|
|
@@ -924,11 +968,16 @@ function deployDialog(det, o = {}) {
|
|
|
924
968
|
const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...domains.flatMap((d) => ["-d", d])];
|
|
925
969
|
if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
|
|
926
970
|
if (v.spa && !p?.spa) argv.push("--spa");
|
|
927
|
-
const
|
|
928
|
-
if (
|
|
929
|
-
if (
|
|
930
|
-
|
|
931
|
-
if (
|
|
971
|
+
const dir = v.dir?.trim().replace(/\/+$/, "") || ".";
|
|
972
|
+
if (det.kind === "static" && (/^[/-]/.test(dir) || dir.split("/").includes(".."))) return invalid("dir", "A folder inside the project");
|
|
973
|
+
if (det.kind === "static" && dir !== (det.dir ?? ".")) argv.push(dir);
|
|
974
|
+
// The login counts once there is a password; the user defaults to admin.
|
|
975
|
+
if (v.authPassword) {
|
|
976
|
+
const user = v.authUser.trim() || "admin";
|
|
977
|
+
const bad = loginError(user, v.authPassword);
|
|
978
|
+
if (bad) return invalid(bad[0] === "user" ? "authUser" : "authPassword", bad[1]);
|
|
979
|
+
argv.push("--auth-user", user, "--auth-password", v.authPassword);
|
|
980
|
+
}
|
|
932
981
|
if (o.upload) argv.push("--no-save");
|
|
933
982
|
run(argv, { server: v.server, cwd: det.path, after: openAfter });
|
|
934
983
|
},
|
|
@@ -1103,9 +1152,10 @@ function authDialog(n, m) {
|
|
|
1103
1152
|
ok: "Save",
|
|
1104
1153
|
onOk: (v) => {
|
|
1105
1154
|
if (v.off) return void run(["auth", m.name, ...sv(n), "--off", "-y"], { server: n });
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1155
|
+
const user = v.user.trim() || "admin";
|
|
1156
|
+
const bad = loginError(user, v.password);
|
|
1157
|
+
if (bad) return invalid(...bad);
|
|
1158
|
+
run(["auth", m.name, ...sv(n), "-u", user, "-p", v.password], { server: n });
|
|
1109
1159
|
},
|
|
1110
1160
|
});
|
|
1111
1161
|
}
|
|
@@ -1170,9 +1220,13 @@ function renderNav() {
|
|
|
1170
1220
|
const aliases = list.filter((s) => !s.registered);
|
|
1171
1221
|
const q = S.filter.toLowerCase();
|
|
1172
1222
|
const hit = (s) => !q || s.name.toLowerCase().includes(q) || s.target.toLowerCase().includes(q) || s.name === S.sel;
|
|
1173
|
-
const item = (s) =>
|
|
1223
|
+
const item = (s) => {
|
|
1224
|
+
const tunnels = openTunnels(s.name).length;
|
|
1225
|
+
const head = `<button type="button" class="srv ${s.name === S.sel ? "on" : ""}" data-f="srv:${esc(s.name)}" ${on(() => go(s.name, S.sel === s.name ? S.tab : "sites"))}>
|
|
1174
1226
|
<div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
|
|
1175
|
-
<div class="t">${esc(s.target)}</div></button>`;
|
|
1227
|
+
<div class="t">${esc(s.target)}${tunnels ? ` · <span class="live">${plural(tunnels, "tunnel")}</span>` : ""}</div></button>`;
|
|
1228
|
+
return s.name === S.sel ? `${head}<div class="sections" role="list" aria-label="${esc(s.name)}">${sectionButtons(s.name, "sec")}</div>` : head;
|
|
1229
|
+
};
|
|
1176
1230
|
const group = (title, l) => {
|
|
1177
1231
|
const shown = l.filter(hit);
|
|
1178
1232
|
return l.length ? `<h3>${title}${q ? `<span>${shown.length}/${l.length}</span>` : ""}</h3>${shown.map(item).join("") || `<div class="nav-none">no match</div>`}` : "";
|
|
@@ -1186,6 +1240,18 @@ function renderNav() {
|
|
|
1186
1240
|
</select>`;
|
|
1187
1241
|
}
|
|
1188
1242
|
|
|
1243
|
+
/** The open server's sections as buttons, each with its count. */
|
|
1244
|
+
function sectionButtons(n, f) {
|
|
1245
|
+
const o = S.overview[n]?.data;
|
|
1246
|
+
const tunnels = openTunnels(n).length;
|
|
1247
|
+
const badge = {
|
|
1248
|
+
sites: o ? ` <span class="badge">${o.sites.length}</span>` : "",
|
|
1249
|
+
tunnels: tunnels ? ` <span class="badge ok">${tunnels} open</span>` : "",
|
|
1250
|
+
updates: updatesBadge(n),
|
|
1251
|
+
};
|
|
1252
|
+
return SECTIONS.map(([t, label], i) => `<button type="button" class="${S.tab === t ? "on" : ""}"${S.tab === t ? ` aria-current="page"` : ""} data-f="${f}:${t}" title="${label} (${i + 1})" ${on(() => go(n, t))}>${label}${badge[t] ?? ""}</button>`).join("");
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1189
1255
|
function renderHeader() {
|
|
1190
1256
|
const st = S.state;
|
|
1191
1257
|
$("#version").textContent = st.version;
|
|
@@ -1266,12 +1332,11 @@ function renderMain() {
|
|
|
1266
1332
|
: o.ready ? `<span class="dot down"></span>no traefik` : ""
|
|
1267
1333
|
: "";
|
|
1268
1334
|
const sub = [esc(s?.target ?? ""), s && !s.registered ? "from ~/.ssh/config" : "", traefik].filter(Boolean).join(" · ");
|
|
1269
|
-
const tab = (t, label) => `<button type="button" class="${S.tab === t ? "on" : ""}"${S.tab === t ? ` aria-current="page"` : ""} data-f="tab:${t}" ${on(() => go(n, t))}>${label}</button>`;
|
|
1270
1335
|
// Reloading with something already on screen: keep it, show a small spinner.
|
|
1271
1336
|
const reloading = needs(S.tab).some((t) => S[STORE[t]][n]?.loading && S[STORE[t]][n]?.data);
|
|
1272
1337
|
main.innerHTML = `
|
|
1273
1338
|
<div class="head">
|
|
1274
|
-
<div><h1>${esc(n)}${s?.isDefault ? ` <span class="star" title="default server">★</span>` : ""}</h1><div class="sub">${sub}</div>${factsLine(n)}</div>
|
|
1339
|
+
<div><h1>${esc(n)}${s?.isDefault ? ` <span class="star" title="default server">★</span>` : ""} <span class="crumb">/ ${SECTIONS.find(([t]) => t === S.tab)[1]}</span>${reloading ? ` <span class="spin" role="img" aria-label="reloading"></span>` : ""}</h1><div class="sub">${sub}</div>${factsLine(n)}</div>
|
|
1275
1340
|
<div class="actions">
|
|
1276
1341
|
${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
|
|
1277
1342
|
${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
|
|
@@ -1286,7 +1351,7 @@ function renderMain() {
|
|
|
1286
1351
|
</div>
|
|
1287
1352
|
</div>
|
|
1288
1353
|
${factsNote(n)}
|
|
1289
|
-
<div class="tabs">${
|
|
1354
|
+
<div class="tabs">${sectionButtons(n, "tab")}</div>
|
|
1290
1355
|
<div id="view"></div>`;
|
|
1291
1356
|
const view = $("#view");
|
|
1292
1357
|
if (ov.loading && !o) view.innerHTML = `<div class="loading"><span class="spin"></span> connecting to ${esc(n)}…</div>`;
|
|
@@ -1296,6 +1361,8 @@ function renderMain() {
|
|
|
1296
1361
|
<div class="actions" style="margin:0">${btn("Set up", () => setupFlow([n]), "primary")} ${btn("Retry", () => (load(n, S.tab, true), render()))}</div>`;
|
|
1297
1362
|
} else if (!o) view.innerHTML = "";
|
|
1298
1363
|
else if (S.tab === "sites") view.innerHTML = sitesView(n, o);
|
|
1364
|
+
else if (S.tab === "tunnels") view.innerHTML = tunnelsView(n, o);
|
|
1365
|
+
else if (S.tab === "traefik") view.innerHTML = traefikView(n);
|
|
1299
1366
|
else if (S.tab === "updates") view.innerHTML = updatesView(n);
|
|
1300
1367
|
else if (S.tab === "firewall") view.innerHTML = firewallView(n);
|
|
1301
1368
|
else if (S.tab === "settings") view.innerHTML = settingsView(n);
|
|
@@ -1334,7 +1401,7 @@ function othersCard(n, o) {
|
|
|
1334
1401
|
<div class="chips show-sm">${links(e)}</div></td>
|
|
1335
1402
|
<td class="hide-sm"><div class="chips">${links(e)}</div></td>
|
|
1336
1403
|
<td class="hide-sm mono dim">${esc(e.dir ?? e.containers.join(", "))}</td>
|
|
1337
|
-
<td class="acts">${e.dir ? btn("Adopt…", () => adoptDialog(n, e), "sm", "lf adopt") : ""}</td></tr>`)
|
|
1404
|
+
<td class="acts">${btn("Tunnel", () => run(["tunnel", e.name, ...sv(n)], { server: n }), "sm", "lf tunnel: an SSH tunnel straight to the container, until you stop it")}${e.dir ? btn("Adopt…", () => adoptDialog(n, e), "sm", "lf adopt") : ""}</td></tr>`)
|
|
1338
1405
|
.join("")}</tbody></table></div>`;
|
|
1339
1406
|
}
|
|
1340
1407
|
|
|
@@ -1396,6 +1463,7 @@ function sitesView(n, o) {
|
|
|
1396
1463
|
? { label: "Roll back…", hint: "lf rollback", title: `To ${older[0].id} (${ago(older[0].at)})`, run: () => confirmDlg({ title: `Roll back ${name}?`, text: `From ${m.current} to ${older[0].id} (${ago(older[0].at)}). Volumes keep their data.`, ok: "Roll back", onOk: () => run(["rollback", name, ...sv(n), "-y"]) }) }
|
|
1397
1464
|
: null,
|
|
1398
1465
|
"-",
|
|
1466
|
+
old ? null : { label: "Open via tunnel", hint: "lf tunnel", title: "An SSH tunnel straight to the container: test it on localhost, no domain needed", run: () => run(["tunnel", name, ...sv(n)], { server: n }) },
|
|
1399
1467
|
main ? { label: "Domains…", hint: "lf domains", run: () => domainsDialog(n, m) } : null,
|
|
1400
1468
|
managed ? { label: m.auth?.length ? "Login…" : "Password…", hint: "lf auth", title: "Protect the site with a user and password", run: () => authDialog(n, m) } : null,
|
|
1401
1469
|
managed ? { label: "Environment…", hint: "lf env", run: () => envDialog(n, name) } : null,
|
|
@@ -1674,6 +1742,117 @@ function editRule(n, num, r) {
|
|
|
1674
1742
|
|
|
1675
1743
|
// ---- history ---------------------------------------------------------------
|
|
1676
1744
|
|
|
1745
|
+
/** Everything an SSH tunnel reaches on server n, and the tunnels open now. */
|
|
1746
|
+
function tunnelsView(n, o) {
|
|
1747
|
+
if (!o.ready && !o.others.length) return `<div class="card"><div class="body empty"><h2>Nothing to tunnel to yet</h2><p>Set up the server and deploy a site first.</p></div></div>`;
|
|
1748
|
+
const open = openTunnels(n);
|
|
1749
|
+
const chips = (l) => `<div class="chips">${l.map((d) => `<span class="chip">${esc(d)}</span>`).join("")}</div>`;
|
|
1750
|
+
const targets = [];
|
|
1751
|
+
const dashArgv = ["traefik", "dashboard", ...sv(n)];
|
|
1752
|
+
targets.push({ name: "Traefik dashboard", status: o.traefik, serves: o.dashboard.hosts, argv: o.dashboard.tunnel ? dashArgv : null, why: "no tunnel yet: see Traefik" });
|
|
1753
|
+
for (const x of o.sites) {
|
|
1754
|
+
if (x.state === "old") continue;
|
|
1755
|
+
// One row per service when a site has several: each is its own container.
|
|
1756
|
+
const many = x.services.length > 1;
|
|
1757
|
+
if (!many) targets.push({ name: x.meta.name, status: x.status, serves: x.domains, argv: ["tunnel", x.meta.name, ...sv(n)] });
|
|
1758
|
+
else for (const svc of x.services) targets.push({ name: `${x.meta.name} · ${svc.service}`, status: svc.status, serves: svc.service === x.main ? x.domains : [], argv: ["tunnel", x.meta.name, svc.service, ...sv(n)] });
|
|
1759
|
+
}
|
|
1760
|
+
for (const e of o.others) targets.push({ name: e.name, status: e.status, serves: e.domains, argv: ["tunnel", e.name, ...sv(n)], other: true });
|
|
1761
|
+
const row = (t) => `<tr><td><div class="site-name">${t.status ? statusDot(t.status) : ""}<b>${esc(t.name)}</b>${t.other ? ` <span class="faint">not landfall's</span>` : ""}</div><div class="show-sm">${chips(t.serves)}</div></td>
|
|
1762
|
+
<td class="hide-sm">${chips(t.serves)}</td>
|
|
1763
|
+
<td class="acts">${t.argv ? tunnelBtn(n, t.argv) : `<span class="dim">${esc(t.why)}</span> ${btn("Traefik", () => go(n, "traefik"), "sm")}`}</td></tr>`;
|
|
1764
|
+
return `${open.length ? `<div class="card scroll"><h2>Open <span class="badge ok">${open.length}</span></h2><table><tbody>${open
|
|
1765
|
+
.map((j) => `<tr><td><b class="mono">lf ${esc(j.info.argv.filter((a, i, l) => a !== "-s" && l[i - 1] !== "-s").join(" "))}</b></td><td class="acts">${tunnelBtn(n, j.info.argv)}</td></tr>`)
|
|
1766
|
+
.join("")}</tbody></table></div>` : ""}
|
|
1767
|
+
<div class="card scroll"><h2>Tunnel to</h2>
|
|
1768
|
+
<div class="lede">An SSH tunnel forwards a port on this machine to a container on ${esc(n)}: test a site on localhost with no domain, HTTPS or login. Nothing changes on the server, and closing the tunnel ends it.</div>
|
|
1769
|
+
<table><thead><tr><th>Container</th><th class="hide-sm">Domains</th><th><span class="sr">Tunnel</span></th></tr></thead><tbody>${targets.map(row).join("")}</tbody></table></div>`;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** Traefik on server n: its dashboard (domain and tunnel) and how landfall runs it. */
|
|
1773
|
+
function traefikView(n) {
|
|
1774
|
+
const st = S.settings[n] ?? { loading: true };
|
|
1775
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
1776
|
+
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1777
|
+
const x = st.data;
|
|
1778
|
+
const reload = { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "traefik", true), render()) : undefined) };
|
|
1779
|
+
const rows = (pairs) => `<dl class="kv">${pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>`;
|
|
1780
|
+
const P = S.state.paths;
|
|
1781
|
+
const dashboard = () =>
|
|
1782
|
+
dialog({
|
|
1783
|
+
title: `Traefik dashboard on ${n}`,
|
|
1784
|
+
text: "Serves the dashboard on a domain behind a login. Without one it stays reachable through an SSH tunnel only (Open via tunnel).\nRuns lf traefik setup, which reconfigures landfall's Traefik.",
|
|
1785
|
+
fields: [
|
|
1786
|
+
{ name: "domain", label: "Domain", value: x.traefik.dashboard ?? "", placeholder: x.domains[0] ? `traefik.${x.domains[0]}` : "traefik.example.com", hint: x.traefik.dashboard ? "Empty: take the dashboard off its domain." : "Needs an A record pointing at the server." },
|
|
1787
|
+
{ name: "user", label: "Login user", value: "admin", placeholder: "admin" },
|
|
1788
|
+
{ name: "password", label: "Login password (at least 8 characters)", type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
|
|
1789
|
+
],
|
|
1790
|
+
ok: "Apply",
|
|
1791
|
+
onOk: (v) => {
|
|
1792
|
+
const d = v.domain.trim();
|
|
1793
|
+
const user = v.user.trim() || "admin";
|
|
1794
|
+
if (!d && !x.traefik.dashboard) return invalid("domain", "Give a domain, or use the tunnel");
|
|
1795
|
+
const bad = d && loginError(user, v.password || "generated");
|
|
1796
|
+
if (bad) return invalid(...bad);
|
|
1797
|
+
const argv = ["traefik", "setup", ...sv(n), "--dashboard", d || "off"];
|
|
1798
|
+
if (d) argv.push("--dashboard-user", user, ...(v.password ? ["--dashboard-password", v.password] : []));
|
|
1799
|
+
run(argv, reload);
|
|
1800
|
+
},
|
|
1801
|
+
});
|
|
1802
|
+
const traefikDialog = () =>
|
|
1803
|
+
dialog({
|
|
1804
|
+
title: `Traefik on ${n}`,
|
|
1805
|
+
text: "Runs lf traefik setup, which reconfigures landfall's Traefik and restarts it (a few seconds without traffic). The settings are remembered.",
|
|
1806
|
+
fields: [
|
|
1807
|
+
{ name: "http3", label: "HTTP/3 (QUIC on 443/udp)", type: "check", value: x.traefik.http3, hint: "Faster on lossy and mobile connections; browsers that can't use it fall back to HTTP/2." },
|
|
1808
|
+
{
|
|
1809
|
+
name: "mode", label: "Ports", type: "select", value: x.traefik.hostNetwork ? "host" : "docker",
|
|
1810
|
+
options: [
|
|
1811
|
+
{ value: "docker", label: "Published by Docker (default)" },
|
|
1812
|
+
{ value: "host", label: "Host network: real client IPs, ufw guards 80/443" },
|
|
1813
|
+
],
|
|
1814
|
+
},
|
|
1815
|
+
{ name: "image", label: "Image", value: x.traefik.image, placeholder: S.state.defaultImage },
|
|
1816
|
+
{ name: "email", label: "Let's Encrypt e-mail (optional)", value: x.traefik.email ?? "", hint: "Only the account contact; Let's Encrypt no longer mails expiry warnings." },
|
|
1817
|
+
{ name: "staging", label: "Let's Encrypt staging CA (test certificates, browsers warn)", type: "check", value: x.traefik.staging },
|
|
1818
|
+
],
|
|
1819
|
+
ok: "Apply",
|
|
1820
|
+
onOk: (v) => {
|
|
1821
|
+
const argv = ["traefik", "setup", ...sv(n), v.http3 ? "--http3" : "--no-http3", v.mode === "host" ? "--host-network" : "--no-host-network", v.staging ? "--staging" : "--no-staging"];
|
|
1822
|
+
if (v.image.trim()) argv.push("--image", v.image.trim());
|
|
1823
|
+
if (v.email.trim()) argv.push("--email", v.email.trim());
|
|
1824
|
+
run(argv, reload);
|
|
1825
|
+
},
|
|
1826
|
+
});
|
|
1827
|
+
// lf traefik setup refuses to touch a Traefik the server already had.
|
|
1828
|
+
const o = S.overview[n]?.data;
|
|
1829
|
+
const foreign = o?.adopted ? o.proxy : "";
|
|
1830
|
+
const dash = o?.dashboard ?? { hosts: x.traefik.dashboard ? [x.traefik.dashboard] : [], tunnel: false };
|
|
1831
|
+
const link = (h) => `<a href="https://${esc(h)}" target="_blank" rel="noopener">${esc(h)}</a>`;
|
|
1832
|
+
const dashArgv = ["traefik", "dashboard", ...sv(n)];
|
|
1833
|
+
return `
|
|
1834
|
+
<div class="card">
|
|
1835
|
+
<h2>Dashboard <span class="actions">${foreign ? "" : btn(dash.hosts.length ? "Domain & login…" : "Put on a domain…", dashboard, "sm")}</span></h2>
|
|
1836
|
+
<div class="body">${rows([
|
|
1837
|
+
["domain", dash.hosts.length ? `${dash.hosts.map(link).join(", ")} <span class="dim">(with its login)</span>` : `<span class="dim">none${foreign ? "" : ": only through the tunnel"}</span>`],
|
|
1838
|
+
["tunnel", dash.tunnel
|
|
1839
|
+
? `${tunnelBtn(n, dashArgv, "Open via tunnel")} <span class="dim">lf traefik dashboard</span>`
|
|
1840
|
+
: foreign ? `<span class="dim">route its dashboard (api@internal) to a domain, or run <span class="mono">lf traefik dashboard --port <its api port></span></span>`
|
|
1841
|
+
: `<span class="dim">set up by an older landfall</span> ${btn("Enable tunnel", () => run(["traefik", "setup", ...sv(n)], reload), "sm", "Re-runs lf traefik setup, which adds it")}`],
|
|
1842
|
+
])}</div>
|
|
1843
|
+
</div>
|
|
1844
|
+
<div class="card">
|
|
1845
|
+
<h2>Traefik <span class="actions">${btn("Status", () => run(["traefik", "status", ...sv(n)]), "sm", "lf traefik status")}${foreign ? "" : btn("Settings…", traefikDialog, "sm")}</span></h2>
|
|
1846
|
+
<div class="body">${foreign ? `<div class="note">Sites go through <b class="mono">${esc(foreign)}</b>, a Traefik landfall did not install and never changes: it is configured in its own files.</div>` : ""}${rows([
|
|
1847
|
+
["image", esc(x.traefik.summary)],
|
|
1848
|
+
["e-mail", esc(x.traefik.email ?? "none")],
|
|
1849
|
+
["HTTP/3", x.traefik.http3 ? `on <span class="dim">(443/udp)</span>` : `<span class="dim">off</span>`],
|
|
1850
|
+
["files", `<span class="mono">${esc(P.traefik)}/docker-compose.yml</span>`],
|
|
1851
|
+
["certificates", `<span class="mono">${esc(P.traefik)}/letsencrypt/</span> <span class="dim">(kept on reinstall)</span>`],
|
|
1852
|
+
])}<p class="faint">Used when landfall installs or reconfigures its own Traefik. With a Traefik that was already there, landfall only writes <span class="mono">${esc(P.proxy)}</span>.</p></div>
|
|
1853
|
+
</div>`;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1677
1856
|
function settingsView(n) {
|
|
1678
1857
|
const st = S.settings[n] ?? { loading: true };
|
|
1679
1858
|
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
@@ -1686,14 +1865,7 @@ function settingsView(n) {
|
|
|
1686
1865
|
promptDlg(`Add a main domain to ${n}`, `${x.domains.length ? "The first main domain stays the primary." : "The first main domain is the primary: new sites get <site>.<domain>, and a bare name like blog means blog.<domain>."}\nPoint <domain> and *.<domain> (A records) at the server once.`, { label: "Domain", placeholder: "example.com", type: "text", value: "" }, (d) => {
|
|
1687
1866
|
if (d) run(["server", "domain", n, "--add", d], reload);
|
|
1688
1867
|
}, "Add");
|
|
1689
|
-
const dashboard = () =>
|
|
1690
|
-
promptDlg(`Traefik dashboard on ${n}`, "Serves the dashboard behind a password (shown in the output). `off` removes it.\nRuns lf traefik setup, which reconfigures landfall's Traefik.", { label: "Domain", type: "text", value: x.traefik.dashboard ?? (x.domains[0] ? `traefik.${x.domains[0]}` : "") }, (d) => {
|
|
1691
|
-
if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
|
|
1692
|
-
}, "Apply");
|
|
1693
1868
|
const sites = S.overview[n]?.data?.sites ?? [];
|
|
1694
|
-
// lf traefik setup refuses to touch a Traefik the server already had.
|
|
1695
|
-
const o = S.overview[n]?.data;
|
|
1696
|
-
const foreign = o?.adopted ? o.proxy : "";
|
|
1697
1869
|
const external = (d) => S.overview[n]?.data?.others?.find((e) => e.domains.includes(d))?.name;
|
|
1698
1870
|
const servedBy = (d) => sites.find((s) => s.domains.includes(d))?.meta.name;
|
|
1699
1871
|
const serve = (d) => {
|
|
@@ -1729,16 +1901,6 @@ function settingsView(n) {
|
|
|
1729
1901
|
: `<p class="dim">No main domain set in landfall. New sites get a free <span class="mono"><site>.<ip>.sslip.io</span> address until you set one.</p>`}
|
|
1730
1902
|
${x.suggest.length ? `<div class="note">${x.domains.length ? "Other domains" : "Domains"} your sites on this server already use: ${x.suggest.map((g) => `<b>${esc(g.domain)}</b> <span class="faint">(${g.hosts})</span> ${btn("Add", () => run(["server", "domain", n, "--add", g.domain], reload), "sm")}`).join(" ")}</div>` : ""}</div>
|
|
1731
1903
|
</div>
|
|
1732
|
-
<div class="card">
|
|
1733
|
-
<h2>Traefik <span class="actions">${foreign ? "" : btn("Dashboard…", dashboard, "sm")}</span></h2>
|
|
1734
|
-
<div class="body">${foreign ? `<div class="note">Sites go through <b class="mono">${esc(foreign)}</b>, a Traefik landfall did not install and never changes: its dashboard is set up in its own config.</div>` : ""}${rows([
|
|
1735
|
-
["image", esc(x.traefik.summary)],
|
|
1736
|
-
["e-mail", esc(x.traefik.email ?? "none")],
|
|
1737
|
-
["dashboard", x.traefik.dashboard ? `<a href="https://${esc(x.traefik.dashboard)}" target="_blank" rel="noopener">${esc(x.traefik.dashboard)}</a>` : "off"],
|
|
1738
|
-
["files", `<span class="mono">${esc(P.traefik)}/docker-compose.yml</span>`],
|
|
1739
|
-
["certificates", `<span class="mono">${esc(P.traefik)}/letsencrypt/</span> <span class="dim">(kept on reinstall)</span>`],
|
|
1740
|
-
])}<p class="faint">Used when landfall installs or reconfigures its own Traefik. With a Traefik that was already there, landfall only writes <span class="mono">${esc(P.proxy)}</span>.</p></div>
|
|
1741
|
-
</div>
|
|
1742
1904
|
<div class="card">
|
|
1743
1905
|
<h2>On the server <span class="dim mono" style="font-weight:400">${esc(P.root)}</span></h2>
|
|
1744
1906
|
<div class="body">${rows([
|
|
@@ -1836,6 +1998,9 @@ function render() {
|
|
|
1836
1998
|
|
|
1837
1999
|
document.addEventListener("keydown", (e) => {
|
|
1838
2000
|
if (e.key === "Escape" && menuEl) return closeMenu();
|
|
2001
|
+
// 1-7: the open server's sections.
|
|
2002
|
+
const sec = SECTIONS[Number(e.key) - 1];
|
|
2003
|
+
if (sec && S.sel && !e.metaKey && !e.ctrlKey && !e.altKey && !$("#dlg").open && !e.target.closest?.("input, textarea, select")) return go(S.sel, sec[0]);
|
|
1839
2004
|
if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
|
|
1840
2005
|
S.job = null;
|
|
1841
2006
|
render();
|