@daemonitor/plugins 1.1.33 → 1.1.35
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/CostPlugin.js +124 -0
- package/dist/plugins/monitoring/CostProviders.js +353 -0
- package/dist/plugins/monitoring/NagiosPlugin.js +138 -0
- package/dist/plugins/monitoring/TplinkSwitchPlugin.js +163 -0
- package/dist/plugins/monitoring/WebsitePlugin.js +26 -8
- package/package.json +1 -1
package/dist/plugins/index.js
CHANGED
|
@@ -13,6 +13,9 @@ import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
|
|
|
13
13
|
import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
|
|
14
14
|
import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
|
|
15
15
|
import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
|
|
16
|
+
import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
|
|
17
|
+
import { createTplinkSwitchPlugin } from "./monitoring/TplinkSwitchPlugin.js";
|
|
18
|
+
import { createCostPlugin } from "./monitoring/CostPlugin.js";
|
|
16
19
|
// In future, implement other plugin factory functions using the same pattern
|
|
17
20
|
// import { createOSPlugin } from "./monitoring/OSPlugin.js"
|
|
18
21
|
// import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
|
|
@@ -37,6 +40,9 @@ export default {
|
|
|
37
40
|
Patroni: createPatroniPlugin,
|
|
38
41
|
Adsense: createAdsensePlugin,
|
|
39
42
|
Ga4: createGa4Plugin,
|
|
43
|
+
Nagios: createNagiosPlugin,
|
|
44
|
+
TplinkSwitch: createTplinkSwitchPlugin,
|
|
45
|
+
Cost: createCostPlugin,
|
|
40
46
|
// CloudflarePlugin: createCloudflarePlugin,
|
|
41
47
|
// EwelinkPlugin: createEwelinkPlugin,
|
|
42
48
|
// MongoDBPlugin: createMongoDBPlugin,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
2
|
+
import { PluginManager } from "../../lib/PluginManager.js";
|
|
3
|
+
import { PROVIDERS } from "./CostProviders.js";
|
|
4
|
+
const DEFAULT_INTERVAL = 3600000;
|
|
5
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
6
|
+
export function shiftDate(date, days) {
|
|
7
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
8
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
9
|
+
return t.toISOString().slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
export const todayUtc = (now = new Date()) => now.toISOString().slice(0, 10);
|
|
12
|
+
/** Earlier of the 1st of this month and 29 days back: one fetch covers the month and a 30-day trace. */
|
|
13
|
+
export function fetchStart(today) {
|
|
14
|
+
const monthStart = `${today.slice(0, 7)}-01`;
|
|
15
|
+
const back = shiftDate(today, -29);
|
|
16
|
+
return back < monthStart ? back : monthStart;
|
|
17
|
+
}
|
|
18
|
+
export function daysInMonth(today) {
|
|
19
|
+
const [y, m] = today.split("-").map(Number);
|
|
20
|
+
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
21
|
+
}
|
|
22
|
+
export function summarizeCost(f, today) {
|
|
23
|
+
const month = today.slice(0, 7);
|
|
24
|
+
const dom = Number(today.slice(8, 10));
|
|
25
|
+
const dim = daysInMonth(today);
|
|
26
|
+
if (!f.days) {
|
|
27
|
+
// ponytail: straight-line projection from MTD over elapsed days; today's
|
|
28
|
+
// partial day drags it low early in the month. Fine for an invoice-level API.
|
|
29
|
+
const mtd = Number(f.monthToDate) || 0;
|
|
30
|
+
return { monthToDate: round2(mtd), projected: round2((mtd / dom) * dim), daily: [] };
|
|
31
|
+
}
|
|
32
|
+
const by = new Map();
|
|
33
|
+
for (const d of f.days)
|
|
34
|
+
by.set(d.date, (by.get(d.date) || 0) + (Number(d.amount) || 0));
|
|
35
|
+
const at = (date) => by.get(date) || 0;
|
|
36
|
+
// Gap-filled, oldest first, one point per day for the trace.
|
|
37
|
+
const daily = [];
|
|
38
|
+
for (let i = 29; i >= 0; i--) {
|
|
39
|
+
const date = shiftDate(today, -i);
|
|
40
|
+
daily.push({ date, amount: round2(at(date)) });
|
|
41
|
+
}
|
|
42
|
+
let mtd = 0;
|
|
43
|
+
for (const [date, amount] of by)
|
|
44
|
+
if (date.startsWith(month) && date <= today)
|
|
45
|
+
mtd += amount;
|
|
46
|
+
const todayAmt = at(today);
|
|
47
|
+
// Project from COMPLETE days only: today is still filling in, and on the 1st
|
|
48
|
+
// there is nothing complete yet, so fall back to the partial day.
|
|
49
|
+
const projected = dom > 1 ? ((mtd - todayAmt) / (dom - 1)) * dim : mtd * dim;
|
|
50
|
+
// The 7 complete days before yesterday, the baseline a spike is judged against.
|
|
51
|
+
let prior = 0;
|
|
52
|
+
for (let i = 2; i <= 8; i++)
|
|
53
|
+
prior += at(shiftDate(today, -i));
|
|
54
|
+
return {
|
|
55
|
+
monthToDate: round2(mtd),
|
|
56
|
+
projected: round2(projected),
|
|
57
|
+
today: round2(todayAmt),
|
|
58
|
+
yesterday: round2(at(shiftDate(today, -1))),
|
|
59
|
+
avg7: round2(prior / 7),
|
|
60
|
+
last30: round2(daily.reduce((s, d) => s + d.amount, 0)),
|
|
61
|
+
daily,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function collectCost(p, today = todayUtc()) {
|
|
65
|
+
// `authFrom` borrows credentials from another config block, e.g. the
|
|
66
|
+
// top-level `workspace` block google-auth.mjs writes.
|
|
67
|
+
if (p.authFrom)
|
|
68
|
+
p = { ...PluginManager.getConfig(p.authFrom), ...p };
|
|
69
|
+
const fetcher = PROVIDERS[p.provider];
|
|
70
|
+
if (!fetcher)
|
|
71
|
+
throw new Error(`unknown cost provider "${p.provider}" (have: ${Object.keys(PROVIDERS).join(", ")})`);
|
|
72
|
+
const f = await fetcher(p, { start: fetchStart(today), end: today }, p.timeout || 20000);
|
|
73
|
+
return {
|
|
74
|
+
currency: (f.currency || "USD").toUpperCase(),
|
|
75
|
+
asOf: today,
|
|
76
|
+
...summarizeCost(f, today),
|
|
77
|
+
breakdown: (f.breakdown || []).filter((b) => b.amount).sort((a, b) => b.amount - a.amount).slice(0, 12)
|
|
78
|
+
.map((b) => ({ name: b.name, amount: round2(b.amount) })),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function createCostPlugin() {
|
|
82
|
+
let refreshTimer = null;
|
|
83
|
+
const refreshFn = async (plugin) => {
|
|
84
|
+
const cfg = (plugin.config || {});
|
|
85
|
+
const intervalMs = cfg.refreshInterval || DEFAULT_INTERVAL;
|
|
86
|
+
for (const p of cfg.providers || []) {
|
|
87
|
+
const id = p.id || p.provider;
|
|
88
|
+
const meta = {
|
|
89
|
+
name: p.name || p.provider,
|
|
90
|
+
provider: p.provider,
|
|
91
|
+
intervalMs,
|
|
92
|
+
...(p.budget ? { budget: Number(p.budget) } : {}),
|
|
93
|
+
...(p.spikeFactor ? { spikeFactor: Number(p.spikeFactor) } : {}),
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await plugin.send({ ...meta, ...(await collectCost(p)) }, `cost-${id}`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
await plugin.send({
|
|
100
|
+
...meta,
|
|
101
|
+
error: String(e?.message || e).slice(0, 160),
|
|
102
|
+
...(e?.status === 401 || e?.status === 403 ? { authExpired: true } : {}),
|
|
103
|
+
}, `cost-${id}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const monitorFn = async (plugin) => {
|
|
108
|
+
const cfg = (plugin.config || {});
|
|
109
|
+
if (!cfg.providers?.length) {
|
|
110
|
+
console.error("cost plugin: no providers configured");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await refreshFn(plugin);
|
|
114
|
+
// Billing data lags by hours and AWS charges per Cost Explorer call, so hourly loses nothing.
|
|
115
|
+
refreshTimer = setInterval(() => refreshFn(plugin), cfg.refreshInterval || DEFAULT_INTERVAL);
|
|
116
|
+
};
|
|
117
|
+
const teardownFn = async () => {
|
|
118
|
+
if (refreshTimer)
|
|
119
|
+
clearInterval(refreshTimer);
|
|
120
|
+
refreshTimer = null;
|
|
121
|
+
};
|
|
122
|
+
return createMonitoringPlugin("cost", "cost", "Service spend per provider (daily, month to date, projection against a budget)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
123
|
+
}
|
|
124
|
+
export default createCostPlugin;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { googleAccessToken } from "./AdsensePlugin.js";
|
|
3
|
+
/** Thrown for non-2xx; `status` lets the plugin tell a dead key (401/403) from an outage. */
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function call(url, init, timeout) {
|
|
12
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
const text = await res.text().catch(() => "");
|
|
15
|
+
let msg = text.slice(0, 160);
|
|
16
|
+
try {
|
|
17
|
+
const j = JSON.parse(text);
|
|
18
|
+
msg = j?.error?.message || j?.errors?.[0]?.message || j?.message || j?.detail || j?.msg || (typeof j?.error === "string" ? j.error : msg);
|
|
19
|
+
}
|
|
20
|
+
catch { /* not JSON */ }
|
|
21
|
+
throw new HttpError(`HTTP ${res.status}${msg ? `: ${msg}` : ""}`, res.status);
|
|
22
|
+
}
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
const json = async (url, init, timeout) => (await call(url, init, timeout)).json();
|
|
26
|
+
const need = (cfg, ...keys) => {
|
|
27
|
+
const missing = keys.filter((k) => !cfg[k]);
|
|
28
|
+
if (missing.length)
|
|
29
|
+
throw new Error(`${cfg.provider} needs ${missing.join(", ")}`);
|
|
30
|
+
};
|
|
31
|
+
export const nextDay = (date) => {
|
|
32
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
33
|
+
t.setUTCDate(t.getUTCDate() + 1);
|
|
34
|
+
return t.toISOString().slice(0, 10);
|
|
35
|
+
};
|
|
36
|
+
const unixDay = (date) => Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
|
|
37
|
+
const isoDay = (unixSec) => new Date(unixSec * 1000).toISOString().slice(0, 10);
|
|
38
|
+
/** Accumulates per-day amounts and per-item month-to-date amounts. */
|
|
39
|
+
export function tally(month) {
|
|
40
|
+
const days = new Map();
|
|
41
|
+
const items = new Map();
|
|
42
|
+
return {
|
|
43
|
+
add(date, amount, item) {
|
|
44
|
+
if (!date || !Number.isFinite(amount))
|
|
45
|
+
return;
|
|
46
|
+
days.set(date, (days.get(date) || 0) + amount);
|
|
47
|
+
if (item && date.startsWith(month))
|
|
48
|
+
items.set(item, (items.get(item) || 0) + amount);
|
|
49
|
+
},
|
|
50
|
+
days: () => [...days].map(([date, amount]) => ({ date, amount })),
|
|
51
|
+
breakdown: () => [...items].map(([name, amount]) => ({ name, amount })),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// OpenAI: Costs API, admin key. Amounts are numbers in dollars. End is exclusive.
|
|
55
|
+
const openai = async (cfg, { start, end }, timeout) => {
|
|
56
|
+
need(cfg, "adminKey");
|
|
57
|
+
const t = tally(end.slice(0, 7));
|
|
58
|
+
let page;
|
|
59
|
+
do {
|
|
60
|
+
const q = new URLSearchParams({ start_time: String(unixDay(start)), end_time: String(unixDay(nextDay(end))), bucket_width: "1d", limit: "31" });
|
|
61
|
+
q.append("group_by[]", "line_item");
|
|
62
|
+
if (page)
|
|
63
|
+
q.set("page", page);
|
|
64
|
+
const body = await json(`https://api.openai.com/v1/organization/costs?${q}`, { headers: { Authorization: `Bearer ${cfg.adminKey}` } }, timeout);
|
|
65
|
+
for (const b of body.data || []) {
|
|
66
|
+
for (const r of b.results || [])
|
|
67
|
+
t.add(isoDay(b.start_time), Number(r.amount?.value) || 0, r.line_item || undefined);
|
|
68
|
+
}
|
|
69
|
+
page = body.has_more ? body.next_page : undefined;
|
|
70
|
+
} while (page);
|
|
71
|
+
return { currency: "USD", days: t.days(), breakdown: t.breakdown() };
|
|
72
|
+
};
|
|
73
|
+
// Anthropic: Admin API cost report. Amount is a decimal STRING in CENTS.
|
|
74
|
+
const anthropic = async (cfg, { start, end }, timeout) => {
|
|
75
|
+
need(cfg, "adminKey");
|
|
76
|
+
const t = tally(end.slice(0, 7));
|
|
77
|
+
let page;
|
|
78
|
+
do {
|
|
79
|
+
const q = new URLSearchParams({ starting_at: `${start}T00:00:00Z`, ending_at: `${nextDay(end)}T00:00:00Z`, bucket_width: "1d", limit: "31" });
|
|
80
|
+
q.append("group_by[]", "description");
|
|
81
|
+
if (page)
|
|
82
|
+
q.set("page", page);
|
|
83
|
+
const body = await json(`https://api.anthropic.com/v1/organizations/cost_report?${q}`, {
|
|
84
|
+
headers: { "x-api-key": cfg.adminKey, "anthropic-version": "2023-06-01" },
|
|
85
|
+
}, timeout);
|
|
86
|
+
for (const b of body.data || []) {
|
|
87
|
+
const date = String(b.starting_at || "").slice(0, 10);
|
|
88
|
+
for (const r of b.results || [])
|
|
89
|
+
t.add(date, (parseFloat(r.amount) || 0) / 100, r.model || r.description || r.cost_type || undefined);
|
|
90
|
+
}
|
|
91
|
+
page = body.has_more ? body.next_page : undefined;
|
|
92
|
+
} while (page);
|
|
93
|
+
return { currency: "USD", days: t.days(), breakdown: t.breakdown() };
|
|
94
|
+
};
|
|
95
|
+
/** AWS SigV4 for a JSON POST. Small enough not to pull in the SDK for one call. */
|
|
96
|
+
export function sigv4(opts) {
|
|
97
|
+
const now = opts.now || new Date();
|
|
98
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
99
|
+
const day = amzDate.slice(0, 8);
|
|
100
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
101
|
+
const hmac = (k, s) => createHmac("sha256", k).update(s).digest();
|
|
102
|
+
const headers = {
|
|
103
|
+
"content-type": "application/x-amz-json-1.1",
|
|
104
|
+
host: opts.host,
|
|
105
|
+
"x-amz-date": amzDate,
|
|
106
|
+
"x-amz-target": opts.target,
|
|
107
|
+
...(opts.sessionToken ? { "x-amz-security-token": opts.sessionToken } : {}),
|
|
108
|
+
};
|
|
109
|
+
const names = Object.keys(headers).sort();
|
|
110
|
+
const canonical = ["POST", "/", "", ...names.map((n) => `${n}:${headers[n]}`), "", names.join(";"), sha(opts.body)].join("\n");
|
|
111
|
+
const scope = `${day}/${opts.region}/${opts.service}/aws4_request`;
|
|
112
|
+
const toSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha(canonical)].join("\n");
|
|
113
|
+
const key = hmac(hmac(hmac(hmac(`AWS4${opts.secretAccessKey}`, day), opts.region), opts.service), "aws4_request");
|
|
114
|
+
const sig = createHmac("sha256", key).update(toSign).digest("hex");
|
|
115
|
+
headers.authorization = `AWS4-HMAC-SHA256 Credential=${opts.accessKeyId}/${scope}, SignedHeaders=${names.join(";")}, Signature=${sig}`;
|
|
116
|
+
return headers;
|
|
117
|
+
}
|
|
118
|
+
// AWS: Cost Explorer GetCostAndUsage. $0.01 per request, so this is one call
|
|
119
|
+
// (plus pages). Amounts are dollar strings. End is exclusive.
|
|
120
|
+
const aws = async (cfg, { start, end }, timeout) => {
|
|
121
|
+
need(cfg, "accessKeyId", "secretAccessKey");
|
|
122
|
+
const t = tally(end.slice(0, 7));
|
|
123
|
+
let token;
|
|
124
|
+
let currency = "USD";
|
|
125
|
+
do {
|
|
126
|
+
const body = JSON.stringify({
|
|
127
|
+
TimePeriod: { Start: start, End: nextDay(end) },
|
|
128
|
+
Granularity: "DAILY",
|
|
129
|
+
Metrics: ["UnblendedCost"],
|
|
130
|
+
GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }],
|
|
131
|
+
...(token ? { NextPageToken: token } : {}),
|
|
132
|
+
});
|
|
133
|
+
const host = "ce.us-east-1.amazonaws.com";
|
|
134
|
+
const headers = sigv4({
|
|
135
|
+
host, region: "us-east-1", service: "ce", target: "AWSInsightsIndexService.GetCostAndUsage", body,
|
|
136
|
+
accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey, sessionToken: cfg.sessionToken,
|
|
137
|
+
});
|
|
138
|
+
const res = await json(`https://${host}/`, { method: "POST", headers, body }, timeout);
|
|
139
|
+
for (const r of res.ResultsByTime || []) {
|
|
140
|
+
const date = r.TimePeriod?.Start;
|
|
141
|
+
for (const g of r.Groups || []) {
|
|
142
|
+
const m = g.Metrics?.UnblendedCost;
|
|
143
|
+
if (m?.Unit)
|
|
144
|
+
currency = m.Unit;
|
|
145
|
+
t.add(date, parseFloat(m?.Amount) || 0, g.Keys?.[0]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
token = res.NextPageToken;
|
|
149
|
+
} while (token);
|
|
150
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
151
|
+
};
|
|
152
|
+
// Vercel: FOCUS billing charges, streamed as JSONL, many lines per day (one per SKU).
|
|
153
|
+
const vercel = async (cfg, { start, end }, timeout) => {
|
|
154
|
+
need(cfg, "token", "teamId");
|
|
155
|
+
const t = tally(end.slice(0, 7));
|
|
156
|
+
const q = new URLSearchParams({ teamId: cfg.teamId, from: `${start}T00:00:00Z`, to: `${nextDay(end)}T00:00:00Z` });
|
|
157
|
+
const text = await (await call(`https://api.vercel.com/v1/billing/charges?${q}`, { headers: { Authorization: `Bearer ${cfg.token}` } }, timeout)).text();
|
|
158
|
+
let currency = "USD";
|
|
159
|
+
for (const line of text.split("\n")) {
|
|
160
|
+
if (!line.trim())
|
|
161
|
+
continue;
|
|
162
|
+
const c = JSON.parse(line);
|
|
163
|
+
if (c.BillingCurrency)
|
|
164
|
+
currency = c.BillingCurrency;
|
|
165
|
+
t.add(String(c.ChargePeriodStart || "").slice(0, 10), Number(c.BilledCost) || 0, c.ServiceName || undefined);
|
|
166
|
+
}
|
|
167
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
168
|
+
};
|
|
169
|
+
// Fastly: month-to-date invoice only. No daily data exists in the API.
|
|
170
|
+
const fastly = async (cfg, _range, timeout) => {
|
|
171
|
+
need(cfg, "token");
|
|
172
|
+
const inv = await json("https://api.fastly.com/billing/v3/invoices/month-to-date", { headers: { "Fastly-Key": cfg.token, Accept: "application/json" } }, timeout);
|
|
173
|
+
const items = inv.transaction_line_items || [];
|
|
174
|
+
return {
|
|
175
|
+
currency: inv.currency_code || "USD",
|
|
176
|
+
monthToDate: Number(inv.monthly_transaction_amount) || items.reduce((s, i) => s + (Number(i.amount) || 0), 0),
|
|
177
|
+
breakdown: items.map((i) => ({ name: i.product_name || i.description || "item", amount: Number(i.amount) || 0 })),
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
// Cloudflare: billable usage (alpha, PayGo usage only, not plan fees). Called
|
|
181
|
+
// with no range: it then returns the current billing period, which sidesteps
|
|
182
|
+
// the rule that a range must include the subscription's billing-anchor day.
|
|
183
|
+
const cloudflare = async (cfg, { end }, timeout) => {
|
|
184
|
+
need(cfg, "token", "accountId");
|
|
185
|
+
const t = tally(end.slice(0, 7));
|
|
186
|
+
const body = await json(`https://api.cloudflare.com/client/v4/accounts/${cfg.accountId}/billable-usage`, { headers: { Authorization: `Bearer ${cfg.token}` } }, timeout);
|
|
187
|
+
let currency = "USD";
|
|
188
|
+
for (const r of body.result || []) {
|
|
189
|
+
if (r.BillingCurrency)
|
|
190
|
+
currency = r.BillingCurrency;
|
|
191
|
+
t.add(String(r.ChargePeriodStart || "").slice(0, 10), Number(r.BilledCost) || 0, r.ServiceName || r.ChargeDescription || undefined);
|
|
192
|
+
}
|
|
193
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
194
|
+
};
|
|
195
|
+
// PostHog: daily spend series, personal API key with billing:read. End is INCLUSIVE.
|
|
196
|
+
const posthog = async (cfg, { start, end }, timeout) => {
|
|
197
|
+
need(cfg, "apiKey");
|
|
198
|
+
const host = (cfg.host || "https://us.posthog.com").replace(/\/$/, "");
|
|
199
|
+
const t = tally(end.slice(0, 7));
|
|
200
|
+
const q = new URLSearchParams({ start_date: start, end_date: end, interval: "day", breakdowns: JSON.stringify(["type"]) });
|
|
201
|
+
let url = `${host}/api/billing/spend/?${q}`;
|
|
202
|
+
while (url) {
|
|
203
|
+
const body = await json(url, { headers: { Authorization: `Bearer ${cfg.apiKey}` } }, timeout);
|
|
204
|
+
for (const s of body.results || []) {
|
|
205
|
+
const dates = s.dates || [];
|
|
206
|
+
dates.forEach((d, i) => t.add(d, Number(s.data?.[i]) || 0, s.breakdown_value || s.label || undefined));
|
|
207
|
+
}
|
|
208
|
+
url = body.next || null;
|
|
209
|
+
}
|
|
210
|
+
return { currency: "USD", days: t.days(), breakdown: t.breakdown() };
|
|
211
|
+
};
|
|
212
|
+
// Google Ads: GAQL over searchStream. cost_micros is an int64 string. Dates are
|
|
213
|
+
// the ACCOUNT's time zone, which this treats as UTC days (off by a few hours at most).
|
|
214
|
+
const googleAds = async (cfg, { start, end }, timeout) => {
|
|
215
|
+
need(cfg, "clientId", "clientSecret", "refreshToken", "developerToken", "customerId");
|
|
216
|
+
const t = tally(end.slice(0, 7));
|
|
217
|
+
const customer = String(cfg.customerId).replace(/-/g, "");
|
|
218
|
+
const headers = {
|
|
219
|
+
Authorization: `Bearer ${await googleAccessToken({ clientId: cfg.clientId, clientSecret: cfg.clientSecret, refreshToken: cfg.refreshToken }, timeout)}`,
|
|
220
|
+
"developer-token": cfg.developerToken,
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
...(cfg.loginCustomerId ? { "login-customer-id": String(cfg.loginCustomerId).replace(/-/g, "") } : {}),
|
|
223
|
+
};
|
|
224
|
+
const query = `SELECT segments.date, customer.currency_code, campaign.name, metrics.cost_micros FROM campaign WHERE segments.date BETWEEN '${start}' AND '${end}'`;
|
|
225
|
+
const batches = await json(`https://googleads.googleapis.com/${cfg.apiVersion || "v26"}/customers/${customer}/googleAds:searchStream`, {
|
|
226
|
+
method: "POST", headers, body: JSON.stringify({ query }),
|
|
227
|
+
}, timeout);
|
|
228
|
+
let currency = "USD";
|
|
229
|
+
for (const b of Array.isArray(batches) ? batches : [batches]) {
|
|
230
|
+
for (const r of b.results || []) {
|
|
231
|
+
if (r.customer?.currencyCode)
|
|
232
|
+
currency = r.customer.currencyCode;
|
|
233
|
+
t.add(r.segments?.date, (Number(r.metrics?.costMicros) || 0) / 1e6, r.campaign?.name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
237
|
+
};
|
|
238
|
+
// Google Cloud: there is no spend REST API. The supported source is the Cloud
|
|
239
|
+
// Billing export to BigQuery (standard usage cost), queried here with
|
|
240
|
+
// jobs.query. Net cost = cost + credits (credits are negative). The export
|
|
241
|
+
// lands hours late, so today and yesterday fill in after the fact.
|
|
242
|
+
// ponytail: OAuth refresh token (scope bigquery.readonly), same as the other
|
|
243
|
+
// Google sources. Add service-account JWT auth if a human token is unwelcome.
|
|
244
|
+
const gcp = async (cfg, { start, end }, timeout) => {
|
|
245
|
+
need(cfg, "clientId", "clientSecret", "refreshToken", "projectId", "table");
|
|
246
|
+
// `project.dataset.gcp_billing_export_v1_XXXXXX`. Validated because it is
|
|
247
|
+
// spliced into SQL; BigQuery cannot bind a table name as a parameter.
|
|
248
|
+
if (!/^[\w-]+\.[\w]+\.[\w]+$/.test(cfg.table))
|
|
249
|
+
throw new Error("gcp table must look like project.dataset.gcp_billing_export_v1_XXXXXX");
|
|
250
|
+
const t = tally(end.slice(0, 7));
|
|
251
|
+
const query = `SELECT FORMAT_DATE('%F', DATE(usage_start_time)) AS day, service.description AS item, ANY_VALUE(currency) AS currency,
|
|
252
|
+
SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS amount
|
|
253
|
+
FROM \`${cfg.table}\`
|
|
254
|
+
WHERE DATE(_PARTITIONTIME) >= DATE_SUB(@start, INTERVAL 1 DAY) AND DATE(usage_start_time) BETWEEN @start AND @end
|
|
255
|
+
GROUP BY day, item`;
|
|
256
|
+
const body = await json(`https://bigquery.googleapis.com/bigquery/v2/projects/${cfg.projectId}/queries`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: {
|
|
259
|
+
Authorization: `Bearer ${await googleAccessToken({ clientId: cfg.clientId, clientSecret: cfg.clientSecret, refreshToken: cfg.refreshToken }, timeout)}`,
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
},
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
query, useLegacySql: false, timeoutMs: timeout, parameterMode: "NAMED",
|
|
264
|
+
queryParameters: [
|
|
265
|
+
{ name: "start", parameterType: { type: "DATE" }, parameterValue: { value: start } },
|
|
266
|
+
{ name: "end", parameterType: { type: "DATE" }, parameterValue: { value: end } },
|
|
267
|
+
],
|
|
268
|
+
}),
|
|
269
|
+
}, timeout + 5000);
|
|
270
|
+
if (body.jobComplete === false)
|
|
271
|
+
throw new Error("BigQuery query did not finish in time");
|
|
272
|
+
let currency = "USD";
|
|
273
|
+
// Rows come back as { f: [{ v }] } in SELECT order, every value a string.
|
|
274
|
+
for (const r of body.rows || []) {
|
|
275
|
+
const [day, item, cur, amount] = (r.f || []).map((c) => c?.v);
|
|
276
|
+
if (cur)
|
|
277
|
+
currency = cur;
|
|
278
|
+
t.add(day, Number(amount) || 0, item || undefined);
|
|
279
|
+
}
|
|
280
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
281
|
+
};
|
|
282
|
+
// Meta Ads: Marketing API insights, one row per day. spend is a numeric string.
|
|
283
|
+
const metaAds = async (cfg, { start, end }, timeout) => {
|
|
284
|
+
need(cfg, "accessToken", "adAccountId");
|
|
285
|
+
const t = tally(end.slice(0, 7));
|
|
286
|
+
const acct = String(cfg.adAccountId).replace(/^act_/, "");
|
|
287
|
+
const v = cfg.apiVersion || "v26.0";
|
|
288
|
+
const auth = { headers: { Authorization: `Bearer ${cfg.accessToken}` } };
|
|
289
|
+
const { currency } = await json(`https://graph.facebook.com/${v}/act_${acct}?fields=currency`, auth, timeout);
|
|
290
|
+
const q = new URLSearchParams({
|
|
291
|
+
fields: "spend,campaign_name", level: "campaign", time_increment: "1",
|
|
292
|
+
time_range: JSON.stringify({ since: start, until: end }), limit: "500",
|
|
293
|
+
});
|
|
294
|
+
let url = `https://graph.facebook.com/${v}/act_${acct}/insights?${q}`;
|
|
295
|
+
while (url) {
|
|
296
|
+
const body = await json(url, auth, timeout);
|
|
297
|
+
for (const r of body.data || [])
|
|
298
|
+
t.add(r.date_start, parseFloat(r.spend) || 0, r.campaign_name);
|
|
299
|
+
url = body.paging?.next || null;
|
|
300
|
+
}
|
|
301
|
+
return { currency: currency || "USD", days: t.days(), breakdown: t.breakdown() };
|
|
302
|
+
};
|
|
303
|
+
// Google Workspace: there is no invoice API, so this is seats x price. The
|
|
304
|
+
// Licensing API lists every assigned seat with its plan (SKU), and seats are
|
|
305
|
+
// priced from Google's published list prices (USD per seat per month) unless
|
|
306
|
+
// `prices` overrides them by SKU name or id. The bill accrues evenly through the
|
|
307
|
+
// month, so each day carries 1/daysInMonth of it and the projection is the
|
|
308
|
+
// monthly bill itself.
|
|
309
|
+
// ponytail: today's seat count is applied to the whole 30-day trace (no seat
|
|
310
|
+
// history), and Enterprise plans have no public price, so they cost 0 until
|
|
311
|
+
// `prices` names one.
|
|
312
|
+
export const WORKSPACE_LIST_PRICE = {
|
|
313
|
+
"1010020027": { flexible: 8.4, annual: 7 }, // Business Starter
|
|
314
|
+
"1010020028": { flexible: 16.8, annual: 14 }, // Business Standard
|
|
315
|
+
"1010020025": { flexible: 26.4, annual: 22 }, // Business Plus
|
|
316
|
+
};
|
|
317
|
+
const workspace = async (cfg, { start, end }, timeout) => {
|
|
318
|
+
need(cfg, "clientId", "clientSecret", "refreshToken", "customerId");
|
|
319
|
+
const auth = { headers: { Authorization: `Bearer ${await googleAccessToken({ clientId: cfg.clientId, clientSecret: cfg.clientSecret, refreshToken: cfg.refreshToken }, timeout)}` } };
|
|
320
|
+
const seats = new Map();
|
|
321
|
+
for (const product of cfg.products || ["Google-Apps"]) {
|
|
322
|
+
let page = "";
|
|
323
|
+
do {
|
|
324
|
+
const q = new URLSearchParams({ customerId: cfg.customerId, maxResults: "1000", ...(page ? { pageToken: page } : {}) });
|
|
325
|
+
const body = await json(`https://licensing.googleapis.com/apps/licensing/v1/product/${encodeURIComponent(product)}/users?${q}`, auth, timeout);
|
|
326
|
+
for (const a of body.items || []) {
|
|
327
|
+
const e = seats.get(a.skuId) || { name: a.skuName || a.skuId, count: 0 };
|
|
328
|
+
e.count++;
|
|
329
|
+
seats.set(a.skuId, e);
|
|
330
|
+
}
|
|
331
|
+
page = body.nextPageToken || "";
|
|
332
|
+
} while (page);
|
|
333
|
+
}
|
|
334
|
+
const plan = cfg.plan === "annual" ? "annual" : "flexible";
|
|
335
|
+
const priceOf = (sku, name) => Number(cfg.prices?.[sku] ?? cfg.prices?.[name] ?? WORKSPACE_LIST_PRICE[sku]?.[plan] ?? 0);
|
|
336
|
+
const lines = [...seats].map(([sku, { name, count }]) => ({ name: `${name} × ${count}`, monthly: count * priceOf(sku, name) }));
|
|
337
|
+
const monthly = lines.reduce((s, l) => s + l.monthly, 0);
|
|
338
|
+
const [y = 1970, m = 1] = end.split("-").map(Number);
|
|
339
|
+
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
340
|
+
const days = [];
|
|
341
|
+
for (let d = start; d <= end; d = nextDay(d))
|
|
342
|
+
days.push({ date: d, amount: monthly / dim });
|
|
343
|
+
const dom = Number(end.slice(8, 10));
|
|
344
|
+
return {
|
|
345
|
+
currency: "USD",
|
|
346
|
+
days,
|
|
347
|
+
// Month to date per plan, so the breakdown sums to the row's MTD.
|
|
348
|
+
breakdown: lines.map((l) => ({ name: l.name, amount: (l.monthly / dim) * dom })),
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
export const PROVIDERS = {
|
|
352
|
+
openai, anthropic, aws, vercel, fastly, cloudflare, posthog, gcp, workspace, "google-ads": googleAds, "meta-ads": metaAds,
|
|
353
|
+
};
|
|
@@ -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,163 @@
|
|
|
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
|
+
// Every configured uplink, so the dashboard can mark it while it's healthy too.
|
|
47
|
+
uplinks: (target.uplinkPorts || []).map((id) => `port ${id}`),
|
|
48
|
+
downUplinks: downUplinks.map((id) => `port ${id}`),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Byte rates from two counter readings. A counter that went backwards (reboot, clear) gives no rate. */
|
|
52
|
+
export function ratesBetween(prev, now) {
|
|
53
|
+
const out = new Map();
|
|
54
|
+
if (!prev)
|
|
55
|
+
return out;
|
|
56
|
+
const dt = (now.at - prev.at) / 1000;
|
|
57
|
+
if (dt <= 0)
|
|
58
|
+
return out;
|
|
59
|
+
for (const [id, c] of now.ports) {
|
|
60
|
+
const p = prev.ports.get(id);
|
|
61
|
+
if (!p || c.rx < p.rx || c.tx < p.tx)
|
|
62
|
+
continue;
|
|
63
|
+
out.set(id, { inBps: Math.round((c.rx - p.rx) / dt), outBps: Math.round((c.tx - p.tx) / dt) });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
export class LoginRefused extends Error {
|
|
68
|
+
}
|
|
69
|
+
async function call(host, path, opts) {
|
|
70
|
+
const res = await fetch(`http://${host}${path}`, {
|
|
71
|
+
method: opts.body !== undefined ? "POST" : "GET",
|
|
72
|
+
headers: opts.token ? { ...HEADERS, Authorization: opts.token } : HEADERS,
|
|
73
|
+
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
74
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new Error(`${path} returned HTTP ${res.status}`);
|
|
78
|
+
const json = await res.json();
|
|
79
|
+
if (json?.error_code)
|
|
80
|
+
throw Object.assign(new Error(`${path} error_code ${json.error_code}`), { code: json.error_code });
|
|
81
|
+
return json?.result || {};
|
|
82
|
+
}
|
|
83
|
+
export const newSwitchState = () => ({ counters: new Map(), refusedUntil: new Map() });
|
|
84
|
+
/** Log in, read one switch, log out. Throws LoginRefused or a network error. */
|
|
85
|
+
export async function readSwitch(target, cfg, state) {
|
|
86
|
+
const { counters, refusedUntil } = state;
|
|
87
|
+
const timeoutMs = cfg.timeoutMs || 8000;
|
|
88
|
+
const wait = refusedUntil.get(target.host);
|
|
89
|
+
if (wait && Date.now() < wait) {
|
|
90
|
+
throw new LoginRefused(`login refused earlier; not retrying until ${new Date(wait).toISOString()} so the switch doesn't lock the account`);
|
|
91
|
+
}
|
|
92
|
+
let login;
|
|
93
|
+
try {
|
|
94
|
+
login = await call(target.host, "/login/status", { body: { login_user: target.username, login_pwd: md5Upper(target.password) }, timeoutMs });
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
if (e?.code) {
|
|
98
|
+
refusedUntil.set(target.host, Date.now() + (cfg.loginBackoffMs || 30 * 60 * 1000));
|
|
99
|
+
throw new LoginRefused(`login refused (error_code ${e.code}); check the username and password`);
|
|
100
|
+
}
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
refusedUntil.delete(target.host);
|
|
104
|
+
const token = login.token;
|
|
105
|
+
try {
|
|
106
|
+
const [info, ports, traffic, loops] = await Promise.all([
|
|
107
|
+
call(target.host, "/system_summary/system_information_status", { token, timeoutMs }),
|
|
108
|
+
call(target.host, "/switching/port_settings_status", { token, timeoutMs }),
|
|
109
|
+
call(target.host, "/traffic_summary/traffic_summary_port_status", { token, timeoutMs }),
|
|
110
|
+
call(target.host, "/loop_detection/loop_detection_status", { token, timeoutMs }).catch(() => ({})),
|
|
111
|
+
]);
|
|
112
|
+
const now = {
|
|
113
|
+
at: Date.now(),
|
|
114
|
+
ports: new Map((traffic.port_list || []).map((p) => [p.port_id, { rx: Number(p.rx_bytes) || 0, tx: Number(p.tx_bytes) || 0 }])),
|
|
115
|
+
};
|
|
116
|
+
const rates = ratesBetween(counters.get(target.host), now);
|
|
117
|
+
counters.set(target.host, now);
|
|
118
|
+
return buildReport(target, { info, ports, traffic, loops }, rates);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
// Log out every time: these switches allow few admin sessions, and an
|
|
122
|
+
// idle one held by the agent could lock someone out of the web UI.
|
|
123
|
+
await call(target.host, "/show_up/logout_status", { token, body: {}, timeoutMs }).catch(() => { });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function createTplinkSwitchPlugin() {
|
|
127
|
+
let refreshTimer = null;
|
|
128
|
+
const state = newSwitchState();
|
|
129
|
+
const refreshFn = async (plugin) => {
|
|
130
|
+
const cfg = (plugin.config || {});
|
|
131
|
+
const targets = (cfg.switches || []).filter((s) => s?.host && s.username && s.password);
|
|
132
|
+
await Promise.all(targets.map(async (target) => {
|
|
133
|
+
let report;
|
|
134
|
+
try {
|
|
135
|
+
report = await readSwitch(target, cfg, state);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
report = {
|
|
139
|
+
ip: target.host,
|
|
140
|
+
name: target.name || target.host,
|
|
141
|
+
kind: "switch",
|
|
142
|
+
reachable: e instanceof LoginRefused,
|
|
143
|
+
error: String(e?.message || e).slice(0, 200),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
await plugin.send(report, `tplinkswitch-${target.host.replace(/[^0-9a-zA-Z]+/g, "-")}`);
|
|
147
|
+
}));
|
|
148
|
+
};
|
|
149
|
+
const monitorFn = async (plugin) => {
|
|
150
|
+
const cfg = (plugin.config || {});
|
|
151
|
+
if (!(cfg.switches || []).length)
|
|
152
|
+
console.warn("tplinkswitch: no switches configured");
|
|
153
|
+
await refreshFn(plugin);
|
|
154
|
+
refreshTimer = setInterval(() => refreshFn(plugin), cfg.refreshInterval || 60000);
|
|
155
|
+
};
|
|
156
|
+
const teardownFn = async () => {
|
|
157
|
+
if (refreshTimer)
|
|
158
|
+
clearInterval(refreshTimer);
|
|
159
|
+
refreshTimer = null;
|
|
160
|
+
};
|
|
161
|
+
return createMonitoringPlugin("tplinkswitch", "tplinkswitch", "TP-Link Omada Easy Managed switch (web API)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
162
|
+
}
|
|
163
|
+
export default createTplinkSwitchPlugin;
|
|
@@ -129,11 +129,18 @@ export function createWebsitePlugin() {
|
|
|
129
129
|
let publicResult;
|
|
130
130
|
try {
|
|
131
131
|
const ctrl = new AbortController();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
// Always bounded: with no timer the runtime's own 5 minute limit
|
|
133
|
+
// applied, and a hung page held up every check queued behind it.
|
|
134
|
+
// Cleared after the body, which can stall as well as the headers.
|
|
135
|
+
const t = setTimeout(() => ctrl.abort(), ep.timeout || 30000);
|
|
136
|
+
let res, body;
|
|
137
|
+
try {
|
|
138
|
+
res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow", headers: ep.headers });
|
|
139
|
+
body = await res.text();
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
135
142
|
clearTimeout(t);
|
|
136
|
-
|
|
143
|
+
}
|
|
137
144
|
const duration = Date.now() - start;
|
|
138
145
|
const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(body, res.status, ep.expectedStrings || [], ep.unexpectedStrings || [], ep.expectedStatusCode);
|
|
139
146
|
publicResult = {
|
|
@@ -226,11 +233,22 @@ export function createWebsitePlugin() {
|
|
|
226
233
|
merged.set(key(e), e);
|
|
227
234
|
return [...merged.values()];
|
|
228
235
|
};
|
|
236
|
+
// One pass at a time. Overlapping passes report out of order, so a check
|
|
237
|
+
// that hung minutes ago lands after a newer success and re-raises the alert.
|
|
238
|
+
let running = false;
|
|
229
239
|
const refreshFn = async (plugin) => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
if (running)
|
|
241
|
+
return;
|
|
242
|
+
running = true;
|
|
243
|
+
try {
|
|
244
|
+
const eps = await endpointsOf(plugin);
|
|
245
|
+
for (const ep of eps) {
|
|
246
|
+
if (ep?.url)
|
|
247
|
+
await check(plugin, ep);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
running = false;
|
|
234
252
|
}
|
|
235
253
|
};
|
|
236
254
|
const monitorFn = async (plugin) => {
|