@daemonitor/plugins 1.1.32 → 1.1.34

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.
@@ -12,6 +12,9 @@ import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin.js";
12
12
  import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
13
13
  import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
14
14
  import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
15
+ import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
16
+ import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
17
+ import { createTplinkSwitchPlugin } from "./monitoring/TplinkSwitchPlugin.js";
15
18
  // In future, implement other plugin factory functions using the same pattern
16
19
  // import { createOSPlugin } from "./monitoring/OSPlugin.js"
17
20
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
@@ -35,6 +38,9 @@ export default {
35
38
  Tplink: createTplinkPlugin,
36
39
  Patroni: createPatroniPlugin,
37
40
  Adsense: createAdsensePlugin,
41
+ Ga4: createGa4Plugin,
42
+ Nagios: createNagiosPlugin,
43
+ TplinkSwitch: createTplinkSwitchPlugin,
38
44
  // CloudflarePlugin: createCloudflarePlugin,
39
45
  // EwelinkPlugin: createEwelinkPlugin,
40
46
  // MongoDBPlugin: createMongoDBPlugin,
@@ -30,8 +30,12 @@ export function reportStart(today) {
30
30
  const back = shiftDate(today, -29);
31
31
  return back < monthStart ? back : monthStart;
32
32
  }
33
- export function reportUrl(account, start, end) {
33
+ export function reportUrl(account, start, end, bySite = false) {
34
34
  const q = new URLSearchParams({ dateRange: "CUSTOM", reportingTimeZone: "ACCOUNT_TIME_ZONE", dimensions: "DATE" });
35
+ // DOMAIN_NAME, not OWNED_SITE_DOMAIN_NAME: the owned-site rows nest (archpaper.com
36
+ // contains www.archpaper.com and jobs.archpaper.com), so they double count.
37
+ if (bySite)
38
+ q.append("dimensions", "DOMAIN_NAME");
35
39
  for (const [k, v] of [["startDate", start], ["endDate", end]]) {
36
40
  const [y, m, d] = v.split("-").map(Number);
37
41
  q.set(`${k}.year`, String(y));
@@ -40,13 +44,14 @@ export function reportUrl(account, start, end) {
40
44
  }
41
45
  q.append("metrics", "ESTIMATED_EARNINGS");
42
46
  q.append("metrics", "PAGE_VIEWS");
47
+ q.append("metrics", "CLICKS");
43
48
  return `${API}/${account}/reports:generate?${q}`;
44
49
  }
45
50
  /** reports:generate response -> per-day rows. Columns are found by header name, not position. */
46
51
  export function parseReport(report) {
47
52
  const headers = Array.isArray(report?.headers) ? report.headers : [];
48
53
  const col = (name) => headers.findIndex((h) => h?.name === name);
49
- const [iDate, iEarn, iViews] = [col("DATE"), col("ESTIMATED_EARNINGS"), col("PAGE_VIEWS")];
54
+ const [iDate, iEarn, iViews, iClicks] = [col("DATE"), col("ESTIMATED_EARNINGS"), col("PAGE_VIEWS"), col("CLICKS")];
50
55
  if (iDate < 0 || iEarn < 0)
51
56
  throw new Error("unexpected report shape (no DATE/ESTIMATED_EARNINGS column)");
52
57
  // A day with no traffic has no row at all, so an empty report is valid.
@@ -57,14 +62,61 @@ export function parseReport(report) {
57
62
  date: String(r?.cells?.[iDate]?.value || ""),
58
63
  earnings: Number(r?.cells?.[iEarn]?.value) || 0,
59
64
  pageViews: iViews < 0 ? 0 : Number(r?.cells?.[iViews]?.value) || 0,
65
+ clicks: iClicks < 0 ? 0 : Number(r?.cells?.[iClicks]?.value) || 0,
60
66
  })),
61
67
  };
62
68
  }
