392 lines
14 KiB
Python
Raw Normal View History

import asyncio
import json
import logging
from abc import ABC, abstractmethod
from collections import deque
from datetime import datetime, timedelta, timezone
from devplacepy.database import db, get_table, get_setting, get_int_setting
logger = logging.getLogger(__name__)
class ConfigField:
def __init__(self, key, label, type="str", default="", help="",
options=None, secret=False, minimum=None, maximum=None, group="General"):
self.key = key
self.label = label
self.type = type
self.default = default
self.help = help
self.options = options or []
self.secret = secret
self.minimum = minimum
self.maximum = maximum
self.group = group
def coerce(self, raw):
if self.type == "int":
try:
value = int(str(raw).strip())
except (TypeError, ValueError):
raise ValueError(f"{self.label} must be a whole number")
if self.minimum is not None and value < self.minimum:
raise ValueError(f"{self.label} must be at least {self.minimum}")
if self.maximum is not None and value > self.maximum:
raise ValueError(f"{self.label} must be at most {self.maximum}")
return value
if self.type == "float":
try:
value = float(str(raw).strip())
except (TypeError, ValueError):
raise ValueError(f"{self.label} must be a number")
if self.minimum is not None and value < self.minimum:
raise ValueError(f"{self.label} must be at least {self.minimum}")
if self.maximum is not None and value > self.maximum:
raise ValueError(f"{self.label} must be at most {self.maximum}")
return value
if self.type == "bool":
text = str(raw).strip()
if text in ("0", "1"):
return text == "1"
raise ValueError(f"{self.label} must be enabled or disabled")
if self.type == "select":
allowed = [option["value"] for option in self.options]
if raw not in allowed:
raise ValueError(f"{self.label} has an invalid selection")
return raw
if self.type == "url":
text = str(raw).strip()
if text and not (text.startswith("http://") or text.startswith("https://")):
raise ValueError(f"{self.label} must be a http(s) URL")
return text
return str(raw)
def to_storage(self, value):
if isinstance(value, bool):
return "1" if value else "0"
return str(value)
def read(self):
raw = get_setting(self.key, "")
if raw == "":
return self.default
try:
return self.coerce(raw)
except ValueError:
return self.default
def display_value(self):
if self.secret:
return ""
raw = get_setting(self.key, "")
if raw == "":
return self.to_storage(self.default)
return raw
def spec(self):
return {
"key": self.key,
"label": self.label,
"type": self.type,
"help": self.help,
"options": self.options,
"secret": self.secret,
"minimum": self.minimum,
"maximum": self.maximum,
"value": self.display_value(),
"is_set": bool(get_setting(self.key, "")) if self.secret else None,
"group": self.group,
}
class BaseService(ABC):
config_fields = []
interval_key = None
min_interval = 1
default_enabled = True
title = ""
description = ""
DEFAULT_LOG_SIZE = 20
TICK_SECONDS = 1
PERSIST_SECONDS = 3
STALE_SECONDS = 15
def __init__(self, name: str, interval_seconds: int = 3600):
self.name = name
self.interval_seconds = interval_seconds
self.interval_key = self.interval_key or f"service_{name}_interval"
self.enabled_key = f"service_{name}_enabled"
self.command_key = f"service_{name}_command"
self.log_size_key = f"service_{name}_log_size"
self.log_buffer = deque(maxlen=self.DEFAULT_LOG_SIZE)
self._task = None
self._running = False
self._shutdown = False
self._started_at = None
self._last_run = None
self._next_run = None
self._next_due = None
self._run_pending = False
self._last_command = None
self._last_persist = None
self.enabled_field = ConfigField(
self.enabled_key, "Enabled", type="bool", default=self.default_enabled,
help="When enabled the service runs on its interval and starts on boot.",
group="General")
self.interval_field = ConfigField(
self.interval_key, "Run interval (seconds)", type="int",
default=interval_seconds, minimum=self.min_interval,
help=f"Delay between runs. Minimum {self.min_interval} seconds.",
group="General")
self.log_size_field = ConfigField(
self.log_size_key, "Log buffer size", type="int",
default=self.DEFAULT_LOG_SIZE, minimum=1, maximum=200,
help="Number of recent log lines to keep.",
group="Advanced")
def all_fields(self) -> list:
return [self.enabled_field, self.interval_field, *self.config_fields, self.log_size_field]
def _grouped_fields(self) -> list:
groups: dict = {}
order: list = []
for field in self.all_fields():
spec = field.spec()
group = spec["group"]
if group not in groups:
groups[group] = []
order.append(group)
groups[group].append(spec)
return [{"name": name, "fields": groups[name]} for name in order]
@property
def status(self) -> str:
return "running" if self._running else "stopped"
def is_enabled(self) -> bool:
return get_setting(self.enabled_key, "1" if self.default_enabled else "0") == "1"
def current_interval(self) -> int:
return max(self.min_interval, get_int_setting(self.interval_key, self.interval_seconds))
def get_config(self) -> dict:
return {field.key: field.read() for field in self.all_fields()}
def log(self, message: str) -> None:
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.log_buffer.append(f"[{stamp}] {message}")
logger.info(f"[{self.name}] {message}")
@abstractmethod
async def run_once(self) -> None:
pass
async def on_enable(self) -> None:
pass
async def on_disable(self) -> None:
pass
def collect_metrics(self) -> dict:
return {}
def _safe_metrics(self) -> dict:
try:
return self.collect_metrics()
except Exception as e:
logger.warning(f"Could not collect metrics for {self.name}: {e}")
return {}
def start_supervisor(self) -> None:
if self._task is not None:
return
self._shutdown = False
self._task = asyncio.create_task(self._run_loop())
def request_shutdown(self) -> None:
self._shutdown = True
async def _run_loop(self) -> None:
self._last_command = get_setting(self.command_key, "")
self.log("Supervisor started")
self._persist_state(force=True)
while not self._shutdown:
try:
await self._tick()
except asyncio.CancelledError:
break
except Exception as e:
self.log(f"Loop error: {e}")
try:
await asyncio.sleep(self.TICK_SECONDS)
except asyncio.CancelledError:
break
if self._started_at is not None:
await self._safe_disable()
self._running = False
self._started_at = None
self._persist_state(force=True)
self.log("Supervisor stopped")
async def _tick(self) -> None:
self._sync_log_size()
self._handle_commands()
if self.is_enabled():
if self._started_at is None:
self._started_at = datetime.now(timezone.utc)
self._running = True
self.log("Service enabled")
await self.on_enable()
due = self._next_due is None or datetime.now(timezone.utc) >= self._next_due
if self._run_pending or due:
self._run_pending = False
await self._execute_run()
elif self._started_at is not None:
await self._safe_disable()
self._started_at = None
self._running = False
self._next_due = None
self._next_run = None
self.log("Service disabled")
self._persist_state()
async def _safe_disable(self) -> None:
try:
await self.on_disable()
except Exception as e:
self.log(f"on_disable error: {e}")
async def _execute_run(self) -> None:
self.interval_seconds = self.current_interval()
self._last_run = datetime.now(timezone.utc).isoformat()
self._next_run = None
self._persist_state(force=True)
try:
await self.run_once()
except asyncio.CancelledError:
raise
except Exception as e:
self.log(f"Error in run_once: {e}")
self.interval_seconds = self.current_interval()
self._next_due = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds)
self._next_run = self._next_due.isoformat()
self.log(f"Next run in {self.interval_seconds}s")
self._persist_state(force=True)
def _handle_commands(self) -> None:
raw = get_setting(self.command_key, "")
if not raw or raw == self._last_command:
return
self._last_command = raw
verb = raw.split(":", 1)[0]
if verb == "run":
self._run_pending = True
self.log("Run requested")
elif verb == "clear":
self.log_buffer.clear()
self.log("Logs cleared")
def _sync_log_size(self) -> None:
size = max(1, get_int_setting(self.log_size_key, self.DEFAULT_LOG_SIZE))
if size != self.log_buffer.maxlen:
self.log_buffer = deque(self.log_buffer, maxlen=size)
def _persist_state(self, force: bool = False) -> None:
now = datetime.now(timezone.utc)
if not force and self._last_persist is not None:
if (now - self._last_persist).total_seconds() < self.PERSIST_SECONDS:
return
self._last_persist = now
record = {
"name": self.name,
"status": self.status,
"last_run": self._last_run or "",
"next_run": self._next_run or "",
"started_at": self._started_at.isoformat() if self._started_at else "",
"heartbeat": now.isoformat(),
"logs": json.dumps(list(self.log_buffer)),
"metrics": json.dumps(self._safe_metrics()),
"updated_at": now.isoformat(),
}
try:
table = get_table("service_state")
existing = table.find_one(name=self.name)
if existing:
table.update({**record, "id": existing["id"]}, ["id"])
else:
table.insert(record)
except Exception as e:
logger.warning(f"Could not persist state for {self.name}: {e}")
def _read_state(self) -> dict:
if "service_state" not in db.tables:
return {}
row = db["service_state"].find_one(name=self.name)
return row or {}
def _display_status(self, enabled: bool, state: dict) -> str:
if not enabled:
return "stopped"
heartbeat = state.get("heartbeat")
if not heartbeat:
return "stalled"
try:
beat = datetime.fromisoformat(heartbeat)
except ValueError:
return "stalled"
if (datetime.now(timezone.utc) - beat).total_seconds() > self.STALE_SECONDS:
return "stalled"
return "running"
def _uptime(self, state: dict, status: str) -> str | None:
if status != "running":
return None
started = state.get("started_at")
if not started:
return None
try:
since = datetime.fromisoformat(started)
except ValueError:
return None
return str(datetime.now(timezone.utc) - since).split(".")[0]
def _logs(self, state: dict) -> list:
raw = state.get("logs")
if not raw:
return []
try:
return json.loads(raw)
except (ValueError, TypeError):
return []
def _metrics(self, state: dict) -> dict:
raw = state.get("metrics")
if not raw:
return {}
try:
return json.loads(raw)
except (ValueError, TypeError):
return {}
def describe(self) -> dict:
state = self._read_state()
enabled = self.is_enabled()
status = self._display_status(enabled, state)
return {
"name": self.name,
"title": self.title or self.name.capitalize(),
"description": self.description,
"enabled": enabled,
"status": status,
"interval_seconds": self.current_interval(),
"last_run": state.get("last_run") or None,
"next_run": state.get("next_run") or None,
"heartbeat": state.get("heartbeat") or None,
"uptime": self._uptime(state, status),
"log_buffer": self._logs(state),
"metrics": self._metrics(state),
"fields": [field.spec() for field in self.all_fields()],
"field_groups": self._grouped_fields(),
}