landfall 0.4.3 → 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 +2 -1
- package/dist/cli.mjs +158 -154
- package/dist/index.html +126 -33
- 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) {
|
|
@@ -1192,9 +1220,13 @@ function renderNav() {
|
|
|
1192
1220
|
const aliases = list.filter((s) => !s.registered);
|
|
1193
1221
|
const q = S.filter.toLowerCase();
|
|
1194
1222
|
const hit = (s) => !q || s.name.toLowerCase().includes(q) || s.target.toLowerCase().includes(q) || s.name === S.sel;
|
|
1195
|
-
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"))}>
|
|
1196
1226
|
<div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
|
|
1197
|
-
<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
|
+
};
|
|
1198
1230
|
const group = (title, l) => {
|
|
1199
1231
|
const shown = l.filter(hit);
|
|
1200
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>`}` : "";
|
|
@@ -1208,6 +1240,18 @@ function renderNav() {
|
|
|
1208
1240
|
</select>`;
|
|
1209
1241
|
}
|
|
1210
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
|
+
|
|
1211
1255
|
function renderHeader() {
|
|
1212
1256
|
const st = S.state;
|
|
1213
1257
|
$("#version").textContent = st.version;
|
|
@@ -1288,12 +1332,11 @@ function renderMain() {
|
|
|
1288
1332
|
: o.ready ? `<span class="dot down"></span>no traefik` : ""
|
|
1289
1333
|
: "";
|
|
1290
1334
|
const sub = [esc(s?.target ?? ""), s && !s.registered ? "from ~/.ssh/config" : "", traefik].filter(Boolean).join(" · ");
|
|
1291
|
-
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>`;
|
|
1292
1335
|
// Reloading with something already on screen: keep it, show a small spinner.
|
|
1293
1336
|
const reloading = needs(S.tab).some((t) => S[STORE[t]][n]?.loading && S[STORE[t]][n]?.data);
|
|
1294
1337
|
main.innerHTML = `
|
|
1295
1338
|
<div class="head">
|
|
1296
|
-
<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>
|
|
1297
1340
|
<div class="actions">
|
|
1298
1341
|
${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
|
|
1299
1342
|
${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
|
|
@@ -1308,7 +1351,7 @@ function renderMain() {
|
|
|
1308
1351
|
</div>
|
|
1309
1352
|
</div>
|
|
1310
1353
|
${factsNote(n)}
|
|
1311
|
-
<div class="tabs">${
|
|
1354
|
+
<div class="tabs">${sectionButtons(n, "tab")}</div>
|
|
1312
1355
|
<div id="view"></div>`;
|
|
1313
1356
|
const view = $("#view");
|
|
1314
1357
|
if (ov.loading && !o) view.innerHTML = `<div class="loading"><span class="spin"></span> connecting to ${esc(n)}…</div>`;
|
|
@@ -1318,6 +1361,8 @@ function renderMain() {
|
|
|
1318
1361
|
<div class="actions" style="margin:0">${btn("Set up", () => setupFlow([n]), "primary")} ${btn("Retry", () => (load(n, S.tab, true), render()))}</div>`;
|
|
1319
1362
|
} else if (!o) view.innerHTML = "";
|
|
1320
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);
|
|
1321
1366
|
else if (S.tab === "updates") view.innerHTML = updatesView(n);
|
|
1322
1367
|
else if (S.tab === "firewall") view.innerHTML = firewallView(n);
|
|
1323
1368
|
else if (S.tab === "settings") view.innerHTML = settingsView(n);
|
|
@@ -1356,7 +1401,7 @@ function othersCard(n, o) {
|
|
|
1356
1401
|
<div class="chips show-sm">${links(e)}</div></td>
|
|
1357
1402
|
<td class="hide-sm"><div class="chips">${links(e)}</div></td>
|
|
1358
1403
|
<td class="hide-sm mono dim">${esc(e.dir ?? e.containers.join(", "))}</td>
|
|
1359
|
-
<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>`)
|
|
1360
1405
|
.join("")}</tbody></table></div>`;
|
|
1361
1406
|
}
|
|
1362
1407
|
|
|
@@ -1697,18 +1742,42 @@ function editRule(n, num, r) {
|
|
|
1697
1742
|
|
|
1698
1743
|
// ---- history ---------------------------------------------------------------
|
|
1699
1744
|
|
|
1700
|
-
|
|
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) {
|
|
1701
1774
|
const st = S.settings[n] ?? { loading: true };
|
|
1702
1775
|
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
1703
1776
|
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1704
1777
|
const x = st.data;
|
|
1705
|
-
const reload = { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "
|
|
1778
|
+
const reload = { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "traefik", true), render()) : undefined) };
|
|
1706
1779
|
const rows = (pairs) => `<dl class="kv">${pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>`;
|
|
1707
|
-
const
|
|
1708
|
-
const addDomain = () =>
|
|
1709
|
-
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) => {
|
|
1710
|
-
if (d) run(["server", "domain", n, "--add", d], reload);
|
|
1711
|
-
}, "Add");
|
|
1780
|
+
const P = S.state.paths;
|
|
1712
1781
|
const dashboard = () =>
|
|
1713
1782
|
dialog({
|
|
1714
1783
|
title: `Traefik dashboard on ${n}`,
|
|
@@ -1755,13 +1824,48 @@ function settingsView(n) {
|
|
|
1755
1824
|
run(argv, reload);
|
|
1756
1825
|
},
|
|
1757
1826
|
});
|
|
1758
|
-
const tunnel = () => run(["traefik", "dashboard", ...sv(n)], { server: n });
|
|
1759
|
-
const sites = S.overview[n]?.data?.sites ?? [];
|
|
1760
1827
|
// lf traefik setup refuses to touch a Traefik the server already had.
|
|
1761
1828
|
const o = S.overview[n]?.data;
|
|
1762
1829
|
const foreign = o?.adopted ? o.proxy : "";
|
|
1763
1830
|
const dash = o?.dashboard ?? { hosts: x.traefik.dashboard ? [x.traefik.dashboard] : [], tunnel: false };
|
|
1764
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
|
+
|
|
1856
|
+
function settingsView(n) {
|
|
1857
|
+
const st = S.settings[n] ?? { loading: true };
|
|
1858
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
1859
|
+
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1860
|
+
const x = st.data;
|
|
1861
|
+
const reload = { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "settings", true), render()) : undefined) };
|
|
1862
|
+
const rows = (pairs) => `<dl class="kv">${pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>`;
|
|
1863
|
+
const dnsRow = (d) => `${esc(d.domain)} ${d.ok ? `<span class="badge ok">dns ok</span>` : d.ips.length ? `<span class="badge warn">dns → ${esc(d.ips.join(", "))}</span>` : `<span class="badge bad">no dns</span>`}`;
|
|
1864
|
+
const addDomain = () =>
|
|
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) => {
|
|
1866
|
+
if (d) run(["server", "domain", n, "--add", d], reload);
|
|
1867
|
+
}, "Add");
|
|
1868
|
+
const sites = S.overview[n]?.data?.sites ?? [];
|
|
1765
1869
|
const external = (d) => S.overview[n]?.data?.others?.find((e) => e.domains.includes(d))?.name;
|
|
1766
1870
|
const servedBy = (d) => sites.find((s) => s.domains.includes(d))?.meta.name;
|
|
1767
1871
|
const serve = (d) => {
|
|
@@ -1797,20 +1901,6 @@ function settingsView(n) {
|
|
|
1797
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>`}
|
|
1798
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>
|
|
1799
1903
|
</div>
|
|
1800
|
-
<div class="card">
|
|
1801
|
-
<h2>Traefik <span class="actions">${dash.tunnel ? btn("Open via tunnel", tunnel, "sm", "lf traefik dashboard: an SSH tunnel to the dashboard, until you stop it") : ""}${foreign ? "" : btn("Dashboard…", dashboard, "sm") + btn("Settings…", traefikDialog, "sm")}</span></h2>
|
|
1802
|
-
<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([
|
|
1803
|
-
["image", esc(x.traefik.summary)],
|
|
1804
|
-
["e-mail", esc(x.traefik.email ?? "none")],
|
|
1805
|
-
["HTTP/3", x.traefik.http3 ? `on <span class="dim">(443/udp)</span>` : `<span class="dim">off</span>`],
|
|
1806
|
-
["dashboard", `${dash.hosts.length ? `${dash.hosts.map(link).join(", ")} <span class="dim">(with its login)</span>` : `<span class="dim">no domain</span>`}${
|
|
1807
|
-
dash.tunnel ? ` · <span class="dim">tunnel: lf traefik dashboard</span>`
|
|
1808
|
-
: foreign ? ` · <span class="dim">tunnel: lf traefik dashboard --port <its api port></span>`
|
|
1809
|
-
: ` · <span class="dim">no tunnel yet</span> ${btn("Enable tunnel", () => run(["traefik", "setup", ...sv(n)], reload), "sm", "Re-runs lf traefik setup, which adds it")}`}`],
|
|
1810
|
-
["files", `<span class="mono">${esc(P.traefik)}/docker-compose.yml</span>`],
|
|
1811
|
-
["certificates", `<span class="mono">${esc(P.traefik)}/letsencrypt/</span> <span class="dim">(kept on reinstall)</span>`],
|
|
1812
|
-
])}<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>
|
|
1813
|
-
</div>
|
|
1814
1904
|
<div class="card">
|
|
1815
1905
|
<h2>On the server <span class="dim mono" style="font-weight:400">${esc(P.root)}</span></h2>
|
|
1816
1906
|
<div class="body">${rows([
|
|
@@ -1908,6 +1998,9 @@ function render() {
|
|
|
1908
1998
|
|
|
1909
1999
|
document.addEventListener("keydown", (e) => {
|
|
1910
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]);
|
|
1911
2004
|
if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
|
|
1912
2005
|
S.job = null;
|
|
1913
2006
|
render();
|