69
+ /** A DATE x DOMAIN_NAME report -> per-day rows keyed by site (hostname without a leading www). */
70
+ export function parseSiteReport(report) {
71
+ const headers = Array.isArray(report?.headers) ? report.headers : [];
72
+ const iDomain = headers.findIndex((h) => h?.name === "DOMAIN_NAME");
73
+ const out = new Map();
74
+ if (iDomain < 0)
75
+ return out;
76
+ const { days } = parseReport(report);
77
+ const rows = Array.isArray(report?.rows) ? report.rows : [];
78
+ rows.forEach((r, i) => {
79
+ // www.example.com and example.com are one site to a reader, so they merge.
80
+ const domain = String(r?.cells?.[iDomain]?.value || "").replace(/^www\./, "");
81
+ if (!domain)
82
+ return;
83
+ if (!out.has(domain))
84
+ out.set(domain, []);
85
+ const list = out.get(domain);
86
+ const same = list.find((d) => d.date === days[i].date);
87
+ if (same) {
88
+ same.earnings += days[i].earnings;
89
+ same.pageViews += days[i].pageViews;
90
+ same.clicks += days[i].clicks;
91
+ }
92
+ else
93
+ list.push({ ...days[i] });
94
+ });
95
+ return out;
96
+ }
97
+ const MAX_SITES = 20;
98
+ /**
99
+ * Per-site figures, biggest earner first. Domains that earned nothing in the
100
+ * window are dropped: AdSense lists every hostname an ad tag loaded on, which
101
+ * includes admin hosts and previews that are noise here. `daily` is earnings
102
+ * only, to keep the payload small.
103
+ */
104
+ export function summarizeSites(bySite, today) {
105
+ return [...bySite.entries()]
106
+ .map(([domain, days]) => {
107
+ const sum = summarize(days, today);
108
+ return { domain, ...sum, daily: sum.daily.map((d) => cents(d.earnings)), window: days.reduce((t, d) => t + d.earnings, 0) };
109
+ })
110
+ .filter((s) => s.window > 0)
111
+ .sort((a, b) => b.window - a.window)
112
+ .slice(0, MAX_SITES)
113
+ .map(({ window: _window, ...site }) => site);
114
+ }
63
115
  const cents = (n) => Math.round(n * 100) / 100;
64
116
  /** Roll per-day rows up into the figures the tile shows. `last7` includes today, as AdSense's own "Last 7 days" does. */
65
117
  export function summarize(days, today) {
66
118
  const by = new Map(days.map((d) => [d.date, d]));
67
- const day = (date) => by.get(date) || { date, earnings: 0, pageViews: 0 };
119
+ const day = (date) => by.get(date) || { date, earnings: 0, pageViews: 0, clicks: 0 };
68
120
  const span = (n) => Array.from({ length: n }, (_, i) => day(shiftDate(today, i - n + 1)));
69
121
  const week = span(7);
70
122
  const last7 = week.reduce((s, d) => s + d.earnings, 0);
@@ -76,13 +128,14 @@ export function summarize(days, today) {
76
128
  last7: cents(last7),
77
129
  monthToDate: cents(days.filter((d) => d.date.startsWith(month)).reduce((s, d) => s + d.earnings, 0)),
78
130
  pageViews7,
131
+ clicks7: week.reduce((s, d) => s + d.clicks, 0),
79
132
  // Page RPM over the same 7 days: earnings per thousand page views.
80
133
  rpm: pageViews7 ? cents((last7 / pageViews7) * 1000) : 0,
81
134
  // Gap-filled so the sparkline has one point per day, oldest first.
82
135
  daily: span(30),
83
136
  };
84
137
  }
