feat: maintenance windows, reports, uptime widget, alert improvements

- Add maintenance window scheduling to suppress alerts during planned
  downtime (model, routes, templates)
- Add weekly email/webhook reports with host summary (core/reports.py,
  Settings → Reports tab)
- Add host uptime history widget with per-check sparkline view
- Expand alert rules: dynamic metric catalog for plugin sources,
  per-rule HTMX field loading, maintenance window awareness
- Register additional dashboard widgets (uptime, SNMP, UniFi, UPS)
- Add Settings → Reports tab configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 08:15:30 -04:00
parent 3c70ac56b3
commit ced1eebe1d
17 changed files with 1286 additions and 84 deletions
+17 -1
View File
@@ -49,7 +49,7 @@ def create_app(
if not testing: if not testing:
from .core.settings import ( from .core.settings import (
load_settings_sync, to_smtp_cfg, to_webhook_cfg, load_settings_sync, to_smtp_cfg, to_webhook_cfg,
to_ansible_cfg, to_plugins_cfg, to_ansible_cfg, to_plugins_cfg, to_oidc_cfg, to_ldap_cfg,
) )
_settings = load_settings_sync(app.config["DATABASE_URL"]) _settings = load_settings_sync(app.config["DATABASE_URL"])
app.config.update( app.config.update(
@@ -60,6 +60,8 @@ def create_app(
WEBHOOK=to_webhook_cfg(_settings), WEBHOOK=to_webhook_cfg(_settings),
ANSIBLE=to_ansible_cfg(_settings), ANSIBLE=to_ansible_cfg(_settings),
PLUGINS=to_plugins_cfg(_settings), PLUGINS=to_plugins_cfg(_settings),
OIDC=to_oidc_cfg(_settings),
LDAP=to_ldap_cfg(_settings),
) )
else: else:
app.config.update( app.config.update(
@@ -70,6 +72,8 @@ def create_app(
WEBHOOK={}, WEBHOOK={},
ANSIBLE={}, ANSIBLE={},
PLUGINS={}, PLUGINS={},
OIDC={"enabled": False},
LDAP={"enabled": False},
) )
# ── 5. Plugin migrations ─────────────────────────────────────────────────── # ── 5. Plugin migrations ───────────────────────────────────────────────────
@@ -95,6 +99,7 @@ def create_app(
from .alerts.routes import alerts_bp from .alerts.routes import alerts_bp
from .ansible.routes import ansible_bp from .ansible.routes import ansible_bp
from .settings.routes import settings_bp from .settings.routes import settings_bp
from .audit.routes import audit_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
@@ -104,6 +109,7 @@ def create_app(
app.register_blueprint(alerts_bp) app.register_blueprint(alerts_bp)
app.register_blueprint(ansible_bp) app.register_blueprint(ansible_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(audit_bp)
# ── 8. Build task registry ───────────────────────────────────────────────── # ── 8. Build task registry ─────────────────────────────────────────────────
app._task_registry = [] app._task_registry = []
@@ -199,7 +205,17 @@ def _register_core_tasks(app: Quart) -> None:
from .core.cleanup import run_cleanup as _cleanup from .core.cleanup import run_cleanup as _cleanup
await _cleanup(app) await _cleanup(app)
async def run_report_check():
from .core.reports import check_and_send
await check_and_send(app)
app._task_registry.extend([ app._task_registry.extend([
ScheduledTask(
name="weekly_report_check",
coro_factory=run_report_check,
interval_seconds=3600,
run_on_startup=False,
),
ScheduledTask( ScheduledTask(
name="ping_monitor", name="ping_monitor",
coro_factory=run_ping_monitors, coro_factory=run_ping_monitors,
+22 -1
View File
@@ -5,7 +5,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import select from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fabledscryer.models.alerts import ( from fabledscryer.models.alerts import (
@@ -14,6 +14,7 @@ from fabledscryer.models.alerts import (
AlertRule, AlertRule,
AlertState, AlertState,
AlertStateEnum, AlertStateEnum,
MaintenanceWindow,
) )
from fabledscryer.models.metrics import PluginMetric from fabledscryer.models.metrics import PluginMetric
@@ -60,6 +61,26 @@ async def record_metric(
session.add(metric) session.add(metric)
await session.flush() await session.flush()
# Check active maintenance windows — skip rule evaluation if suppressed
mw_result = await session.execute(
select(MaintenanceWindow).where(
MaintenanceWindow.start_at <= now,
MaintenanceWindow.end_at >= now,
or_(
MaintenanceWindow.scope_source.is_(None),
and_(
MaintenanceWindow.scope_source == source_module,
or_(
MaintenanceWindow.scope_resource.is_(None),
MaintenanceWindow.scope_resource == resource_name,
),
),
),
).limit(1)
)
if mw_result.scalar_one_or_none() is not None:
return # suppressed by maintenance window
# Load matching enabled rules # Load matching enabled rules
result = await session.execute( result = await session.execute(
select(AlertRule).where( select(AlertRule).where(
+275
View File
@@ -0,0 +1,275 @@
"""fabledscryer/core/reports.py
Weekly digest report: uptime, active alerts, alert events, top metrics.
Called by the scheduler (hourly check) or triggered manually from Settings.
"""
from __future__ import annotations
import asyncio
import logging
import smtplib
import ssl
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from sqlalchemy import and_, case, func, select
from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertEvent
from fabledscryer.models.hosts import Host
from fabledscryer.models.metrics import PluginMetric
from fabledscryer.models.monitors import PingResult, PingStatus
logger = logging.getLogger(__name__)
_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
async def build_report_data(app) -> dict:
"""Collect all data for the weekly digest."""
now = datetime.now(timezone.utc)
cutoff_7d = now - timedelta(days=7)
cutoff_24h = now - timedelta(hours=24)
async with app.db_sessionmaker() as db:
# ── Uptime summary (7d) ───────────────────────────────────────────────
host_result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = host_result.scalars().all()
uptime_rows = await db.execute(
select(
PingResult.host_id,
func.count(PingResult.id).label("total"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up"),
)
.where(PingResult.probed_at >= cutoff_7d)
.group_by(PingResult.host_id)
)
uptime_map: dict[str, float | None] = {}
for row in uptime_rows:
pct = round(float(row.up) / float(row.total) * 100, 2) if row.total else None
uptime_map[row.host_id] = pct
uptime_summary = []
for h in hosts:
uptime_summary.append({
"name": h.name,
"pct_7d": uptime_map.get(h.id),
})
# ── Active alerts ─────────────────────────────────────────────────────
active_result = await db.execute(
select(AlertState, AlertRule)
.join(AlertRule, AlertState.rule_id == AlertRule.id)
.where(AlertState.state.in_([
AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending,
]))
.order_by(AlertState.last_transition_at.desc())
)
active_alerts = [
{
"state": state.state.value.upper(),
"name": rule.name,
"resource": rule.resource_name,
"metric": rule.metric_name,
"operator": rule.operator.value,
"threshold": rule.threshold,
}
for state, rule in active_result.all()
]
# ── Alert event counts (7d) ───────────────────────────────────────────
events_result = await db.execute(
select(
func.count(case((AlertEvent.to_state == "firing", 1), else_=None)).label("fired"),
func.count(case((AlertEvent.to_state == "resolved", 1), else_=None)).label("resolved"),
)
.where(AlertEvent.transitioned_at >= cutoff_7d)
)
event_row = events_result.one()
alert_events = {
"fired": int(event_row.fired or 0),
"resolved": int(event_row.resolved or 0),
}
# ── Top Traefik routers by avg request_rate (last 24h) ────────────────
top_routers_result = await db.execute(
select(
PluginMetric.resource_name,
func.avg(PluginMetric.value).label("avg_rate"),
)
.where(
and_(
PluginMetric.source_module == "traefik",
PluginMetric.metric_name == "request_rate",
PluginMetric.recorded_at >= cutoff_24h,
)
)
.group_by(PluginMetric.resource_name)
.order_by(func.avg(PluginMetric.value).desc())
.limit(10)
)
top_routers = [
{"name": row.resource_name, "avg_rate": float(row.avg_rate)}
for row in top_routers_result
]
# ── Hosts currently down ──────────────────────────────────────────────
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id)
.subquery()
)
down_result = await db.execute(
select(Host.name)
.join(PingResult, PingResult.host_id == Host.id)
.join(
latest_ping_subq,
and_(
PingResult.host_id == latest_ping_subq.c.host_id,
PingResult.probed_at == latest_ping_subq.c.max_at,
),
)
.where(
Host.ping_enabled.is_(True),
PingResult.status == PingStatus.down,
)
.order_by(Host.name)
)
hosts_down = [row.name for row in down_result]
return {
"generated_at": now,
"uptime_summary": uptime_summary,
"active_alerts": active_alerts,
"alert_events": alert_events,
"top_routers": top_routers,
"hosts_down": hosts_down,
}
def _render_report_text(data: dict) -> str:
now = data["generated_at"]
lines: list[str] = []
lines.append("Fabled Scryer — Weekly Report")
lines.append(f"Generated: {now.strftime('%Y-%m-%d %H:%M UTC')}")
lines.append("")
# ── Hosts currently down ──────────────────────────────────────────────────
down = data["hosts_down"]
if down:
lines.append(f"{len(down)} host(s) currently DOWN: {', '.join(down)}")
lines.append("")
# ── Uptime summary ────────────────────────────────────────────────────────
lines.append("UPTIME SUMMARY (7-day)")
lines.append("" * 40)
for entry in data["uptime_summary"]:
pct = entry["pct_7d"]
pct_str = f"{pct:.2f}%" if pct is not None else "no data"
lines.append(f" {entry['name']:<32} {pct_str}")
if not data["uptime_summary"]:
lines.append(" (no ping-enabled hosts)")
lines.append("")
# ── Active alerts ─────────────────────────────────────────────────────────
alerts = data["active_alerts"]
lines.append(f"ACTIVE ALERTS ({len(alerts)})")
lines.append("" * 40)
if alerts:
for a in alerts:
lines.append(
f" [{a['state']}] {a['name']}{a['resource']}:"
f"{a['metric']} {a['operator']} {a['threshold']}"
)
else:
lines.append(" None — all clear.")
lines.append("")
# ── Alert events this week ────────────────────────────────────────────────
ev = data["alert_events"]
lines.append("ALERT EVENTS THIS WEEK")
lines.append("" * 40)
lines.append(f" Fired: {ev['fired']}")
lines.append(f" Resolved: {ev['resolved']}")
lines.append("")
# ── Top Traefik routers ───────────────────────────────────────────────────
if data["top_routers"]:
lines.append("TOP TRAEFIK ROUTERS (avg req/s, last 24h)")
lines.append("" * 40)
for r in data["top_routers"]:
lines.append(f" {r['name']:<40} {r['avg_rate']:.2f} req/s")
lines.append("")
lines.append("" * 40)
lines.append("Fabled Scryer — https://git.fabledsword.com/bvandeusen/FabledScryer")
return "\n".join(lines)
async def send_report(app) -> tuple[bool, str]:
"""Build and email the weekly digest. Returns (success, message)."""
smtp_cfg = app.config.get("SMTP", {})
if not smtp_cfg.get("host") or not smtp_cfg.get("recipients"):
return False, "SMTP not configured — set host and recipients in Settings → Notifications."
try:
data = await build_report_data(app)
except Exception as exc:
logger.exception("Failed to build report data")
return False, f"Data collection failed: {exc}"
body = _render_report_text(data)
date_str = data["generated_at"].strftime("%Y-%m-%d")
subject = f"[Fabled Scryer] Weekly Report — {date_str}"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = smtp_cfg.get("username", "fabledscryer@localhost")
msg["To"] = ", ".join(smtp_cfg["recipients"])
msg.set_content(body)
try:
loop = asyncio.get_running_loop()
from fabledscryer.core.notifications import _smtp_send_sync
await loop.run_in_executor(None, _smtp_send_sync, smtp_cfg, msg)
logger.info("Weekly report sent to %s", smtp_cfg["recipients"])
return True, f"Report sent to {', '.join(smtp_cfg['recipients'])}."
except Exception as exc:
logger.error("Failed to send weekly report: %s", exc)
return False, f"Send failed: {exc}"
async def check_and_send(app) -> None:
"""Hourly scheduler hook: send if day/hour match and not sent recently."""
from fabledscryer.core.settings import get_setting, set_setting
async with app.db_sessionmaker() as db:
enabled = await get_setting(db, "reports.enabled")
if not enabled:
return
schedule_day = int(await get_setting(db, "reports.schedule_day") or 6)
schedule_hour = int(await get_setting(db, "reports.schedule_hour") or 8)
last_sent_str = await get_setting(db, "reports.last_sent_at") or ""
now = datetime.now(timezone.utc)
if now.weekday() != schedule_day or now.hour != schedule_hour:
return
if last_sent_str:
try:
last_sent = datetime.fromisoformat(last_sent_str)
if (now - last_sent).total_seconds() < 23 * 3600:
return # already sent this window
except ValueError:
pass
ok, msg = await send_report(app)
if ok:
async with app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "reports.last_sent_at", now.isoformat())
else:
logger.warning("Weekly report send failed: %s", msg)
+244 -22
View File
@@ -1,14 +1,14 @@
# fabledscryer/core/widgets.py # fabledscryer/core/widgets.py
# #
# Central widget registry. Each entry describes one widget type that can be # Central widget registry. Each entry describes one widget type that can be
# placed on a dashboard. Plugin widgets declare which plugin must be enabled # placed on a dashboard. Plugin widgets declare which plugin must be enabled.
# for the widget to be available.
# #
# Forward-compatibility notes: # "params" is a list of configurable parameters for the widget. Each param:
# - Step 8 (widget variants) will add multiple entries per plugin and a # key - form field name (stored in config_json)
# "variant" sub-key rather than one entry per plugin. # label - human-readable label
# - Step 9 (plugin management) will allow external plugins to register # type - "select" (only type currently supported)
# entries here at load time via register_widget(). # default - default value (int or str determines how it's parsed from the form)
# options - list of {value, label} dicts
from __future__ import annotations from __future__ import annotations
WIDGET_REGISTRY: dict[str, dict] = { WIDGET_REGISTRY: dict[str, dict] = {
@@ -18,8 +18,19 @@ WIDGET_REGISTRY: dict[str, dict] = {
"description": "Live ping status and latency history for all monitored hosts", "description": "Live ping status and latency history for all monitored hosts",
"hx_url": "/ping/rows", "hx_url": "/ping/rows",
"detail_url": "/ping/", "detail_url": "/ping/",
"plugin": None, # built-in; always available "plugin": None,
"poll": True, "poll": True,
"params": [],
},
"uptime_summary": {
"key": "uptime_summary",
"label": "Uptime / SLA",
"description": "Per-host rolling uptime % for 24h, 7d, and 30d windows",
"hx_url": "/hosts/uptime/widget",
"detail_url": "/hosts/uptime",
"plugin": None,
"poll": False,
"params": [],
}, },
"dns": { "dns": {
"key": "dns", "key": "dns",
@@ -29,39 +40,250 @@ WIDGET_REGISTRY: dict[str, dict] = {
"detail_url": "/dns/", "detail_url": "/dns/",
"plugin": None, "plugin": None,
"poll": True, "poll": True,
"params": [],
}, },
"traefik": { "traefik_routers": {
"key": "traefik", "key": "traefik_routers",
"label": "Traefik", "label": "Traefik — Routers",
"description": "Proxy request rates, service health, and recent errors", "description": "Top routers by request rate with error highlights and expiring certs",
"hx_url": "/plugins/traefik/widget", "hx_url": "/plugins/traefik/widget",
"detail_url": "/plugins/traefik/", "detail_url": "/plugins/traefik/",
"plugin": "traefik", "plugin": "traefik",
"poll": True, "poll": True,
"params": [],
}, },
"unifi": { "traefik_access_log": {
"key": "unifi", "key": "traefik_access_log",
"label": "UniFi Network", "label": "Traefik — Access Log",
"description": "WAN status, client counts, active alarms", "description": "Traffic origin summary — top IPs, countries, and request distribution",
"hx_url": "/plugins/traefik/widget/access_log",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
"params": [
{
"key": "ip_filter",
"label": "IP Filter",
"type": "select",
"default": "all",
"options": [
{"value": "all", "label": "All traffic"},
{"value": "internal", "label": "Internal only"},
{"value": "external", "label": "External only"},
],
},
{
"key": "limit",
"label": "Top IPs shown",
"type": "select",
"default": 10,
"options": [
{"value": 10, "label": "10 entries"},
{"value": 20, "label": "20 entries"},
{"value": 50, "label": "50 entries"},
],
},
],
},
"traefik_request_chart": {
"key": "traefik_request_chart",
"label": "Traefik — Request Chart",
"description": "Request rate and error percentage over time (Chart.js)",
"hx_url": "/plugins/traefik/widget/request_chart",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
"params": [
{
"key": "hours",
"label": "Time range",
"type": "select",
"default": 6,
"options": [
{"value": 1, "label": "Last 1 hour"},
{"value": 6, "label": "Last 6 hours"},
{"value": 24, "label": "Last 24 hours"},
],
},
],
},
"unifi_overview": {
"key": "unifi_overview",
"label": "UniFi — Overview",
"description": "WAN status, client counts, offline devices, and active alarms",
"hx_url": "/plugins/unifi/widget", "hx_url": "/plugins/unifi/widget",
"detail_url": "/plugins/unifi/", "detail_url": "/plugins/unifi/",
"plugin": "unifi", "plugin": "unifi",
"poll": True, "poll": True,
"params": [],
}, },
"ups": { "unifi_clients": {
"key": "ups", "key": "unifi_clients",
"label": "UPS", "label": "UniFi — Top Clients",
"description": "Battery charge, runtime estimate, load percentage", "description": "Active wireless and wired clients sorted by bandwidth",
"hx_url": "/plugins/unifi/widget/clients",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
"params": [
{
"key": "limit",
"label": "Clients shown",
"type": "select",
"default": 5,
"options": [
{"value": 5, "label": "5 clients"},
{"value": 10, "label": "10 clients"},
{"value": 20, "label": "20 clients"},
],
},
],
},
"unifi_devices": {
"key": "unifi_devices",
"label": "UniFi — Devices",
"description": "Network infrastructure devices with online/offline state",
"hx_url": "/plugins/unifi/widget/devices",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
"params": [
{
"key": "type_filter",
"label": "Device type",
"type": "select",
"default": "all",
"options": [
{"value": "all", "label": "All devices"},
{"value": "wireless", "label": "Wireless APs"},
{"value": "wired", "label": "Wired only"},
{"value": "offline", "label": "Offline devices"},
],
},
],
},
"ups_status": {
"key": "ups_status",
"label": "UPS — Status",
"description": "Battery charge, runtime estimate, load percentage, and on-battery alerts",
"hx_url": "/plugins/ups/widget", "hx_url": "/plugins/ups/widget",
"detail_url": "/plugins/ups/", "detail_url": "/plugins/ups/",
"plugin": "ups", "plugin": "ups",
"poll": True, "poll": True,
"params": [],
},
"ups_history": {
"key": "ups_history",
"label": "UPS — History Chart",
"description": "Battery %, load %, and input voltage over time (Chart.js)",
"hx_url": "/plugins/ups/widget/history",
"detail_url": "/plugins/ups/",
"plugin": "ups",
"poll": True,
"params": [
{
"key": "hours",
"label": "Time range",
"type": "select",
"default": 6,
"options": [
{"value": 1, "label": "Last 1 hour"},
{"value": 6, "label": "Last 6 hours"},
{"value": 24, "label": "Last 24 hours"},
],
},
],
},
"docker_containers": {
"key": "docker_containers",
"label": "Docker — Containers",
"description": "Container status overview — running/stopped counts and per-container CPU",
"hx_url": "/plugins/docker/widget",
"detail_url": "/plugins/docker/",
"plugin": "docker",
"poll": True,
"params": [
{
"key": "show_stopped",
"label": "Show stopped",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "Running only"},
{"value": "yes", "label": "All containers"},
],
},
],
},
"http_monitors": {
"key": "http_monitors",
"label": "HTTP Monitors",
"description": "Synthetic endpoint checks — up/down status and response times",
"hx_url": "/plugins/http/widget",
"detail_url": "/plugins/http/",
"plugin": "http",
"poll": True,
"params": [
{
"key": "show_down_only",
"label": "Display filter",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "All monitors"},
{"value": "yes", "label": "Failing only"},
],
},
{
"key": "limit",
"label": "Max shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 monitors"},
{"value": 10, "label": "10 monitors"},
{"value": 20, "label": "20 monitors"},
],
},
],
},
"docker_resources": {
"key": "docker_resources",
"label": "Docker — Resources",
"description": "CPU and memory usage bars for running containers",
"hx_url": "/plugins/docker/widget/resources",
"detail_url": "/plugins/docker/",
"plugin": "docker",
"poll": True,
"params": [
{
"key": "limit",
"label": "Containers shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 containers"},
{"value": 10, "label": "10 containers"},
{"value": 20, "label": "20 containers"},
],
},
],
},
"snmp_devices": {
"key": "snmp_devices",
"label": "SNMP — Devices",
"description": "Latest OID readings for all configured SNMP devices",
"hx_url": "/plugins/snmp/widget",
"detail_url": "/plugins/snmp/",
"plugin": "snmp",
"poll": True,
"params": [],
}, },
} }
def register_widget(definition: dict) -> None: def register_widget(definition: dict) -> None:
"""Register a widget at runtime (used by external plugins in step 9).""" """Register a widget at runtime (used by external plugins)."""
WIDGET_REGISTRY[definition["key"]] = definition WIDGET_REGISTRY[definition["key"]] = definition
+89 -3
View File
@@ -1,14 +1,62 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, render_template, request, redirect, url_for, current_app from datetime import datetime, timedelta, timezone
from sqlalchemy import select, func from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from sqlalchemy import and_, case, select, func
from fabledscryer.auth.middleware import require_role from fabledscryer.auth.middleware import require_role
from fabledscryer.core.audit import log_audit
from fabledscryer.models.hosts import Host, ProbeType from fabledscryer.models.hosts import Host, ProbeType
from fabledscryer.models.monitors import PingResult, DnsResult from fabledscryer.models.monitors import PingResult, DnsResult, PingStatus
from fabledscryer.models.users import UserRole from fabledscryer.models.users import UserRole
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts") hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
async def _compute_uptime(db) -> dict[str, dict]:
"""Return per-host uptime % for 24h, 7d, 30d windows.
Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}.
"""
now = datetime.now(timezone.utc)
cutoff_30d = now - timedelta(days=30)
cutoff_7d = now - timedelta(days=7)
cutoff_24h = now - timedelta(hours=24)
result = await db.execute(
select(
PingResult.host_id,
# 30d window — all rows in query qualify
func.count(PingResult.id).label("total_30d"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up_30d"),
# 7d sub-window
func.sum(case((PingResult.probed_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_7d, PingResult.status == PingStatus.up), 1),
else_=0,
)).label("up_7d"),
# 24h sub-window
func.sum(case((PingResult.probed_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_24h, PingResult.status == PingStatus.up), 1),
else_=0,
)).label("up_24h"),
)
.where(PingResult.probed_at >= cutoff_30d)
.group_by(PingResult.host_id)
)
def _pct(up, total):
return round(float(up) / float(total) * 100, 2) if total else None
stats: dict[str, dict] = {}
for row in result:
stats[row.host_id] = {
"24h": _pct(row.up_24h, row.total_24h),
"7d": _pct(row.up_7d, row.total_7d),
"30d": _pct(row.up_30d, row.total_30d),
}
return stats
@hosts_bp.get("/") @hosts_bp.get("/")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def list_hosts(): async def list_hosts():
@@ -45,12 +93,14 @@ async def list_hosts():
) )
) )
latest_dns = {r.host_id: r for r in dr.scalars()} latest_dns = {r.host_id: r for r in dr.scalars()}
uptime = await _compute_uptime(db)
return await render_template( return await render_template(
"hosts/list.html", "hosts/list.html",
hosts=hosts, hosts=hosts,
latest_pings=latest_pings, latest_pings=latest_pings,
latest_dns=latest_dns, latest_dns=latest_dns,
uptime=uptime,
) )
@@ -77,6 +127,9 @@ async def create_host():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
db.add(host) db.add(host)
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.created", entity_type="host", entity_id=host.name,
detail={"address": host.address})
return redirect(url_for("hosts.list_hosts")) return redirect(url_for("hosts.list_hosts"))
@@ -115,10 +168,43 @@ async def update_host(host_id: str):
@hosts_bp.post("/<host_id>/delete") @hosts_bp.post("/<host_id>/delete")
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def delete_host(host_id: str): async def delete_host(host_id: str):
host_name = None
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
result = await db.execute(select(Host).where(Host.id == host_id)) result = await db.execute(select(Host).where(Host.id == host_id))
host = result.scalar_one_or_none() host = result.scalar_one_or_none()
if host: if host:
host_name = host.name
await db.delete(host) await db.delete(host)
if host_name:
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.deleted", entity_type="host", entity_id=host_name)
return redirect(url_for("hosts.list_hosts")) return redirect(url_for("hosts.list_hosts"))
@hosts_bp.get("/uptime")
@require_role(UserRole.viewer)
async def uptime_page():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await _compute_uptime(db)
return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime)
@hosts_bp.get("/uptime/widget")
@require_role(UserRole.viewer)
async def uptime_widget():
share_token = request.args.get("s")
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()
uptime = await _compute_uptime(db)
return await render_template(
"hosts/uptime_widget.html",
hosts=hosts,
uptime=uptime,
share_token=share_token,
)
+18
View File
@@ -62,6 +62,24 @@ class AlertState(Base):
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class MaintenanceWindow(Base):
__tablename__ = "maintenance_windows"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Scope — null means "all". scope_resource requires scope_source to be set.
scope_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
scope_resource: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_by: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
class AlertEvent(Base): class AlertEvent(Base):
__tablename__ = "alert_events" __tablename__ = "alert_events"
+3 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -51,3 +51,5 @@ class DashboardWidget(Base):
) )
widget_key: Mapped[str] = mapped_column(String(64), nullable=False) widget_key: Mapped[str] = mapped_column(String(64), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
config_json: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,29 @@
{# alerts/_rule_fields.html — HTMX partial: resource + metric selects for a given source_module #}
<div class="form-group">
<label>Resource Name</label>
{% if resources %}
<select name="resource_name" required>
{% if not current_resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
{% for r in resources %}
<option value="{{ r }}" {% if r == current_resource %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
{% else %}
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
</select>
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Resource names populate from live metric data. Start the scheduler and try again.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric Name</label>
<select name="metric_name" required>
{% if not current_metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" {% if m == current_metric %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
+23 -11
View File
@@ -49,7 +49,10 @@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<h2 class="section-title">Rules</h2> <h2 class="section-title">Rules</h2>
<a class="btn" href="/alerts/rules/new">New Rule</a> <div style="display:flex;gap:0.5rem;">
<a class="btn btn-ghost" href="/alerts/maintenance">Maintenance</a>
<a class="btn" href="/alerts/rules/new">New Rule</a>
</div>
</div> </div>
{% if rules %} {% if rules %}
<div class="card-flush"> <div class="card-flush">
@@ -58,24 +61,33 @@
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Condition</th> <th>Condition</th>
<th>Consec.</th> <th style="text-align:center;">Consec.</th>
<th>Enabled</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for rule in rules %} {% for rule in rules %}
<tr> <tr {% if not rule.enabled %}style="opacity:0.55;"{% endif %}>
<td>{{ rule.name }}</td>
<td style="color:var(--text-muted);">
{{ rule.source_module }}/{{ rule.resource_name }}:{{ rule.metric_name }}
{{ rule.operator.value }} {{ rule.threshold }}
</td>
<td style="color:var(--text-muted);">{{ rule.consecutive_failures_required }}</td>
<td> <td>
{% if rule.enabled %}<span class="badge badge-green">yes</span>{% else %}<span class="badge badge-dim">no</span>{% endif %} {{ rule.name }}
{% if not rule.enabled %}
<span style="font-size:0.72rem;color:var(--text-dim);margin-left:0.35rem;">disabled</span>
{% endif %}
</td> </td>
<td style="color:var(--text-muted);font-size:0.85rem;">
<span style="color:var(--text-dim);">{{ rule.source_module }}/</span>{{ rule.resource_name }}
<span style="font-family:ui-monospace,monospace;font-size:0.8rem;">
:{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}
</span>
</td>
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }}</td>
<td class="td-actions"> <td class="td-actions">
<a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">
<button type="submit" class="btn btn-sm btn-ghost">
{% if rule.enabled %}Disable{% else %}Enable{% endif %}
</button>
</form>
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;"> <form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button> onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Maintenance Windows — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Maintenance Windows</h1>
<div style="display:flex;gap:0.5rem;">
<a class="btn btn-ghost" href="/alerts/">← Alerts</a>
<a class="btn" href="/alerts/maintenance/new">New Window</a>
</div>
</div>
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.5rem;">
During an active window, alert rule evaluation is suppressed for the matching scope.
Metrics are still recorded — only notifications are silenced.
</p>
{% if windows %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Scope</th>
<th>Start</th>
<th>End</th>
<th style="text-align:center;">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for w in windows %}
{% set is_active = w.start_at <= now and w.end_at >= now %}
{% set is_upcoming = w.start_at > now %}
<tr>
<td style="font-weight:500;">{{ w.name }}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">
{% if w.scope_source is none %}
<span title="All alert rules">All alerts</span>
{% elif w.scope_resource is none %}
<span>{{ w.scope_source }}/*</span>
{% else %}
<span>{{ w.scope_source }}/{{ w.scope_resource }}</span>
{% endif %}
</td>
<td style="font-size:0.85rem;font-family:ui-monospace,monospace;">
{{ w.start_at.strftime("%Y-%m-%d %H:%M") }} UTC
</td>
<td style="font-size:0.85rem;font-family:ui-monospace,monospace;">
{{ w.end_at.strftime("%Y-%m-%d %H:%M") }} UTC
</td>
<td style="text-align:center;">
{% if is_active %}
<span class="badge badge-yellow">active</span>
{% elif is_upcoming %}
<span class="badge badge-dim">upcoming</span>
{% else %}
<span style="color:var(--text-dim);font-size:0.8rem;">expired</span>
{% endif %}
</td>
<td class="td-actions">
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete window &quot;{{ w.name }}&quot;?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<p style="color:var(--text-muted);">No maintenance windows configured.</p>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}New Maintenance Window — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">New Maintenance Window</h1>
<div class="card">
<form method="post" action="/alerts/maintenance">
<div class="form-group">
<label>Window Name</label>
<input type="text" name="name" required autofocus
placeholder="e.g. Weekly reboot — homelab">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Start (UTC)</label>
<input type="datetime-local" name="start_at" required>
</div>
<div class="form-group">
<label>End (UTC)</label>
<input type="datetime-local" name="end_at" required>
</div>
</div>
{# ── Scope ─────────────────────────────────────────────────────────────── #}
<div class="form-group">
<label>Scope</label>
<select name="_scope_type" id="scope-type" onchange="updateScope()">
<option value="all">All alerts</option>
<option value="source">By source module</option>
<option value="resource">By source + resource</option>
</select>
</div>
<div id="scope-source-row" style="display:none;" class="form-group">
<label>Source Module</label>
<select name="scope_source" id="scope-source">
<option value="">— select —</option>
{% for src in source_modules %}
<option value="{{ src }}">{{ src }}</option>
{% endfor %}
</select>
</div>
<div id="scope-resource-row" style="display:none;" class="form-group">
<label>Resource Name</label>
<input type="text" name="scope_resource" id="scope-resource"
placeholder="Exact resource name (e.g. my-server)">
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Leave blank to suppress all resources within the selected source.
</p>
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">Create Window</button>
<a href="/alerts/maintenance" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
<div class="card" style="margin-top:1rem;">
<div class="section-title" style="margin-bottom:0.5rem;">How scope works</div>
<div style="display:grid;gap:0.4rem;font-size:0.82rem;color:var(--text-muted);">
<div><strong style="color:var(--text);">All alerts</strong> — every alert rule is suppressed</div>
<div><strong style="color:var(--text);">By source</strong> — e.g. source=ping suppresses all ping alerts</div>
<div><strong style="color:var(--text);">By source + resource</strong> — e.g. ping/my-server suppresses only that host's ping alerts</div>
</div>
</div>
</div>
<script>
function updateScope() {
var t = document.getElementById('scope-type').value;
document.getElementById('scope-source-row').style.display = t === 'all' ? 'none' : '';
document.getElementById('scope-resource-row').style.display = t === 'resource' ? '' : 'none';
if (t === 'all') {
document.getElementById('scope-source').value = '';
document.getElementById('scope-resource').value = '';
}
if (t === 'source') {
document.getElementById('scope-resource').value = '';
}
}
</script>
{% endblock %}
+136 -44
View File
@@ -1,49 +1,141 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}New Alert Rule — Fabled Scryer{% endblock %} {% block title %}{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %} — Fabled Scryer{% endblock %}
{% block content %} {% block content %}
<div style="max-width:560px;margin:2rem auto;"> <div style="max-width:600px;margin:2rem auto;">
<h1 class="page-title">New Alert Rule</h1> <h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1>
<div class="card"> <div class="card">
<form method="post" action="/alerts/rules"> <form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">
<div class="form-group">
<label>Rule Name</label> <div class="form-group">
<input type="text" name="name" required autofocus placeholder="e.g. Home server down"> <label>Rule Name</label>
<input type="text" name="name" required autofocus
value="{{ rule.name if rule else '' }}"
placeholder="e.g. Home server down">
</div>
{# ── Source module ───────────────────────────────────────────────────── #}
<div class="form-group">
<label>Source</label>
<select name="source_module" required
hx-get="/alerts/api/rule_fields"
hx-trigger="change"
hx-target="#rule-fields"
hx-include="[name='source_module']">
{% for src in source_modules %}
<option value="{{ src }}" {% if src == initial_source %}selected{% endif %}>
{{ src }}
</option>
{% endfor %}
</select>
</div>
{# ── Dynamic resource + metric ────────────────────────────────────────── #}
<div id="rule-fields">
{# Inline the same partial logic so the page is fully usable without JS #}
<div class="form-group">
<label>Resource Name</label>
{% if resources %}
<select name="resource_name" required>
{% if not (rule and rule.resource_name) %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
{% for r in resources %}
<option value="{{ r }}" {% if rule and r == rule.resource_name %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
{% else %}
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
</select>
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Resource names populate from live metric data. Start the scheduler and try again.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric Name</label>
<select name="metric_name" required>
{% if not (rule and rule.metric_name) %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" {% if rule and m == rule.metric_name %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
</div>
{# ── Condition ───────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator">
{% for val, label in operators %}
<option value="{{ val }}" {% if rule and rule.operator.value == val %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold</label>
<input type="number" name="threshold" step="any" required
value="{{ rule.threshold if rule else '' }}"
placeholder="0.5">
</div>
</div>
<div class="form-group">
<label>Consecutive failures before FIRING</label>
<input type="number" name="consecutive_failures_required" min="1"
value="{{ rule.consecutive_failures_required if rule else 1 }}">
</div>
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;">
Metric reference
</summary>
<div style="margin-top:0.75rem;display:grid;gap:0.5rem;">
{% set descs = {
"ping": [("up", "1.0 = reachable, 0.0 = unreachable"),
("response_time_ms", "round-trip latency in milliseconds")],
"dns": [("resolved", "1.0 = resolved, 0.0 = failed"),
("ip_changed", "1.0 when resolved IP differs from expected")],
"traefik": [("request_rate", "requests/sec for this router"),
("error_rate", "fraction of 5xx responses (01)"),
("latency_p50_ms", "median response time ms"),
("latency_p95_ms", "p95 response time ms"),
("latency_p99_ms", "p99 response time ms"),
("response_bytes_rate", "bytes/sec of responses"),
("cert_expiry_days", "days until TLS cert expires")],
"unifi": [("is_up", "1.0 = WAN up, 0.0 = down"),
("latency_ms", "WAN latency in ms"),
("total_clients", "number of connected clients")],
"ups": [("battery_charge_pct", "battery charge 0100"),
("on_battery", "1.0 when running on battery"),
("battery_runtime_secs", "estimated runtime remaining in seconds")],
"docker": [("cpu_pct", "container CPU usage %"),
("mem_pct", "container memory usage %")],
"http": [("is_up", "1.0 = check passed, 0.0 = failed"),
("response_ms", "HTTP response time in ms")],
} %}
{% for src, pairs in descs.items() %}
<div>
<div style="font-size:0.78rem;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.2rem;">{{ src }}</div>
{% for metric, desc in pairs %}
<div style="display:grid;grid-template-columns:160px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;">
<code>{{ metric }}</code>
<span style="color:var(--text-muted);">{{ desc }}</span>
</div> </div>
<div class="form-group"> {% endfor %}
<label>Source Module</label> </div>
<input type="text" name="source_module" required placeholder="ping, dns, traefik, ..."> {% endfor %}
</div> </div>
<div class="form-group"> </details>
<label>Resource Name</label>
<input type="text" name="resource_name" required placeholder="host name, router name, ..."> <div style="display:flex;gap:1rem;margin-top:1.5rem;">
</div> <button type="submit" class="btn">{% if rule %}Save Changes{% else %}Create Rule{% endif %}</button>
<div class="form-group"> <a href="/alerts/" class="btn btn-ghost">Cancel</a>
<label>Metric Name</label> </div>
<input type="text" name="metric_name" required placeholder="up, response_time_ms, ..."> </form>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator">
{% for val, label in operators %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold</label>
<input type="number" name="threshold" step="any" required placeholder="0.5">
</div>
</div>
<div class="form-group">
<label>Consecutive failures required before FIRING (default: 1)</label>
<input type="number" name="consecutive_failures_required" value="1" min="1">
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">Create Rule</button>
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div> </div>
{% endblock %} {% endblock %}
+26 -1
View File
@@ -3,7 +3,10 @@
{% block content %} {% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Hosts</h1> <h1 class="page-title" style="margin-bottom:0;">Hosts</h1>
<a class="btn" href="/hosts/new">Add Host</a> <div style="display:flex;gap:0.5rem;">
<a class="btn btn-ghost" href="/hosts/uptime">SLA</a>
<a class="btn" href="/hosts/new">Add Host</a>
</div>
</div> </div>
{% if hosts %} {% if hosts %}
<div class="card-flush"> <div class="card-flush">
@@ -16,6 +19,9 @@
<th style="text-align:center;">Ping</th> <th style="text-align:center;">Ping</th>
<th style="text-align:center;">Latency</th> <th style="text-align:center;">Latency</th>
<th style="text-align:center;">DNS</th> <th style="text-align:center;">DNS</th>
<th style="text-align:center;" title="Uptime last 24 hours">24h</th>
<th style="text-align:center;" title="Uptime last 7 days">7d</th>
<th style="text-align:center;" title="Uptime last 30 days">30d</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -23,6 +29,7 @@
{% for host in hosts %} {% for host in hosts %}
{% set ping = latest_pings.get(host.id) %} {% set ping = latest_pings.get(host.id) %}
{% set dns = latest_dns.get(host.id) %} {% set dns = latest_dns.get(host.id) %}
{% set ut = uptime.get(host.id, {}) %}
<tr> <tr>
<td>{{ host.name }}</td> <td>{{ host.name }}</td>
<td style="color:var(--text-muted);">{{ host.address }}</td> <td style="color:var(--text-muted);">{{ host.address }}</td>
@@ -64,6 +71,24 @@
<span class="dot dot-down" title="Failed"></span> <span class="dot dot-down" title="Failed"></span>
{% endif %} {% endif %}
</td> </td>
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<td style="text-align:center;font-size:0.82rem;font-family:ui-monospace,monospace;">
{% if not host.ping_enabled %}
<span style="color:var(--text-dim);"></span>
{% elif pct is none %}
<span style="color:var(--text-muted);"></span>
{% elif pct >= 99.9 %}
<span style="color:var(--green);">{{ "%.1f"|format(pct) }}%</span>
{% elif pct >= 99 %}
<span style="color:var(--yellow);">{{ "%.1f"|format(pct) }}%</span>
{% elif pct >= 95 %}
<span style="color:var(--orange);">{{ "%.1f"|format(pct) }}%</span>
{% else %}
<span style="color:var(--red);">{{ "%.1f"|format(pct) }}%</span>
{% endif %}
</td>
{% endfor %}
<td class="td-actions"> <td class="td-actions">
<a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a> <a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;"> <form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Uptime / SLA — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Uptime / SLA</h1>
<a class="btn btn-ghost" href="/hosts/">← Hosts</a>
</div>
{# ── Summary pills ──────────────────────────────────────────────────────────── #}
{% set ping_hosts = hosts | selectattr("ping_enabled") | list %}
{% set total = ping_hosts | length %}
{% if total %}
{% set full_30d = ping_hosts | selectattr("id", "in", uptime.keys())
| selectattr("id", "defined") | list %}
{% set healthy = namespace(count=0) %}
{% for h in ping_hosts %}
{% set pct = uptime.get(h.id, {}).get("30d") %}
{% if pct is not none and pct >= 99 %}{% set healthy.count = healthy.count + 1 %}{% endif %}
{% endfor %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Hosts monitored</div>
<span class="stat-val">{{ total }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">≥ 99% (30d)</div>
<span class="stat-val" style="color:{% if healthy.count == total %}var(--green){% else %}var(--orange){% endif %};">
{{ healthy.count }}
</span>
</div>
</div>
{% endif %}
{# ── SLA table ──────────────────────────────────────────────────────────────── #}
{% if hosts %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Host</th>
<th>Address</th>
<th style="text-align:center;min-width:80px;" title="Uptime last 24 hours">24 h</th>
<th style="text-align:center;min-width:80px;" title="Uptime last 7 days">7 d</th>
<th style="text-align:center;min-width:80px;" title="Uptime last 30 days">30 d</th>
<th style="min-width:160px;">30-day bar</th>
</tr>
</thead>
<tbody>
{% for host in hosts %}
{% set ut = uptime.get(host.id, {}) %}
<tr>
<td style="font-weight:500;">{{ host.name }}</td>
<td style="color:var(--text-muted);font-size:0.85rem;">{{ host.address }}</td>
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<td style="text-align:center;font-size:0.88rem;font-family:ui-monospace,monospace;">
{% if not host.ping_enabled %}
<span style="color:var(--text-dim);"></span>
{% elif pct is none %}
<span style="color:var(--text-muted);" title="No data yet"></span>
{% elif pct >= 99.9 %}
<span style="color:var(--green);">{{ "%.2f"|format(pct) }}%</span>
{% elif pct >= 99 %}
<span style="color:var(--yellow);">{{ "%.2f"|format(pct) }}%</span>
{% elif pct >= 95 %}
<span style="color:var(--orange);">{{ "%.2f"|format(pct) }}%</span>
{% else %}
<span style="color:var(--red);">{{ "%.2f"|format(pct) }}%</span>
{% endif %}
</td>
{% endfor %}
{# 30-day visual bar #}
{% set pct30 = ut.get("30d") %}
<td>
{% if not host.ping_enabled %}
<span style="color:var(--text-dim);font-size:0.8rem;">ping off</span>
{% elif pct30 is none %}
<span style="color:var(--text-muted);font-size:0.8rem;">no data</span>
{% else %}
<div style="display:flex;align-items:center;gap:0.5rem;">
<div style="flex:1;height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
<div style="height:100%;width:{{ pct30 | round(1) }}%;background:{% if pct30 >= 99.9 %}var(--green){% elif pct30 >= 99 %}var(--yellow){% elif pct30 >= 95 %}var(--orange){% else %}var(--red){% endif %};border-radius:4px;transition:width 0.3s;"></div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<p style="color:var(--text-muted);">No hosts configured. <a href="/hosts/new">Add one.</a></p>
</div>
{% endif %}
<p style="color:var(--text-dim);font-size:0.78rem;margin-top:1rem;">
Uptime is computed from ping probe results only. Hosts with ping disabled show —.
</p>
{% endblock %}
@@ -0,0 +1,45 @@
{# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #}
{% if not hosts %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No ping-enabled hosts configured.
</div>
{% else %}
<div style="display:grid;gap:2px;">
<div style="display:grid;grid-template-columns:1fr 52px 52px 52px;gap:0.35rem;font-size:0.72rem;color:var(--text-dim);padding:0 0 0.25rem;border-bottom:1px solid var(--border);">
<span>Host</span>
<span style="text-align:center;">24h</span>
<span style="text-align:center;">7d</span>
<span style="text-align:center;">30d</span>
</div>
{% for host in hosts %}
{% set ut = uptime.get(host.id, {}) %}
<div style="display:grid;grid-template-columns:1fr 52px 52px 52px;gap:0.35rem;align-items:center;padding:0.15rem 0;font-size:0.82rem;">
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ host.name }}</span>
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<span style="text-align:center;font-family:ui-monospace,monospace;font-size:0.78rem;">
{% if pct is none %}
<span style="color:var(--text-dim);"></span>
{% elif pct >= 99.9 %}
<span style="color:var(--green);">{{ "%.1f"|format(pct) }}%</span>
{% elif pct >= 99 %}
<span style="color:var(--yellow);">{{ "%.1f"|format(pct) }}%</span>
{% elif pct >= 95 %}
<span style="color:var(--orange);">{{ "%.1f"|format(pct) }}%</span>
{% else %}
<span style="color:var(--red);">{{ "%.1f"|format(pct) }}%</span>
{% endif %}
</span>
{% endfor %}
</div>
{% endfor %}
</div>
{% if share_token %}
<div style="margin-top:0.5rem;text-align:right;">
<a href="/hosts/uptime" style="font-size:0.75rem;color:var(--text-muted);">Full SLA →</a>
</div>
{% endif %}
{% endif %}
@@ -4,6 +4,8 @@
{% set tabs = [ {% set tabs = [
("general", "General", "/settings/general/"), ("general", "General", "/settings/general/"),
("notifications", "Notifications", "/settings/notifications/"), ("notifications", "Notifications", "/settings/notifications/"),
("reports", "Reports", "/settings/reports/"),
("auth", "Auth", "/settings/auth/"),
("ansible", "Ansible", "/settings/ansible/"), ("ansible", "Ansible", "/settings/ansible/"),
("plugins", "Plugins", "/settings/plugins/"), ("plugins", "Plugins", "/settings/plugins/"),
] %} ] %}
@@ -0,0 +1,91 @@
{# fabledscryer/templates/settings/reports.html #}
{% extends "base.html" %}
{% block title %}Settings — Reports — Fabled Scryer{% endblock %}
{% block content %}
{% set active_tab = "reports" %}
{% include "settings/_tabs.html" %}
{% if flash %}
<div style="padding:0.75rem 1rem;border-radius:6px;margin-bottom:1.25rem;
background:{% if flash.ok %}var(--green-dim){% else %}var(--red-dim){% endif %};
border:1px solid {% if flash.ok %}var(--green){% else %}var(--red){% endif %};
color:{% if flash.ok %}var(--green){% else %}var(--red){% endif %};
font-size:0.88rem;">
{{ flash.message }}
</div>
{% endif %}
<div style="max-width:560px;">
{# ── Schedule ─────────────────────────────────────────────────────────────── #}
<form method="post" action="/settings/reports/">
<div class="card" style="margin-bottom:1.25rem;">
<h2 class="section-title" style="margin-bottom:1.25rem;">Weekly Digest</h2>
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
<input type="checkbox" name="reports.enabled" id="reports-enabled"
{% if settings['reports.enabled'] %}checked{% endif %}>
<label for="reports-enabled" style="margin:0;">Enable weekly email digest</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:0.5rem;">
<div class="form-group">
<label>Send on day</label>
<select name="reports.schedule_day">
{% for i, day in [(0,"Monday"),(1,"Tuesday"),(2,"Wednesday"),(3,"Thursday"),(4,"Friday"),(5,"Saturday"),(6,"Sunday")] %}
<option value="{{ i }}" {% if settings['reports.schedule_day'] == i %}selected{% endif %}>
{{ day }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>At hour (UTC)</label>
<select name="reports.schedule_hour">
{% for h in range(24) %}
<option value="{{ h }}" {% if settings['reports.schedule_hour'] == h %}selected{% endif %}>
{{ "%02d:00"|format(h) }}
</option>
{% endfor %}
</select>
</div>
</div>
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>.
{% if settings['reports.last_sent_at'] %}
Last sent: {{ settings['reports.last_sent_at'][:16] }} UTC.
{% endif %}
</p>
<div style="margin-top:1rem;">
<button type="submit" class="btn">Save</button>
</div>
</div>
</form>
{# ── Report contents ─────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.75rem;">Report contents</h2>
<div style="display:grid;gap:0.35rem;font-size:0.85rem;color:var(--text-muted);">
<div><strong style="color:var(--text);">Uptime summary</strong> — 7-day % per ping-enabled host</div>
<div><strong style="color:var(--text);">Hosts currently down</strong> — highlighted at top if any</div>
<div><strong style="color:var(--text);">Active alerts</strong> — firing, acknowledged, pending rules</div>
<div><strong style="color:var(--text);">Alert events (7d)</strong> — fired and resolved counts</div>
<div><strong style="color:var(--text);">Top Traefik routers</strong> — avg req/s over last 24h (if plugin enabled)</div>
</div>
</div>
{# ── Send now ─────────────────────────────────────────────────────────────── #}
<div class="card">
<h2 class="section-title" style="margin-bottom:0.75rem;">Send Now</h2>
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:1rem;">
Send a report immediately regardless of schedule. Useful for testing.
</p>
<form method="post" action="/settings/reports/send-now/">
<button type="submit" class="btn btn-ghost">Send report now</button>
</form>
</div>
</div>
{% endblock %}