Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c70a4807dc | |||
| 1a470e7cfa | |||
| 990c658e65 | |||
| b78d6ac0fe | |||
| afd5060f59 | |||
| f61f7aebc2 | |||
| 5c382d2b8d | |||
| 35bba451f5 | |||
| 80edfba0c0 | |||
| 6bc8de192e | |||
| 2d8166d04a | |||
| ab33d81b30 | |||
| 2c0328f36d | |||
| fb8e27825d |
@@ -24,11 +24,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install build tools
|
- name: Install build tools
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python3 -m pip install --upgrade pip
|
||||||
pip install build twine
|
python3 -m pip install build twine
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python3 -m build
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: get_version
|
id: get_version
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
python -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
|
python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: actions/gitea-release-action@v1
|
uses: actions/gitea-release-action@v1
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.1.1"
|
__version__ = "5.1.2"
|
||||||
|
|||||||
@@ -312,9 +312,10 @@ class PluginLoader:
|
|||||||
|
|
||||||
loaded_count = 0
|
loaded_count = 0
|
||||||
raw_config = config or {}
|
raw_config = config or {}
|
||||||
# Per-plugin config lives under the 'plugins' key; fall back to top-level
|
# Per-plugin config lives under the 'plugins' key or at top-level.
|
||||||
# for backwards compatibility.
|
# CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check
|
||||||
plugin_config = raw_config.get("plugins", raw_config)
|
# both the subdict and top-level so that either layout in .hbc.yaml works.
|
||||||
|
plugins_subconfig = raw_config.get("plugins", {})
|
||||||
|
|
||||||
# Scan for Python files
|
# Scan for Python files
|
||||||
for plugin_file in directory.glob("*.py"):
|
for plugin_file in directory.glob("*.py"):
|
||||||
@@ -359,8 +360,9 @@ class PluginLoader:
|
|||||||
|
|
||||||
self.logger.debug(f"Found plugin class: {name}")
|
self.logger.debug(f"Found plugin class: {name}")
|
||||||
|
|
||||||
# Instantiate plugin with config
|
# Instantiate plugin with config — check plugins subdict first,
|
||||||
plugin_instance_config = plugin_config.get(obj.name, {})
|
# then top-level keys (e.g. nagios_runner: ... at root of config).
|
||||||
|
plugin_instance_config = plugins_subconfig.get(obj.name) or raw_config.get(obj.name, {})
|
||||||
plugin = obj(config=plugin_instance_config)
|
plugin = obj(config=plugin_instance_config)
|
||||||
|
|
||||||
# Initialize plugin
|
# Initialize plugin
|
||||||
|
|||||||
+9
-4
@@ -52,12 +52,17 @@ def decode_value(val: str) -> Any:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return val[1:] # Return as string without @
|
return val[1:] # Return as string without @
|
||||||
|
|
||||||
# Try numeric evaluation (original behavior)
|
# Try numeric conversion (avoid eval to prevent SyntaxWarnings on version strings)
|
||||||
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
|
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
|
||||||
try:
|
try:
|
||||||
return eval(val)
|
return int(val)
|
||||||
except Exception:
|
except ValueError:
|
||||||
return val
|
pass
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return val
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|||||||
+14
-7
@@ -385,13 +385,20 @@ _DRIVERS = {
|
|||||||
|
|
||||||
|
|
||||||
def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
||||||
"""Send *notif* to a single named channel, honouring min_level."""
|
"""Send *notif* to a single named channel, honouring min_level.
|
||||||
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
|
||||||
if _level_value(notif.level) < _level_value(min_level):
|
RECOVER always bypasses min_level — a recovery is always relevant if the
|
||||||
logger.debug(
|
channel was configured for any alerting (handles the restart-then-recover case
|
||||||
"channel '%s': skipping level %s (min_level=%s)", channel_name, notif.level, min_level
|
where _alerted_channels is empty and we fall through to the normal loop).
|
||||||
)
|
"""
|
||||||
return True # not an error — filtered intentionally
|
level = notif.level.upper()
|
||||||
|
if level != "RECOVER":
|
||||||
|
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
||||||
|
if _level_value(level) < _level_value(min_level):
|
||||||
|
logger.debug(
|
||||||
|
"channel '%s': skipping level %s (min_level=%s)", channel_name, level, min_level
|
||||||
|
)
|
||||||
|
return True # not an error — filtered intentionally
|
||||||
|
|
||||||
ch_type = channel_cfg.get("type", "")
|
ch_type = channel_cfg.get("type", "")
|
||||||
driver = _DRIVERS.get(ch_type)
|
driver = _DRIVERS.get(ch_type)
|
||||||
|
|||||||
@@ -3,20 +3,13 @@
|
|||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
|
||||||
margin: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 { color: #333; margin-bottom: 10px; font-size: 1.5em; }
|
||||||
color: #333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #666;
|
color: #666;
|
||||||
@@ -41,7 +34,7 @@
|
|||||||
border-left: 4px solid #ddd;
|
border-left: 4px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card.critical { border-left-color: #f44336; }
|
.summary-card.critical { border-left-color: #ea1e0f; }
|
||||||
.summary-card.warning { border-left-color: #ff9800; }
|
.summary-card.warning { border-left-color: #ff9800; }
|
||||||
.summary-card.ok { border-left-color: #4caf50; }
|
.summary-card.ok { border-left-color: #4caf50; }
|
||||||
|
|
||||||
@@ -51,7 +44,7 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-number.critical { color: #f44336; }
|
.summary-number.critical { color: #ea1e0f; }
|
||||||
.summary-number.warning { color: #ff9800; }
|
.summary-number.warning { color: #ff9800; }
|
||||||
.summary-number.ok { color: #4caf50; }
|
.summary-number.ok { color: #4caf50; }
|
||||||
|
|
||||||
@@ -116,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alert-item.acknowledged {
|
.alert-item.acknowledged {
|
||||||
opacity: 0.6;
|
opacity: 0.8;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,25 @@
|
|||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
|
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
|
||||||
<style>
|
<style>
|
||||||
|
/* ── Reset / shared baseline ── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html {
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5em; color: #333; margin: 0 0 5px; }
|
||||||
|
h2 { font-size: 1.1em; color: #333; margin: 0 0 8px; }
|
||||||
|
p { margin: 0; }
|
||||||
|
|
||||||
/* Navigation bar — shared across all pages */
|
/* Navigation bar — shared across all pages */
|
||||||
.nav {
|
.nav {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 10px 15px;
|
padding: 6px 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -42,6 +57,17 @@
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
|
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
|
||||||
|
.nav-username {
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-width 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.nav-user:hover .nav-username {
|
||||||
|
max-width: 160px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.nav-avatar {
|
.nav-avatar {
|
||||||
width: 28px; height: 28px;
|
width: 28px; height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -94,6 +120,158 @@
|
|||||||
.nav-links.nav-open { display: flex; }
|
.nav-links.nav-open { display: flex; }
|
||||||
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Swiss railway clock — nav */
|
||||||
|
.nav-clock {
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 4px 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#swiss-clock { display: block; }
|
||||||
|
|
||||||
|
/* Swiss railway clock — full-page overlay */
|
||||||
|
#clock-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: #1a1a1a;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#clock-overlay.visible { display: flex; }
|
||||||
|
#swiss-clock-overlay { display: block; }
|
||||||
</style>
|
</style>
|
||||||
<script src="static/sorttable.js"></script>
|
<script>
|
||||||
|
/* ── Swiss Federal Railway (SBB) clock ── */
|
||||||
|
|
||||||
|
/* Draw one frame of the clock onto any canvas element. */
|
||||||
|
function drawSwissClock(canvas) {
|
||||||
|
var SIZE = canvas.width;
|
||||||
|
var R = SIZE / 2;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var now = new Date();
|
||||||
|
var h = now.getHours() % 12;
|
||||||
|
var m = now.getMinutes();
|
||||||
|
var s = now.getSeconds();
|
||||||
|
var ms = now.getMilliseconds();
|
||||||
|
|
||||||
|
/* Seconds hand idles ~1.5 s at 12 before advancing (SBB behaviour) */
|
||||||
|
var sFrac = s + ms / 1000;
|
||||||
|
var sAngle = sFrac >= 58.5 ? 0 : (sFrac / 58.5) * Math.PI * 2;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
/* face */
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(R, R, R - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#333';
|
||||||
|
ctx.lineWidth = SIZE * 0.018;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
/* tick marks */
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
var a = (i / 60) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
var isHour = (i % 5 === 0);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(R + Math.cos(a) * (isHour ? R * 0.72 : R * 0.88),
|
||||||
|
R + Math.sin(a) * (isHour ? R * 0.72 : R * 0.88));
|
||||||
|
ctx.lineTo(R + Math.cos(a) * R * 0.94,
|
||||||
|
R + Math.sin(a) * R * 0.94);
|
||||||
|
ctx.strokeStyle = '#222';
|
||||||
|
ctx.lineWidth = isHour ? SIZE * 0.027 : SIZE * 0.011;
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hands */
|
||||||
|
function hand(angle, tip, tail, width, color) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(R, R);
|
||||||
|
ctx.rotate(angle);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tail, 0);
|
||||||
|
ctx.lineTo(tip, 0);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineCap = 'square';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
hand((m + s / 60) / 60 * Math.PI * 2 - Math.PI / 2,
|
||||||
|
R * 0.88, -R * 0.12, SIZE * 0.027, '#222'); /* minute */
|
||||||
|
hand((h + m / 60) / 12 * Math.PI * 2 - Math.PI / 2,
|
||||||
|
R * 0.58, -R * 0.12, SIZE * 0.039, '#222'); /* hour */
|
||||||
|
hand(sAngle - Math.PI / 2, R * 0.78, -R * 0.22,
|
||||||
|
SIZE * 0.013, '#e00'); /* second tail+tip */
|
||||||
|
|
||||||
|
/* round dot at tip of second hand */
|
||||||
|
var dotR = SIZE * 0.028;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(R, R);
|
||||||
|
ctx.rotate(sAngle - Math.PI / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(R * 0.78, 0, dotR, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#e00';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
/* centre cap */
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(R, R, R * 0.04, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#222';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize the overlay canvas to fit the viewport, keeping it square. */
|
||||||
|
function resizeOverlayClock() {
|
||||||
|
var oc = document.getElementById('swiss-clock-overlay');
|
||||||
|
if (!oc) return;
|
||||||
|
var size = Math.min(window.innerWidth, window.innerHeight) * 0.88;
|
||||||
|
size = Math.floor(size);
|
||||||
|
oc.width = size;
|
||||||
|
oc.height = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main tick — redraws both nav clock and (if visible) overlay clock. */
|
||||||
|
function clockTick() {
|
||||||
|
var nav = document.getElementById('swiss-clock');
|
||||||
|
if (nav) drawSwissClock(nav);
|
||||||
|
var overlay = document.getElementById('clock-overlay');
|
||||||
|
if (overlay && overlay.classList.contains('visible')) {
|
||||||
|
var oc = document.getElementById('swiss-clock-overlay');
|
||||||
|
if (oc) drawSwissClock(oc);
|
||||||
|
}
|
||||||
|
var delay = 100 - (Date.now() % 100);
|
||||||
|
setTimeout(clockTick, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
/* Start the shared tick loop */
|
||||||
|
clockTick();
|
||||||
|
|
||||||
|
/* Overlay toggle — clicking the nav clock opens it */
|
||||||
|
var navClock = document.querySelector('.nav-clock');
|
||||||
|
var overlay = document.getElementById('clock-overlay');
|
||||||
|
if (navClock && overlay) {
|
||||||
|
navClock.addEventListener('click', function() {
|
||||||
|
resizeOverlayClock();
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
});
|
||||||
|
overlay.addEventListener('click', function() {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
if (overlay.classList.contains('visible')) resizeOverlayClock();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="static/sorttable.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -7,10 +7,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0;
|
|
||||||
background: #f5f5f5;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,8 +485,10 @@
|
|||||||
{% include 'menu.html' %}
|
{% include 'menu.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ header }}</h1>
|
<div>
|
||||||
<p class="subtitle">Real-time host monitoring and event log</p>
|
<h1>{{ header }}</h1>
|
||||||
|
<p class="subtitle">Real-time host monitoring and event log</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-section">
|
<div class="table-section">
|
||||||
<table id="ntable" class="sortable">
|
<table id="ntable" class="sortable">
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-clock" title="Click for full-screen clock">
|
||||||
|
<canvas id="swiss-clock" width="44" height="44"></canvas>
|
||||||
|
</div>
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<a href="/profile" class="nav-user{% if active_page == 'profile' %} active{% endif %}" title="{{ current_user.full_name or current_user.username }}">
|
<a href="/profile" class="nav-user{% if active_page == 'profile' %} active{% endif %}" title="{{ current_user.full_name or current_user.username }}">
|
||||||
{% if current_user.avatar %}
|
{% if current_user.avatar %}
|
||||||
@@ -21,6 +24,12 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Full-page clock overlay (click anywhere to dismiss) -->
|
||||||
|
<div id="clock-overlay">
|
||||||
|
<canvas id="swiss-clock-overlay" width="400" height="400"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var btn = document.getElementById('nav-hamburger-btn');
|
var btn = document.getElementById('nav-hamburger-btn');
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body { overflow: hidden; }
|
||||||
margin: 10px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
|
|||||||
@@ -3,15 +3,7 @@
|
|||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body { overflow: visible; }
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
|
|||||||
@@ -3,19 +3,10 @@
|
|||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body { overflow: visible; }
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { color: #333; margin-bottom: 4px; font-size: 1.5em; }
|
h1 { color: #333; margin-bottom: 4px; font-size: 1.5em; }
|
||||||
|
|||||||
+82
-30
@@ -60,6 +60,7 @@ class AlertState:
|
|||||||
self.acknowledged = False # Whether alert has been acknowledged
|
self.acknowledged = False # Whether alert has been acknowledged
|
||||||
self.acknowledged_at = None # Timestamp when acknowledged
|
self.acknowledged_at = None # Timestamp when acknowledged
|
||||||
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
|
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
|
||||||
|
self.pending_since: Optional[float] = None # non-None while waiting out grace period before notifying
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
self,
|
self,
|
||||||
@@ -105,6 +106,7 @@ class AlertState:
|
|||||||
self.level = level
|
self.level = level
|
||||||
self.since = now
|
self.since = now
|
||||||
self.notification_count = 0
|
self.notification_count = 0
|
||||||
|
self.last_notification = None # restart reminder interval on level change
|
||||||
# Reset acknowledgment on state change
|
# Reset acknowledgment on state change
|
||||||
if level != AlertLevel.OK:
|
if level != AlertLevel.OK:
|
||||||
# Only reset if changing to a different alert level
|
# Only reset if changing to a different alert level
|
||||||
@@ -339,8 +341,9 @@ class ThresholdChecker:
|
|||||||
self.default_config = "default"
|
self.default_config = "default"
|
||||||
|
|
||||||
self.renotify_interval = renotify_interval
|
self.renotify_interval = renotify_interval
|
||||||
|
self.grace_seconds: float = float(config.get("grace", 2))
|
||||||
self.journal = journal
|
self.journal = journal
|
||||||
|
|
||||||
# Parse configuration
|
# Parse configuration
|
||||||
self._parse_config(config)
|
self._parse_config(config)
|
||||||
|
|
||||||
@@ -371,7 +374,8 @@ class ThresholdChecker:
|
|||||||
self.threshold_configs.clear()
|
self.threshold_configs.clear()
|
||||||
self.thresholds.clear()
|
self.thresholds.clear()
|
||||||
self.host_config_mapping.clear()
|
self.host_config_mapping.clear()
|
||||||
|
self.grace_seconds = float(config.get("grace", 2))
|
||||||
|
|
||||||
# Parse new configuration
|
# Parse new configuration
|
||||||
self._parse_config(config)
|
self._parse_config(config)
|
||||||
|
|
||||||
@@ -759,15 +763,10 @@ class ThresholdChecker:
|
|||||||
# Update state and check for changes
|
# Update state and check for changes
|
||||||
old_level = alert_state.level
|
old_level = alert_state.level
|
||||||
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
||||||
# For check_value, we don't have full plugin data, pass None
|
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, None)
|
||||||
lvl, message, formatted_msg = self._trigger_notification(host_name, metric_path, old_level, new_level, value, threshold, None)
|
|
||||||
# Update alert state with formatted message
|
|
||||||
alert_state.formatted_message = formatted_msg
|
|
||||||
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
|
||||||
return (old_level, new_level)
|
return (old_level, new_level)
|
||||||
elif new_level != AlertLevel.OK:
|
elif new_level != AlertLevel.OK:
|
||||||
# Check if we should re-notify
|
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, None)
|
||||||
self._check_renotify(host_name, alert_state, metric_path, value, threshold, None)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
def check_plugin_data(
|
def check_plugin_data(
|
||||||
@@ -826,14 +825,10 @@ class ThresholdChecker:
|
|||||||
old_level = alert_state.level
|
old_level = alert_state.level
|
||||||
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
||||||
state_changes.append((metric_path, old_level, new_level, value))
|
state_changes.append((metric_path, old_level, new_level, value))
|
||||||
lvl, message, formatted_msg = self._trigger_notification(host_name, metric_path, old_level, new_level, value, threshold, data)
|
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data)
|
||||||
# Update alert state with formatted message
|
|
||||||
alert_state.formatted_message = formatted_msg
|
|
||||||
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
|
||||||
elif new_level != AlertLevel.OK:
|
elif new_level != AlertLevel.OK:
|
||||||
# Check if we should re-notify
|
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data)
|
||||||
self._check_renotify(host_name, alert_state, metric_path, value, threshold, data)
|
|
||||||
|
|
||||||
# Check nested metrics (e.g., partition data in disk_monitor)
|
# Check nested metrics (e.g., partition data in disk_monitor)
|
||||||
self._check_nested_metrics(
|
self._check_nested_metrics(
|
||||||
host_name,
|
host_name,
|
||||||
@@ -895,20 +890,9 @@ class ThresholdChecker:
|
|||||||
old_level = alert_state.level
|
old_level = alert_state.level
|
||||||
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
||||||
state_changes.append((metric_path, old_level, new_level, value))
|
state_changes.append((metric_path, old_level, new_level, value))
|
||||||
lvl, message, formatted_msg = self._trigger_notification(
|
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data)
|
||||||
host_name,
|
|
||||||
metric_path,
|
|
||||||
old_level,
|
|
||||||
new_level,
|
|
||||||
value,
|
|
||||||
threshold,
|
|
||||||
data # Pass full plugin data for format string
|
|
||||||
)
|
|
||||||
# Update alert state with formatted message
|
|
||||||
alert_state.formatted_message = formatted_msg
|
|
||||||
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
|
||||||
elif new_level != AlertLevel.OK:
|
elif new_level != AlertLevel.OK:
|
||||||
self._check_renotify(host_name, alert_state, metric_path, value, threshold, data)
|
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data)
|
||||||
|
|
||||||
def _trigger_notification(
|
def _trigger_notification(
|
||||||
self,
|
self,
|
||||||
@@ -947,7 +931,7 @@ class ThresholdChecker:
|
|||||||
|
|
||||||
# Format message
|
# Format message
|
||||||
if new_level == AlertLevel.OK:
|
if new_level == AlertLevel.OK:
|
||||||
lvl = "RECOVERED"
|
lvl = "RECOVER"
|
||||||
message = f"{metric_path} = {display_value} ({old_level.name} -> OK)"
|
message = f"{metric_path} = {display_value} ({old_level.name} -> OK)"
|
||||||
elif new_level == AlertLevel.WARNING:
|
elif new_level == AlertLevel.WARNING:
|
||||||
lvl = "WARNING"
|
lvl = "WARNING"
|
||||||
@@ -1083,6 +1067,74 @@ class ThresholdChecker:
|
|||||||
)
|
)
|
||||||
return f"(threshold: {op_symbol} {threshold_value})"
|
return f"(threshold: {op_symbol} {threshold_value})"
|
||||||
|
|
||||||
|
def _apply_grace(
|
||||||
|
self,
|
||||||
|
host_name: str,
|
||||||
|
alert_state: AlertState,
|
||||||
|
metric_path: str,
|
||||||
|
old_level: AlertLevel,
|
||||||
|
new_level: AlertLevel,
|
||||||
|
value: Any,
|
||||||
|
threshold: ThresholdConfig,
|
||||||
|
plugin_data: Optional[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Handle a state-change transition with grace-period logic.
|
||||||
|
|
||||||
|
Transitioning INTO alert: defers the notification for grace_seconds.
|
||||||
|
Transitioning TO OK:
|
||||||
|
- Still in grace window (pending_since set): suppresses both the alert
|
||||||
|
and the recovery — the spike never warranted a page.
|
||||||
|
- Past grace: fires the RECOVER notification normally.
|
||||||
|
"""
|
||||||
|
lvl, message, formatted_msg = self._trigger_notification(
|
||||||
|
host_name, metric_path, old_level, new_level, value, threshold, plugin_data
|
||||||
|
)
|
||||||
|
alert_state.formatted_message = formatted_msg
|
||||||
|
|
||||||
|
if new_level == AlertLevel.OK:
|
||||||
|
if alert_state.pending_since is not None:
|
||||||
|
logger.info(
|
||||||
|
"Alert suppressed (recovered within %.0fs grace): %s on %s",
|
||||||
|
self.grace_seconds, metric_path, host_name,
|
||||||
|
)
|
||||||
|
alert_state.pending_since = None
|
||||||
|
else:
|
||||||
|
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
||||||
|
else:
|
||||||
|
alert_state.pending_since = time.time()
|
||||||
|
logger.debug(
|
||||||
|
"Alert deferred (%.0fs grace): %s on %s = %s",
|
||||||
|
self.grace_seconds, metric_path, host_name, value,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_pending_or_renotify(
|
||||||
|
self,
|
||||||
|
host_name: str,
|
||||||
|
alert_state: AlertState,
|
||||||
|
metric_path: str,
|
||||||
|
value: Any,
|
||||||
|
threshold: ThresholdConfig,
|
||||||
|
plugin_data: Optional[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Called when alert level is unchanged and non-OK.
|
||||||
|
|
||||||
|
If a deferred notification is pending and grace_seconds have elapsed,
|
||||||
|
fires it now. Otherwise falls through to normal reminder logic.
|
||||||
|
"""
|
||||||
|
if alert_state.pending_since is not None:
|
||||||
|
if time.time() - alert_state.pending_since >= self.grace_seconds:
|
||||||
|
lvl, message, formatted_msg = self._trigger_notification(
|
||||||
|
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data
|
||||||
|
)
|
||||||
|
alert_state.formatted_message = formatted_msg
|
||||||
|
self._send_notification(
|
||||||
|
host_name, lvl, message, metric_path, AlertLevel.OK, alert_state.level, value
|
||||||
|
)
|
||||||
|
alert_state.pending_since = None
|
||||||
|
# else: still within grace window, do nothing
|
||||||
|
else:
|
||||||
|
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data)
|
||||||
|
|
||||||
def _check_renotify(
|
def _check_renotify(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
|
|||||||
@@ -171,6 +171,24 @@ def dicttos(ID, d):
|
|||||||
DROPOVERDUE = 7 * 24 * 3600 # seconds before an overdue host becomes UNKNOWN
|
DROPOVERDUE = 7 * 24 * 3600 # seconds before an overdue host becomes UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def _set_connectivity_alert(host, afam, level_name):
|
||||||
|
"""Update (or clear) a connectivity alert_state entry for a host/address-family.
|
||||||
|
|
||||||
|
level_name is "CRITICAL", "WARNING", or "OK". "OK" removes the entry so
|
||||||
|
that recovered hosts don't clutter the Alerts Dashboard.
|
||||||
|
"""
|
||||||
|
from .threshold import AlertState, AlertLevel
|
||||||
|
metric_path = f"connectivity.{afam}"
|
||||||
|
level = getattr(AlertLevel, level_name, AlertLevel.OK)
|
||||||
|
if level == AlertLevel.OK:
|
||||||
|
host.alert_states.pop(metric_path, None)
|
||||||
|
return
|
||||||
|
if metric_path not in host.alert_states:
|
||||||
|
host.alert_states[metric_path] = AlertState(metric_path)
|
||||||
|
state = host.alert_states[metric_path]
|
||||||
|
state.update(level, level_name)
|
||||||
|
|
||||||
|
|
||||||
def _make_timer_callbacks(uname, host, ctx):
|
def _make_timer_callbacks(uname, host, ctx):
|
||||||
"""Return (on_overdue, on_unknown) async callbacks for connection timer logic.
|
"""Return (on_overdue, on_unknown) async callbacks for connection timer logic.
|
||||||
|
|
||||||
@@ -182,6 +200,7 @@ def _make_timer_callbacks(uname, host, ctx):
|
|||||||
|
|
||||||
async def on_unknown(connection):
|
async def on_unknown(connection):
|
||||||
connection.newstate(connection.__class__.UNKNOWN, connection.lastbeat)
|
connection.newstate(connection.__class__.UNKNOWN, connection.lastbeat)
|
||||||
|
# Keep connectivity alert active when host transitions to unknown
|
||||||
if msg_to_websockets:
|
if msg_to_websockets:
|
||||||
msg_to_websockets("host", host.stateinfo())
|
msg_to_websockets("host", host.stateinfo())
|
||||||
|
|
||||||
@@ -196,6 +215,8 @@ def _make_timer_callbacks(uname, host, ctx):
|
|||||||
uname,
|
uname,
|
||||||
notify_mod.Notification(title=f"[CRITICAL] {uname}", body=msg, level="CRITICAL"),
|
notify_mod.Notification(title=f"[CRITICAL] {uname}", body=msg, level="CRITICAL"),
|
||||||
)
|
)
|
||||||
|
# Track in alert_states so the Alerts Dashboard shows this
|
||||||
|
_set_connectivity_alert(host, connection.afam, "CRITICAL")
|
||||||
if threshold_checker:
|
if threshold_checker:
|
||||||
threshold_checker.check_value(
|
threshold_checker.check_value(
|
||||||
host_name=uname,
|
host_name=uname,
|
||||||
@@ -410,6 +431,8 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
if conn.getstate() != hbdcls.Connection.UP:
|
if conn.getstate() != hbdcls.Connection.UP:
|
||||||
lasts = conn.state
|
lasts = conn.state
|
||||||
d = conn.newstate(hbdcls.Connection.UP, now)
|
d = conn.newstate(hbdcls.Connection.UP, now)
|
||||||
|
# Clear connectivity alert now that the host is back up
|
||||||
|
_set_connectivity_alert(host, conn.afam, "OK")
|
||||||
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
||||||
# it was never down, it just hasn't been seen before.
|
# it was never down, it just hasn't been seen before.
|
||||||
if not newh:
|
if not newh:
|
||||||
@@ -436,6 +459,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
notify_mod.Notification(title=f"[INFO] {uname}", body=m, level="INFO"),
|
notify_mod.Notification(title=f"[INFO] {uname}", body=m, level="INFO"),
|
||||||
)
|
)
|
||||||
conn.newstate(hbdcls.Connection.DOWN, now)
|
conn.newstate(hbdcls.Connection.DOWN, now)
|
||||||
|
_set_connectivity_alert(host, conn.afam, "CRITICAL")
|
||||||
|
|
||||||
if interval > 0:
|
if interval > 0:
|
||||||
host.interval = interval
|
host.interval = interval
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.1.1"
|
version = "5.1.2"
|
||||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+48
-18
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
what=$1
|
what=$1
|
||||||
|
on_ha=0
|
||||||
|
[ -z "$what" ] && what="client"
|
||||||
|
|
||||||
if [ -d /homeassistant ]; then
|
if [ -d /homeassistant ]; then
|
||||||
echo "cannot install in HA, run \"docker exec -it homeassistant $0 $@\""
|
echo "cannot install in HA, run \"docker exec -it homeassistant $0 $@\""
|
||||||
@@ -23,36 +25,64 @@ if [ -d /config ]; then
|
|||||||
echo "Installing on HA"
|
echo "Installing on HA"
|
||||||
where="/config/bin"
|
where="/config/bin"
|
||||||
venv="/config/venvs"
|
venv="/config/venvs"
|
||||||
|
on_ha=1
|
||||||
else
|
else
|
||||||
if [ ! -d ~/.local/bin ] && [ ! -d ~/bin ]; then
|
if [ ! -d $HOME/.local/bin ] && [ ! -d $HOME/bin ]; then
|
||||||
echo "No suitable bin directory found in PATH, please add either ~/.local/bin or ~/bin to your PATH"
|
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
for where in ~/bin ~/.local/bin; do
|
for where in $HOME/bin $HOME/.local/bin notset ; do
|
||||||
if echo ":$PATH:" | grep -q ":$where:" ; then
|
if echo ":$PATH:" | grep -q ":$where:" ; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
venv="~/venvs"
|
if [ "$where" = "notset" ]; then
|
||||||
|
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
venv="$HOME/venvs"
|
||||||
fi
|
fi
|
||||||
python3 -m pip --version > /dev/null 2>&1 || { echo "pip is not installed, please install pip for python3"; exit 1; }
|
|
||||||
|
echo "Installing heartbeat $what"
|
||||||
|
|
||||||
|
if [ ! -d $venv/hbd ]; then
|
||||||
|
python3 -m pip --version > /dev/null 2>&1
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
# truenas does not have pip installed by default, so we need to fetch get-pip.py and install pip
|
||||||
|
echo "pip is not installed, fetching get-pip.py and installing pip"
|
||||||
|
arg="--without-pip"
|
||||||
|
fi
|
||||||
|
mkdir -p $venv
|
||||||
|
have_venv=$(python3 -c "import venv" &> /dev/null && echo "Installed" || echo "Not Installed")
|
||||||
|
if [ "$have_venv" = "Not Installed" ]; then
|
||||||
|
echo "python venv module not found, installing virtualenv"
|
||||||
|
python3 -m pip install --user virtualenv
|
||||||
|
python3 -m virtualenv $venv/hbd --system-site-packages $arg
|
||||||
|
else
|
||||||
|
python3 -m venv $venv/hbd --system-site-packages $arg
|
||||||
|
fi
|
||||||
|
. $venv/hbd/bin/activate
|
||||||
|
if [ -n "$arg" ]; then
|
||||||
|
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3 get-pip.py
|
||||||
|
fi
|
||||||
|
deactivate
|
||||||
|
fi
|
||||||
|
|
||||||
|
. $venv/hbd/bin/activate
|
||||||
|
python3 -mpip install --upgrade --index-url https://git.wrede.ca/api/packages/andreas/pypi/simple/ --extra-index-url https://pypi.org/simple hbd[$what]
|
||||||
|
|
||||||
if [ "$what" = "server" ]; then
|
if [ "$what" = "server" ]; then
|
||||||
echo "Installing heartbeat server (hbd)"
|
rm -f $where/hbd
|
||||||
else
|
|
||||||
what="client"
|
|
||||||
echo "Installing heartbeat client (hbc)"
|
|
||||||
fi
|
|
||||||
if [ ! -d $venv/hbd ]; then
|
|
||||||
mkdir -p $venv
|
|
||||||
python3 -m venv $venv/hbd --system-site-packages
|
|
||||||
fi
|
|
||||||
. $venv/hbd/bin/activate
|
|
||||||
pip install --index-url https://git.wrede.ca/api/packages/andreas/pypi/simple/ --extra-index-url https://pypi.org/simple hbd[$what]
|
|
||||||
if [ "$what" = "server" ]; then
|
|
||||||
rm -f ~$where/hbd
|
|
||||||
ln -sf $(which hbd) $where/hbd
|
ln -sf $(which hbd) $where/hbd
|
||||||
|
echo "hbd installed, you can run it with \"$where/hbd\" or \"hbd\" if $where is in your PATH"
|
||||||
else
|
else
|
||||||
rm -f $where/hbc
|
rm -f $where/hbc
|
||||||
ln -sf $(which hbc) $where/hbc
|
ln -sf $(which hbc) $where/hbc
|
||||||
|
if [ $on_ha -eq 1 ]; then
|
||||||
|
echo "restarting hbc "
|
||||||
|
job=$(grep run_hbc configuration.yaml | sed 's/run_hbc://')
|
||||||
|
$job
|
||||||
|
else
|
||||||
|
echo "hbc installed, you can run it with \"$where/hbc\" or \"hbc\" if $where is in your PATH"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user