Files
Bjorn/web_utils/system_utils.py
infinition aac77a3e76 Add Loki and Sentinel utility classes for web API endpoints
- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads.
- Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications.
- Both classes include error handling and JSON response formatting.
2026-03-14 22:33:10 +01:00

468 lines
21 KiB
Python

# web_utils/system_utils.py
"""
System utilities for management operations.
Handles system commands, service management, configuration.
"""
from __future__ import annotations
import json
import subprocess
import logging
import os
import time
from pathlib import Path
from typing import Any, Dict, Optional
from logger import Logger
logger = Logger(name="system_utils.py", level=logging.DEBUG)
class SystemUtils:
"""Utilities for system-level operations."""
def __init__(self, shared_data):
self.logger = logger
self.shared_data = shared_data
def _send_json(self, handler, data, status=200):
"""Send a JSON response (helper to reduce boilerplate)."""
handler.send_response(status)
handler.send_header("Content-type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps(data).encode('utf-8'))
def reboot_system(self, handler):
"""Reboot the system."""
try:
subprocess.Popen(["sudo", "reboot"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self._send_json(handler, {"status": "success", "message": "System is rebooting"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def shutdown_system(self, handler):
"""Shutdown the system."""
try:
subprocess.Popen(["sudo", "shutdown", "now"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self._send_json(handler, {"status": "success", "message": "System is shutting down"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def restart_bjorn_service(self, handler):
"""Restart the Bjorn service."""
if not hasattr(handler, 'send_response'):
raise TypeError("Invalid handler passed. Expected an HTTP handler.")
try:
subprocess.Popen(["sudo", "systemctl", "restart", "bjorn.service"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self._send_json(handler, {"status": "success", "message": "Bjorn service restarted successfully"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def clear_logs(self, handler):
"""Clear logs directory contents."""
try:
logs_dir = os.path.join(self.shared_data.current_dir, "data", "logs")
if os.path.isdir(logs_dir):
for entry in os.scandir(logs_dir):
try:
if entry.is_file() or entry.is_symlink():
os.remove(entry.path)
elif entry.is_dir():
import shutil
shutil.rmtree(entry.path)
except OSError as e:
self.logger.warning(f"Failed to remove {entry.path}: {e}")
self._send_json(handler, {"status": "success", "message": "Logs cleared successfully"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def initialize_db(self, handler):
"""Initialize or prepare database schema."""
try:
self.shared_data.sync_actions_to_database()
self.shared_data.initialize_database()
self.shared_data.initialize_statistics()
self._send_json(handler, {"status": "success", "message": "Database initialized successfully"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def erase_bjorn_memories(self, handler):
"""Erase all Bjorn-related memories and restart service."""
try:
# Import file_utils for clear operations
from web_utils.file_utils import FileUtils
file_utils = FileUtils(self.logger, self.shared_data)
# Clear various components
file_utils.clear_output_folder(handler)
self.clear_netkb(handler, restart=False)
self.clear_livestatus(handler, restart=False)
self.clear_actions_file(handler, restart=False)
self.clear_shared_config_json(handler, restart=False)
self.clear_logs(handler)
# Restart service once at the end
self.logger.debug("Restarting Bjorn service after clearing memories...")
self.restart_bjorn_service(handler)
self.logger.info("Bjorn memories erased and service restarted successfully.")
handler.send_response(200)
handler.send_header('Content-type', 'application/json')
handler.end_headers()
handler.wfile.write(json.dumps({
"status": "success",
"message": "Bjorn memories erased and service restarted successfully."
}).encode('utf-8'))
except Exception as e:
self.logger.error(f"Error erasing Bjorn memories: {str(e)}")
handler.send_response(500)
handler.send_header('Content-type', 'application/json')
handler.end_headers()
handler.wfile.write(json.dumps({
"status": "error",
"message": f"Error erasing Bjorn memories: {str(e)}"
}).encode('utf-8'))
def clear_netkb(self, handler, restart=True):
"""Clear network knowledge base in database."""
try:
db = self.shared_data.db
db.execute("DELETE FROM action_results;")
db.execute("DELETE FROM hosts;")
db.update_livestats(0, 0, 0, 0)
if restart:
self.restart_bjorn_service(handler)
self._send_json(handler, {"status": "success", "message": "NetKB cleared in database"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def clear_livestatus(self, handler, restart=True):
"""Clear live status counters."""
try:
self.shared_data.db.update_livestats(0, 0, 0, 0)
if restart:
self.restart_bjorn_service(handler)
self._send_json(handler, {"status": "success", "message": "Livestatus counters reset"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def clear_actions_file(self, handler, restart=True):
"""Clear actions table and resynchronize from modules."""
try:
self.shared_data.db.execute("DELETE FROM actions;")
self.shared_data.generate_actions_json()
if restart:
self.restart_bjorn_service(handler)
self._send_json(handler, {"status": "success", "message": "Actions table refreshed"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def clear_shared_config_json(self, handler, restart=True):
"""Reset configuration to defaults."""
try:
self.shared_data.config = self.shared_data.get_default_config()
self.shared_data.save_config()
if restart:
self.restart_bjorn_service(handler)
self._send_json(handler, {"status": "success", "message": "Configuration reset to defaults"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def save_configuration(self, data):
"""Save configuration to database."""
try:
if not isinstance(data, dict):
return {"status": "error", "message": "Invalid data format: expected dictionary"}
cfg = dict(self.shared_data.config)
for k, v in data.items():
if isinstance(v, bool):
cfg[k] = v
elif isinstance(v, str) and v.lower() in ('true', 'false'):
cfg[k] = (v.lower() == 'true')
elif isinstance(v, (int, float)):
cfg[k] = v
elif isinstance(v, list) or v is None:
cfg[k] = [] if v is None else [x for x in v if x != ""]
elif isinstance(v, str):
cfg[k] = float(v) if v.replace('.', '', 1).isdigit() and '.' in v else (int(v) if v.isdigit() else v)
else:
cfg[k] = v
self.shared_data.config = cfg
self.shared_data.save_config()
self.shared_data.load_config()
return {"status": "success", "message": "Configuration saved"}
except Exception as e:
self.logger.error(f"Error saving configuration: {e}")
return {"status": "error", "message": str(e)}
def serve_current_config(self, handler):
"""Serve current configuration as JSON (Optimized via SharedData cache)."""
handler.send_response(200)
handler.send_header("Content-type", "application/json")
handler.end_headers()
handler.wfile.write(self.shared_data.config_json.encode('utf-8'))
def restore_default_config(self, handler):
"""Restore default configuration."""
handler.send_response(200)
handler.send_header("Content-type", "application/json")
handler.end_headers()
self.shared_data.config = self.shared_data.default_config.copy()
self.shared_data.save_config()
handler.wfile.write(json.dumps(self.shared_data.config).encode('utf-8'))
def serve_logs(self, handler):
"""Serve logs for web console."""
try:
log_file_path = self.shared_data.webconsolelog
if not os.path.exists(log_file_path):
# Create the log file if it doesn't exist; tail aggregation
# is handled by the bjorn service, not by shell piping.
Path(log_file_path).touch(exist_ok=True)
with open(log_file_path, 'r') as log_file:
log_lines = log_file.readlines()
max_lines = 2000
if len(log_lines) > max_lines:
log_lines = log_lines[-max_lines:]
with open(log_file_path, 'w') as log_file:
log_file.writelines(log_lines)
log_data = ''.join(log_lines)
handler.send_response(200)
handler.send_header("Content-type", "text/plain")
handler.end_headers()
handler.wfile.write(log_data.encode('utf-8'))
except BrokenPipeError:
pass
except Exception as e:
handler.send_response(500)
handler.send_header("Content-type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8'))
def sse_log_stream(self, handler):
"""Stream logs using Server-Sent Events (SSE)."""
try:
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("Connection", "keep-alive")
handler.send_header("Access-Control-Allow-Origin", "*")
handler.end_headers()
log_file_path = self.shared_data.log_file
handler.wfile.write(b"data: Connected\n\n")
handler.wfile.flush()
with open(log_file_path, 'r') as log_file:
log_file.seek(0, os.SEEK_END)
while True:
line = log_file.readline()
if line:
message = f"data: {line.strip()}\n\n"
handler.wfile.write(message.encode('utf-8'))
handler.wfile.flush()
else:
handler.wfile.write(b": heartbeat\n\n")
handler.wfile.flush()
time.sleep(1)
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as e:
self.logger.info("Client disconnected from SSE stream")
except Exception as e:
self.logger.error(f"SSE Error: {e}")
finally:
self.logger.info("SSE stream closed")
def _parse_progress(self):
"""Parse bjorn_progress ('42%', '', 0, '100%') → int 0-100."""
raw = getattr(self.shared_data, "bjorn_progress", 0)
if isinstance(raw, (int, float)):
return max(0, min(int(raw), 100))
if isinstance(raw, str):
cleaned = raw.strip().rstrip('%').strip()
if not cleaned:
return 0
try:
return max(0, min(int(cleaned), 100))
except (ValueError, TypeError):
return 0
return 0
def serve_bjorn_status(self, handler):
try:
status_data = {
"status": self.shared_data.bjorn_orch_status,
"status2": self.shared_data.bjorn_status_text2,
# 🟢 PROGRESS — parse "42%" / "" / 0 safely
"progress": self._parse_progress(),
"image_path": "/bjorn_status_image?t=" + str(int(time.time())),
"battery": {
"present": bool(getattr(self.shared_data, "battery_present", False)),
"level_pct": int(getattr(self.shared_data, "battery_percent", 0)),
"charging": bool(getattr(self.shared_data, "battery_is_charging", False)),
"voltage": getattr(self.shared_data, "battery_voltage", None),
"source": getattr(self.shared_data, "battery_source", "unknown"),
"updated_at": float(getattr(self.shared_data, "battery_last_update", 0.0)),
},
}
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
handler.send_header("Pragma", "no-cache")
handler.send_header("Expires", "0")
handler.end_headers()
handler.wfile.write(json.dumps(status_data).encode('utf-8'))
except BrokenPipeError:
pass
except Exception as e:
self.logger.error(f"Error in serve_bjorn_status: {str(e)}")
def check_manual_mode(self, handler):
"""Check if manual mode is enabled."""
try:
handler.send_response(200)
handler.send_header("Content-type", "text/plain")
handler.end_headers()
handler.wfile.write(str(self.shared_data.operation_mode).encode('utf-8'))
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError):
# Client closed the socket before response flush: normal with polling/XHR aborts.
return
except Exception as e:
self.logger.error(f"check_manual_mode failed: {e}")
def check_console_autostart(self, handler):
"""Check console autostart setting."""
try:
handler.send_response(200)
handler.send_header("Content-type", "text/plain")
handler.end_headers()
handler.wfile.write(str(self.shared_data.consoleonwebstart).encode('utf-8'))
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError):
# Client closed the socket before response flush: normal with polling/XHR aborts.
return
except Exception as e:
self.logger.error(f"check_console_autostart failed: {e}")
# ----------------------------------------------------------------
# EPD Layout API (EPD-01 / EPD-02)
# ----------------------------------------------------------------
def epd_get_layout(self, handler):
"""GET /api/epd/layout — return current layout JSON.
Optional query param: ?epd_type=epd2in7
If provided, returns the layout for that EPD type (custom or built-in)
without changing the active device layout.
"""
try:
from urllib.parse import parse_qs, urlparse
from display_layout import BUILTIN_LAYOUTS
query = parse_qs(urlparse(handler.path).query)
requested_type = query.get('epd_type', [''])[0]
layout = getattr(self.shared_data, 'display_layout', None)
if layout is None:
self._send_json(handler, {"status": "error", "message": "Layout engine not initialised"}, 503)
return
if requested_type and requested_type != self.shared_data.config.get('epd_type', ''):
# Return layout for the requested type without modifying active layout
custom_path = os.path.join(layout._custom_dir, f'{requested_type}.json')
if os.path.isfile(custom_path):
import json as _json
with open(custom_path, 'r') as f:
self._send_json(handler, _json.load(f))
return
# Fallback to built-in
base = requested_type.split('_')[0] if '_' in requested_type else requested_type
builtin = BUILTIN_LAYOUTS.get(requested_type) or BUILTIN_LAYOUTS.get(base)
if builtin:
self._send_json(handler, builtin)
return
self._send_json(handler, {"status": "error", "message": f"Unknown EPD type: {requested_type}"}, 404)
return
self._send_json(handler, layout.to_dict())
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def epd_save_layout(self, handler, data):
"""POST /api/epd/layout — save a custom layout."""
try:
layout = getattr(self.shared_data, 'display_layout', None)
if layout is None:
self._send_json(handler, {"status": "error", "message": "Layout engine not initialised"}, 503)
return
if not isinstance(data, dict) or 'meta' not in data or 'elements' not in data:
self._send_json(handler, {"status": "error", "message": "Invalid layout: must contain 'meta' and 'elements'"}, 400)
return
epd_type = data.get('meta', {}).get('epd_type') or None
layout.save_custom(data, epd_type=epd_type)
self._send_json(handler, {"status": "success", "message": "Layout saved"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def epd_reset_layout(self, handler, data):
"""POST /api/epd/layout/reset — reset to built-in default."""
try:
layout = getattr(self.shared_data, 'display_layout', None)
if layout is None:
self._send_json(handler, {"status": "error", "message": "Layout engine not initialised"}, 503)
return
epd_type = data.get('epd_type') if isinstance(data, dict) else None
layout.reset_to_default(epd_type=epd_type)
self._send_json(handler, {"status": "success", "message": "Layout reset to default"})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)
def epd_list_layouts(self, handler):
"""GET /api/epd/layouts — list available EPD types and their layouts."""
try:
from display_layout import BUILTIN_LAYOUTS
result = {}
for epd_type, layout_dict in BUILTIN_LAYOUTS.items():
result[epd_type] = {
"meta": layout_dict.get("meta", {}),
"builtin": True,
}
# Check for custom overrides
layout = getattr(self.shared_data, 'display_layout', None)
if layout:
custom_dir = layout._custom_dir
if os.path.isdir(custom_dir):
for fname in os.listdir(custom_dir):
if fname.endswith('.json'):
epd_name = fname[:-5]
try:
with open(os.path.join(custom_dir, fname), 'r') as f:
custom_data = json.load(f)
if epd_name in result:
result[epd_name]["has_custom"] = True
result[epd_name]["custom_meta"] = custom_data.get("meta", {})
else:
result[epd_name] = {
"meta": custom_data.get("meta", {}),
"builtin": False,
"has_custom": True,
}
except Exception:
pass
# Add current active type info
current_type = self.shared_data.config.get('epd_type', 'epd2in13_V4')
self._send_json(handler, {
"current_epd_type": current_type,
"layouts": result,
})
except Exception as e:
self._send_json(handler, {"status": "error", "message": str(e)}, 500)