feat(scheduler+ntp): Memory-Alerts + NTP-Status freq/rms — v1.1.107

- Scheduler: runMemoryCheck() liest /proc/meminfo, feuert Warning bei ≥85% und Critical bei ≥95% RAM-Auslastung (5-Min-Intervall, 1h dedupe pro Severity-Key)
- NTP-Status-Karte: freq_ppm (±ppm mit Warnung bei >100) und rms_offset_ms werden jetzt angezeigt — Felder waren im Backend schon vorhanden, aber nie gerendert
- Alerts scopeDesc: mem.high-Trigger in beiden i18n-Dateien dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-26 05:54:57 +02:00
parent 99b4225e64
commit ac6c580318
7 changed files with 110 additions and 6 deletions

View File

@@ -1 +1 @@
1.1.106
1.1.107

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
var version = "1.1.106"
var version = "1.1.107"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
var version = "1.1.106"
var version = "1.1.107"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -40,7 +40,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.1.106"
var version = "1.1.107"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
@@ -108,6 +108,13 @@ const (
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
// Backend nach 12h immer noch unten ist.
backendDownCheckInterval = 2 * time.Minute
// memCheckInterval — alle 5 Minuten /proc/meminfo lesen. Schwellen
// warning 85%, critical 95%. Dedupe 1h pro Severity damit bei einem
// kurzfristigen Spike nicht jeder Tick feuert.
memCheckInterval = 5 * time.Minute
memWarnPct = 85.0
memCriticalPct = 95.0
)
func main() {
@@ -197,6 +204,10 @@ func main() {
// Scheduler-Restart down ist, brauchen wir nicht 2 Minuten zu warten.
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
memTick := time.NewTicker(memCheckInterval)
defer memTick.Stop()
runMemoryCheck(ctx, alertSvc, alertDedupe)
for {
select {
case <-renewTick.C:
@@ -222,6 +233,8 @@ func main() {
runAuditCleanup(ctx, auditRepo, setupStore)
case <-backendDownTick.C:
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
case <-memTick.C:
runMemoryCheck(ctx, alertSvc, alertDedupe)
}
}
}
@@ -326,6 +339,68 @@ func runDiskCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
// remaining hat UND eine lokale CA existiert, wird automatisch neu
// signiert. Restart-Hinweis als Info-Alert — wir starten edgeguard-api
// nicht selbst neu, das passiert beim nächsten geplanten Update/Reboot.
// runMemoryCheck liest /proc/meminfo und feuert bei hoher RAM-Belegung.
// Schwellen: warning >= 85%, critical >= 95%. Dedupe 1h pro Severity
// damit kurze Spikes (Backup, apt-Upgrade) keine Alert-Flut erzeugen.
func runMemoryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return
}
var memTotal, memAvail int64
for _, line := range strings.Split(string(data), "\n") {
var key string
var val int64
if _, err := fmt.Sscanf(line, "%s %d", &key, &val); err != nil {
continue
}
switch key {
case "MemTotal:":
memTotal = val
case "MemAvailable:":
memAvail = val
}
}
if memTotal <= 0 {
return
}
usedPct := float64(memTotal-memAvail) * 100 / float64(memTotal)
usedGB := float64(memTotal-memAvail) / 1024 / 1024
totalGB := float64(memTotal) / 1024 / 1024
var key, title string
var sev alerts.Severity
switch {
case usedPct >= memCriticalPct:
key = "mem.high.critical"
sev = alerts.SeverityError
title = fmt.Sprintf("RAM kritisch hoch: %.0f%%", usedPct)
case usedPct >= memWarnPct:
key = "mem.high.warning"
sev = alerts.SeverityWarning
title = fmt.Sprintf("RAM-Belegung hoch: %.0f%%", usedPct)
default:
return
}
if !d.shouldFire(key) {
return
}
desc := fmt.Sprintf(
"RAM-Auslastung: %.1f%% — %.1f von %.1f GB belegt.\n\n"+
"Häufige Ursachen:\n"+
" • Unbound-Cache zu groß (rrset-cache-size in /etc/edgeguard/unbound/unbound.conf)\n"+
" • Squid cache_mem zu groß (64 MB default)\n"+
" • PostgreSQL shared_buffers (default ~128 MB)\n"+
" • Prozesse prüfen: ps aux --sort=-%mem | head -10",
usedPct, usedGB, totalGB)
if _, err := a.Fire(ctx, "mem.high", sev, title, desc); err != nil {
slog.Warn("scheduler: memory-check alert fire failed", "error", err)
}
}
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert

View File

