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/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
- .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
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 === "sites" ? ["sites", "facts"] : ["sites", "facts", 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
- div.textContent = l.replace(ANSI, "") || " ";
587
- const c = lineClass(div.textContent);
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: "authUser", label: "Login user (optional)", placeholder: "admin", hint: "Visitors log in with this user and password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
916
- { name: "authPassword", label: "Login password (at least 8 characters)", type: "password" },
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 user = v.authUser.trim();
928
- if (user && !/^[A-Za-z0-9._@-]{1,64}$/.test(user)) return invalid("authUser", "Only letters, digits and . _ @ -");
929
- if (user && v.authPassword.length < 8) return invalid("authPassword", "The password needs at least 8 characters");
930
- if (!user && v.authPassword) return invalid("authUser", "Give a user for the password");
931
- if (user) argv.push("--auth-user", user, "--auth-password", v.authPassword);
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
- if (!v.user.trim()) return invalid("user", "Give a user name");
1107
- if (v.password.length < 8) return invalid("password", "The password needs at least 8 characters");
1108
- run(["auth", m.name, ...sv(n), "-u", v.user.trim(), "-p", v.password], { server: n });
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) => `<button type="button" class="srv ${s.name === S.sel ? "on" : ""}"${s.name === S.sel ? ` aria-current="page"` : ""} data-f="srv:${esc(s.name)}" ${on(() => go(s.name, S.sel === s.name ? S.tab : "sites"))}>
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">${tab("sites", `Sites${o ? ` <span class="badge">${o.sites.length}</span>` : ""}`)}${tab("updates", `Updates${updatesBadge(n)}`)}${tab("firewall", "Firewall")}${tab("history", "History")}${tab("settings", "Settings")}${reloading ? `<span class="spin" role="img" aria-label="reloading" style="align-self:center;margin-left:8px"></span>` : ""}</div>
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 ? `&nbsp;<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 &lt;its api port&gt;</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">&lt;site&gt;.&lt;ip&gt;.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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Set up servers and deploy sites behind Traefik with automatic HTTPS",
5
5
  "author": "ph1p <me@ph1p.dev>",
6
6
  "license": "MIT",