@daemonitor/plugins 1.1.37 → 1.1.39

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.
@@ -1,7 +1,10 @@
1
- // Aggregate named service processes (nginx, php-fpm, db, …) from `ps` output.
2
- // Used for host processes (OSPlugin) and per-container processes (LxcPlugin via
3
- // `lxc exec … ps`). MEM (rss) is exact; CPU is `ps` %cpu, which is the average
4
- // over each process's lifetime — a rough indicator for long-running daemons.
1
+ // Aggregate named service processes (nginx, php-fpm, db, …). A Linux host's
2
+ // come from /proc (sampleProcesses), with CPU measured over the time since
3
+ // the last sample, so a graph of it moves. Everywhere else, and a container's
4
+ // (LxcPlugin via `lxc exec … ps`), from `ps` output, whose %cpu is the average
5
+ // over each process's lifetime: for a daemon up for months it never moves.
6
+ import { execSync } from "node:child_process";
7
+ import { readdirSync, readFileSync } from "node:fs";
5
8
  // The command whose output parseProcesses() expects (headerless: comm rss pcpu).
6
9
  export const PS_CMD = "ps -eo comm=,rss=,pcpu=";
7
10
  const WATCH = [
@@ -12,6 +15,106 @@ const WATCH = [
12
15
  { key: "redis", test: (c) => c.startsWith("redis-server") || c.startsWith("redis") },
13
16
  { key: "node", test: (c) => c.startsWith("node") },
14
17
  ];
18
+ /**
19
+ * Parse /proc/<pid>/stat. Its comm is in parentheses and may hold spaces or
20
+ * a ')' of its own, so the fields are counted from after the last ')': the
21
+ * first one there is field 3 (state), so utime (14) is index 11.
22
+ */
23
+ export function parseStat(pid, text) {
24
+ const open = text.indexOf("("), close = text.lastIndexOf(")");
25
+ if (open < 0 || close < open)
26
+ return null;
27
+ const f = text.slice(close + 2).trim().split(/\s+/);
28
+ const utime = Number(f[11]), stime = Number(f[12]), start = Number(f[19]), rss = Number(f[21]);
29
+ if (![utime, stime, start, rss].every(Number.isFinite))
30
+ return null;
31
+ return { pid, comm: text.slice(open + 1, close), ticks: utime + stime, rss, start };
32
+ }
33
+ /**
34
+ * Group processes by WATCH, each group's cpu as a percent of one core. With
35
+ * `prev` (each pid's ticks at the last sample, `dt` seconds ago), over that
36
+ * interval; a pid that wasn't there then started since, so all its ticks are
37
+ * the interval's. Without (the first sample), over each one's life so far,
38
+ * as ps reports it.
39
+ */
40
+ export function groupStats(now, prev, o) {
41
+ const agg = new Map();
42
+ for (const p of now) {
43
+ const w = WATCH.find((x) => x.test(p.comm));
44
+ if (!w)
45
+ continue;
46
+ const cur = agg.get(w.key) || { name: w.key, count: 0, cpu: 0, mem: 0 };
47
+ cur.count++;
48
+ cur.mem += p.rss * o.page;
49
+ if (prev && o.dt > 0) {
50
+ const before = prev.get(p.pid);
51
+ // Fewer ticks than before is a reused pid: a new process.
52
+ const used = before != null && p.ticks >= before ? p.ticks - before : p.ticks;
53
+ cur.cpu += (used / o.hz / o.dt) * 100;
54
+ }
55
+ else {
56
+ const life = o.uptime - p.start / o.hz;
57
+ if (life > 0)
58
+ cur.cpu += (p.ticks / o.hz / life) * 100;
59
+ }
60
+ agg.set(w.key, cur);
61
+ }
62
+ return Array.from(agg.values())
63
+ .map((p) => ({ ...p, cpu: Math.round(p.cpu * 10) / 10 }))
64
+ .sort((a, b) => b.mem - a.mem);
65
+ }
66
+ /** Every process's stat, or null where there's no /proc (macOS). */
67
+ export function readProcStats() {
68
+ let pids;
69
+ try {
70
+ pids = readdirSync("/proc").filter((d) => /^\d+$/.test(d));
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ const out = [];
76
+ for (const d of pids) {
77
+ try {
78
+ const s = parseStat(Number(d), readFileSync(`/proc/${d}/stat`, "utf8"));
79
+ if (s)
80
+ out.push(s);
81
+ }
82
+ catch {
83
+ // It exited between the listing and the read.
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ // The last sample's ticks per pid, and when it was taken.
89
+ let last = null;
90
+ let hz = 0, page = 0;
91
+ const conf = (name, fallback) => {
92
+ try {
93
+ return Number(execSync(`getconf ${name}`, { encoding: "utf8", timeout: 2000 }).trim()) || fallback;
94
+ }
95
+ catch {
96
+ return fallback;
97
+ }
98
+ };
99
+ /** A Linux host's named processes from /proc, cpu since the last call; null where there's no /proc. */
100
+ export function sampleProcesses() {
101
+ const stats = readProcStats();
102
+ if (!stats)
103
+ return null;
104
+ if (!hz) {
105
+ hz = conf("CLK_TCK", 100);
106
+ page = conf("PAGESIZE", 4096);
107
+ }
108
+ let uptime = 0;
109
+ try {
110
+ uptime = Number(readFileSync("/proc/uptime", "utf8").split(" ")[0]) || 0;
111
+ }
112
+ catch { /* lifetime cpu reads 0 */ }
113
+ const at = Date.now();
114
+ const groups = groupStats(stats, last?.ticks ?? null, { dt: last ? (at - last.at) / 1000 : 0, hz, page, uptime });
115
+ last = { at, ticks: new Map(stats.map((s) => [s.pid, s.ticks])) };
116
+ return groups;
117
+ }
15
118
  export function parseProcesses(out) {
16
119
  const agg = new Map();
17
120
  for (const line of String(out || "").split("\n")) {
@@ -2,11 +2,12 @@ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
2
  import { arch, cpus, freemem, hostname, loadavg, networkInterfaces, platform, release, totalmem, type, uptime, } from "os";
3
3
  import { readFileSync } from "fs";
4
4
  import { execSync } from "child_process";
5
- import { parseProcesses, PS_CMD } from "../../lib/processes.js";
5
+ import { parseProcesses, PS_CMD, sampleProcesses } from "../../lib/processes.js";
6
6
  // Named service processes (nginx / php-fpm / db / …) running on the host.
7
+ // From /proc on Linux, cpu over the time since the last report; ps elsewhere.
7
8
  function collectProcesses() {
8
9
  try {
9
- return parseProcesses(execSync(PS_CMD, { encoding: "utf8", timeout: 5000 }));
10
+ return sampleProcesses() ?? parseProcesses(execSync(PS_CMD, { encoding: "utf8", timeout: 5000 }));
10
11
  }
11
12
  catch {
12
13
  return [];
@@ -25,8 +26,11 @@ function collectDisks() {
25
26
  try {
26
27
  const darwin = platform() === "darwin";
27
28
  // Linux: `-T` gives the fstype column; bytes via --block-size=1.
28
- // macOS: no fstype flag, `-l` restricts to local filesystems.
29
- const cmd = darwin ? "df -kl" : "df -PT --block-size=1";
29
+ // `-l` on both: local filesystems only, which is all this reports, and
30
+ // df skips network mounts without touching them. Without it a dead NFS
31
+ // server hung every call past the timeout, so the Patroni nodes
32
+ // reported no disks at all for seven weeks.
33
+ const cmd = darwin ? "df -kl" : "df -lPT --block-size=1";
30
34
  const out = execSync(cmd, { encoding: "utf8", timeout: 5000 });
31
35
  const raw = [];
32
36
  for (const line of out.split("\n").slice(1)) {
@@ -193,6 +193,49 @@ async function collectSite(site) {
193
193
  catch (e) {
194
194
  out.cronError = String(e?.stderr || e?.message || e).slice(0, 160);
195
195
  }
196
+ // 6. Plugins: how many are active, which sit inactive (code on disk that
197
+ // nobody uses, still reachable by a request), and which have an update
198
+ // waiting. The update flags come from WordPress's own cached check.
199
+ try {
200
+ const raw = await wp(site, ["plugin", "list", "--fields=name,status,update,version", "--format=json"]);
201
+ const list = parseJsonSafe(raw, []).filter((p) => p.status === "active" || p.status === "active-network" || p.status === "inactive");
202
+ out.plugins = {
203
+ active: list.filter((p) => p.status !== "inactive").length,
204
+ inactive: list.filter((p) => p.status === "inactive").map((p) => String(p.name)),
205
+ updates: list.filter((p) => p.update === "available").map((p) => String(p.name)),
206
+ };
207
+ }
208
+ catch (e) {
209
+ out.pluginsError = String(e?.message || e).slice(0, 160);
210
+ }
211
+ // 7. Themes with an update waiting, from the same cached check.
212
+ try {
213
+ const raw = await wp(site, ["theme", "list", "--fields=name,update", "--format=json"]);
214
+ out.themeUpdates = parseJsonSafe(raw, []).filter((t) => t.update === "available").map((t) => String(t.name));
215
+ }
216
+ catch (e) {
217
+ out.themesError = String(e?.message || e).slice(0, 160);
218
+ }
219
+ // 8. A core release newer than the one installed (asks api.wordpress.org;
220
+ // an empty list is up to date).
221
+ try {
222
+ const raw = await wp(site, ["core", "check-update", "--format=json"], 30000);
223
+ const ups = parseJsonSafe(raw, []);
224
+ out.coreUpdate = ups.length ? String(ups[0].version) : null;
225
+ }
226
+ catch (e) {
227
+ out.coreUpdateError = String(e?.message || e).slice(0, 160);
228
+ }
229
+ // 9. The PHP that WP-CLI, and so the site, runs on. `wp cli info` doesn't
230
+ // load WordPress.
231
+ try {
232
+ const info = parseJsonSafe(await wp(site, ["cli", "info", "--format=json"]), {});
233
+ if (info.php_version)
234
+ out.phpVersion = String(info.php_version);
235
+ }
236
+ catch (e) {
237
+ out.phpVersionError = String(e?.message || e).slice(0, 160);
238
+ }
196
239
  return out;
197
240
  }
198
241
  export function createWordpressPlugin() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.37",
3
+ "version": "1.1.39",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",