zabbixvms 0.1.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.
- zabbixvms-0.1.0/LICENSE +21 -0
- zabbixvms-0.1.0/PKG-INFO +77 -0
- zabbixvms-0.1.0/README.md +55 -0
- zabbixvms-0.1.0/pyproject.toml +60 -0
- zabbixvms-0.1.0/setup.cfg +4 -0
- zabbixvms-0.1.0/src/zabbixvms/__init__.py +7 -0
- zabbixvms-0.1.0/src/zabbixvms/agent.py +117 -0
- zabbixvms-0.1.0/src/zabbixvms/collector.py +190 -0
- zabbixvms-0.1.0/src/zabbixvms/config.py +166 -0
- zabbixvms-0.1.0/src/zabbixvms/data/config_default.json +28 -0
- zabbixvms-0.1.0/src/zabbixvms/data/zabbix_template.yaml +128 -0
- zabbixvms-0.1.0/src/zabbixvms/log.py +133 -0
- zabbixvms-0.1.0/src/zabbixvms/metrics.py +184 -0
- zabbixvms-0.1.0/src/zabbixvms/sender.py +45 -0
- zabbixvms-0.1.0/src/zabbixvms/service.py +264 -0
- zabbixvms-0.1.0/src/zabbixvms/servicecontrol.py +93 -0
- zabbixvms-0.1.0/src/zabbixvms/template.py +170 -0
- zabbixvms-0.1.0/src/zabbixvms/tray.py +294 -0
- zabbixvms-0.1.0/src/zabbixvms.egg-info/PKG-INFO +77 -0
- zabbixvms-0.1.0/src/zabbixvms.egg-info/SOURCES.txt +35 -0
- zabbixvms-0.1.0/src/zabbixvms.egg-info/dependency_links.txt +1 -0
- zabbixvms-0.1.0/src/zabbixvms.egg-info/entry_points.txt +5 -0
- zabbixvms-0.1.0/src/zabbixvms.egg-info/requires.txt +8 -0
- zabbixvms-0.1.0/src/zabbixvms.egg-info/top_level.txt +1 -0
- zabbixvms-0.1.0/tests/test_agent.py +387 -0
- zabbixvms-0.1.0/tests/test_collector.py +387 -0
- zabbixvms-0.1.0/tests/test_collector_db.py +128 -0
- zabbixvms-0.1.0/tests/test_config.py +243 -0
- zabbixvms-0.1.0/tests/test_entrypoints.py +44 -0
- zabbixvms-0.1.0/tests/test_log.py +176 -0
- zabbixvms-0.1.0/tests/test_metrics.py +85 -0
- zabbixvms-0.1.0/tests/test_sender.py +107 -0
- zabbixvms-0.1.0/tests/test_service.py +346 -0
- zabbixvms-0.1.0/tests/test_servicecontrol.py +161 -0
- zabbixvms-0.1.0/tests/test_template.py +206 -0
- zabbixvms-0.1.0/tests/test_tray.py +337 -0
- zabbixvms-0.1.0/tests/test_triggers.py +99 -0
zabbixvms-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Logic Elements s.r.o
|
|
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.
|
zabbixvms-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zabbixvms
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zabbix monitoring agent for Logic Elements VMS deployments
|
|
5
|
+
Author-email: Jan Bartovský <jan.bartovsky@logicelements.cz>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/LogicElements/py-vms-zabbix
|
|
8
|
+
Keywords: logicelements,vibration,vms,zabbix
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: mysql-connector-python
|
|
15
|
+
Requires-Dist: jsonpickle
|
|
16
|
+
Requires-Dist: zabbix_utils
|
|
17
|
+
Requires-Dist: pywin32
|
|
18
|
+
Requires-Dist: PyYAML
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# zabbixvms
|
|
24
|
+
|
|
25
|
+
Zabbix agent pro monitorování instalací VMS od Logic Elements.
|
|
26
|
+
|
|
27
|
+
Sleduje MySQL databázi (`BVMS`), do které zapisuje serverový software VMS, a odvozené metriky o stavu systému (měřené otáčky, stáří bufferů, konfigurace a časových značek, počty řádků v bufferech) odesílá na Zabbix server jako trapper položky (`vms.speed`, `vms.buf_rows_1`, ...).
|
|
28
|
+
|
|
29
|
+
Balíček vznikl vyčleněním z projektu [`pyvms`](https://github.com/LogicElements/py-vms); na něm už nezávisí a s databází `BVMS` pracuje sám.
|
|
30
|
+
|
|
31
|
+
## Dokumentace
|
|
32
|
+
|
|
33
|
+
- [PRS-zabbixvms.md](doc/PRS-zabbixvms.md) – Product Requirement Specification: účel projektu, use cases a requirementy.
|
|
34
|
+
- [PLAN-zabbixvms.md](doc/PLAN-zabbixvms.md) – plán: rozpad requirementů do etap.
|
|
35
|
+
- [NAVRH-zabbixvms.md](doc/NAVRH-zabbixvms.md) – návrh struktury balíčku: jména, členění modulů a odpovědnosti.
|
|
36
|
+
- [NAVOD-zabbix.md](doc/NAVOD-zabbix.md) – návod k nastavení Zabbixu: založení hostů, import šablony a její přiřazení.
|
|
37
|
+
- [CHYBY-agenta.md](doc/CHYBY-agenta.md) – co agent hlásí, kde chyby vznikají a kde k nim hledat podrobnosti.
|
|
38
|
+
- [BUILD-balicku.md](doc/BUILD-balicku.md) – sestavení balíčku: virtuální prostředí, testy, `build.bat` a publikování.
|
|
39
|
+
|
|
40
|
+
## Instalace
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Běží pouze na Windows (pro integraci s Windows Service se používá `pywin32`).
|
|
47
|
+
|
|
48
|
+
Python musí být na serveru nainstalovaný **pro celý stroj** (volba „for all users"), ne jen
|
|
49
|
+
pro přihlášeného uživatele. Služba běží pod účtem LocalSystem a hostitelský proces
|
|
50
|
+
`pythonservice.exe` potřebuje najít `python3XX.dll`. U instalace jen pro uživatele leží tahle
|
|
51
|
+
knihovna v `%LOCALAPPDATA%`, LocalSystem ji na své cestě nemá a služba pak nenastartuje –
|
|
52
|
+
ohlásí se jen chyba 1053, protože proces skončí dřív, než stihne odpovědět správci služeb.
|
|
53
|
+
|
|
54
|
+
## Použití
|
|
55
|
+
|
|
56
|
+
Instalace vytvoří dva vstupní body:
|
|
57
|
+
|
|
58
|
+
- `zabbixvms-service` – služba Windows `ZabbixVms`,
|
|
59
|
+
- `zabbixvms-tray` – ikona v systray, spouští se bez konzolového okna.
|
|
60
|
+
|
|
61
|
+
Konfigurace (přístupové údaje k MySQL a Zabbixu, seznam monitorovaných turbín) je JSON
|
|
62
|
+
v souboru `C:\ProgramData\LogicElements\ZabbixVms\config.json`. Pokud soubor neexistuje,
|
|
63
|
+
vytvoří se při prvním spuštění z výchozí šablony dodané v balíčku; existující soubor
|
|
64
|
+
zůstává beze změny i při aktualizaci balíčku.
|
|
65
|
+
|
|
66
|
+
## Vývoj
|
|
67
|
+
|
|
68
|
+
Testy se spouští z kořene repozitáře:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -e ".[dev]"
|
|
72
|
+
pytest
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Licence
|
|
76
|
+
|
|
77
|
+
MIT © Logic Elements s.r.o. — viz [LICENSE](LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# zabbixvms
|
|
2
|
+
|
|
3
|
+
Zabbix agent pro monitorování instalací VMS od Logic Elements.
|
|
4
|
+
|
|
5
|
+
Sleduje MySQL databázi (`BVMS`), do které zapisuje serverový software VMS, a odvozené metriky o stavu systému (měřené otáčky, stáří bufferů, konfigurace a časových značek, počty řádků v bufferech) odesílá na Zabbix server jako trapper položky (`vms.speed`, `vms.buf_rows_1`, ...).
|
|
6
|
+
|
|
7
|
+
Balíček vznikl vyčleněním z projektu [`pyvms`](https://github.com/LogicElements/py-vms); na něm už nezávisí a s databází `BVMS` pracuje sám.
|
|
8
|
+
|
|
9
|
+
## Dokumentace
|
|
10
|
+
|
|
11
|
+
- [PRS-zabbixvms.md](doc/PRS-zabbixvms.md) – Product Requirement Specification: účel projektu, use cases a requirementy.
|
|
12
|
+
- [PLAN-zabbixvms.md](doc/PLAN-zabbixvms.md) – plán: rozpad requirementů do etap.
|
|
13
|
+
- [NAVRH-zabbixvms.md](doc/NAVRH-zabbixvms.md) – návrh struktury balíčku: jména, členění modulů a odpovědnosti.
|
|
14
|
+
- [NAVOD-zabbix.md](doc/NAVOD-zabbix.md) – návod k nastavení Zabbixu: založení hostů, import šablony a její přiřazení.
|
|
15
|
+
- [CHYBY-agenta.md](doc/CHYBY-agenta.md) – co agent hlásí, kde chyby vznikají a kde k nim hledat podrobnosti.
|
|
16
|
+
- [BUILD-balicku.md](doc/BUILD-balicku.md) – sestavení balíčku: virtuální prostředí, testy, `build.bat` a publikování.
|
|
17
|
+
|
|
18
|
+
## Instalace
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Běží pouze na Windows (pro integraci s Windows Service se používá `pywin32`).
|
|
25
|
+
|
|
26
|
+
Python musí být na serveru nainstalovaný **pro celý stroj** (volba „for all users"), ne jen
|
|
27
|
+
pro přihlášeného uživatele. Služba běží pod účtem LocalSystem a hostitelský proces
|
|
28
|
+
`pythonservice.exe` potřebuje najít `python3XX.dll`. U instalace jen pro uživatele leží tahle
|
|
29
|
+
knihovna v `%LOCALAPPDATA%`, LocalSystem ji na své cestě nemá a služba pak nenastartuje –
|
|
30
|
+
ohlásí se jen chyba 1053, protože proces skončí dřív, než stihne odpovědět správci služeb.
|
|
31
|
+
|
|
32
|
+
## Použití
|
|
33
|
+
|
|
34
|
+
Instalace vytvoří dva vstupní body:
|
|
35
|
+
|
|
36
|
+
- `zabbixvms-service` – služba Windows `ZabbixVms`,
|
|
37
|
+
- `zabbixvms-tray` – ikona v systray, spouští se bez konzolového okna.
|
|
38
|
+
|
|
39
|
+
Konfigurace (přístupové údaje k MySQL a Zabbixu, seznam monitorovaných turbín) je JSON
|
|
40
|
+
v souboru `C:\ProgramData\LogicElements\ZabbixVms\config.json`. Pokud soubor neexistuje,
|
|
41
|
+
vytvoří se při prvním spuštění z výchozí šablony dodané v balíčku; existující soubor
|
|
42
|
+
zůstává beze změny i při aktualizaci balíčku.
|
|
43
|
+
|
|
44
|
+
## Vývoj
|
|
45
|
+
|
|
46
|
+
Testy se spouští z kořene repozitáře:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
pytest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Licence
|
|
54
|
+
|
|
55
|
+
MIT © Logic Elements s.r.o. — viz [LICENSE](LICENSE).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zabbixvms"
|
|
7
|
+
description = "Zabbix monitoring agent for Logic Elements VMS deployments"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name="Jan Bartovský", email="jan.bartovsky@logicelements.cz" },
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
|
+
]
|
|
20
|
+
keywords = ["logicelements", "vibration", "vms", "zabbix"]
|
|
21
|
+
|
|
22
|
+
requires-python = ">=3.12"
|
|
23
|
+
dependencies = [
|
|
24
|
+
"mysql-connector-python",
|
|
25
|
+
"jsonpickle",
|
|
26
|
+
"zabbix_utils",
|
|
27
|
+
"pywin32",
|
|
28
|
+
"PyYAML",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
zabbixvms-service = "zabbixvms.service:main"
|
|
38
|
+
|
|
39
|
+
[project.gui-scripts]
|
|
40
|
+
zabbixvms-tray = "zabbixvms.tray:main"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools]
|
|
46
|
+
include-package-data = true
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.package-data]
|
|
49
|
+
zabbixvms = ["data/*.json", "data/*.yaml"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
pythonpath = ["src"]
|
|
54
|
+
markers = [
|
|
55
|
+
"db: needs the BVMS test database, skipped when it is not reachable",
|
|
56
|
+
"gui: builds a real window and systray icon, needs a desktop session",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[project.urls]
|
|
60
|
+
"Homepage" = "https://github.com/LogicElements/py-vms-zabbix"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The measurement loop: collect the values of every turbine, send them to Zabbix,
|
|
2
|
+
report how the cycle went, wait, repeat.
|
|
3
|
+
|
|
4
|
+
An error in one cycle must not end the loop. The database is unreachable, Zabbix is
|
|
5
|
+
down, a turbine has no row - the cycle is given up, the state of the agent says so,
|
|
6
|
+
and the next cycle starts five seconds later and picks up again once the cause is
|
|
7
|
+
gone.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from zabbixvms.collector import Collector
|
|
15
|
+
from zabbixvms.config import DEFAULT_PERIOD, Config
|
|
16
|
+
from zabbixvms.log import log
|
|
17
|
+
from zabbixvms.sender import TrapperSender
|
|
18
|
+
|
|
19
|
+
# Delay between the end of one measurement cycle and the start of the next; it comes
|
|
20
|
+
# from the configuration, this is what an agent without one would wait.
|
|
21
|
+
CYCLE_DELAY = DEFAULT_PERIOD
|
|
22
|
+
|
|
23
|
+
# Values of vms.agent_status.
|
|
24
|
+
OK = 0
|
|
25
|
+
WARNING = 1
|
|
26
|
+
ERROR = 2
|
|
27
|
+
|
|
28
|
+
# vms.agent_error is a Character item, which holds 255 characters.
|
|
29
|
+
MAX_ERROR_LENGTH = 255
|
|
30
|
+
|
|
31
|
+
STATUS_KEY = "vms.agent_status"
|
|
32
|
+
ERROR_KEY = "vms.agent_error"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Agent:
|
|
36
|
+
"""Runs the measurement cycle over all turbines of the configuration."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: Config, collector: Collector | None = None,
|
|
39
|
+
sender: TrapperSender | None = None, sleep=time.sleep) -> None:
|
|
40
|
+
self._config = config
|
|
41
|
+
self._collector = collector if collector is not None else Collector(config.database)
|
|
42
|
+
self._sender = sender if sender is not None else TrapperSender(config.zabbix)
|
|
43
|
+
self._sleep = sleep
|
|
44
|
+
self._running = False
|
|
45
|
+
self._warnings: list[str] = []
|
|
46
|
+
# Error of the last cycle, or None when the last cycle went through.
|
|
47
|
+
self.last_error: Exception | None = None
|
|
48
|
+
# What the agent said about itself in the last cycle.
|
|
49
|
+
self.status = OK
|
|
50
|
+
self.error_text = ""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def running(self) -> bool:
|
|
54
|
+
return self._running
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def period(self) -> int:
|
|
58
|
+
"""Seconds waited between cycles, as the configuration sets them."""
|
|
59
|
+
return self._config.zabbix.period
|
|
60
|
+
|
|
61
|
+
def cycle(self) -> None:
|
|
62
|
+
"""One measurement cycle: every turbine collected and sent."""
|
|
63
|
+
self._warnings = []
|
|
64
|
+
if not self._collector.is_connected:
|
|
65
|
+
self._collector.connect()
|
|
66
|
+
for turbine in self._config.turbines:
|
|
67
|
+
values = self._collector.collect(turbine)
|
|
68
|
+
self._warnings.extend(self._collector.warnings)
|
|
69
|
+
self._sender.send(turbine, values)
|
|
70
|
+
|
|
71
|
+
def run(self) -> None:
|
|
72
|
+
"""Repeat the cycle until stop() is called."""
|
|
73
|
+
self._running = True
|
|
74
|
+
while self._running:
|
|
75
|
+
try:
|
|
76
|
+
self.cycle()
|
|
77
|
+
self.last_error = None
|
|
78
|
+
status, message = self._state_of_the_cycle()
|
|
79
|
+
except Exception as err:
|
|
80
|
+
# The loop outlives the cycle; the database or Zabbix may come back.
|
|
81
|
+
self.last_error = err
|
|
82
|
+
status, message = ERROR, str(err)
|
|
83
|
+
log.error("measurement cycle failed: %s", err)
|
|
84
|
+
self._collector.close()
|
|
85
|
+
|
|
86
|
+
self.status = status
|
|
87
|
+
self.error_text = message[:MAX_ERROR_LENGTH]
|
|
88
|
+
self.report_state()
|
|
89
|
+
self._sleep(self.period)
|
|
90
|
+
|
|
91
|
+
def stop(self) -> None:
|
|
92
|
+
"""Ask the loop to finish and release the database connection."""
|
|
93
|
+
self._running = False
|
|
94
|
+
self._collector.close()
|
|
95
|
+
|
|
96
|
+
def report_state(self) -> None:
|
|
97
|
+
"""Send how the agent is doing to the host of every turbine.
|
|
98
|
+
|
|
99
|
+
The state goes out even when the cycle failed, so the operator sees why no
|
|
100
|
+
values arrived. When Zabbix is the thing that is down, this cannot get
|
|
101
|
+
through either and is only written to the log.
|
|
102
|
+
"""
|
|
103
|
+
values = {STATUS_KEY: self.status, ERROR_KEY: self.error_text}
|
|
104
|
+
for turbine in self._config.turbines:
|
|
105
|
+
try:
|
|
106
|
+
self._sender.send(turbine, values)
|
|
107
|
+
except Exception as err:
|
|
108
|
+
log.error("state of the agent could not be sent for turbine %s: %s",
|
|
109
|
+
turbine.name, err)
|
|
110
|
+
|
|
111
|
+
def _state_of_the_cycle(self) -> tuple[int, str]:
|
|
112
|
+
"""Status and text after a cycle that got through without an exception."""
|
|
113
|
+
if not self._warnings:
|
|
114
|
+
return OK, ""
|
|
115
|
+
message = "; ".join(self._warnings)
|
|
116
|
+
log.warning("measurement cycle finished with a warning: %s", message)
|
|
117
|
+
return WARNING, message
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Reads the BVMS database and computes the metric values from it.
|
|
2
|
+
|
|
3
|
+
What each metric is computed from is the source table of UC4 in the PRS. Values of
|
|
4
|
+
the info table are read by column name, never by position, so adding a column or
|
|
5
|
+
reordering the table does not change what is sent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
import mysql.connector
|
|
13
|
+
|
|
14
|
+
from zabbixvms.config import DatabaseConfig, Turbine
|
|
15
|
+
|
|
16
|
+
# Columns of the info table the metrics are computed from. They are enumerated in the
|
|
17
|
+
# query instead of SELECT *, so every value is picked by name.
|
|
18
|
+
INFO_COLUMNS = (
|
|
19
|
+
"Date",
|
|
20
|
+
"Phase_Marker",
|
|
21
|
+
"Date_Timestamp",
|
|
22
|
+
"Date_Config",
|
|
23
|
+
"Date_Buffer_1",
|
|
24
|
+
"Date_Buffer_2",
|
|
25
|
+
"Time_bulk_1",
|
|
26
|
+
"Time_bulk_2",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Ages are reported up to one month; anything older says the same thing, that the data
|
|
30
|
+
# stopped coming long ago. The database keeps 0001-01-01 for "never", which saturates
|
|
31
|
+
# here like any other very old date.
|
|
32
|
+
MAX_AGE = timedelta(days=30)
|
|
33
|
+
MAX_AGE_SECONDS = int(MAX_AGE.total_seconds())
|
|
34
|
+
|
|
35
|
+
# Metrics of a buffer the turbine does not have configured.
|
|
36
|
+
NO_BUFFER = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CollectorError(Exception):
|
|
40
|
+
"""The database does not hold what the agent needs for a turbine."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def speed(phase_marker: int | None) -> float:
|
|
44
|
+
"""Turbine speed in rpm from the phase marker period.
|
|
45
|
+
|
|
46
|
+
A standing turbine has no phase marker period, the speed is 0 rpm then.
|
|
47
|
+
"""
|
|
48
|
+
if not phase_marker:
|
|
49
|
+
return 0.0
|
|
50
|
+
return round(1e8 / phase_marker * 60, 4)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def age(moment: datetime | None, now: datetime) -> int:
|
|
54
|
+
"""Whole seconds between moment and the measurement time, at most one month.
|
|
55
|
+
|
|
56
|
+
A missing value means the data never came, which is as old as it gets and
|
|
57
|
+
saturates too. A date in the future would be a negative age, which an unsigned
|
|
58
|
+
item cannot hold, so it reads as zero.
|
|
59
|
+
"""
|
|
60
|
+
if moment is None:
|
|
61
|
+
return MAX_AGE_SECONDS
|
|
62
|
+
elapsed = int((now - moment).total_seconds())
|
|
63
|
+
return max(0, min(elapsed, MAX_AGE_SECONDS))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Collector:
|
|
67
|
+
"""Reads one turbine's values out of the BVMS database."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, database: DatabaseConfig, connect=mysql.connector.connect) -> None:
|
|
70
|
+
self._database = database
|
|
71
|
+
self._connect = connect
|
|
72
|
+
self._connection = None
|
|
73
|
+
# Things worth telling about that do not stop the collection; filled by
|
|
74
|
+
# collect() for the turbine it was called with.
|
|
75
|
+
self.warnings: list[str] = []
|
|
76
|
+
|
|
77
|
+
def connect(self) -> None:
|
|
78
|
+
"""Open the connection to MySQL using the values from the configuration."""
|
|
79
|
+
self._connection = self._connect(
|
|
80
|
+
host=self._database.host,
|
|
81
|
+
database=self._database.database,
|
|
82
|
+
user=self._database.user,
|
|
83
|
+
password=self._database.password,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_connected(self) -> bool:
|
|
88
|
+
"""Whether the collector currently holds a connection to MySQL."""
|
|
89
|
+
return self._connection is not None
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
"""Close the connection; closing a collector that is not connected is fine."""
|
|
93
|
+
if self._connection is not None:
|
|
94
|
+
self._connection.close()
|
|
95
|
+
self._connection = None
|
|
96
|
+
|
|
97
|
+
def read_info(self, turbine: Turbine) -> dict:
|
|
98
|
+
"""Row of the info table belonging to the turbine, keyed by column name."""
|
|
99
|
+
columns = ", ".join(f"`{column}`" for column in INFO_COLUMNS)
|
|
100
|
+
query = (f"SELECT {columns} FROM `{self._database.info_table}` "
|
|
101
|
+
f"WHERE `SystemId` = %s")
|
|
102
|
+
|
|
103
|
+
cursor = self._cursor()
|
|
104
|
+
try:
|
|
105
|
+
cursor.execute(query, (turbine.system_id,))
|
|
106
|
+
rows = cursor.fetchall()
|
|
107
|
+
finally:
|
|
108
|
+
cursor.close()
|
|
109
|
+
|
|
110
|
+
if not rows:
|
|
111
|
+
raise CollectorError(
|
|
112
|
+
f"table {self._database.info_table} has no row with SystemId "
|
|
113
|
+
f"{turbine.system_id} of turbine {turbine.name!r}"
|
|
114
|
+
)
|
|
115
|
+
return rows[0]
|
|
116
|
+
|
|
117
|
+
def read_buffer_rows(self, turbine: Turbine) -> dict[str, int]:
|
|
118
|
+
"""Row counts of the turbine's buffer tables, keyed by table name."""
|
|
119
|
+
if not turbine.buffers:
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
placeholders = ", ".join(["%s"] * len(turbine.buffers))
|
|
123
|
+
query = ("SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES "
|
|
124
|
+
f"WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders})")
|
|
125
|
+
|
|
126
|
+
cursor = self._cursor()
|
|
127
|
+
try:
|
|
128
|
+
cursor.execute(query, (self._database.database, *turbine.buffers))
|
|
129
|
+
rows = cursor.fetchall()
|
|
130
|
+
finally:
|
|
131
|
+
cursor.close()
|
|
132
|
+
|
|
133
|
+
return {row["TABLE_NAME"]: row["TABLE_ROWS"] or 0 for row in rows}
|
|
134
|
+
|
|
135
|
+
def collect(self, turbine: Turbine, now: datetime | None = None) -> dict[str, float]:
|
|
136
|
+
"""Values of all collected metrics of one turbine, keyed by metric key."""
|
|
137
|
+
if now is None:
|
|
138
|
+
now = datetime.now()
|
|
139
|
+
self.warnings = []
|
|
140
|
+
info = self.read_info(turbine)
|
|
141
|
+
buffer_rows = self.read_buffer_rows(turbine)
|
|
142
|
+
for table in turbine.buffers:
|
|
143
|
+
if table not in buffer_rows:
|
|
144
|
+
# Counting zero rows here would look like an empty buffer, which is
|
|
145
|
+
# something else entirely than a table that is not there.
|
|
146
|
+
self.warnings.append(
|
|
147
|
+
f"buffer table {table!r} of turbine {turbine.name!r} is not in "
|
|
148
|
+
f"database {self._database.database}")
|
|
149
|
+
return self.values(turbine, info, buffer_rows, now)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def values(turbine: Turbine, info: dict, buffer_rows: dict[str, int],
|
|
153
|
+
now: datetime) -> dict[str, float]:
|
|
154
|
+
"""Metric values computed from one info row and the buffer row counts.
|
|
155
|
+
|
|
156
|
+
Every value of one cycle is computed against the same measurement time.
|
|
157
|
+
"""
|
|
158
|
+
values = {
|
|
159
|
+
"vms.speed": speed(info["Phase_Marker"]),
|
|
160
|
+
"vms.info_age": age(info["Date"], now),
|
|
161
|
+
"vms.timestamp_age": age(info["Date_Timestamp"], now),
|
|
162
|
+
"vms.config_age": age(info["Date_Config"], now),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# The buffers are summed up into one metric each. Rows and bulk time add up;
|
|
166
|
+
# the age is the worst of them, because summing two ages gives a number that
|
|
167
|
+
# is the age of nothing. A buffer the turbine does not have contributes
|
|
168
|
+
# nothing, so a turbine with one buffer reports exactly that one. Which info
|
|
169
|
+
# columns a buffer uses follows its position, not the table name in the
|
|
170
|
+
# configuration.
|
|
171
|
+
rows = bulk = oldest = NO_BUFFER
|
|
172
|
+
for index in (1, 2):
|
|
173
|
+
if len(turbine.buffers) < index:
|
|
174
|
+
continue
|
|
175
|
+
table = turbine.buffers[index - 1]
|
|
176
|
+
oldest = max(oldest, age(info[f"Date_Buffer_{index}"], now))
|
|
177
|
+
bulk += info[f"Time_bulk_{index}"] or 0
|
|
178
|
+
rows += buffer_rows.get(table, 0)
|
|
179
|
+
|
|
180
|
+
values["vms.buf_rows"] = rows
|
|
181
|
+
values["vms.buf_age"] = oldest
|
|
182
|
+
values["vms.buf_bulk"] = bulk
|
|
183
|
+
|
|
184
|
+
return values
|
|
185
|
+
|
|
186
|
+
def _cursor(self):
|
|
187
|
+
"""Cursor returning rows as dicts, so values are picked by column name."""
|
|
188
|
+
if self._connection is None:
|
|
189
|
+
raise CollectorError("collector is not connected to the database")
|
|
190
|
+
return self._connection.cursor(dictionary=True)
|