landfall 0.5.4 → 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
@@ -160,7 +160,52 @@ button.more { padding-inline: 8px; letter-spacing: .05em; }
160
160
  .tile .t { color: var(--faint); font-size: var(--fs-sm); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
161
161
  .tile.add { border: 1.5px dashed var(--line-strong); background: none; box-shadow: none; color: var(--dim); display: grid; place-items: center; min-height: 68px; }
162
162
  .tile.add:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); transform: none; box-shadow: none; }
163
- .cli { margin-top: 32px; padding: 16px 18px; border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--hover); font-size: var(--fs); color: var(--dim); }
163
+ .home-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; color: var(--dim); font-size: var(--fs); margin: 0 0 20px; }
164
+ .home-sub .sep { width: 1px; height: 14px; background: var(--line-strong); }
165
+ .home-sub .spin { width: 10px; height: 10px; vertical-align: -1px; }
166
+ .home-note { font-size: var(--fs-sm); margin: 10px 0 0; }
167
+ .tile .w { font-size: var(--fs-xs); margin-top: 4px; color: var(--faint); }
168
+ .tile .w.login { color: var(--warn); } .tile .w.down { color: var(--bad); }
169
+ .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; margin: 28px 0 20px; align-items: start; }
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; }
191
+ .recent { list-style: none; margin: 0; padding: 0; }
192
+ .recent li { display: flex; align-items: center; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--line); }
193
+ .recent li:last-child { border-bottom: 0; }
194
+ .recent li > div { flex: 1; min-width: 0; }
195
+ .recent li .mono { font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196
+ .where-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
197
+ .where-cols > div { padding: 14px 18px 16px; }
198
+ .where-cols > div + div { border-left: 1px solid var(--line); }
199
+ @container (max-width: 720px) { .where-cols > div + div { border-left: 0; border-top: 1px solid var(--line); } }
200
+ .where-cols h3 { margin: 0 0 8px; font-size: var(--fs-xs); font-weight: 500; color: var(--faint); text-transform: uppercase; letter-spacing: .04em; }
201
+ .paths { margin: 0; font-size: var(--fs-sm); }
202
+ .paths dt { overflow-wrap: anywhere; color: var(--text); margin-top: 8px; }
203
+ .paths dt:first-child { margin-top: 0; }
204
+ .paths dd { margin: 1px 0 0; color: var(--dim); }
205
+ .nav-home { margin: 6px 0 2px; grid-template-columns: 14px minmax(0, 1fr); column-gap: 4px; }
206
+ .nav-home .ico { width: 14px; height: 14px; color: var(--faint); }
207
+ .nav-home.on .ico { color: var(--accent); }
208
+ .cli { margin-top: 20px; padding: 16px 18px; border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--hover); font-size: var(--fs); color: var(--dim); }
164
209
  .cli pre { margin: 10px 0 0; font: 12.5px/1.7 var(--mono); color: var(--text); white-space: pre-wrap; }
165
210
  .cli pre span { color: var(--faint); }
166
211
 
@@ -184,7 +229,7 @@ td.acts button { margin-left: 4px; }
184
229
  .scroll { overflow-x: auto; }
185
230
  .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); margin-right: 7px; vertical-align: 1px; }
186
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); }
187
- .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; }
188
233
  .badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
189
234
  .badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
