@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.
- package/dist/plugins/index.js +6 -0
- package/dist/plugins/monitoring/AdsensePlugin.js +94 -21
- package/dist/plugins/monitoring/Ga4Plugin.js +164 -0
- package/dist/plugins/monitoring/NagiosPlugin.js +138 -0
- package/dist/plugins/monitoring/TplinkSwitchPlugin.js +161 -0
- package/dist/plugins/monitoring/WebsitePlugin.js +2 -1
- package/package.json +1 -1
package/dist/plugins/index.js
CHANGED
|
@@ -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
|
|
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
|
|
121
|
-
|
|
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
|
|
144
|
-
//
|
|
145
|
-
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval ||
|
|
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
|
}
|