"""HTTP server implementation using aiohttp and jinja2.""" import asyncio import json import time import urllib.parse import os import logging from aiohttp import web import jinja2 logger = logging.getLogger(__name__) def _render_template(html_str: str, **context) -> str: tmpl = jinja2.Template(html_str) return tmpl.render(**context) async def start( host: str, port: int, config, hbdclass, msgs_getter, log=None, email=None, pushmsg=None, msg_to_websockets=None, tcss=None, DEBUG=0, verbose=False, get_now=None, VER="", threshold_checker=None, ): """Start an aiohttp web server and block until cancelled. This function is intended to be awaited inside the main asyncio event loop. """ get_now = get_now or (lambda: time.time()) async def index(request): res = [] res.append('') res.append("") res.append("") res.append("Heartbeat") if tcss: res.append(tcss) res.append("") res.append('') res.append(f"

Heartbeat status {VER}

") res += hbdclass.ubHost.buildhosttable() res += hbdclass.ubHost.buildmsgtable(msgs_getter()) res.append( "

%s (%s)

" % ( time.strftime("%H:%M:%S", time.localtime(get_now())), config.get("tz", "CET-1CDT"), ) ) res.append("") body = "\n".join(res) return web.Response(text=body, content_type="text/html") async def api_hosts(request): lst = [hbdclass.Host.hosts[h].jsons() for h in hbdclass.Host.hosts] return web.json_response(json.loads("[" + ",".join(lst) + "]")) async def api_messages(request): lst = msgs_getter()[-30:] return web.json_response(lst) async def cmd(request): qa = request.rel_url.query uname = qa.get("h") ucmd = qa.get("c") if not ucmd or not uname: return web.Response(status=400, text="need h= and c= arguments") if uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") hbdclass.Host.hosts[uname].cmds.append( ("CMD", {"cmd": urllib.parse.unquote(ucmd)}) ) return web.Response(text=f"cmd {uname} queued") async def drop(request): qa = request.rel_url.query uname = qa.get("h") if not uname: return web.Response(status=400, text="need h= argument") if uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") if log: log(uname, "dropped") del hbdclass.Host.hosts[uname] return web.Response(text="Done") async def register(request): qa = request.rel_url.query uname = qa.get("h") if not uname: return web.Response(status=400, text="need h= argument") if uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") ll = hbdclass.Host.hosts[uname].registerDns() if log: log(uname, ll) return web.Response(text=str(ll)) async def update(request): qa = request.rel_url.query uname = urllib.parse.unquote(qa.get("h", "")) ucode = qa.get("c") if not ucode or not uname: return web.Response(status=400, text="need h= and c= arguments") if uname != "All" and uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") if uname != "All": names = [uname] else: names = [n for n in hbdclass.Host.hosts if hbdclass.Host.hosts[n].cver >= 2] out = [] for n in names: err = None try: r = {"csum": None, "code": ucode} hbdclass.Host.hosts[n].cmds.append(("UPD", r)) except Exception as e: err = str(e) out.append(f"update started for {n}: {err if err else 'OK'}") return web.Response(text="\n".join(out)) async def live(request): # render template from hbd/templates/live.html using Jinja2 # Resolve templates directory relative to the hbd package pkg_dir = os.path.dirname(__file__) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) host = config.get("hb_host", "localhost") extra_scripts = config.get("http_extra_scripts", "") host = request.host.split(":")[0] if config.get("wss_port"): heartbeat_ws_url = f"wss://{host}:{config['wss_port']}/hbd" else: heartbeat_ws_url = f"ws://{host}:{config.get('ws_port', 50005)}/hbd" tmpl = env.get_template("live.html") body = tmpl.render( title="Heartbeat", header="Heartbeat", request=request, heartbeat_ws_url=heartbeat_ws_url, extra_scripts=extra_scripts, hosts=[ hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts) ], messages=msgs_getter()[-30:], ) return web.Response(text=body, content_type="text/html") async def static(request): """Serve files from the package static directory. URL form: /static/ """ p = request.match_info.get("path", "") logger.debug("static file requested: %s", p) base = os.path.abspath(os.path.join(os.path.dirname(__file__), "static")) # normalize and prevent directory traversal target = os.path.abspath(os.path.normpath(os.path.join(base, p))) if not target.startswith(base + os.sep) and target != base: return web.Response(status=403, text="Forbidden") if not os.path.exists(target) or not os.path.isfile(target): return web.Response(status=404, text="Not Found") logger.info("serving static file: %s", target) return web.FileResponse(path=target) async def favicon(request): """Serve favicon.ico from the package static directory.""" base = os.path.abspath(os.path.join(os.path.dirname(__file__), "static/images")) target = os.path.join(base, "favicon.ico") if not os.path.exists(target) or not os.path.isfile(target): return web.Response(status=404, text="Not Found") return web.FileResponse(path=target) # ------------------------------------------------------------------------- # Plugin Data API Endpoints # ------------------------------------------------------------------------- async def api_host_plugins(request): """Get all plugin data for a specific host.""" hostname = request.match_info.get("hostname") if hostname not in hbdclass.Host.hosts: return web.json_response( {"error": f"Host '{hostname}' not found"}, status=404 ) host = hbdclass.Host.hosts[hostname] # Get plugin data with most recent sample for each plugin plugins_summary = {} for plugin_name, samples in host.plugin_data.items(): if samples: # Get most recent sample timestamp, data = samples[-1] plugins_summary[plugin_name] = { "timestamp": timestamp, "data": data, "sample_count": len(samples), } return web.json_response({ "hostname": hostname, "plugins": plugins_summary, }) async def api_host_plugin_detail(request): """Get detailed data for a specific plugin on a host.""" hostname = request.match_info.get("hostname") plugin_name = request.match_info.get("plugin_name") if hostname not in hbdclass.Host.hosts: return web.json_response( {"error": f"Host '{hostname}' not found"}, status=404 ) host = hbdclass.Host.hosts[hostname] # Get limit from query parameter limit = request.rel_url.query.get("limit", "10") try: limit = int(limit) except ValueError: limit = 10 # Get plugin data samples = host.get_plugin_data(plugin_name, limit=limit) if not samples: return web.json_response( {"error": f"No data for plugin '{plugin_name}' on host '{hostname}'"}, status=404 ) # Format samples formatted_samples = [ { "timestamp": ts, "data": data, } for ts, data in samples ] return web.json_response({ "hostname": hostname, "plugin": plugin_name, "samples": formatted_samples, "sample_count": len(formatted_samples), }) async def api_host_alerts(request): """Get alert states for a specific host.""" hostname = request.match_info.get("hostname") if hostname not in hbdclass.Host.hosts: return web.json_response( {"error": f"Host '{hostname}' not found"}, status=404 ) host = hbdclass.Host.hosts[hostname] # Get alert states alerts = [] for metric_path, alert_state in host.alert_states.items(): alerts.append(alert_state.to_dict()) # Get summary if threshold_checker available summary = {"ok": 0, "warning": 0, "critical": 0, "unknown": 0} if threshold_checker: summary = threshold_checker.get_alert_summary(host.alert_states) return web.json_response({ "hostname": hostname, "alerts": alerts, "summary": summary, }) async def api_all_alerts(request): """Get all active alerts across all hosts.""" all_alerts = [] for hostname, host in hbdclass.Host.hosts.items(): if threshold_checker: active_alerts = threshold_checker.get_active_alerts(host.alert_states) else: # Fallback if no threshold checker from hbd.client.threshold import AlertLevel active_alerts = [ state for state in host.alert_states.values() if state.level != AlertLevel.OK ] for alert in active_alerts: alert_dict = alert.to_dict() alert_dict["hostname"] = hostname all_alerts.append(alert_dict) # Sort by level (critical first) then by hostname level_order = {"CRITICAL": 0, "WARNING": 1, "UNKNOWN": 2, "OK": 3} all_alerts.sort( key=lambda a: (level_order.get(a["level"], 99), a["hostname"], a["metric_path"]) ) # Get summary counts summary = {"critical": 0, "warning": 0, "unknown": 0, "total": len(all_alerts)} for alert in all_alerts: level = alert["level"].lower() if level in summary: summary[level] += 1 return web.json_response({ "alerts": all_alerts, "summary": summary, "host_count": len(hbdclass.Host.hosts), }) # ------------------------------------------------------------------------- # UI Pages # ------------------------------------------------------------------------- async def plugins_page(request): """Render the plugin metrics visualization page.""" pkg_dir = os.path.dirname(__file__) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) # Collect all hosts with plugin data hosts_with_plugins = [] for hostname in sorted(hbdclass.Host.hosts.keys()): host = hbdclass.Host.hosts[hostname] if host.plugin_data: hosts_with_plugins.append({ "name": hostname, "plugins": list(host.plugin_data.keys()), }) tmpl = env.get_template("plugins.html") body = tmpl.render( title="Plugin Metrics - Heartbeat", header="Plugin Metrics", hosts=hosts_with_plugins, ) return web.Response(text=body, content_type="text/html") async def alerts_page(request): """Render the alerts dashboard page.""" pkg_dir = os.path.dirname(__file__) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) tmpl = env.get_template("alerts.html") body = tmpl.render( title="Alerts Dashboard - Heartbeat", header="Alerts Dashboard", ) return web.Response(text=body, content_type="text/html") app = web.Application() app.add_routes( [ web.get("/", index), web.get("/api/0/hosts", api_hosts), web.get("/api/0/messages", api_messages), web.get("/api/0/hosts/{hostname}/plugins", api_host_plugins), web.get("/api/0/hosts/{hostname}/plugins/{plugin_name}", api_host_plugin_detail), web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts), web.get("/api/0/alerts", api_all_alerts), web.get("/c", cmd), web.get("/d", drop), web.get("/n", register), web.get("/u", update), web.get("/live", live), web.get("/plugins", plugins_page), web.get("/alerts", alerts_page), web.get("/static/{path:.*}", static), web.get("/favicon.ico", favicon), ] ) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, host, port) await site.start() if verbose: print(f"HTTP server started on {host}:{port}") try: await asyncio.Future() finally: await runner.cleanup()