190
235
  .badge.bad { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
@@ -307,7 +352,6 @@ body.dragging::after {
307
352
  <header>
308
353
  <a class="brand" href="#/"><svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="4" fill="var(--accent)"/><path d="M4 5h8M4 8h8M4 11h5" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>landfall <small id="version"></small></a>
309
354
  <div class="project" id="project"></div>
310
- <button type="button" class="sm ghost" id="app-settings" title="landfall's defaults for every server (lf config)">Defaults</button>
311
355
  </header>
312
356
  <!-- The filter stays put across renders, so typing keeps its focus. -->
313
357
  <nav class="servers" id="nav">
@@ -345,11 +389,19 @@ const S = {
345
389
  tab: "sites",
346
390
  overview: {}, // name -> {data} | {error} | {loading}
347
391
  firewall: {},
392
+ system: {}, // name -> {data: lf system --json}
348
393
  history: {},
349
394
  updates: {},
350
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
351
399
  settings: {}, // name -> {data: lf server config --json + dns of the main domain}
352
- 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}
353
405
  open: new Set(), // expanded site rows
354
406
  filter: "", // server list filter
355
407
  reach: {}, // name -> {state: ok|login|down, detail, ms} from /api/reach
@@ -505,10 +557,10 @@ window.addEventListener("hashchange", route);
505
557
 
506
558
  // ---- loading -------------------------------------------------------------
507
559
 
508
- const STORE = { sites: "overview", tunnels: "overview", traefik: "settings", facts: "facts", updates: "updates", firewall: "firewall", history: "history", settings: "settings" };
509
- const URLS = { sites: "", tunnels: "", traefik: "/config", facts: "/facts", updates: "/updates", firewall: "/firewall", history: "/history", settings: "/config" };
510
- // A server's sections, in the list and as tabs on phones; keys 1-7 switch between them.
511
- 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"]];
512
564
 
513
565
  // The overview and facts drive the header, so every tab needs them.
514
566
  const needs = (tab) => (STORE[tab] === "overview" ? ["sites", "facts"] : ["sites", "facts", tab]);
@@ -518,14 +570,16 @@ function load(name, tab, force = false) {
518
570
  if (store[name] && !force) continue;
519
571
  // Keep what is shown while it reloads; views show a spinner only when they have nothing yet.
520
572
  store[name] = { ...store[name], loading: true, error: undefined };
521
- http(`/api/servers/${encodeURIComponent(name)}${URLS[t]}`).then(
522
- (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()),
523
575
  (e) => ((store[name] = { error: e.message }), render()),
524
576
  );
525
577
  }
526
578
  }
527
579
  function invalidate(name) {
528
580
  delete S.dns[name];
581
+ delete S.errs[name];
582
+ delete S.certs[name];
529
583
  // What is on screen reloads in place; other tabs reload when opened.
530
584
  const shown = new Set(S.sel === name ? needs(S.tab).map((t) => STORE[t]) : []);
531
585
  for (const k of Object.values(STORE)) if (!shown.has(k)) delete S[k][name];
@@ -1320,10 +1374,11 @@ function authDialog(n, m) {
1320
1374
  });
1321
1375
  }
1322
1376
 
