@daemonitor/plugins 1.1.30 → 1.1.32
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/plugins/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createSnmpPlugin } from "./monitoring/SnmpPlugin.js";
|
|
|
11
11
|
import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin.js";
|
|
12
12
|
import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
|
|
13
13
|
import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
|
|
14
|
+
import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
|
|
14
15
|
// In future, implement other plugin factory functions using the same pattern
|
|
15
16
|
// import { createOSPlugin } from "./monitoring/OSPlugin.js"
|
|
16
17
|
// import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
|
|
@@ -33,6 +34,7 @@ export default {
|
|
|
33
34
|
ThreeCX: createThreeCXPlugin,
|
|
34
35
|
Tplink: createTplinkPlugin,
|
|
35
36
|
Patroni: createPatroniPlugin,
|
|
37
|
+
Adsense: createAdsensePlugin,
|
|
36
38
|
// CloudflarePlugin: createCloudflarePlugin,
|
|
37
39
|
// EwelinkPlugin: createEwelinkPlugin,
|
|
38
40
|
// MongoDBPlugin: createMongoDBPlugin,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
2
|
+
// Google AdSense earnings monitor.
|
|
3
|
+
//
|
|
4
|
+
// AdSense has no service-account access, so this runs on a user refresh token
|
|
5
|
+
// (scope adsense.readonly) minted once by hand. Each poll trades it for a
|
|
6
|
+
// one-hour access token and pulls ONE report: earnings and page views per day,
|
|
7
|
+
// from whichever is earlier of the 1st of the month or 29 days ago, through
|
|
8
|
+
// today. Every figure on the tile (today, yesterday, 7 days, month to date, RPM,
|
|
9
|
+
// the daily sparkline) is summed locally from those rows, so the numbers cannot
|
|
10
|
+
// disagree with each other the way separate report calls can mid-update.
|
|
11
|
+
//
|
|
12
|
+
// Dates are the ACCOUNT's time zone, not this host's: "today" for an account in
|
|
13
|
+
// New York is still yesterday on a UTC box for a few hours every night.
|
|
14
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
15
|
+
const API = "https://adsense.googleapis.com/v2";
|
|
16
|
+
/** YYYY-MM-DD shifted by whole days. */
|
|
17
|
+
export function shiftDate(date, days) {
|
|
18
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
19
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
20
|
+
return t.toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
/** Today's date as the account sees it. */
|
|
23
|
+
export function todayIn(timeZone, now = new Date()) {
|
|
24
|
+
// en-CA formats as YYYY-MM-DD.
|
|
25
|
+
return new Intl.DateTimeFormat("en-CA", { timeZone: timeZone || "UTC" }).format(now);
|
|
26
|
+
}
|
|
27
|
+
/** Earlier of the 1st of this month and 29 days back, so one report covers both windows. */
|
|
28
|
+
export function reportStart(today) {
|
|
29
|
+
const monthStart = `${today.slice(0, 7)}-01`;
|
|
30
|
+
const back = shiftDate(today, -29);
|
|
31
|
+
return back < monthStart ? back : monthStart;
|
|
32
|
+
}
|
|
33
|
+
export function reportUrl(account, start, end) {
|
|
34
|
+
const q = new URLSearchParams({ dateRange: "CUSTOM", reportingTimeZone: "ACCOUNT_TIME_ZONE", dimensions: "DATE" });
|
|
35
|
+
for (const [k, v] of [["startDate", start], ["endDate", end]]) {
|
|
36
|
+
const [y, m, d] = v.split("-").map(Number);
|
|
37
|
+
q.set(`${k}.year`, String(y));
|
|
38
|
+
q.set(`${k}.month`, String(m));
|
|
39
|
+
q.set(`${k}.day`, String(d));
|
|
40
|
+
}
|
|
41
|
+
q.append("metrics", "ESTIMATED_EARNINGS");
|
|
42
|
+
q.append("metrics", "PAGE_VIEWS");
|
|
43
|
+
return `${API}/${account}/reports:generate?${q}`;
|
|
44
|
+
}
|
|
45
|
+
/** reports:generate response -> per-day rows. Columns are found by header name, not position. */
|
|
46
|
+
export function parseReport(report) {
|
|
47
|
+
const headers = Array.isArray(report?.headers) ? report.headers : [];
|
|
48
|
+
const col = (name) => headers.findIndex((h) => h?.name === name);
|
|
49
|
+
const [iDate, iEarn, iViews] = [col("DATE"), col("ESTIMATED_EARNINGS"), col("PAGE_VIEWS")];
|
|
50
|
+
if (iDate < 0 || iEarn < 0)
|
|
51
|
+
throw new Error("unexpected report shape (no DATE/ESTIMATED_EARNINGS column)");
|
|
52
|
+
// A day with no traffic has no row at all, so an empty report is valid.
|
|
53
|
+
const rows = Array.isArray(report?.rows) ? report.rows : [];
|
|
54
|
+
return {
|
|
55
|
+
currency: headers[iEarn]?.currencyCode || "USD",
|
|
56
|
+
days: rows.map((r) => ({
|
|
57
|
+
date: String(r?.cells?.[iDate]?.value || ""),
|
|
58
|
+
earnings: Number(r?.cells?.[iEarn]?.value) || 0,
|
|
59
|
+
pageViews: iViews < 0 ? 0 : Number(r?.cells?.[iViews]?.value) || 0,
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const cents = (n) => Math.round(n * 100) / 100;
|
|
64
|
+
/** Roll per-day rows up into the figures the tile shows. `last7` includes today, as AdSense's own "Last 7 days" does. */
|
|
65
|
+
export function summarize(days, today) {
|
|
66
|
+
const by = new Map(days.map((d) => [d.date, d]));
|
|
67
|
+
const day = (date) => by.get(date) || { date, earnings: 0, pageViews: 0 };
|
|
68
|
+
const span = (n) => Array.from({ length: n }, (_, i) => day(shiftDate(today, i - n + 1)));
|
|
69
|
+
const week = span(7);
|
|
70
|
+
const last7 = week.reduce((s, d) => s + d.earnings, 0);
|
|
71
|
+
const pageViews7 = week.reduce((s, d) => s + d.pageViews, 0);
|
|
72
|
+
const month = today.slice(0, 7);
|
|
73
|
+
return {
|
|
74
|
+
today: cents(day(today).earnings),
|
|
75
|
+
yesterday: cents(day(shiftDate(today, -1)).earnings),
|
|
76
|
+
last7: cents(last7),
|
|
77
|
+
monthToDate: cents(days.filter((d) => d.date.startsWith(month)).reduce((s, d) => s + d.earnings, 0)),
|
|
78
|
+
pageViews7,
|
|
79
|
+
// Page RPM over the same 7 days: earnings per thousand page views.
|
|
80
|
+
rpm: pageViews7 ? cents((last7 / pageViews7) * 1000) : 0,
|
|
81
|
+
// Gap-filled so the sparkline has one point per day, oldest first.
|
|
82
|
+
daily: span(30),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function getJson(url, init, timeout) {
|
|
86
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
|
|
87
|
+
const body = await res.json().catch(() => ({}));
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const e = new Error(body?.error_description || body?.error?.message || body?.error || `HTTP ${res.status}`);
|
|
90
|
+
e.code = typeof body?.error === "string" ? body.error : body?.error?.status;
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
return body;
|
|
94
|
+
}
|
|
95
|
+
export function createAdsensePlugin() {
|
|
96
|
+
let refreshTimer = null;
|
|
97
|
+
// Resolved once: the account and its time zone do not change between polls.
|
|
98
|
+
let account = null;
|
|
99
|
+
const collect = async (cfg) => {
|
|
100
|
+
const timeout = cfg.timeout || 15000;
|
|
101
|
+
const tok = await getJson(TOKEN_URL, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
104
|
+
body: new URLSearchParams({
|
|
105
|
+
grant_type: "refresh_token",
|
|
106
|
+
client_id: cfg.clientId || "",
|
|
107
|
+
client_secret: cfg.clientSecret || "",
|
|
108
|
+
refresh_token: cfg.refreshToken || "",
|
|
109
|
+
}).toString(),
|
|
110
|
+
}, timeout);
|
|
111
|
+
const auth = { headers: { Authorization: `Bearer ${tok.access_token}` } };
|
|
112
|
+
if (!account) {
|
|
113
|
+
const list = (await getJson(`${API}/accounts`, auth, timeout))?.accounts || [];
|
|
114
|
+
const a = cfg.accountId ? list.find((x) => x.name === cfg.accountId) : list[0];
|
|
115
|
+
if (!a)
|
|
116
|
+
throw new Error(cfg.accountId ? `account ${cfg.accountId} not visible to this token` : "no AdSense account visible to this token");
|
|
117
|
+
account = { name: a.name, timeZone: a.timeZone?.id };
|
|
118
|
+
}
|
|
119
|
+
const today = todayIn(account.timeZone);
|
|
120
|
+
const { currency, days } = parseReport(await getJson(reportUrl(account.name, reportStart(today), today), auth, timeout));
|
|
121
|
+
return { accountId: account.name, currency, asOf: today, ...summarize(days, today) };
|
|
122
|
+
};
|
|
123
|
+
const refreshFn = async (plugin) => {
|
|
124
|
+
const cfg = (plugin.config || {});
|
|
125
|
+
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
|
|
126
|
+
await plugin.send({ error: "adsense plugin needs clientId, clientSecret and refreshToken" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await plugin.send(await collect(cfg));
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
// invalid_grant = the refresh token was revoked or expired. Nothing the
|
|
134
|
+
// next poll can fix, so it is flagged apart from a transient API error.
|
|
135
|
+
await plugin.send({
|
|
136
|
+
error: String(e?.message || e).slice(0, 160),
|
|
137
|
+
...(e?.code === "invalid_grant" ? { authExpired: true } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const monitorFn = async (plugin) => {
|
|
142
|
+
await refreshFn(plugin);
|
|
143
|
+
// AdSense figures lag by hours; 10 minutes keeps the row inside the
|
|
144
|
+
// server's 15-minute staleness window without spending API quota.
|
|
145
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 600000);
|
|
146
|
+
};
|
|
147
|
+
const teardownFn = async () => {
|
|
148
|
+
if (refreshTimer)
|
|
149
|
+
clearInterval(refreshTimer);
|
|
150
|
+
refreshTimer = null;
|
|
151
|
+
};
|
|
152
|
+
return createMonitoringPlugin("adsense", "adsense", "Google AdSense earnings (today, yesterday, 7 days, month to date, page RPM)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
153
|
+
}
|
|
154
|
+
export default createAdsensePlugin;
|
|
@@ -2,6 +2,7 @@ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
|
2
2
|
import { exec } from "child_process";
|
|
3
3
|
import { promisify } from "util";
|
|
4
4
|
import { hostname } from "os";
|
|
5
|
+
import { readFile } from "fs/promises";
|
|
5
6
|
const execAsync = promisify(exec);
|
|
6
7
|
const EXEC_OPTS = { maxBuffer: 32 * 1024 * 1024 }; // containers.length can be large
|
|
7
8
|
/**
|
|
@@ -38,9 +39,78 @@ export function cleanHealthOutput(raw) {
|
|
|
38
39
|
return stripped.length > 300 ? "…" + stripped.slice(-300) : stripped;
|
|
39
40
|
}
|
|
40
41
|
const HEALTH_RE = /\((healthy|unhealthy|health: starting|starting)\)/i;
|
|
41
|
-
// Previous cumulative NetIO/BlockIO totals per container id, to derive a
|
|
42
|
-
// per-second rate across polls. Module-level (one collect per host per cycle).
|
|
43
42
|
const ioPrev = {};
|
|
43
|
+
/** Sum of rbytes/wbytes across every device in a cgroup v2 io.stat. */
|
|
44
|
+
export function parseIoStat(text) {
|
|
45
|
+
let a = 0, b = 0, seen = false;
|
|
46
|
+
for (const line of text.split("\n")) {
|
|
47
|
+
const r = /\brbytes=(\d+)/.exec(line);
|
|
48
|
+
const w = /\bwbytes=(\d+)/.exec(line);
|
|
49
|
+
if (r || w)
|
|
50
|
+
seen = true;
|
|
51
|
+
if (r)
|
|
52
|
+
a += Number(r[1]);
|
|
53
|
+
if (w)
|
|
54
|
+
b += Number(w[1]);
|
|
55
|
+
}
|
|
56
|
+
return seen ? { a, b } : (text.trim() === "" ? { a: 0, b: 0 } : null);
|
|
57
|
+
}
|
|
58
|
+
/** rx/tx bytes summed over a /proc/<pid>/net/dev, loopback excluded. */
|
|
59
|
+
export function parseNetDev(text) {
|
|
60
|
+
let a = 0, b = 0, seen = false;
|
|
61
|
+
for (const line of text.split("\n")) {
|
|
62
|
+
const m = /^\s*([^:\s]+):\s*(.*)$/.exec(line);
|
|
63
|
+
if (!m || m[1] === "lo")
|
|
64
|
+
continue;
|
|
65
|
+
const f = m[2].trim().split(/\s+/).map(Number);
|
|
66
|
+
// receive: bytes packets errs drop fifo frame compressed multicast | transmit: bytes …
|
|
67
|
+
if (f.length < 9 || !Number.isFinite(f[0]) || !Number.isFinite(f[8]))
|
|
68
|
+
continue;
|
|
69
|
+
a += f[0];
|
|
70
|
+
b += f[8];
|
|
71
|
+
seen = true;
|
|
72
|
+
}
|
|
73
|
+
return seen ? { a, b } : null;
|
|
74
|
+
}
|
|
75
|
+
/** The cgroup v2 path in a /proc/<pid>/cgroup ("0::/system.slice/docker-….scope"), or null on v1. */
|
|
76
|
+
export function cgroupV2Path(text) {
|
|
77
|
+
const m = /^0::(\/.*)$/m.exec(text);
|
|
78
|
+
return m ? m[1].trim() : null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Byte rate between two cumulative readings. Undefined when there is no usable
|
|
82
|
+
* previous reading: none yet, a different source, or the counter went backwards
|
|
83
|
+
* (container restarted, counters reset).
|
|
84
|
+
*/
|
|
85
|
+
export function ioRate(cur, prev, dt) {
|
|
86
|
+
if (prev == null || dt <= 0 || cur < prev)
|
|
87
|
+
return undefined;
|
|
88
|
+
return Math.round((cur - prev) / dt);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Exact cumulative counters for one container, read from the kernel rather than
|
|
92
|
+
* `docker stats`, which prints three significant figures. At "17.8GB" one step
|
|
93
|
+
* is 100MB, so the delta between two 30s polls was almost always zero.
|
|
94
|
+
*
|
|
95
|
+
* Needs the host pid (from inspect). Works for docker and podman, systemd or
|
|
96
|
+
* cgroupfs driver, because the cgroup path comes from the process itself.
|
|
97
|
+
* Either half is null when it can't be read (cgroup v1, a hidepid /proc); the
|
|
98
|
+
* caller falls back to the stats strings for that half.
|
|
99
|
+
*/
|
|
100
|
+
async function readExactIo(pid, hostNet) {
|
|
101
|
+
if (!(pid > 0))
|
|
102
|
+
return { net: null, blk: null };
|
|
103
|
+
const read = (p) => readFile(p, "utf8").catch(() => null);
|
|
104
|
+
const [cg, dev] = await Promise.all([read(`/proc/${pid}/cgroup`), hostNet ? Promise.resolve(null) : read(`/proc/${pid}/net/dev`)]);
|
|
105
|
+
const path = cg ? cgroupV2Path(cg) : null;
|
|
106
|
+
const io = path ? await read(`/sys/fs/cgroup${path}/io.stat`) : null;
|
|
107
|
+
return {
|
|
108
|
+
// Host networking shares the host's interfaces; reading them would charge
|
|
109
|
+
// all host traffic to this container. docker stats reports 0 for it, and so do we.
|
|
110
|
+
net: hostNet ? { a: 0, b: 0 } : (dev ? parseNetDev(dev) : null),
|
|
111
|
+
blk: io != null ? parseIoStat(io) : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
44
114
|
// docker stats renders "12MB / 7.02MB" (rx / tx, or read / write). Split + convert.
|
|
45
115
|
function pairToBytes(value) {
|
|
46
116
|
const [x, y] = (value || "").split("/").map((p) => p.trim());
|
|
@@ -65,7 +135,7 @@ function memToBytes(value) {
|
|
|
65
135
|
* 2. `docker stats` — live CPU / memory for running containers (one batch)
|
|
66
136
|
* 3. `docker inspect` — restart count, health, exit code (one batch)
|
|
67
137
|
*/
|
|
68
|
-
async function collectContainers(bin) {
|
|
138
|
+
export async function collectContainers(bin) {
|
|
69
139
|
const psFmt = '{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Status}}|{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}';
|
|
70
140
|
const { stdout: psOut } = await execAsync(`${bin} ps -a --format '${psFmt}'`, EXEC_OPTS);
|
|
71
141
|
const containers = [];
|
|
@@ -94,12 +164,12 @@ async function collectContainers(bin) {
|
|
|
94
164
|
if (!containers.length)
|
|
95
165
|
return containers;
|
|
96
166
|
// 2. live stats for running containers (one batched call, best-effort).
|
|
97
|
-
// NetIO/BlockIO are CUMULATIVE since container start
|
|
98
|
-
//
|
|
167
|
+
// NetIO/BlockIO are CUMULATIVE since container start; they are kept here only
|
|
168
|
+
// as the fallback for counters the kernel won't give us (see readExactIo), and
|
|
169
|
+
// the rate is derived after inspect, once the pid is known.
|
|
170
|
+
const statsIo = new Map();
|
|
99
171
|
try {
|
|
100
172
|
const { stdout: statsOut } = await execAsync(`${bin} stats --no-stream --format '{{.ID}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}'`, EXEC_OPTS);
|
|
101
|
-
const now = Date.now();
|
|
102
|
-
const seen = new Set();
|
|
103
173
|
for (const line of statsOut.split("\n")) {
|
|
104
174
|
if (!line.trim())
|
|
105
175
|
continue;
|
|
@@ -112,28 +182,8 @@ async function collectContainers(bin) {
|
|
|
112
182
|
c.mem = memToBytes(used);
|
|
113
183
|
c.memLimit = memToBytes(limit);
|
|
114
184
|
c.memPercent = parseFloat((memPerc || "").replace("%", "")) || 0;
|
|
115
|
-
|
|
116
|
-
const blk = pairToBytes(blkIO); // { a: read, b: write }
|
|
117
|
-
seen.add(id);
|
|
118
|
-
const prev = ioPrev[id];
|
|
119
|
-
if (prev && now > prev.ts) {
|
|
120
|
-
const dt = (now - prev.ts) / 1000;
|
|
121
|
-
// Guard counter resets (container restart): negative delta → skip that field.
|
|
122
|
-
if (net.a >= prev.rx)
|
|
123
|
-
c.netIn = Math.round((net.a - prev.rx) / dt);
|
|
124
|
-
if (net.b >= prev.tx)
|
|
125
|
-
c.netOut = Math.round((net.b - prev.tx) / dt);
|
|
126
|
-
if (blk.a >= prev.rd)
|
|
127
|
-
c.blkRead = Math.round((blk.a - prev.rd) / dt);
|
|
128
|
-
if (blk.b >= prev.wr)
|
|
129
|
-
c.blkWrite = Math.round((blk.b - prev.wr) / dt);
|
|
130
|
-
}
|
|
131
|
-
ioPrev[id] = { rx: net.a, tx: net.b, rd: blk.a, wr: blk.b, ts: now };
|
|
185
|
+
statsIo.set(id, { net: pairToBytes(netIO), blk: pairToBytes(blkIO) });
|
|
132
186
|
}
|
|
133
|
-
// Drop prev samples for containers no longer present.
|
|
134
|
-
for (const k of Object.keys(ioPrev))
|
|
135
|
-
if (!seen.has(k))
|
|
136
|
-
delete ioPrev[k];
|
|
137
187
|
}
|
|
138
188
|
catch (err) {
|
|
139
189
|
console.error("docker: stats collection failed:", err.message);
|
|
@@ -143,24 +193,52 @@ async function collectContainers(bin) {
|
|
|
143
193
|
// template errors with `map has no entry for key "Health"` for containers
|
|
144
194
|
// without a healthcheck, which would fail the whole batch. Health is parsed
|
|
145
195
|
// from the ps `Status` string ("(healthy)" / "(unhealthy)") instead.
|
|
196
|
+
// Pid + network mode ride the same call, for the exact IO counters.
|
|
197
|
+
const procInfo = new Map();
|
|
146
198
|
try {
|
|
147
199
|
const ids = containers.map((c) => c.id).join(" ");
|
|
148
|
-
const inspectFmt = '{{.Id}}|{{.RestartCount}}|{{.State.ExitCode}}';
|
|
200
|
+
const inspectFmt = '{{.Id}}|{{.RestartCount}}|{{.State.ExitCode}}|{{.State.Pid}}|{{.HostConfig.NetworkMode}}';
|
|
149
201
|
const { stdout: inspOut } = await execAsync(`${bin} inspect --format '${inspectFmt}' ${ids}`, EXEC_OPTS);
|
|
150
202
|
for (const line of inspOut.split("\n")) {
|
|
151
203
|
if (!line.trim())
|
|
152
204
|
continue;
|
|
153
|
-
const [fullId, restarts, exitCode] = line.split("|");
|
|
205
|
+
const [fullId, restarts, exitCode, pid, netMode] = line.split("|");
|
|
154
206
|
const c = byId.get(fullId.slice(0, 12));
|
|
155
207
|
if (!c)
|
|
156
208
|
continue;
|
|
157
209
|
c.restarts = parseInt(restarts, 10) || 0;
|
|
158
210
|
c.exitCode = parseInt(exitCode, 10) || 0;
|
|
211
|
+
procInfo.set(c.id, { pid: parseInt(pid, 10) || 0, hostNet: (netMode || "").trim() === "host" });
|
|
159
212
|
}
|
|
160
213
|
}
|
|
161
214
|
catch (err) {
|
|
162
215
|
console.error("docker: inspect collection failed:", err.message);
|
|
163
216
|
}
|
|
217
|
+
// 3b. Per-second IO rates: exact kernel counters where readable, the rounded
|
|
218
|
+
// stats strings otherwise. Only containers docker stats saw (i.e. running).
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
await Promise.all([...statsIo].map(async ([id, fromStats]) => {
|
|
221
|
+
const c = byId.get(id);
|
|
222
|
+
if (!c)
|
|
223
|
+
return;
|
|
224
|
+
const info = procInfo.get(id);
|
|
225
|
+
const exact = info ? await readExactIo(info.pid, info.hostNet) : { net: null, blk: null };
|
|
226
|
+
const net = exact.net ? { ...exact.net, src: "exact" } : { ...fromStats.net, src: "stats" };
|
|
227
|
+
const blk = exact.blk ? { ...exact.blk, src: "exact" } : { ...fromStats.blk, src: "stats" };
|
|
228
|
+
const prev = ioPrev[id];
|
|
229
|
+
const dt = prev ? (now - prev.ts) / 1000 : 0;
|
|
230
|
+
const pn = prev?.net?.src === net.src ? prev.net : undefined;
|
|
231
|
+
const pb = prev?.blk?.src === blk.src ? prev.blk : undefined;
|
|
232
|
+
c.netIn = ioRate(net.a, pn?.a, dt);
|
|
233
|
+
c.netOut = ioRate(net.b, pn?.b, dt);
|
|
234
|
+
c.blkRead = ioRate(blk.a, pb?.a, dt);
|
|
235
|
+
c.blkWrite = ioRate(blk.b, pb?.b, dt);
|
|
236
|
+
ioPrev[id] = { net, blk, ts: now };
|
|
237
|
+
}));
|
|
238
|
+
// Drop prev samples for containers no longer present.
|
|
239
|
+
for (const k of Object.keys(ioPrev))
|
|
240
|
+
if (!statsIo.has(k))
|
|
241
|
+
delete ioPrev[k];
|
|
164
242
|
// 4. For UNHEALTHY containers only, read the healthcheck's own last output.
|
|
165
243
|
//
|
|
166
244
|
// "1/13 containers unhealthy" says a problem exists and nothing about what it
|