85
- async function getJson(url, init, timeout) {
138
+ export async function getJson(url, init, timeout) {
86
139
  const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
87
140
  const body = await res.json().catch(() => ({}));
88
141
  if (!res.ok) {
@@ -92,33 +145,53 @@ async function getJson(url, init, timeout) {
92
145
  }
93
146
  return body;
94
147
  }
148
+ /** Trade a refresh token for a one-hour access token. Shared with the ga4 plugin. */
149
+ export async function googleAccessToken(cfg, timeout) {
150
+ const tok = await getJson(TOKEN_URL, {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
153
+ body: new URLSearchParams({
154
+ grant_type: "refresh_token",
155
+ client_id: cfg.clientId || "",
156
+ client_secret: cfg.clientSecret || "",
157
+ refresh_token: cfg.refreshToken || "",
158
+ }).toString(),
159
+ }, timeout);
160
+ return tok.access_token;
161
+ }
95
162
  export function createAdsensePlugin() {
96
163
  let refreshTimer = null;
97
164
  // Resolved once: the account and its time zone do not change between polls.
98
165
  let account = null;
99
166
  const collect = async (cfg) => {
100
167
  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}` } };
168
+ const auth = { headers: { Authorization: `Bearer ${await googleAccessToken(cfg, timeout)}` } };
112
169
  if (!account) {
113
170
  const list = (await getJson(`${API}/accounts`, auth, timeout))?.accounts || [];
114
171
  const a = cfg.accountId ? list.find((x) => x.name === cfg.accountId) : list[0];
115
172
  if (!a)
116
173
  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 };
174
+ account = { name: a.name, displayName: a.displayName, timeZone: a.timeZone?.id };
118
175
  }
119
176
  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) };
177
+ const start = reportStart(today);
178
+ const { currency, days } = parseReport(await getJson(reportUrl(account.name, start, today), auth, timeout));
179
+ // The per-site breakdown is extra: if it fails, the account totals still report.
180
+ let sites = [];
181
+ try {
182
+ sites = summarizeSites(parseSiteReport(await getJson(reportUrl(account.name, start, today, true), auth, timeout)), today);
183
+ }
184
+ catch { /* totals only */ }
185
+ return {
186
+ // The account's own name ("The Architect's Newspaper") labels the service.
187
+ // It overrides the plugin's instance name, which only says "AdSense".
188
+ ...(account.displayName ? { name: account.displayName } : {}),
189
+ accountId: account.name,
190
+ currency,
191
+ asOf: today,
192
+ ...summarize(days, today),
193
+ sites,
194
+ };
122
195
  };
123
196
  const refreshFn = async (plugin) => {
124
197
  const cfg = (plugin.config || {});
@@ -140,9 +213,9 @@ export function createAdsensePlugin() {
140
213
  };
141
214
  const monitorFn = async (plugin) => {
142
215
  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);
216
+ // AdSense figures lag by hours, so 15 minutes loses nothing. The server
217
+ // allows these rows 30 minutes of silence before calling them offline.
218
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 900000);
146
219
  };
147
220
  const teardownFn = async () => {
148
221
  if (refreshTimer)
@@ -0,0 +1,164 @@
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
+ import { getJson, googleAccessToken, shiftDate, todayIn } from "./AdsensePlugin.js";
3
+ // Google Analytics 4 overview across every account and property the Google
4
+ // login can see.
5
+ //
6
+ // Runs on a user refresh token with scope analytics.readonly, minted the same
7
+ // way as the adsense plugin's (client/etc/adsense-auth.mjs --source=ga4).
8
+ //
9
+ // Each poll lists the account/property tree from the Admin API, then for each
10
+ // property asks the Data API for: active users right now (realtime), users,
11
+ // sessions and page views for today, yesterday and the last 7 days, and 30
12
+ // days of daily figures. 7-day users come from GA itself, never from summing
13
+ // days: one person on three days is one user, not three.
14
+ //
15
+ // The report carries its own 30-day history, so the server stores no history
16
+ // rows for this type. Hiding properties and nicknames live in the portal.
17
+ const ADMIN = "https://analyticsadmin.googleapis.com/v1beta";
18
+ const DATA = "https://analyticsdata.googleapis.com/v1beta";
19
+ const DAYS = 30;
20
+ // Parallel property requests. GA quotas are per property, so this only bounds
21
+ // how hard one poll hits the network.
22
+ const CONCURRENCY = 4;
23
+ const ZERO = { users: 0, sessions: 0, views: 0 };
24
+ const METRICS = [{ name: "activeUsers" }, { name: "sessions" }, { name: "screenPageViews" }];
25
+ /** accountSummaries response -> accounts with their GA4 properties. */
26
+ export function parseAccounts(summaries) {
27
+ return (summaries || []).map((a) => ({
28
+ id: String(a?.account || "").replace(/^accounts\//, ""),
29
+ name: String(a?.displayName || a?.account || ""),
30
+ properties: (a?.propertySummaries || [])
31
+ .filter((p) => !p?.propertyType || p.propertyType === "PROPERTY_TYPE_ORDINARY")
32
+ .map((p) => ({ id: String(p?.property || "").replace(/^properties\//, ""), name: String(p?.displayName || p?.property || "") })),
33
+ }));
34
+ }
35
+ const num = (v) => Number(v?.value) || 0;
36
+ const totals = (row) => row ? { users: num(row.metricValues?.[0]), sessions: num(row.metricValues?.[1]), views: num(row.metricValues?.[2]) } : { ...ZERO };
37
+ /**
38
+ * batchRunReports response -> the figures the portal shows.
39
+ * Report 0: no dimensions, three named date ranges (today, yesterday, last7).
40
+ * Report 1: one row per date over the last 30 days.
41
+ */
42
+ export function parseProperty(batch, now = new Date()) {
43
+ const [summary, byDate] = batch?.reports || [];
44
+ // A multi-range report adds a "dateRange" dimension holding each range's name.
45
+ const range = (name) => totals((summary?.rows || []).find((r) => r?.dimensionValues?.[0]?.value === name));
46
+ // Gap-fill the 30 days in the property's own time zone: GA leaves days with
47
+ // no traffic out entirely.
48
+ const today = todayIn(byDate?.metadata?.timeZone || summary?.metadata?.timeZone, now);
49
+ const by = new Map();
50
+ for (const r of byDate?.rows || []) {
51
+ const d = String(r?.dimensionValues?.[0]?.value || "");
52
+ if (d.length === 8)
53
+ by.set(`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}`, totals(r));
54
+ }
55
+ const dates = Array.from({ length: DAYS }, (_, i) => shiftDate(today, i - DAYS + 1));
56
+ const days = dates.map((d) => by.get(d) || ZERO);
57
+ return {
58
+ asOf: today,
59
+ today: range("today"),
60
+ yesterday: range("yesterday"),
61
+ last7: range("last7"),
62
+ daily: {
63
+ start: dates[0],
64
+ users: days.map((d) => d.users),
65
+ sessions: days.map((d) => d.sessions),
66
+ views: days.map((d) => d.views),
67
+ },
68
+ };
69
+ }
70
+ /** runRealtimeReport -> users active in the last 30 minutes. No rows means zero. */
71
+ export const parseRealtime = (r) => num(r?.rows?.[0]?.metricValues?.[0]);
72
+ export const BATCH_BODY = {
73
+ requests: [
74
+ {
75
+ dateRanges: [
76
+ { startDate: "today", endDate: "today", name: "today" },
77
+ { startDate: "yesterday", endDate: "yesterday", name: "yesterday" },
78
+ { startDate: "6daysAgo", endDate: "today", name: "last7" },
79
+ ],
80
+ metrics: METRICS,
81
+ },
82
+ {
83
+ dateRanges: [{ startDate: `${DAYS - 1}daysAgo`, endDate: "today" }],
84
+ dimensions: [{ name: "date" }],
85
+ metrics: METRICS,
86
+ limit: DAYS + 5,
87
+ },
88
+ ],
89
+ };
90
+ async function inBatches(items, size, fn) {
91
+ const out = [];
92
+ for (let i = 0; i < items.length; i += size)
93
+ out.push(...(await Promise.all(items.slice(i, i + size).map(fn))));
94
+ return out;
95
+ }
96
+ export function createGa4Plugin() {
97
+ let refreshTimer = null;
98
+ const collect = async (cfg) => {
99
+ const timeout = cfg.timeout || 20000;
100
+ const token = await googleAccessToken(cfg, timeout);
101
+ const get = (url) => getJson(url, { headers: { Authorization: `Bearer ${token}` } }, timeout);
102
+ const post = (url, body) => getJson(url, {
103
+ method: "POST",
104
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
105
+ body: JSON.stringify(body),
106
+ }, timeout);
107
+ // Every account and property this login can see, across pages.
108
+ const summaries = [];
109
+ let page = "";
110
+ do {
111
+ const r = await get(`${ADMIN}/accountSummaries?pageSize=200${page ? `&pageToken=${encodeURIComponent(page)}` : ""}`);
112
+ summaries.push(...(r?.accountSummaries || []));
113
+ page = r?.nextPageToken || "";
114
+ } while (page);
115
+ const allow = Array.isArray(cfg.accounts) && cfg.accounts.length ? new Set(cfg.accounts.map(String)) : null;
116
+ // Filtered before any report is requested: an account outside the list is
117
+ // never queried, so its figures never reach this customer.
118
+ const accounts = parseAccounts(summaries).filter((a) => !allow || allow.has(a.id));
119
+ for (const account of accounts) {
120
+ account.properties = await inBatches(account.properties, CONCURRENCY, async (p) => {
121
+ // One property failing (no access to its data, quota) must not hide the rest.
122
+ try {
123
+ const [batch, realtime] = await Promise.all([
124
+ post(`${DATA}/properties/${p.id}:batchRunReports`, BATCH_BODY),
125
+ post(`${DATA}/properties/${p.id}:runRealtimeReport`, { metrics: [{ name: "activeUsers" }] }).catch(() => null),
126
+ ]);
127
+ return { ...p, ...parseProperty(batch), activeNow: realtime ? parseRealtime(realtime) : null };
128
+ }
129
+ catch (e) {
130
+ return { ...p, error: String(e?.message || e).slice(0, 160) };
131
+ }
132
+ });
133
+ }
134
+ const properties = accounts.reduce((n, a) => n + a.properties.length, 0);
135
+ return { name: "Google Analytics", accounts, properties };
136
+ };
137
+ const refreshFn = async (plugin) => {
138
+ const cfg = (plugin.config || {});
139
+ if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
140
+ await plugin.send({ error: "ga4 plugin needs clientId, clientSecret and refreshToken" });
141
+ return;
142
+ }
143
+ try {
144
+ await plugin.send(await collect(cfg));
145
+ }
146
+ catch (e) {
147
+ await plugin.send({
148
+ error: String(e?.message || e).slice(0, 160),
149
+ ...(e?.code === "invalid_grant" ? { authExpired: true } : {}),
150
+ });
151
+ }
152
+ };
153
+ const monitorFn = async (plugin) => {
154
+ await refreshFn(plugin);
155
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 900000);
156
+ };
157
+ const teardownFn = async () => {
158
+ if (refreshTimer)
159
+ clearInterval(refreshTimer);
160
+ refreshTimer = null;
161
+ };
162
+ return createMonitoringPlugin("ga4", "ga4", "Google Analytics 4 overview (active now, users, sessions and views per property)", async () => { }, monitorFn, refreshFn, teardownFn);
163
+ }
164
+ export default createGa4Plugin;
@@ -0,0 +1,138 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
+ const STATES = ["ok", "warning", "critical", "unknown"];
4
+ // Output is capped: a check printing a megabyte would otherwise be sent every
5
+ // minute and stored in history.
6
+ const MAX_OUTPUT = 4000;
7
+ export function stateOf(exitCode) {
8
+ return exitCode != null && exitCode >= 0 && exitCode <= 3 ? STATES[exitCode] : "unknown";
9
+ }
10
+ const num = (s) => (s == null || s === "" || Number.isNaN(Number(s)) ? undefined : Number(s));
11
+ /**
12
+ * Parse performance data: space-separated `'label'=value[UOM];[warn];[crit];[min];[max]`.
13
+ * Labels may be quoted to contain spaces, with '' standing for a literal quote.
14
+ * Malformed items are skipped rather than failing the whole check.
15
+ */
16
+ export function parsePerfdata(raw) {
17
+ const out = [];
18
+ const re = /\s*('(?:[^']|'')+'|[^\s=']+)=(\S+)/g;
19
+ let m;
20
+ while ((m = re.exec(raw))) {
21
+ const label = m[1].startsWith("'") ? m[1].slice(1, -1).replace(/''/g, "'") : m[1];
22
+ const [valueUom, warn, crit, min, max] = m[2].split(";");
23
+ const vm = valueUom.match(/^(-?[\d.]+(?:[eE][-+]?\d+)?)([a-zA-Z%]*)$/);
24
+ if (!vm)
25
+ continue; // "U" (undetermined) or garbage
26
+ out.push({
27
+ label,
28
+ value: Number(vm[1]),
29
+ ...(vm[2] ? { uom: vm[2] } : {}),
30
+ ...(warn ? { warn } : {}),
31
+ ...(crit ? { crit } : {}),
32
+ ...(num(min) != null ? { min: num(min) } : {}),
33
+ ...(num(max) != null ? { max: num(max) } : {}),
34
+ });
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * Split plugin output into the status line, the long output and perfdata.
40
+ * Perfdata can follow "|" on the first line and on any long-output line after
41
+ * a "|" there (the Nagios 3 multi-line form).
42
+ */
43
+ export function parseOutput(stdout) {
44
+ const lines = stdout.replace(/\r/g, "").replace(/\n+$/, "").split("\n");
45
+ const [first = "", ...rest] = lines;
46
+ const [output, firstPerf = ""] = splitPipe(first);
47
+ let perf = firstPerf;
48
+ const long = [];
49
+ let inPerf = false;
50
+ for (const line of rest) {
51
+ if (inPerf) {
52
+ perf += " " + line;
53
+ continue;
54
+ }
55
+ const [text, more] = splitPipe(line);
56
+ if (text.trim())
57
+ long.push(text.trimEnd());
58
+ if (more != null) {
59
+ perf += " " + more;
60
+ inPerf = true;
61
+ }
62
+ }
63
+ return {
64
+ output: output.trim(),
65
+ ...(long.length ? { longOutput: long.join("\n").slice(0, MAX_OUTPUT) } : {}),
66
+ perfdata: parsePerfdata(perf),
67
+ };
68
+ }
69
+ function splitPipe(line) {
70
+ const i = line.indexOf("|");
71
+ return i < 0 ? [line] : [line.slice(0, i), line.slice(i + 1)];
72
+ }
73
+ export async function runCheck(check, defaults = {}) {
74
+ const [bin, ...args] = check.command;
75
+ const resolved = defaults.pluginDir && !bin.includes("/") ? `${defaults.pluginDir.replace(/\/$/, "")}/${bin}` : bin;
76
+ const timeoutMs = (check.timeout ?? defaults.timeout ?? 30) * 1000;
77
+ const started = Date.now();
78
+ return new Promise((resolve) => {
79
+ execFile(resolved, args, { timeout: timeoutMs, maxBuffer: 256 * 1024, env: { ...process.env, ...(check.env || {}) }, killSignal: "SIGKILL" }, (err, stdout, stderr) => {
80
+ const durationMs = Date.now() - started;
81
+ const base = { name: check.name, command: bin.split("/").pop() || bin, durationMs };
82
+ // A non-zero exit is the plugin reporting a state, not a failure to run.
83
+ const exitCode = err ? (typeof err.code === "number" ? err.code : null) : 0;
84
+ if (err && err.killed) {
85
+ return resolve({ ...base, state: "unknown", exitCode: null, output: `Check timed out after ${timeoutMs / 1000}s`, perfdata: [], timedOut: true });
86
+ }
87
+ if (err && exitCode == null) {
88
+ // Couldn't start it at all: ENOENT, EACCES and so on.
89
+ return resolve({ ...base, state: "unknown", exitCode: null, output: `Could not run ${base.command}: ${err.code || err.message}`, perfdata: [], error: String(err.message || err).slice(0, 300) });
90
+ }
91
+ const parsed = parseOutput(String(stdout || stderr || "").slice(0, MAX_OUTPUT));
92
+ resolve({ ...base, state: stateOf(exitCode), exitCode, ...parsed, output: parsed.output || "(no output)" });
93
+ });
94
+ });
95
+ }
96
+ function slug(s) {
97
+ return s.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
98
+ }
99
+ export function createNagiosPlugin() {
100
+ let refreshTimer = null;
101
+ let running = false;
102
+ const checksOf = (plugin) => {
103
+ const cfg = (plugin.config || {});
104
+ return (Array.isArray(cfg.checks) ? cfg.checks : []).filter((c) => c?.name && Array.isArray(c.command) && c.command.length && c.command.every((a) => typeof a === "string"));
105
+ };
106
+ const refreshFn = async (plugin) => {
107
+ // A slow check must not pile up overlapping runs of itself.
108
+ if (running)
109
+ return;
110
+ running = true;
111
+ try {
112
+ const cfg = (plugin.config || {});
113
+ await Promise.all(checksOf(plugin).map(async (check) => {
114
+ const result = await runCheck(check, cfg);
115
+ // The interval tells the dashboard how long silence is normal, so a
116
+ // check that runs every 10 minutes doesn't read OFFLINE in between.
117
+ await plugin.send({ ...result, intervalMs: cfg.refreshInterval || 60000 }, `nagios-${slug(check.name)}`);
118
+ }));
119
+ }
120
+ finally {
121
+ running = false;
122
+ }
123
+ };
124
+ const monitorFn = async (plugin) => {
125
+ const n = checksOf(plugin).length;
126
+ if (!n)
127
+ console.warn("nagios: no valid checks configured (each needs a name and a command array)");
128
+ await refreshFn(plugin);
129
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
130
+ };
131
+ const teardownFn = async () => {
132
+ if (refreshTimer)
133
+ clearInterval(refreshTimer);
134
+ refreshTimer = null;
135
+ };
136
+ return createMonitoringPlugin("nagios", "nagios", "Nagios-compatible check commands", async () => { }, monitorFn, refreshFn, teardownFn);
137
+ }
138
+ export default createNagiosPlugin;
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
+ const HEADERS = { "Content-Type": "application/json;charset=utf-8", "X-Requested-With": "XMLHttpRequest", SourceType: "switch_web" };
4
+ const LOOP = { "0": "normal", "1": "blocked", "2": "loop" };
5
+ export const md5Upper = (s) => createHash("md5").update(s).digest("hex").toUpperCase();
6
+ /** Build the report from the raw API results. Pure, so the check can drive it. */
7
+ export function buildReport(target, raw, rates) {
8
+ const info = raw.info || {};
9
+ const trafficById = new Map((raw.traffic?.port_list || []).map((p) => [p.port_id, p]));
10
+ const loopById = new Map((raw.loops?.port_list || []).map((p) => [p.port_id, p]));
11
+ const interfaces = (raw.ports?.port_list || []).map((p) => {
12
+ const speed = String(p.port_speed_actual || "");
13
+ const up = !!speed && speed !== "Link Down";
14
+ const t = trafficById.get(p.port_id);
15
+ const r = rates.get(p.port_id) || {};
16
+ return {
17
+ name: `port ${p.port_id}`,
18
+ up,
19
+ adminUp: p.port_state_cfg !== "0",
20
+ ...(up ? { speed, duplex: p.port_duplex_actual } : {}),
21
+ ...(t ? { rxBytes: t.rx_bytes, txBytes: t.tx_bytes } : {}),
22
+ ...(r.inBps != null ? { inBps: r.inBps, outBps: r.outBps } : {}),
23
+ loop: LOOP[String(loopById.get(p.port_id)?.port_state ?? "0")] || "normal",
24
+ };
25
+ });
26
+ const portIds = (raw.ports?.port_list || []).map((p) => p.port_id);
27
+ const downUplinks = (target.uplinkPorts || []).filter((id) => {
28
+ const i = portIds.indexOf(id);
29
+ return i < 0 || !interfaces[i].up;
30
+ });
31
+ return {
32
+ ip: target.host,
33
+ name: target.name || info.des || target.host,
34
+ kind: "switch",
35
+ model: info.hw_ver || info.des,
36
+ serial: info.serial_number,
37
+ mac: info.sys_mac_addr,
38
+ firmware: info.fw_ver,
39
+ sysDescr: [info.hw_ver || info.des, info.fw_ver && `firmware ${info.fw_ver}`].filter(Boolean).join(", "),
40
+ reachable: true,
41
+ portsUp: interfaces.filter((i) => i.up).length,
42
+ portsTotal: interfaces.length,
43
+ interfaces,
44
+ loops: interfaces.filter((i) => i.loop === "loop").map((i) => i.name),
45
+ blocked: interfaces.filter((i) => i.loop === "blocked").map((i) => i.name),
46
+ downUplinks: downUplinks.map((id) => `port ${id}`),
47
+ };
48
+ }
49
+ /** Byte rates from two counter readings. A counter that went backwards (reboot, clear) gives no rate. */
50
+ export function ratesBetween(prev, now) {
51
+ const out = new Map();
52
+ if (!prev)
53
+ return out;
54
+ const dt = (now.at - prev.at) / 1000;
55
+ if (dt <= 0)
56
+ return out;
57
+ for (const [id, c] of now.ports) {
58
+ const p = prev.ports.get(id);
59
+ if (!p || c.rx < p.rx || c.tx < p.tx)
60
+ continue;
61
+ out.set(id, { inBps: Math.round((c.rx - p.rx) / dt), outBps: Math.round((c.tx - p.tx) / dt) });
62
+ }
63
+ return out;
64
+ }
65
+ export class LoginRefused extends Error {
66
+ }
67
+ async function call(host, path, opts) {
68
+ const res = await fetch(`http://${host}${path}`, {
69
+ method: opts.body !== undefined ? "POST" : "GET",
70
+ headers: opts.token ? { ...HEADERS, Authorization: opts.token } : HEADERS,
71
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
72
+ signal: AbortSignal.timeout(opts.timeoutMs),
73
+ });
74
+ if (!res.ok)
75
+ throw new Error(`${path} returned HTTP ${res.status}`);
76
+ const json = await res.json();
77
+ if (json?.error_code)
78
+ throw Object.assign(new Error(`${path} error_code ${json.error_code}`), { code: json.error_code });
79
+ return json?.result || {};
80
+ }
81
+ export const newSwitchState = () => ({ counters: new Map(), refusedUntil: new Map() });
82
+ /** Log in, read one switch, log out. Throws LoginRefused or a network error. */
83
+ export async function readSwitch(target, cfg, state) {
84
+ const { counters, refusedUntil } = state;
85
+ const timeoutMs = cfg.timeoutMs || 8000;
86
+ const wait = refusedUntil.get(target.host);
87
+ if (wait && Date.now() < wait) {
88
+ throw new LoginRefused(`login refused earlier; not retrying until ${new Date(wait).toISOString()} so the switch doesn't lock the account`);
89
+ }
90
+ let login;
91
+ try {
92
+ login = await call(target.host, "/login/status", { body: { login_user: target.username, login_pwd: md5Upper(target.password) }, timeoutMs });
93
+ }
94
+ catch (e) {
95
+ if (e?.code) {
96
+ refusedUntil.set(target.host, Date.now() + (cfg.loginBackoffMs || 30 * 60 * 1000));
97
+ throw new LoginRefused(`login refused (error_code ${e.code}); check the username and password`);
98
+ }
99
+ throw e;
100
+ }
101
+ refusedUntil.delete(target.host);
102
+ const token = login.token;
103
+ try {
104
+ const [info, ports, traffic, loops] = await Promise.all([
105
+ call(target.host, "/system_summary/system_information_status", { token, timeoutMs }),
106
+ call(target.host, "/switching/port_settings_status", { token, timeoutMs }),
107
+ call(target.host, "/traffic_summary/traffic_summary_port_status", { token, timeoutMs }),
108
+ call(target.host, "/loop_detection/loop_detection_status", { token, timeoutMs }).catch(() => ({})),
109
+ ]);
110
+ const now = {
111
+ at: Date.now(),
112
+ ports: new Map((traffic.port_list || []).map((p) => [p.port_id, { rx: Number(p.rx_bytes) || 0, tx: Number(p.tx_bytes) || 0 }])),
113
+ };
114
+ const rates = ratesBetween(counters.get(target.host), now);
115
+ counters.set(target.host, now);
116
+ return buildReport(target, { info, ports, traffic, loops }, rates);
117
+ }
118
+ finally {
119
+ // Log out every time: these switches allow few admin sessions, and an
120
+ // idle one held by the agent could lock someone out of the web UI.
121
+ await call(target.host, "/show_up/logout_status", { token, body: {}, timeoutMs }).catch(() => { });
122
+ }
123
+ }
124
+ export function createTplinkSwitchPlugin() {
125
+ let refreshTimer = null;
126
+ const state = newSwitchState();
127
+ const refreshFn = async (plugin) => {
128
+ const cfg = (plugin.config || {});
129
+ const targets = (cfg.switches || []).filter((s) => s?.host && s.username && s.password);
130
+ await Promise.all(targets.map(async (target) => {
131
+ let report;
132
+ try {
133
+ report = await readSwitch(target, cfg, state);
134
+ }
135
+ catch (e) {
136
+ report = {
137
+ ip: target.host,
138
+ name: target.name || target.host,
139
+ kind: "switch",
140
+ reachable: e instanceof LoginRefused,
141
+ error: String(e?.message || e).slice(0, 200),
142
+ };
143
+ }
144
+ await plugin.send(report, `tplinkswitch-${target.host.replace(/[^0-9a-zA-Z]+/g, "-")}`);
145
+ }));
146
+ };
147
+ const monitorFn = async (plugin) => {
148
+ const cfg = (plugin.config || {});
149
+ if (!(cfg.switches || []).length)
150
+ console.warn("tplinkswitch: no switches configured");
151
+ await refreshFn(plugin);
152
+ refreshTimer = setInterval(() => refreshFn(plugin), cfg.refreshInterval || 60000);
153
+ };
154
+ const teardownFn = async () => {
155
+ if (refreshTimer)
156
+ clearInterval(refreshTimer);
157
+ refreshTimer = null;
158
+ };
159
+ return createMonitoringPlugin("tplinkswitch", "tplinkswitch", "TP-Link Omada Easy Managed switch (web API)", async () => { }, monitorFn, refreshFn, teardownFn);
160
+ }
161
+ export default createTplinkSwitchPlugin;
@@ -130,7 +130,7 @@ export function createWebsitePlugin() {
130
130
  try {
131
131
  const ctrl = new AbortController();
132
132
  const t = ep.timeout ? setTimeout(() => ctrl.abort(), ep.timeout) : null;
133
- const res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow" });
133
+ const res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow", headers: ep.headers });
134
134
  if (t)
135
135
  clearTimeout(t);
136
136
  const body = await res.text();
@@ -180,6 +180,7 @@ export function createWebsitePlugin() {
180
180
  unexpectedStrings: cfg.unexpectedStrings,
181
181
  expectedStatusCode: cfg.expectedStatusCode,
182
182
  timeout: cfg.timeout,
183
+ headers: cfg.headers,
183
184
  origin: cfg.origin,
184
185
  }];
185
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.32",
3
+ "version": "1.1.34",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",