1323
- function ensureDns(n, o) {
1324
- 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;
1325
1379
  const domains = [...new Set(o.sites.flatMap((x) => x.domains))];
1326
- S.dns[n] = { loading: true };
1380
+ // A recheck keeps the last answer on screen.
1381
+ S.dns[n] = { ...S.dns[n], loading: true };
1327
1382
  http(`/api/servers/${encodeURIComponent(n)}/dns?domains=${encodeURIComponent(domains.join(","))}`).then(
1328
1383
  (data) => ((S.dns[n] = { data }), render()),
1329
1384
  (e) => ((S.dns[n] = { error: e.message }), render()),
@@ -1335,10 +1390,12 @@ function domainChip(n, d) {
1335
1390
  const url = `https://${d}`;
1336
1391
  let dot = "", title = "checking…";
1337
1392
  if (c) {
1338
- 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}`) : "";
1339
1395
  if (!c.ips.length) (dot = "down"), (title = "DNS: does not resolve");
1340
1396
  else if (!c.ok) (dot = "warn"), (title = `DNS points at ${c.ips.join(", ")}, not this server`);
1341
- 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}`);
1342
1399
  else (dot = "down"), (title = `HTTPS: ${st ?? c.https.error}`);
1343
1400
  }
1344
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>`;
@@ -1398,7 +1455,7 @@ function renderNav() {
1398
1455
  };
1399
1456
  $("#filter").hidden = list.length < 8;
1400
1457
  $("#add-server").textContent = list.length < 8 ? "+ Add server" : "+";
1401
- $("#navlist").innerHTML = `${group("own", "Servers", own)}${group("ssh", "From ~/.ssh/config", aliases)}${group("down", "Unreachable", down)}
1458
+ $("#navlist").innerHTML = `<button type="button" class="srv nav-home ${S.sel ? "" : "on"}"${S.sel ? "" : ` aria-current="page"`} data-f="home" ${on(() => go(null))}><svg class="ico" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 2.5h4.5v4.5h-4.5zM9 2.5h4.5v4.5h-4.5zM2.5 9h4.5v4.5h-4.5zM9 9h4.5v4.5h-4.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg><div class="n">Overview</div></button>${group("own", "Servers", own)}${group("ssh", "From ~/.ssh/config", aliases)}${group("down", "Unreachable", down)}
1402
1459
  ${list.length ? "" : `<div class="nav-none">No servers yet.</div>`}`;
1403
1460
  $("#navfoot").hidden = !list.length;
1404
1461
  $("#navfoot").innerHTML = reachSummary(list);
@@ -1439,6 +1496,19 @@ function toggleGroup(key) {
1439
1496
  }
1440
1497
 
1441
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
+
1442
1512
  function sectionButtons(n, f) {
1443
1513
  const o = S.overview[n]?.data;
1444
1514
  const tunnels = openTunnels(n).length;
@@ -1447,7 +1517,7 @@ function sectionButtons(n, f) {
1447
1517
  tunnels: tunnels ? ` <span class="badge ok">${tunnels} open</span>` : "",
1448
1518
  updates: updatesBadge(n),
1449
1519
  };
1450
- 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("");
1451
1521
  }
1452
1522
 
1453
1523
  function renderHeader() {
@@ -1479,10 +1549,10 @@ const statusDot = (status) => {
1479
1549
  };
1480
1550
 
1481
1551
  const size = (b) => {
1482
- const u = ["B", "K", "M", "G", "T"];
1552
+ const u = ["B", "KB", "MB", "GB", "TB"];
1483
1553
  let i = 0;
1484
1554
  while (b >= 1024 && i < u.length - 1) (b /= 1024), i++;
1485
- 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]}`;
1486
1556
  };
1487
1557
  const uptime = (s) => {
1488
1558
  const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
@@ -1507,9 +1577,11 @@ function factsLine(n) {
1507
1577
  }
1508
1578
 
1509
1579
  function factsNote(n) {
1510
- 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.
1511
1583
  if (!sup?.note) return "";
1512
- 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>`;
1513
1585
  }
1514
1586
 
