@daemonitor/plugins 1.1.45 → 1.1.50

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.
@@ -18,6 +18,7 @@ import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
18
18
  import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
19
19
  import { createTplinkSwitchPlugin } from "./monitoring/TplinkSwitchPlugin.js";
20
20
  import { createCostPlugin } from "./monitoring/CostPlugin.js";
21
+ import { createSupabasePlugin } from "./monitoring/SupabasePlugin.js";
21
22
  // In future, implement other plugin factory functions using the same pattern
22
23
  // import { createOSPlugin } from "./monitoring/OSPlugin.js"
23
24
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
@@ -47,6 +48,7 @@ export default {
47
48
  Nagios: createNagiosPlugin,
48
49
  TplinkSwitch: createTplinkSwitchPlugin,
49
50
  Cost: createCostPlugin,
51
+ Supabase: createSupabasePlugin,
50
52
  // CloudflarePlugin: createCloudflarePlugin,
51
53
  // EwelinkPlugin: createEwelinkPlugin,
52
54
  // MongoDBPlugin: createMongoDBPlugin,
@@ -175,6 +175,20 @@ async function inBatches(items, size, fn) {
175
175
  out.push(...(await Promise.all(items.slice(i, i + size).map(fn))));
176
176
  return out;
177
177
  }
178
+ /**
179
+ * One more try after a timeout, so a slow moment doesn't warn for a whole poll.
180
+ * Only timeouts: a 403 or a quota error will say the same thing again.
181
+ */
182
+ export async function retryTimeout(fn) {
183
+ try {
184
+ return await fn();
185
+ }
186
+ catch (e) {
187
+ if (e?.name !== "TimeoutError")
188
+ throw e;
189
+ return fn();
190
+ }
191
+ }
178
192
  export function createGa4Plugin() {
179
193
  let refreshTimer = null;
180
194
  const collect = async (cfg) => {
@@ -203,7 +217,7 @@ export function createGa4Plugin() {
203
217
  // One property failing (no access to its data, quota) must not hide the rest.
204
218
  try {
205
219
  const [batch, realtime, streams] = await Promise.all([
206
- post(`${DATA}/properties/${p.id}:batchRunReports`, batchBody(cfg.excludePages)),
220
+ retryTimeout(() => post(`${DATA}/properties/${p.id}:batchRunReports`, batchBody(cfg.excludePages))),
207
221
  post(`${DATA}/properties/${p.id}:runRealtimeReport`, { metrics: [{ name: "activeUsers" }] }).catch(() => null),
208
222
  get(`${ADMIN}/properties/${p.id}/dataStreams?pageSize=50`).catch(() => null),
209
223
  ]);
@@ -36,6 +36,57 @@ async function pingHost(ip, count, timeout) {
36
36
  return { up: false };
37
37
  }
38
38
  }
39
+ /**
40
+ * Read one neighbour-table entry from `ip -j neigh show <ip>` output.
41
+ * REACHABLE means the device answered ARP within the last few seconds.
42
+ */
43
+ export function neighState(json) {
44
+ try {
45
+ const e = JSON.parse(json)?.[0];
46
+ if (!e)
47
+ return null;
48
+ const state = Array.isArray(e.state) ? e.state[0] : String(e.state || "");
49
+ return { state, ...(e.lladdr ? { mac: String(e.lladdr) } : {}) };
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ let ipMissing = false;
56
+ /**
57
+ * Is the device on the wire even though it ignored ping? The failed ping still
58
+ * made the kernel ARP for it, and a device that drops ICMP almost always
59
+ * answers ARP, leaving a REACHABLE neighbour entry. DELAY and PROBE mean the
60
+ * kernel is still checking a stale entry, so look again after it has had time.
61
+ * Linux only, and needs the `ip` command (iproute2); anything else says no.
62
+ */
63
+ export async function arpReachable(ip) {
64
+ if (process.platform !== "linux" || ipMissing)
65
+ return null;
66
+ for (let attempt = 0; attempt < 3; attempt++) {
67
+ let stdout;
68
+ try {
69
+ ;
70
+ ({ stdout } = await pexec("ip", ["-j", "neigh", "show", ip], { timeout: 3000 }));
71
+ }
72
+ catch (e) {
73
+ if (e?.code === "ENOENT") {
74
+ ipMissing = true;
75
+ console.warn("netscan: `ip` (iproute2) is not installed, so devices that ignore ping can't be confirmed by ARP");
76
+ }
77
+ return null;
78
+ }
79
+ const n = neighState(stdout);
80
+ if (!n)
81
+ return null;
82
+ if (n.state === "REACHABLE")
83
+ return { mac: n.mac };
84
+ if (n.state !== "DELAY" && n.state !== "PROBE")
85
+ return null;
86
+ await new Promise((r) => setTimeout(r, 2000));
87
+ }
88
+ return null;
89
+ }
39
90
  export function createNetScanPlugin() {
40
91
  let refreshTimer = null;
41
92
  const devicesOf = (plugin) => {
@@ -51,12 +102,18 @@ export function createNetScanPlugin() {
51
102
  // refresh interval.
52
103
  await Promise.all(devices.map(async (d) => {
53
104
  const r = await pingHost(d.ip, count, timeout);
105
+ if (!r.up && d.arp !== false) {
106
+ const arp = await arpReachable(d.ip);
107
+ if (arp)
108
+ Object.assign(r, { up: true, via: "arp", ...(arp.mac ? { mac: arp.mac } : {}) });
109
+ }
54
110
  await plugin.send({
55
111
  name: d.name,
56
112
  ip: d.ip,
57
113
  type: d.type || "host",
58
- mac: d.mac,
114
+ mac: d.mac || r.mac,
59
115
  up: r.up,
116
+ ...(r.via ? { via: r.via } : {}),
60
117
  latency: r.latency,
61
118
  loss: r.loss,
62
119
  warnMs: d.warnMs ?? 200,
@@ -0,0 +1,151 @@
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
+ const DEFAULTS = {
3
+ services: ["auth", "db", "pooler", "realtime", "rest", "storage"],
4
+ diskWarnPct: 80,
5
+ diskCritPct: 90,
6
+ connWarnPct: 85,
7
+ requestTimeout: 10000,
8
+ };
9
+ const MGMT = "https://api.supabase.com/v1";
10
+ /** Prometheus text exposition format, samples only. Comments and blanks are skipped. */
11
+ export function parseProm(text) {
12
+ const out = [];
13
+ for (const line of String(text || "").split("\n")) {
14
+ const m = /^([a-zA-Z_:][\w:]*)(?:\{(.*)\})?\s+(\S+)/.exec(line);
15
+ if (!m)
16
+ continue;
17
+ const value = Number(m[3]);
18
+ if (!Number.isFinite(value))
19
+ continue;
20
+ const labels = {};
21
+ for (const l of (m[2] || "").matchAll(/(\w+)="((?:[^"\\]|\\.)*)"/g))
22
+ labels[l[1]] = l[2];
23
+ out.push({ name: m[1], labels, value });
24
+ }
25
+ return out;
26
+ }
27
+ /**
28
+ * The handful of figures worth a tile, from the ~200 series the endpoint
29
+ * serves. Every one is null when its series is absent, never 0: a missing
30
+ * series means "not measured", and 0% disk would read as healthy.
31
+ */
32
+ export function pickMetrics(samples) {
33
+ const all = (name) => samples.filter((s) => s.name === name);
34
+ const sum = (name) => {
35
+ const rows = all(name);
36
+ return rows.length ? rows.reduce((n, s) => n + s.value, 0) : null;
37
+ };
38
+ // Supabase keeps Postgres on its own volume at /data. Root is the fallback
39
+ // for an image that does not split them.
40
+ const onMount = (name) => {
41
+ const rows = all(name);
42
+ return (rows.find((s) => s.labels.mountpoint === "/data") ?? rows.find((s) => s.labels.mountpoint === "/"))?.value ?? null;
43
+ };
44
+ const pct = (used, total) => used != null && total ? Math.round((used / total) * 1000) / 10 : null;
45
+ const diskTotal = onMount("node_filesystem_size_bytes");
46
+ const diskAvail = onMount("node_filesystem_avail_bytes");
47
+ const diskUsed = diskTotal != null && diskAvail != null ? diskTotal - diskAvail : null;
48
+ const memTotal = sum("node_memory_MemTotal_bytes");
49
+ const memAvail = sum("node_memory_MemAvailable_bytes");
50
+ return {
51
+ diskTotalBytes: diskTotal,
52
+ diskUsedBytes: diskUsed,
53
+ diskPct: pct(diskUsed, diskTotal),
54
+ memPct: pct(memTotal != null && memAvail != null ? memTotal - memAvail : null, memTotal),
55
+ load1: sum("node_load1"),
56
+ connections: sum("pg_stat_database_num_backends"),
57
+ maxConnections: sum("pg_settings_max_connections"),
58
+ dbSizeBytes: sum("pg_database_size_bytes"),
59
+ };
60
+ }
61
+ async function get(url, headers, timeout) {
62
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeout) });
63
+ if (!res.ok)
64
+ throw new Error(`HTTP ${res.status} from ${new URL(url).host}${new URL(url).pathname}`);
65
+ return res;
66
+ }
67
+ export async function collectProject(p, cfg) {
68
+ const token = p.accessToken || cfg.accessToken;
69
+ const timeout = cfg.requestTimeout ?? DEFAULTS.requestTimeout;
70
+ const base = { kind: "supabase", ref: p.ref, name: p.name || p.ref, timestamp: Date.now() };
71
+ if (!token && !p.serviceKey) {
72
+ return { ...base, reachable: false, error: "supabase project needs an accessToken or a serviceKey" };
73
+ }
74
+ const bearer = { Authorization: `Bearer ${token}`, Accept: "application/json" };
75
+ const services = (cfg.services?.length ? cfg.services : DEFAULTS.services).join(",");
76
+ const skipped = Promise.resolve(null);
77
+ // Asked together, and one failing must not hide the others: a token that has
78
+ // expired still leaves the disk figures worth reporting.
79
+ const [project, health, prom] = await Promise.allSettled([
80
+ token ? get(`${MGMT}/projects/${p.ref}`, bearer, timeout).then((r) => r.json()) : skipped,
81
+ token ? get(`${MGMT}/projects/${p.ref}/health?services=${services}`, bearer, timeout).then((r) => r.json()) : skipped,
82
+ p.serviceKey
83
+ ? get(`https://${p.ref}.supabase.co/customer/v1/privileged/metrics`, { Authorization: `Basic ${Buffer.from(`service_role:${p.serviceKey}`).toString("base64")}` }, timeout).then((r) => r.text())
84
+ : skipped,
85
+ ]);
86
+ const results = [project, health, prom];
87
+ const errors = results
88
+ .filter((r) => r.status === "rejected")
89
+ .map((r) => String(r.reason?.message || r.reason).slice(0, 160));
90
+ const value = (r) => (r.status === "fulfilled" ? r.value : null);
91
+ const proj = value(project);
92
+ const got = results.filter((r) => r.status === "fulfilled" && r.value != null).length;
93
+ if (!got)
94
+ return { ...base, reachable: false, error: errors[0] || "no response" };
95
+ const healthList = Array.isArray(value(health)) ? value(health) : [];
96
+ const promText = value(prom);
97
+ return {
98
+ ...base,
99
+ name: p.name || proj?.name || p.ref,
100
+ reachable: true,
101
+ projectStatus: proj?.status ?? null,
102
+ region: proj?.region ?? null,
103
+ pgVersion: proj?.database?.version ?? null,
104
+ services: healthList.map((s) => ({
105
+ name: String(s?.name ?? "unknown"),
106
+ healthy: s?.healthy === true,
107
+ status: String(s?.status ?? "UNKNOWN"),
108
+ error: s?.error ? String(s.error).slice(0, 160) : undefined,
109
+ })),
110
+ metrics: promText ? pickMetrics(parseProm(promText)) : null,
111
+ // A source that was configured and failed. The report is partial, and the
112
+ // server says so instead of showing a green tile with half its figures.
113
+ errors,
114
+ diskWarnPct: cfg.diskWarnPct ?? DEFAULTS.diskWarnPct,
115
+ diskCritPct: cfg.diskCritPct ?? DEFAULTS.diskCritPct,
116
+ connWarnPct: cfg.connWarnPct ?? DEFAULTS.connWarnPct,
117
+ };
118
+ }
119
+ export function createSupabasePlugin() {
120
+ let refreshTimer = null;
121
+ const refreshFn = async (plugin) => {
122
+ const cfg = (plugin.config || {});
123
+ const projects = (cfg.projects || []).filter((p) => p?.ref);
124
+ if (!projects.length) {
125
+ await plugin.send({ kind: "supabase", reachable: false, error: "supabase plugin needs at least one project ref" });
126
+ return;
127
+ }
128
+ // Sequential: a handful of projects, and one slow account must not be able
129
+ // to starve the rest of their timeout.
130
+ for (const p of projects) {
131
+ const uid = `supabase-${p.ref}`;
132
+ try {
133
+ await plugin.send(await collectProject(p, cfg), uid);
134
+ }
135
+ catch (e) {
136
+ await plugin.send({ kind: "supabase", ref: p.ref, name: p.name || p.ref, reachable: false, error: String(e?.message || e).slice(0, 200), timestamp: Date.now() }, uid);
137
+ }
138
+ }
139
+ };
140
+ const monitorFn = async (plugin) => {
141
+ await refreshFn(plugin);
142
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
143
+ };
144
+ const teardownFn = async () => {
145
+ if (refreshTimer)
146
+ clearInterval(refreshTimer);
147
+ refreshTimer = null;
148
+ };
149
+ return createMonitoringPlugin("supabase", "supabase", "Supabase cloud project monitor (project status, service health, disk, connections)", async () => { }, monitorFn, refreshFn, teardownFn);
150
+ }
151
+ export default createSupabasePlugin;
@@ -12,6 +12,20 @@ const AUTH_ERRORS = {
12
12
  /** A login DSM refused. Retrying every pass would trip its auto block. */
13
13
  export const authErrorText = (code) => AUTH_ERRORS[Number(code)];
14
14
  const apiErrorText = (api, code) => Number(code) === 105 ? `${api}: this account lacks permission, it may need to be an administrator` : `${api}: DSM error ${code}`;
15
+ // "SID not found". DSM drops about 1% of sessions it has just opened, on the
16
+ // first call after a successful login. Why isn't known; a new login works.
17
+ const SESSION_LOST = 119;
18
+ /** Run a whole login-to-logout pass, again if DSM lost the session. Once only. */
19
+ export async function retryLostSession(pass) {
20
+ try {
21
+ return await pass();
22
+ }
23
+ catch (e) {
24
+ if (e?.dsmCode !== SESSION_LOST)
25
+ throw e;
26
+ return pass();
27
+ }
28
+ }
15
29
  function post(url, form, cfg) {
16
30
  return new Promise((resolve, reject) => {
17
31
  const body = new URLSearchParams(form).toString();
@@ -109,7 +123,7 @@ export function summarise(storage, usb, system, util, cfg = {}) {
109
123
  async function collect(call, cfg) {
110
124
  const storage = await call("SYNO.Storage.CGI.Storage", "load_info", 1);
111
125
  if (!storage.success)
112
- throw new Error(apiErrorText("storage", storage.error?.code));
126
+ throw Object.assign(new Error(apiErrorText("storage", storage.error?.code)), { dsmCode: storage.error?.code });
113
127
  // Extras: the card is still worth drawing without them.
114
128
  const [usb, system, util] = await Promise.all([
115
129
  call("SYNO.Core.ExternalDevice.Storage.USB", "list", 1, { additional: '["all"]' }).catch(() => null),
@@ -136,25 +150,27 @@ export function createSynologyPlugin() {
136
150
  throw new Error("config needs account and password");
137
151
  if (Date.now() < lockedUntil)
138
152
  throw new Error(lockedReason);
139
- const login = await post(cfg.url, { api: "SYNO.API.Auth", version: "6", method: "login", account: cfg.account, passwd: cfg.password, format: "sid" }, cfg);
140
- if (!login.success) {
141
- const code = login.error?.code;
142
- const refused = authErrorText(code);
143
- if (refused) {
144
- lockedUntil = Date.now() + REAUTH_AFTER_MS;
145
- lockedReason = `login refused: ${refused}. Next try within the hour, or restart the agent`;
146
- throw new Error(lockedReason);
153
+ await report(await retryLostSession(async () => {
154
+ const login = await post(cfg.url, { api: "SYNO.API.Auth", version: "6", method: "login", account: cfg.account, passwd: cfg.password, format: "sid" }, cfg);
155
+ if (!login.success) {
156
+ const code = login.error?.code;
157
+ const refused = authErrorText(code);
158
+ if (refused) {
159
+ lockedUntil = Date.now() + REAUTH_AFTER_MS;
160
+ lockedReason = `login refused: ${refused}. Next try within the hour, or restart the agent`;
161
+ throw new Error(lockedReason);
162
+ }
163
+ throw new Error(`login failed: DSM error ${code}`);
164
+ }
165
+ const sid = String(login.data?.sid);
166
+ const call = (api, method, version, extra = {}) => post(cfg.url, { api, method, version: String(version), _sid: sid, ...extra }, cfg);
167
+ try {
168
+ return await collect(call, cfg);
169
+ }
170
+ finally {
171
+ await call("SYNO.API.Auth", "logout", 6).catch(() => { });
147
172
  }
148
- throw new Error(`login failed: DSM error ${code}`);
149
- }
150
- const sid = String(login.data?.sid);
151
- const call = (api, method, version, extra = {}) => post(cfg.url, { api, method, version: String(version), _sid: sid, ...extra }, cfg);
152
- try {
153
- await report(await collect(call, cfg));
154
- }
155
- finally {
156
- await call("SYNO.API.Auth", "logout", 6).catch(() => { });
157
- }
173
+ }));
158
174
  }
159
175
  catch (e) {
160
176
  await report({ kind: "qnap", reachable: false, error: String(e?.message || e).slice(0, 120) });
@@ -169,6 +185,10 @@ export function createSynologyPlugin() {
169
185
  clearInterval(refreshTimer);
170
186
  refreshTimer = null;
171
187
  };
172
- return createMonitoringPlugin("synology", "snmp", "Synology NAS disks, pools and volumes through the DSM web API", async () => { }, monitorFn, refreshFn, teardownFn);
188
+ const plugin = createMonitoringPlugin("synology", "snmp", "Synology NAS disks, pools and volumes through the DSM web API", async () => { }, monitorFn, refreshFn, teardownFn);
189
+ // `name` is the x-type sent to the server, and PluginManager picks plugins from
190
+ // the config list by getName(). Without this, listing "synology" runs nothing.
191
+ plugin.getName = () => "synology";
192
+ return plugin;
173
193
  }
174
194
  export default createSynologyPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.45",
3
+ "version": "1.1.50",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",
@@ -30,18 +30,14 @@
30
30
  "packageManager": "pnpm@9.15.9",
31
31
  "devDependencies": {
32
32
  "@types/node": "^26.1.1",
33
- "nuxt": "^3.15.4",
34
- "typescript": "^5.8.3",
35
- "vue": "^3.5.13",
36
- "vue-router": "^4.5.0"
33
+ "typescript": "^5.8.3"
37
34
  },
38
35
  "dependencies": {
39
36
  "@daemonitor/common": "^1.0.0",
40
37
  "dotenv": "^16.0.3",
41
38
  "ewelink-api": "^3.1.1",
42
39
  "node-machine-id": "^1.1.12",
43
- "ohmyfetch": "^0.4.21",
44
- "pm2": "^6.0.5",
40
+ "pm2": "^7.1.1",
45
41
  "string-to-color": "^2.2.2",
46
42
  "ts-node": "^10.9.1",
47
43
  "tsconfig-paths": "^4.2.0"