@@ -837,6 +837,9 @@
"stratum": "Stratum",
"offset": "Offset",
"offsetHint": "Zeitdifferenz zur Referenzquelle. Werte > 100 ms sind ungewöhnlich — Netzwerkprobleme oder fehlkonfigurierte Quelle prüfen.",
"freqPpm": "Frequenzfehler",
"freqPpmHint": "Frequenzabweichung der lokalen Uhr zur Referenz. Werte > ±100 ppm weisen auf eine driftende Uhr oder einen Hardware-Defekt hin.",
"rmsOffset": "RMS-Offset",
"loading": "Lade…"
},
"pool": {
@@ -1116,7 +1119,7 @@
"title": "Health-Alarme",
"intro": "Notification-Channels für kritische Events. Webhook (Slack/Discord/Teams/Generic-HTTP) oder Email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.",
"scopeTitle": "Was triggert Alarme?",
"scopeDesc": "cert.expiring — TLS-Zertifikat <14 Tage Restzeit (dedupe 12h). cert.renew_failed — ACME-Renewer hat Fails. backup.failed — Scheduled Backup konnte nicht erstellt werden. license.invalid — License-Server liefert valid=false. backend.down — alle Server eines Backend-Pools sind DOWN (2-Min-Check, dedupe 12h). disk.full — Root-Filesystem ≥80% Warnung, ≥90% Critical (stündlich, dedupe 12h).",
"scopeDesc": "cert.expiring — TLS-Zertifikat <14 Tage Restzeit (dedupe 12h). cert.renew_failed — ACME-Renewer hat Fails. backup.failed — Scheduled Backup konnte nicht erstellt werden. license.invalid — License-Server liefert valid=false. backend.down — alle Server eines Backend-Pools sind DOWN (2-Min-Check, dedupe 12h). disk.full — Root-Filesystem ≥80% Warnung, ≥90% Critical (stündlich, dedupe 12h). mem.high — RAM-Auslastung ≥85% Warnung, ≥95% Critical (5-Min-Check, dedupe 1h).",
"tabs": { "channels": "Channels", "events": "History" },
"add": "Channel hinzufügen",
"addTitle": "Notification-Channel anlegen",

View File

@@ -837,6 +837,9 @@
"stratum": "Stratum",
"offset": "Offset",
"offsetHint": "Time difference to reference source. Values > 100 ms are unusual — check network or misconfigured source.",
"freqPpm": "Freq. error",
"freqPpmHint": "Frequency error of the local clock vs. reference. Values > ±100 ppm indicate a drifting clock or hardware issue.",
"rmsOffset": "RMS offset",
"loading": "Loading…"
},
"pool": {
@@ -1116,7 +1119,7 @@
"title": "Health alerts",
"intro": "Notification channels for critical events. Webhook (Slack/Discord/Teams/generic-HTTP) or email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.",
"scopeTitle": "What triggers alerts?",
"scopeDesc": "cert.expiring — TLS cert <14 days remaining (12 h dedupe). cert.renew_failed — ACME renewer cycle had failures. backup.failed — scheduled backup couldn't run. license.invalid — License server returns valid=false. backend.down — all servers in a backend pool are DOWN (2 min check, 12 h dedupe). disk.full — root filesystem ≥80% warning, ≥90% critical (hourly check, 12 h dedupe).",
"scopeDesc": "cert.expiring — TLS cert <14 days remaining (12 h dedupe). cert.renew_failed — ACME renewer cycle had failures. backup.failed — scheduled backup couldn't run. license.invalid — License server returns valid=false. backend.down — all servers in a backend pool are DOWN (2 min check, 12 h dedupe). disk.full — root filesystem ≥80% warning, ≥90% critical (hourly check, 12 h dedupe). mem.high — RAM usage ≥85% warning, ≥95% critical (5 min check, 1 h dedupe).",
"tabs": { "channels": "Channels", "events": "History" },
"add": "Add channel",
"addTitle": "Add notification channel",

View File

@@ -161,6 +161,29 @@ export default function NTPPage() {
/>
</Tooltip>
</Col>
{ntpStatus.freq_ppm != null && (
<Col xs={12} sm={6}>
<Tooltip title={t('ntp.statusCard.freqPpmHint')}>
<Statistic
title={t('ntp.statusCard.freqPpm')}
value={(ntpStatus.freq_ppm >= 0 ? '+' : '') + ntpStatus.freq_ppm.toFixed(3) + ' ppm'}
valueStyle={{
fontSize: 13,
color: Math.abs(ntpStatus.freq_ppm) > 100 ? '#d48806' : undefined,
}}
/>
</Tooltip>
</Col>
)}
{ntpStatus.rms_offset_ms != null && ntpStatus.rms_offset_ms > 0 && (
<Col xs={12} sm={6}>
<Statistic
title={t('ntp.statusCard.rmsOffset')}
value={ntpStatus.rms_offset_ms.toFixed(3) + ' ms'}
valueStyle={{ fontSize: 13 }}
/>
</Col>
)}
</Row>
) : (
<Typography.Text type="secondary">{t('ntp.statusCard.loading')}</Typography.Text>