|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
from typing import Optional
|
|
|
|
from devplacepy.services.base import BaseService
|
|
from devplacepy.services.manager import service_manager
|
|
from devplacepy.services.pubsub import publish as pubsub_publish
|
|
from devplacepy.services.pubsub.hub import pubsub
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SEGMENT = r"[A-Za-z0-9_-]+"
|
|
LOG_TAIL = 400
|
|
|
|
|
|
def _instance_project(inst: dict) -> dict:
|
|
from devplacepy.database import get_table
|
|
|
|
return get_table("projects").find_one(uid=inst["project_uid"]) or {}
|
|
|
|
|
|
def _instance_is_broadcastable(inst: dict) -> bool:
|
|
project = _instance_project(inst)
|
|
return bool(project) and not project.get("is_private")
|
|
|
|
|
|
async def _container_list(_match: re.Match) -> dict:
|
|
from devplacepy.routers.admin.containers import _decorate
|
|
from devplacepy.services.containers import store
|
|
|
|
return {"instances": _decorate(store.all_instances()), "partial": True}
|
|
|
|
|
|
async def _project_containers(match: re.Match) -> Optional[dict]:
|
|
from devplacepy.database import get_table, resolve_by_slug
|
|
from devplacepy.services.containers import store
|
|
|
|
project = resolve_by_slug(get_table("projects"), match.group("slug"))
|
|
if not project or project.get("is_private"):
|
|
return None
|
|
return {"instances": store.list_instances(project["uid"])}
|
|
|
|
|
|
async def _container_detail(match: re.Match) -> Optional[dict]:
|
|
from devplacepy.services.containers import api, store
|
|
|
|
uid = match.group("uid")
|
|
inst = store.get_instance(uid)
|
|
if not inst or not _instance_is_broadcastable(inst):
|
|
return None
|
|
return {
|
|
"instance": inst,
|
|
"events": store.list_events(uid),
|
|
"schedules": store.list_schedules(uid),
|
|
"stats": api.instance_stats(uid),
|
|
"runtime": api.instance_runtime(inst),
|
|
}
|
|
|
|
|
|
async def _container_logs(match: re.Match) -> Optional[dict]:
|
|
from devplacepy.services.containers import store
|
|
from devplacepy.services.containers.runtime import get_backend
|
|
|
|
uid = match.group("uid")
|
|
inst = store.get_instance(uid)
|
|
if not inst or not _instance_is_broadcastable(inst):
|
|
return None
|
|
if not inst.get("container_id"):
|
|
return {"logs": ""}
|
|
lines: list = []
|
|
|
|
async def collect(line: str) -> None:
|
|
lines.append(line)
|
|
|
|
await get_backend().logs(
|
|
inst["container_id"], follow=False, tail=LOG_TAIL, on_log=collect
|
|
)
|
|
return {"logs": "\n".join(lines)}
|
|
|
|
|
|
async def _bots(_match: re.Match) -> dict:
|
|
from devplacepy.routers.admin.bots import _frames_payload
|
|
|
|
return _frames_payload()
|
|
|
|
|
|
async def _services(_match: re.Match) -> dict:
|
|
return {"services": service_manager.describe_all()}
|
|
|
|
|
|
async def _service_detail(match: re.Match) -> Optional[dict]:
|
|
svc = service_manager.get_service(match.group("name"))
|
|
if svc is None:
|
|
return None
|
|
return {"service": svc.describe()}
|
|
|
|
|
|
async def _ai_usage(match: re.Match) -> dict:
|
|
from devplacepy.services.openai_gateway.analytics import build_analytics
|
|
from devplacepy.services.openai_gateway.usage import pricing_from_cfg
|
|
|
|
hours = int(match.group("hours"))
|
|
svc = service_manager.get_service("openai")
|
|
pricing = pricing_from_cfg(svc.get_config()) if svc is not None else None
|
|
return build_analytics(hours, top_n=10, pricing=pricing)
|
|
|
|
|
|
async def _backups(_match: re.Match) -> dict:
|
|
from devplacepy.routers.admin.backups import _dashboard
|
|
|
|
return _dashboard(can_download=False)
|
|
|
|
|
|
VIEWS = [
|
|
(re.compile(r"^container\.list$"), _container_list, 4.0),
|
|
(re.compile(rf"^project\.(?P<slug>{_SEGMENT})\.containers$"), _project_containers, 3.0),
|
|
(re.compile(rf"^container\.(?P<uid>{_SEGMENT})\.detail$"), _container_detail, 4.0),
|
|
(re.compile(rf"^container\.(?P<uid>{_SEGMENT})\.logs$"), _container_logs, 3.0),
|
|
(re.compile(r"^fleet\.bots$"), _bots, 2.0),
|
|
(re.compile(r"^admin\.services$"), _services, 5.0),
|
|
(re.compile(rf"^admin\.services\.(?P<name>{_SEGMENT})$"), _service_detail, 5.0),
|
|
(re.compile(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0),
|
|
(re.compile(r"^admin\.backups$"), _backups, 8.0),
|
|
]
|
|
|
|
|
|
class LiveViewRelayService(BaseService):
|
|
title = "Live view relay"
|
|
description = (
|
|
"Pushes admin live-view snapshots (container list and instances, bot fleet, "
|
|
"background services, AI usage, backups) onto the pub/sub bus, computed only for topics "
|
|
"that currently have subscribers. Runs on the service lock owner where pub/sub "
|
|
"subscribers converge, replacing per-client HTTP polling with server push."
|
|
)
|
|
default_enabled = True
|
|
|
|
def __init__(self):
|
|
super().__init__(name="live_view_relay", interval_seconds=1)
|
|
self._last: dict[str, float] = {}
|
|
|
|
def _handler_for(self, topic: str):
|
|
for pattern, compute, interval in VIEWS:
|
|
match = pattern.match(topic)
|
|
if match is not None:
|
|
return compute, interval, match
|
|
return None
|
|
|
|
async def run_once(self) -> None:
|
|
now = time.monotonic()
|
|
active: set[str] = set()
|
|
published = 0
|
|
for entry in pubsub.topics():
|
|
topic = entry["topic"]
|
|
if not entry["subscribers"] or "*" in topic:
|
|
continue
|
|
handler = self._handler_for(topic)
|
|
if handler is None:
|
|
continue
|
|
active.add(topic)
|
|
compute, interval, match = handler
|
|
if now - self._last.get(topic, 0.0) < interval:
|
|
continue
|
|
self._last[topic] = now
|
|
try:
|
|
payload = await compute(match)
|
|
except Exception:
|
|
logger.exception("live view relay failed for %s", topic)
|
|
continue
|
|
if payload is None:
|
|
continue
|
|
published += await pubsub_publish(topic, payload)
|
|
self._last = {topic: ts for topic, ts in self._last.items() if topic in active}
|
|
if published:
|
|
self.log(f"pushed {published} live-view frame(s)")
|
|
|
|
def collect_metrics(self) -> dict:
|
|
return {"tracked_topics": len(self._last)}
|