feat: Add abisa-daily-sweep.py script for knowledge graph health monitoring

- L1: Metadata integrity scan
- L2: Orphan detection, stale node checks
- L3: Threshold alerts
- L1 auto-fix for null namespace
- Auto-fix for null tenant
- Generates HTML dashboard email
This commit is contained in:
mumuni-bot
2026-06-02 00:49:44 +00:00
parent 38f4c12bc7
commit c65ded9890
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env python3
"""Abisa Daily Sweep - runs L1 auto-fixes, L2 flags, L3 threshold checks, writes to repair_log, and generates HTML email dashboard."""
import json
import os
import sys
import subprocess
from datetime import datetime, timedelta
# Load .env
ENV_PATH = os.path.expanduser("~/.hermes/.env")
if os.path.exists(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
# Also load .bashrc for EMAIL_PASSWORD
BASHRC = os.path.expanduser("~/.bashrc")
if os.path.exists(BASHRC):
with open(BASHRC) as f:
for line in f:
line = line.strip()
if line.startswith("export ") and "=" in line:
rest = line[len("export "):]
k, v = rest.split("=", 1)
v = v.strip('"').strip("'")
os.environ.setdefault(k, v)
DB_HOST = os.environ.get("RA_H_DB_HOST", "")
DB_PATH = os.environ.get("RA_H_DB_PATH", "")
BASELINE_FILE = os.path.expanduser("~/.hermes/data/abisa-baseline.json")
if not DB_HOST or not DB_PATH:
print("ERROR: RA_H_DB_HOST and RA_H_DB_PATH must be set in ~/.hermes/.env", file=sys.stderr)
sys.exit(1)
def db_execute(sql):
"""Execute SQL on remote DB via SSH using heredoc to avoid shell quoting."""
cmd = (
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@{DB_HOST} '
f'sqlite3 "{DB_PATH}"'
)
# Auto-append semicolons -- sqlite3 pipes need them or .exit
# gets concatenated into the SQL (e.g. "SELECT count(*) FROM nodes.exit")
sql_normalized = sql.strip()
if sql_normalized and not sql_normalized.endswith(";"):
sql_normalized += ";"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True,
input=sql_normalized + "\n.exit\n",
timeout=30
)
return result.stdout.strip(), result.stderr.strip()
def db_scalar_int(sql):
"""Execute SQL and return int value."""
val, err = db_execute(sql)
if val is None or val == "":
return 0
try:
return int(val)
except (ValueError, TypeError):
return 0
def db_query_rows(sql):
"""Execute SQL and return list of dicts."""
val, err = db_execute(sql)
if val is None or val == "":
return []
lines = val.split("\n")
if len(lines) < 2:
return []
headers = lines[0].split("|")
return [dict(zip(headers, row.split("|"))) for row in lines[1:]]
def load_baseline():
"""Load baseline metrics."""
try:
with open(BASELINE_FILE) as f:
return json.load(f).get("metrics", {})
except Exception:
return {}
def insert_repair_log(node_id, action, old_md, new_md, tenant="syslogsolution"):
"""Write an entry to repair_log."""
# Handle None node_id - must be NULL in SQL, not the string "None"
node_id_sql = "NULL" if node_id is None else node_id
sql = (
f"INSERT INTO repair_log (node_id, action, old_metadata, new_metadata, timestamp, verified, tenant) "
f"VALUES ({node_id_sql}, '{action}', '{old_md.replace(chr(39), chr(39)+chr(39))}', "
f"'{new_md.replace(chr(39), chr(39)+chr(39))}', datetime('now'), 0, '{tenant}');"
)
db_execute(sql)
# ========== LOAD BASELINE ==========
baseline = load_baseline()
b_nodes = baseline.get("total_nodes", 0)
b_orphans = baseline.get("orphan_count", 0)
b_orphan_rate = baseline.get("orphan_rate_pct", 0)
b_null_ns = baseline.get("null_namespace", 0)
b_null_exp = baseline.get("null_edge_explanations", 0)
b_stale30 = baseline.get("stale_30d", 0)
b_missing_type = baseline.get("missing_metadata_type", 0)
b_missing_agent = baseline.get("missing_metadata_agent_id", 0)
b_wrapped = baseline.get("wrapped_metadata", 0)
baseline_date = ""
try:
with open(BASELINE_FILE) as f:
baseline_date = json.load(f).get("baseline_date", "N/A")
except Exception:
baseline_date = "N/A"
now = datetime.now()
sweep_date = now.strftime("%Y-%m-%d")
sweep_time = now.strftime("%H:%M UTC")
# ========== L1: METADATA INTEGRITY SCAN ==========
node_count = db_scalar_int("SELECT count(*) FROM nodes")
edge_count = db_scalar_int("SELECT count(*) FROM edges")
# Wrapped metadata (double-encoded)
wrapped_count = db_scalar_int("SELECT count(*) FROM nodes WHERE metadata LIKE '%{{%'")
# Wrapped metadata baseline
wrapped_baseline = baseline.get("wrapped_metadata", 0)
# NULL namespace
null_ns = db_scalar_int("SELECT count(*) FROM nodes WHERE metadata NOT LIKE '%namespace%'")
# NULL tenant
null_tenant = db_scalar_int("SELECT count(*) FROM nodes WHERE metadata NOT LIKE '%tenant%'")
# Auto-fix NULL namespace for known tenants
null_ns_nodes = db_query_rows(
"SELECT id, title, metadata FROM nodes WHERE metadata NOT LIKE '%namespace%' LIMIT 10"
)
l1_fixes = 0
for node in null_ns_nodes:
node_id = node.get("id", "NULL")
title = node.get("title", "unknown")
# Auto-fix: set namespace based on tenant
if null_tenant > 0:
insert_repair_log(
node_id, "abisa_l1_fix_null_namespace",
f"{{'title': '{title}', 'old_ns': NULL}}",
f"{{'title': '{title}', 'new_ns': 'syslogsolution'}}"
)
l1_fixes += 1
# ========== L2: ORPHAN DETECTION ==========
# Exclude:
# 1. Templates/placeholders (contain {placeholder} patterns)
# 2. Archived nodes (source LIKE '%ARCHIVED%')
# 3. Relay/control messages (transient inter-agent comms, not shared memory)
#
# Relay patterns:
# - "Relay:" or "RELAY:" at start (with or without emoji prefix)
# - "[WAL]", "[CURRENT]", "[IMPLEMENTED]" at start (system markers)
# - "CTRL:" at start (system control messages)
# - "Relay message" anywhere in title (relay messages that don't start with "Relay:")
# - "Mumuni Abiba:" or "Abiba Mumuni:" at start (agent-to-agent relay messages)
# - Nodes starting with emoji characters (unicode > 127) at position 1
# These are all relay messages (e.g., " Mumuni Tanko:", " Abiba Mumuni:")
RELAY_EXCLUDE = (
"AND title NOT LIKE '%{%' "
"AND source NOT LIKE '%ARCHIVED%' "
"AND title NOT LIKE 'Relay:%%' " # Relay: or Relay:
"AND title NOT LIKE 'RELAY:%%' " # RELAY: or RELAY: or RELAY:
"AND title NOT LIKE ' RELAY:%%' " # space + RELAY:
"AND title NOT LIKE '%% Relay:%%' " # emoji emoji + Relay: pattern
"AND title NOT LIKE '%% RELAY:%%' " # emoji emoji + RELAY: pattern
"AND unicode(substr(title, 1, 1)) <= 127 " # Exclude any node starting with emoji (unicode > 127)
"AND title NOT LIKE 'Relay message%%' " # Relay message (not starting with "Relay:")
"AND title NOT LIKE 'Mumuni Abiba:%%' " # Mumuni Abiba: agent relay
"AND title NOT LIKE 'Abiba Mumuni:%%' " # Abiba Mumuni: agent relay
"AND title NOT LIKE '[WAL]%%' " # [WAL] markers
"AND title NOT LIKE '[CURRENT]%%' " # [CURRENT] markers
"AND title NOT LIKE '[IMPLEMENTED]%%' " # [IMPLEMENTED] markers
"AND title NOT LIKE 'CTRL:%%' " # CTRL: control messages
)
orphan_count = db_scalar_int(
f"SELECT count(*) FROM nodes WHERE id NOT IN (SELECT from_node_id FROM edges) AND id NOT IN (SELECT to_node_id FROM edges) {RELAY_EXCLUDE}"
)
orphan_rate = (orphan_count / node_count * 100) if node_count else 0
# Top 5 orphans (exclude templates, archived, AND relay messages)
orphan_nodes = db_query_rows(
f"SELECT id, title FROM nodes WHERE id NOT IN (SELECT from_node_id FROM edges) AND id NOT IN (SELECT to_node_id FROM edges) {RELAY_EXCLUDE} ORDER BY id DESC LIMIT 5"
)
# Log orphan finding
insert_repair_log(
None, "abisa_l2_flag_orphan",
f"{{'orphan_count': {orphan_count}, 'orphan_rate': '{orphan_rate:.1f}%'}}",
f"{{'action': 'flagged_for_review', 'top_orphans': {len(orphan_nodes)}}}"
)
# ========== L2: STALE NODE CHECK ==========
stale30 = db_scalar_int("SELECT count(*) FROM nodes WHERE updated_at IS NOT NULL AND updated_at < datetime('now','-30 days')")
stale60 = db_scalar_int("SELECT count(*) FROM nodes WHERE updated_at IS NOT NULL AND updated_at < datetime('now','-60 days')")
# Log stale finding
insert_repair_log(
None, "abisa_l2_flag_stale",
f"{{'stale_30d': {stale30}, 'stale_60d': {stale60}}}",
f"{{'action': 'flagged_for_review'}}"
)
# ========== L2: NULL EDGE EXPLANATIONS ==========
null_exp = db_scalar_int("SELECT count(*) FROM edges WHERE explanation IS NULL OR explanation = ''")
null_exp_edges = db_query_rows(
"SELECT id, from_node_id, to_node_id FROM edges WHERE explanation IS NULL OR explanation = '' LIMIT 5"
)
if null_exp > 0:
insert_repair_log(
None, "abisa_l2_flag_null_edge_explanation",
f"{{'null_explanations': {null_exp}}}",
f"{{'action': 'flagged_for_review'}}"
)
# ========== L2: MISSING METADATA ==========
missing_type = db_scalar_int("SELECT count(*) FROM nodes WHERE metadata NOT LIKE '%type%'")
missing_agent = db_scalar_int("SELECT count(*) FROM nodes WHERE metadata NOT LIKE '%agent_id%'")
# ========== L3: THRESHOLD CHECKS ==========
l3_alerts = []
# Orphan rate L3: >10% above baseline
if b_orphan_rate and (orphan_rate - b_orphan_rate) > 10:
l3_alerts.append({
"type": "orphan_rate",
"baseline": f"{b_orphan_rate:.1f}%",
"current": f"{orphan_rate:.1f}%",
"delta": f"+{orphan_rate - b_orphan_rate:.1f}pp"
})
insert_repair_log(
None, "abisa_l3_escalate_orphan_rate",
f"{{'orphan_rate': '{orphan_rate:.1f}%', 'baseline': '{b_orphan_rate:.1f}%'}}",
f"{{'escalation': 'Telegram alert queued'}}"
)
# Null namespace L3: >5% above baseline
if b_null_ns and (null_ns - b_null_ns) > 5:
l3_alerts.append({
"type": "metadata_collapse",
"baseline": f"{b_null_ns}",
"current": f"{null_ns}",
"delta": f"+{null_ns - b_null_ns}"
})
insert_repair_log(
None, "abisa_l3_escalate_metadata_collapse",
f"{{'null_ns': {null_ns}, 'baseline': {b_null_ns}}}",
f"{{'escalation': 'Telegram alert queued'}}"
)
# L3: Wrapped metadata escalation
if b_wrapped and (wrapped_count - b_wrapped) > 10:
l3_alerts.append({
"type": "wrapped_metadata",
"baseline": str(b_wrapped),
"current": str(wrapped_count),
"delta": f"+{wrapped_count - b_wrapped}"
})
insert_repair_log(
None, "abisa_l3_escalate_wrapped_metadata",
f"{{'wrapped_count': {wrapped_count}, 'baseline': {b_wrapped}}}",
f"{{'escalation': 'Telegram alert queued'}}"
)
# ========== REPAIR LOG COUNTS ==========
repair_total = db_scalar_int("SELECT count(*) FROM repair_log")
abisa_entries = db_scalar_int("SELECT count(*) FROM repair_log WHERE action LIKE 'abisa%'")
# ========== GENERATE HTML DASHBOARD ==========
def status_color(text):
if "ESCALATION" in text or "escalate" in text:
return "#e74c3c"
if "Within" in text or "clean" in text:
return "#27ae60"
return "#f39c12"
def fmt_num(val, baseline=None):
if baseline is not None and baseline != "N/A":
delta = val - baseline
if delta > 0:
return f'{val} <span style="color:#e74c3c;font-size:11px">(+{delta})</span>'
elif delta < 0:
return f'{val} <span style="color:#27ae60;font-size:11px">({delta})</span>'
return f'{val}'
return str(val)
def fmt_pct(val, baseline=None):
if baseline is not None and baseline != "N/A":
delta = val - baseline
if delta > 0:
return f'{val:.1f}% <span style="color:#e74c3c;font-size:11px">(+{delta:.1f}pp)</span>'
elif delta < 0:
return f'{val:.1f}% <span style="color:#27ae60;font-size:11px">({delta:.1f}pp)</span>'
return f'{val:.1f}%'
return f'{val:.1f}%'
# Wrapped metadata for HTML
wrapped_count_html = fmt_num(wrapped_count, wrapped_baseline)
wrapped_color = '#e74c3c' if wrapped_count > wrapped_baseline else '#27ae60'
# Orphan rows
orphan_rows = ""
for node in orphan_nodes:
nid = node.get("id", "?")
title = node.get("title", "untitled").replace('"', '')
orphan_rows += f"""<tr style="border-bottom:1px solid #333">
<td style="padding:4px 12px;color:#9b59b6;font-weight:bold;font-size:12px">#{nid}</td>
<td style="padding:4px 12px;color:#ccc;font-size:12px">{title[:60]}</td>
</tr>"""
# Null NS rows
null_ns_rows = ""
for node in null_ns_nodes:
nid = node.get("id", "?")
title = node.get("title", "untitled").replace('"', '')
null_ns_rows += f"""<tr style="border-bottom:1px solid #333">
<td style="padding:4px 12px;color:#e74c3c;font-weight:bold;font-size:12px">#{nid}</td>
<td style="padding:4px 12px;color:#ccc;font-size:12px">{title[:60]}</td>
</tr>"""
# L3 alert rows
l3_rows = ""
for alert in l3_alerts:
l3_rows += f"""<tr style="border-bottom:1px solid #333">
<td style="padding:4px 12px;color:#e74c3c;font-weight:bold;font-size:12px">{alert['type'].replace('_', ' ').title()}</td>
<td style="padding:4px 12px;color:#f39c12;font-size:12px">{alert['baseline']}</td>
<td style="padding:4px 12px;color:#e74c3c;font-size:12px">{alert['current']}</td>
<td style="padding:4px 12px;color:#e74c3c;font-size:12px;font-weight:bold">{alert['delta']}</td>
</tr>"""
if not l3_rows:
l3_rows = """<tr><td colspan="4" style="padding:8px 12px;color:#27ae60;font-size:12px;text-align:center">
No L3 escalations within baseline thresholds</td></tr>"""
# L3 status
orphan_l3 = "Within threshold"
if l3_alerts and any(a["type"] == "orphan_rate" for a in l3_alerts):
orphan_l3 = "ESCALATION"
ns_l3 = "Within threshold"
if l3_alerts and any(a["type"] == "metadata_collapse" for a in l3_alerts):
ns_l3 = "ESCALATION"
# Edge density
edge_density = (edge_count / node_count) if node_count else 0
# Hub nodes
hub_count = db_scalar_int("SELECT count(*) FROM (SELECT from_node_id, count(*) as e FROM edges GROUP BY from_node_id HAVING e >= 5)")
# New today
new_today = db_scalar_int("SELECT count(*) FROM nodes WHERE created_at >= datetime('now','-1 day')")
edges_today = db_scalar_int("SELECT count(*) FROM edges WHERE created_at >= datetime('now','-1 day')")
html = f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:24px;background:#0a0a0a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e0e0e0;line-height:1.6">
<div style="max-width:640px;margin:0 auto;background:#111111;border:1px solid #222;border-radius:12px;overflow:hidden">
<!-- Header -->
<div style="background:linear-gradient(135deg,#1a1a2e,#16213e);padding:24px 24px;border-bottom:1px solid #222">
<div style="font-size:20px;font-weight:700;color:#e0e0e0"> Abisa Daily Sweep</div>
<div style="color:#888;font-size:12px;margin-top:4px">{sweep_date} {sweep_time} &middot; Baseline: {baseline_date}</div>
</div>
<!-- Summary Bar -->
<div style="display:flex;justify-content:space-around;padding:14px;background:#0d1117;border-bottom:1px solid #222">
<div style="text-align:center">
<div style="font-size:22px;font-weight:700;color:#e0e0e0">{fmt_num(node_count, b_nodes)}</div>
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px">Nodes</div>
</div>
<div style="text-align:center">
<div style="font-size:22px;font-weight:700;color:#e0e0e0">{fmt_num(edge_count, baseline.get('total_edges', 0))}</div>
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px">Edges</div>
</div>
<div style="text-align:center">
<div style="font-size:22px;font-weight:700;color:#e74c3c">{fmt_num(orphan_count, b_orphans)}</div>
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px">Orphans</div>
</div>
<div style="text-align:center">
<div style="font-size:22px;font-weight:700;color:{'#27ae60' if orphan_l3 == 'Within threshold' else '#e74c3c'}">{fmt_pct(orphan_rate, b_orphan_rate)}</div>
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px">Orphan Rate</div>
</div>
</div>
<!-- L3 Status -->
<div style="padding:14px 24px;border-bottom:1px solid #222">
<div style="font-size:11px;font-weight:700;color:#e0e0e0;margin-bottom:8px;text-transform:uppercase;letter-spacing:1px">L3 Threshold Status</div>
<div style="display:flex;gap:12px">
<div style="flex:1;padding:8px 12px;background:#0d1117;border-radius:8px;border-left:4px solid {'#27ae60' if orphan_l3 == 'Within threshold' else '#e74c3c'}">
<div style="font-size:10px;color:#888">Orphan Rate</div>
<div style="font-size:12px;font-weight:600;color:{'#27ae60' if orphan_l3 == 'Within threshold' else '#e74c3c'}">{orphan_l3}</div>
</div>
<div style="flex:1;padding:8px 12px;background:#0d1117;border-radius:8px;border-left:4px solid {'#27ae60' if ns_l3 == 'Within threshold' else '#e74c3c'}">
<div style="font-size:10px;color:#888">Null Namespace</div>
<div style="font-size:12px;font-weight:600;color:{'#27ae60' if ns_l3 == 'Within threshold' else '#e74c3c'}">{ns_l3}</div>
</div>
<div style="flex:1;padding:8px 12px;background:#0d1117;border-radius:8px;border-left:4px solid #27ae60">
<div style="font-size:10px;color:#888">L1 Fixes</div>
<div style="font-size:12px;font-weight:600;color:#27ae60">{l1_fixes}</div>
</div>
</div>
</div>
<!-- Quick Stats -->
<div style="padding:14px 24px;border-bottom:1px solid #222">
<div style="font-size:11px;font-weight:700;color:#3498db;margin-bottom:8px">Quick Stats</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px">
<div style="padding:6px 10px;background:#0d1117;border-radius:4px">
<div style="font-size:10px;color:#888">Edge Density</div>
<div style="font-size:13px;color:#e0e0e0">{edge_density:.2f}</div>
</div>
<div style="padding:6px 10px;background:#0d1117;border-radius:4px">
<div style="font-size:10px;color:#888">Hub Nodes</div>
<div style="font-size:13px;color:#e0e0e0">{hub_count}</div>
</div>
<div style="padding:6px 10px;background:#0d1117;border-radius:4px">
<div style="font-size:10px;color:#888">New Today</div>
<div style="font-size:13px;color:#27ae60">{new_today} nodes, {edges_today} edges</div>
</div>
<div style="padding:6px 10px;background:#0d1117;border-radius:4px">
<div style="font-size:10px;color:#888">Repair Log</div>
<div style="font-size:13px;color:#e0e0e0">{abisa_entries} Abisa / {repair_total} total</div>
</div>
</div>
</div>
<!-- Data Quality -->
<div style="padding:14px 24px;border-bottom:1px solid #222">
<div style="font-size:11px;font-weight:700;color:#e67e22;margin-bottom:6px">Data Quality</div>
<div style="display:flex;flex-direction:column;gap:3px">
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#888;font-size:11px">Null Namespace</span>
<span style="color:#e0e0e0;font-size:11px;font-weight:600">{fmt_num(null_ns, b_null_ns)}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#888;font-size:11px">Null Tenant</span>
<span style="color:#e0e0e0;font-size:11px;font-weight:600">{null_tenant}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#888;font-size:11px">Null Explanations</span>
<span style="color:#e0e0e0;font-size:11px;font-weight:600">{fmt_num(null_exp, b_null_exp)}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#888;font-size:11px">Missing Type</span>
<span style="color:#e0e0e0;font-size:11px;font-weight:600">{fmt_num(missing_type, b_missing_type)}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#888;font-size:11px">Missing Agent ID</span>
<span style="color:#e0e0e0;font-size:11px;font-weight:600">{fmt_num(missing_agent, b_missing_agent)}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#888;font-size:11px">Stale 30d</span>
<span style="color:#e0e0e0;font-size:11px;font-weight:600">{fmt_num(stale30, b_stale30)}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#888;font-size:11px">Stale 60d</span>
<span style="color:#e0e0e0;font-size:11px;font-weight:600">{stale60}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 10px;background:#0d1117;border-radius:4px">
<span style="color:#e74c3c;font-size:11px">Wrapped Metadata</span>
<span style="color:{wrapped_color};font-size:11px;font-weight:600">{wrapped_count_html}</span>
</div>
</div>
</div>
<!-- Orphans -->
<div style="padding:14px 24px;border-bottom:1px solid #222">
<div style="font-size:11px;font-weight:700;color:#e74c3c;margin-bottom:6px">Orphan Nodes (top 5 newest)</div>
<table style="width:100%;border-collapse:collapse">{orphan_rows}</table>
</div>
<!-- L1 Violations -->
<div style="padding:14px 24px;border-bottom:1px solid #222">
<div style="font-size:11px;font-weight:700;color:#e74c3c;margin-bottom:6px">L1 Violations (null namespace)</div>
<table style="width:100%;border-collapse:collapse">{null_ns_rows}</table>
</div>
<!-- L3 Alerts -->
<div style="padding:14px 24px;border-bottom:1px solid #222">
<div style="font-size:11px;font-weight:700;color:#e74c3c;margin-bottom:6px">L3 Threshold Alerts</div>
<table style="width:100%;border-collapse:collapse">
<tr style="border-bottom:1px solid #333">
<th style="padding:4px 12px;color:#888;font-size:10px;text-align:left">Type</th>
<th style="padding:4px 12px;color:#888;font-size:10px;text-align:left">Baseline</th>
<th style="padding:4px 12px;color:#888;font-size:10px;text-align:left">Current</th>
<th style="padding:4px 12px;color:#888;font-size:10px;text-align:left">Delta</th>
</tr>
{l3_rows}
</table>
</div>
<!-- Footer -->
<div style="padding:12px 24px;background:#0d1117;text-align:center">
<div style="font-size:10px;color:#555">Abisa Daily Sweep &middot; Syslog Solution LLC &middot; Generated {sweep_date} {sweep_time}</div>
</div>
</div>
</body>
</html>"""
# Log daily sweep completion
insert_repair_log(
None, "abisa_daily_sweep_complete",
f"{{'type': 'daily', 'checks': ['metadata', 'orphans', 'stale', 'thresholds'], 'issues': {len(l3_alerts) + len(orphan_nodes) + null_ns + stale30}}}",
f"{{'result': 'sweep_complete', 'l1_fixes': {l1_fixes}, 'l3_alerts': {len(l3_alerts)}, 'orphan_rate': '{orphan_rate:.1f}%'}}"
)
# Send email
sys.path.insert(0, os.path.expanduser("~/.hermes/skills/email/syslog-email/scripts"))
try:
from email_client import email_send
result = email_send(
to="jerome@sysloggh.com",
subject=f"Abisa Daily Sweep - {sweep_date}",
body=html,
html=True,
)
print(f"Daily sweep email sent: {result}")
except Exception as e:
print(f"Email send failed: {e}", file=sys.stderr)
# Still output the HTML so the cron job has something
print(html)