feat: rename to FabledScryer, multi-dashboard system, plugin management, branding
- Rename package fablednetmon → fabledscryer throughout - Multi-dashboard: ownership, per-user defaults, HTMX edit (add/remove/reorder) - Read-only share tokens scoped to individual dashboards - Dashboard edit is HTMX-driven (no page reloads) - Plugin management system: remote catalog, download/install, hot-reload, in-app restart - plugin_index.py: fetch/cache remote index.yaml; default URL → bvandeusen/fabledscryer-plugins - plugin_manager.py: download_and_install_plugin, hot_reload_plugin, restart_app - ZIP extraction handles GitHub archive formats (name-v1.0.0/, name-main/) - Settings split into tabbed sections: General, Notifications, Ansible, Plugins - Plugins tab: catalog browser (HTMX), install/activate/update/restart actions - UI/branding: dark palette (#07071a), crystal ball SVG logo, animated star field, Libertinus Serif applied to headings, nav, labels, and section titles - Widget registry (core/widgets.py) for dashboard plugin integration - UPS widget.html (dashboard card) and settings/_tabs.html include - Migrations 0005–0008: dashboards, is_default, ownership, share tokens - docs/plugins/: writing-a-plugin.md updated with publishing guide, index.yaml.example template for fabledscryer-plugins repo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from quart import Blueprint, current_app, render_template, request, redirect, url_for
|
||||
from sqlalchemy import select, func, case
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.users import UserRole
|
||||
from fabledscryer.models.hosts import Host
|
||||
from fabledscryer.models.monitors import PingResult
|
||||
from fabledscryer.core.settings import get_all_settings, set_setting, DEFAULTS
|
||||
from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE
|
||||
|
||||
ping_bp = Blueprint("ping", __name__, url_prefix="/ping")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PILL_COUNT = 30 # pills always show the last N pings regardless of range
|
||||
|
||||
|
||||
async def _last_n_pings(db, host_ids: list[str], n: int = PILL_COUNT) -> dict[str, list]:
|
||||
"""Return {host_id: [PingResult]} for last n pings per host, oldest first."""
|
||||
if not host_ids:
|
||||
return {}
|
||||
rn = func.row_number().over(
|
||||
partition_by=PingResult.host_id,
|
||||
order_by=PingResult.probed_at.desc(),
|
||||
).label("rn")
|
||||
subq = (
|
||||
select(PingResult.id, rn)
|
||||
.where(PingResult.host_id.in_(host_ids))
|
||||
.subquery()
|
||||
)
|
||||
pr = await db.execute(
|
||||
select(PingResult)
|
||||
.join(subq, PingResult.id == subq.c.id)
|
||||
.where(subq.c.rn <= n)
|
||||
.order_by(PingResult.host_id, PingResult.probed_at.asc())
|
||||
)
|
||||
result: dict[str, list] = {hid: [] for hid in host_ids}
|
||||
for row in pr.scalars():
|
||||
result[row.host_id].append(row)
|
||||
return result
|
||||
|
||||
|
||||
async def _uptime_pcts(db, host_ids: list[str], since) -> dict[str, float | None]:
|
||||
"""Return {host_id: uptime_%} for results since the given datetime."""
|
||||
if not host_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(
|
||||
PingResult.host_id,
|
||||
func.count().label("total"),
|
||||
func.sum(case((PingResult.status == "up", 1), else_=0)).label("up_count"),
|
||||
)
|
||||
.where(PingResult.host_id.in_(host_ids))
|
||||
.where(PingResult.probed_at >= since)
|
||||
.group_by(PingResult.host_id)
|
||||
)
|
||||
pcts: dict[str, float | None] = {hid: None for hid in host_ids}
|
||||
for row in result.all():
|
||||
if row.total and row.total > 0:
|
||||
pcts[row.host_id] = (row.up_count or 0) / row.total * 100.0
|
||||
return pcts
|
||||
|
||||
|
||||
async def _thresholds(db) -> tuple[int, int]:
|
||||
settings = await get_all_settings(db)
|
||||
good = int(settings.get("ping.threshold.good_ms", DEFAULTS["ping.threshold.good_ms"]))
|
||||
warn = int(settings.get("ping.threshold.warn_ms", DEFAULTS["ping.threshold.warn_ms"]))
|
||||
return good, warn
|
||||
|
||||
|
||||
@ping_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
good_ms, warn_ms = await _thresholds(db)
|
||||
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||
current_range = request.args.get("range", DEFAULT_RANGE)
|
||||
return await render_template(
|
||||
"ping/index.html",
|
||||
good_ms=good_ms,
|
||||
warn_ms=warn_ms,
|
||||
poll_interval=poll_interval,
|
||||
current_range=current_range,
|
||||
)
|
||||
|
||||
|
||||
@ping_bp.get("/rows")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rows():
|
||||
"""HTMX fragment — host rows with pills + uptime % for selected range."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
|
||||
)
|
||||
hosts = result.scalars().all()
|
||||
host_ids = [h.id for h in hosts]
|
||||
pings_by_host = await _last_n_pings(db, host_ids)
|
||||
uptime_pcts = await _uptime_pcts(db, host_ids, since)
|
||||
good_ms, warn_ms = await _thresholds(db)
|
||||
|
||||
return await render_template(
|
||||
"ping/rows.html",
|
||||
hosts=hosts,
|
||||
pings_by_host=pings_by_host,
|
||||
uptime_pcts=uptime_pcts,
|
||||
range_key=range_key,
|
||||
good_ms=good_ms,
|
||||
warn_ms=warn_ms,
|
||||
)
|
||||
|
||||
|
||||
@ping_bp.post("/settings")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_settings():
|
||||
form = await request.form
|
||||
try:
|
||||
good = max(1, int(form.get("good_ms", 50)))
|
||||
warn = max(good + 1, int(form.get("warn_ms", 200)))
|
||||
except (ValueError, TypeError):
|
||||
good, warn = 50, 200
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await set_setting(db, "ping.threshold.good_ms", good)
|
||||
await set_setting(db, "ping.threshold.warn_ms", warn)
|
||||
return redirect(url_for("ping.index"))
|
||||
Reference in New Issue
Block a user