@silentjma/n8n-nodes-unraid 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,398 @@
1
+ "use strict";
2
+ // Pure analysis helpers: turn Unraid API snapshots and log lines into findings with suggested next steps.
3
+ // No n8n imports so the rules can be unit-tested and reused by both nodes.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.DEFAULT_HEALTH_OPTIONS = exports.LOG_RULE_NAMES = void 0;
6
+ exports.severityAtLeast = severityAtLeast;
7
+ exports.sortFindings = sortFindings;
8
+ exports.containerName = containerName;
9
+ exports.classifyLogLine = classifyLogLine;
10
+ exports.analyzeHealth = analyzeHealth;
11
+ exports.summarizeFindings = summarizeFindings;
12
+ const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
13
+ function severityAtLeast(severity, minimum) {
14
+ return SEVERITY_RANK[severity] >= SEVERITY_RANK[minimum];
15
+ }
16
+ function sortFindings(findings) {
17
+ return [...findings].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || a.key.localeCompare(b.key));
18
+ }
19
+ /** Docker names come back as ["/plex"]; the first one without the slash is what the WebGUI shows. */
20
+ function containerName(container) {
21
+ const first = container.names?.find((n) => n) ?? container.id ?? '';
22
+ return first.replace(/^\//, '');
23
+ }
24
+ const num = (value) => {
25
+ if (value === null || value === undefined || value === '')
26
+ return undefined;
27
+ const n = Number(value);
28
+ return Number.isFinite(n) ? n : undefined;
29
+ };
30
+ const pct = (used, total) => {
31
+ const u = num(used);
32
+ const t = num(total);
33
+ return u !== undefined && t ? Math.round((u / t) * 1000) / 10 : undefined;
34
+ };
35
+ const LOG_RULES = [
36
+ {
37
+ rule: 'disk-io-error',
38
+ pattern: /\b(I\/O error|blk_update_request|critical medium error|Buffer I\/O error|md: disk\d+ (read|write) error|ata\d+(\.\d+)?: .*(failed command|exception|hard resetting link|link is slow|SError)|UNC\b|end_request)/i,
39
+ severity: 'critical',
40
+ category: 'disk',
41
+ nextSteps: [
42
+ 'Identify the disk from the ataX / sdX / diskN in the line (Tools › System Devices or Main tab).',
43
+ 'Run a SMART extended test on that disk and check reallocated / pending sector counts.',
44
+ 'Reseat or replace the SATA/SAS cable and power cable; link resets are usually cabling.',
45
+ 'If the disk is disabled in the array, do not write to it; rebuild from parity once the cause is fixed.',
46
+ ],
47
+ },
48
+ {
49
+ rule: 'filesystem-corruption',
50
+ pattern: /\b(BTRFS (error|critical|warning)|XFS \(.*\): (Corruption|Metadata|Internal error|log I\/O error)|EXT4-fs error|ZFS.*(error|faulted|degraded)|checksum error|csum failed|parent transid verify failed)/i,
51
+ severity: 'critical',
52
+ category: 'filesystem',
53
+ nextSteps: [
54
+ 'Note the affected device (e.g. md1p1, nvme0n1p1) and stop writing to it.',
55
+ 'Start the array in maintenance mode and run a filesystem check (Main › disk › Check Filesystem Status).',
56
+ 'For BTRFS pools run a scrub; for ZFS run "zpool status -v" and a scrub.',
57
+ 'Run memtest if checksum errors keep appearing on several devices (bad RAM causes them).',
58
+ ],
59
+ },
60
+ {
61
+ rule: 'out-of-memory',
62
+ pattern: /\b(Out of memory|oom-kill|oom_reaper|invoked oom-killer|Killed process \d+)/i,
63
+ severity: 'critical',
64
+ category: 'memory',
65
+ nextSteps: [
66
+ 'Find the killed process / container in the line and set a memory limit on it (--memory=… in Extra Parameters).',
67
+ 'Check for containers writing to RAM (paths mapped to /tmp or not to /mnt/user) filling the rootfs.',
68
+ 'Review VM memory allocations against total RAM.',
69
+ ],
70
+ },
71
+ {
72
+ rule: 'hardware-mce',
73
+ pattern: /\b(Machine check|mce: \[Hardware Error\]|Hardware Error|EDAC .*(error|CE|UE)|PCIe Bus Error|AER: (Corrected|Uncorrected))/i,
74
+ severity: 'critical',
75
+ category: 'hardware',
76
+ nextSteps: [
77
+ 'Install mcelog (NerdTools / Nerd Pack) to decode the machine check, or check the BIOS event log.',
78
+ 'Run memtest86 for at least one full pass.',
79
+ 'Update the BIOS and check CPU / VRM temperatures and PSU health.',
80
+ ],
81
+ },
82
+ {
83
+ rule: 'kernel-trace',
84
+ pattern: /\b(Call Trace|kernel BUG|general protection fault|Kernel panic|BUG: unable to handle|watchdog: BUG: soft lockup|rcu_sched self-detected stall|macvlan.*(call trace|broadcast))/i,
85
+ severity: 'critical',
86
+ category: 'kernel',
87
+ nextSteps: [
88
+ 'Download diagnostics (Tools › Diagnostics) while the trace is still in the syslog.',
89
+ 'If the trace mentions macvlan, switch Docker custom networks to ipvlan (Settings › Docker).',
90
+ 'Check that Unraid and plugins are up to date; post the diagnostics on the Unraid forum if it repeats.',
91
+ ],
92
+ },
93
+ {
94
+ rule: 'disk-space',
95
+ pattern: /\b(No space left on device|disk quota exceeded|cache.*(full|is full)|docker\.img.*(full|100%)|ENOSPC)/i,
96
+ severity: 'critical',
97
+ category: 'storage',
98
+ nextSteps: [
99
+ 'Check docker.img / cache / array usage on the Main tab.',
100
+ 'Run the Mover if the cache is full, and set a minimum free space on shares.',
101
+ 'Find containers writing inside docker.img (Docker › Container Size) and map their paths to appdata.',
102
+ ],
103
+ },
104
+ {
105
+ rule: 'parity-errors',
106
+ pattern: /\b(parity (check|sync).*(error|incorrect)|sync errors?|Parity.*finished.*\d+ errors)/i,
107
+ severity: 'warning',
108
+ category: 'parity',
109
+ nextSteps: [
110
+ 'If this follows an unclean shutdown, run a correcting parity check.',
111
+ 'If errors repeat on consecutive checks, run memtest and check disk SMART data.',
112
+ ],
113
+ },
114
+ {
115
+ rule: 'auth-failure',
116
+ pattern: /\b(authentication failure|Failed password|Invalid user|Failed login|webGUI: Unsuccessful login|refused connect|Bad protocol version)/i,
117
+ severity: 'warning',
118
+ category: 'security',
119
+ nextSteps: [
120
+ 'Check the source IP: if it is not on your LAN, the server is exposed to the internet.',
121
+ 'Disable port forwarding for SSH / WebGUI; use WireGuard, Tailscale or Unraid Connect remote access instead.',
122
+ 'Set a strong root password and consider disabling SSH password login.',
123
+ ],
124
+ },
125
+ {
126
+ rule: 'segfault',
127
+ pattern: /\b(segfault at|traps: .* (general protection|trap divide error)|core dumped)/i,
128
+ severity: 'warning',
129
+ category: 'application',
130
+ nextSteps: [
131
+ 'Identify the process; if it belongs to a container, restart it and check for an image update.',
132
+ 'Repeated segfaults across processes can point to bad RAM; run memtest.',
133
+ ],
134
+ },
135
+ {
136
+ rule: 'docker-error',
137
+ pattern: /\b(dockerd|containerd|docker)\b.*\b(error|failed|cannot|unable)\b/i,
138
+ severity: 'warning',
139
+ category: 'docker',
140
+ nextSteps: [
141
+ 'Check the named container\'s own log (Docker › container › Logs) for the root cause.',
142
+ 'Restart the container; if Docker itself fails, check that docker.img is not full or corrupted.',
143
+ ],
144
+ },
145
+ {
146
+ rule: 'network',
147
+ pattern: /\b(Link is Down|NIC Link is Down|link down|carrier lost|bond\d+: .*(link status definitely down|No active)|duplicate address detected|DHCP.*(failed|timeout))/i,
148
+ severity: 'warning',
149
+ category: 'network',
150
+ nextSteps: [
151
+ 'Check the network cable, switch port and NIC; note which interface went down.',
152
+ 'If it flaps, check for duplicate IPs and bonding / bridging settings (Settings › Network).',
153
+ ],
154
+ },
155
+ {
156
+ rule: 'temperature',
157
+ pattern: /\b(temperature above threshold|cpu clock throttled|Package temperature|overheat|thermal)\b/i,
158
+ severity: 'warning',
159
+ category: 'temperature',
160
+ nextSteps: [
161
+ 'Check fans, airflow and dust; confirm CPU cooler mounting.',
162
+ 'Review fan curves (Dynamix Auto Fan Control) and disk temperature thresholds.',
163
+ ],
164
+ },
165
+ {
166
+ rule: 'app-fatal',
167
+ pattern: /\b(fatal( error)?:|panic:|unhandled exception|uncaught exception|traceback \(most recent call last\)|database disk image is malformed|database is locked|corrupt(ed|ion)\b)/i,
168
+ severity: 'warning',
169
+ category: 'application',
170
+ nextSteps: [
171
+ 'Read the lines before this one in the container log for the root cause.',
172
+ 'For database corruption: stop the container, back up its appdata, then repair or restore the database.',
173
+ 'Check the appdata path is on a healthy disk / pool and not full.',
174
+ ],
175
+ },
176
+ {
177
+ rule: 'generic-error',
178
+ pattern: /\b(error|failed|failure|fatal|critical|panic|denied)\b/i,
179
+ severity: 'info',
180
+ category: 'general',
181
+ nextSteps: ['Look at the surrounding log lines for context; repeated occurrences deserve a closer look.'],
182
+ },
183
+ ];
184
+ const BENIGN = [
185
+ /ACPI (Error|Warning|BIOS Error).*(AE_NOT_FOUND|AE_ALREADY_EXISTS)/i, // Common BIOS table noise.
186
+ /\bcorrected error\b.*\bPCIe\b.*\bReceiver Error\b/i,
187
+ /\b(error|failed)=0\b/i,
188
+ ];
189
+ function classifyLogLine(line) {
190
+ if (!line.trim() || BENIGN.some((p) => p.test(line)))
191
+ return null;
192
+ for (const r of LOG_RULES) {
193
+ if (r.pattern.test(line))
194
+ return { severity: r.severity, category: r.category, rule: r.rule, nextSteps: r.nextSteps };
195
+ }
196
+ return null;
197
+ }
198
+ exports.LOG_RULE_NAMES = LOG_RULES.map((r) => r.rule);
199
+ exports.DEFAULT_HEALTH_OPTIONS = {
200
+ diskUsageWarningPercent: 90,
201
+ diskUsageCriticalPercent: 97,
202
+ defaultTempWarning: 45,
203
+ defaultTempCritical: 55,
204
+ defaultSsdTempWarning: 60,
205
+ defaultSsdTempCritical: 70,
206
+ memoryWarningPercent: 90,
207
+ cpuWarningPercent: 95,
208
+ reportStoppedAutostartContainers: true,
209
+ reportContainerUpdates: true,
210
+ includeUnraidNotifications: true,
211
+ ignoreContainers: [],
212
+ };
213
+ const diskLabel = (d) => d.name || d.device || d.id;
214
+ function analyzeHealth(snapshot, options = {}) {
215
+ const o = { ...exports.DEFAULT_HEALTH_OPTIONS, ...options };
216
+ const findings = [];
217
+ const array = snapshot.array;
218
+ if (array?.state && array.state !== 'STARTED') {
219
+ findings.push({
220
+ key: `array:state:${array.state}`,
221
+ severity: 'critical',
222
+ category: 'array',
223
+ title: `Array is ${array.state}`,
224
+ details: `The Unraid array is in state ${array.state}; shares, Docker and VMs on the array are unavailable.`,
225
+ nextSteps: array.state === 'STOPPED'
226
+ ? ['If this was not intentional, check the Main tab for missing disks before starting the array.', 'Start the array once all disks are present.']
227
+ : ['Open the Main tab: the state names the problem (missing / wrong / too small disk).', 'Do not start the array or format anything until the disk assignment is correct.'],
228
+ suggestedAction: array.state === 'STOPPED' ? { resource: 'array', operation: 'start' } : undefined,
229
+ });
230
+ }
231
+ const arrayDisks = [...(array?.parities ?? []), ...(array?.disks ?? []), ...(array?.caches ?? [])];
232
+ for (const d of arrayDisks) {
233
+ const label = diskLabel(d);
234
+ if (d.status && !['DISK_OK', 'DISK_NP'].includes(d.status)) {
235
+ findings.push({
236
+ key: `disk:status:${d.id}:${d.status}`,
237
+ severity: 'critical',
238
+ category: 'disk',
239
+ title: `${label} is ${d.status.replace(/^DISK_/, '').toLowerCase()}`,
240
+ details: `${d.type ?? 'Disk'} ${label} (${d.device ?? 'no device'}) reports status ${d.status}.`,
241
+ nextSteps: d.status.includes('DSBL') || d.status === 'DISK_INVALID'
242
+ ? [
243
+ 'The disk is disabled (red X) and is being emulated from parity. Do not write heavily to the array.',
244
+ 'Check SMART data and cabling, then download diagnostics before changing anything.',
245
+ 'Rebuild onto the same disk (if healthy) or a replacement: stop array, unassign, start, stop, reassign, start.',
246
+ ]
247
+ : ['Check the Main tab for the disk assignment and whether the disk is physically connected.', 'Download diagnostics before reassigning disks.'],
248
+ });
249
+ }
250
+ const errors = num(d.numErrors);
251
+ if (errors && errors > 0) {
252
+ findings.push({
253
+ key: `disk:errors:${d.id}`,
254
+ severity: 'warning',
255
+ category: 'disk',
256
+ title: `${label} has ${errors} read/write error(s)`,
257
+ details: `The array driver recorded ${errors} unrecoverable I/O errors on ${label} since the counters were last cleared.`,
258
+ nextSteps: ['Run a SMART extended test on the disk.', 'Check / replace the SATA and power cables.', 'Clear the statistics after fixing so new errors stand out.'],
259
+ });
260
+ }
261
+ if (d.temp !== null && d.temp !== undefined) {
262
+ const ssd = d.rotational === false;
263
+ const crit = d.critical ?? (ssd ? o.defaultSsdTempCritical : o.defaultTempCritical);
264
+ const warn = d.warning ?? (ssd ? o.defaultSsdTempWarning : o.defaultTempWarning);
265
+ const level = d.temp >= crit ? 'critical' : d.temp >= warn ? 'warning' : null;
266
+ if (level) {
267
+ findings.push({
268
+ key: `disk:temp:${d.id}:${level}`,
269
+ severity: level,
270
+ category: 'temperature',
271
+ title: `${label} is at ${d.temp}°C`,
272
+ details: `${label} temperature ${d.temp}°C exceeds the ${level} threshold (${level === 'critical' ? crit : warn}°C).`,
273
+ nextSteps: ['Check case fans and airflow over the drive cage.', 'Pause a running parity check if it is driving the temperature up.', 'Review fan control and per-disk thresholds (Settings › Disk Settings).'],
274
+ });
275
+ }
276
+ }
277
+ const usage = pct(d.fsUsed, d.fsSize);
278
+ if (usage !== undefined && usage >= o.diskUsageWarningPercent) {
279
+ const level = usage >= o.diskUsageCriticalPercent ? 'critical' : 'warning';
280
+ findings.push({
281
+ key: `disk:usage:${d.id}:${level}`,
282
+ severity: level,
283
+ category: 'storage',
284
+ title: `${label} is ${usage}% full`,
285
+ details: `Filesystem on ${label} is ${usage}% used.`,
286
+ nextSteps: d.type === 'CACHE'
287
+ ? ['Run the Mover (Main › Move) and check share cache settings.', 'Set a minimum free space on the pool so writes overflow to the array.']
288
+ : ['Set a minimum free space on shares and check the split level / allocation method.', 'Plan a new or larger disk if all data disks are filling up.'],
289
+ });
290
+ }
291
+ }
292
+ const parity = array?.parityCheckStatus;
293
+ if (parity?.status === 'FAILED') {
294
+ findings.push({
295
+ key: 'parity:failed',
296
+ severity: 'critical',
297
+ category: 'parity',
298
+ title: 'Last parity check failed',
299
+ details: 'The most recent parity check did not finish successfully.',
300
+ nextSteps: ['Check the syslog around the end of the check for disk errors.', 'Run the check again once disk issues are fixed.'],
301
+ });
302
+ }
303
+ else if (parity && (parity.errors ?? 0) > 0 && !parity.running) {
304
+ findings.push({
305
+ key: `parity:errors:${parity.errors}`,
306
+ severity: 'warning',
307
+ category: 'parity',
308
+ title: `Parity check found ${parity.errors} error(s)`,
309
+ details: `The last parity check (${parity.status}) reported ${parity.errors} sync errors.`,
310
+ nextSteps: ['After an unclean shutdown, run a correcting check once.', 'If errors return on the next non-correcting check, run memtest and inspect SMART data.'],
311
+ });
312
+ }
313
+ for (const d of snapshot.disks ?? []) {
314
+ if (d.smartStatus && d.smartStatus !== 'OK' && d.smartStatus !== 'UNKNOWN') {
315
+ findings.push({
316
+ key: `smart:${d.id}:${d.smartStatus}`,
317
+ severity: 'critical',
318
+ category: 'disk',
319
+ title: `SMART status of ${d.name || d.device} is ${d.smartStatus}`,
320
+ details: `Physical disk ${d.device} reports SMART status ${d.smartStatus}.`,
321
+ nextSteps: ['Back up anything on this disk that is not protected by parity.', 'Order a replacement and run an extended SMART test to confirm.'],
322
+ });
323
+ }
324
+ }
325
+ const ignored = new Set(o.ignoreContainers.map((n) => n.trim().toLowerCase()).filter(Boolean));
326
+ for (const c of snapshot.docker?.containers ?? []) {
327
+ const name = containerName(c);
328
+ if (ignored.has(name.toLowerCase()))
329
+ continue;
330
+ if (o.reportStoppedAutostartContainers && c.autoStart && c.state && c.state !== 'RUNNING') {
331
+ findings.push({
332
+ key: `container:down:${name}`,
333
+ severity: 'warning',
334
+ category: 'docker',
335
+ title: `Container ${name} is ${c.state.toLowerCase()}`,
336
+ details: `${name} (${c.image}) has autostart enabled but is ${c.state} — ${c.status ?? ''}`.trim(),
337
+ nextSteps: [
338
+ `Read the last log lines of ${name} for the exit reason.`,
339
+ `Restart ${name}; if it exits again, check its appdata paths and recent image changes.`,
340
+ ],
341
+ suggestedAction: { resource: 'dockerContainer', operation: c.state === 'PAUSED' ? 'unpause' : 'start', id: c.id, name },
342
+ });
343
+ }
344
+ if (o.reportContainerUpdates && c.isUpdateAvailable) {
345
+ findings.push({
346
+ key: `container:update:${name}`,
347
+ severity: 'info',
348
+ category: 'docker-update',
349
+ title: `Update available for ${name}`,
350
+ details: `A newer image is available for ${name} (${c.image}).`,
351
+ nextSteps: [`Read the release notes of ${c.image} for breaking changes.`, `Update ${name} (back up its appdata first for databases).`],
352
+ suggestedAction: { resource: 'dockerContainer', operation: 'update', id: c.id, name },
353
+ });
354
+ }
355
+ }
356
+ if (o.includeUnraidNotifications) {
357
+ for (const n of snapshot.notifications?.warningsAndAlerts ?? []) {
358
+ findings.push({
359
+ key: `notification:${n.id}`,
360
+ severity: n.importance === 'ALERT' ? 'critical' : 'warning',
361
+ category: 'unraid-notification',
362
+ title: n.subject || n.title || 'Unraid notification',
363
+ details: [n.title, n.description].filter(Boolean).join(' — '),
364
+ nextSteps: ['Open the notification in the Unraid WebGUI and archive it once handled.'],
365
+ });
366
+ }
367
+ }
368
+ const mem = snapshot.metrics?.memory?.percentTotal;
369
+ if (mem !== undefined && mem >= o.memoryWarningPercent) {
370
+ findings.push({
371
+ key: 'memory:high',
372
+ severity: 'warning',
373
+ category: 'memory',
374
+ title: `Memory usage at ${Math.round(mem)}%`,
375
+ details: `RAM usage is ${Math.round(mem)}% (swap ${Math.round(snapshot.metrics?.memory?.percentSwapTotal ?? 0)}%).`,
376
+ nextSteps: ['Find the heaviest containers (Docker › Advanced view / ctop).', 'Set memory limits on containers and check VM allocations.'],
377
+ });
378
+ }
379
+ const cpu = snapshot.metrics?.cpu?.percentTotal;
380
+ if (cpu !== undefined && cpu >= o.cpuWarningPercent) {
381
+ findings.push({
382
+ key: 'cpu:high',
383
+ severity: 'info',
384
+ category: 'cpu',
385
+ title: `CPU usage at ${Math.round(cpu)}%`,
386
+ details: `Total CPU usage is ${Math.round(cpu)}% at the time of the check.`,
387
+ nextSteps: ['Check whether a parity check, mover, transcode or backup is running.', 'Pin heavy containers / VMs to specific cores if this is persistent.'],
388
+ });
389
+ }
390
+ return sortFindings(findings);
391
+ }
392
+ function summarizeFindings(findings) {
393
+ const counts = { critical: 0, warning: 0, info: 0 };
394
+ for (const f of findings)
395
+ counts[f.severity]++;
396
+ const status = counts.critical ? 'critical' : counts.warning ? 'warning' : counts.info ? 'info' : 'ok';
397
+ return { status, counts, total: findings.length };
398
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.unraidGraphql = unraidGraphql;
4
+ const n8n_workflow_1 = require("n8n-workflow");
5
+ async function unraidGraphql(query, variables = {}) {
6
+ const credentials = await this.getCredentials('unraidApi');
7
+ const baseUrl = String(credentials.serverUrl ?? '').replace(/\/+$/, '');
8
+ if (!/^https?:\/\//i.test(baseUrl)) {
9
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Invalid Unraid server URL "${baseUrl}". It must start with http:// or https://.`);
10
+ }
11
+ let response;
12
+ try {
13
+ response = (await this.helpers.httpRequestWithAuthentication.call(this, 'unraidApi', {
14
+ method: 'POST',
15
+ url: `${baseUrl}/graphql`,
16
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
17
+ body: { query, variables },
18
+ json: true,
19
+ skipSslCertificateValidation: credentials.allowUnauthorizedCerts === true,
20
+ }));
21
+ }
22
+ catch (error) {
23
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), error, {
24
+ message: 'Unraid API request failed',
25
+ description: baseUrl.startsWith('http://')
26
+ ? 'If SSL is enabled on Unraid, http:// is redirected to https:// and the request body is lost. Use the https:// URL (with Ignore SSL Issues for the self-signed certificate).'
27
+ : 'Check the server URL, that the API is enabled (Unraid 7.2+ or the Unraid Connect plugin) and that the API key is valid.',
28
+ });
29
+ }
30
+ if (response?.errors?.length) {
31
+ const message = response.errors.map((e) => (e.path?.length ? `${e.path.join('.')}: ${e.message}` : e.message)).join('; ');
32
+ const forbidden = response.errors.some((e) => e.extensions?.code === 'FORBIDDEN' || /forbidden|permission/i.test(e.message));
33
+ const unauthenticated = response.errors.some((e) => e.extensions?.code === 'UNAUTHENTICATED');
34
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Unraid API error: ${message}`, {
35
+ description: unauthenticated
36
+ ? 'The API key is invalid or was revoked. Create a new one in Settings › Management Access › API Keys and update the Unraid API credential.'
37
+ : forbidden
38
+ ? 'The API key lacks the permission for this operation. Give it the admin role, or the matching resource permission.'
39
+ : undefined,
40
+ });
41
+ }
42
+ if (!response?.data) {
43
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Unraid API returned no data');
44
+ }
45
+ return response.data;
46
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listContainerRefs = listContainerRefs;
4
+ exports.matchContainer = matchContainer;
5
+ exports.commandSafeName = commandSafeName;
6
+ exports.resolveContainerIds = resolveContainerIds;
7
+ exports.containerOptions = containerOptions;
8
+ exports.withName = withName;
9
+ const n8n_workflow_1 = require("n8n-workflow");
10
+ const analysis_1 = require("./analysis");
11
+ const client_1 = require("./client");
12
+ const queries_1 = require("./queries");
13
+ async function listContainerRefs() {
14
+ const data = await client_1.unraidGraphql.call(this, queries_1.Q.containerNames);
15
+ return data.docker.containers ?? [];
16
+ }
17
+ function matchContainer(refs, value) {
18
+ const v = value.trim();
19
+ const lower = v.replace(/^\//, '').toLowerCase();
20
+ return (refs.find((c) => c.id === v || c.id.endsWith(`:${v}`)) ??
21
+ refs.find((c) => (0, analysis_1.containerName)(c).toLowerCase() === lower) ??
22
+ refs.find((c) => c.names.some((n) => n.replace(/^\//, '').toLowerCase() === lower)) ??
23
+ refs.find((c) => commandSafeName((0, analysis_1.containerName)(c)) === commandSafeName(v)));
24
+ }
25
+ /** Telegram commands allow only [a-z0-9_], so "Plex-Media-Server" is sent as plex_media_server. */
26
+ function commandSafeName(name) {
27
+ return name
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, '_')
30
+ .replace(/^_+|_+$/g, '');
31
+ }
32
+ /** Resolves names/IDs, reusing one container listing per execution via the cache object. */
33
+ async function resolveContainerIds(values, cache) {
34
+ cache.refs ??= await listContainerRefs.call(this);
35
+ return values.map((value) => {
36
+ const match = matchContainer(cache.refs, value);
37
+ if (!match) {
38
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), `No Docker container named or with ID "${value}"`, {
39
+ description: `Known containers: ${cache.refs.map((c) => (0, analysis_1.containerName)(c)).join(', ') || 'none'}`,
40
+ });
41
+ }
42
+ return match.id;
43
+ });
44
+ }
45
+ async function containerOptions() {
46
+ const refs = await listContainerRefs.call(this);
47
+ return refs
48
+ .map((c) => ({ name: `${(0, analysis_1.containerName)(c)} (${c.state.toLowerCase()})`, value: (0, analysis_1.containerName)(c) }))
49
+ .sort((a, b) => a.name.localeCompare(b.name));
50
+ }
51
+ /** Adds a plain `name` field next to Docker's `names` array. */
52
+ function withName(container) {
53
+ return { name: (0, analysis_1.containerName)(container), ...container };
54
+ }
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitLogContent = splitLogContent;
4
+ exports.invalidPatternReason = invalidPatternReason;
5
+ exports.compilePattern = compilePattern;
6
+ exports.filterLogLines = filterLogLines;
7
+ // Log text -> structured lines, optionally filtered by regex and classified with suggested next steps.
8
+ const analysis_1 = require("./analysis");
9
+ function splitLogContent(content, startLine) {
10
+ const raw = content.split(/\r?\n/);
11
+ if (raw.length && raw[raw.length - 1] === '')
12
+ raw.pop();
13
+ return raw.map((line, i) => ({ lineNumber: startLine ? startLine + i : undefined, line }));
14
+ }
15
+ /** Why a filter pattern is not a valid regular expression, or null when it is fine (or empty). */
16
+ function invalidPatternReason(pattern) {
17
+ if (!pattern?.trim())
18
+ return null;
19
+ try {
20
+ new RegExp(pattern, 'i');
21
+ return null;
22
+ }
23
+ catch (error) {
24
+ return `Invalid filter pattern "${pattern}": ${error.message}`;
25
+ }
26
+ }
27
+ /** Case-insensitive regex for a filter pattern; undefined when empty or invalid (validate with invalidPatternReason first). */
28
+ function compilePattern(pattern) {
29
+ return invalidPatternReason(pattern) === null && pattern?.trim() ? new RegExp(pattern, 'i') : undefined;
30
+ }
31
+ function filterLogLines(lines, filter) {
32
+ const regex = compilePattern(filter.pattern);
33
+ const out = [];
34
+ for (const l of lines) {
35
+ if (regex && !regex.test(l.line))
36
+ continue;
37
+ if (filter.analyze) {
38
+ const match = (0, analysis_1.classifyLogLine)(l.line);
39
+ if (filter.minSeverity && filter.minSeverity !== 'all' && (!match || !(0, analysis_1.severityAtLeast)(match.severity, filter.minSeverity)))
40
+ continue;
41
+ out.push(match ? { ...l, ...match } : l);
42
+ }
43
+ else {
44
+ out.push(l);
45
+ }
46
+ }
47
+ return out;
48
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ // GraphQL documents used by the Unraid nodes, written against api/generated-schema.graphql from github.com/unraid/api.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.M = exports.Q = exports.METRICS_FIELDS = exports.NOTIFICATION_FIELDS = exports.PHYSICAL_DISK_FIELDS = exports.ARRAY_FIELDS = exports.PARITY_FIELDS = exports.ARRAY_DISK_FIELDS = exports.CONTAINER_FIELDS = void 0;
5
+ exports.CONTAINER_FIELDS = `
6
+ id names image imageId state status created autoStart autoStartOrder
7
+ isUpdateAvailable isRebuildReady isOrphaned webUiUrl iconUrl projectUrl
8
+ ports { ip privatePort publicPort type }`;
9
+ exports.ARRAY_DISK_FIELDS = `
10
+ id idx name device size status type temp warning critical rotational isSpinning
11
+ numReads numWrites numErrors fsSize fsFree fsUsed fsType comment`;
12
+ exports.PARITY_FIELDS = 'date duration speed status errors progress correcting paused running';
13
+ exports.ARRAY_FIELDS = `
14
+ id state
15
+ capacity { kilobytes { free used total } disks { free used total } }
16
+ parityCheckStatus { ${exports.PARITY_FIELDS} }
17
+ parities { ${exports.ARRAY_DISK_FIELDS} }
18
+ disks { ${exports.ARRAY_DISK_FIELDS} }
19
+ caches { ${exports.ARRAY_DISK_FIELDS} }
20
+ boot { ${exports.ARRAY_DISK_FIELDS} }`;
21
+ exports.PHYSICAL_DISK_FIELDS = 'id device type name vendor size firmwareRevision serialNum interfaceType smartStatus temperature isSpinning';
22
+ exports.NOTIFICATION_FIELDS = 'id title subject description importance link type timestamp formattedTimestamp';
23
+ exports.METRICS_FIELDS = `
24
+ cpu { percentTotal }
25
+ memory { total used free available percentTotal swapTotal swapUsed percentSwapTotal }`;
26
+ exports.Q = {
27
+ online: '{ online }',
28
+ info: `{
29
+ online
30
+ info {
31
+ time
32
+ os { platform distro release kernel arch hostname uptime }
33
+ cpu { manufacturer brand cores threads speed speedmax }
34
+ system { manufacturer model virtual }
35
+ }
36
+ }`,
37
+ metrics: `{ metrics { ${exports.METRICS_FIELDS} } }`,
38
+ array: `{ array { ${exports.ARRAY_FIELDS} } }`,
39
+ parityHistory: `{ parityHistory { ${exports.PARITY_FIELDS} } }`,
40
+ disks: `{ disks { ${exports.PHYSICAL_DISK_FIELDS} } }`,
41
+ shares: '{ shares { id name free used size comment cache include exclude splitLevel } }',
42
+ containers: `{ docker { containers { ${exports.CONTAINER_FIELDS} } } }`,
43
+ containerNames: '{ docker { containers { id names state } } }',
44
+ container: `query ($id: PrefixedID!) { docker { container(id: $id) { ${exports.CONTAINER_FIELDS} } } }`,
45
+ containerLogs: `query ($id: PrefixedID!, $since: DateTime, $tail: Int) {
46
+ docker { logs(id: $id, since: $since, tail: $tail) { containerId cursor lines { timestamp message } } }
47
+ }`,
48
+ containerUpdateStatuses: '{ docker { containerUpdateStatuses { name updateStatus } } }',
49
+ vms: '{ vms { domains { id name state } } }',
50
+ logFiles: '{ logFiles { name path size modifiedAt } }',
51
+ logFile: `query ($path: String!, $lines: Int, $startLine: Int) {
52
+ logFile(path: $path, lines: $lines, startLine: $startLine) { path content totalLines startLine }
53
+ }`,
54
+ notifications: `query ($filter: NotificationFilter!) { notifications { list(filter: $filter) { ${exports.NOTIFICATION_FIELDS} } } }`,
55
+ notificationOverview: `{ notifications { overview {
56
+ unread { info warning alert total }
57
+ archive { info warning alert total }
58
+ } } }`,
59
+ warningsAndAlerts: `{ notifications { warningsAndAlerts { ${exports.NOTIFICATION_FIELDS} } } }`,
60
+ health: `{
61
+ info { os { hostname uptime } }
62
+ array { ${exports.ARRAY_FIELDS} }
63
+ disks { ${exports.PHYSICAL_DISK_FIELDS} }
64
+ docker { containers { ${exports.CONTAINER_FIELDS} } }
65
+ notifications { warningsAndAlerts { ${exports.NOTIFICATION_FIELDS} } }
66
+ metrics { ${exports.METRICS_FIELDS} }
67
+ }`,
68
+ };
69
+ exports.M = {
70
+ arraySetState: `mutation ($input: ArrayStateInput!) { array { setState(input: $input) { id state } } }`,
71
+ parityStart: 'mutation ($correct: Boolean!) { parityCheck { start(correct: $correct) } }',
72
+ parityPause: 'mutation { parityCheck { pause } }',
73
+ parityResume: 'mutation { parityCheck { resume } }',
74
+ parityCancel: 'mutation { parityCheck { cancel } }',
75
+ dockerAction: (action) => `mutation ($id: PrefixedID!) { docker { ${action}(id: $id) { ${exports.CONTAINER_FIELDS} } } }`,
76
+ dockerUpdateMany: `mutation ($ids: [PrefixedID!]!) { docker { updateContainers(ids: $ids) { ${exports.CONTAINER_FIELDS} } } }`,
77
+ dockerUpdateAll: `mutation { docker { updateAllContainers { ${exports.CONTAINER_FIELDS} } } }`,
78
+ dockerRefreshDigests: 'mutation { refreshDockerDigests }',
79
+ vmAction: (action) => `mutation ($id: PrefixedID!) { vm { ${action}(id: $id) } }`,
80
+ createNotification: `mutation ($input: NotificationData!) { createNotification(input: $input) { ${exports.NOTIFICATION_FIELDS} } }`,
81
+ notifyIfUnique: `mutation ($input: NotificationData!) { notifyIfUnique(input: $input) { ${exports.NOTIFICATION_FIELDS} } }`,
82
+ archiveNotification: `mutation ($id: PrefixedID!) { archiveNotification(id: $id) { ${exports.NOTIFICATION_FIELDS} } }`,
83
+ archiveAll: `mutation ($importance: NotificationImportance) { archiveAll(importance: $importance) {
84
+ unread { info warning alert total } archive { info warning alert total }
85
+ } }`,
86
+ };