@daemonitor/plugins 1.1.34 → 1.1.36

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.
@@ -15,6 +15,7 @@ import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
15
15
  import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
16
16
  import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
17
17
  import { createTplinkSwitchPlugin } from "./monitoring/TplinkSwitchPlugin.js";
18
+ import { createCostPlugin } from "./monitoring/CostPlugin.js";
18
19
  // In future, implement other plugin factory functions using the same pattern
19
20
  // import { createOSPlugin } from "./monitoring/OSPlugin.js"
20
21
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
@@ -41,6 +42,7 @@ export default {
41
42
  Ga4: createGa4Plugin,
42
43
  Nagios: createNagiosPlugin,
43
44
  TplinkSwitch: createTplinkSwitchPlugin,
45
+ Cost: createCostPlugin,
44
46
  // CloudflarePlugin: createCloudflarePlugin,
45
47
  // EwelinkPlugin: createEwelinkPlugin,
46
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
+ };
@@ -43,6 +43,8 @@ export function buildReport(target, raw, rates) {
43
43
  interfaces,
44
44
  loops: interfaces.filter((i) => i.loop === "loop").map((i) => i.name),
45
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}`),
46
48
  downUplinks: downUplinks.map((id) => `port ${id}`),
47
49
  };
48
50
  }
@@ -129,11 +129,18 @@ export function createWebsitePlugin() {
129
129
  let publicResult;
130
130
  try {
131
131
  const ctrl = new AbortController();
132
- const t = ep.timeout ? setTimeout(() => ctrl.abort(), ep.timeout) : null;
133
- const res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow", headers: ep.headers });
134
- if (t)
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
- const body = await res.text();
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
- const eps = await endpointsOf(plugin);
231
- for (const ep of eps) {
232
- if (ep?.url)
233
- await check(plugin, ep);
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.34",
3
+ "version": "1.1.36",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",