1515
1587
  function renderMain() {
@@ -1522,6 +1594,10 @@ function renderMain() {
1522
1594
  <p>It is not in landfall's list or in ~/.ssh/config (anymore).</p>
1523
1595
  <p>${btn("All servers", () => go(null), "primary")} ${btn("Add a server…", addServer)}</p></div></div>`);
1524
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);
1525
1601
  const ov = S.overview[n] ?? { loading: true };
1526
1602
  const o = ov.data;
1527
1603
  const traefik = o
@@ -1531,17 +1607,20 @@ function renderMain() {
1531
1607
  : "";
1532
1608
  const sub = [esc(s?.target ?? ""), s && !s.registered ? "from ~/.ssh/config" : "", traefik].filter(Boolean).join(" · ");
1533
1609
  // Reloading with something already on screen: keep it, show a small spinner.
1534
- 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);
1535
1612
  main.innerHTML = `
1536
1613
  <div class="head">
1537
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>
1538
1615
  <div class="actions">
1539
- ${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") : ""}
1540
1617
  ${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
1541
1618
  ${btn("↻", () => (load(n, S.tab, true), render()), "", "Reload", "reload").replace("<button", `<button aria-label="Reload"`)}
1542
1619
  ${menuBtn([
1543
1620
  { label: "Server info", hint: "lf server info", run: () => run(["server", "info", n]) },
1544
- { 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,
1545
1624
  s?.isDefault ? null : { label: "Make default", title: "Commands without -s use it", run: () => makeDefault(n) },
1546
1625
  s?.registered ? "-" : null,
1547
1626
  s?.registered ? { label: `Forget ${n}…`, cls: "danger", hint: "lf server rm", run: () => forget(n) } : null,
@@ -1560,30 +1639,70 @@ function renderMain() {
1560
1639
  } else if (!o) view.innerHTML = "";
1561
1640
  else if (S.tab === "sites") view.innerHTML = sitesView(n, o);
1562
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);
1563
1644
  else if (S.tab === "traefik") view.innerHTML = traefikView(n);
1564
1645
  else if (S.tab === "updates") view.innerHTML = updatesView(n);
1565
1646
  else if (S.tab === "firewall") view.innerHTML = firewallView(n);
1647
+ else if (S.tab === "system") view.innerHTML = systemView(n);
1566
1648
  else if (S.tab === "settings") view.innerHTML = settingsView(n);
1567
1649
  else view.innerHTML = historyView(n);
1568
1650
  }
1569
1651
 
1570
- /** No server open: the servers to pick from, and how the same works in a terminal. */
1652
+ /** No server open: the servers, recent deploys, landfall's defaults and every place it writes to. */
1571
1653
  function homeView() {
1572
- const list = S.state.servers;
1654
+ const st = S.state;
1655
+ const list = st.servers;
1573
1656
  const own = list.filter((s) => s.registered).sort((a, b) => reachRank(a.name) - reachRank(b.name));
1657
+ const why = (n) => {
1658
+ const r = S.reach[n];
1659
+ return r && r.state !== "ok" ? `<div class="w ${r.state}">${REACH_DOT[r.state][1]}</div>` : "";
1660
+ };
1574
1661
  const tile = (s) => `<button type="button" class="tile" ${on(() => go(s.name, "sites"))}>
1575
1662
  <div class="n">${reachDot(s.name)}${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
1576
- <div class="t">${esc(s.target)}</div></button>`;
1577
- const def = list.find((s) => s.isDefault)?.name;
1663
+ <div class="t">${esc(s.target)}</div>${why(s.name)}</button>`;
1664
+ const d = st.defaults;
1665
+ const def = d.default;
1666
+ const count = { ok: 0, login: 0, down: 0 };
1667
+ for (const s of list) if (S.reach[s.name]) count[S.reach[s.name].state]++;
1668
+ const stat = (k, word) => (count[k] ? `<span><span class="dot ${REACH_DOT[k][0]}"></span>${count[k]} ${word}</span>` : "");
1669
+ const status = S.reaching ? `<span><span class="spin"></span> checking ssh login…</span>` : S.reachedAt ? `${stat("ok", "reachable")}${stat("login", "login refused")}${stat("down", "unreachable")}` : "";
1670
+ const recent = (st.projects ?? []).slice(0, 6);
1671
+ const recentCard = `<div class="card"><h2>Recent deploys ${recent.length ? `<span class="badge">${(st.projects ?? []).length}</span>` : ""}
1672
+ <span class="actions">${btn("Deploy folder…", () => folderDialog(st.cwd), "sm", "Pick a folder, a recent project, or drop one here")}</span></h2>
1673
+ ${recent.length
1674
+ ? `<ul class="recent">${recent
1675
+ .map((r) => `<li><div><b>${esc(r.name)}</b> <span class="dim">→ ${esc(r.server)}${r.domains[0] ? ` · ${esc(r.domains[0])}` : " · tunnel only"}</span>
1676
+ <div class="faint mono" title="${esc(r.path)}">${esc(tilde(r.path))} · ${ago(r.at)}</div></div>
1677
+ ${btn("Deploy again", () => browse(r.path).then((x) => deployDialog(x.detected), (err) => toast(err.message)), "sm", `lf deploy from ${r.path}`)}</li>`)
1678
+ .join("")}</ul>`
1679
+ : `<div class="body dim">Nothing deployed from this machine yet. Folders you deploy show up here to deploy again.</div>`}</div>`;
1680
+ const row = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
1681
+ const defaultsCard = `<div class="card"><h2>Defaults <span class="sub">for every server, unless it sets its own</span>
1682
+ <span class="actions">${btn("Edit…", defaultsDialog, "sm", "lf config")}</span></h2>
1683
+ <div class="body"><dl class="kv">
1684
+ ${row("Default server", def ? `<a href="#/${encodeURIComponent(def)}">${esc(def)}</a>` : `<span class="dim">none: commands need -s</span>`)}
1685
+ ${row("New sites", d.tunnelOnly ? "no domain, tunnel only" : "get a domain")}
1686
+ ${row("Let's Encrypt e-mail", d.email ? `<span class="mono">${esc(d.email)}</span>` : `<span class="dim">none</span>`)}
1687
+ ${row("Releases kept", esc(String(d.keep)))}
1688
+ ${row("Saved in", `<span class="mono">${esc(d.configFile)}</span>`)}
1689
+ </dl></div></div>`;
1690
+ const paths = (l) => `<dl class="paths">${l.map((x) => `<dt class="mono">${esc(x.path)}</dt><dd>${esc(x.what)}</dd>`).join("")}</dl>`;
1691
+ const whereCard = `<div class="card"><h2>Where landfall puts things <span class="sub">nothing it installs is hidden</span></h2>
1692
+ <div class="where-cols"><div><h3>On this machine</h3>${paths(d.locations.local)}</div><div><h3>On each server</h3>${paths(d.locations.server)}</div></div></div>`;
1578
1693
  return `<div class="home">
1579
- <h1>${own.length ? "Your servers" : "Pick a server"}</h1>
1580
- <p>The dot shows whether landfall's ssh login works (hover for why not); nothing else connects until you open one. Servers are ssh targets: any ~/.ssh/config alias works${own.length < list.length ? ` (${list.length - own.length} in the list on the left)` : ""}.</p>
1694
+ <h1>Overview</h1>
1695
+ <div class="home-sub">${plural(own.length, "server")}${own.length < list.length ? ` <span class="faint">+ ${list.length - own.length} from ~/.ssh/config</span>` : ""}${status ? `<span class="sep"></span>${status}` : ""}</div>
1581
1696
  <div class="grid">${own.map(tile).join("")}<button type="button" class="tile add" ${on(addServer)}>+ Add server</button></div>
1697
+ <p class="faint home-note">The dot shows whether landfall's ssh login works (hover for why not); nothing else connects until you open a server. Any ~/.ssh/config alias works as a server.</p>
1698
+ <div class="cols">${recentCard}${defaultsCard}</div>
1699
+ ${whereCard}
1582
1700
  <div class="cli">Everything here runs an <span class="mono">lf</span> command, shown above its output, so you can do the same in a terminal:
1583
1701
  <pre>lf deploy ./dist -d blog <span># a folder, Dockerfile project or image, with HTTPS</span>
1584
1702
  lf ls${def ? "" : " -s <server>"} <span># sites, and what else Traefik routes</span>
1585
1703
  lf logs blog -f <span># follow a site's logs</span>
1586
1704
  lf undo <span># revert the last change</span>
1705
+ lf config <span># these defaults, and where landfall puts things</span>
1587
1706
  lf help <span># every command</span></pre></div>
1588
1707
  </div>`;
1589
1708
  }
@@ -1705,7 +1824,7 @@ function sitesView(n, o) {
1705
1824
  .join("");
1706
1825
  return `<div class="card scroll"><table>
1707
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>
1708
- <tbody>${rows}</tbody></table></div>${othersCard(n, o)}`;
1827
+ <tbody>${rows}</tbody></table></div>${errorsCard(n)}${othersCard(n, o)}`;
1709
1828
  }
1710
1829
 
1711
1830
  /** Where a site lives on the server: docker compose works in its current folder. */
@@ -1781,7 +1900,9 @@ function updatesView(n) {
1781
1900
  danger: flags.includes("--reboot"),
1782
1901
  onOk: () => run(["update", "--packages", ...flags, ...s, "-y"], { server: n }),
1783
1902
  });
1784
- 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 ? "" : [
1785
1906
  btn("Refresh lists", () => refreshUpdates(n), "sm", "Fetch the newest package lists first (apt update)"),
1786
1907
  sec.length && p.mgr !== "apk" ? btn(`Security only (${sec.length})`, () => upgrade(["--security"], "security updates", sec.length), "sm") : "",
1787
1908
  p.updates.length ? btn("Upgrade all", () => upgrade([], "all packages", p.updates.length), "sm primary") : "",
@@ -1814,7 +1935,7 @@ function updatesView(n) {
1814
1935
  ? `<span class="badge">${p.updates.length}</span>${sec.length ? ` <span class="badge warn">${sec.length} security</span>` : ""}`
1815
1936
  : `<span class="badge ok">up to date</span>`;
1816
1937
  return `
1817
- <div class="card">
1938
+ ${p.mgr === "none" ? "" : `<div class="card">
1818
1939
  <h2>Packages ${head}${p.reboot ? ` <span class="badge warn">reboot required</span>` : ""}
1819
1940
  <span class="dim hide-sm" style="font-weight:400">${esc(p.mgr)}${p.refreshed ? "" : " · lists not refreshed"}</span>
1820
1941
  <span class="actions">${pkgActs}</span></h2>
@@ -1822,8 +1943,9 @@ function updatesView(n) {
1822
1943
  <thead><tr><th>Package</th><th class="hide-sm">Installed</th><th>New</th><th></th></tr></thead>
1823
1944
  <tbody>${pkgRows}${p.updates.length > shown.length ? `<tr><td colspan="4" class="dim">… ${p.updates.length - shown.length} more</td></tr>` : ""}</tbody>
1824
1945
  </table></div>` : ""}
1825
- </div>
1826
- <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">
1827
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>
1828
1950
  <span class="actions">${e.composeUpdate ? btn("Update compose", () => run(["update", "--compose", ...s, "-y"], { server: n }), "sm primary") : ""}</span></h2>
1829
1951
  ${[e.dockerNote, e.composeNote].filter(Boolean).map((t) => `<p class="dim">${esc(t)}</p>`).join("")}
@@ -1835,7 +1957,7 @@ function updatesView(n) {
1835
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>
1836
1958
  <tbody>${imgRows || `<tr><td colspan="5" class="dim">no containers</td></tr>`}</tbody>
1837
1959
  </table></div>
1838
- </div>`;
1960
+ </div>`}`;
1839
1961
  }
1840
1962
 
1841
1963
  // ---- firewall --------------------------------------------------------------
@@ -1940,6 +2062,175 @@ function editRule(n, num, r) {
1940
2062
  });
1941
2063
  }
1942
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
+
1943
2234
  // ---- history ---------------------------------------------------------------
1944
2235
 
1945
2236
  /** Everything an SSH tunnel reaches on server n, and the tunnels open now. */
@@ -2139,6 +2430,245 @@ function settingsView(n) {
2139
2430
  </div>`;
2140
2431
  }
2141
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
+
2142
2672
  function historyView(n) {
2143
2673
  const st = S.history[n] ?? { loading: true };
2144
2674
  if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
@@ -2212,8 +2742,8 @@ function render() {
2212
2742
 
2213
2743
  document.addEventListener("keydown", (e) => {
2214
2744
  if (e.key === "Escape" && menuEl) return closeMenu();
2215
- // 1-7: the open server's sections.
2216
- 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];
2217
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]);
2218
2748
  if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
2219
2749
  S.job = null;
@@ -2228,7 +2758,18 @@ $("#filter").addEventListener("input", (e) => {
2228
2758
  $("#add-server").onclick = () => addServer();
2229
2759
  // Servers go up and down: check again each minute while the page is visible.
2230
2760
  setInterval(() => document.hidden || loadReach(), 60_000);
2231
- $("#app-settings").onclick = () => defaultsDialog();
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);
2232
2773
 
2233
2774
  (async () => {
2234
2775
  try {