landfall 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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];
@@ -1344,10 +1374,11 @@ function authDialog(n, m) {
1344
1374
  });
1345
1375
  }
1346
1376
 
1347
- function ensureDns(n, o) {
1348
- if (S.dns[n] || !o?.sites.length) return;
1377
+ function ensureDns(n, o, force = false) {
1378
+ if ((S.dns[n] && !force) || S.dns[n]?.loading || !o?.sites.length) return;
1349
1379
  const domains = [...new Set(o.sites.flatMap((x) => x.domains))];
1350
- S.dns[n] = { loading: true };
1380
+ // A recheck keeps the last answer on screen.
1381
+ S.dns[n] = { ...S.dns[n], loading: true };
1351
1382
  http(`/api/servers/${encodeURIComponent(n)}/dns?domains=${encodeURIComponent(domains.join(","))}`).then(
1352
1383
  (data) => ((S.dns[n] = { data }), render()),
1353
1384
  (e) => ((S.dns[n] = { error: e.message }), render()),
@@ -1359,10 +1390,12 @@ function domainChip(n, d) {
1359
1390
  const url = `https://${d}`;
1360
1391
  let dot = "", title = "checking…";
1361
1392
  if (c) {
1362
- const st = c.https.status;
1393
+ const { status: st, ms, tls } = c.https;
1394
+ const cert = tls ? (tls.valid ? ` · certificate ${plural(tls.days, "day")} left` : ` · certificate: ${tls.error}`) : "";
1363
1395
  if (!c.ips.length) (dot = "down"), (title = "DNS: does not resolve");
1364
1396
  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)" : ""}`);
1397
+ else if (st && st < 500 && tls && (!tls.valid || tls.days < 7)) (dot = "warn"), (title = `HTTPS ${st}${cert}`);
1398
+ else if (st && st < 500) (dot = "up"), (title = `HTTPS ${st}${st === 401 ? " (login required)" : ""} in ${ms} ms${cert}`);
1366
1399
  else (dot = "down"), (title = `HTTPS: ${st ?? c.https.error}`);
1367
1400
  }
1368
1401
  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 +1496,19 @@ function toggleGroup(key) {
1463
1496
  }
1464
1497
 
1465
1498
  /** The open server's sections as buttons, each with its count. */
1499
+ /** What the server can do (`lf server info --json` can); null until its facts are in, then everything shows. */
1500
+ const can = (n) => S.facts[n]?.data?.can ?? null;
1501
+ /** When each section is worth a tab: the same conditions the CLI checks before doing it. */
1502
+ const SECTION_IF = {
1503
+ sites: (k) => k.docker || k.setup,
1504
+ tunnels: (k) => k.docker,
1505
+ traffic: (k) => k.docker,
1506
+ traefik: (k) => k.docker,
1507
+ updates: (k) => k.packages || k.docker,
1508
+ firewall: (k) => k.firewall,
1509
+ };
1510
+ const sections = (n) => SECTIONS.filter(([t]) => !can(n) || (SECTION_IF[t]?.(can(n)) ?? true));
1511
+
1466
1512
  function sectionButtons(n, f) {
1467
1513
  const o = S.overview[n]?.data;
1468
1514
  const tunnels = openTunnels(n).length;
@@ -1471,7 +1517,7 @@ function sectionButtons(n, f) {
1471
1517
  tunnels: tunnels ? ` <span class="badge ok">${tunnels} open</span>` : "",
1472
1518
  updates: updatesBadge(n),
1473
1519
  };
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("");
1520
+ 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
1521
  }
1476
1522
 
1477
1523
  function renderHeader() {
@@ -1503,10 +1549,10 @@ const statusDot = (status) => {
1503
1549
  };
1504
1550
 
1505
1551
  const size = (b) => {
1506
- const u = ["B", "K", "M", "G", "T"];
1552
+ const u = ["B", "KB", "MB", "GB", "TB"];
1507
1553
  let i = 0;
1508
1554
  while (b >= 1024 && i < u.length - 1) (b /= 1024), i++;
1509
- return `${b < 10 && i ? b.toFixed(1) : Math.round(b)}${u[i]}`;
1555
+ return `${b < 10 && i ? b.toFixed(1) : Math.round(b)} ${u[i]}`;
1510
1556
  };
1511
1557
  const uptime = (s) => {
1512
1558
  const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
@@ -1531,9 +1577,11 @@ function factsLine(n) {
1531
1577
  }
1532
1578
 
1533
1579
  function factsNote(n) {
1534
- const sup = S.facts[n]?.data?.support;
1580
+ const f = S.facts[n]?.data;
1581
+ const sup = f?.support;
1582
+ // A system that installs its own way (DSM, SteamOS) is not a problem, just a different kind of server.
1535
1583
  if (!sup?.note) return "";
1536
- return `<div class="note ${sup.level === "ok" ? "" : "bad"}">${esc(sup.note)}</div>`;
1584
+ 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
1585
  }
1538
1586
 
1539
1587
  function renderMain() {
@@ -1546,6 +1594,10 @@ function renderMain() {
1546
1594
  <p>It is not in landfall's list or in ~/.ssh/config (anymore).</p>
1547
1595
  <p>${btn("All servers", () => go(null), "primary")} ${btn("Add a server…", addServer)}</p></div></div>`);
1548
1596
  }
1597
+ // A section this server has no use for (no docker, no ufw…): open the first one it has.
1598
+ const shown = sections(n);
1599
+ if (!shown.some(([t]) => t === S.tab)) return void location.replace(`#/${encodeURIComponent(n)}/${shown[0][0]}`);
1600
+ const k = can(n);
1549
1601
  const ov = S.overview[n] ?? { loading: true };
1550
1602
  const o = ov.data;
1551
1603
  const traefik = o
@@ -1555,17 +1607,20 @@ function renderMain() {
1555
1607
  : "";
1556
1608
  const sub = [esc(s?.target ?? ""), s && !s.registered ? "from ~/.ssh/config" : "", traefik].filter(Boolean).join(" · ");
1557
1609
  // 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);
1610
+ // Stats reload every few seconds and say so themselves.
1611
+ const reloading = needs(S.tab).some((t) => t !== "stats" && S[STORE[t]][n]?.loading && S[STORE[t]][n]?.data);
1559
1612
  main.innerHTML = `
1560
1613
  <div class="head">
1561
1614
  <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
1615
  <div class="actions">
1563
- ${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
1616
+ ${!k || k.setup ? btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying") : ""}
1564
1617
  ${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
1565
1618
  ${btn("↻", () => (load(n, S.tab, true), render()), "", "Reload", "reload").replace("<button", `<button aria-label="Reload"`)}
1566
1619
  ${menuBtn([
1567
1620
  { 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)]) },
1621
+ !k || k.docker ? { label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) } : null,
1622
+ !k || k.admin ? "-" : null,
1623
+ !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
1624
  s?.isDefault ? null : { label: "Make default", title: "Commands without -s use it", run: () => makeDefault(n) },
1570
1625
  s?.registered ? "-" : null,
1571
1626
  s?.registered ? { label: `Forget ${n}…`, cls: "danger", hint: "lf server rm", run: () => forget(n) } : null,
@@ -1584,9 +1639,12 @@ function renderMain() {
1584
1639
  } else if (!o) view.innerHTML = "";
1585
1640
  else if (S.tab === "sites") view.innerHTML = sitesView(n, o);
1586
1641
  else if (S.tab === "tunnels") view.innerHTML = tunnelsView(n, o);
1642
+ else if (S.tab === "stats") view.innerHTML = statsView(n);
1643
+ else if (S.tab === "traffic") view.innerHTML = trafficView(n);
1587
1644
  else if (S.tab === "traefik") view.innerHTML = traefikView(n);
1588
1645
  else if (S.tab === "updates") view.innerHTML = updatesView(n);
1589
1646
  else if (S.tab === "firewall") view.innerHTML = firewallView(n);
1647
+ else if (S.tab === "system") view.innerHTML = systemView(n);
1590
1648
  else if (S.tab === "settings") view.innerHTML = settingsView(n);
1591
1649
  else view.innerHTML = historyView(n);
1592
1650
  }
@@ -1766,7 +1824,7 @@ function sitesView(n, o) {
1766
1824
  .join("");
1767
1825
  return `<div class="card scroll"><table>
1768
1826
  <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)}`;
1827
+ <tbody>${rows}</tbody></table></div>${errorsCard(n)}${othersCard(n, o)}`;
1770
1828
  }
1771
1829
 
1772
1830
  /** Where a site lives on the server: docker compose works in its current folder. */
@@ -1842,7 +1900,9 @@ function updatesView(n) {
1842
1900
  danger: flags.includes("--reboot"),
1843
1901
  onOk: () => run(["update", "--packages", ...flags, ...s, "-y"], { server: n }),
1844
1902
  });
1845
- const pkgActs = [
1903
+ // Package upgrades need a package manager and root (the CLI's can.packages).
1904
+ const pkgOk = p.mgr !== "none" && (can(n)?.packages ?? true);
1905
+ const pkgActs = !pkgOk ? "" : [
1846
1906
  btn("Refresh lists", () => refreshUpdates(n), "sm", "Fetch the newest package lists first (apt update)"),
1847
1907
  sec.length && p.mgr !== "apk" ? btn(`Security only (${sec.length})`, () => upgrade(["--security"], "security updates", sec.length), "sm") : "",
1848
1908
  p.updates.length ? btn("Upgrade all", () => upgrade([], "all packages", p.updates.length), "sm primary") : "",
@@ -1875,7 +1935,7 @@ function updatesView(n) {
1875
1935
  ? `<span class="badge">${p.updates.length}</span>${sec.length ? ` <span class="badge warn">${sec.length} security</span>` : ""}`
1876
1936
  : `<span class="badge ok">up to date</span>`;
1877
1937
  return `
1878
- <div class="card">
1938
+ ${p.mgr === "none" ? "" : `<div class="card">
1879
1939
  <h2>Packages ${head}${p.reboot ? ` <span class="badge warn">reboot required</span>` : ""}
1880
1940
  <span class="dim hide-sm" style="font-weight:400">${esc(p.mgr)}${p.refreshed ? "" : " · lists not refreshed"}</span>
1881
1941
  <span class="actions">${pkgActs}</span></h2>
@@ -1883,8 +1943,9 @@ function updatesView(n) {
1883
1943
  <thead><tr><th>Package</th><th class="hide-sm">Installed</th><th>New</th><th></th></tr></thead>
1884
1944
  <tbody>${pkgRows}${p.updates.length > shown.length ? `<tr><td colspan="4" class="dim">… ${p.updates.length - shown.length} more</td></tr>` : ""}</tbody>
1885
1945
  </table></div>` : ""}
1886
- </div>
1887
- <div class="card">
1946
+ ${pkgOk ? "" : `<p class="dim">${esc(S.facts[n]?.data?.why?.packages ?? "")}</p>`}
1947
+ </div>`}
1948
+ ${!e.docker && can(n) && !can(n).docker ? "" : `<div class="card">
1888
1949
  <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
1950
  <span class="actions">${e.composeUpdate ? btn("Update compose", () => run(["update", "--compose", ...s, "-y"], { server: n }), "sm primary") : ""}</span></h2>
1890
1951
  ${[e.dockerNote, e.composeNote].filter(Boolean).map((t) => `<p class="dim">${esc(t)}</p>`).join("")}
@@ -1896,7 +1957,7 @@ function updatesView(n) {
1896
1957
  <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
1958
  <tbody>${imgRows || `<tr><td colspan="5" class="dim">no containers</td></tr>`}</tbody>
1898
1959
  </table></div>
1899
- </div>`;
1960
+ </div>`}`;
1900
1961
  }
1901
1962
 
1902
1963
  // ---- firewall --------------------------------------------------------------
@@ -2001,6 +2062,175 @@ function editRule(n, num, r) {
2001
2062
  });
2002
2063
  }
2003
2064
 
2065
+ // ---- system ----------------------------------------------------------------
2066
+
2067
+ const SVC_FAILED = (s) => s.active === "failed" || s.active === "crashed";
2068
+ const SVC_UP = (s) => s.active === "active" || s.active === "started";
2069
+ // What keeps the server and its sites running: shown first, the rest behind "all".
2070
+ const KEY_SVCS = ["docker", "containerd", "ssh", "sshd", "fail2ban", "ufw", "unattended-upgrades", "cron", "crond", "systemd-timesyncd", "chronyd", "chrony"];
2071
+ const svcName = (u) => u.replace(/\.service$/, "");
2072
+
2073
+ function systemView(n) {
2074
+ const st = S.system[n] ?? { loading: true };
2075
+ if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading ${esc(n)}…</div>`;
2076
+ if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
2077
+ const i = st.data;
2078
+ const s = sv(n);
2079
+ const go_ = (argv) => run([...argv, ...s], { server: n });
2080
+ const rows = (pairs) => `<dl class="kv">${pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>`;
2081
+
2082
+ const swapTotal = i.swap.reduce((t, x) => t + x.size, 0);
2083
+ const d = i.docker;
2084
+ // Change buttons only where the CLI would do it: as admin, and not on a system that keeps it itself.
2085
+ const adm = i.root, set = adm && !i.managed;
2086
+ const may = (ok, html) => (ok ? html : "");
2087
+ const basics = `<div class="card"><h2>Basics</h2><div class="body">${rows([
2088
+ ["Host name", `<span class="mono">${esc(i.hostname)}</span> ${may(set, btn("Change", () => renameHost(n, i.hostname), "ghost sm"))}`],
2089
+ ["Time zone", `${esc(i.timezone)} <span class="faint">· ${esc(i.time)}</span> ${may(set, btn("Change", () => changeTz(n, i.timezone), "ghost sm"))}`],
2090
+ ["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>`],
2091
+ ["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>`}
2092
+ ${!set ? "" : i.swap.some((x) => x.file === "/swapfile")
2093
+ ? 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")
2094
+ : swapTotal ? "" : btn("Add…", () => addSwapDlg(n, i), "ghost sm")}`],
2095
+ ["Docker logs", !d ? `<span class="faint">no docker</span>`
2096
+ : `${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")}`],
2097
+ ])}${d?.daemonError ? `<div class="note bad" style="margin:12px 0 0">${esc(d.daemonError)}</div>` : ""}
2098
+ ${i.managed ? `<p class="faint" style="margin:12px 0 0">${esc(i.managed)}.</p>` : ""}
2099
+ ${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>`;
2100
+
2101
+ let services = "";
2102
+ if (i.init !== "other") {
2103
+ const all = S.allSvcs?.[n];
2104
+ const shown = i.services
2105
+ .filter((x) => all || SVC_FAILED(x) || KEY_SVCS.includes(svcName(x.unit)) || (i.init === "openrc" && SVC_UP(x)))
2106
+ .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));
2107
+ const nFailed = i.services.filter(SVC_FAILED).length;
2108
+ const row = (x) => {
2109
+ const u = svcName(x.unit);
2110
+ const ssh = /^sshd?$/.test(u), core = /^(docker|containerd)$/.test(u);
2111
+ const stop = () => confirmDlg({
2112
+ title: `Stop ${u} on ${n}?`,
2113
+ 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.",
2114
+ ok: "Stop it", danger: true, onOk: () => go_(["services", "stop", u, "-y", ...(ssh ? ["--force"] : [])]),
2115
+ });
2116
+ const restart = () => core
2117
+ ? confirmDlg({ title: `Restart ${u} on ${n}?`, text: "Every site goes down for a moment.", ok: "Restart", danger: true, onOk: () => go_(["services", "restart", u, "-y"]) })
2118
+ : go_(["services", "restart", u]);
2119
+ const cls = SVC_FAILED(x) ? "bad" : SVC_UP(x) ? "ok" : "";
2120
+ return `<tr><td class="mono">${esc(u)}</td><td><span class="badge ${cls}">${esc(x.sub || x.active)}</span></td>
2121
+ ${i.init === "systemd" ? `<td class="faint hide-sm">${esc(x.enabled ?? "")}</td>` : ""}<td class="dim hide-sm">${esc(x.description)}</td>
2122
+ <td class="acts">${btn("Logs", () => run(["services", "logs", u, "-n", "200", ...s], { server: n }), "sm")}${may(adm, menuBtn([
2123
+ { label: "Restart", run: restart },
2124
+ SVC_UP(x) ? { label: "Stop…", cls: "danger", run: stop } : { label: "Start", run: () => go_(["services", "start", u]) },
2125
+ ], `Actions for ${u}`))}</td></tr>`;
2126
+ };
2127
+ services = `<div class="card"><h2>Services ${nFailed ? `<span class="badge bad">${nFailed} failed</span>` : ""} <span class="sub">${i.init}</span>
2128
+ <span class="actions">${btn(all ? "Fewer" : `All ${i.services.length}`, () => ((S.allSvcs = { ...S.allSvcs, [n]: !all }), render()), "sm")}</span></h2>
2129
+ <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>
2130
+ <tbody>${shown.map(row).join("") || `<tr><td colspan="5" class="dim">none</td></tr>`}</tbody></table></div></div>`;
2131
+ }
2132
+
2133
+ const keyRows = i.keys.map((k) => {
2134
+ const label = k.comment || k.fingerprint.slice(7, 19);
2135
+ const rm = () => confirmDlg({
2136
+ title: `Remove ${label} from ${k.user}?`,
2137
+ text: k.mine ? "This is the key this machine logs in with: removing it can lock you out." : `${k.type} ${k.fingerprint}`,
2138
+ ok: "Remove it", danger: true,
2139
+ onOk: () => go_(["keys", "rm", k.fingerprint, "--user", k.user, "-y", ...(k.mine ? ["--force"] : [])]),
2140
+ });
2141
+ 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>
2142
+ <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>`;
2143
+ }).join("");
2144
+ 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>
2145
+ <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>
2146
+ <tbody>${keyRows || `<tr><td colspan="4" class="dim">no keys found</td></tr>`}</tbody></table></div></div>`;
2147
+
2148
+ const banned = i.jails.flatMap((j) => j.ips.map((ip) => ({ ip, jail: j.name })));
2149
+ const bans = i.fail2ban === "running"
2150
+ ? `<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>
2151
+ <div class="scroll"><table><thead><tr><th>IP</th><th>Jail</th><th><span class="sr">Actions</span></th></tr></thead>
2152
+ <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>`
2153
+ : `<div class="card"><h2>Bans</h2><div class="body dim">${i.fail2ban === "missing" ? "fail2ban is not installed." : "fail2ban is not running."}</div></div>`;
2154
+
2155
+ return basics + services + keys + bans;
2156
+ }
2157
+
2158
+ function renameHost(n, old) {
2159
+ dialog({
2160
+ title: `Rename ${n}`,
2161
+ text: "The server's own host name: what its shell prompt and logs show. Your ssh alias stays as it is.",
2162
+ fields: [{ name: "name", label: "Host name", value: old, placeholder: "web-1" }],
2163
+ ok: "Rename",
2164
+ onOk: (v) => {
2165
+ const name = v.name.trim();
2166
+ 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" };
2167
+ if (name !== old) run(["hostname", name, ...sv(n)], { server: n });
2168
+ },
2169
+ });
2170
+ }
2171
+ function changeTz(n, old) {
2172
+ dialog({
2173
+ title: `Time zone of ${n}`,
2174
+ text: "Cron jobs, logs and the clock in containers that read it follow it.",
2175
+ fields: [{ name: "tz", label: "Time zone", value: old, placeholder: "Europe/Berlin, UTC", hint: `Yours is ${Intl.DateTimeFormat().resolvedOptions().timeZone}.` }],
2176
+ ok: "Change",
2177
+ onOk: (v) => {
2178
+ const tz = v.tz.trim();
2179
+ if (!/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*$/.test(tz)) return { error: "A name like Europe/Berlin or UTC.", field: "tz" };
2180
+ if (tz !== old) run(["timezone", tz, ...sv(n)], { server: n });
2181
+ },
2182
+ });
2183
+ }
2184
+ function addSwapDlg(n, i) {
2185
+ const gb = 1024 ** 3;
2186
+ // Rule of thumb: as much as memory up to 2 GB, never more than a tenth of the free disk.
2187
+ const pick = Math.max(0.5, Math.min(i.mem > 2 * gb ? 2 : 1, Math.floor((i.diskFree / gb / 10) * 2) / 2));
2188
+ dialog({
2189
+ title: `Add swap to ${n}`,
2190
+ 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.`,
2191
+ fields: [{ name: "size", label: "Size", value: `${pick >= 1 ? pick + "G" : "512M"}`, placeholder: "1G, 512M" }],
2192
+ ok: "Add swap",
2193
+ onOk: (v) => {
2194
+ if (!/^\d+(\.\d+)?\s*[mg]$/i.test(v.size.trim())) return { error: "A size like 1G or 512M.", field: "size" };
2195
+ run(["swap", v.size.trim(), ...sv(n)], { server: n });
2196
+ },
2197
+ });
2198
+ }
2199
+ function logLimitDlg(n, d) {
2200
+ dialog({
2201
+ title: `Docker log limit on ${n}`,
2202
+ text: "Caps the log Docker keeps per container. It applies to containers created afterwards: redeploy or restart sites to apply it to them.",
2203
+ fields: [
2204
+ { name: "size", label: "Per file", value: d.maxSize ?? "10m", placeholder: "10m", hint: "k, m or g. Empty removes the limit." },
2205
+ { name: "files", label: "Files kept", value: String(d.maxFile ?? 3), placeholder: "3" },
2206
+ { name: "restart", type: "check", label: "Restart Docker now", hint: "Sites go down for a moment. Otherwise it applies on Docker's next start." },
2207
+ ],
2208
+ ok: "Save",
2209
+ onOk: (v) => {
2210
+ const sz = v.size.trim().toLowerCase();
2211
+ if (sz && !/^\d+[kmg]$/.test(sz)) return { error: "A size like 10m.", field: "size" };
2212
+ if (!/^\d+$/.test(v.files.trim())) return { error: "A number.", field: "files" };
2213
+ run(["log-limit", sz || "off", "--files", v.files.trim(), ...(v.restart ? ["--restart", "-y"] : []), ...sv(n)], { server: n });
2214
+ },
2215
+ });
2216
+ }
2217
+ function addKeyDlg(n) {
2218
+ dialog({
2219
+ title: `Add an SSH key to ${n}`,
2220
+ text: "Paste a public key (the .pub file, one line). Whoever holds its private key can log in.",
2221
+ fields: [
2222
+ { name: "key", label: "Public key", type: "textarea", value: "", placeholder: "ssh-ed25519 AAAA… name@laptop" },
2223
+ { name: "user", label: "User", value: "", placeholder: "the login user", hint: "Whose account it logs in to. Empty: the user landfall logs in as." },
2224
+ ],
2225
+ ok: "Add key",
2226
+ onOk: (v) => {
2227
+ const key = v.key.trim().split("\n")[0];
2228
+ if (!/^(\S+\s+)?(ssh-|ecdsa-|sk-)\S+ AAAA/.test(key)) return { error: "That does not look like a public key.", field: "key" };
2229
+ run(["keys", "add", key, ...(v.user.trim() ? ["--user", v.user.trim()] : []), ...sv(n)], { server: n });
2230
+ },
2231
+ });
2232
+ }
2233
+
2004
2234
  // ---- history ---------------------------------------------------------------
2005
2235
 
2006
2236
  /** Everything an SSH tunnel reaches on server n, and the tunnels open now. */
@@ -2200,6 +2430,245 @@ function settingsView(n) {
2200
2430
  </div>`;
2201
2431
  }
2202
2432
 
2433
+ // The last samples per server, for the sparklines: 5 minutes at one per 5s.
2434
+ const STATS_LOG = 60;
2435
+ function logStats(n, s) {
2436
+ const log = (S.statsLog[n] ??= []);
2437
+ log.push({ cpu: s.cpu, mem: s.mem.total ? ((s.mem.total - s.mem.available) / s.mem.total) * 100 : 0 });
2438
+ if (log.length > STATS_LOG) log.shift();
2439
+ }
2440
+ const pctOf = (used, total) => (total ? Math.round((used / total) * 100) : 0);
2441
+ /** A sparkline of 0-100 values, oldest left. */
2442
+ function spark(values) {
2443
+ if (values.length < 2) return "";
2444
+ const pts = values.map((v, i) => `${((STATS_LOG - values.length + i) / (STATS_LOG - 1)) * 100},${24 - (Math.min(100, v) / 100) * 22}`).join(" ");
2445
+ 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>`;
2446
+ }
2447
+ function meter(label, p, value, sub, line = "", hint = "") {
2448
+ const cls = p >= 90 ? "bad" : p >= 70 ? "warn" : "";
2449
+ return `<div class="meter ${cls}" title="${esc(hint)}"><div class="k"><span>${esc(label)}</span></div>
2450
+ <div class="v">${value}${line}</div>
2451
+ ${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>`}
2452
+ <div class="t">${sub}</div></div>`;
2453
+ }
2454
+ /** Live usage of one server: meters, the busiest processes and containers, disk usage on request. */
2455
+ function statsView(n) {
2456
+ const st = S.stats[n] ?? { loading: true };
2457
+ if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> measuring ${esc(n)} for a second…</div>`;
2458
+ 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>`;
2459
+ const s = st.data;
2460
+ const log = S.statsLog[n] ?? [];
2461
+ const memUsed = s.mem.total - s.mem.available;
2462
+ const swapUsed = s.mem.swapTotal - s.mem.swapFree;
2463
+ const meters = [
2464
+ 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"),
2465
+ 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"),
2466
+ s.mem.swapTotal ? meter("Swap", pctOf(swapUsed, s.mem.swapTotal), `${pctOf(swapUsed, s.mem.swapTotal)}%`, `${size(swapUsed)} of ${size(s.mem.swapTotal)}`) : "",
2467
+ ...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`)),
2468
+ ...s.net.map((x) => meter(`Network ${x.iface}`, null, `↓ ${size(x.rx)}/s`, `↑ ${size(x.tx)}/s`)),
2469
+ ].join("");
2470
+ const procs = s.procs.slice(0, 8);
2471
+ const procCard = procs.length
2472
+ ? `<div class="card"><h2>Processes <span class="sub">busiest and largest · cpu 100% = one core</span></h2><div class="scroll"><table>
2473
+ <thead><tr><th>Name</th><th class="num">CPU</th><th class="num">Memory</th><th class="num hide-sm">PID</th></tr></thead>
2474
+ <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>`
2475
+ : "";
2476
+ const up = (x) => isUp(x.status);
2477
+ const ctrCard = !s.docker
2478
+ ? `<div class="note">No access to Docker on ${esc(n)}: containers are not shown. Set up the server to fix that.</div>`
2479
+ : s.containers.length
2480
+ ? `<div class="card"><h2>Containers <span class="badge">${s.containers.length}</span></h2><div class="scroll"><table>
2481
+ <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>
2482
+ <tbody>${s.containers
2483
+ .map((x) => `<tr><td>${statusDot(x.status)}<span class="mono">${esc(x.name)}</span><div class="faint">${esc(x.image)}</div></td>
2484
+ <td class="hide-sm">${x.site ? `<b>${esc(x.site)}</b>` : `<span class="faint">${esc(x.project || "-")}</span>`}</td>
2485
+ <td class="num">${up(x) ? `${Math.round(x.cpu * 10) / 10}%` : ""}</td>
2486
+ <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>
2487
+ <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>
2488
+ <td class="num hide-sm">${x.restarts ? `<span class="badge warn">${x.restarts}</span>` : `<span class="faint">0</span>`}</td></tr>`)
2489
+ .join("")}</tbody></table></div></div>`
2490
+ : "";
2491
+ 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>`;
2492
+ return `<div class="meters">${meters}</div>${ctrCard}${procCard}${diskCard(n)}${live}`;
2493
+ }
2494
+
2495
+ // ---- traffic ---------------------------------------------------------------
2496
+
2497
+ const TRAFFIC_SPANS = [["1h", "Last hour"], ["24h", "24 hours"], ["7d", "7 days"]];
2498
+ const CLASS_COLORS = { "2xx": "var(--accent)", "3xx": "var(--faint)", "4xx": "var(--warn)", "5xx": "var(--bad)" };
2499
+ const ms = (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${v} ms`);
2500
+ const count = (v) => v.toLocaleString();
2501
+ const sv2 = (n) => (S.state.defaults.default === n ? "" : ` -s ${n}`);
2502
+
2503
+ /** One lazily fetched view per server, outside the tab stores: the sites' error card and certificates. */
2504
+ function ensure(store, n, url) {
2505
+ if (S[store][n]) return;
2506
+ S[store][n] = { loading: true };
2507
+ http(`/api/servers/${encodeURIComponent(n)}${url}`).then(
2508
+ (data) => ((S[store][n] = { data }), render()),
2509
+ (e) => ((S[store][n] = { error: e.message }), render()),
2510
+ );
2511
+ }
2512
+
2513
+ /** Requests per minute or hour, stacked by status class; hover a bar for its numbers. */
2514
+ function trafficChart(series, bucket) {
2515
+ const W = 600, H = 120;
2516
+ const tot = series.map((x) => x["2xx"] + x["3xx"] + x["4xx"] + x["5xx"]);
2517
+ const max = Math.max(1, ...tot);
2518
+ const bw = W / series.length;
2519
+ const gap = bw > 4 ? 1 : 0;
2520
+ const when = (at) => (bucket === "hour" ? new Date(`${at}:00:00Z`) : new Date(`${at}:00Z`));
2521
+ 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" }));
2522
+ const bars = series
2523
+ .map((x, i) => {
2524
+ let y = H;
2525
+ const segs = ["2xx", "3xx", "4xx", "5xx"]
2526
+ .filter((k) => x[k])
2527
+ .map((k) => {
2528
+ const h = Math.max(1, (x[k] / max) * (H - 4));
2529
+ y -= h;
2530
+ return `<rect x="${i * bw + gap / 2}" y="${y}" width="${Math.max(0.5, bw - gap)}" height="${h}" fill="${CLASS_COLORS[k]}"/>`;
2531
+ })
2532
+ .join("");
2533
+ const tip = `${label(x.at)}: ${count(tot[i])} requests${["4xx", "5xx"].filter((k) => x[k]).map((k) => `, ${count(x[k])} ${k}`).join("")}`;
2534
+ return `<g><title>${esc(tip)}</title><rect x="${i * bw}" y="0" width="${bw}" height="${H}" fill="transparent"/>${segs}</g>`;
2535
+ })
2536
+ .join("");
2537
+ 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("");
2538
+ 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>
2539
+ <div class="tticks faint">${ticks}</div>
2540
+ <div class="tlegend">${Object.entries(CLASS_COLORS).map(([k, col]) => `<span><i style="background:${col}"></i>${k}</span>`).join("")}</div></div>`;
2541
+ }
2542
+
2543
+ const errorCell = (status) => `<span class="badge ${status >= 500 ? "bad" : "warn"}">${status}</span>`;
2544
+
2545
+ function trafficView(n) {
2546
+ ensure("certs", n, "/certs");
2547
+ const st = S.traffic[n] ?? { loading: true };
2548
+ 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>`;
2549
+ if (st.loading && !st.data) return `${spans}<div class="loading"><span class="spin"></span> reading Traefik's access log…</div>`;
2550
+ if (st.error && !st.data) return `${spans}<div class="note bad">${esc(st.error)}</div>`;
2551
+ const t = st.data;
2552
+ if (!t.proxy) return `<div class="note">No Traefik on ${esc(n)}: nothing to count. Set up the server to install it.</div>`;
2553
+ if (t.format === "common") {
2554
+ return `<div class="note warn">Traefik writes its access log as plain text. ${t.own
2555
+ ? `Add <span class="mono">--accesslog.format=json</span> to its command to see traffic here.`
2556
+ : `${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)}`;
2557
+ }
2558
+ 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)}`;
2559
+ const T = t.total;
2560
+ const errs = T.classes["4xx"] + T.classes["5xx"];
2561
+ const pct = (v) => (T.requests ? Math.round((v / T.requests) * 1000) / 10 : 0);
2562
+ const meters = [
2563
+ meter("Requests", null, count(T.requests), `${plural(t.sites.length, "site")} · ${size(T.bytes)} sent`),
2564
+ meter("Server errors", null, `${count(T.classes["5xx"])}`, `${pct(T.classes["5xx"])}% · 5xx: the site failed`),
2565
+ meter("Client errors", null, `${count(T.classes["4xx"])}`, `${pct(T.classes["4xx"])}% · 4xx: not found, denied`),
2566
+ meter("Response time", null, ms(T.p50), `median · 95% within ${ms(T.p95)}`, "", "From Traefik receiving the request to the last byte sent"),
2567
+ ].join("");
2568
+ const other = t.hosts.filter((h) => !h.site);
2569
+ 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>`;
2570
+ const siteCard = `<div class="card"><h2>By site</h2><div class="scroll"><table>
2571
+ <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>
2572
+ <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>
2573
+ ${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>`;
2574
+ 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>` : "");
2575
+ 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"]]);
2576
+ 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"]]);
2577
+ 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"]]);
2578
+ 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()}` : "";
2579
+ return `${spans}<div class="meters">${meters}</div>
2580
+ <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>
2581
+ ${siteCard}<div class="cols">${errors}${paths}</div>${ips}${certsCard(n)}
2582
+ <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>`;
2583
+ }
2584
+
2585
+ /** Each site domain's certificate: days left, who issued it, why getting one failed. */
2586
+ function certsCard(n) {
2587
+ const st = S.certs[n];
2588
+ const head = `<h2>Certificates <span class="sub">lf certs</span></h2>`;
2589
+ if (!st || (st.loading && !st.data)) return `<div class="card">${head}<div class="loading"><span class="spin"></span> reading certificates…</div></div>`;
2590
+ if (st.error) return `<div class="card">${head}<div class="body"><div class="note bad" style="margin:0">${esc(st.error)}</div></div></div>`;
2591
+ const r = st.data;
2592
+ if (!r.domains.length) return "";
2593
+ 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>`;
2594
+ 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("");
2595
+ 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>
2596
+ <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>`;
2597
+ }
2598
+
2599
+ /** The sites tab: what failed in the last 24 hours, only when something did. */
2600
+ function errorsCard(n) {
2601
+ if (!can(n)?.docker) return "";
2602
+ ensure("errs", n, "/traffic?since=24h");
2603
+ const t = S.errs[n]?.data;
2604
+ if (!t || t.format !== "json") return "";
2605
+ 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);
2606
+ if (!rows.length) return "";
2607
+ const fives = t.total.classes["5xx"];
2608
+ 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>
2609
+ <thead><tr><th>Status</th><th>Site</th><th>Path</th><th class="num">Requests</th></tr></thead>
2610
+ <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>`;
2611
+ }
2612
+
2613
+ function loadDisk(n) {
2614
+ S.disk[n] = { ...S.disk[n], loading: true, error: undefined };
2615
+ render();
2616
+ http(`/api/servers/${encodeURIComponent(n)}/disk`).then(
2617
+ (data) => ((S.disk[n] = { data }), render()),
2618
+ (e) => ((S.disk[n] = { error: e.message }), render()),
2619
+ );
2620
+ }
2621
+ function pruneFlow(n) {
2622
+ http(`/api/servers/${encodeURIComponent(n)}/prune`).then((p) => {
2623
+ const imgs = p.images.filter((i) => !i.keep);
2624
+ const kept = (w) => p.images.filter((i) => i.keep === w).length;
2625
+ const most = imgs.reduce((t, i) => t + i.size, 0) + p.buildCache;
2626
+ const keep = [[kept("release"), "for rollback"], [kept("undo"), "for undo"], [kept("used"), "in use"]].filter(([k]) => k).map(([k, w]) => `${k} ${w}`).join(", ");
2627
+ dialog({
2628
+ title: `Clean up ${n}?`,
2629
+ text: `${plural(imgs.length, "unused image")} and ${size(p.buildCache)} of build cache: up to ${size(most)}.${keep ? ` Kept: ${keep}.` : ""} Volumes and containers stay.`,
2630
+ fields: kept("tagged") ? [{ name: "all", type: "check", label: `Also ${plural(kept("tagged"), "unused tagged image")}`, hint: "Pulled again when a site needs them" }] : [],
2631
+ ok: "Clean up",
2632
+ danger: true,
2633
+ onOk: (v) => run(["prune", ...sv(n), "-y", ...(v.all ? ["--all"] : [])], { server: n, after: () => (loadDisk(n), []) }),
2634
+ });
2635
+ }, (e) => toast(e.message));
2636
+ }
2637
+ function diskCard(n) {
2638
+ const st = S.disk[n];
2639
+ const head = (acts) => `<h2>Disk usage <span class="sub">images, volumes, build cache, sites and logs</span><span class="actions">${acts}</span></h2>`;
2640
+ 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>`;
2641
+ if (!st.data) {
2642
+ 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>`;
2643
+ return `<div class="card">${head(st.loading ? "" : btn("Retry", () => loadDisk(n), "sm"))}${body}</div>`;
2644
+ }
2645
+ const d = st.data;
2646
+ 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>`;
2647
+ const sub = (t) => `<h3 style="margin:16px 18px 6px;font-size:var(--fs-sm);color:var(--dim);font-weight:600">${t}</h3>`;
2648
+ const docker = d.dockerDf.length
2649
+ ? sub("Docker") + tbl([["Kind"], ["Count", "num"], ["In use", "num"], ["Size", "num"], ["Reclaimable", "num"]],
2650
+ 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(""))
2651
+ : "";
2652
+ const sites = d.sites.length
2653
+ ? sub(`Sites <span class="faint mono">${esc(S.state.paths.sites)}</span>`) + tbl([["Site"], ["Files", "num"], ["Releases", "num"]],
2654
+ 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(""))
2655
+ : "";
2656
+ const vols = d.volumes.filter((x) => x.size > 0).slice(0, 10);
2657
+ const volumes = vols.length
2658
+ ? sub("Largest volumes") + tbl([["Volume"], ["Size", "num"], ["Used by", "num"]],
2659
+ 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(""))
2660
+ : "";
2661
+ const lg = d.logs.filter((x) => x.size > 0).slice(0, 10);
2662
+ 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("")) : "";
2663
+ const notes = [
2664
+ d.journald ? `systemd journal: ${size(d.journald)}` : "",
2665
+ d.docker ? "" : "No access to Docker: images, volumes and logs are not shown.",
2666
+ d.root ? "" : "Without root or passwordless sudo, root-owned folders and container logs may be missing.",
2667
+ ].filter(Boolean);
2668
+ 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}
2669
+ ${notes.length ? `<div class="body faint">${notes.map(esc).join("<br>")}</div>` : `<div style="height:8px"></div>`}</div>`;
2670
+ }
2671
+
2203
2672
  function historyView(n) {
2204
2673
  const st = S.history[n] ?? { loading: true };
2205
2674
  if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
@@ -2273,8 +2742,8 @@ function render() {
2273
2742
 
2274
2743
  document.addEventListener("keydown", (e) => {
2275
2744
  if (e.key === "Escape" && menuEl) return closeMenu();
2276
- // 1-7: the open server's sections.
2277
- const sec = SECTIONS[Number(e.key) - 1];
2745
+ // 1-9: the open server's sections.
2746
+ const sec = S.sel && sections(S.sel)[Number(e.key) - 1];
2278
2747
  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
2748
  if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
2280
2749
  S.job = null;
@@ -2289,6 +2758,18 @@ $("#filter").addEventListener("input", (e) => {
2289
2758
  $("#add-server").onclick = () => addServer();
2290
2759
  // Servers go up and down: check again each minute while the page is visible.
2291
2760
  setInterval(() => document.hidden || loadReach(), 60_000);
2761
+ // Sites answer or not: check their domains again each minute while their tab is open.
2762
+ setInterval(() => {
2763
+ const n = S.sel;
2764
+ if (document.hidden || !n || S.tab !== "sites") return;
2765
+ ensureDns(n, S.overview[n]?.data, true);
2766
+ }, 60_000);
2767
+ // Live stats: a new sample every 5s while the tab is open, unless the last one is still running.
2768
+ setInterval(() => {
2769
+ const n = S.sel;
2770
+ if (document.hidden || !n || S.tab !== "stats" || S.stats[n]?.loading || S.overview[n]?.error) return;
2771
+ load(n, "stats", true);
2772
+ }, 5_000);
2292
2773
 
2293
2774
  (async () => {
2294
2775
  try {