landfall 0.5.5 → 0.6.1

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
@@ -168,6 +168,26 @@ button.more { padding-inline: 8px; letter-spacing: .05em; }
168
168
  .tile .w.login { color: var(--warn); } .tile .w.down { color: var(--bad); }
169
169
  .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; margin: 28px 0 20px; align-items: start; }
170
170
  .cols > .card + .card { margin-top: 0; }
171
+ .meters { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 20px; }
172
+ .meter { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); padding: 12px 14px; box-shadow: var(--shadow); min-width: 0; }
173
+ .meter .k { display: flex; justify-content: space-between; gap: 8px; font-size: var(--fs-sm); color: var(--dim); }
174
+ .meter .k span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
175
+ .meter .v { display: flex; align-items: flex-end; gap: 10px; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; margin: 2px 0 6px; white-space: nowrap; }
176
+ .meter .bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
177
+ .meter .bar > i { display: block; height: 100%; background: var(--ok); border-radius: 3px; }
178
+ .meter.warn .bar > i { background: var(--warn); } .meter.bad .bar > i { background: var(--bad); }
179
+ .meter .t { font-size: var(--fs-xs); color: var(--faint); margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
180
+ .tchart { position: relative; }
181
+ .tchart svg { display: block; width: 100%; height: 120px; border-bottom: 1px solid var(--line); }
182
+ .tchart .tmax { position: absolute; top: -2px; left: 0; font-size: var(--fs-xs); }
183
+ .tticks { display: grid; grid-template-columns: 1fr 1fr 1fr; font-size: var(--fs-xs); margin-top: 4px; }
184
+ .tlegend { display: flex; gap: 14px; font-size: var(--fs-xs); color: var(--dim); margin-top: 8px; }
185
+ .tlegend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
186
+ .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px 16px; margin-top: 20px; }
187
+ .cols .card + .card { margin-top: 0; }
188
+ .cols + .card { margin-top: 20px; }
189
+ .meter svg { flex: 1; min-width: 0; height: 26px; color: var(--accent); }
190
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
171
191
  .recent { list-style: none; margin: 0; padding: 0; }
172
192
  .recent li { display: flex; align-items: center; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--line); }
173
193
  .recent li:last-child { border-bottom: 0; }
@@ -209,7 +229,7 @@ td.acts button { margin-left: 4px; }
209
229
  .scroll { overflow-x: auto; }
210
230
  .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); margin-right: 7px; vertical-align: 1px; }
211
231
  .dot.up { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-bg); } .dot.down { background: var(--bad); box-shadow: 0 0 0 3px var(--bad-bg); } .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-bg); }
212
- .badge { display: inline-block; font-size: var(--fs-xs); font-weight: 500; line-height: 1.6; padding: 0 8px; border-radius: 99px; background: var(--hover); color: var(--dim); border: 1px solid var(--line); }
232
+ .badge { display: inline-block; font-size: var(--fs-xs); font-weight: 500; line-height: 1.6; padding: 0 8px; border-radius: 99px; background: var(--hover); color: var(--dim); border: 1px solid var(--line); white-space: nowrap; }
213
233
  .badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
214
234
  .badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
