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