@silentjma/n8n-nodes-unraid 0.0.0-stage → 0.1.0
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/dist/credentials/UnraidApi.credentials.js +65 -0
- package/dist/lib/analysis.js +398 -0
- package/dist/lib/client.js +46 -0
- package/dist/lib/containers.js +54 -0
- package/dist/lib/logs.js +41 -0
- package/dist/lib/queries.js +86 -0
- package/dist/nodes/Unraid/Unraid.node.js +712 -0
- package/dist/nodes/Unraid/Unraid.node.json +9 -0
- package/dist/nodes/Unraid/unraid.svg +1 -0
- package/dist/nodes/UnraidTrigger/UnraidTrigger.node.js +283 -0
- package/dist/nodes/UnraidTrigger/UnraidTrigger.node.json +9 -0
- package/dist/nodes/UnraidTrigger/unraid.svg +1 -0
- package/package.json +75 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package. Versions follow [semver](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
### Project
|
|
8
|
+
- Community files: CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, SUPPORT, issue and pull request templates, Dependabot.
|
|
9
|
+
- CI on every push and pull request. A release workflow publishes to npm with provenance when a `v*` tag is pushed.
|
|
10
|
+
- Buy Me a Coffee funding link.
|
|
11
|
+
|
|
12
|
+
## 0.1.0 - 2026-10-25
|
|
13
|
+
|
|
14
|
+
First release.
|
|
15
|
+
|
|
16
|
+
### Nodes
|
|
17
|
+
- **Unraid** action node:
|
|
18
|
+
- System: health report with suggested next steps, info, metrics.
|
|
19
|
+
- Array: status, start, stop, parity check start, pause, resume and cancel, parity history.
|
|
20
|
+
- Disks and shares.
|
|
21
|
+
- Docker: list, get, logs (analysed), update status, check for updates, start, stop, restart, pause, unpause, update one or more containers, update all.
|
|
22
|
+
- VMs: list, start, stop, force stop, pause, resume, reboot, reset.
|
|
23
|
+
- Log files: list and read, with a regex filter and severity analysis.
|
|
24
|
+
- Notifications: create (optionally only if unique), list, overview, warnings and alerts, archive.
|
|
25
|
+
- Custom GraphQL.
|
|
26
|
+
- **Unraid Trigger** polling events: log file lines, container log lines, health problems (optionally resolved ones too), new notifications, container state changes, container updates available.
|
|
27
|
+
- **Unraid API** credential: server URL, `x-api-key`, and an option to accept self-signed certificates. The credential test fails on a revoked or invalid key, even though Unraid answers those with HTTP 200.
|
|
28
|
+
- Containers can be given by name, ID or command-safe name (`plex_media_server`).
|
|
29
|
+
- Built-in rules for log lines and health findings, each with next steps:
|
|
30
|
+
- Logs: disk I/O, filesystem corruption, OOM, hardware/MCE, kernel traces, disk space, parity, auth failures, segfaults, Docker, network, temperature, application fatal errors.
|
|
31
|
+
- Health: array state, disk status, errors, temperature (separate HDD and SSD thresholds) and usage, SMART, parity, stopped autostart containers, updates, memory, CPU, Unraid alerts.
|
|
32
|
+
|
|
33
|
+
### Workflows
|
|
34
|
+
- `unraid-alerts.json`, `unraid-docker.json` and `unraid-sysadmin-bot.json`: alerts, Docker updates and on-demand actions, and sysadmin commands, all managed from a Telegram bot.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohamed Ayoub Jabane
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @silentjma/n8n-nodes-unraid
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@silentjma/n8n-nodes-unraid)
|
|
4
|
+
[](https://github.com/SilentJMA/n8n-nodes-unraid/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.buymeacoffee.com/kilwanitrod)
|
|
7
|
+
|
|
8
|
+
Unofficial n8n community nodes for the [Unraid API](https://docs.unraid.net/API/) (GraphQL, [unraid/api](https://github.com/unraid/api)).
|
|
9
|
+
|
|
10
|
+
- **Unraid**: an action node for System, Array, Disk, Share, Docker Container, Virtual Machine, Log File and Notification, plus a Custom GraphQL operation.
|
|
11
|
+
- **Unraid Trigger**: a polling trigger for new log lines (syslog or a container log), health problems, notifications, container state changes and container updates.
|
|
12
|
+
|
|
13
|
+
Both nodes use the same built-in rules ([src/lib/analysis.ts](src/lib/analysis.ts)). The rules tag log lines and health findings as `info`, `warning` or `critical` and attach **suggested next steps**.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
**Community Nodes (recommended):** in n8n, open **Settings › Community Nodes › Install** and enter `@silentjma/n8n-nodes-unraid`.
|
|
18
|
+
|
|
19
|
+
**Manual / custom folder:** run `npm pack` and extract the tarball's `package/` folder into `~/.n8n/custom/n8n-nodes-unraid`. In Docker that is `/home/node/.n8n/custom`, which on Unraid is usually `/mnt/user/appdata/n8n/custom`. Then restart n8n.
|
|
20
|
+
|
|
21
|
+
Prefer workflows without a community node? [n8n-unraid-telegram-workflows](https://github.com/SilentJMA/n8n-unraid-telegram-workflows) has the same three workflows built only from n8n's built-in nodes.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Unraid 7.2+ has the API built in. Older versions need the Unraid Connect plugin.
|
|
26
|
+
- An API key, created in **Settings › Management Access › API Keys** or with `unraid-api apikey --create`. Read operations work with a read-only role. Updates, restarts and array/VM control need **admin**.
|
|
27
|
+
|
|
28
|
+
## Credential: Unraid API
|
|
29
|
+
|
|
30
|
+
| Field | Notes |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| Server URL | WebGUI base URL, e.g. `https://192.168.1.10`. Requests go to `<url>/graphql` with header `x-api-key`. Use https when SSL is enabled: Unraid redirects http, and the request body is lost. |
|
|
33
|
+
| API Key | The key from the WebGUI |
|
|
34
|
+
| Ignore SSL Issues | For self-signed certificates |
|
|
35
|
+
|
|
36
|
+
## Unraid node operations
|
|
37
|
+
|
|
38
|
+
| Resource | Operations |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| System | Get Health Report (findings + next steps), Get Info, Get Metrics |
|
|
41
|
+
| Array | Get Status, Start, Stop, Start / Pause / Resume / Cancel Parity Check, Get Parity History |
|
|
42
|
+
| Disk / Share | Get Many |
|
|
43
|
+
| Docker Container | Get, Get Many (filters: state, updates only, autostart, name), Get Logs (analysed), Get Update Status, Check for Updates, Start, Stop, Restart, Pause, Unpause, Update (one or many), Update All |
|
|
44
|
+
| Virtual Machine | Get Many, Start, Stop, Force Stop, Pause, Resume, Reboot, Reset |
|
|
45
|
+
| Log File | Get Many (`/var/log`), Read (last N lines or from a line; regex filter; analysis; one item or one per line) |
|
|
46
|
+
| Notification | Create (optionally only if unique), Get Many, Get Overview, Get Warnings and Alerts, Archive, Archive All |
|
|
47
|
+
| Custom GraphQL | Execute any query or mutation |
|
|
48
|
+
|
|
49
|
+
Containers can be given by **name** (`plex`) or ID.
|
|
50
|
+
|
|
51
|
+
## Unraid Trigger events
|
|
52
|
+
|
|
53
|
+
| Event | Fires on |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| Log File Line | New lines in a `/var/log` file at or above the minimum severity. Tracks the line count and handles log rotation. |
|
|
56
|
+
| Container Log Line | New lines in a container's log (cursor based) |
|
|
57
|
+
| Health Problem | A finding not reported before. Can also emit "resolved" items. |
|
|
58
|
+
| New Notification | New unread Unraid notification at or above an importance |
|
|
59
|
+
| Container State Changed | Start / stop / pause / removal |
|
|
60
|
+
| Container Update Available | A container newly has an image update |
|
|
61
|
+
|
|
62
|
+
On the first poll, the trigger only records a baseline. Turn on *Emit Existing on First Poll* to also get what already exists. Manual "Fetch Test Event" always returns current data.
|
|
63
|
+
|
|
64
|
+
## Workflows (managed from Telegram)
|
|
65
|
+
|
|
66
|
+
`npm run workflow` ([scripts/build-workflows.mjs](scripts/build-workflows.mjs)) generates three workflows in [workflows/](workflows/):
|
|
67
|
+
|
|
68
|
+
| Workflow | Triggers | What it does |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| [Unraid · Alerts](workflows/unraid-alerts.json) | Syslog every minute, health report every 5 min | Sends each new problem to Telegram with suggested next steps and tappable follow-up commands (`/logs_sonarr`, `/disks`…). Repeats are throttled (60 min per alert key). Optional: AI triage (disabled by default), an Unraid notification as well, and automatic restart of stopped autostart containers. |
|
|
71
|
+
| [Unraid · Docker updates & actions](workflows/unraid-docker.json) | Weekly (Sun 04:00), and called by the bot | Weekly: updates every container that has a new image, except `cfg.excludeFromUpdates`. It reports only when something changed. Also answers the Docker commands. |
|
|
72
|
+
| [Unraid · Sysadmin & Telegram bot](workflows/unraid-sysadmin-bot.json) | Telegram Trigger | The bot. Answers the sysadmin commands and forwards Docker commands to the Docker workflow. |
|
|
73
|
+
|
|
74
|
+
A Telegram bot delivers updates to a single webhook, so **only the bot workflow has a Telegram Trigger**. The other two just send messages.
|
|
75
|
+
|
|
76
|
+
### Commands
|
|
77
|
+
|
|
78
|
+
| Server | Docker |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `/status`: health report with next steps | `/containers`: list with quick actions |
|
|
81
|
+
| `/info`: uptime, CPU, RAM, array usage | `/updates`: check for image updates |
|
|
82
|
+
| `/disks`: status, temperature, usage, errors | `/update_all`: update all (asks first) |
|
|
83
|
+
| `/parity`, `/parity_start` (asks first), `/parity_pause`, `/parity_resume`, `/parity_cancel` | `/update_<name>`, `/restart_<name>`, `/start_<name>` |
|
|
84
|
+
| `/syslog`: recent warnings with next steps | `/stop_<name>` (asks first) |
|
|
85
|
+
| `/notifications`, `/archive_all` | `/logs_<name>`: last lines, analysed |
|
|
86
|
+
| `/vms`, `/help` | |
|
|
87
|
+
|
|
88
|
+
Telegram commands allow only `a-z0-9_`, so container names are written with underscores: `Plex-Media-Server` becomes `/logs_plex_media_server`. Actions that ask first reply with a `…_confirm` command to tap.
|
|
89
|
+
|
|
90
|
+
For autocomplete, send this to @BotFather with `/setcommands`:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
status - Health report with next steps
|
|
94
|
+
info - Uptime, CPU, RAM, array usage
|
|
95
|
+
disks - Disk status and temperatures
|
|
96
|
+
containers - Docker containers with quick actions
|
|
97
|
+
updates - Check for container updates
|
|
98
|
+
syslog - Recent syslog warnings
|
|
99
|
+
parity - Parity check status
|
|
100
|
+
notifications - Unread Unraid notifications
|
|
101
|
+
help - All commands
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Setup
|
|
105
|
+
|
|
106
|
+
1. Create a bot with @BotFather and add a **Telegram** credential in n8n with its token.
|
|
107
|
+
2. Import the three workflows. In each one, select the **Unraid API** credential (`https://192.168.178.2`, Ignore SSL Issues on) and the Telegram credential.
|
|
108
|
+
3. Publish the **Sysadmin & Telegram bot** workflow and send the bot any message. While `cfg.allowedChatIds` is empty, the bot only replies with your chat ID and runs nothing else.
|
|
109
|
+
4. Put that ID in **Bot settings › cfg.allowedChatIds**, **Alert settings › cfg.telegramChatId** and **Settings › cfg.telegramChatId** (Docker workflow). After that, the bot ignores every other chat.
|
|
110
|
+
5. In the bot's **Run Docker workflow** node, pick *Unraid · Docker updates & actions*. Alternatively, regenerate with `DOCKER_WORKFLOW_ID=<id> npm run workflow` and re-import.
|
|
111
|
+
6. Publish the Docker and Alerts workflows.
|
|
112
|
+
|
|
113
|
+
Keep **n8n** in `cfg.excludeFromUpdates`. Updating it recreates the container that is running the workflow, so the run is cut off and no report arrives.
|
|
114
|
+
|
|
115
|
+
The workflows use node types `CUSTOM.unraid` / `CUSTOM.unraidTrigger`, which n8n uses for nodes in `~/.n8n/custom`. If you install the package through Community Nodes, generate with `UNRAID_NODE_PREFIX=@silentjma/n8n-nodes-unraid npm run workflow`.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
See [AGENT.md](AGENT.md) for the full maintenance and release procedure.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm install
|
|
123
|
+
npm run build && npm run lint && npm run typecheck && npm test
|
|
124
|
+
npm pack # -> silentjma-n8n-nodes-unraid-0.1.0.tgz
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The queries in [src/lib/queries.ts](src/lib/queries.ts) are written against `api/generated-schema.graphql` from the unraid/api repository. Check them again when Unraid changes the schema.
|
|
128
|
+
|
|
129
|
+
## Support and contributing
|
|
130
|
+
|
|
131
|
+
- **Questions:** [Discussions › Q&A](https://github.com/SilentJMA/n8n-nodes-unraid/discussions/categories/q-a). Common problems are listed in [SUPPORT.md](SUPPORT.md).
|
|
132
|
+
- **Bugs and feature requests:** [open an issue](https://github.com/SilentJMA/n8n-nodes-unraid/issues/new/choose) using the templates. Never paste API keys or tokens.
|
|
133
|
+
- **Security:** report privately, see [SECURITY.md](SECURITY.md).
|
|
134
|
+
- **Contributing:** pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
135
|
+
|
|
136
|
+
If these nodes save you time, you can support the project:
|
|
137
|
+
|
|
138
|
+
<a href="https://www.buymeacoffee.com/kilwanitrod"><img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=%E2%98%95&slug=kilwanitrod&button_colour=FFDD00&font_colour=000000&font_family=Inter&outline_colour=000000&coffee_colour=ffffff" alt="Buy me a coffee" height="40"></a>
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
[MIT](LICENSE). Not affiliated with or endorsed by Lime Technology / Unraid.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UnraidApi = void 0;
|
|
4
|
+
class UnraidApi {
|
|
5
|
+
name = 'unraidApi';
|
|
6
|
+
displayName = 'Unraid API';
|
|
7
|
+
documentationUrl = 'https://docs.unraid.net/API/how-to-use-the-api/';
|
|
8
|
+
properties = [
|
|
9
|
+
{
|
|
10
|
+
displayName: 'Server URL',
|
|
11
|
+
name: 'serverUrl',
|
|
12
|
+
type: 'string',
|
|
13
|
+
default: 'https://tower.local',
|
|
14
|
+
placeholder: 'https://192.168.1.10',
|
|
15
|
+
required: true,
|
|
16
|
+
description: 'Base URL of the Unraid WebGUI; requests go to /graphql. Use https:// when SSL is enabled (Unraid redirects http:// and the request would fail).',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
displayName: 'API Key',
|
|
20
|
+
name: 'apiKey',
|
|
21
|
+
type: 'string',
|
|
22
|
+
typeOptions: { password: true },
|
|
23
|
+
default: '',
|
|
24
|
+
required: true,
|
|
25
|
+
description: 'Created in Settings › Management Access › API Keys (or "unraid-api apikey --create"). Docker updates, array and VM control need the admin role.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
displayName: 'Ignore SSL Issues',
|
|
29
|
+
name: 'allowUnauthorizedCerts',
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
default: false,
|
|
32
|
+
description: 'Whether to connect even if the server uses a self-signed or otherwise invalid certificate (the default for Unraid on a LAN IP)',
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
authenticate = {
|
|
36
|
+
type: 'generic',
|
|
37
|
+
properties: {
|
|
38
|
+
headers: {
|
|
39
|
+
'x-api-key': '={{$credentials.apiKey}}',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
test = {
|
|
44
|
+
request: {
|
|
45
|
+
baseURL: '={{$credentials.serverUrl.replace(/\\/+$/, "")}}',
|
|
46
|
+
url: '/graphql',
|
|
47
|
+
method: 'POST',
|
|
48
|
+
skipSslCertificateValidation: '={{$credentials.allowUnauthorizedCerts}}',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: { query: '{ online info { os { hostname } } }' },
|
|
51
|
+
},
|
|
52
|
+
// Unraid answers HTTP 200 with a GraphQL error for a bad key, so check the body.
|
|
53
|
+
rules: [
|
|
54
|
+
{
|
|
55
|
+
type: 'responseSuccessBody',
|
|
56
|
+
properties: { key: 'errors[0].extensions.code', value: 'UNAUTHENTICATED', message: 'Invalid or revoked API key' },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'responseSuccessBody',
|
|
60
|
+
properties: { key: 'errors[0].extensions.code', value: 'FORBIDDEN', message: 'The API key may not read server info; give it at least a read role' },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
exports.UnraidApi = UnraidApi;
|
|
@@ -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
|
+
}
|