smartscan 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {smartscan-0.2.0 → smartscan-0.3.0}/AGENTS.md +14 -6
- {smartscan-0.2.0 → smartscan-0.3.0}/CHANGELOG.md +14 -3
- {smartscan-0.2.0 → smartscan-0.3.0}/PKG-INFO +4 -4
- {smartscan-0.2.0 → smartscan-0.3.0}/README.md +3 -3
- {smartscan-0.2.0 → smartscan-0.3.0}/docs/configuration.md +8 -7
- {smartscan-0.2.0 → smartscan-0.3.0}/docs/index.md +7 -7
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/cli.py +19 -19
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/commands.py +67 -55
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/constants.py +1 -38
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/database.py +26 -49
- smartscan-0.3.0/src/smartscan/fields.py +289 -0
- smartscan-0.3.0/src/smartscan/llm.py +251 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/models.py +3 -3
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/output.py +56 -55
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/smartctl.py +104 -100
- {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_cli.py +50 -34
- {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_llm.py +33 -26
- smartscan-0.2.0/src/smartscan/llm.py +0 -207
- {smartscan-0.2.0 → smartscan-0.3.0}/.copier-answers.yaml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.devcontainer/devcontainer.json +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.dockerignore +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.editorconfig +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.github/workflows/docs.yaml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.github/workflows/publish-to-pypi.yaml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.github/workflows/publish-to-test-pypi.yaml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.gitignore +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.pre-commit-config.yaml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.prettierignore +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/.yamlfmt.yaml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/Dockerfile +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/LICENSE +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/docs/llm-examples.md +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/justfile +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/pyproject.toml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/ruff.toml +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/__init__.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/__main__.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/config.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/exceptions.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/logging.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/thresholds.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/tests/conftest.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_core.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_output.py +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/uv.lock +0 -0
- {smartscan-0.2.0 → smartscan-0.3.0}/zensical.toml +0 -0
|
@@ -14,10 +14,15 @@ smartscan is a CLI tool that runs `smartctl` on all disk devices, extracts key S
|
|
|
14
14
|
- DO NOT use ad hoc `pip install` commands instead of updating `pyproject.toml`.
|
|
15
15
|
- DO NOT edit generated output under `site/`; rebuild it from source.
|
|
16
16
|
|
|
17
|
+
## Agent Guidelines
|
|
18
|
+
|
|
19
|
+
- Do not read or write files that are excluded by `.gitignore`, unless a skill or the user explicitly directs you to do so.
|
|
20
|
+
- Proactively use available agent skills when a task matches a skill's description.
|
|
21
|
+
|
|
17
22
|
## Conventions
|
|
18
23
|
|
|
19
24
|
- Application code lives under `src/smartscan`.
|
|
20
|
-
- The CLI uses stdlib `argparse` with `argcomplete` for shell completion. Subcommands: `collect` and `
|
|
25
|
+
- The CLI uses stdlib `argparse` with `argcomplete` for shell completion. Subcommands: `collect`, `query`, and `lsblk`.
|
|
21
26
|
- Terminal output uses [Rich](https://rich.readthedocs.io/) for styled tables and formatting.
|
|
22
27
|
- Configuration uses [Pydantic](https://docs.pydantic.dev/) `BaseModel` for validation.
|
|
23
28
|
- SMART field structures use `TypedDict` (`SmartInfo`) for type safety.
|
|
@@ -30,15 +35,18 @@ smartscan is a CLI tool that runs `smartctl` on all disk devices, extracts key S
|
|
|
30
35
|
|
|
31
36
|
| Module | Responsibility |
|
|
32
37
|
|---|---|
|
|
33
|
-
| `constants.py` | Default paths,
|
|
38
|
+
| `constants.py` | Default paths, database migrations, error messages |
|
|
34
39
|
| `exceptions.py` | `SmartScanError` hierarchy |
|
|
35
40
|
| `models.py` | `SmartScanConfig` (Pydantic), `SmartInfo` (TypedDict) |
|
|
41
|
+
| `fields.py` | `FieldDef` registry — single source of truth for field metadata, DB columns, display labels |
|
|
36
42
|
| `config.py` | `load_config()` — read TOML config into `SmartScanConfig` |
|
|
37
43
|
| `logging.py` | `setup_logging()` |
|
|
38
|
-
| `smartctl.py` | `find_disks()`, `run_smartctl()`, `extract_fields()`, helpers |
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
44
|
+
| `smartctl.py` | `find_disks()`, `run_smartctl()`, `extract_fields()`, `build_device_tree()`, helpers |
|
|
45
|
+
| `thresholds.py` | `check_thresholds()`, `Alert` dataclass |
|
|
46
|
+
| `output.py` | Rich-powered `print_table()`, `print_query_table()`, `print_json_output()`, `print_lsblk_tree()` |
|
|
47
|
+
| `database.py` | SQLite init, open, save, query, schema generation |
|
|
48
|
+
| `llm.py` | `BaseLLMProvider` → `OpenAIProvider` / `AnthropicProvider`, `call_llm()`, `_build_prompt()` |
|
|
49
|
+
| `commands.py` | `do_collect()`, `do_query()`, `do_lsblk()` |
|
|
42
50
|
| `cli.py` | `create_parser()`, `main()` |
|
|
43
51
|
|
|
44
52
|
## Testing Guidelines
|
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-06-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Global `--exclude` option usable by all subcommands, including `collect`
|
|
13
|
+
- `exclude_patterns` top-level config key shared across subcommands
|
|
14
|
+
- Exclude patterns now match both symlink names and resolved target paths
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `identify` subcommand renamed to `lsblk` (**breaking**: CLI invocation changes)
|
|
19
|
+
- `exclude_patterns` config key removed from `[lsblk]` section; use top-level key instead (**breaking**: update TOML config)
|
|
20
|
+
- Extracted `fields.py` and `llm.py` into dedicated modules
|
|
21
|
+
|
|
8
22
|
## [0.2.0] - 2026-06-13
|
|
9
23
|
|
|
10
24
|
### Added
|
|
@@ -56,6 +70,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
56
70
|
- Threshold checks now also run in `query` mode for historical alert display
|
|
57
71
|
- Database migration errors are now logged with a warning instead of being silently swallowed, except for expected "duplicate column" cases
|
|
58
72
|
- Docker image now installs `smartmontools` so the `smartctl` binary is available at run time
|
|
59
|
-
|
|
60
|
-
[0.2.0]: https://github.com/ak1ra-lab/smartscan/tag/?h=v0.2.0
|
|
61
|
-
[0.1.0]: https://github.com/ak1ra-lab/smartscan/tag/?h=v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: smartscan
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A CLI tool that collects SMART data from disk devices, checks configurable health thresholds, optionally analyses results with an LLM, and tracks history in SQLite.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ak1ra-lab/smartscan
|
|
6
6
|
Project-URL: Repository, https://github.com/ak1ra-lab/smartscan
|
|
@@ -55,9 +55,9 @@ smartscan query --since 2026-01-01
|
|
|
55
55
|
Map disks to their /dev/disk/ identifiers (no root needed):
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
smartscan
|
|
59
|
-
smartscan
|
|
60
|
-
smartscan
|
|
58
|
+
smartscan lsblk
|
|
59
|
+
smartscan lsblk --source by-id "Samsung"
|
|
60
|
+
smartscan lsblk --exclude '^/dev/(loop|zd)\d+'
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Read the [full documentation](https://ak1ra-lab.github.io/smartscan/) for shell completion, configuration reference, and advanced usage.
|
|
@@ -32,9 +32,9 @@ smartscan query --since 2026-01-01
|
|
|
32
32
|
Map disks to their /dev/disk/ identifiers (no root needed):
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
smartscan
|
|
36
|
-
smartscan
|
|
37
|
-
smartscan
|
|
35
|
+
smartscan lsblk
|
|
36
|
+
smartscan lsblk --source by-id "Samsung"
|
|
37
|
+
smartscan lsblk --exclude '^/dev/(loop|zd)\d+'
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Read the [full documentation](https://ak1ra-lab.github.io/smartscan/) for shell completion, configuration reference, and advanced usage.
|
|
@@ -13,6 +13,12 @@ db_path = "~/.local/share/smartscan/smartscan.db"
|
|
|
13
13
|
# Custom log file path
|
|
14
14
|
log_file = "~/.local/state/smartscan/smartscan.log"
|
|
15
15
|
|
|
16
|
+
# Regex patterns to exclude disk devices by symlink name or resolved path.
|
|
17
|
+
# Applies to both `collect` and `lsblk` subcommands.
|
|
18
|
+
# Useful for filtering out optical drives, loop devices, ZFS zvols, etc.
|
|
19
|
+
# Example: exclude_patterns = ["BD-RE", '^/dev/(loop|zd)\d+']
|
|
20
|
+
exclude_patterns = []
|
|
21
|
+
|
|
16
22
|
# ── collect ────────────────────────────────────────────────────
|
|
17
23
|
[collect]
|
|
18
24
|
# Skip database writes (run once without saving)
|
|
@@ -29,17 +35,12 @@ no_save = false
|
|
|
29
35
|
# Show only records from the last N days (overrides `since`)
|
|
30
36
|
# last_days = 7
|
|
31
37
|
|
|
32
|
-
# ──
|
|
33
|
-
[
|
|
38
|
+
# ── lsblk ────────────────────────────────────────────────────
|
|
39
|
+
[lsblk]
|
|
34
40
|
# Restrict to specific /dev/disk/ source directories
|
|
35
41
|
# (by-id, by-path, by-diskseq; default: all when empty)
|
|
36
42
|
# source = ["by-id", "by-path"]
|
|
37
43
|
|
|
38
|
-
# Regex patterns to exclude resolved device paths.
|
|
39
|
-
# Useful for filtering out loop devices, ZFS zvols, etc.
|
|
40
|
-
# Example: exclude_patterns = ["^/dev/(loop|zd)\\d+"]
|
|
41
|
-
exclude_patterns = []
|
|
42
|
-
|
|
43
44
|
# ── Threshold alerts ────────────────────────────────────────────
|
|
44
45
|
[thresholds]
|
|
45
46
|
# Master switch: set to false to disable all threshold checks
|
|
@@ -6,7 +6,7 @@ A CLI tool that runs `smartctl` on all disks, extracts key SMART health metrics,
|
|
|
6
6
|
|
|
7
7
|
- Collects SMART data via `smartctl --all --json` from `/dev/disk/by-id/ata-*` and `/dev/disk/by-id/nvme-*`.
|
|
8
8
|
- Displays results as Rich-styled tables with warnings for critical values.
|
|
9
|
-
- Maps disk devices to their `/dev/disk/` identifiers (by-id, by-path, by-diskseq) with a Rich tree view (`
|
|
9
|
+
- Maps disk devices to their `/dev/disk/` identifiers (by-id, by-path, by-diskseq) with a Rich tree view (`lsblk` subcommand).
|
|
10
10
|
- Stores historical data in SQLite for trend analysis (`query` subcommand).
|
|
11
11
|
- Supports JSON lines output for scripting.
|
|
12
12
|
- Configurable via TOML config file with Pydantic validation.
|
|
@@ -57,14 +57,14 @@ smartscan --json collect
|
|
|
57
57
|
smartscan --json query --since 2024-01-01
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
`smartscan
|
|
60
|
+
`smartscan lsblk` maps block devices to their identifiers under `/dev/disk/`. It does not require root:
|
|
61
61
|
|
|
62
62
|
```shell
|
|
63
|
-
smartscan
|
|
64
|
-
smartscan
|
|
65
|
-
smartscan
|
|
66
|
-
smartscan
|
|
67
|
-
smartscan --json
|
|
63
|
+
smartscan lsblk
|
|
64
|
+
smartscan lsblk "Samsung"
|
|
65
|
+
smartscan lsblk --source by-id
|
|
66
|
+
smartscan lsblk --source by-id --source by-diskseq
|
|
67
|
+
smartscan --json lsblk
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
Output example:
|
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
|
|
11
11
|
import argcomplete
|
|
12
12
|
|
|
13
|
-
from .commands import do_collect,
|
|
13
|
+
from .commands import do_collect, do_lsblk, do_query
|
|
14
14
|
from .config import load_config
|
|
15
15
|
from .constants import DEFAULT_CONFIG_PATH, DEFAULT_DB_PATH, DEFAULT_LOG_FILE
|
|
16
16
|
from .exceptions import SmartScanError
|
|
@@ -52,6 +52,15 @@ def _build_parser(config: SmartScanConfig | None = None) -> argparse.ArgumentPar
|
|
|
52
52
|
default=cfg.format == "json",
|
|
53
53
|
help="Output as JSON lines instead of table format",
|
|
54
54
|
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--exclude",
|
|
57
|
+
dest="exclude_patterns",
|
|
58
|
+
action="append",
|
|
59
|
+
metavar="PATTERN",
|
|
60
|
+
default=cfg.exclude_patterns,
|
|
61
|
+
help="Regex patterns to exclude disk devices by symlink name or resolved path "
|
|
62
|
+
"(may be repeated; appended to config exclude_patterns)",
|
|
63
|
+
)
|
|
55
64
|
|
|
56
65
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
57
66
|
|
|
@@ -114,33 +123,24 @@ def _build_parser(config: SmartScanConfig | None = None) -> argparse.ArgumentPar
|
|
|
114
123
|
help="Show extended fields in terminal output",
|
|
115
124
|
)
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
"
|
|
126
|
+
lsblk_parser = sub.add_parser(
|
|
127
|
+
"lsblk",
|
|
119
128
|
help="Map disk devices to their /dev/disk identifiers",
|
|
120
129
|
)
|
|
121
|
-
|
|
130
|
+
lsblk_parser.add_argument(
|
|
122
131
|
"pattern",
|
|
123
132
|
nargs="?",
|
|
124
133
|
default=".*",
|
|
125
134
|
help="Regex pattern to filter identifier names",
|
|
126
135
|
)
|
|
127
|
-
|
|
136
|
+
lsblk_parser.add_argument(
|
|
128
137
|
"--source",
|
|
129
|
-
dest="
|
|
138
|
+
dest="lsblk_source",
|
|
130
139
|
action="append",
|
|
131
140
|
choices=["by-id", "by-path", "by-diskseq"],
|
|
132
141
|
default=None,
|
|
133
142
|
help="Restrict to specific /dev/disk/ source directories "
|
|
134
|
-
"(appended to
|
|
135
|
-
)
|
|
136
|
-
identify_parser.add_argument(
|
|
137
|
-
"--exclude",
|
|
138
|
-
dest="identify_exclude",
|
|
139
|
-
action="append",
|
|
140
|
-
metavar="PATTERN",
|
|
141
|
-
default=None,
|
|
142
|
-
help="Additional regex patterns to exclude resolved device paths "
|
|
143
|
-
"(appended to identify.exclude_patterns config; may be repeated)",
|
|
143
|
+
"(appended to lsblk.source config; may be repeated; default: all)",
|
|
144
144
|
)
|
|
145
145
|
|
|
146
146
|
argcomplete.autocomplete(parser)
|
|
@@ -166,12 +166,12 @@ def main() -> None:
|
|
|
166
166
|
args.llm_config = config.llm
|
|
167
167
|
args.collect_config = config.collect
|
|
168
168
|
args.query_config = config.query
|
|
169
|
-
args.
|
|
169
|
+
args.lsblk_config = config.lsblk
|
|
170
170
|
|
|
171
171
|
if args.command == "query":
|
|
172
172
|
do_query(args)
|
|
173
|
-
elif args.command == "
|
|
174
|
-
|
|
173
|
+
elif args.command == "lsblk":
|
|
174
|
+
do_lsblk(args)
|
|
175
175
|
else:
|
|
176
176
|
do_collect(args)
|
|
177
177
|
except SmartScanError as exc:
|
|
@@ -8,49 +8,100 @@ import sqlite3
|
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
10
|
from argparse import Namespace
|
|
11
|
+
from typing import Any
|
|
11
12
|
|
|
12
13
|
from .database import init_db, open_db, query_smart_info, save_to_db
|
|
13
14
|
from .exceptions import DiskNotFoundError
|
|
14
15
|
from .llm import call_llm
|
|
15
16
|
from .output import (
|
|
16
|
-
print_identify_json,
|
|
17
|
-
print_identify_tree,
|
|
18
17
|
print_json_output,
|
|
19
18
|
print_llm_analysis,
|
|
19
|
+
print_lsblk_json,
|
|
20
|
+
print_lsblk_tree,
|
|
20
21
|
print_query_table,
|
|
21
22
|
print_table,
|
|
22
23
|
row_to_fields,
|
|
23
24
|
)
|
|
24
25
|
from .smartctl import build_device_tree, extract_fields, find_disks, run_smartctl
|
|
25
|
-
from .thresholds import check_thresholds
|
|
26
|
+
from .thresholds import Alert, check_thresholds
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
def
|
|
29
|
+
def _build_alerts_text(alerts: list[Alert]) -> str:
|
|
30
|
+
if not alerts:
|
|
31
|
+
return " (none)"
|
|
32
|
+
return "\n".join(f" - {a.message}" for a in alerts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _collect_one_disk(
|
|
36
|
+
disk_idx: int,
|
|
37
|
+
total_disks: int,
|
|
38
|
+
symlink: Any,
|
|
39
|
+
conn: sqlite3.Connection | None,
|
|
40
|
+
args: Namespace,
|
|
41
|
+
) -> int:
|
|
42
|
+
disk_name = symlink.name
|
|
43
|
+
disk_path = os.readlink(symlink)
|
|
44
|
+
|
|
45
|
+
data, rc = run_smartctl(symlink)
|
|
46
|
+
if not data:
|
|
47
|
+
return rc
|
|
48
|
+
|
|
49
|
+
fields = extract_fields(data)
|
|
50
|
+
|
|
51
|
+
alerts: list[Alert] = []
|
|
52
|
+
if args.thresholds_enabled:
|
|
53
|
+
alerts = check_thresholds(fields, args.threshold_rules)
|
|
54
|
+
|
|
55
|
+
if args.json:
|
|
56
|
+
print_json_output(disk_name, disk_path, fields, data)
|
|
57
|
+
else:
|
|
58
|
+
print_table(disk_name, fields, alerts, verbose=args.verbose)
|
|
59
|
+
|
|
60
|
+
llm_analysis = None
|
|
61
|
+
if args.force_llm or (args.llm_config.enabled and alerts):
|
|
62
|
+
alerts_text = _build_alerts_text(alerts)
|
|
63
|
+
llm_analysis = call_llm(fields, alerts_text, args.llm_config, raw_data=data)
|
|
64
|
+
if llm_analysis:
|
|
65
|
+
if not args.json:
|
|
66
|
+
print_llm_analysis(llm_analysis)
|
|
67
|
+
if args.llm_config.delay > 0 and disk_idx < total_disks - 1:
|
|
68
|
+
time.sleep(args.llm_config.delay)
|
|
69
|
+
|
|
70
|
+
if conn:
|
|
71
|
+
try:
|
|
72
|
+
save_to_db(
|
|
73
|
+
conn, disk_name, disk_path, fields, data, llm_analysis=llm_analysis
|
|
74
|
+
)
|
|
75
|
+
except sqlite3.Error as exc:
|
|
76
|
+
logging.error("Failed to save SMART data for %s: %s", disk_name, exc)
|
|
77
|
+
|
|
78
|
+
return rc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def do_lsblk(args: Namespace) -> None:
|
|
29
82
|
from .smartctl import _SOURCE_DIRS
|
|
30
83
|
|
|
31
|
-
config_cfg = args.
|
|
84
|
+
config_cfg = args.lsblk_config
|
|
32
85
|
|
|
33
86
|
sources_list = list(config_cfg.source or _SOURCE_DIRS)
|
|
34
|
-
if args.
|
|
35
|
-
sources_list.extend(args.
|
|
87
|
+
if args.lsblk_source:
|
|
88
|
+
sources_list.extend(args.lsblk_source)
|
|
36
89
|
sources = tuple(sources_list)
|
|
37
90
|
|
|
38
|
-
exclude_list = list(config_cfg.exclude_patterns)
|
|
39
|
-
if args.identify_exclude:
|
|
40
|
-
exclude_list.extend(args.identify_exclude)
|
|
41
|
-
|
|
42
91
|
try:
|
|
43
92
|
devices = build_device_tree(
|
|
44
|
-
args.pattern,
|
|
93
|
+
args.pattern,
|
|
94
|
+
sources=sources,
|
|
95
|
+
exclude_patterns=args.exclude_patterns or None,
|
|
45
96
|
)
|
|
46
97
|
except DiskNotFoundError as exc:
|
|
47
98
|
logging.error("%s", exc)
|
|
48
99
|
sys.exit(1)
|
|
49
100
|
|
|
50
101
|
if args.json:
|
|
51
|
-
|
|
102
|
+
print_lsblk_json(devices)
|
|
52
103
|
else:
|
|
53
|
-
|
|
104
|
+
print_lsblk_tree(devices)
|
|
54
105
|
|
|
55
106
|
|
|
56
107
|
def do_collect(args: Namespace) -> None:
|
|
@@ -67,56 +118,17 @@ def do_collect(args: Namespace) -> None:
|
|
|
67
118
|
logging.error("Failed to open database at %s: %s", args.db_path, exc)
|
|
68
119
|
|
|
69
120
|
try:
|
|
70
|
-
disks = find_disks(args.pattern)
|
|
121
|
+
disks = find_disks(args.pattern, exclude_patterns=args.exclude_patterns or None)
|
|
71
122
|
except DiskNotFoundError as exc:
|
|
72
123
|
logging.error("%s", exc)
|
|
73
124
|
sys.exit(1)
|
|
74
125
|
|
|
75
|
-
thresholds_enabled = args.thresholds_enabled
|
|
76
|
-
|
|
77
126
|
exit_code = 0
|
|
78
127
|
for disk_idx, symlink in enumerate(disks):
|
|
79
|
-
|
|
80
|
-
disk_path = os.readlink(symlink)
|
|
81
|
-
|
|
82
|
-
data, rc = run_smartctl(symlink)
|
|
128
|
+
rc = _collect_one_disk(disk_idx, len(disks), symlink, conn, args)
|
|
83
129
|
if rc != 0:
|
|
84
130
|
exit_code = 1
|
|
85
131
|
|
|
86
|
-
if not data:
|
|
87
|
-
continue
|
|
88
|
-
|
|
89
|
-
fields = extract_fields(data)
|
|
90
|
-
|
|
91
|
-
alerts = []
|
|
92
|
-
if thresholds_enabled:
|
|
93
|
-
alerts = check_thresholds(fields, args.threshold_rules)
|
|
94
|
-
|
|
95
|
-
if args.json:
|
|
96
|
-
print_json_output(disk_name, disk_path, fields, data)
|
|
97
|
-
else:
|
|
98
|
-
print_table(disk_name, fields, alerts, verbose=args.verbose)
|
|
99
|
-
|
|
100
|
-
llm_analysis = None
|
|
101
|
-
if args.force_llm or (args.llm_config.enabled and alerts):
|
|
102
|
-
alerts_text = (
|
|
103
|
-
"\n".join(f" - {a.message}" for a in alerts) if alerts else " (none)"
|
|
104
|
-
)
|
|
105
|
-
llm_analysis = call_llm(fields, alerts_text, args.llm_config, raw_data=data)
|
|
106
|
-
if llm_analysis:
|
|
107
|
-
if not args.json:
|
|
108
|
-
print_llm_analysis(llm_analysis)
|
|
109
|
-
if args.llm_config.delay > 0 and disk_idx < len(disks) - 1:
|
|
110
|
-
time.sleep(args.llm_config.delay)
|
|
111
|
-
|
|
112
|
-
if conn:
|
|
113
|
-
try:
|
|
114
|
-
save_to_db(
|
|
115
|
-
conn, disk_name, disk_path, fields, data, llm_analysis=llm_analysis
|
|
116
|
-
)
|
|
117
|
-
except sqlite3.Error as exc:
|
|
118
|
-
logging.error("Failed to save SMART data for %s: %s", disk_name, exc)
|
|
119
|
-
|
|
120
132
|
if conn:
|
|
121
133
|
conn.close()
|
|
122
134
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Default paths, database
|
|
1
|
+
"""Default paths, database migration constants, and error messages."""
|
|
2
2
|
|
|
3
3
|
DEFAULT_DB_PATH = "~/.local/share/smartscan/smartscan.db"
|
|
4
4
|
DEFAULT_LOG_FILE = "~/.local/state/smartscan/smartscan.log"
|
|
@@ -15,43 +15,6 @@ SMARTCTL_ERROR_MSGS = [
|
|
|
15
15
|
"Bit 7: The device self-test log contains records of errors. [ATA only] Failed self-tests outdated by a newer successful extended self-test are ignored.",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
-
DB_SCHEMA = """
|
|
19
|
-
CREATE TABLE IF NOT EXISTS smart_info (
|
|
20
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
-
timestamp TEXT NOT NULL,
|
|
22
|
-
disk_name TEXT NOT NULL,
|
|
23
|
-
disk_path TEXT NOT NULL,
|
|
24
|
-
model_family TEXT,
|
|
25
|
-
model_name TEXT,
|
|
26
|
-
serial_number TEXT,
|
|
27
|
-
firmware_version TEXT,
|
|
28
|
-
user_capacity_bytes INTEGER,
|
|
29
|
-
user_capacity_gib REAL,
|
|
30
|
-
rotation_rate TEXT,
|
|
31
|
-
interface_speed TEXT,
|
|
32
|
-
power_on_time_hours TEXT,
|
|
33
|
-
power_cycle_count TEXT,
|
|
34
|
-
smart_status TEXT,
|
|
35
|
-
temperature_celsius TEXT,
|
|
36
|
-
reallocated_sector_ct TEXT,
|
|
37
|
-
current_pending_sector TEXT,
|
|
38
|
-
offline_uncorrectable TEXT,
|
|
39
|
-
reallocated_event_count TEXT,
|
|
40
|
-
ata_smart_error_log_count TEXT,
|
|
41
|
-
self_test_status TEXT,
|
|
42
|
-
udma_crc_error_count TEXT,
|
|
43
|
-
raw_read_error_rate TEXT,
|
|
44
|
-
spin_retry_count TEXT,
|
|
45
|
-
power_off_retract_count TEXT,
|
|
46
|
-
load_cycle_count TEXT,
|
|
47
|
-
helium_level TEXT,
|
|
48
|
-
raw_json TEXT,
|
|
49
|
-
llm_analysis TEXT
|
|
50
|
-
);
|
|
51
|
-
CREATE INDEX IF NOT EXISTS idx_smart_info_disk_ts
|
|
52
|
-
ON smart_info(disk_path, timestamp);
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
18
|
DB_MIGRATIONS = [
|
|
56
19
|
"ALTER TABLE smart_info ADD COLUMN serial_number TEXT",
|
|
57
20
|
"ALTER TABLE smart_info ADD COLUMN firmware_version TEXT",
|
|
@@ -10,11 +10,34 @@ from datetime import datetime, timezone
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
-
from .constants import DB_MIGRATIONS
|
|
13
|
+
from .constants import DB_MIGRATIONS
|
|
14
14
|
from .exceptions import InvalidDateError
|
|
15
|
+
from .fields import ALL_HEALTH_FIELDS, get_field
|
|
15
16
|
from .models import SmartInfo
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
def _build_schema() -> str:
|
|
20
|
+
col_defs = [
|
|
21
|
+
" id INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
22
|
+
" timestamp TEXT NOT NULL",
|
|
23
|
+
" disk_name TEXT NOT NULL",
|
|
24
|
+
" disk_path TEXT NOT NULL",
|
|
25
|
+
]
|
|
26
|
+
for f in ALL_HEALTH_FIELDS:
|
|
27
|
+
col_defs.append(f" {f.db_column} {f.db_type}")
|
|
28
|
+
col_defs.append(" raw_json TEXT")
|
|
29
|
+
col_defs.append(" llm_analysis TEXT")
|
|
30
|
+
cols = ",\n".join(col_defs)
|
|
31
|
+
return (
|
|
32
|
+
f"CREATE TABLE IF NOT EXISTS smart_info (\n{cols}\n);\n"
|
|
33
|
+
"CREATE INDEX IF NOT EXISTS idx_smart_info_disk_ts\n"
|
|
34
|
+
" ON smart_info(disk_path, timestamp);"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
DB_SCHEMA = _build_schema()
|
|
39
|
+
|
|
40
|
+
|
|
18
41
|
def _run_migrations(conn: sqlite3.Connection) -> None:
|
|
19
42
|
for sql in DB_MIGRATIONS:
|
|
20
43
|
try:
|
|
@@ -105,30 +128,7 @@ _COLUMNS_INSERT = (
|
|
|
105
128
|
"timestamp",
|
|
106
129
|
"disk_name",
|
|
107
130
|
"disk_path",
|
|
108
|
-
|
|
109
|
-
"model_name",
|
|
110
|
-
"serial_number",
|
|
111
|
-
"firmware_version",
|
|
112
|
-
"user_capacity_bytes",
|
|
113
|
-
"user_capacity_gib",
|
|
114
|
-
"rotation_rate",
|
|
115
|
-
"interface_speed",
|
|
116
|
-
"power_on_time_hours",
|
|
117
|
-
"power_cycle_count",
|
|
118
|
-
"smart_status",
|
|
119
|
-
"temperature_celsius",
|
|
120
|
-
"reallocated_sector_ct",
|
|
121
|
-
"current_pending_sector",
|
|
122
|
-
"offline_uncorrectable",
|
|
123
|
-
"reallocated_event_count",
|
|
124
|
-
"ata_smart_error_log_count",
|
|
125
|
-
"self_test_status",
|
|
126
|
-
"udma_crc_error_count",
|
|
127
|
-
"raw_read_error_rate",
|
|
128
|
-
"spin_retry_count",
|
|
129
|
-
"power_off_retract_count",
|
|
130
|
-
"load_cycle_count",
|
|
131
|
-
"helium_level",
|
|
131
|
+
*(f.db_column for f in ALL_HEALTH_FIELDS),
|
|
132
132
|
"raw_json",
|
|
133
133
|
"llm_analysis",
|
|
134
134
|
)
|
|
@@ -152,30 +152,7 @@ def save_to_db(
|
|
|
152
152
|
timestamp,
|
|
153
153
|
disk_name,
|
|
154
154
|
disk_path,
|
|
155
|
-
fields
|
|
156
|
-
fields["model_name"],
|
|
157
|
-
fields["serial_number"],
|
|
158
|
-
fields["firmware_version"],
|
|
159
|
-
fields["user_capacity_bytes"],
|
|
160
|
-
fields["user_capacity_gib"],
|
|
161
|
-
fields["rotation_rate"],
|
|
162
|
-
fields["interface_speed"],
|
|
163
|
-
fields["power_on_time"],
|
|
164
|
-
fields["power_cycle_count"],
|
|
165
|
-
fields["smart_status"],
|
|
166
|
-
fields["temperature"],
|
|
167
|
-
fields["reallocated_sector_ct"],
|
|
168
|
-
fields["current_pending_sector"],
|
|
169
|
-
fields["offline_uncorrectable"],
|
|
170
|
-
fields["reallocated_event_count"],
|
|
171
|
-
fields["ata_smart_error_log"],
|
|
172
|
-
fields["self_test_status"],
|
|
173
|
-
fields["udma_crc_error_count"],
|
|
174
|
-
fields["raw_read_error_rate"],
|
|
175
|
-
fields["spin_retry_count"],
|
|
176
|
-
fields["power_off_retract_count"],
|
|
177
|
-
fields["load_cycle_count"],
|
|
178
|
-
fields["helium_level"],
|
|
155
|
+
*(get_field(fields, f.key) for f in ALL_HEALTH_FIELDS),
|
|
179
156
|
json.dumps(raw_data, ensure_ascii=False),
|
|
180
157
|
llm_analysis,
|
|
181
158
|
),
|