215
235
  .badge.bad { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
@@ -369,11 +389,19 @@ const S = {
369
389
  tab: "sites",
370
390
  overview: {}, // name -> {data} | {error} | {loading}
371
391
  firewall: {},
392
+ system: {}, // name -> {data: lf system --json}
372
393
  history: {},
373
394
  updates: {},
374
395
  facts: {},
396
+ stats: {}, // name -> {data: lf stats --json}; reloads every few seconds while the tab is open
397
+ statsLog: {}, // name -> [{cpu, mem}], the last samples, for the sparklines
398
+ disk: {}, // name -> {data: lf disk --json}, only on request: du takes a while
375
399
  settings: {}, // name -> {data: lf server config --json + dns of the main domain}
376
- dns: {}, // name -> {data: {ips, domains: [{domain, ips, ok, https}]}}
400
+ dns: {}, // name -> {data: {ips, domains: [{domain, ips, ok, https: lf check}]}}; checked again each minute
401
+ traffic: {}, // name -> {data: lf traffic --json --since S.since}
402
+ since: "24h", // the Traffic tab's range
403
+ errs: {}, // name -> {data: lf traffic --json --since 24h}, for the sites' errors card
404
+ certs: {}, // name -> {data: lf certs --json}
377
405
  open: new Set(), // expanded site rows
378
406
  filter: "", // server list filter
379
407
  reach: {}, // name -> {state: ok|login|down, detail, ms} from /api/reach
@@ -529,10 +557,10 @@ window.addEventListener("hashchange", route);
529
557
 
530
558
  // ---- loading -------------------------------------------------------------
531
559
 
532
- const STORE = { sites: "overview", tunnels: "overview", traefik: "settings", facts: "facts", updates: "updates", firewall: "firewall", history: "history", settings: "settings" };
533
- const URLS = { sites: "", tunnels: "", traefik: "/config", facts: "/facts", updates: "/updates", firewall: "/firewall", history: "/history", settings: "/config" };
534
- // A server's sections, in the list and as tabs on phones; keys 1-7 switch between them.
535
- const SECTIONS = [["sites", "Sites"], ["tunnels", "Tunnels"], ["traefik", "Traefik"], ["updates", "Updates"], ["firewall", "Firewall"], ["history", "History"], ["settings", "Settings"]];
560
+ const STORE = { sites: "overview", tunnels: "overview", stats: "stats", traffic: "traffic", traefik: "settings", facts: "facts", updates: "updates", firewall: "firewall", system: "system", history: "history", settings: "settings" };
561
+ const URLS = { sites: "", tunnels: "", stats: "/stats", traffic: "/traffic", traefik: "/config", facts: "/facts", updates: "/updates", firewall: "/firewall", system: "/system", history: "/history", settings: "/config" };
562
+ // A server's sections, in the list and as tabs on phones; keys 1-9 switch between them.
563
+ const SECTIONS = [["sites", "Sites"], ["tunnels", "Tunnels"], ["stats", "Stats"], ["traffic", "Traffic"], ["traefik", "Traefik"], ["updates", "Updates"], ["firewall", "Firewall"], ["system", "System"], ["history", "History"], ["settings", "Settings"]];
536
564
 
537
565
  // The overview and facts drive the header, so every tab needs them.
538
566
  const needs = (tab) => (STORE[tab] === "overview" ? ["sites", "facts"] : ["sites", "facts", tab]);
@@ -542,14 +570,16 @@ function load(name, tab, force = false) {
542
570
  if (store[name] && !force) continue;
543
571
  // Keep what is shown while it reloads; views show a spinner only when they have nothing yet.
544
572
  store[name] = { ...store[name], loading: true, error: undefined };
545
- http(`/api/servers/${encodeURIComponent(name)}${URLS[t]}`).then(
546
- (data) => ((store[name] = { data }), render()),
573
+ http(`/api/servers/${encodeURIComponent(name)}${URLS[t]}${t === "traffic" ? `?since=${S.since}` : ""}`).then(
574
+ (data) => (t === "stats" && logStats(name, data), (store[name] = { data }), render()),
547
575
  (e) => ((store[name] = { error: e.message }), render()),
548
576
  );
549
577
  }
550
578
  }
551
579
  function invalidate(name) {
552
580
  delete S.dns[name];
581
+ delete S.errs[name];
582
+ delete S.certs[name];
553
583
  // What is on screen reloads in place; other tabs reload when opened.
554
584
  const shown = new Set(S.sel === name ? needs(S.tab).map((t) => STORE[t]) : []);
555
585
  for (const k of Object.values(STORE)) if (!shown.has(k)) delete S[k][name];
@@ -1126,8 +1156,13 @@ function deployDialog(det, o = {}) {
1126
1156
  { name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
1127
1157
  ]
1128
1158
  : []),
1129
- { name: "authPassword", label: `Login password (optional, at least ${S.state.rules.minPassword} 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." },
1130
- { name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
1159
+ {
1160
+ legend: "Login (optional)",
1161
+ fields: [
1162
+ { name: "authUser", label: "User", value: "admin", placeholder: "admin" },
1163
+ { name: "authPassword", label: `Password (at least ${S.state.rules.minPassword} characters)`, type: "password", 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." },
1164
+ ],
1165
+ },
1131
1166
  ],
1132
1167
  ok: "Deploy",
1133
1168
  onOk: (v) => {
@@ -1344,10 +1379,11 @@ function authDialog(n, m) {
1344
1379
  });
1345
1380
  }
1346
1381
 
1347
- function ensureDns(n, o) {
1348
- if (S.dns[n] || !o?.sites.length) return;
1382
+ function ensureDns(n, o, force = false) {
1383
+ if ((S.dns[n] && !force) || S.dns[n]?.loading || !o?.sites.length) return;
1349
1384
  const domains = [...new Set(o.sites.flatMap((x) => x.domains))];
1350
- S.dns[n] = { loading: true };
1385
+ // A recheck keeps the last answer on screen.
1386
+ S.dns[n] = { ...S.dns[n], loading: true };
1351
1387
  http(`/api/servers/${encodeURIComponent(n)}/dns?domains=${encodeURIComponent(domains.join(","))}`).then(
1352
1388
  (data) => ((S.dns[n] = { data }), render()),
1353
1389
  (e) => ((S.dns[n] = { error: e.message }), render()),
@@ -1359,10 +1395,12 @@ function domainChip(n, d) {
1359
1395
  const url = `https://${d}`;
1360
1396
  let dot = "", title = "checking…";
1361
1397
  if (c) {
1362
- const st = c.https.status;
1398
+ const { status: st, ms, tls } = c.https;
1399
+ const cert = tls ? (tls.valid ? ` · certificate ${plural(tls.days, "day")} left` : ` · certificate: ${tls.error}`) : "";
1363
1400
  if (!c.ips.length) (dot = "down"), (title = "DNS: does not resolve");
1364
1401
  else if (!c.ok) (dot = "warn"), (title = `DNS points at ${c.ips.join(", ")}, not this server`);
1365
- else if (st && st < 500) (dot = "up"), (title = `HTTPS ${st}${st === 401 ? " (login required)" : ""}`);
1402
+ else if (st && st < 500 && tls && (!tls.valid || tls.days < 7)) (dot = "warn"), (title = `HTTPS ${st}${cert}`);
1403
+ else if (st && st < 500) (dot = "up"), (title = `HTTPS ${st}${st === 401 ? " (login required)" : ""} in ${ms} ms${cert}`);
1366
1404
  else (dot = "down"), (title = `HTTPS: ${st ?? c.https.error}`);
1367
1405
  }
1368
1406
  return `<span class="chip"><span class="dot ${dot}" title="${esc(title)}"></span><a href="${esc(url)}" target="_blank" rel="noopener" title="${esc(title)}" onclick="event.stopPropagation()">${esc(d)}</a>${btn("⧉", () => copy(url), "ghost", "Copy the address")}</span>`;
@@ -1463,6 +1501,19 @@ function toggleGroup(key) {
1463
1501
  }
1464
1502
 
1465
1503
  /** The open server's sections as buttons, each with its count. */
1504
+ /** What the server can do (`lf server info --json` can); null until its facts are in, then everything shows. */
1505
+ const can = (n) => S.facts[n]?.data?.can ?? null;
1506
+ /** When each section is worth a tab: the same conditions the CLI checks before doing it. */
1507
+ const SECTION_IF = {
1508
+ sites: (k) => k.docker || k.setup,
1509
+ tunnels: (k) => k.docker,
1510
+ traffic: (k) => k.docker,
1511
+ traefik: (k) => k.docker,
1512
+ updates: (k) => k.packages || k.docker,
1513
+ firewall: (k) => k.firewall,
1514
+ };
1515
+ const sections = (n) => SECTIONS.filter(([t]) => !can(n) || (SECTION_IF[t]?.(can(n)) ?? true));
1516
+
1466
1517
  function sectionButtons(n, f) {
1467
1518
  const o = S.overview[n]?.data;
1468
1519
  const tunnels = openTunnels(n).length;
@@ -1471,7 +1522,7 @@ function sectionButtons(n, f) {
1471
1522
  tunnels: tunnels ? ` <span class="badge ok">${tunnels} open</span>` : "",
1472
1523
  updates: updatesBadge(n),
1473
1524
  };
1474
- 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("");
1525
+ return sections(n).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("");
1475
1526
  }
1476
1527
 
1477
1528
  function renderHeader() {
@@ -1503,10 +1554,10 @@ const statusDot = (status) => {
1503
1554
  };
1504
1555
 
1505
1556
  const size = (b) => {
1506
- const u = ["B", "K", "M", "G", "T"];
1557
+ const u = ["B", "KB", "MB", "GB", "TB"];
1507
1558
  let i = 0;
1508
1559
  while (b >= 1024 && i < u.length - 1) (b /= 1024), i++;
1509
- return `${b < 10 && i ? b.toFixed(1) : Math.round(b)}${u[i]}`;
1560
+ return `${b < 10 && i ? b.toFixed(1) : Math.round(b)} ${u[i]}`;
1510
1561
  };
1511
1562
  const uptime = (s) => {
1512
1563
  const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
@@ -1531,9 +1582,11 @@ function factsLine(n) {
1531
1582
  }
1532
1583
 
1533
1584
  function factsNote(n) {
1534
- const sup = S.facts[n]?.data?.support;
1585
+ const f = S.facts[n]?.data;
1586
+ const sup = f?.support;
1587
+ // A system that installs its own way (DSM, SteamOS) is not a problem, just a different kind of server.
1535
1588
  if (!sup?.note) return "";
1536
- return `<div class="note ${sup.level === "ok" ? "" : "bad"}">${esc(sup.note)}</div>`;
1589
+ return `<div class="note ${sup.level === "eol" || sup.level === "unsupported" ? "bad" : ""}">${esc(sup.note)}${f.can && !f.can.docker ? `. Docker: ${esc(f.why.docker)}` : ""}</div>`;
1537
1590
  }
1538
1591
 
1539
1592
  function renderMain() {
@@ -1546,6 +1599,10 @@ function renderMain() {
1546
1599
  <p>It is not in landfall's list or in ~/.ssh/config (anymore).</p>
1547
1600
  <p>${btn("All servers", () => go(null), "primary")} ${btn("Add a server…", addServer)}</p></div></div>`);
1548
1601
  }
1602
+ // A section this server has no use for (no docker, no ufw…): open the first one it has.
1603
+ const shown = sections(n);
1604
+ if (!shown.some(([t]) => t === S.tab)) return void location.replace(`#/${encodeURIComponent(n)}/${shown[0][0]}`);
1605
+ const k = can(n);
1549
1606
  const ov = S.overview[n] ?? { loading: true };
1550
1607
  const o = ov.data;
1551
1608
  const traefik = o
@@ -1555,17 +1612,20 @@ function renderMain() {
1555
1612
  : "";
1556
1613
  const sub = [esc(s?.target ?? ""), s && !s.registered ? "from ~/.ssh/config" : "", traefik].filter(Boolean).join(" · ");
1557
1614
  // Reloading with something already on screen: keep it, show a small spinner.
1558
- const reloading = needs(S.tab).some((t) => S[STORE[t]][n]?.loading && S[STORE[t]][n]?.data);
1615
+ // Stats reload every few seconds and say so themselves.
1616
+ const reloading = needs(S.tab).some((t) => t !== "stats" && S[STORE[t]][n]?.loading && S[STORE[t]][n]?.data);
1559
1617
  main.innerHTML = `
1560
1618
  <div class="head">
1561
1619
  <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>
1562
1620
  <div class="actions">
1563
- ${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
1621
+ ${!k || k.setup ? btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying") : ""}
1564
1622
  ${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
1565
1623
  ${btn("↻", () => (load(n, S.tab, true), render()), "", "Reload", "reload").replace("<button", `<button aria-label="Reload"`)}
1566
1624
  ${menuBtn([
1567
1625
  { label: "Server info", hint: "lf server info", run: () => run(["server", "info", n]) },
1568
- { label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) },
1626
+ !k || k.docker ? { label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) } : null,
1627
+ !k || k.admin ? "-" : null,
1628
+ !k || k.admin ? { label: "Reboot…", cls: "danger", hint: "lf reboot", run: () => confirmDlg({ title: `Reboot ${n}?`, text: "Its sites are offline for a minute or two. Waits until it is back and checks its containers run again.", ok: "Reboot", danger: true, onOk: () => run(["reboot", ...sv(n), "-y"], { server: n }) }) } : null,
1569
1629
  s?.isDefault ? null : { label: "Make default", title: "Commands without -s use it", run: () => makeDefault(n) },
1570
1630
  s?.registered ? "-" : null,
1571
1631
  s?.registered ? { label: `Forget ${n}…`, cls: "danger", hint: "lf server rm", run: () => forget(n) } : null,
@@ -1584,9 +1644,12 @@ function renderMain() {
1584
1644
  } else if (!o) view.innerHTML = "";
1585
1645
  else if (S.tab === "sites") view.innerHTML = sitesView(n, o);
1586
1646
  else if (S.tab === "tunnels") view.innerHTML = tunnelsView(n, o);
1647
+ else if (S.tab === "stats") view.innerHTML = statsView(n);
1648
+ else if (S.tab === "traffic") view.innerHTML = trafficView(n);
1587
1649
  else if (S.tab === "traefik") view.innerHTML = traefikView(n);
1588
1650
  else if (S.tab === "updates") view.innerHTML = updatesView(n);
1589
1651
  else if (S.tab === "firewall") view.innerHTML = firewallView(n);
1652
+ else if (S.tab === "system") view.innerHTML = systemView(n);
1590
1653
  else if (S.tab === "settings") view.innerHTML = settingsView(n);
1591
1654
  else view.innerHTML = historyView(n);
1592
1655
  }
@@ -1766,7 +1829,7 @@ function sitesView(n, o) {
1766
1829
  .join("");
1767
1830
  return `<div class="card scroll"><table>
1768
1831
  <thead><tr><th>Site</th><th class="hide-sm">Domains</th><th class="hide-sm">Kind</th><th class="hide-sm">Deployed</th><th><span class="sr">Actions</span></th></tr></thead>
1769
- <tbody>${rows}</tbody></table></div>${othersCard(n, o)}`;
1832
+ <tbody>${rows}</tbody></table></div>${errorsCard(n)}${othersCard(n, o)}`;
1770
1833
  }
1771
1834
 
1772
1835
  /** Where a site lives on the server: docker compose works in its current folder. */
@@ -1842,7 +1905,9 @@ function updatesView(n) {
1842
1905
  danger: flags.includes("--reboot"),
1843
1906
  onOk: () => run(["update", "--packages", ...flags, ...s, "-y"], { server: n }),
1844
1907
  });
1845
- const pkgActs = [
1908
+ // Package upgrades need a package manager and root (the CLI's can.packages).
1909
+ const pkgOk = p.mgr !== "none" && (can(n)?.packages ?? true);
1910
+ const pkgActs = !pkgOk ? "" : [
1846
1911
  btn("Refresh lists", () => refreshUpdates(n), "sm", "Fetch the newest package lists first (apt update)"),
1847
1912
  sec.length && p.mgr !== "apk" ? btn(`Security only (${sec.length})`, () => upgrade(["--security"], "security updates", sec.length), "sm") : "",
1848
1913
  p.updates.length ? btn("Upgrade all", () => upgrade([], "all packages", p.updates.length), "sm primary") : "",
@@ -1875,7 +1940,7 @@ function updatesView(n) {
1875
1940
  ? `<span class="badge">${p.updates.length}</span>${sec.length ? ` <span class="badge warn">${sec.length} security</span>` : ""}`
1876
1941
  : `<span class="badge ok">up to date</span>`;
1877
1942
  return `
1878
- <div class="card">
1943
+ ${p.mgr === "none" ? "" : `<div class="card">
1879
1944
  <h2>Packages ${head}${p.reboot ? ` <span class="badge warn">reboot required</span>` : ""}
1880
1945
  <span class="dim hide-sm" style="font-weight:400">${esc(p.mgr)}${p.refreshed ? "" : " · lists not refreshed"}</span>
1881
1946
  <span class="actions">${pkgActs}</span></h2>
@@ -1883,8 +1948,9 @@ function updatesView(n) {
1883
1948
  <thead><tr><th>Package</th><th class="hide-sm">Installed</th><th>New</th><th></th></tr></thead>
1884
1949
  <tbody>${pkgRows}${p.updates.length > shown.length ? `<tr><td colspan="4" class="dim">… ${p.updates.length - shown.length} more</td></tr>` : ""}</tbody>
1885
1950
  </table></div>` : ""}
1886
- </div>
1887
- <div class="card">
1951
+ ${pkgOk ? "" : `<p class="dim">${esc(S.facts[n]?.data?.why?.packages ?? "")}</p>`}
1952
+ </div>`}
1953
+ ${!e.docker && can(n) && !can(n).docker ? "" : `<div class="card">
1888
1954
  <h2>Docker <span class="badge ${!e.docker || e.dockerNote || e.composeNote ? "warn" : "ok"}">${e.docker ? `${esc(e.docker)} · compose ${esc(e.compose || "missing")}` : "not installed"}</span>
1889
1955
  <span class="actions">${e.composeUpdate ? btn("Update compose", () => run(["update", "--compose", ...s, "-y"], { server: n }), "sm primary") : ""}</span></h2>
1890
1956
  ${[e.dockerNote, e.composeNote].filter(Boolean).map((t) => `<p class="dim">${esc(t)}</p>`).join("")}
@@ -1896,7 +1962,7 @@ function updatesView(n) {
1896
1962
  <thead><tr><th>Name</th><th class="hide-sm">Image</th><th>State</th><th class="hide-sm">Note</th><th></th></tr></thead>
1897
1963
  <tbody>${imgRows || `<tr><td colspan="5" class="dim">no containers</td></tr>`}</tbody>
1898
1964
  </table></div>
1899
- </div>`;
1965
+ </div>`}`;
1900
1966
  }
1901
1967
 
1902
1968
  // ---- firewall --------------------------------------------------------------
@@ -2001,6 +2067,175 @@ function editRule(n, num, r) {
2001
2067
  });
2002
2068
  }
2003
2069
 
2070
+ // ---- system ----------------------------------------------------------------
2071
+
2072
+ const SVC_FAILED = (s) => s.active === "failed" || s.active === "crashed";
2073
+ const SVC_UP = (s) => s.active === "active" || s.active === "started";
2074
+ // What keeps the server and its sites running: shown first, the rest behind "all".
2075
+ const KEY_SVCS = ["docker", "containerd", "ssh", "sshd", "fail2ban", "ufw", "unattended-upgrades", "cron", "crond", "systemd-timesyncd", "chronyd", "chrony"];
2076
+ const svcName = (u) => u.replace(/\.service$/, "");
2077
+
2078
+ function systemView(n) {
2079
+ const st = S.system[n] ?? { loading: true };
2080
+ if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading ${esc(n)}…</div>`;
2081
+ if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
2082
+ const i = st.data;
2083
+ const s = sv(n);
2084
+ const go_ = (argv) => run([...argv, ...s], { server: n });
2085
+ const rows = (pairs) => `<dl class="kv">${pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>`;
2086
+
2087
+ const swapTotal = i.swap.reduce((t, x) => t + x.size, 0);
2088
+ const d = i.docker;
2089
+ // Change buttons only where the CLI would do it: as admin, and not on a system that keeps it itself.
2090
+ const adm = i.root, set = adm && !i.managed;
2091
+ const may = (ok, html) => (ok ? html : "");
2092
+ const basics = `<div class="card"><h2>Basics</h2><div class="body">${rows([
2093
+ ["Host name", `<span class="mono">${esc(i.hostname)}</span> ${may(set, btn("Change", () => renameHost(n, i.hostname), "ghost sm"))}`],
2094
+ ["Time zone", `${esc(i.timezone)} <span class="faint">· ${esc(i.time)}</span> ${may(set, btn("Change", () => changeTz(n, i.timezone), "ghost sm"))}`],
2095
+ ["Clock", i.ntp === true ? `<span class="badge ok">synced</span>` : i.ntp === false ? `<span class="badge warn">not synced</span>` : `<span class="faint">unknown</span>`],
2096
+ ["Swap", `${swapTotal ? `${size(swapTotal)} <span class="faint">${esc(i.swap.map((x) => `${x.file} · ${size(x.used)} used`).join(", "))}</span>` : `<span class="badge${i.mem < 4 * 1024 ** 3 ? " warn" : ""}">none</span> <span class="faint">${size(i.mem)} memory</span>`}
2097
+ ${!set ? "" : i.swap.some((x) => x.file === "/swapfile")
2098
+ ? btn("Remove", () => confirmDlg({ title: `Remove the swap file on ${n}?`, text: "What is swapped out moves back to memory first.", ok: "Remove it", danger: true, onOk: () => go_(["swap", "off", "-y"]) }), "ghost sm danger")
2099
+ : swapTotal ? "" : btn("Add…", () => addSwapDlg(n, i), "ghost sm")}`],
2100
+ ["Docker logs", !d ? `<span class="faint">no docker</span>`
2101
+ : `${d.maxSize ? `${esc(d.maxSize)} × ${d.maxFile ?? 1} per container` : d.driver === "json-file" ? `<span class="badge warn">unlimited</span>` : ""} <span class="faint">${esc(d.driver)}</span> ${d.daemonError || !set ? "" : btn(d.maxSize ? "Change" : "Limit…", () => logLimitDlg(n, d), "ghost sm")}`],
2102
+ ])}${d?.daemonError ? `<div class="note bad" style="margin:12px 0 0">${esc(d.daemonError)}</div>` : ""}
2103
+ ${i.managed ? `<p class="faint" style="margin:12px 0 0">${esc(i.managed)}.</p>` : ""}
2104
+ ${i.root ? "" : `<p class="faint" style="margin:12px 0 0">${esc(i.user)} has no passwordless sudo and root's key does not work: other users' keys, bans and Docker's settings may be missing.</p>`}</div></div>`;
2105
+
2106
+ let services = "";
2107
+ if (i.init !== "other") {
2108
+ const all = S.allSvcs?.[n];
2109
+ const shown = i.services
2110
+ .filter((x) => all || SVC_FAILED(x) || KEY_SVCS.includes(svcName(x.unit)) || (i.init === "openrc" && SVC_UP(x)))
2111
+ .sort((a, b) => (SVC_FAILED(b) - SVC_FAILED(a)) || (KEY_SVCS.includes(svcName(b.unit)) - KEY_SVCS.includes(svcName(a.unit))) || a.unit.localeCompare(b.unit));
2112
+ const nFailed = i.services.filter(SVC_FAILED).length;
2113
+ const row = (x) => {
2114
+ const u = svcName(x.unit);
2115
+ const ssh = /^sshd?$/.test(u), core = /^(docker|containerd)$/.test(u);
2116
+ const stop = () => confirmDlg({
2117
+ title: `Stop ${u} on ${n}?`,
2118
+ text: ssh ? "New ssh logins fail until it is started again (from the provider's console)." : core ? "Every site goes down until it is started again." : "It stays stopped until started again, or until the next boot if it is enabled.",
2119
+ ok: "Stop it", danger: true, onOk: () => go_(["services", "stop", u, "-y", ...(ssh ? ["--force"] : [])]),
2120
+ });
2121
+ const restart = () => core
2122
+ ? confirmDlg({ title: `Restart ${u} on ${n}?`, text: "Every site goes down for a moment.", ok: "Restart", danger: true, onOk: () => go_(["services", "restart", u, "-y"]) })
2123
+ : go_(["services", "restart", u]);
2124
+ const cls = SVC_FAILED(x) ? "bad" : SVC_UP(x) ? "ok" : "";
2125
+ return `<tr><td class="mono">${esc(u)}</td><td><span class="badge ${cls}">${esc(x.sub || x.active)}</span></td>
2126
+ ${i.init === "systemd" ? `<td class="faint hide-sm">${esc(x.enabled ?? "")}</td>` : ""}<td class="dim hide-sm">${esc(x.description)}</td>
2127
+ <td class="acts">${btn("Logs", () => run(["services", "logs", u, "-n", "200", ...s], { server: n }), "sm")}${may(adm, menuBtn([
2128
+ { label: "Restart", run: restart },
2129
+ SVC_UP(x) ? { label: "Stop…", cls: "danger", run: stop } : { label: "Start", run: () => go_(["services", "start", u]) },
2130
+ ], `Actions for ${u}`))}</td></tr>`;
2131
+ };
2132
+ services = `<div class="card"><h2>Services ${nFailed ? `<span class="badge bad">${nFailed} failed</span>` : ""} <span class="sub">${i.init}</span>
2133
+ <span class="actions">${btn(all ? "Fewer" : `All ${i.services.length}`, () => ((S.allSvcs = { ...S.allSvcs, [n]: !all }), render()), "sm")}</span></h2>
2134
+ <div class="scroll"><table><thead><tr><th>Service</th><th>State</th>${i.init === "systemd" ? `<th class="hide-sm">Boot</th>` : ""}<th class="hide-sm">Description</th><th><span class="sr">Actions</span></th></tr></thead>
2135
+ <tbody>${shown.map(row).join("") || `<tr><td colspan="5" class="dim">none</td></tr>`}</tbody></table></div></div>`;
2136
+ }
2137
+
2138
+ const keyRows = i.keys.map((k) => {
2139
+ const label = k.comment || k.fingerprint.slice(7, 19);
2140
+ const rm = () => confirmDlg({
2141
+ title: `Remove ${label} from ${k.user}?`,
2142
+ text: k.mine ? "This is the key this machine logs in with: removing it can lock you out." : `${k.type} ${k.fingerprint}`,
2143
+ ok: "Remove it", danger: true,
2144
+ onOk: () => go_(["keys", "rm", k.fingerprint, "--user", k.user, "-y", ...(k.mine ? ["--force"] : [])]),
2145
+ });
2146
+ return `<tr><td>${esc(k.user)}</td><td>${k.mine ? `<span class="badge ok" title="This machine logs in with it">this machine</span> ` : ""}${esc(k.comment || "")}${k.options ? `<div class="faint mono">${esc(k.options)}</div>` : ""}</td>
2147
+ <td class="mono faint hide-sm">${esc(k.type.replace(/^ssh-/, ""))} ${esc(k.fingerprint.slice(7, 23))}…</td><td class="acts">${may(adm || k.user === i.user, btn("Remove", rm, "sm danger"))}</td></tr>`;
2148
+ }).join("");
2149
+ const keys = `<div class="card"><h2>SSH keys <span class="badge">${i.keys.length}</span> <span class="sub">who may log in</span><span class="actions">${btn("Add key…", () => addKeyDlg(n), "sm")}</span></h2>
2150
+ <div class="scroll"><table><thead><tr><th>User</th><th>Comment</th><th class="hide-sm">Key</th><th><span class="sr">Actions</span></th></tr></thead>
2151
+ <tbody>${keyRows || `<tr><td colspan="4" class="dim">no keys found</td></tr>`}</tbody></table></div></div>`;
2152
+
2153
+ const banned = i.jails.flatMap((j) => j.ips.map((ip) => ({ ip, jail: j.name })));
2154
+ const bans = i.fail2ban === "running"
2155
+ ? `<div class="card"><h2>Bans <span class="badge${banned.length ? " warn" : ""}">${banned.length}</span> <span class="sub">fail2ban · ${esc(i.jails.map((j) => `${j.name}: ${j.total} in total`).join(", "))}</span></h2>
2156
+ <div class="scroll"><table><thead><tr><th>IP</th><th>Jail</th><th><span class="sr">Actions</span></th></tr></thead>
2157
+ <tbody>${banned.map((b) => `<tr><td class="mono">${esc(b.ip)}</td><td>${esc(b.jail)}</td><td class="acts">${may(adm, btn("Unban", () => go_(["unban", b.ip]), "sm"))}</td></tr>`).join("") || `<tr><td colspan="3" class="dim">nobody banned right now</td></tr>`}</tbody></table></div></div>`
2158
+ : `<div class="card"><h2>Bans</h2><div class="body dim">${i.fail2ban === "missing" ? "fail2ban is not installed." : "fail2ban is not running."}</div></div>`;
2159
+
2160
+ return basics + services + keys + bans;
2161
+ }
2162
+
2163
+ function renameHost(n, old) {
2164
+ dialog({
2165
+ title: `Rename ${n}`,
2166
+ text: "The server's own host name: what its shell prompt and logs show. Your ssh alias stays as it is.",
2167
+ fields: [{ name: "name", label: "Host name", value: old, placeholder: "web-1" }],
2168
+ ok: "Rename",
2169
+ onOk: (v) => {
2170
+ const name = v.name.trim();
2171
+ if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(name)) return { error: "Letters, digits and -, up to 63, no dots.", field: "name" };
2172
+ if (name !== old) run(["hostname", name, ...sv(n)], { server: n });
2173
+ },
2174
+ });
2175
+ }
2176
+ function changeTz(n, old) {
2177
+ dialog({
2178
+ title: `Time zone of ${n}`,
2179
+ text: "Cron jobs, logs and the clock in containers that read it follow it.",
2180
+ fields: [{ name: "tz", label: "Time zone", value: old, placeholder: "Europe/Berlin, UTC", hint: `Yours is ${Intl.DateTimeFormat().resolvedOptions().timeZone}.` }],
2181
+ ok: "Change",
2182
+ onOk: (v) => {
2183
+ const tz = v.tz.trim();
2184
+ if (!/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*$/.test(tz)) return { error: "A name like Europe/Berlin or UTC.", field: "tz" };
2185
+ if (tz !== old) run(["timezone", tz, ...sv(n)], { server: n });
2186
+ },
2187
+ });
2188
+ }
2189
+ function addSwapDlg(n, i) {
2190
+ const gb = 1024 ** 3;
2191
+ // Rule of thumb: as much as memory up to 2 GB, never more than a tenth of the free disk.
2192
+ const pick = Math.max(0.5, Math.min(i.mem > 2 * gb ? 2 : 1, Math.floor((i.diskFree / gb / 10) * 2) / 2));
2193
+ dialog({
2194
+ title: `Add swap to ${n}`,
2195
+ text: `A swap file at /swapfile, on at boot. It keeps the server alive when memory runs out (slowly instead of killing processes). ${size(i.diskFree)} free on the disk.`,
2196
+ fields: [{ name: "size", label: "Size", value: `${pick >= 1 ? pick + "G" : "512M"}`, placeholder: "1G, 512M" }],
2197
+ ok: "Add swap",
2198
+ onOk: (v) => {
2199
+ if (!/^\d+(\.\d+)?\s*[mg]$/i.test(v.size.trim())) return { error: "A size like 1G or 512M.", field: "size" };
2200
+ run(["swap", v.size.trim(), ...sv(n)], { server: n });
2201
+ },
2202
+ });
2203
+ }
2204
+ function logLimitDlg(n, d) {
2205
+ dialog({
2206
+ title: `Docker log limit on ${n}`,
2207
+ text: "Caps the log Docker keeps per container. It applies to containers created afterwards: redeploy or restart sites to apply it to them.",
2208
+ fields: [
2209
+ { name: "size", label: "Per file", value: d.maxSize ?? "10m", placeholder: "10m", hint: "k, m or g. Empty removes the limit." },
2210
+ { name: "files", label: "Files kept", value: String(d.maxFile ?? 3), placeholder: "3" },
2211
+ { name: "restart", type: "check", label: "Restart Docker now", hint: "Sites go down for a moment. Otherwise it applies on Docker's next start." },
2212
+ ],
2213
+ ok: "Save",
2214
+ onOk: (v) => {
2215
+ const sz = v.size.trim().toLowerCase();
2216
+ if (sz && !/^\d+[kmg]$/.test(sz)) return { error: "A size like 10m.", field: "size" };
2217
+ if (!/^\d+$/.test(v.files.trim())) return { error: "A number.", field: "files" };
2218
+ run(["log-limit", sz || "off", "--files", v.files.trim(), ...(v.restart ? ["--restart", "-y"] : []), ...sv(n)], { server: n });
2219
+ },
2220
+ });
2221
+ }
2222
+ function addKeyDlg(n) {
2223
+ dialog({
2224
+ title: `Add an SSH key to ${n}`,
2225
+ text: "Paste a public key (the .pub file, one line). Whoever holds its private key can log in.",
2226
+ fields: [
2227
+ { name: "key", label: "Public key", type: "textarea", value: "", placeholder: "ssh-ed25519 AAAA… name@laptop" },
2228
+ { name: "user", label: "User", value: "", placeholder: "the login user", hint: "Whose account it logs in to. Empty: the user landfall logs in as." },
2229
+ ],
2230
+ ok: "Add key",
2231
+ onOk: (v) => {
2232
+ const key = v.key.trim().split("\n")[0];
2233
+ if (!/^(\S+\s+)?(ssh-|ecdsa-|sk-)\S+ AAAA/.test(key)) return { error: "That does not look like a public key.", field: "key" };
2234
+ run(["keys", "add", key, ...(v.user.trim() ? ["--user", v.user.trim()] : []), ...sv(n)], { server: n });
2235
+ },
2236
+ });
2237
+ }
2238
+
2004
2239
  // ---- history ---------------------------------------------------------------
2005
2240
 
2006
2241
  /** Everything an SSH tunnel reaches on server n, and the tunnels open now. */
@@ -2052,8 +2287,13 @@ function traefikView(n) {
2052
2287
  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.",
2053
2288
  fields: [
2054
2289
  { 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." },
2055
- { name: "user", label: "Login user", value: "admin", placeholder: "admin" },
2056
- { name: "password", label: `Login password (at least ${S.state.rules.minPassword} characters)`, type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
2290
+ {
2291
+ legend: "Login",
2292
+ fields: [
2293
+ { name: "user", label: "User", value: "admin", placeholder: "admin" },
2294
+ { name: "password", label: `Password (at least ${S.state.rules.minPassword} characters)`, type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
2295
+ ],
2296
+ },
2057
2297
  ],
2058
2298
  ok: "Apply",
2059
2299
  onOk: (v) => {
@@ -2200,6 +2440,245 @@ function settingsView(n) {
2200
2440
  </div>`;
2201
2441
  }
2202
2442
 
2443
+ // The last samples per server, for the sparklines: 5 minutes at one per 5s.
2444
+ const STATS_LOG = 60;
2445
+ function logStats(n, s) {
2446
+ const log = (S.statsLog[n] ??= []);
2447
+ log.push({ cpu: s.cpu, mem: s.mem.total ? ((s.mem.total - s.mem.available) / s.mem.total) * 100 : 0 });
2448
+ if (log.length > STATS_LOG) log.shift();
2449
+ }
2450
+ const pctOf = (used, total) => (total ? Math.round((used / total) * 100) : 0);
2451
+ /** A sparkline of 0-100 values, oldest left. */
2452
+ function spark(values) {
2453
+ if (values.length < 2) return "";
2454
+ const pts = values.map((v, i) => `${((STATS_LOG - values.length + i) / (STATS_LOG - 1)) * 100},${24 - (Math.min(100, v) / 100) * 22}`).join(" ");
2455
+ return `<svg viewBox="0 0 100 26" preserveAspectRatio="none" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg>`;
2456
+ }
2457
+ function meter(label, p, value, sub, line = "", hint = "") {
2458
+ const cls = p >= 90 ? "bad" : p >= 70 ? "warn" : "";
2459
+ return `<div class="meter ${cls}" title="${esc(hint)}"><div class="k"><span>${esc(label)}</span></div>
2460
+ <div class="v">${value}${line}</div>
2461
+ ${p === null ? "" : `<div class="bar" role="meter" aria-label="${esc(label)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${p}"><i style="width:${Math.min(100, p)}%"></i></div>`}
2462
+ <div class="t">${sub}</div></div>`;
2463
+ }
2464
+ /** Live usage of one server: meters, the busiest processes and containers, disk usage on request. */
2465
+ function statsView(n) {
2466
+ const st = S.stats[n] ?? { loading: true };
2467
+ if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> measuring ${esc(n)} for a second…</div>`;
2468
+ if (st.error && !st.data) return `<div class="note bad">${esc(st.error)}</div><div class="actions" style="margin:0">${btn("Retry", () => (load(n, "stats", true), render()))}</div>`;
2469
+ const s = st.data;
2470
+ const log = S.statsLog[n] ?? [];
2471
+ const memUsed = s.mem.total - s.mem.available;
2472
+ const swapUsed = s.mem.swapTotal - s.mem.swapFree;
2473
+ const meters = [
2474
+ meter("CPU", s.cpu, `${s.cpu}%`, `${plural(s.cpus, "core")} · load ${s.load.join(" ")}`, spark(log.map((x) => x.cpu)), "Busy share of all cores over one second; load is the 1, 5 and 15 minute average"),
2475
+ meter("Memory", pctOf(memUsed, s.mem.total), `${pctOf(memUsed, s.mem.total)}%`, `${size(memUsed)} of ${size(s.mem.total)}`, spark(log.map((x) => x.mem)), "Used, without caches the kernel gives back when needed"),
2476
+ s.mem.swapTotal ? meter("Swap", pctOf(swapUsed, s.mem.swapTotal), `${pctOf(swapUsed, s.mem.swapTotal)}%`, `${size(swapUsed)} of ${size(s.mem.swapTotal)}`) : "",
2477
+ ...s.disks.map((d) => meter(`Disk ${d.mount}`, pctOf(d.used, d.total), `${pctOf(d.used, d.total)}%`, `${size(d.used)} of ${size(d.total)} · ${size(d.available)} free`)),
2478
+ ...s.net.map((x) => meter(`Network ${x.iface}`, null, `↓ ${size(x.rx)}/s`, `↑ ${size(x.tx)}/s`)),
2479
+ ].join("");
2480
+ const procs = s.procs.slice(0, 8);
2481
+ const procCard = procs.length
2482
+ ? `<div class="card"><h2>Processes <span class="sub">busiest and largest · cpu 100% = one core</span></h2><div class="scroll"><table>
2483
+ <thead><tr><th>Name</th><th class="num">CPU</th><th class="num">Memory</th><th class="num hide-sm">PID</th></tr></thead>
2484
+ <tbody>${procs.map((p) => `<tr><td class="mono">${esc(p.name)}</td><td class="num">${p.cpu}%</td><td class="num">${size(p.rss)}</td><td class="num faint hide-sm">${p.pid}</td></tr>`).join("")}</tbody></table></div></div>`
2485
+ : "";
2486
+ const up = (x) => isUp(x.status);
2487
+ const ctrCard = !s.docker
2488
+ ? `<div class="note">No access to Docker on ${esc(n)}: containers are not shown. Set up the server to fix that.</div>`
2489
+ : s.containers.length
2490
+ ? `<div class="card"><h2>Containers <span class="badge">${s.containers.length}</span></h2><div class="scroll"><table>
2491
+ <thead><tr><th>Container</th><th class="hide-sm">Site</th><th class="num">CPU</th><th class="num">Memory</th><th class="num hide-sm" title="Received and sent since the container started">Network in / out</th><th class="num hide-sm">Restarts</th></tr></thead>
2492
+ <tbody>${s.containers
2493
+ .map((x) => `<tr><td>${statusDot(x.status)}<span class="mono">${esc(x.name)}</span><div class="faint">${esc(x.image)}</div></td>
2494
+ <td class="hide-sm">${x.site ? `<b>${esc(x.site)}</b>` : `<span class="faint">${esc(x.project || "-")}</span>`}</td>
2495
+ <td class="num">${up(x) ? `${Math.round(x.cpu * 10) / 10}%` : ""}</td>
2496
+ <td class="num">${up(x) ? `${size(x.mem)}${x.memLimit && x.memLimit < s.mem.total ? `<div class="faint" title="Set by mem_limit or deploy.resources in its compose file">limit ${size(x.memLimit)}</div>` : ""}` : `<span class="faint">${esc(x.status)}</span>`}</td>
2497
+ <td class="num hide-sm" title="since the container started">${up(x) ? `↓ ${size(x.net.rx)} <span class="faint">·</span> ↑ ${size(x.net.tx)}` : ""}</td>
2498
+ <td class="num hide-sm">${x.restarts ? `<span class="badge warn">${x.restarts}</span>` : `<span class="faint">0</span>`}</td></tr>`)
2499
+ .join("")}</tbody></table></div></div>`
2500
+ : "";
2501
+ const live = `<p class="faint" style="margin-top:14px">${st.loading ? `<span class="spin"></span> ` : ""}${st.error ? `<span style="color:var(--bad)">last sample failed: ${esc(st.error)}</span> · ` : ""}measured ${new Date(s.at).toLocaleTimeString()} · new sample every 5s while this tab is open · <span class="mono">lf stats -w${S.state.defaults.default === n ? "" : ` -s ${esc(n)}`}</span></p>`;
2502
+ return `<div class="meters">${meters}</div>${ctrCard}${procCard}${diskCard(n)}${live}`;
2503
+ }
2504
+
2505
+ // ---- traffic ---------------------------------------------------------------
2506
+
2507
+ const TRAFFIC_SPANS = [["1h", "Last hour"], ["24h", "24 hours"], ["7d", "7 days"]];
2508
+ const CLASS_COLORS = { "2xx": "var(--accent)", "3xx": "var(--faint)", "4xx": "var(--warn)", "5xx": "var(--bad)" };
2509
+ const ms = (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${v} ms`);
2510
+ const count = (v) => v.toLocaleString();
2511
+ const sv2 = (n) => (S.state.defaults.default === n ? "" : ` -s ${n}`);
2512
+
2513
+ /** One lazily fetched view per server, outside the tab stores: the sites' error card and certificates. */
2514
+ function ensure(store, n, url) {
2515
+ if (S[store][n]) return;
2516
+ S[store][n] = { loading: true };
2517
+ http(`/api/servers/${encodeURIComponent(n)}${url}`).then(
2518
+ (data) => ((S[store][n] = { data }), render()),
2519
+ (e) => ((S[store][n] = { error: e.message }), render()),
2520
+ );
2521
+ }
2522
+
2523
+ /** Requests per minute or hour, stacked by status class; hover a bar for its numbers. */
2524
+ function trafficChart(series, bucket) {
2525
+ const W = 600, H = 120;
2526
+ const tot = series.map((x) => x["2xx"] + x["3xx"] + x["4xx"] + x["5xx"]);
2527
+ const max = Math.max(1, ...tot);
2528
+ const bw = W / series.length;
2529
+ const gap = bw > 4 ? 1 : 0;
2530
+ const when = (at) => (bucket === "hour" ? new Date(`${at}:00:00Z`) : new Date(`${at}:00Z`));
2531
+ const label = (at) => (bucket === "hour" ? when(at).toLocaleString([], { weekday: "short", hour: "2-digit", minute: "2-digit" }) : when(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
2532
+ const bars = series
2533
+ .map((x, i) => {
2534
+ let y = H;
2535
+ const segs = ["2xx", "3xx", "4xx", "5xx"]
2536
+ .filter((k) => x[k])
2537
+ .map((k) => {
2538
+ const h = Math.max(1, (x[k] / max) * (H - 4));
2539
+ y -= h;
2540
+ return `<rect x="${i * bw + gap / 2}" y="${y}" width="${Math.max(0.5, bw - gap)}" height="${h}" fill="${CLASS_COLORS[k]}"/>`;
2541
+ })
2542
+ .join("");
2543
+ const tip = `${label(x.at)}: ${count(tot[i])} requests${["4xx", "5xx"].filter((k) => x[k]).map((k) => `, ${count(x[k])} ${k}`).join("")}`;
2544
+ return `<g><title>${esc(tip)}</title><rect x="${i * bw}" y="0" width="${bw}" height="${H}" fill="transparent"/>${segs}</g>`;
2545
+ })
2546
+ .join("");
2547
+ const ticks = [0, Math.floor(series.length / 2), series.length - 1].map((i, j) => `<span style="text-align:${["left", "center", "right"][j]}">${esc(label(series[i].at))}</span>`).join("");
2548
+ return `<div class="tchart"><div class="tmax faint">${count(max)}</div><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Requests per ${bucket}">${bars}</svg>
2549
+ <div class="tticks faint">${ticks}</div>
2550
+ <div class="tlegend">${Object.entries(CLASS_COLORS).map(([k, col]) => `<span><i style="background:${col}"></i>${k}</span>`).join("")}</div></div>`;
2551
+ }
2552
+
2553
+ const errorCell = (status) => `<span class="badge ${status >= 500 ? "bad" : "warn"}">${status}</span>`;
2554
+
2555
+ function trafficView(n) {
2556
+ ensure("certs", n, "/certs");
2557
+ const st = S.traffic[n] ?? { loading: true };
2558
+ const spans = `<div class="actions" style="margin:0 0 14px">${TRAFFIC_SPANS.map(([v, l]) => btn(l, () => ((S.since = v), load(n, "traffic", true), render()), `sm${S.since === v ? " primary" : ""}`, `lf traffic --since ${v}`)).join("")}</div>`;
2559
+ if (st.loading && !st.data) return `${spans}<div class="loading"><span class="spin"></span> reading Traefik's access log…</div>`;
2560
+ if (st.error && !st.data) return `${spans}<div class="note bad">${esc(st.error)}</div>`;
2561
+ const t = st.data;
2562
+ if (!t.proxy) return `<div class="note">No Traefik on ${esc(n)}: nothing to count. Set up the server to install it.</div>`;
2563
+ if (t.format === "common") {
2564
+ return `<div class="note warn">Traefik writes its access log as plain text. ${t.own
2565
+ ? `Add <span class="mono">--accesslog.format=json</span> to its command to see traffic here.`
2566
+ : `${btn("Switch to JSON", () => run(["traefik", "setup", ...sv(n), "-y"], { server: n, after: () => (load(n, "traffic", true), []) }), "sm", "lf traefik setup: restarts Traefik for a moment")}`}</div>${certsCard(n)}`;
2567
+ }
2568
+ if (t.format === "none") return `${spans}<div class="card"><div class="body empty"><h2>No requests in this span</h2><p class="dim">Traefik's access log has nothing from the last ${esc(S.since)}.</p></div></div>${certsCard(n)}`;
2569
+ const T = t.total;
2570
+ const errs = T.classes["4xx"] + T.classes["5xx"];
2571
+ const pct = (v) => (T.requests ? Math.round((v / T.requests) * 1000) / 10 : 0);
2572
+ const meters = [
2573
+ meter("Requests", null, count(T.requests), `${plural(t.sites.length, "site")} · ${size(T.bytes)} sent`),
2574
+ meter("Server errors", null, `${count(T.classes["5xx"])}`, `${pct(T.classes["5xx"])}% · 5xx: the site failed`),
2575
+ meter("Client errors", null, `${count(T.classes["4xx"])}`, `${pct(T.classes["4xx"])}% · 4xx: not found, denied`),
2576
+ meter("Response time", null, ms(T.p50), `median · 95% within ${ms(T.p95)}`, "", "From Traefik receiving the request to the last byte sent"),
2577
+ ].join("");
2578
+ const other = t.hosts.filter((h) => !h.site);
2579
+ const rowsOf = (label, x) => `<tr><td>${label}</td><td class="num">${count(x.requests)}</td><td class="num">${x.classes["4xx"] ? `<span class="badge warn">${count(x.classes["4xx"])}</span>` : `<span class="faint">0</span>`}</td><td class="num">${x.classes["5xx"] ? `<span class="badge bad">${count(x.classes["5xx"])}</span>` : `<span class="faint">0</span>`}</td><td class="num hide-sm">${ms(x.p50)}</td><td class="num hide-sm">${ms(x.p95)}</td><td class="num hide-sm">${size(x.bytes)}</td></tr>`;
2580
+ const siteCard = `<div class="card"><h2>By site</h2><div class="scroll"><table>
2581
+ <thead><tr><th>Site</th><th class="num">Requests</th><th class="num">4xx</th><th class="num">5xx</th><th class="num hide-sm">Median</th><th class="num hide-sm">95%</th><th class="num hide-sm">Sent</th></tr></thead>
2582
+ <tbody>${t.sites.map((x) => rowsOf(`<b>${esc(x.site)}</b><div class="faint">${esc(x.hosts.join(" "))}</div>`, x)).join("")}${other.slice(0, 10).map((h) => rowsOf(`<span class="mono dim">${esc(h.host)}</span>`, h)).join("")}</tbody></table></div>
2583
+ ${other.length ? `<div class="body faint" style="padding-top:0">Hosts without a site: bots asking for the server's IP or old domains${other.length > 10 ? `, ${other.length - 10} more` : ""}.</div>` : ""}</div>`;
2584
+ const list = (title, rows, cols) => (rows.length ? `<div class="card"><h2>${title}</h2><div class="scroll"><table><thead><tr>${cols.map(([c, cls = ""]) => `<th class="${cls}">${c}</th>`).join("")}</tr></thead><tbody>${rows.join("")}</tbody></table></div></div>` : "");
2585
+ const paths = list("Busiest paths", T.paths.map((p) => `<tr><td class="mono">${esc(p.path)}</td><td class="num">${count(p.count)}</td><td class="num">${p.errors ? `<span class="badge warn">${count(p.errors)}</span>` : ""}</td></tr>`), [["Path"], ["Requests", "num"], ["Failed", "num"]]);
2586
+ const errors = list("Failing requests", T.errors.map((e) => `<tr><td>${errorCell(e.status)}</td><td class="mono">${esc(e.path)}</td><td class="num">${count(e.count)}</td></tr>`), [["Status"], ["Path"], ["Requests", "num"]]);
2587
+ const ips = list("Clients", T.ips.map((i) => `<tr><td class="mono">${esc(i.ip)}</td><td class="num">${count(i.count)}</td></tr>`), [["IP"], ["Requests", "num"]]);
2588
+ const reach = t.from && new Date(t.from) - (Date.now() - t.since * 1000) > (t.bucket === "hour" ? 2 * 3_600_000 : 120_000) ? ` · the log reaches back to ${new Date(t.from).toLocaleString()}` : "";
2589
+ return `${spans}<div class="meters">${meters}</div>
2590
+ <div class="card"><h2>Requests per ${t.bucket} ${errs ? `<span class="sub">${count(errs)} failed</span>` : ""}</h2><div class="body">${trafficChart(T.series, t.bucket)}</div></div>
2591
+ ${siteCard}<div class="cols">${errors}${paths}</div>${ips}${certsCard(n)}
2592
+ <p class="faint" style="margin-top:14px">${st.loading ? `<span class="spin"></span> ` : ""}from Traefik's access log, read ${new Date(t.at).toLocaleTimeString()}${reach} · <span class="mono">lf traffic --since ${esc(S.since)}${esc(sv2(n))}</span></p>`;
2593
+ }
2594
+
2595
+ /** Each site domain's certificate: days left, who issued it, why getting one failed. */
2596
+ function certsCard(n) {
2597
+ const st = S.certs[n];
2598
+ const head = `<h2>Certificates <span class="sub">lf certs</span></h2>`;
2599
+ if (!st || (st.loading && !st.data)) return `<div class="card">${head}<div class="loading"><span class="spin"></span> reading certificates…</div></div>`;
2600
+ if (st.error) return `<div class="card">${head}<div class="body"><div class="note bad" style="margin:0">${esc(st.error)}</div></div></div>`;
2601
+ const r = st.data;
2602
+ if (!r.domains.length) return "";
2603
+ const days = (c) => !c ? `<span class="badge bad">none</span>` : c.days < 7 ? `<span class="badge bad">${c.days < 0 ? "expired" : plural(c.days, "day")}</span>` : c.staging ? `<span class="badge warn">staging</span>` : `<span class="badge ${c.days < 30 ? "warn" : "ok"}">${plural(c.days, "day")}</span>`;
2604
+ const rows = r.domains.map((d) => `<tr><td>${esc(d.domain)}${d.error && (!d.cert || d.cert.days < 30) ? `<div class="faint" style="color:var(--bad)">${esc(d.error.error)}</div>` : ""}</td><td class="hide-sm dim">${esc(d.site)}</td><td>${days(d.cert)}</td><td class="hide-sm dim">${d.cert ? esc(new Date(d.cert.expires).toLocaleDateString()) : ""}</td><td class="hide-sm faint">${esc(d.cert?.issuer ?? "")}</td></tr>`).join("");
2605
+ return `<div class="card">${head}${r.denied.length ? `<div class="body"><div class="note warn" style="margin:0">${esc(r.denied.join(", "))} needs root to read: this login has no passwordless sudo.</div></div>` : ""}<div class="scroll"><table>
2606
+ <thead><tr><th>Domain</th><th class="hide-sm">Site</th><th>Left</th><th class="hide-sm">Expires</th><th class="hide-sm">Issuer</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
2607
+ }
2608
+
2609
+ /** The sites tab: what failed in the last 24 hours, only when something did. */
2610
+ function errorsCard(n) {
2611
+ if (!can(n)?.docker) return "";
2612
+ ensure("errs", n, "/traffic?since=24h");
2613
+ const t = S.errs[n]?.data;
2614
+ if (!t || t.format !== "json") return "";
2615
+ const rows = t.hosts.flatMap((h) => h.errors.map((e) => ({ ...e, where: h.site ?? h.host, site: !!h.site }))).filter((e) => e.site || e.status >= 500).sort((a, b) => (b.status >= 500) - (a.status >= 500) || b.count - a.count).slice(0, 6);
2616
+ if (!rows.length) return "";
2617
+ const fives = t.total.classes["5xx"];
2618
+ return `<div class="card"><h2>Errors in the last 24h <span class="sub">${fives ? `${count(fives)} server errors · ` : ""}${count(t.total.classes["4xx"])} client errors</span><span class="actions">${btn("Traffic", () => (location.hash = `#/${encodeURIComponent(n)}/traffic`), "sm")}</span></h2><div class="scroll"><table>
2619
+ <thead><tr><th>Status</th><th>Site</th><th>Path</th><th class="num">Requests</th></tr></thead>
2620
+ <tbody>${rows.map((e) => `<tr><td>${errorCell(e.status)}</td><td>${e.site ? `<b>${esc(e.where)}</b>` : `<span class="mono dim">${esc(e.where)}</span>`}</td><td class="mono">${esc(e.path)}</td><td class="num">${count(e.count)}</td></tr>`).join("")}</tbody></table></div></div>`;
2621
+ }
2622
+
2623
+ function loadDisk(n) {
2624
+ S.disk[n] = { ...S.disk[n], loading: true, error: undefined };
2625
+ render();
2626
+ http(`/api/servers/${encodeURIComponent(n)}/disk`).then(
2627
+ (data) => ((S.disk[n] = { data }), render()),
2628
+ (e) => ((S.disk[n] = { error: e.message }), render()),
2629
+ );
2630
+ }
2631
+ function pruneFlow(n) {
2632
+ http(`/api/servers/${encodeURIComponent(n)}/prune`).then((p) => {
2633
+ const imgs = p.images.filter((i) => !i.keep);
2634
+ const kept = (w) => p.images.filter((i) => i.keep === w).length;
2635
+ const most = imgs.reduce((t, i) => t + i.size, 0) + p.buildCache;
2636
+ const keep = [[kept("release"), "for rollback"], [kept("undo"), "for undo"], [kept("used"), "in use"]].filter(([k]) => k).map(([k, w]) => `${k} ${w}`).join(", ");
2637
+ dialog({
2638
+ title: `Clean up ${n}?`,
2639
+ text: `${plural(imgs.length, "unused image")} and ${size(p.buildCache)} of build cache: up to ${size(most)}.${keep ? ` Kept: ${keep}.` : ""} Volumes and containers stay.`,
2640
+ fields: kept("tagged") ? [{ name: "all", type: "check", label: `Also ${plural(kept("tagged"), "unused tagged image")}`, hint: "Pulled again when a site needs them" }] : [],
2641
+ ok: "Clean up",
2642
+ danger: true,
2643
+ onOk: (v) => run(["prune", ...sv(n), "-y", ...(v.all ? ["--all"] : [])], { server: n, after: () => (loadDisk(n), []) }),
2644
+ });
2645
+ }, (e) => toast(e.message));
2646
+ }
2647
+ function diskCard(n) {
2648
+ const st = S.disk[n];
2649
+ const head = (acts) => `<h2>Disk usage <span class="sub">images, volumes, build cache, sites and logs</span><span class="actions">${acts}</span></h2>`;
2650
+ if (!st) return `<div class="card">${head(btn("Analyze", () => loadDisk(n), "sm", "lf disk: sums up folders, takes a few seconds"))}<div class="body dim">Where the space goes. Sums up folders on the server, so only on request.</div></div>`;
2651
+ if (!st.data) {
2652
+ const body = st.loading ? `<div class="loading"><span class="spin"></span> adding up folders…</div>` : `<div class="body"><div class="note bad" style="margin:0">${esc(st.error)}</div></div>`;
2653
+ return `<div class="card">${head(st.loading ? "" : btn("Retry", () => loadDisk(n), "sm"))}${body}</div>`;
2654
+ }
2655
+ const d = st.data;
2656
+ const tbl = (cols, rows) => `<div class="scroll"><table><thead><tr>${cols.map(([c, cls = ""]) => `<th class="${cls}">${c}</th>`).join("")}</tr></thead><tbody>${rows}</tbody></table></div>`;
2657
+ const sub = (t) => `<h3 style="margin:16px 18px 6px;font-size:var(--fs-sm);color:var(--dim);font-weight:600">${t}</h3>`;
2658
+ const docker = d.dockerDf.length
2659
+ ? sub("Docker") + tbl([["Kind"], ["Count", "num"], ["In use", "num"], ["Size", "num"], ["Reclaimable", "num"]],
2660
+ d.dockerDf.map((x) => `<tr><td>${esc(x.type)}</td><td class="num">${x.count}</td><td class="num">${x.active}</td><td class="num">${size(x.size)}</td><td class="num">${x.reclaimable ? `<span class="badge warn">${size(x.reclaimable)}</span>` : `<span class="faint">0</span>`}</td></tr>`).join(""))
2661
+ : "";
2662
+ const sites = d.sites.length
2663
+ ? sub(`Sites <span class="faint mono">${esc(S.state.paths.sites)}</span>`) + tbl([["Site"], ["Files", "num"], ["Releases", "num"]],
2664
+ d.sites.map((x) => `<tr><td>${esc(x.name)}</td><td class="num">${size(x.size)}</td><td class="num">${x.releases} · ${size(x.releasesSize)}</td></tr>`).join(""))
2665
+ : "";
2666
+ const vols = d.volumes.filter((x) => x.size > 0).slice(0, 10);
2667
+ const volumes = vols.length
2668
+ ? sub("Largest volumes") + tbl([["Volume"], ["Size", "num"], ["Used by", "num"]],
2669
+ vols.map((x) => `<tr><td class="mono">${esc(x.name)}</td><td class="num">${size(x.size)}</td><td class="num">${x.links ? plural(x.links, "container") : `<span class="badge warn">nothing</span>`}</td></tr>`).join(""))
2670
+ : "";
2671
+ const lg = d.logs.filter((x) => x.size > 0).slice(0, 10);
2672
+ const logs = lg.length ? sub("Largest container logs") + tbl([["Container"], ["Size", "num"]], lg.map((x) => `<tr><td class="mono">${esc(x.container)}</td><td class="num">${size(x.size)}</td></tr>`).join("")) : "";
2673
+ const notes = [
2674
+ d.journald ? `systemd journal: ${size(d.journald)}` : "",
2675
+ d.docker ? "" : "No access to Docker: images, volumes and logs are not shown.",
2676
+ d.root ? "" : "Without root or passwordless sudo, root-owned folders and container logs may be missing.",
2677
+ ].filter(Boolean);
2678
+ return `<div class="card">${head((d.docker ? btn("Clean up…", () => pruneFlow(n), "sm", "lf prune: remove unused images and the build cache") : "") + btn("↻", () => loadDisk(n), "sm", "Analyze again").replace("<button", `<button aria-label="Analyze again"`))}${docker}${sites}${volumes}${logs}
2679
+ ${notes.length ? `<div class="body faint">${notes.map(esc).join("<br>")}</div>` : `<div style="height:8px"></div>`}</div>`;
2680
+ }
2681
+
2203
2682
  function historyView(n) {
2204
2683
  const st = S.history[n] ?? { loading: true };
2205
2684
  if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
@@ -2273,8 +2752,8 @@ function render() {
2273
2752
 
2274
2753
  document.addEventListener("keydown", (e) => {
2275
2754
  if (e.key === "Escape" && menuEl) return closeMenu();
2276
- // 1-7: the open server's sections.
2277
- const sec = SECTIONS[Number(e.key) - 1];
2755
+ // 1-9: the open server's sections.
2756
+ const sec = S.sel && sections(S.sel)[Number(e.key) - 1];
2278
2757
  if (sec && S.sel && !e.metaKey && !e.ctrlKey && !e.altKey && !$("#dlg").open && !e.target.closest?.("input, textarea, select")) return go(S.sel, sec[0]);
2279
2758
  if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
2280
2759
  S.job = null;
@@ -2289,6 +2768,18 @@ $("#filter").addEventListener("input", (e) => {
2289
2768
  $("#add-server").onclick = () => addServer();
2290
2769
  // Servers go up and down: check again each minute while the page is visible.
2291
2770
  setInterval(() => document.hidden || loadReach(), 60_000);
2771
+ // Sites answer or not: check their domains again each minute while their tab is open.
2772
+ setInterval(() => {
2773
+ const n = S.sel;
2774
+ if (document.hidden || !n || S.tab !== "sites") return;
2775
+ ensureDns(n, S.overview[n]?.data, true);
2776
+ }, 60_000);
2777
+ // Live stats: a new sample every 5s while the tab is open, unless the last one is still running.
2778
+ setInterval(() => {
2779
+ const n = S.sel;
2780
+ if (document.hidden || !n || S.tab !== "stats" || S.stats[n]?.loading || S.overview[n]?.error) return;
2781
+ load(n, "stats", true);
2782
+ }, 5_000);
2292
2783
 
2293
2784
  (async () => {
2294
2785
  try {