refactor: rename package directory fabledscryer → roundtable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
|
||||
from fabledscryer.core.audit import log_audit
|
||||
from sqlalchemy import select
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow
|
||||
from fabledscryer.models.hosts import Host
|
||||
from fabledscryer.models.metrics import PluginMetric
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
|
||||
|
||||
_OPERATORS = [(op.value, op.value) for op in AlertOperator]
|
||||
|
||||
# Static catalog: source_module → available metric names
|
||||
METRIC_CATALOG: dict[str, list[str]] = {
|
||||
"ping": ["up", "response_time_ms"],
|
||||
"dns": ["resolved", "ip_changed"],
|
||||
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
|
||||
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
|
||||
"unifi": ["is_up", "latency_ms", "total_clients"],
|
||||
"ups": ["battery_charge_pct", "on_battery", "battery_runtime_secs"],
|
||||
"docker": ["cpu_pct", "mem_pct"],
|
||||
"http": ["is_up", "response_ms"],
|
||||
# snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics
|
||||
"snmp": [],
|
||||
}
|
||||
|
||||
# Ordered list for source module picker
|
||||
_SOURCE_MODULES = list(METRIC_CATALOG.keys())
|
||||
|
||||
|
||||
async def _get_resources(db, source_module: str) -> list[str]:
|
||||
"""Return sorted distinct resource names for a source module.
|
||||
|
||||
For ping/dns, supplements with host names so the list is populated
|
||||
even before any metrics have been recorded.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PluginMetric.resource_name)
|
||||
.where(PluginMetric.source_module == source_module)
|
||||
.distinct()
|
||||
.order_by(PluginMetric.resource_name)
|
||||
)
|
||||
resources: set[str] = {r for (r,) in result}
|
||||
|
||||
if source_module in ("ping", "dns"):
|
||||
host_result = await db.execute(select(Host.name).order_by(Host.name))
|
||||
for (name,) in host_result:
|
||||
resources.add(name)
|
||||
|
||||
return sorted(resources)
|
||||
|
||||
|
||||
@alerts_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def list_alerts():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
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 = result.all()
|
||||
|
||||
rules_result = await db.execute(
|
||||
select(AlertRule).order_by(AlertRule.name)
|
||||
)
|
||||
rules = rules_result.scalars().all()
|
||||
|
||||
return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS)
|
||||
|
||||
|
||||
@alerts_bp.get("/api/rule_fields")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rule_fields_api():
|
||||
source = request.args.get("source_module", "")
|
||||
current_resource = request.args.get("current_resource", "")
|
||||
current_metric = request.args.get("current_metric", "")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
resources = await _get_resources(db, source) if source else []
|
||||
metrics = METRIC_CATALOG.get(source, [])
|
||||
# Dynamic sources (e.g. snmp) have empty static catalog — query live metric names
|
||||
if source and not metrics:
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
m_result = await db.execute(
|
||||
select(PluginMetric.metric_name)
|
||||
.where(PluginMetric.source_module == source)
|
||||
.distinct()
|
||||
.order_by(PluginMetric.metric_name)
|
||||
)
|
||||
metrics = [r for (r,) in m_result]
|
||||
return await render_template(
|
||||
"alerts/_rule_fields.html",
|
||||
resources=resources,
|
||||
metrics=metrics,
|
||||
current_resource=current_resource,
|
||||
current_metric=current_metric,
|
||||
)
|
||||
|
||||
|
||||
@alerts_bp.get("/rules/new")
|
||||
@require_role(UserRole.operator)
|
||||
async def new_rule():
|
||||
first_source = _SOURCE_MODULES[0]
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
resources = await _get_resources(db, first_source)
|
||||
return await render_template(
|
||||
"alerts/rules_form.html",
|
||||
rule=None,
|
||||
operators=_OPERATORS,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
initial_source=first_source,
|
||||
resources=resources,
|
||||
metrics=METRIC_CATALOG.get(first_source, []),
|
||||
)
|
||||
|
||||
|
||||
@alerts_bp.get("/rules/<rule_id>/edit")
|
||||
@require_role(UserRole.operator)
|
||||
async def edit_rule(rule_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule is None:
|
||||
return "Not found", 404
|
||||
resources = await _get_resources(db, rule.source_module)
|
||||
return await render_template(
|
||||
"alerts/rules_form.html",
|
||||
rule=rule,
|
||||
operators=_OPERATORS,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
initial_source=rule.source_module,
|
||||
resources=resources,
|
||||
metrics=METRIC_CATALOG.get(rule.source_module, []),
|
||||
)
|
||||
|
||||
|
||||
@alerts_bp.post("/rules")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_rule():
|
||||
form = await request.form
|
||||
user_id = session.get("user_id")
|
||||
now = datetime.now(timezone.utc)
|
||||
rule = AlertRule(
|
||||
name=form["name"].strip(),
|
||||
source_module=form["source_module"].strip(),
|
||||
resource_name=form["resource_name"].strip(),
|
||||
metric_name=form["metric_name"].strip(),
|
||||
operator=AlertOperator(form["operator"]),
|
||||
threshold=float(form["threshold"]),
|
||||
consecutive_failures_required=int(form.get("consecutive_failures_required") or 1),
|
||||
enabled=True,
|
||||
created_by=user_id,
|
||||
)
|
||||
state = AlertState(
|
||||
rule_id=rule.id,
|
||||
state=AlertStateEnum.inactive,
|
||||
last_transition_at=now,
|
||||
)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
state.rule_id = rule.id
|
||||
db.add(state)
|
||||
await log_audit(current_app, user_id, session.get("username", ""), "alert_rule.created",
|
||||
entity_type="alert_rule", entity_id=rule.name,
|
||||
detail={"source": rule.source_module, "resource": rule.resource_name,
|
||||
"metric": rule.metric_name})
|
||||
return redirect(url_for("alerts.list_alerts"))
|
||||
|
||||
|
||||
@alerts_bp.post("/rules/<rule_id>")
|
||||
@require_role(UserRole.operator)
|
||||
async def update_rule(rule_id: str):
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule is None:
|
||||
return "Not found", 404
|
||||
rule.name = form["name"].strip()
|
||||
rule.source_module = form["source_module"].strip()
|
||||
rule.resource_name = form["resource_name"].strip()
|
||||
rule.metric_name = form["metric_name"].strip()
|
||||
rule.operator = AlertOperator(form["operator"])
|
||||
rule.threshold = float(form["threshold"])
|
||||
rule.consecutive_failures_required = int(form.get("consecutive_failures_required") or 1)
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"alert_rule.updated", entity_type="alert_rule", entity_id=rule.name)
|
||||
return redirect(url_for("alerts.list_alerts"))
|
||||
|
||||
|
||||
@alerts_bp.post("/rules/<rule_id>/toggle")
|
||||
@require_role(UserRole.operator)
|
||||
async def toggle_rule(rule_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule is None:
|
||||
return "Not found", 404
|
||||
rule.enabled = not rule.enabled
|
||||
action = "alert_rule.enabled" if rule.enabled else "alert_rule.disabled"
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
action, entity_type="alert_rule", entity_id=rule.name)
|
||||
return redirect(url_for("alerts.list_alerts"))
|
||||
|
||||
|
||||
@alerts_bp.post("/rules/<rule_id>/delete")
|
||||
@require_role(UserRole.admin)
|
||||
async def delete_rule(rule_id: str):
|
||||
rule_name = None
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule:
|
||||
rule_name = rule.name
|
||||
await db.delete(rule)
|
||||
if rule_name:
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"alert_rule.deleted", entity_type="alert_rule", entity_id=rule_name)
|
||||
return redirect(url_for("alerts.list_alerts"))
|
||||
|
||||
|
||||
@alerts_bp.get("/maintenance")
|
||||
@require_role(UserRole.viewer)
|
||||
async def list_maintenance():
|
||||
now = datetime.now(timezone.utc)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(MaintenanceWindow).order_by(MaintenanceWindow.start_at.desc())
|
||||
)
|
||||
windows = result.scalars().all()
|
||||
return await render_template(
|
||||
"alerts/maintenance.html",
|
||||
windows=windows,
|
||||
now=now,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
)
|
||||
|
||||
|
||||
@alerts_bp.get("/maintenance/new")
|
||||
@require_role(UserRole.operator)
|
||||
async def new_maintenance():
|
||||
return await render_template(
|
||||
"alerts/maintenance_form.html",
|
||||
window=None,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
)
|
||||
|
||||
|
||||
@alerts_bp.post("/maintenance")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_maintenance():
|
||||
form = await request.form
|
||||
user_id = session.get("user_id")
|
||||
start_at = datetime.fromisoformat(form["start_at"]).replace(tzinfo=timezone.utc)
|
||||
end_at = datetime.fromisoformat(form["end_at"]).replace(tzinfo=timezone.utc)
|
||||
scope_source = form.get("scope_source", "").strip() or None
|
||||
scope_resource = form.get("scope_resource", "").strip() or None
|
||||
if scope_source is None:
|
||||
scope_resource = None # can't have resource without source
|
||||
window = MaintenanceWindow(
|
||||
name=form["name"].strip(),
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
scope_source=scope_source,
|
||||
scope_resource=scope_resource,
|
||||
created_by=user_id,
|
||||
)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(window)
|
||||
scope_desc = "all" if not scope_source else (
|
||||
f"{scope_source}/{scope_resource}" if scope_resource else f"{scope_source}/*"
|
||||
)
|
||||
await log_audit(current_app, user_id, session.get("username", ""),
|
||||
"maintenance_window.created", entity_type="maintenance_window",
|
||||
entity_id=window.name, detail={"scope": scope_desc})
|
||||
return redirect(url_for("alerts.list_maintenance"))
|
||||
|
||||
|
||||
@alerts_bp.post("/maintenance/<window_id>/delete")
|
||||
@require_role(UserRole.operator)
|
||||
async def delete_maintenance(window_id: str):
|
||||
window_name = None
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(
|
||||
select(MaintenanceWindow).where(MaintenanceWindow.id == window_id)
|
||||
)
|
||||
window = result.scalar_one_or_none()
|
||||
if window:
|
||||
window_name = window.name
|
||||
await db.delete(window)
|
||||
if window_name:
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"maintenance_window.deleted", entity_type="maintenance_window",
|
||||
entity_id=window_name)
|
||||
return redirect(url_for("alerts.list_maintenance"))
|
||||
|
||||
|
||||
@alerts_bp.post("/<rule_id>/acknowledge")
|
||||
@require_role(UserRole.operator)
|
||||
async def acknowledge(rule_id: str):
|
||||
user_id = session.get("user_id")
|
||||
now = datetime.now(timezone.utc)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(
|
||||
select(AlertState).where(AlertState.rule_id == rule_id)
|
||||
)
|
||||
state = result.scalar_one_or_none()
|
||||
if state is None:
|
||||
return "Not found", 404
|
||||
if state.state != AlertStateEnum.firing:
|
||||
return "Conflict: alert is not in FIRING state", 409
|
||||
state.state = AlertStateEnum.acknowledged
|
||||
state.acknowledged_by = user_id
|
||||
state.acknowledged_at = now
|
||||
state.last_transition_at = now
|
||||
return redirect(url_for("alerts.list_alerts"))
|
||||
@@ -0,0 +1,132 @@
|
||||
# fabledscryer/ansible/executor.py
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OUTPUT_CAP_BYTES = 1024 * 1024 # 1 MB DB cap
|
||||
_FLUSH_LINES = 50
|
||||
_FLUSH_SECS = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Done:
|
||||
status: str
|
||||
|
||||
|
||||
# Per-run broadcast state (lives until process restarts)
|
||||
_run_lines: dict[str, list[str]] = {} # all output lines received so far
|
||||
_run_done: dict[str, _Done] = {} # set when run completes
|
||||
_run_listeners: dict[str, list[asyncio.Queue]] = {} # active SSE client queues
|
||||
|
||||
|
||||
def register_listener(run_id: str) -> asyncio.Queue:
|
||||
"""Create a listener queue for a run. Replays existing lines immediately."""
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
for line in _run_lines.get(run_id, []):
|
||||
q.put_nowait(line)
|
||||
done = _run_done.get(run_id)
|
||||
if done:
|
||||
q.put_nowait(done)
|
||||
else:
|
||||
_run_listeners.setdefault(run_id, []).append(q)
|
||||
return q
|
||||
|
||||
|
||||
def deregister_listener(run_id: str, q: asyncio.Queue) -> None:
|
||||
listeners = _run_listeners.get(run_id, [])
|
||||
if q in listeners:
|
||||
listeners.remove(q)
|
||||
|
||||
|
||||
def _broadcast(run_id: str, item: str | _Done) -> None:
|
||||
if isinstance(item, str):
|
||||
_run_lines.setdefault(run_id, []).append(item)
|
||||
else:
|
||||
_run_done[run_id] = item
|
||||
for q in _run_listeners.get(run_id, []):
|
||||
q.put_nowait(item)
|
||||
if isinstance(item, _Done):
|
||||
_run_listeners.pop(run_id, None)
|
||||
|
||||
|
||||
async def start_run(
|
||||
app: "Quart",
|
||||
run_id: str,
|
||||
playbook_path: str,
|
||||
inventory_path: str,
|
||||
source_path: str,
|
||||
) -> None:
|
||||
"""Execute ansible-playbook as a subprocess and update the DB run row."""
|
||||
_run_lines[run_id] = []
|
||||
|
||||
db_output = ""
|
||||
truncated = False
|
||||
lines_since_flush = 0
|
||||
last_flush_at = time.monotonic()
|
||||
|
||||
async def _flush(final_status: str | None = None) -> None:
|
||||
nonlocal lines_since_flush, last_flush_at
|
||||
from sqlalchemy import update
|
||||
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||
|
||||
values: dict = {"output": db_output}
|
||||
if final_status is not None:
|
||||
values["status"] = AnsibleRunStatus(final_status)
|
||||
values["finished_at"] = datetime.now(timezone.utc)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(AnsibleRun).where(AnsibleRun.id == run_id).values(**values)
|
||||
)
|
||||
lines_since_flush = 0
|
||||
last_flush_at = time.monotonic()
|
||||
|
||||
cmd = ["ansible-playbook", playbook_path, "-i", inventory_path]
|
||||
cwd = source_path if Path(source_path).exists() else None
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd=cwd,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
|
||||
async for raw_line in proc.stdout:
|
||||
line = raw_line.decode(errors="replace").rstrip("\n\r")
|
||||
_broadcast(run_id, line)
|
||||
|
||||
if not truncated:
|
||||
candidate = (db_output + "\n" + line) if db_output else line
|
||||
if len(candidate.encode()) > _OUTPUT_CAP_BYTES:
|
||||
db_output += "\n[output truncated]"
|
||||
truncated = True
|
||||
else:
|
||||
db_output = candidate
|
||||
|
||||
lines_since_flush += 1
|
||||
elapsed = time.monotonic() - last_flush_at
|
||||
if lines_since_flush >= _FLUSH_LINES or elapsed >= _FLUSH_SECS:
|
||||
await _flush()
|
||||
|
||||
await proc.wait()
|
||||
final_status = "success" if proc.returncode == 0 else "failed"
|
||||
|
||||
except Exception:
|
||||
logger.exception("Ansible run %s failed with exception", run_id)
|
||||
final_status = "failed"
|
||||
|
||||
await _flush(final_status)
|
||||
_broadcast(run_id, _Done(status=final_status))
|
||||
@@ -0,0 +1,169 @@
|
||||
# fabledscryer/ansible/routes.py
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from quart import (
|
||||
Blueprint, Response, current_app, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from fabledscryer.ansible import executor, sources as src_module
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible")
|
||||
|
||||
|
||||
def _get_sources() -> list[dict]:
|
||||
ansible_cfg = current_app.config.get("ANSIBLE", {})
|
||||
return src_module.get_sources(ansible_cfg)
|
||||
|
||||
|
||||
@ansible_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50)
|
||||
)
|
||||
runs = result.scalars().all()
|
||||
return await render_template("ansible/index.html", runs=runs)
|
||||
|
||||
|
||||
@ansible_bp.get("/browse")
|
||||
@require_role(UserRole.viewer)
|
||||
async def browse():
|
||||
all_sources = _get_sources()
|
||||
source_data = []
|
||||
for source in all_sources:
|
||||
playbooks = src_module.discover_playbooks(source["path"])
|
||||
inventories = src_module.discover_inventories(source["path"])
|
||||
source_data.append({
|
||||
"source": source,
|
||||
"playbooks": playbooks,
|
||||
"inventories": inventories,
|
||||
})
|
||||
return await render_template("ansible/browse.html", source_data=source_data)
|
||||
|
||||
|
||||
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def view_playbook(source_name: str, playbook_path: str):
|
||||
all_sources = _get_sources()
|
||||
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is None:
|
||||
return "Playbook not found", 404
|
||||
return await render_template(
|
||||
"ansible/browse.html",
|
||||
source_data=[],
|
||||
view_source=source_name,
|
||||
view_path=playbook_path,
|
||||
view_contents=contents,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.post("/runs")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_run():
|
||||
form = await request.form
|
||||
playbook_path = form.get("playbook_path", "").strip()
|
||||
source_name = form.get("source_name", "").strip()
|
||||
inventory_path = form.get("inventory_path", "").strip()
|
||||
|
||||
if not playbook_path or not inventory_path:
|
||||
return "playbook_path and inventory_path are required", 400
|
||||
|
||||
all_sources = _get_sources()
|
||||
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
run = AnsibleRun(
|
||||
id=run_id,
|
||||
playbook_path=playbook_path,
|
||||
inventory_path=inventory_path,
|
||||
source_name=source_name,
|
||||
triggered_by=session["user_id"],
|
||||
status=AnsibleRunStatus.running,
|
||||
started_at=now,
|
||||
)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(run)
|
||||
|
||||
task = asyncio.create_task(
|
||||
executor.start_run(
|
||||
current_app._get_current_object(), # type: ignore[attr-defined]
|
||||
run_id,
|
||||
playbook_path,
|
||||
inventory_path,
|
||||
source["path"],
|
||||
)
|
||||
)
|
||||
task.add_done_callback(
|
||||
lambda t: t.exception() and current_app.logger.error(
|
||||
"Ansible run %s raised: %s", run_id, t.exception()
|
||||
)
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"ansible/run_started.html",
|
||||
run=run,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.get("/runs/<run_id>/stream")
|
||||
@require_role(UserRole.viewer)
|
||||
async def stream_run(run_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
|
||||
run = result.scalar_one_or_none()
|
||||
if run is None:
|
||||
return "Not found", 404
|
||||
|
||||
async def generate():
|
||||
if run.status != AnsibleRunStatus.running:
|
||||
yield f"event: done\ndata: {run.status.value}\n\n"
|
||||
return
|
||||
|
||||
q = executor.register_listener(run_id)
|
||||
try:
|
||||
while True:
|
||||
msg = await q.get()
|
||||
if isinstance(msg, executor._Done):
|
||||
yield f"event: done\ndata: {msg.status}\n\n"
|
||||
break
|
||||
# Escape newlines in SSE data field
|
||||
safe = msg.replace("\n", " ")
|
||||
yield f"event: output\ndata: {safe}\n\n"
|
||||
finally:
|
||||
executor.deregister_listener(run_id, q)
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
content_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.get("/runs/<run_id>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def run_detail(run_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
|
||||
run = result.scalar_one_or_none()
|
||||
if run is None:
|
||||
return "Not found", 404
|
||||
return await render_template("ansible/run_detail.html", run=run)
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INVENTORY_NAMES = {"hosts", "inventory", "inventory.yml", "inventory.ini"}
|
||||
|
||||
|
||||
def get_sources(ansible_cfg: dict) -> list[dict]:
|
||||
"""Return resolved source list from ansible config section.
|
||||
|
||||
Each source dict has: name, type, path (resolved local path),
|
||||
url (git only), branch (git only), pull_interval_seconds (git only).
|
||||
|
||||
Config structure in config.yaml::
|
||||
|
||||
ansible:
|
||||
cache_dir: /var/cache/fabledscryer/ansible
|
||||
sources:
|
||||
- name: my-playbooks
|
||||
type: local
|
||||
path: /opt/playbooks
|
||||
- name: infra-repo
|
||||
type: git
|
||||
url: https://github.com/user/infra.git
|
||||
branch: main
|
||||
pull_interval_seconds: 3600
|
||||
"""
|
||||
sources = ansible_cfg.get("sources", [])
|
||||
cache_dir = ansible_cfg.get("cache_dir", "/var/cache/fabledscryer/ansible")
|
||||
result = []
|
||||
for src in sources:
|
||||
src_type = src.get("type", "local")
|
||||
if src_type == "git":
|
||||
if not src.get("url"):
|
||||
raise ValueError(f"Ansible git source {src['name']!r} is missing required 'url' field")
|
||||
path = str(Path(cache_dir) / src["name"])
|
||||
else:
|
||||
if not src.get("path"):
|
||||
raise ValueError(f"Ansible local source {src['name']!r} is missing required 'path' field")
|
||||
path = src.get("path", "")
|
||||
result.append({
|
||||
"name": src["name"],
|
||||
"type": src_type,
|
||||
"path": path,
|
||||
"url": src.get("url"),
|
||||
"branch": src.get("branch", "main"),
|
||||
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def discover_playbooks(source_path: str) -> list[str]:
|
||||
"""Recursively find .yml and .yaml files in source_path. Returns relative paths."""
|
||||
root = Path(source_path)
|
||||
if not root.exists():
|
||||
return []
|
||||
playbooks = set()
|
||||
for ext in ("*.yml", "*.yaml"):
|
||||
for p in root.rglob(ext):
|
||||
playbooks.add(str(p.relative_to(root)))
|
||||
return sorted(playbooks)
|
||||
|
||||
|
||||
def discover_inventories(source_path: str) -> list[str]:
|
||||
"""Non-recursive: return inventory filenames present in root of source_path."""
|
||||
root = Path(source_path)
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(name for name in INVENTORY_NAMES if (root / name).exists())
|
||||
|
||||
|
||||
def read_playbook(source_path: str, relative_path: str) -> str | None:
|
||||
"""Return contents of a playbook file, or None if not found / path escape."""
|
||||
root = Path(source_path).resolve()
|
||||
target = (root / relative_path).resolve()
|
||||
# Guard against path traversal
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not target.exists() or not target.is_file():
|
||||
return None
|
||||
return target.read_text(errors="replace")
|
||||
|
||||
|
||||
async def git_pull(source: dict) -> None:
|
||||
"""Clone the git repo if absent; pull if already present."""
|
||||
path = Path(source["path"])
|
||||
if not (path / ".git").exists():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "clone",
|
||||
"--branch", source["branch"],
|
||||
"--single-branch",
|
||||
source["url"], str(path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.error("git clone failed for %r: %s", source["name"], stderr.decode(errors="replace"))
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", str(path), "pull",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.error("git pull failed for %r: %s", source["name"], stderr.decode(errors="replace"))
|
||||
@@ -0,0 +1,295 @@
|
||||
# fabledscryer/app.py
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from quart import Quart, render_template
|
||||
from .config import load_bootstrap
|
||||
from .database import init_db
|
||||
|
||||
|
||||
def create_app(
|
||||
config_path: Path | str | None = None,
|
||||
testing: bool = False,
|
||||
) -> Quart:
|
||||
app = Quart(__name__, template_folder="templates")
|
||||
|
||||
# ── 1. Bootstrap: db_url + secret_key only ────────────────────────────────
|
||||
if not testing:
|
||||
bootstrap = load_bootstrap(config_path)
|
||||
else:
|
||||
bootstrap = {
|
||||
"database_url": "postgresql+asyncpg://test/test",
|
||||
"secret_key": "test-secret-key",
|
||||
"plugin_dir": "plugins",
|
||||
}
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=bootstrap["secret_key"],
|
||||
DATABASE_URL=bootstrap["database_url"],
|
||||
PLUGIN_DIR=bootstrap["plugin_dir"],
|
||||
TESTING=testing,
|
||||
)
|
||||
|
||||
# ── 2. Init DB engine ──────────────────────────────────────────────────────
|
||||
if not testing:
|
||||
init_db(app)
|
||||
else:
|
||||
from unittest.mock import MagicMock
|
||||
app.db_sessionmaker = MagicMock()
|
||||
|
||||
# ── 3. Core migrations only (creates app_settings table) ──────────────────
|
||||
if not testing:
|
||||
from .core.migration_runner import run_core_migrations
|
||||
run_core_migrations(
|
||||
app.config["DATABASE_URL"],
|
||||
plugin_dir=Path(app.config["PLUGIN_DIR"]).resolve(),
|
||||
)
|
||||
|
||||
# ── 4. Load all settings from DB → populate app.config ────────────────────
|
||||
if not testing:
|
||||
from .core.settings import (
|
||||
load_settings_sync, to_smtp_cfg, to_webhook_cfg,
|
||||
to_ansible_cfg, to_plugins_cfg, to_oidc_cfg, to_ldap_cfg,
|
||||
)
|
||||
_settings = load_settings_sync(app.config["DATABASE_URL"])
|
||||
app.config.update(
|
||||
SESSION_LIFETIME_HOURS=_settings.get("session.lifetime_hours", 8),
|
||||
DATA_RETENTION_DAYS=_settings.get("data.retention_days", 90),
|
||||
MONITORS_POLL_INTERVAL=_settings.get("monitors.poll_interval_seconds", 60),
|
||||
SMTP=to_smtp_cfg(_settings),
|
||||
WEBHOOK=to_webhook_cfg(_settings),
|
||||
ANSIBLE=to_ansible_cfg(_settings),
|
||||
PLUGINS=to_plugins_cfg(_settings),
|
||||
OIDC=to_oidc_cfg(_settings),
|
||||
LDAP=to_ldap_cfg(_settings),
|
||||
)
|
||||
else:
|
||||
app.config.update(
|
||||
SESSION_LIFETIME_HOURS=8,
|
||||
DATA_RETENTION_DAYS=90,
|
||||
MONITORS_POLL_INTERVAL=60,
|
||||
SMTP={},
|
||||
WEBHOOK={},
|
||||
ANSIBLE={},
|
||||
PLUGINS={},
|
||||
OIDC={"enabled": False},
|
||||
LDAP={"enabled": False},
|
||||
)
|
||||
|
||||
# ── 5. Plugin migrations ───────────────────────────────────────────────────
|
||||
# Step 3 already applied all plugin migrations via discover_all_plugin_migration_dirs,
|
||||
# so this is a no-op on normal startup. We still run it with all discovered dirs so
|
||||
# Alembic can resolve the full revision graph regardless of which plugins are enabled.
|
||||
if not testing:
|
||||
from .core.migration_runner import run_plugin_migrations, discover_all_plugin_migration_dirs
|
||||
_plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
_all_plugin_dirs = discover_all_plugin_migration_dirs(_plugin_dir)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], _all_plugin_dirs)
|
||||
|
||||
# ── 6. Alert pipeline ──────────────────────────────────────────────────────
|
||||
from .core.alerts import init_alerts
|
||||
init_alerts(app)
|
||||
|
||||
# ── 7. Register core blueprints ────────────────────────────────────────────
|
||||
from .auth.routes import auth_bp
|
||||
from .dashboard.routes import dashboard_bp
|
||||
from .hosts.routes import hosts_bp
|
||||
from .ping.routes import ping_bp
|
||||
from .dns.routes import dns_bp
|
||||
from .alerts.routes import alerts_bp
|
||||
from .ansible.routes import ansible_bp
|
||||
from .settings.routes import settings_bp
|
||||
from .audit.routes import audit_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(hosts_bp)
|
||||
app.register_blueprint(ping_bp)
|
||||
app.register_blueprint(dns_bp)
|
||||
app.register_blueprint(alerts_bp)
|
||||
app.register_blueprint(ansible_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(audit_bp)
|
||||
|
||||
# ── 8. Build task registry ─────────────────────────────────────────────────
|
||||
app._task_registry = []
|
||||
|
||||
if not testing:
|
||||
_register_core_tasks(app)
|
||||
|
||||
# ── 9. Load plugins ────────────────────────────────────────────────────────
|
||||
if not testing:
|
||||
from .core.plugin_manager import load_plugins
|
||||
load_plugins(app)
|
||||
|
||||
# ── 10. Template context: inject plugin_failures into every response ───────
|
||||
@app.context_processor
|
||||
def _inject_plugin_failures():
|
||||
from .core.plugin_manager import get_plugin_failures
|
||||
return {"plugin_failures": get_plugin_failures()}
|
||||
|
||||
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
||||
@app.before_request
|
||||
async def _validate_share_token():
|
||||
"""If ?s=<token> is present, validate it and set g.share_viewer."""
|
||||
from quart import request, g
|
||||
raw = request.args.get("s")
|
||||
if not raw:
|
||||
return
|
||||
try:
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
from .models.dashboard import DashboardShareToken
|
||||
token_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(DashboardShareToken)
|
||||
.where(DashboardShareToken.token_hash == token_hash)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share:
|
||||
now = datetime.now(timezone.utc)
|
||||
if share.expires_at is None or share.expires_at > now:
|
||||
g.share_viewer = True
|
||||
g.share_dashboard_id = share.dashboard_id
|
||||
except Exception:
|
||||
pass # never block a request due to token validation failure
|
||||
|
||||
# ── 11. Health check ───────────────────────────────────────────────────────
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
# ── 12. Error handlers ─────────────────────────────────────────────────────
|
||||
@app.errorhandler(404)
|
||||
async def not_found(_):
|
||||
return await render_template("errors/404.html"), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
async def server_error(_):
|
||||
return await render_template("errors/500.html"), 500
|
||||
|
||||
# ── 11. Startup hook ───────────────────────────────────────────────────────
|
||||
@app.before_serving
|
||||
async def startup():
|
||||
if not testing:
|
||||
await _mark_interrupted_runs(app)
|
||||
asyncio.create_task(_run_scheduler(app))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _register_core_tasks(app: Quart) -> None:
|
||||
from .core.scheduler import ScheduledTask
|
||||
|
||||
poll_interval = app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||
cleanup_interval = 3600 # hourly
|
||||
|
||||
async def run_ping_monitors():
|
||||
from sqlalchemy import select
|
||||
from .models.hosts import Host
|
||||
from .monitors.ping import ping_check
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
result = await session.execute(
|
||||
select(Host).where(Host.ping_enabled.is_(True))
|
||||
)
|
||||
for host in result.scalars().all():
|
||||
try:
|
||||
await ping_check(host, session)
|
||||
except Exception:
|
||||
app.logger.exception(f"Ping check failed for host {host.name!r}")
|
||||
|
||||
async def run_dns_monitors():
|
||||
from sqlalchemy import select
|
||||
from .models.hosts import Host
|
||||
from .monitors.dns import dns_check
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
result = await session.execute(
|
||||
select(Host).where(Host.dns_enabled.is_(True))
|
||||
)
|
||||
for host in result.scalars().all():
|
||||
try:
|
||||
await dns_check(host, session)
|
||||
except Exception:
|
||||
app.logger.exception(f"DNS check failed for host {host.name!r}")
|
||||
|
||||
async def run_cleanup():
|
||||
from .core.cleanup import run_cleanup as _cleanup
|
||||
await _cleanup(app)
|
||||
|
||||
async def run_report_check():
|
||||
from .core.reports import check_and_send
|
||||
await check_and_send(app)
|
||||
|
||||
app._task_registry.extend([
|
||||
ScheduledTask(
|
||||
name="weekly_report_check",
|
||||
coro_factory=run_report_check,
|
||||
interval_seconds=3600,
|
||||
run_on_startup=False,
|
||||
),
|
||||
ScheduledTask(
|
||||
name="ping_monitor",
|
||||
coro_factory=run_ping_monitors,
|
||||
interval_seconds=poll_interval,
|
||||
run_on_startup=True,
|
||||
),
|
||||
ScheduledTask(
|
||||
name="dns_monitor",
|
||||
coro_factory=run_dns_monitors,
|
||||
interval_seconds=poll_interval,
|
||||
run_on_startup=True,
|
||||
),
|
||||
ScheduledTask(
|
||||
name="data_cleanup",
|
||||
coro_factory=run_cleanup,
|
||||
interval_seconds=cleanup_interval,
|
||||
run_on_startup=False,
|
||||
),
|
||||
])
|
||||
|
||||
# Register git-pull tasks for each ansible git source
|
||||
ansible_cfg = app.config.get("ANSIBLE", {})
|
||||
from .ansible.sources import get_sources
|
||||
for source in get_sources(ansible_cfg):
|
||||
if source["type"] != "git":
|
||||
continue
|
||||
_source = source
|
||||
|
||||
async def run_git_pull(src=_source):
|
||||
from .ansible.sources import git_pull
|
||||
await git_pull(src)
|
||||
|
||||
app._task_registry.append(
|
||||
ScheduledTask(
|
||||
name=f"ansible_git_pull_{_source['name']}",
|
||||
coro_factory=run_git_pull,
|
||||
interval_seconds=_source["pull_interval_seconds"],
|
||||
run_on_startup=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _mark_interrupted_runs(app: Quart) -> None:
|
||||
from sqlalchemy import update
|
||||
from .models.ansible import AnsibleRun, AnsibleRunStatus
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(AnsibleRun)
|
||||
.where(AnsibleRun.status == AnsibleRunStatus.running)
|
||||
.values(
|
||||
status=AnsibleRunStatus.interrupted,
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _run_scheduler(app: Quart) -> None:
|
||||
from .core.scheduler import start_scheduler
|
||||
await start_scheduler(app._task_registry)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from quart import Blueprint, render_template, request, current_app
|
||||
from sqlalchemy import select
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.audit import AuditEvent
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
audit_bp = Blueprint("audit", __name__, url_prefix="/audit")
|
||||
|
||||
|
||||
@audit_bp.get("/")
|
||||
@require_role(UserRole.admin)
|
||||
async def list_events():
|
||||
limit = min(int(request.args.get("limit", 200)), 500)
|
||||
action_filter = request.args.get("action", "").strip()
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
stmt = select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(limit)
|
||||
if action_filter:
|
||||
stmt = select(AuditEvent).where(
|
||||
AuditEvent.action.like(f"{action_filter}%")
|
||||
).order_by(AuditEvent.timestamp.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
events = result.scalars().all()
|
||||
|
||||
# Parse detail_json for display
|
||||
parsed = []
|
||||
for e in events:
|
||||
detail = {}
|
||||
if e.detail_json:
|
||||
try:
|
||||
detail = json.loads(e.detail_json)
|
||||
except (ValueError, TypeError):
|
||||
detail = {"raw": e.detail_json}
|
||||
parsed.append({"event": e, "detail": detail})
|
||||
|
||||
return await render_template(
|
||||
"audit/list.html",
|
||||
events=parsed,
|
||||
limit=limit,
|
||||
action_filter=action_filter,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""fabledscryer/auth/ldap_auth.py
|
||||
|
||||
LDAP authentication helper. Requires the optional `ldap3` package.
|
||||
Falls back gracefully if ldap3 is not installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ldap_available() -> bool:
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None:
|
||||
"""Synchronous LDAP auth. Returns user info dict or None on failure.
|
||||
|
||||
Called via run_in_executor so it doesn't block the event loop.
|
||||
"""
|
||||
import ldap3
|
||||
|
||||
host = cfg.get("host", "")
|
||||
port = int(cfg.get("port", 389))
|
||||
use_tls = cfg.get("tls", False)
|
||||
bind_dn = cfg.get("bind_dn", "")
|
||||
bind_password = cfg.get("bind_password", "")
|
||||
base_dn = cfg.get("base_dn", "")
|
||||
user_filter = cfg.get("user_filter", "(uid={username})").format(username=username)
|
||||
admin_group_dn = cfg.get("admin_group_dn", "")
|
||||
operator_group_dn = cfg.get("operator_group_dn", "")
|
||||
attr_username = cfg.get("attr_username", "uid")
|
||||
attr_email = cfg.get("attr_email", "mail")
|
||||
|
||||
tls = ldap3.Tls() if use_tls else None
|
||||
server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL)
|
||||
|
||||
# Service account bind to search for user DN
|
||||
try:
|
||||
svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
except Exception as exc:
|
||||
logger.error("LDAP service bind failed: %s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
svc_conn.search(
|
||||
search_base=base_dn,
|
||||
search_filter=user_filter,
|
||||
attributes=[attr_username, attr_email, "memberOf", "dn"],
|
||||
)
|
||||
if not svc_conn.entries:
|
||||
logger.debug("LDAP user not found: %s", username)
|
||||
return None
|
||||
entry = svc_conn.entries[0]
|
||||
user_dn = str(entry.entry_dn)
|
||||
ldap_username = str(getattr(entry, attr_username, [username])[0]) if hasattr(entry, attr_username) else username
|
||||
ldap_email = str(getattr(entry, attr_email, [""])[0]) if hasattr(entry, attr_email) else ""
|
||||
member_of: list[str] = [str(g) for g in getattr(entry, "memberOf", [])]
|
||||
except Exception as exc:
|
||||
logger.error("LDAP search failed: %s", exc)
|
||||
return None
|
||||
finally:
|
||||
svc_conn.unbind()
|
||||
|
||||
# Bind as the user to verify password
|
||||
try:
|
||||
user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
|
||||
user_conn.unbind()
|
||||
except Exception:
|
||||
logger.debug("LDAP password verify failed for %s", username)
|
||||
return None
|
||||
|
||||
# Map role from group membership
|
||||
role = "viewer"
|
||||
if admin_group_dn and admin_group_dn in member_of:
|
||||
role = "admin"
|
||||
elif operator_group_dn and operator_group_dn in member_of:
|
||||
role = "operator"
|
||||
|
||||
return {
|
||||
"username": ldap_username,
|
||||
"email": ldap_email,
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None:
|
||||
"""Async wrapper around synchronous LDAP auth. Returns user info or None."""
|
||||
if not _ldap_available():
|
||||
logger.warning("ldap3 not installed — LDAP auth unavailable")
|
||||
return None
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _ldap_authenticate_sync, cfg, username, password)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
from quart import session, redirect, url_for, abort, g
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
_ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin]
|
||||
|
||||
|
||||
def require_role(minimum_role: UserRole):
|
||||
"""Decorator: requires authenticated user with at least minimum_role.
|
||||
Also allows access for validated share-token requests (viewer level only).
|
||||
"""
|
||||
def decorator(f):
|
||||
@functools.wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Share-token requests bypass session auth for viewer-level endpoints
|
||||
if getattr(g, "share_viewer", False) and minimum_role == UserRole.viewer:
|
||||
return await f(*args, **kwargs)
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return redirect(url_for("auth.login"))
|
||||
user_role = UserRole(session.get("user_role", "viewer"))
|
||||
if _ROLE_ORDER.index(user_role) < _ROLE_ORDER.index(minimum_role):
|
||||
abort(403)
|
||||
return await f(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def login_user(user) -> None:
|
||||
"""Write user identity into the session."""
|
||||
session["user_id"] = user.id
|
||||
session["user_role"] = user.role.value
|
||||
session["username"] = user.username
|
||||
|
||||
|
||||
def logout_user() -> None:
|
||||
session.clear()
|
||||
|
||||
|
||||
def current_user_id() -> str | None:
|
||||
return session.get("user_id")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""fabledscryer/auth/oidc.py
|
||||
|
||||
OIDC Authorization Code flow helpers using httpx.
|
||||
No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.).
|
||||
|
||||
JWT signature verification is intentionally skipped for homelab use;
|
||||
we trust the HTTPS connection to the discovery-documented token/userinfo endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level cache: {discovery_url: (fetched_at, doc)}
|
||||
_discovery_cache: dict[str, tuple[float, dict]] = {}
|
||||
_DISCOVERY_TTL = 300 # seconds
|
||||
|
||||
|
||||
async def get_discovery(discovery_url: str) -> dict:
|
||||
"""Fetch and cache the OIDC provider discovery document."""
|
||||
now = time.monotonic()
|
||||
if discovery_url in _discovery_cache:
|
||||
fetched_at, doc = _discovery_cache[discovery_url]
|
||||
if now - fetched_at < _DISCOVERY_TTL:
|
||||
return doc
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(discovery_url)
|
||||
resp.raise_for_status()
|
||||
doc = resp.json()
|
||||
_discovery_cache[discovery_url] = (now, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def build_authorize_url(doc: dict, client_id: str, redirect_uri: str,
|
||||
scopes: str, state: str, nonce: str) -> str:
|
||||
"""Build the IdP authorization redirect URL."""
|
||||
import urllib.parse
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
}
|
||||
return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
async def exchange_code(doc: dict, client_id: str, client_secret: str,
|
||||
code: str, redirect_uri: str) -> dict:
|
||||
"""Exchange authorization code for tokens. Returns token response dict."""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
doc["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_userinfo(doc: dict, access_token: str) -> dict:
|
||||
"""Fetch user info from the IdP's userinfo endpoint."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
doc["userinfo_endpoint"],
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def decode_id_token_payload(id_token: str) -> dict:
|
||||
"""Decode ID token payload without verifying signature (homelab only)."""
|
||||
try:
|
||||
parts = id_token.split(".")
|
||||
if len(parts) < 2:
|
||||
return {}
|
||||
payload_b64 = parts[1]
|
||||
# Add padding
|
||||
payload_b64 += "=" * (4 - len(payload_b64) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def map_role(groups: list[str], admin_group: str, operator_group: str) -> str:
|
||||
"""Map IdP groups to a local role string."""
|
||||
if admin_group and admin_group in groups:
|
||||
return "admin"
|
||||
if operator_group and operator_group in groups:
|
||||
return "operator"
|
||||
return "viewer"
|
||||
|
||||
|
||||
def generate_state() -> str:
|
||||
return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=")
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
import bcrypt
|
||||
from quart import Blueprint, render_template, request, redirect, url_for, session
|
||||
from sqlalchemy import select, func
|
||||
from fabledscryer.auth.middleware import login_user, logout_user
|
||||
from fabledscryer.models.users import User, UserRole
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
async def _provision_external_user(app, username: str, email: str, role_str: str):
|
||||
"""Create or update a user from an external auth provider (OIDC/LDAP).
|
||||
|
||||
External users get a randomised unusable password so local login is blocked.
|
||||
Their role is updated on every login to reflect current IdP group membership.
|
||||
"""
|
||||
import secrets
|
||||
from fabledscryer.models.users import UserRole
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
new_role = UserRole(role_str)
|
||||
async with db.begin():
|
||||
if user is None:
|
||||
unusable_hash = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode()
|
||||
user = User(username=username, email=email or f"{username}@external",
|
||||
password_hash=unusable_hash, role=new_role)
|
||||
db.add(user)
|
||||
else:
|
||||
if user.role != new_role:
|
||||
user.role = new_role
|
||||
# Re-fetch committed user
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_user_count(app) -> int:
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(func.count()).select_from(User))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_user_by_username(app, username: str) -> User | None:
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@auth_bp.get("/login")
|
||||
async def login():
|
||||
from quart import current_app
|
||||
if await get_user_count(current_app) == 0:
|
||||
return redirect(url_for("auth.setup"))
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
return await render_template("auth/login.html",
|
||||
oidc_enabled=oidc_cfg.get("enabled", False))
|
||||
|
||||
|
||||
@auth_bp.post("/login")
|
||||
async def login_post():
|
||||
from quart import current_app
|
||||
from fabledscryer.core.audit import log_audit
|
||||
form = await request.form
|
||||
username = form.get("username", "").strip()
|
||||
password_str = form.get("password", "")
|
||||
password = password_str.encode()
|
||||
|
||||
# Try local auth first
|
||||
user = await get_user_by_username(current_app, username)
|
||||
if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()):
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "local", "role": user.role.value})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# Try LDAP fallback if enabled
|
||||
ldap_cfg = current_app.config.get("LDAP", {})
|
||||
if ldap_cfg.get("enabled"):
|
||||
from fabledscryer.auth.ldap_auth import ldap_authenticate
|
||||
ldap_info = await ldap_authenticate(ldap_cfg, username, password_str)
|
||||
if ldap_info:
|
||||
user = await _provision_external_user(
|
||||
current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"]
|
||||
)
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "ldap", "role": user.role.value})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
return await render_template(
|
||||
"auth/login.html",
|
||||
error="Invalid credentials",
|
||||
oidc_enabled=oidc_cfg.get("enabled", False),
|
||||
), 400
|
||||
|
||||
|
||||
@auth_bp.get("/login/oidc")
|
||||
async def login_oidc():
|
||||
from quart import current_app
|
||||
from fabledscryer.auth.oidc import get_discovery, build_authorize_url, generate_state
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
if not oidc_cfg.get("enabled") or not oidc_cfg.get("discovery_url"):
|
||||
return redirect(url_for("auth.login"))
|
||||
try:
|
||||
doc = await get_discovery(oidc_cfg["discovery_url"])
|
||||
except Exception as exc:
|
||||
return await render_template("auth/login.html", error=f"OIDC discovery failed: {exc}",
|
||||
oidc_enabled=True), 502
|
||||
state = generate_state()
|
||||
nonce = generate_state()
|
||||
session["oidc_state"] = state
|
||||
session["oidc_nonce"] = nonce
|
||||
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
|
||||
url = build_authorize_url(
|
||||
doc, oidc_cfg["client_id"], redirect_uri,
|
||||
oidc_cfg.get("scopes", "openid profile email"), state, nonce,
|
||||
)
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@auth_bp.get("/login/oidc/callback")
|
||||
async def login_oidc_callback():
|
||||
from quart import current_app
|
||||
from fabledscryer.auth.oidc import get_discovery, exchange_code, get_userinfo, map_role
|
||||
from fabledscryer.core.audit import log_audit
|
||||
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
error = request.args.get("error")
|
||||
if error:
|
||||
desc = request.args.get("error_description", error)
|
||||
return await render_template("auth/login.html", error=f"SSO error: {desc}",
|
||||
oidc_enabled=True), 400
|
||||
|
||||
code = request.args.get("code", "")
|
||||
state = request.args.get("state", "")
|
||||
if not code or state != session.pop("oidc_state", None):
|
||||
return await render_template("auth/login.html", error="Invalid SSO state — try again.",
|
||||
oidc_enabled=True), 400
|
||||
|
||||
try:
|
||||
doc = await get_discovery(oidc_cfg["discovery_url"])
|
||||
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
|
||||
tokens = await exchange_code(
|
||||
doc, oidc_cfg["client_id"], oidc_cfg["client_secret"], code, redirect_uri
|
||||
)
|
||||
userinfo = await get_userinfo(doc, tokens["access_token"])
|
||||
except Exception as exc:
|
||||
return await render_template("auth/login.html", error=f"SSO login failed: {exc}",
|
||||
oidc_enabled=True), 502
|
||||
|
||||
username_claim = oidc_cfg.get("username_claim", "preferred_username")
|
||||
email_claim = oidc_cfg.get("email_claim", "email")
|
||||
groups_claim = oidc_cfg.get("groups_claim", "groups")
|
||||
|
||||
username = userinfo.get(username_claim) or userinfo.get("sub", "")
|
||||
email = userinfo.get(email_claim, "") or f"{username}@oidc"
|
||||
groups = userinfo.get(groups_claim, [])
|
||||
if not isinstance(groups, list):
|
||||
groups = []
|
||||
role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", ""))
|
||||
|
||||
if not username:
|
||||
return await render_template("auth/login.html",
|
||||
error="SSO returned no username.",
|
||||
oidc_enabled=True), 400
|
||||
|
||||
user = await _provision_external_user(current_app, username, email, role)
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "oidc", "role": role})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
|
||||
@auth_bp.get("/logout")
|
||||
async def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth_bp.get("/setup")
|
||||
async def setup():
|
||||
from quart import current_app
|
||||
if await get_user_count(current_app) > 0:
|
||||
return redirect(url_for("auth.login"))
|
||||
return await render_template("auth/setup.html")
|
||||
|
||||
|
||||
@auth_bp.post("/setup")
|
||||
async def setup_post():
|
||||
from quart import current_app
|
||||
from fabledscryer.core.audit import log_audit
|
||||
if await get_user_count(current_app) > 0:
|
||||
return redirect(url_for("auth.login"))
|
||||
form = await request.form
|
||||
username = form.get("username", "").strip()
|
||||
email = form.get("email", "").strip()
|
||||
password = form.get("password", "").encode()
|
||||
if not username or not email or not password:
|
||||
return await render_template("auth/setup.html", error="All fields required"), 400
|
||||
pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()).decode()
|
||||
user = User(username=username, email=email, password_hash=pw_hash, role=UserRole.admin)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(user)
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.setup",
|
||||
detail={"username": username})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
import click
|
||||
from .app import create_app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--config", default="config.yaml", help="Path to config.yaml")
|
||||
@click.option("--host", default="0.0.0.0")
|
||||
@click.option("--port", default=5000, type=int)
|
||||
@click.option("--debug", is_flag=True, default=False)
|
||||
def main(config: str, host: str, port: int, debug: bool) -> None:
|
||||
"""Start the Fabled Scryer server."""
|
||||
app = create_app(config_path=Path(config))
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
@@ -0,0 +1,81 @@
|
||||
# fabledscryer/config.py
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SECRET_KEY_FILE = Path("/data/secret.key")
|
||||
|
||||
|
||||
def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]:
|
||||
"""Return the minimum bootstrap config: database_url and secret_key.
|
||||
|
||||
This is the only config read from files/env vars at startup.
|
||||
Everything else is stored in the app_settings DB table.
|
||||
|
||||
config_path is optional — used only for backwards compatibility with
|
||||
existing config.yaml deployments. The file is not required.
|
||||
"""
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
raw: dict[str, Any] = {}
|
||||
if config_path is None:
|
||||
config_path = Path("config.yaml")
|
||||
config_path = Path(config_path)
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with config_path.open() as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
database_url = (
|
||||
os.environ.get("FABLEDSCRYER_DATABASE_URL")
|
||||
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
|
||||
or raw.get("database", {}).get("url")
|
||||
)
|
||||
if not database_url:
|
||||
raise ValueError(
|
||||
"Database URL is required. Set FABLEDSCRYER_DATABASE_URL env var "
|
||||
"or add 'database.url' to config.yaml."
|
||||
)
|
||||
|
||||
secret_key = _resolve_secret_key(raw)
|
||||
plugin_dir = (
|
||||
os.environ.get("FABLEDSCRYER_PLUGIN_DIR")
|
||||
or raw.get("plugin_dir", "plugins")
|
||||
)
|
||||
|
||||
return {
|
||||
"database_url": database_url,
|
||||
"secret_key": secret_key,
|
||||
"plugin_dir": plugin_dir,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_secret_key(raw: dict) -> str:
|
||||
"""Resolve secret_key: env var → file → auto-generate."""
|
||||
from_env = os.environ.get("FABLEDSCRYER_SECRET_KEY") or raw.get("secret_key")
|
||||
if from_env:
|
||||
return from_env
|
||||
|
||||
if _SECRET_KEY_FILE.exists():
|
||||
key = _SECRET_KEY_FILE.read_text().strip()
|
||||
if key:
|
||||
return key
|
||||
|
||||
key = secrets.token_hex(32)
|
||||
try:
|
||||
_SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_SECRET_KEY_FILE.write_text(key)
|
||||
logger.info("Generated new secret key and saved to %s", _SECRET_KEY_FILE)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Could not write secret key to %s (%s). "
|
||||
"Key will not persist across restarts.",
|
||||
_SECRET_KEY_FILE, exc,
|
||||
)
|
||||
return key
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fabledscryer.models.alerts import (
|
||||
AlertEvent,
|
||||
AlertOperator,
|
||||
AlertRule,
|
||||
AlertState,
|
||||
AlertStateEnum,
|
||||
MaintenanceWindow,
|
||||
)
|
||||
from fabledscryer.models.metrics import PluginMetric
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_app: "Quart | None" = None
|
||||
|
||||
|
||||
def init_alerts(app: "Quart") -> None:
|
||||
"""Store app reference for use in notification dispatch tasks."""
|
||||
global _app
|
||||
_app = app
|
||||
|
||||
|
||||
async def record_metric(
|
||||
session: AsyncSession,
|
||||
source_module: str,
|
||||
resource_name: str,
|
||||
metric_name: str,
|
||||
value: float,
|
||||
) -> None:
|
||||
"""Write a metric row and evaluate alert rules inline.
|
||||
|
||||
Must be called inside an active transaction:
|
||||
async with session.begin():
|
||||
await record_metric(session, ...)
|
||||
|
||||
Notification I/O is deferred via asyncio.create_task() so no network
|
||||
calls occur while the transaction is open.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Write metric row
|
||||
metric = PluginMetric(
|
||||
source_module=source_module,
|
||||
resource_name=resource_name,
|
||||
metric_name=metric_name,
|
||||
value=value,
|
||||
recorded_at=now,
|
||||
)
|
||||
session.add(metric)
|
||||
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
|
||||
result = await session.execute(
|
||||
select(AlertRule).where(
|
||||
AlertRule.source_module == source_module,
|
||||
AlertRule.resource_name == resource_name,
|
||||
AlertRule.metric_name == metric_name,
|
||||
AlertRule.enabled.is_(True),
|
||||
)
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
for rule in rules:
|
||||
state_result = await session.execute(
|
||||
select(AlertState).where(AlertState.rule_id == rule.id)
|
||||
)
|
||||
alert_state = state_result.scalar_one_or_none()
|
||||
if alert_state is None:
|
||||
logger.warning(f"No alert_state row for rule {rule.id}, skipping")
|
||||
continue
|
||||
|
||||
notifications = await _evaluate_rule(session, rule, alert_state, value, now)
|
||||
|
||||
for notif_type, event_id in notifications:
|
||||
if _app is not None:
|
||||
asyncio.create_task(
|
||||
_dispatch_notification(
|
||||
_app, notif_type, rule, value, event_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_breached(value: float, operator: AlertOperator, threshold: float) -> bool:
|
||||
op = operator.value
|
||||
if op == ">":
|
||||
return value > threshold
|
||||
if op == "<":
|
||||
return value < threshold
|
||||
if op == ">=":
|
||||
return value >= threshold
|
||||
if op == "<=":
|
||||
return value <= threshold
|
||||
if op == "==":
|
||||
return value == threshold
|
||||
if op == "!=":
|
||||
return value != threshold
|
||||
return False
|
||||
|
||||
|
||||
async def _evaluate_rule(
|
||||
session: AsyncSession,
|
||||
rule: AlertRule,
|
||||
state: AlertState,
|
||||
value: float,
|
||||
now: datetime,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Evaluate a single rule against a metric value. Returns list of (notif_type, event_id)."""
|
||||
breached = _is_breached(value, rule.operator, rule.threshold)
|
||||
from_state = state.state
|
||||
notifications: list[tuple[str, str]] = []
|
||||
|
||||
if state.state == AlertStateEnum.inactive:
|
||||
if breached:
|
||||
state.consecutive_failure_count = 1
|
||||
if state.consecutive_failure_count >= rule.consecutive_failures_required:
|
||||
state.state = AlertStateEnum.firing
|
||||
state.last_transition_at = now
|
||||
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
|
||||
notifications.append(("firing", event_id))
|
||||
else:
|
||||
state.state = AlertStateEnum.pending
|
||||
state.last_transition_at = now
|
||||
|
||||
elif state.state == AlertStateEnum.pending:
|
||||
if breached:
|
||||
state.consecutive_failure_count += 1
|
||||
if state.consecutive_failure_count >= rule.consecutive_failures_required:
|
||||
state.state = AlertStateEnum.firing
|
||||
state.last_transition_at = now
|
||||
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
|
||||
notifications.append(("firing", event_id))
|
||||
else:
|
||||
state.consecutive_failure_count = 0
|
||||
state.state = AlertStateEnum.inactive
|
||||
state.last_transition_at = now
|
||||
|
||||
elif state.state == AlertStateEnum.firing:
|
||||
if not breached:
|
||||
# RESOLVED is transient: record event, notify, then immediately go inactive
|
||||
event_id = _add_event(session, rule.id, from_state.value, "resolved", value, now)
|
||||
notifications.append(("resolved", event_id))
|
||||
state.consecutive_failure_count = 0
|
||||
state.state = AlertStateEnum.inactive
|
||||
state.last_transition_at = now
|
||||
|
||||
elif state.state == AlertStateEnum.acknowledged:
|
||||
if not breached:
|
||||
event_id = _add_event(session, rule.id, from_state.value, "resolved", value, now)
|
||||
notifications.append(("resolved", event_id))
|
||||
state.consecutive_failure_count = 0
|
||||
state.state = AlertStateEnum.inactive
|
||||
state.last_transition_at = now
|
||||
state.acknowledged_by = None
|
||||
state.acknowledged_at = None
|
||||
else:
|
||||
state.consecutive_failure_count += 1
|
||||
if state.consecutive_failure_count >= rule.consecutive_failures_required:
|
||||
state.state = AlertStateEnum.firing
|
||||
state.last_transition_at = now
|
||||
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
|
||||
notifications.append(("firing", event_id))
|
||||
|
||||
await session.flush()
|
||||
return notifications
|
||||
|
||||
|
||||
def _add_event(
|
||||
session: AsyncSession,
|
||||
rule_id: str,
|
||||
from_state: str,
|
||||
to_state: str,
|
||||
value: float,
|
||||
now: datetime,
|
||||
) -> str:
|
||||
"""Add an AlertEvent row and return its ID."""
|
||||
event_id = str(uuid.uuid4())
|
||||
event = AlertEvent(
|
||||
id=event_id,
|
||||
rule_id=rule_id,
|
||||
from_state=from_state,
|
||||
to_state=to_state,
|
||||
metric_value=value,
|
||||
transitioned_at=now,
|
||||
)
|
||||
session.add(event)
|
||||
return event_id
|
||||
|
||||
|
||||
async def _dispatch_notification(
|
||||
app: "Quart",
|
||||
notif_type: str,
|
||||
rule: AlertRule,
|
||||
value: float,
|
||||
event_id: str,
|
||||
) -> None:
|
||||
"""Run after the transaction commits. Sends notifications and updates the event row."""
|
||||
from fabledscryer.core.notifications import dispatch_notifications
|
||||
|
||||
alert_data = {
|
||||
"rule_name": rule.name,
|
||||
"state": notif_type.upper(),
|
||||
"metric": rule.metric_name,
|
||||
"value": value,
|
||||
"threshold": rule.threshold,
|
||||
"resource": rule.resource_name,
|
||||
"source_module": rule.source_module,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
smtp_cfg = app.config.get("SMTP", {})
|
||||
webhook_cfg = app.config.get("WEBHOOK", {})
|
||||
results = await dispatch_notifications(smtp_cfg, webhook_cfg, alert_data)
|
||||
|
||||
# Update event row with delivery results in a new transaction
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
event_result = await db.execute(
|
||||
select(AlertEvent).where(AlertEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if event:
|
||||
email_r = results.get("email")
|
||||
if email_r:
|
||||
event.email_sent = email_r["sent"]
|
||||
event.email_error = email_r.get("error")
|
||||
webhook_r = results.get("webhook")
|
||||
if webhook_r:
|
||||
event.webhook_sent = webhook_r["sent"]
|
||||
event.webhook_error = webhook_r.get("error")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""fabledscryer/core/audit.py
|
||||
|
||||
Helpers for writing audit log entries. Each call opens its own DB
|
||||
session so audit events are committed independently of the calling
|
||||
route's transaction (audit is only written after the main action
|
||||
succeeds, by calling this after the main `async with db.begin()` block).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_audit(
|
||||
app,
|
||||
user_id: str | None,
|
||||
username: str,
|
||||
action: str,
|
||||
entity_type: str | None = None,
|
||||
entity_id: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""Write one audit event. Never raises — failures are logged and swallowed."""
|
||||
from fabledscryer.models.audit import AuditEvent
|
||||
try:
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(AuditEvent(
|
||||
user_id=user_id,
|
||||
username=username or "unknown",
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
detail_json=json.dumps(detail) if detail else None,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("Failed to write audit event action=%r", action)
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from fabledscryer.models.monitors import DnsResult, PingResult
|
||||
from fabledscryer.models.metrics import PluginMetric
|
||||
from fabledscryer.models.ansible import AnsibleRun
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_cleanup(app: "Quart") -> None:
|
||||
"""Delete rows older than DATA_RETENTION_DAYS from time-series tables."""
|
||||
retention_days: int = app.config.get("DATA_RETENTION_DAYS", 90)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
for model, ts_col in [
|
||||
(PingResult, PingResult.probed_at),
|
||||
(DnsResult, DnsResult.resolved_at),
|
||||
(PluginMetric, PluginMetric.recorded_at),
|
||||
(AnsibleRun, AnsibleRun.started_at),
|
||||
]:
|
||||
result = await session.execute(
|
||||
delete(model).where(ts_col < cutoff)
|
||||
)
|
||||
if result.rowcount:
|
||||
logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}")
|
||||
@@ -0,0 +1,82 @@
|
||||
# fabledscryer/core/migration_runner.py
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def discover_all_plugin_migration_dirs(plugin_dir: Path) -> list[Path]:
|
||||
"""Scan plugin_dir for any migrations/ subdirs, regardless of enabled status.
|
||||
|
||||
Used by run_core_migrations so Alembic can resolve the full revision graph
|
||||
even when a plugin migration is already stamped in alembic_version.
|
||||
"""
|
||||
dirs: list[Path] = []
|
||||
if not plugin_dir.exists():
|
||||
return dirs
|
||||
for entry in sorted(plugin_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
mdir = entry / "migrations"
|
||||
if mdir.exists():
|
||||
dirs.append(mdir)
|
||||
return dirs
|
||||
|
||||
|
||||
def run_core_migrations(db_url: str, plugin_dir: Path | None = None) -> None:
|
||||
"""Run core Alembic migrations.
|
||||
|
||||
Includes all discovered plugin migration dirs (if plugin_dir given) so
|
||||
Alembic can resolve any previously-applied plugin revisions in the graph.
|
||||
Called first so the app_settings table exists before loading settings.
|
||||
"""
|
||||
dirs = discover_all_plugin_migration_dirs(plugin_dir) if plugin_dir else []
|
||||
_run(db_url, plugin_migration_dirs=dirs)
|
||||
|
||||
|
||||
def run_plugin_migrations(db_url: str, plugin_migration_dirs: list[Path]) -> None:
|
||||
"""Run plugin Alembic migrations after settings are loaded."""
|
||||
if plugin_migration_dirs:
|
||||
_run(db_url, plugin_migration_dirs=plugin_migration_dirs)
|
||||
|
||||
|
||||
def run_migrations(db_url: str, plugin_migration_dirs: list[Path] | None = None) -> None:
|
||||
"""Run core + plugin Alembic migrations (single-phase convenience wrapper)."""
|
||||
_run(db_url, plugin_migration_dirs=plugin_migration_dirs or [])
|
||||
|
||||
|
||||
def _run(db_url: str, plugin_migration_dirs: list[Path]) -> None:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
core_migrations = Path(__file__).parent.parent / "migrations"
|
||||
core_versions = core_migrations / "versions"
|
||||
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(core_migrations))
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
version_locs = [str(core_versions)]
|
||||
for plugin_dir in plugin_migration_dirs:
|
||||
pv = plugin_dir / "versions"
|
||||
if pv.exists():
|
||||
version_locs.append(str(pv))
|
||||
|
||||
cfg.set_main_option("version_locations", " ".join(version_locs))
|
||||
|
||||
logger.info("Running migrations (version_locations: %s)", " ".join(version_locs))
|
||||
command.upgrade(cfg, "heads")
|
||||
logger.info("Migrations complete")
|
||||
|
||||
|
||||
def get_plugin_migration_dirs(plugin_dir: Path, plugins_cfg: dict) -> list[Path]:
|
||||
"""Return migration directories for enabled plugins (filesystem scan only)."""
|
||||
dirs = []
|
||||
for name, cfg in plugins_cfg.items():
|
||||
if not cfg.get("enabled", False):
|
||||
continue
|
||||
mdir = plugin_dir / name / "migrations"
|
||||
if mdir.exists():
|
||||
dirs.append(mdir)
|
||||
return dirs
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_core_head() -> str:
|
||||
"""Return the current head revision ID of the core migration scripts.
|
||||
|
||||
Called at migration authoring time (when running ``alembic revision``),
|
||||
not at application runtime. The returned ID should be captured statically
|
||||
in a plugin's ``depends_on`` field.
|
||||
"""
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
migrations_dir = Path(__file__).parent.parent / "migrations"
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(migrations_dir))
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
heads = script.get_heads()
|
||||
if len(heads) != 1:
|
||||
raise RuntimeError(
|
||||
f"Expected exactly 1 core migration head, got {len(heads)}: {heads}"
|
||||
)
|
||||
return heads[0]
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
import types
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
from jinja2 import Template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def dispatch_notifications(
|
||||
smtp_cfg: dict,
|
||||
webhook_cfg: dict,
|
||||
alert_data: dict,
|
||||
) -> dict:
|
||||
"""Dispatch all configured notification channels. Returns results dict."""
|
||||
results = {}
|
||||
|
||||
if smtp_cfg.get("host") and smtp_cfg.get("recipients"):
|
||||
results["email"] = await _send_email(smtp_cfg, alert_data)
|
||||
|
||||
if webhook_cfg.get("url"):
|
||||
results["webhook"] = await _send_webhook(webhook_cfg, alert_data)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _send_email(cfg: dict, alert: dict) -> dict:
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"[Fabled Scryer] {alert['state']} — {alert['rule_name']}"
|
||||
msg["From"] = cfg.get("username", "fabledscryer@localhost")
|
||||
msg["To"] = ", ".join(cfg["recipients"])
|
||||
msg.set_content(
|
||||
f"Alert: {alert['state']}\n"
|
||||
f"Rule: {alert['rule_name']}\n"
|
||||
f"Resource: {alert['resource']}\n"
|
||||
f"Metric: {alert['metric']} = {alert['value']}\n"
|
||||
f"Threshold: {alert['threshold']}\n"
|
||||
f"Time: {alert['timestamp']}\n"
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
||||
return {"sent": True, "error": None}
|
||||
except Exception as exc:
|
||||
logger.error(f"Email notification failed: {exc}")
|
||||
return {"sent": False, "error": str(exc)}
|
||||
|
||||
|
||||
def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
||||
ctx = ssl.create_default_context() if cfg.get("tls", True) else None
|
||||
with smtplib.SMTP(cfg["host"], cfg.get("port", 587)) as smtp:
|
||||
if ctx:
|
||||
smtp.starttls(context=ctx)
|
||||
if cfg.get("username"):
|
||||
smtp.login(cfg["username"], cfg.get("password", ""))
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
async def _send_webhook(cfg: dict, alert: dict) -> dict:
|
||||
template_str = cfg.get(
|
||||
"template",
|
||||
'{"content": "{{ alert.state }} — {{ alert.rule_name }}"}',
|
||||
)
|
||||
|
||||
# Render Jinja2 template with alert as a namespace object (attribute access)
|
||||
try:
|
||||
alert_ns = types.SimpleNamespace(**alert)
|
||||
rendered = Template(template_str).render(alert=alert_ns)
|
||||
except Exception as exc:
|
||||
error = f"Template render failed: {exc}"
|
||||
logger.error(error)
|
||||
return {"sent": False, "error": error}
|
||||
|
||||
# Validate JSON before sending
|
||||
try:
|
||||
json.loads(rendered)
|
||||
except json.JSONDecodeError as exc:
|
||||
error = f"Rendered webhook template is not valid JSON: {exc}"
|
||||
logger.error(error)
|
||||
return {"sent": False, "error": error}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.post(
|
||||
cfg["url"],
|
||||
content=rendered,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"sent": True, "error": None}
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
logger.error(f"Webhook notification failed: {error}")
|
||||
return {"sent": False, "error": error}
|
||||
@@ -0,0 +1,93 @@
|
||||
# fabledscryer/core/plugin_index.py
|
||||
"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_TTL = 300 # seconds
|
||||
# Per-URL cache: url → (raw_data, fetched_at monotonic)
|
||||
_url_cache: dict[str, tuple[dict, float]] = {}
|
||||
|
||||
|
||||
class CatalogPlugin:
|
||||
"""One entry from the remote plugin index."""
|
||||
|
||||
__slots__ = (
|
||||
"name", "version", "description", "author",
|
||||
"min_app_version", "download_url", "checksum_sha256",
|
||||
"repository_url", "homepage", "license", "tags",
|
||||
)
|
||||
|
||||
def __init__(self, data: dict[str, Any]) -> None:
|
||||
self.name: str = data.get("name", "")
|
||||
self.version: str = data.get("version", "0.0.0")
|
||||
self.description: str = data.get("description", "")
|
||||
self.author: str = data.get("author", "")
|
||||
self.min_app_version: str | None = data.get("min_app_version")
|
||||
self.download_url: str = data.get("download_url", "")
|
||||
self.checksum_sha256: str = data.get("checksum_sha256", "")
|
||||
self.repository_url: str = data.get("repository_url", "")
|
||||
self.homepage: str = data.get("homepage", "")
|
||||
self.license: str = data.get("license", "")
|
||||
self.tags: list[str] = data.get("tags", [])
|
||||
|
||||
|
||||
async def _fetch_one(url: str, force: bool = False) -> list[CatalogPlugin]:
|
||||
"""Fetch a single index URL, using per-URL cache."""
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
now = time.monotonic()
|
||||
if not force and url in _url_cache:
|
||||
raw, fetched_at = _url_cache[url]
|
||||
if now - fetched_at < _CACHE_TTL:
|
||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
raw = yaml.safe_load(resp.text) or {}
|
||||
_url_cache[url] = (raw, now)
|
||||
logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", [])))
|
||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch plugin catalog from %s", url)
|
||||
if url in _url_cache:
|
||||
raw, _ = _url_cache[url]
|
||||
logger.warning("Returning stale cached catalog for %s", url)
|
||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_catalog(
|
||||
index_urls: str | list[str],
|
||||
force: bool = False,
|
||||
) -> list[CatalogPlugin]:
|
||||
"""Fetch and merge plugin catalogs from one or more index URLs.
|
||||
|
||||
Results are cached per-URL for _CACHE_TTL seconds. Plugins with the same
|
||||
name across repos are deduplicated — the first occurrence (repo list order)
|
||||
wins. Pass force=True to bypass all caches.
|
||||
"""
|
||||
if isinstance(index_urls, str):
|
||||
index_urls = [index_urls]
|
||||
|
||||
seen: dict[str, CatalogPlugin] = {}
|
||||
for url in index_urls:
|
||||
url = url.strip()
|
||||
if not url:
|
||||
continue
|
||||
for plugin in await _fetch_one(url, force=force):
|
||||
if plugin.name not in seen:
|
||||
seen[plugin.name] = plugin
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def clear_catalog_cache() -> None:
|
||||
"""Invalidate all in-process catalog caches."""
|
||||
_url_cache.clear()
|
||||
@@ -0,0 +1,436 @@
|
||||
# fabledscryer/core/plugin_manager.py
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yaml
|
||||
from packaging.version import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track which plugins were successfully loaded so hot-reload knows
|
||||
# whether to register a blueprint (new plugin) or skip it (already registered).
|
||||
_LOADED_PLUGINS: set[str] = set()
|
||||
|
||||
# Track plugins that failed to load: name → human-readable reason.
|
||||
_FAILED_PLUGINS: dict[str, str] = {}
|
||||
|
||||
|
||||
def get_plugin_failures() -> dict[str, str]:
|
||||
"""Return a copy of the failed-plugin registry (name → error message)."""
|
||||
return dict(_FAILED_PLUGINS)
|
||||
|
||||
|
||||
def _import_plugin(name: str, plugin_path: Path):
|
||||
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
|
||||
|
||||
Using importlib.import_module(name) fails for plugins whose names shadow
|
||||
Python stdlib modules (e.g. the 'http' plugin vs stdlib's 'http' package).
|
||||
This helper loads from the filesystem path directly and registers the module
|
||||
under a namespaced key so relative imports within the plugin still work.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
module_key = f"_fabledscryer_plugin_{name}"
|
||||
if module_key in sys.modules:
|
||||
return sys.modules[module_key]
|
||||
|
||||
init_file = plugin_path / "__init__.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_key,
|
||||
str(init_file),
|
||||
submodule_search_locations=[str(plugin_path)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not create import spec for plugin {name!r}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = module_key
|
||||
sys.modules[module_key] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(module_key, None)
|
||||
raise
|
||||
return module
|
||||
|
||||
|
||||
def load_plugins(app: "Quart") -> None:
|
||||
"""Import all enabled plugins and register their blueprints and scheduled tasks.
|
||||
|
||||
For each plugin listed as enabled in config.yaml under 'plugins':
|
||||
1. Locate the plugin directory under PLUGIN_DIR
|
||||
2. Load and validate plugin.yaml (name must match dir, min_app_version check)
|
||||
3. Merge plugin.yaml config defaults with user overrides in app.config["PLUGINS"]
|
||||
4. Import the plugin Python package
|
||||
5. Validate required exports: setup() and get_scheduled_tasks()
|
||||
6. Call setup(app)
|
||||
7. Register blueprint at /plugins/<name>/ if get_blueprint() exists
|
||||
8. Append get_scheduled_tasks() results to app._task_registry
|
||||
"""
|
||||
import fabledscryer
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"])
|
||||
plugins_cfg: dict = app.config["PLUGINS"]
|
||||
|
||||
# Ensure plugin_dir is on sys.path so plugins are importable by name
|
||||
plugin_dir_str = str(plugin_dir.resolve())
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
|
||||
for name, cfg in list(plugins_cfg.items()):
|
||||
if not cfg.get("enabled", False):
|
||||
continue
|
||||
|
||||
plugin_path = plugin_dir / name
|
||||
if not plugin_path.exists():
|
||||
_FAILED_PLUGINS[name] = f"Plugin directory not found: {plugin_path}"
|
||||
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
|
||||
continue
|
||||
|
||||
# Load and validate plugin.yaml
|
||||
yaml_path = plugin_path / "plugin.yaml"
|
||||
if not yaml_path.exists():
|
||||
_FAILED_PLUGINS[name] = "plugin.yaml not found"
|
||||
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
|
||||
continue
|
||||
|
||||
try:
|
||||
with yaml_path.open() as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Failed to read plugin.yaml: {exc}"
|
||||
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
|
||||
continue
|
||||
|
||||
if meta.get("name") != name:
|
||||
_FAILED_PLUGINS[name] = (
|
||||
f"plugin.yaml name {meta.get('name')!r} does not match directory name"
|
||||
)
|
||||
logger.error(
|
||||
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
|
||||
name, meta.get("name"),
|
||||
)
|
||||
continue
|
||||
|
||||
min_ver = meta.get("min_app_version")
|
||||
if min_ver:
|
||||
try:
|
||||
if Version(fabledscryer.__version__) < Version(min_ver):
|
||||
_FAILED_PLUGINS[name] = (
|
||||
f"Requires fabledscryer>={min_ver}, running {fabledscryer.__version__}"
|
||||
)
|
||||
logger.error(
|
||||
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
|
||||
name, min_ver, fabledscryer.__version__,
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Invalid min_app_version {min_ver!r}: {exc}"
|
||||
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
|
||||
continue
|
||||
|
||||
# Merge plugin.yaml config defaults with user overrides (non-"enabled" keys).
|
||||
# If a stored value's type doesn't match the default (e.g. a list default
|
||||
# was corrupted to a string in the DB), fall back to the yaml default.
|
||||
defaults = meta.get("config", {})
|
||||
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
|
||||
merged = {"enabled": True, **defaults}
|
||||
for k, v in user_overrides.items():
|
||||
default_v = defaults.get(k)
|
||||
if default_v is not None and type(v) is not type(default_v):
|
||||
logger.warning(
|
||||
"Plugin %r: config key %r has wrong type (%s), using yaml default",
|
||||
name, k, type(v).__name__,
|
||||
)
|
||||
else:
|
||||
merged[k] = v
|
||||
plugins_cfg[name] = merged
|
||||
|
||||
# Import the plugin package (file-path load avoids stdlib name collisions)
|
||||
try:
|
||||
module = _import_plugin(name, plugin_path)
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Import error: {exc}"
|
||||
logger.exception("Plugin %r: import failed, skipping", name)
|
||||
continue
|
||||
|
||||
# Validate required exports
|
||||
if not hasattr(module, "setup"):
|
||||
_FAILED_PLUGINS[name] = "Missing required export: setup()"
|
||||
logger.error("Plugin %r: missing required export 'setup', skipping", name)
|
||||
continue
|
||||
if not hasattr(module, "get_scheduled_tasks"):
|
||||
_FAILED_PLUGINS[name] = "Missing required export: get_scheduled_tasks()"
|
||||
logger.error(
|
||||
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
|
||||
)
|
||||
continue
|
||||
|
||||
# Call setup
|
||||
try:
|
||||
module.setup(app)
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"setup() failed: {exc}"
|
||||
logger.exception("Plugin %r: setup() raised, skipping", name)
|
||||
continue
|
||||
|
||||
# Register blueprint (optional)
|
||||
if hasattr(module, "get_blueprint"):
|
||||
try:
|
||||
bp = module.get_blueprint()
|
||||
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
||||
logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, name)
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Blueprint registration failed: {exc}"
|
||||
logger.exception("Plugin %r: get_blueprint() raised", name)
|
||||
continue
|
||||
|
||||
# Register scheduled tasks
|
||||
try:
|
||||
tasks = module.get_scheduled_tasks()
|
||||
app._task_registry.extend(tasks)
|
||||
logger.info("Plugin %r: registered %d scheduled task(s)", name, len(tasks))
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"get_scheduled_tasks() failed: {exc}"
|
||||
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
|
||||
continue
|
||||
|
||||
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
|
||||
_LOADED_PLUGINS.add(name)
|
||||
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
||||
|
||||
|
||||
async def download_and_install_plugin(
|
||||
app: "Quart",
|
||||
name: str,
|
||||
download_url: str,
|
||||
expected_sha256: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""Download a plugin zip, verify its checksum, and extract it to PLUGIN_DIR.
|
||||
|
||||
Returns (success, message). Does NOT hot-reload — call hot_reload_plugin()
|
||||
after this to activate the plugin without a restart (new plugins only).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
|
||||
# Ensure plugin_dir is on sys.path (may not be set yet if no plugins were
|
||||
# enabled at startup)
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
|
||||
try:
|
||||
logger.info("Downloading plugin %r from %s", name, download_url)
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||
resp = await client.get(download_url)
|
||||
resp.raise_for_status()
|
||||
content = resp.content
|
||||
except Exception as exc:
|
||||
msg = f"Download failed: {exc}"
|
||||
logger.error("Plugin %r: %s", name, msg)
|
||||
return False, msg
|
||||
|
||||
# Verify checksum if provided
|
||||
if expected_sha256:
|
||||
actual = hashlib.sha256(content).hexdigest()
|
||||
if actual != expected_sha256.lower():
|
||||
msg = f"Checksum mismatch: expected {expected_sha256}, got {actual}"
|
||||
logger.error("Plugin %r: %s", name, msg)
|
||||
return False, msg
|
||||
|
||||
# Extract the zip
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / f"{name}.zip"
|
||||
zip_path.write_bytes(content)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
members = zf.namelist()
|
||||
# Collect unique top-level names (dirs and loose files)
|
||||
top_level = {m.split("/")[0] for m in members}
|
||||
|
||||
dest = plugin_dir / name
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Detect a single top-level directory to strip. Handles:
|
||||
# name/ (clean plugin zip)
|
||||
# name-v1.0.0/ (GitHub release tag archive)
|
||||
# fabledscryer-plugins-main/name/ (whole-repo archive)
|
||||
# Strategy: if there is exactly one top-level item and it is a
|
||||
# directory whose name starts with the plugin name (case-insensitive),
|
||||
# strip it. Otherwise extract flat into dest.
|
||||
prefix: str | None = None
|
||||
if len(top_level) == 1:
|
||||
candidate = list(top_level)[0]
|
||||
if candidate.lower().startswith(name.lower()):
|
||||
prefix = candidate + "/"
|
||||
|
||||
for member in members:
|
||||
if prefix:
|
||||
if not member.startswith(prefix):
|
||||
continue
|
||||
rel = member[len(prefix):]
|
||||
else:
|
||||
rel = member
|
||||
if not rel or rel.endswith("/"):
|
||||
if rel:
|
||||
(dest / rel.rstrip("/")).mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(zf.read(member))
|
||||
except Exception as exc:
|
||||
msg = f"Extraction failed: {exc}"
|
||||
logger.error("Plugin %r: %s", name, msg)
|
||||
return False, msg
|
||||
|
||||
# Run migrations for the newly installed plugin.
|
||||
# Include all plugin migration dirs so Alembic can resolve any previously-stamped
|
||||
# revisions from other plugins already in alembic_version.
|
||||
try:
|
||||
mdir = plugin_dir / name / "migrations"
|
||||
if mdir.exists():
|
||||
from fabledscryer.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed after install", name)
|
||||
return False, "Plugin extracted but migrations failed — check logs"
|
||||
|
||||
logger.info("Plugin %r installed successfully", name)
|
||||
return True, "Installed"
|
||||
|
||||
|
||||
def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
||||
"""Activate a newly installed plugin without restarting the app.
|
||||
|
||||
This works for plugins that were NOT previously loaded. For plugins that
|
||||
are already registered (blueprint already mounted), a full restart is
|
||||
required to pick up code changes — use restart_app() in that case.
|
||||
|
||||
Returns (success, message).
|
||||
"""
|
||||
import fabledscryer
|
||||
|
||||
if name in _LOADED_PLUGINS:
|
||||
return False, "Plugin already loaded — restart required to apply updates"
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
plugin_path = plugin_dir / name
|
||||
|
||||
if not plugin_path.exists():
|
||||
return False, f"Plugin directory {plugin_path} not found"
|
||||
|
||||
yaml_path = plugin_path / "plugin.yaml"
|
||||
if not yaml_path.exists():
|
||||
return False, "plugin.yaml not found"
|
||||
|
||||
try:
|
||||
with yaml_path.open() as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
return False, f"Could not read plugin.yaml: {exc}"
|
||||
|
||||
if meta.get("name") != name:
|
||||
return False, f"plugin.yaml name mismatch: expected {name!r}, got {meta.get('name')!r}"
|
||||
|
||||
min_ver = meta.get("min_app_version")
|
||||
if min_ver:
|
||||
try:
|
||||
if Version(fabledscryer.__version__) < Version(min_ver):
|
||||
return False, (
|
||||
f"Plugin requires fabledscryer>={min_ver} "
|
||||
f"but running {fabledscryer.__version__}"
|
||||
)
|
||||
except Exception:
|
||||
return False, f"Invalid min_app_version: {min_ver!r}"
|
||||
|
||||
# Merge config defaults with any stored user config.
|
||||
# Discard stored values whose type doesn't match the yaml default (corruption guard).
|
||||
defaults = meta.get("config", {})
|
||||
stored_cfg = app.config.get("PLUGINS", {}).get(name, {})
|
||||
merged = {"enabled": True, **defaults}
|
||||
for k, v in stored_cfg.items():
|
||||
if k == "enabled":
|
||||
continue
|
||||
default_v = defaults.get(k)
|
||||
if default_v is not None and type(v) is not type(default_v):
|
||||
logger.warning("Plugin %r: config key %r has wrong type, using yaml default", name, k)
|
||||
else:
|
||||
merged[k] = v
|
||||
app.config["PLUGINS"][name] = merged
|
||||
|
||||
# Run migrations if the plugin has them (safe to re-run — alembic is idempotent).
|
||||
# Pass ALL plugin migration dirs so Alembic can resolve any previously-stamped
|
||||
# revisions from other plugins that are already in alembic_version.
|
||||
mdir = plugin_path / "migrations"
|
||||
if mdir.exists():
|
||||
try:
|
||||
from fabledscryer.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed during hot-reload", name)
|
||||
return False, "Migration failed — check logs"
|
||||
|
||||
# Import (file-path load avoids stdlib name collisions)
|
||||
try:
|
||||
module = _import_plugin(name, plugin_path)
|
||||
except Exception as exc:
|
||||
return False, f"Import failed: {exc}"
|
||||
|
||||
if not hasattr(module, "setup") or not hasattr(module, "get_scheduled_tasks"):
|
||||
return False, "Plugin missing required exports (setup, get_scheduled_tasks)"
|
||||
|
||||
try:
|
||||
module.setup(app)
|
||||
except Exception as exc:
|
||||
return False, f"setup() raised: {exc}"
|
||||
|
||||
if hasattr(module, "get_blueprint"):
|
||||
try:
|
||||
bp = module.get_blueprint()
|
||||
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
||||
except Exception as exc:
|
||||
return False, f"Blueprint registration failed: {exc}"
|
||||
|
||||
try:
|
||||
tasks = module.get_scheduled_tasks()
|
||||
# De-duplicate: skip tasks whose name already exists in the registry
|
||||
existing_names = {t.name for t in app._task_registry}
|
||||
new_tasks = [t for t in tasks if t.name not in existing_names]
|
||||
app._task_registry.extend(new_tasks)
|
||||
except Exception as exc:
|
||||
return False, f"get_scheduled_tasks() raised: {exc}"
|
||||
|
||||
_LOADED_PLUGINS.add(name)
|
||||
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
|
||||
return True, f"Plugin activated (v{meta.get('version', '?')})"
|
||||
|
||||
|
||||
def restart_app() -> None:
|
||||
"""Replace the current process with a fresh copy (in-app restart).
|
||||
|
||||
Used when a plugin update requires a full restart to take effect.
|
||||
In Docker the container will be relaunched by the restart policy.
|
||||
"""
|
||||
logger.info("Initiating in-app restart via os.execv")
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
name: str
|
||||
coro_factory: Callable[[], Coroutine]
|
||||
interval_seconds: int
|
||||
run_on_startup: bool = False
|
||||
|
||||
|
||||
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
|
||||
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
|
||||
last_run: dict[str, float] = {}
|
||||
|
||||
for task in tasks:
|
||||
if task.run_on_startup:
|
||||
logger.info(f"Startup task: {task.name}")
|
||||
asyncio.create_task(_run_task(task))
|
||||
last_run[task.name] = asyncio.get_event_loop().time()
|
||||
|
||||
while True:
|
||||
now = asyncio.get_event_loop().time()
|
||||
for task in tasks:
|
||||
last = last_run.get(task.name, 0)
|
||||
if now - last >= task.interval_seconds:
|
||||
asyncio.create_task(_run_task(task))
|
||||
last_run[task.name] = now
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def _run_task(task: ScheduledTask) -> None:
|
||||
try:
|
||||
await task.coro_factory()
|
||||
except Exception:
|
||||
logger.exception(f"Scheduled task {task.name!r} raised an exception")
|
||||
@@ -0,0 +1,201 @@
|
||||
# fabledscryer/core/settings.py
|
||||
"""DB-backed application settings.
|
||||
|
||||
Keys use dotted notation (e.g. "smtp.host").
|
||||
Values are JSON-encoded in the DB.
|
||||
|
||||
Usage in create_app() (before event loop):
|
||||
from fabledscryer.core.settings import load_settings_sync
|
||||
settings = load_settings_sync(db_url)
|
||||
|
||||
Usage at runtime (inside async handlers):
|
||||
from fabledscryer.core.settings import get_setting, set_setting
|
||||
async with app.db_sessionmaker() as session:
|
||||
value = await get_setting(session, "smtp.host")
|
||||
await set_setting(session, "smtp.host", "mail.example.com")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fabledscryer.models.settings import AppSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_WEBHOOK_TEMPLATE = (
|
||||
'{"content": "**{{ alert.state }}** — {{ alert.resource }} — '
|
||||
'{{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}'
|
||||
)
|
||||
|
||||
# All recognised settings and their defaults.
|
||||
# Plugin settings are stored as "plugin.<name>" and handled separately.
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"session.lifetime_hours": 8,
|
||||
"data.retention_days": 90,
|
||||
"monitors.poll_interval_seconds": 60,
|
||||
"smtp.host": "",
|
||||
"smtp.port": 587,
|
||||
"smtp.tls": True,
|
||||
"smtp.username": "",
|
||||
"smtp.password": "",
|
||||
"smtp.recipients": [],
|
||||
"webhook.url": "",
|
||||
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
||||
"ansible.sources": [],
|
||||
"ping.threshold.good_ms": 50,
|
||||
"ping.threshold.warn_ms": 200,
|
||||
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml",
|
||||
# OIDC single-sign-on
|
||||
"oidc.enabled": False,
|
||||
"oidc.discovery_url": "",
|
||||
"oidc.client_id": "",
|
||||
"oidc.client_secret": "",
|
||||
"oidc.scopes": "openid profile email",
|
||||
"oidc.username_claim": "preferred_username",
|
||||
"oidc.email_claim": "email",
|
||||
"oidc.groups_claim": "groups",
|
||||
"oidc.admin_group": "",
|
||||
"oidc.operator_group": "",
|
||||
# LDAP authentication
|
||||
"ldap.enabled": False,
|
||||
"ldap.host": "",
|
||||
"ldap.port": 389,
|
||||
"ldap.tls": False,
|
||||
"ldap.bind_dn": "",
|
||||
"ldap.bind_password": "",
|
||||
"ldap.base_dn": "",
|
||||
"ldap.user_filter": "(uid={username})",
|
||||
"ldap.admin_group_dn": "",
|
||||
"ldap.operator_group_dn": "",
|
||||
"ldap.attr_username": "uid",
|
||||
"ldap.attr_email": "mail",
|
||||
# Scheduled reports
|
||||
"reports.enabled": False,
|
||||
"reports.schedule_day": 6, # 0=Monday … 6=Sunday
|
||||
"reports.schedule_hour": 8, # UTC hour
|
||||
"reports.last_sent_at": "",
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Async helpers (use inside request handlers / scheduled tasks)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str) -> Any:
|
||||
"""Return the value for key, or the default if not set."""
|
||||
result = await session.execute(
|
||||
select(AppSetting).where(AppSetting.key == key)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return DEFAULTS.get(key)
|
||||
return json.loads(row.value_json)
|
||||
|
||||
|
||||
async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
|
||||
"""Upsert a setting. Call inside an active transaction."""
|
||||
result = await session.execute(
|
||||
select(AppSetting).where(AppSetting.key == key)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
if row is None:
|
||||
session.add(AppSetting(key=key, value_json=json.dumps(value), updated_at=now))
|
||||
else:
|
||||
row.value_json = json.dumps(value)
|
||||
row.updated_at = now
|
||||
|
||||
|
||||
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
"""Return flat key→value dict with defaults filled in for missing keys."""
|
||||
result = await session.execute(select(AppSetting))
|
||||
stored = {row.key: json.loads(row.value_json) for row in result.scalars()}
|
||||
out: dict[str, Any] = {}
|
||||
for key, default in DEFAULTS.items():
|
||||
out[key] = stored.get(key, default)
|
||||
# Include any plugin.* keys stored in DB
|
||||
for key, value in stored.items():
|
||||
if key.startswith("plugin.") and key not in out:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Structured config extractors (dict shapes expected by existing consumers)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def to_smtp_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {
|
||||
"host": settings.get("smtp.host", ""),
|
||||
"port": settings.get("smtp.port", 587),
|
||||
"tls": settings.get("smtp.tls", True),
|
||||
"username": settings.get("smtp.username", ""),
|
||||
"password": settings.get("smtp.password", ""),
|
||||
"recipients": settings.get("smtp.recipients", []),
|
||||
}
|
||||
|
||||
|
||||
def to_webhook_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {
|
||||
"url": settings.get("webhook.url", ""),
|
||||
"template": settings.get("webhook.template", _DEFAULT_WEBHOOK_TEMPLATE),
|
||||
}
|
||||
|
||||
|
||||
def to_ansible_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {"sources": settings.get("ansible.sources", [])}
|
||||
|
||||
|
||||
def to_oidc_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")}
|
||||
|
||||
|
||||
def to_ldap_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")}
|
||||
|
||||
|
||||
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
|
||||
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
|
||||
result = {}
|
||||
for key, value in settings.items():
|
||||
if key.startswith("plugin."):
|
||||
name = key[len("plugin."):]
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Synchronous loader — safe to call before the event loop starts
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_settings_sync(db_url: str) -> dict[str, Any]:
|
||||
"""Load all settings from DB synchronously via asyncio.run().
|
||||
|
||||
Safe to call in create_app() before the Quart event loop starts.
|
||||
Returns flat key→value dict with defaults filled in.
|
||||
"""
|
||||
async def _load() -> dict[str, Any]:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
engine = create_async_engine(db_url, echo=False)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with factory() as session:
|
||||
result = await session.execute(select(AppSetting))
|
||||
return {row.key: json.loads(row.value_json) for row in result.scalars()}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
stored = asyncio.run(_load())
|
||||
out: dict[str, Any] = {}
|
||||
for key, default in DEFAULTS.items():
|
||||
out[key] = stored.get(key, default)
|
||||
for key, value in stored.items():
|
||||
if key.startswith("plugin.") and key not in out:
|
||||
out[key] = value
|
||||
return out
|
||||
@@ -0,0 +1,44 @@
|
||||
# fabledscryer/core/time_range.py
|
||||
"""Shared time-range utilities for scoping queries and sparkline data."""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TypeVar
|
||||
|
||||
RANGES: dict[str, timedelta] = {
|
||||
"1h": timedelta(hours=1),
|
||||
"6h": timedelta(hours=6),
|
||||
"24h": timedelta(hours=24),
|
||||
"7d": timedelta(days=7),
|
||||
"30d": timedelta(days=30),
|
||||
}
|
||||
|
||||
RANGE_OPTIONS: list[str] = list(RANGES.keys())
|
||||
DEFAULT_RANGE = "24h"
|
||||
|
||||
|
||||
def parse_range(range_str: str | None) -> tuple[datetime, str]:
|
||||
"""Return (since_datetime, range_key) from a query-param string like '24h'."""
|
||||
key = range_str if range_str in RANGES else DEFAULT_RANGE
|
||||
since = datetime.now(timezone.utc) - RANGES[key]
|
||||
return since, key
|
||||
|
||||
|
||||
def bucket_seconds(since: datetime, target_points: int = 80) -> int:
|
||||
"""Return bucket width in seconds so that (now - since) / width ≈ target_points.
|
||||
|
||||
Minimum is 60s (one poll interval) so raw data is never re-averaged below
|
||||
the collection resolution.
|
||||
"""
|
||||
range_secs = (datetime.now(timezone.utc) - since).total_seconds()
|
||||
return max(60, int(range_secs / target_points))
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def subsample(seq: list[T], n: int = 80) -> list[T]:
|
||||
"""Return at most n evenly-distributed elements from seq (preserves order)."""
|
||||
if len(seq) <= n:
|
||||
return seq
|
||||
indices = sorted({int(round(i * (len(seq) - 1) / (n - 1))) for i in range(n)})
|
||||
return [seq[i] for i in indices]
|
||||
@@ -0,0 +1,298 @@
|
||||
# fabledscryer/core/widgets.py
|
||||
#
|
||||
# Central widget registry. Each entry describes one widget type that can be
|
||||
# placed on a dashboard. Plugin widgets declare which plugin must be enabled.
|
||||
#
|
||||
# "params" is a list of configurable parameters for the widget. Each param:
|
||||
# key - form field name (stored in config_json)
|
||||
# label - human-readable label
|
||||
# type - "select" (only type currently supported)
|
||||
# default - default value (int or str determines how it's parsed from the form)
|
||||
# options - list of {value, label} dicts
|
||||
from __future__ import annotations
|
||||
|
||||
WIDGET_REGISTRY: dict[str, dict] = {
|
||||
"ping": {
|
||||
"key": "ping",
|
||||
"label": "Ping",
|
||||
"description": "Live ping status and latency history for all monitored hosts",
|
||||
"hx_url": "/ping/rows",
|
||||
"detail_url": "/ping/",
|
||||
"plugin": None,
|
||||
"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": {
|
||||
"key": "dns",
|
||||
"label": "DNS",
|
||||
"description": "DNS resolution status for all monitored hosts",
|
||||
"hx_url": "/dns/rows",
|
||||
"detail_url": "/dns/",
|
||||
"plugin": None,
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"traefik_routers": {
|
||||
"key": "traefik_routers",
|
||||
"label": "Traefik — Routers",
|
||||
"description": "Top routers by request rate with error highlights and expiring certs",
|
||||
"hx_url": "/plugins/traefik/widget",
|
||||
"detail_url": "/plugins/traefik/",
|
||||
"plugin": "traefik",
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"traefik_access_log": {
|
||||
"key": "traefik_access_log",
|
||||
"label": "Traefik — Access Log",
|
||||
"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",
|
||||
"detail_url": "/plugins/unifi/",
|
||||
"plugin": "unifi",
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"unifi_clients": {
|
||||
"key": "unifi_clients",
|
||||
"label": "UniFi — Top Clients",
|
||||
"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",
|
||||
"detail_url": "/plugins/ups/",
|
||||
"plugin": "ups",
|
||||
"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:
|
||||
"""Register a widget at runtime (used by external plugins)."""
|
||||
WIDGET_REGISTRY[definition["key"]] = definition
|
||||
|
||||
|
||||
def get_available_widgets(plugins_cfg: dict) -> list[dict]:
|
||||
"""Return widgets whose plugin dependency is satisfied."""
|
||||
result = []
|
||||
for w in WIDGET_REGISTRY.values():
|
||||
if w["plugin"] is None:
|
||||
result.append(w)
|
||||
elif plugins_cfg.get(w["plugin"], {}).get("enabled", False):
|
||||
result.append(w)
|
||||
return result
|
||||
@@ -0,0 +1,588 @@
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from urllib.parse import urlencode
|
||||
from quart import Blueprint, render_template, current_app, abort, redirect, request, session
|
||||
from sqlalchemy import select, func, update
|
||||
from fabledscryer.auth.middleware import require_role, current_user_id
|
||||
from fabledscryer.models.users import UserRole
|
||||
from fabledscryer.models.hosts import Host
|
||||
from fabledscryer.models.monitors import PingResult, DnsResult
|
||||
from fabledscryer.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
||||
from fabledscryer.core.widgets import get_available_widgets, WIDGET_REGISTRY
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
# ── Access helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _can_view(dash: Dashboard, user_id: str, user_role: str) -> bool:
|
||||
if user_role == UserRole.admin:
|
||||
return True
|
||||
if dash.owner_id is None: # system dashboard
|
||||
return True
|
||||
if dash.owner_id == user_id: # own dashboard
|
||||
return True
|
||||
return dash.is_shared # shared by another user
|
||||
|
||||
|
||||
def _can_edit(dash: Dashboard, user_id: str, user_role: str) -> bool:
|
||||
if user_role == UserRole.admin:
|
||||
return True
|
||||
return dash.owner_id == user_id
|
||||
|
||||
|
||||
# ── DB helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_widgets(db, dashboard_id: int) -> list[DashboardWidget]:
|
||||
result = await db.execute(
|
||||
select(DashboardWidget)
|
||||
.where(DashboardWidget.dashboard_id == dashboard_id)
|
||||
.order_by(DashboardWidget.position)
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
async def _normalise_positions(db, dashboard_id: int) -> None:
|
||||
for i, w in enumerate(await _get_widgets(db, dashboard_id)):
|
||||
w.position = i
|
||||
|
||||
|
||||
async def _accessible_dashboards(db, user_id: str, user_role: str) -> list[Dashboard]:
|
||||
"""All dashboards visible to this user, ordered by id."""
|
||||
result = await db.execute(select(Dashboard).order_by(Dashboard.id))
|
||||
all_dashes = list(result.scalars())
|
||||
return [d for d in all_dashes if _can_view(d, user_id, user_role)]
|
||||
|
||||
|
||||
async def _resolve_default(db, user_id: str, user_role: str) -> Dashboard | None:
|
||||
"""
|
||||
1. User's own dashboard with is_default=True
|
||||
2. System dashboard (owner_id=None) with is_default=True
|
||||
3. First accessible dashboard
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Dashboard)
|
||||
.where(Dashboard.owner_id == user_id, Dashboard.is_default.is_(True))
|
||||
.limit(1)
|
||||
)
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash:
|
||||
return dash
|
||||
|
||||
result = await db.execute(
|
||||
select(Dashboard)
|
||||
.where(Dashboard.owner_id.is_(None), Dashboard.is_default.is_(True))
|
||||
.limit(1)
|
||||
)
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash:
|
||||
return dash
|
||||
|
||||
accessible = await _accessible_dashboards(db, user_id, user_role)
|
||||
return accessible[0] if accessible else None
|
||||
|
||||
|
||||
async def _get_or_create_system_default(db) -> Dashboard:
|
||||
"""Ensure a system (owner_id=None) default dashboard exists."""
|
||||
result = await db.execute(
|
||||
select(Dashboard).where(Dashboard.owner_id.is_(None)).limit(1)
|
||||
)
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None:
|
||||
dash = Dashboard(name="Default", owner_id=None, is_default=True, is_shared=True)
|
||||
db.add(dash)
|
||||
await db.flush()
|
||||
plugins_cfg = current_app.config.get("PLUGINS", {})
|
||||
for pos, w in enumerate(get_available_widgets(plugins_cfg)):
|
||||
db.add(DashboardWidget(
|
||||
dashboard_id=dash.id, widget_key=w["key"], position=pos,
|
||||
))
|
||||
return dash
|
||||
|
||||
|
||||
async def _get_summary_stats(db) -> dict:
|
||||
latest_ping_subq = (
|
||||
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
|
||||
.group_by(PingResult.host_id).subquery()
|
||||
)
|
||||
pr = await db.execute(
|
||||
select(PingResult).join(
|
||||
latest_ping_subq,
|
||||
(PingResult.host_id == latest_ping_subq.c.host_id)
|
||||
& (PingResult.probed_at == latest_ping_subq.c.max_at),
|
||||
)
|
||||
)
|
||||
pings = list(pr.scalars())
|
||||
ping_up = sum(1 for p in pings if p.status.value == "up")
|
||||
ping_down = sum(1 for p in pings if p.status.value == "down")
|
||||
|
||||
latest_dns_subq = (
|
||||
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
|
||||
.group_by(DnsResult.host_id).subquery()
|
||||
)
|
||||
dr = await db.execute(
|
||||
select(DnsResult).join(
|
||||
latest_dns_subq,
|
||||
(DnsResult.host_id == latest_dns_subq.c.host_id)
|
||||
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
|
||||
)
|
||||
)
|
||||
dns = list(dr.scalars())
|
||||
dns_ok = sum(1 for d in dns if d.status.value == "resolved")
|
||||
dns_fail = sum(1 for d in dns if d.status.value != "resolved")
|
||||
total = (await db.execute(select(func.count()).select_from(Host))).scalar()
|
||||
|
||||
return dict(ping_up=ping_up, ping_down=ping_down,
|
||||
dns_ok=dns_ok, dns_fail=dns_fail, total_hosts=total)
|
||||
|
||||
|
||||
def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
|
||||
plugins_cfg = current_app.config.get("PLUGINS", {})
|
||||
out = []
|
||||
for w in widgets:
|
||||
defn = WIDGET_REGISTRY.get(w.widget_key)
|
||||
if defn is None:
|
||||
continue
|
||||
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
|
||||
continue
|
||||
# Build stored config (falls back to empty; defaults live in the registry)
|
||||
config: dict = {}
|
||||
if w.config_json:
|
||||
try:
|
||||
config = json.loads(w.config_json)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Always append wid so widget templates can create unique canvas IDs
|
||||
url_params = {**config, "wid": w.id}
|
||||
hx_url = defn["hx_url"] + "?" + urlencode(url_params)
|
||||
out.append({
|
||||
"db": w,
|
||||
"defn": defn,
|
||||
"hx_url": hx_url,
|
||||
"title": w.title or defn["label"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Edit panel helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_panels_data(db, dashboard_id: int) -> tuple:
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dashboard_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None:
|
||||
return None, [], []
|
||||
widgets = await _get_widgets(db, dashboard_id)
|
||||
plugins_cfg = current_app.config.get("PLUGINS", {})
|
||||
available = get_available_widgets(plugins_cfg)
|
||||
# Multiple instances of the same widget type are allowed — show all available
|
||||
resolved = [
|
||||
{"db": w, "defn": WIDGET_REGISTRY[w.widget_key], "title": w.title or WIDGET_REGISTRY[w.widget_key]["label"]}
|
||||
for w in widgets if w.widget_key in WIDGET_REGISTRY
|
||||
]
|
||||
return dash, resolved, available
|
||||
|
||||
|
||||
async def _panels_response(dashboard_id: int):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
dash, resolved, addable = await _get_panels_data(db, dashboard_id)
|
||||
return await render_template(
|
||||
"dashboard/_edit_panels.html",
|
||||
dashboard=dash, widgets=resolved, addable=addable,
|
||||
)
|
||||
|
||||
|
||||
# ── Root redirect ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await _get_or_create_system_default(db)
|
||||
dash = await _resolve_default(db, user_id, user_role)
|
||||
if dash is None:
|
||||
abort(404)
|
||||
return redirect(f"/d/{dash.id}")
|
||||
|
||||
|
||||
# ── Dashboard view ────────────────────────────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/d/<int:dash_id>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def view(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_view(dash, user_id, user_role):
|
||||
abort(404)
|
||||
|
||||
stats = await _get_summary_stats(db)
|
||||
widgets = await _get_widgets(db, dash_id)
|
||||
accessible = await _accessible_dashboards(db, user_id, user_role)
|
||||
|
||||
resolved = _resolve_widgets(widgets)
|
||||
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||
can_edit = _can_edit(dash, user_id, user_role)
|
||||
|
||||
return await render_template(
|
||||
"dashboard/index.html",
|
||||
dashboard=dash,
|
||||
all_dashboards=accessible,
|
||||
widgets=resolved,
|
||||
poll_interval=poll_interval,
|
||||
can_edit=can_edit,
|
||||
**stats,
|
||||
)
|
||||
|
||||
|
||||
# ── Dashboard management ──────────────────────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/dashboards/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def list_dashboards():
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await _get_or_create_system_default(db)
|
||||
accessible = await _accessible_dashboards(db, user_id, user_role)
|
||||
|
||||
my_dashes = [d for d in accessible if d.owner_id == user_id]
|
||||
shared_dashes = [d for d in accessible if d.owner_id != user_id]
|
||||
|
||||
return await render_template(
|
||||
"dashboard/list.html",
|
||||
my_dashboards=my_dashes,
|
||||
shared_dashboards=shared_dashes,
|
||||
user_id=user_id,
|
||||
user_role=user_role,
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.post("/dashboards/create")
|
||||
@require_role(UserRole.viewer)
|
||||
async def create_dashboard():
|
||||
user_id = current_user_id()
|
||||
form = await request.form
|
||||
name = (form.get("name") or "New Dashboard").strip()[:128]
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
dash = Dashboard(name=name, owner_id=user_id, is_default=False, is_shared=False)
|
||||
db.add(dash)
|
||||
await db.flush()
|
||||
dash_id = dash.id
|
||||
return redirect(f"/d/{dash_id}/edit")
|
||||
|
||||
|
||||
@dashboard_bp.post("/dashboards/<int:dash_id>/rename")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rename_dashboard(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()[:128]
|
||||
if name:
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash and _can_edit(dash, user_id, user_role):
|
||||
dash.name = name
|
||||
return redirect("/dashboards/")
|
||||
|
||||
|
||||
@dashboard_bp.post("/dashboards/<int:dash_id>/toggle-share")
|
||||
@require_role(UserRole.viewer)
|
||||
async def toggle_share(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash and _can_edit(dash, user_id, user_role):
|
||||
dash.is_shared = not dash.is_shared
|
||||
return redirect("/dashboards/")
|
||||
|
||||
|
||||
@dashboard_bp.post("/dashboards/<int:dash_id>/set-default")
|
||||
@require_role(UserRole.viewer)
|
||||
async def set_default_dashboard(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_view(dash, user_id, user_role):
|
||||
abort(404)
|
||||
# Clear is_default on all dashboards this user owns
|
||||
await db.execute(
|
||||
update(Dashboard)
|
||||
.where(Dashboard.owner_id == user_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
# If setting a system/shared dashboard as default, we instead create
|
||||
# a personal preference record by marking the system one (safe since
|
||||
# system dashboards are shared — the flag just signals this user's preference)
|
||||
dash.is_default = True
|
||||
return redirect("/dashboards/")
|
||||
|
||||
|
||||
@dashboard_bp.post("/dashboards/<int:dash_id>/delete")
|
||||
@require_role(UserRole.viewer)
|
||||
async def delete_dashboard(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
# Count remaining owned by this user (don't delete their last one if any)
|
||||
owned_count = (await db.execute(
|
||||
select(func.count()).select_from(Dashboard)
|
||||
.where(Dashboard.owner_id == user_id)
|
||||
)).scalar()
|
||||
if dash.owner_id == user_id and owned_count <= 1:
|
||||
pass # refuse to delete user's last owned dashboard
|
||||
else:
|
||||
await db.delete(dash)
|
||||
return redirect("/dashboards/")
|
||||
|
||||
|
||||
# ── Dashboard edit ────────────────────────────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/d/<int:dash_id>/edit")
|
||||
@require_role(UserRole.viewer)
|
||||
async def edit(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
dash, resolved, addable = await _get_panels_data(db, dash_id)
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
return await render_template(
|
||||
"dashboard/edit.html",
|
||||
dashboard=dash, widgets=resolved, addable=addable,
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.post("/d/<int:dash_id>/edit/add/<widget_key>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def add_widget(dash_id: int, widget_key: str):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
defn = WIDGET_REGISTRY.get(widget_key)
|
||||
if defn is None:
|
||||
abort(400)
|
||||
form = await request.form
|
||||
title = form.get("title", "").strip() or None
|
||||
# Parse configured params
|
||||
config: dict = {}
|
||||
for p in defn.get("params", []):
|
||||
val = form.get(f"param_{p['key']}")
|
||||
if val is not None:
|
||||
if isinstance(p["default"], int):
|
||||
try:
|
||||
config[p["key"]] = int(val)
|
||||
except ValueError:
|
||||
config[p["key"]] = p["default"]
|
||||
else:
|
||||
config[p["key"]] = val
|
||||
config_json = json.dumps(config) if config else None
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
widgets = await _get_widgets(db, dash_id)
|
||||
next_pos = max((w.position for w in widgets), default=-1) + 1
|
||||
db.add(DashboardWidget(
|
||||
dashboard_id=dash_id, widget_key=widget_key, position=next_pos,
|
||||
title=title, config_json=config_json,
|
||||
))
|
||||
return await _panels_response(dash_id)
|
||||
|
||||
|
||||
@dashboard_bp.post("/d/<int:dash_id>/edit/remove/<int:widget_id>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def remove_widget(dash_id: int, widget_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
result = await db.execute(
|
||||
select(DashboardWidget).where(DashboardWidget.id == widget_id)
|
||||
)
|
||||
widget = result.scalar_one_or_none()
|
||||
if widget and widget.dashboard_id == dash_id:
|
||||
await db.delete(widget)
|
||||
await _normalise_positions(db, dash_id)
|
||||
return await _panels_response(dash_id)
|
||||
|
||||
|
||||
@dashboard_bp.post("/d/<int:dash_id>/edit/reorder")
|
||||
@require_role(UserRole.viewer)
|
||||
async def reorder_widgets(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
data = await request.get_json(silent=True)
|
||||
if not data or not isinstance(data.get("order"), list):
|
||||
abort(400)
|
||||
order: list[int] = data["order"]
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
widgets = await _get_widgets(db, dash_id)
|
||||
widget_map = {w.id: w for w in widgets}
|
||||
for pos, wid in enumerate(order):
|
||||
if wid in widget_map:
|
||||
widget_map[wid].position = pos
|
||||
return ("", 204)
|
||||
|
||||
|
||||
# ── Share tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/d/<int:dash_id>/share")
|
||||
@require_role(UserRole.viewer)
|
||||
async def manage_share_tokens(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
result = await db.execute(
|
||||
select(DashboardShareToken)
|
||||
.where(DashboardShareToken.dashboard_id == dash_id)
|
||||
.order_by(DashboardShareToken.created_at.desc())
|
||||
)
|
||||
tokens = list(result.scalars())
|
||||
now = datetime.now(timezone.utc)
|
||||
return await render_template(
|
||||
"dashboard/share.html",
|
||||
dashboard=dash, tokens=tokens, now=now,
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.post("/d/<int:dash_id>/share/create")
|
||||
@require_role(UserRole.viewer)
|
||||
async def create_share_token(dash_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
|
||||
form = await request.form
|
||||
label = (form.get("label") or "").strip()[:128]
|
||||
expires_in = form.get("expires_in", "")
|
||||
|
||||
expires_at = None
|
||||
if expires_in.isdigit() and int(expires_in) > 0:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=int(expires_in))
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(DashboardShareToken(
|
||||
dashboard_id=dash_id,
|
||||
token_hash=token_hash,
|
||||
label=label,
|
||||
expires_at=expires_at,
|
||||
created_by=user_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
|
||||
return await render_template(
|
||||
"dashboard/share_created.html",
|
||||
dashboard=dash,
|
||||
raw_token=raw_token,
|
||||
label=label,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.post("/d/<int:dash_id>/share/revoke/<int:token_id>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def revoke_share_token(dash_id: int, token_id: int):
|
||||
user_id = current_user_id()
|
||||
user_role = session.get("user_role", "viewer")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
result = await db.execute(
|
||||
select(DashboardShareToken).where(DashboardShareToken.id == token_id)
|
||||
)
|
||||
token = result.scalar_one_or_none()
|
||||
if token and token.dashboard_id == dash_id:
|
||||
await db.delete(token)
|
||||
return redirect(f"/d/{dash_id}/share")
|
||||
|
||||
|
||||
# ── Public share view (no login required) ────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/share/<raw_token>")
|
||||
async def public_share(raw_token: str):
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(DashboardShareToken)
|
||||
.where(DashboardShareToken.token_hash == token_hash)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
abort(404)
|
||||
if share.expires_at is not None and share.expires_at <= now:
|
||||
abort(410)
|
||||
result = await db.execute(
|
||||
select(Dashboard).where(Dashboard.id == share.dashboard_id)
|
||||
)
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None:
|
||||
abort(404)
|
||||
widgets = await _get_widgets(db, dash.id)
|
||||
|
||||
resolved = _resolve_widgets(widgets)
|
||||
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||
|
||||
return await render_template(
|
||||
"dashboard/share_view.html",
|
||||
dashboard=dash,
|
||||
widgets=resolved,
|
||||
poll_interval=poll_interval,
|
||||
raw_token=raw_token,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
|
||||
def init_db(app: "Quart") -> None:
|
||||
"""Create async engine and attach db_sessionmaker to app.
|
||||
|
||||
Called during create_app() after config is loaded.
|
||||
Does not create tables — Alembic handles migrations.
|
||||
"""
|
||||
db_url: str = app.config["DATABASE_URL"]
|
||||
engine = create_async_engine(db_url, echo=False)
|
||||
app.db_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
engine, expire_on_commit=False
|
||||
)
|
||||
app._db_engine = engine
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
from quart import Blueprint, current_app, render_template
|
||||
from sqlalchemy import select, func
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.users import UserRole
|
||||
from fabledscryer.models.hosts import Host
|
||||
from fabledscryer.models.monitors import DnsResult
|
||||
|
||||
dns_bp = Blueprint("dns", __name__, url_prefix="/dns")
|
||||
|
||||
|
||||
async def _latest_dns(db, host_ids: list[str]) -> dict:
|
||||
if not host_ids:
|
||||
return {}
|
||||
subq = (
|
||||
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
|
||||
.where(DnsResult.host_id.in_(host_ids))
|
||||
.group_by(DnsResult.host_id)
|
||||
.subquery()
|
||||
)
|
||||
pr = await db.execute(
|
||||
select(DnsResult).join(
|
||||
subq,
|
||||
(DnsResult.host_id == subq.c.host_id) & (DnsResult.resolved_at == subq.c.max_at),
|
||||
)
|
||||
)
|
||||
return {r.host_id: r for r in pr.scalars()}
|
||||
|
||||
|
||||
@dns_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name)
|
||||
)
|
||||
hosts = result.scalars().all()
|
||||
latest = await _latest_dns(db, [h.id for h in hosts])
|
||||
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||
return await render_template("dns/index.html", hosts=hosts, latest=latest, poll_interval=poll_interval)
|
||||
|
||||
|
||||
@dns_bp.get("/rows")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rows():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name)
|
||||
)
|
||||
hosts = result.scalars().all()
|
||||
latest = await _latest_dns(db, [h.id for h in hosts])
|
||||
return await render_template("dns/rows.html", hosts=hosts, latest=latest)
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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.core.audit import log_audit
|
||||
from fabledscryer.models.hosts import Host, ProbeType
|
||||
from fabledscryer.models.monitors import PingResult, DnsResult, PingStatus
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
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("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def list_hosts():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(Host).order_by(Host.name))
|
||||
hosts = result.scalars().all()
|
||||
|
||||
# Latest ping result per host
|
||||
latest_ping_subq = (
|
||||
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
|
||||
.group_by(PingResult.host_id)
|
||||
.subquery()
|
||||
)
|
||||
pr = await db.execute(
|
||||
select(PingResult).join(
|
||||
latest_ping_subq,
|
||||
(PingResult.host_id == latest_ping_subq.c.host_id)
|
||||
& (PingResult.probed_at == latest_ping_subq.c.max_at),
|
||||
)
|
||||
)
|
||||
latest_pings = {r.host_id: r for r in pr.scalars()}
|
||||
|
||||
# Latest DNS result per host
|
||||
latest_dns_subq = (
|
||||
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
|
||||
.group_by(DnsResult.host_id)
|
||||
.subquery()
|
||||
)
|
||||
dr = await db.execute(
|
||||
select(DnsResult).join(
|
||||
latest_dns_subq,
|
||||
(DnsResult.host_id == latest_dns_subq.c.host_id)
|
||||
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
|
||||
)
|
||||
)
|
||||
latest_dns = {r.host_id: r for r in dr.scalars()}
|
||||
uptime = await _compute_uptime(db)
|
||||
|
||||
return await render_template(
|
||||
"hosts/list.html",
|
||||
hosts=hosts,
|
||||
latest_pings=latest_pings,
|
||||
latest_dns=latest_dns,
|
||||
uptime=uptime,
|
||||
)
|
||||
|
||||
|
||||
@hosts_bp.get("/new")
|
||||
@require_role(UserRole.operator)
|
||||
async def new_host():
|
||||
return await render_template("hosts/form.html", host=None, probe_types=list(ProbeType))
|
||||
|
||||
|
||||
@hosts_bp.post("/")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_host():
|
||||
form = await request.form
|
||||
host = Host(
|
||||
name=form["name"].strip(),
|
||||
address=form["address"].strip(),
|
||||
probe_type=ProbeType(form.get("probe_type", "tcp")),
|
||||
probe_port=int(form.get("probe_port") or 80),
|
||||
ping_enabled="ping_enabled" in form,
|
||||
dns_enabled="dns_enabled" in form,
|
||||
dns_expected_ip=form.get("dns_expected_ip", "").strip() or None,
|
||||
# poll_interval_seconds not exposed in UI yet; per-host scheduling not implemented
|
||||
)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
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"))
|
||||
|
||||
|
||||
@hosts_bp.get("/<host_id>/edit")
|
||||
@require_role(UserRole.operator)
|
||||
async def edit_host(host_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(Host).where(Host.id == host_id))
|
||||
host = result.scalar_one_or_none()
|
||||
if host is None:
|
||||
return "Not found", 404
|
||||
return await render_template("hosts/form.html", host=host, probe_types=list(ProbeType))
|
||||
|
||||
|
||||
@hosts_bp.post("/<host_id>")
|
||||
@require_role(UserRole.operator)
|
||||
async def update_host(host_id: str):
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Host).where(Host.id == host_id))
|
||||
host = result.scalar_one_or_none()
|
||||
if host is None:
|
||||
return "Not found", 404
|
||||
host.name = form["name"].strip()
|
||||
host.address = form["address"].strip()
|
||||
host.probe_type = ProbeType(form.get("probe_type", "tcp"))
|
||||
host.probe_port = int(form.get("probe_port") or 80)
|
||||
host.ping_enabled = "ping_enabled" in form
|
||||
host.dns_enabled = "dns_enabled" in form
|
||||
host.dns_expected_ip = form.get("dns_expected_ip", "").strip() or None
|
||||
# poll_interval_seconds not exposed in UI yet
|
||||
return redirect(url_for("hosts.list_hosts"))
|
||||
|
||||
|
||||
@hosts_bp.post("/<host_id>/delete")
|
||||
@require_role(UserRole.admin)
|
||||
async def delete_host(host_id: str):
|
||||
host_name = None
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Host).where(Host.id == host_id))
|
||||
host = result.scalar_one_or_none()
|
||||
if host:
|
||||
host_name = host.name
|
||||
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"))
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from alembic import context
|
||||
from fabledscryer.models.base import Base
|
||||
import fabledscryer.models # noqa: F401 — registers all models in metadata
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
import os, yaml
|
||||
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
|
||||
try:
|
||||
with open(cfg_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
url = cfg.get("database", {}).get("url", "")
|
||||
except FileNotFoundError:
|
||||
url = ""
|
||||
return (
|
||||
os.environ.get("FABLEDSCRYER_DATABASE_URL")
|
||||
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
|
||||
or url
|
||||
)
|
||||
|
||||
|
||||
def _resolved_url() -> str:
|
||||
"""Return URL from migration_runner config if set, else fall back to get_url()."""
|
||||
return config.get_main_option("sqlalchemy.url") or get_url()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=_resolved_url(), target_metadata=target_metadata,
|
||||
literal_binds=True, dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
cfg = config.get_section(config.config_ini_section, {})
|
||||
cfg["sqlalchemy.url"] = _resolved_url()
|
||||
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Core initial schema: users table
|
||||
|
||||
Revision ID: 0001_core_initial
|
||||
Revises:
|
||||
Create Date: 2026-03-16
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0001_core_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("username", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum("admin", "operator", "viewer", name="userrole"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, default=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("users")
|
||||
op.execute("DROP TYPE IF EXISTS userrole")
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Core monitors, metrics, and alert tables
|
||||
|
||||
Revision ID: 0002_core_monitors
|
||||
Revises: 0001_core_initial
|
||||
Create Date: 2026-03-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002_core_monitors"
|
||||
down_revision: Union[str, None] = "0001_core_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# hosts
|
||||
op.create_table(
|
||||
"hosts",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("address", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"probe_type",
|
||||
sa.Enum("tcp", "icmp", name="probetype"),
|
||||
nullable=False,
|
||||
server_default="tcp",
|
||||
),
|
||||
sa.Column("probe_port", sa.Integer, nullable=False, server_default="80"),
|
||||
sa.Column("ping_enabled", sa.Boolean, nullable=False, server_default="true"),
|
||||
sa.Column("dns_enabled", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("dns_expected_ip", sa.String(255), nullable=True),
|
||||
sa.Column("poll_interval_seconds", sa.Integer, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
# ping_results
|
||||
op.create_table(
|
||||
"ping_results",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("probed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("status", sa.Enum("up", "down", name="pingstatus"), nullable=False),
|
||||
sa.Column("response_time_ms", sa.Float, nullable=True),
|
||||
)
|
||||
|
||||
# dns_results
|
||||
op.create_table(
|
||||
"dns_results",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("status", sa.Enum("resolved", "failed", name="dnsstatus"), nullable=False),
|
||||
sa.Column("resolved_ip", sa.String(255), nullable=True),
|
||||
)
|
||||
|
||||
# plugin_metrics
|
||||
op.create_table(
|
||||
"plugin_metrics",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("source_module", sa.String(64), nullable=False),
|
||||
sa.Column("resource_name", sa.String(255), nullable=False),
|
||||
sa.Column("metric_name", sa.String(128), nullable=False),
|
||||
sa.Column("value", sa.Float, nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
# alert_rules
|
||||
op.create_table(
|
||||
"alert_rules",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("source_module", sa.String(64), nullable=False),
|
||||
sa.Column("resource_name", sa.String(255), nullable=False),
|
||||
sa.Column("metric_name", sa.String(128), nullable=False),
|
||||
sa.Column(
|
||||
"operator",
|
||||
sa.Enum(">", "<", ">=", "<=", "==", "!=", name="alertoperator"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("threshold", sa.Float, nullable=False),
|
||||
sa.Column("consecutive_failures_required", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("enabled", sa.Boolean, nullable=False, server_default="true"),
|
||||
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
# alert_states
|
||||
op.create_table(
|
||||
"alert_states",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("alert_rules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
),
|
||||
sa.Column(
|
||||
"state",
|
||||
sa.Enum("inactive", "pending", "firing", "acknowledged", "resolved", name="alertstateenum"),
|
||||
nullable=False,
|
||||
server_default="inactive",
|
||||
),
|
||||
sa.Column("consecutive_failure_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("last_transition_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("acknowledged_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# alert_events
|
||||
op.create_table(
|
||||
"alert_events",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("rule_id", sa.String(36), sa.ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("from_state", sa.String(32), nullable=False),
|
||||
sa.Column("to_state", sa.String(32), nullable=False),
|
||||
sa.Column("metric_value", sa.Float, nullable=False),
|
||||
sa.Column("transitioned_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("email_sent", sa.Boolean, nullable=True),
|
||||
sa.Column("email_error", sa.Text, nullable=True),
|
||||
sa.Column("webhook_sent", sa.Boolean, nullable=True),
|
||||
sa.Column("webhook_error", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("alert_events")
|
||||
op.drop_table("alert_states")
|
||||
op.drop_table("alert_rules")
|
||||
op.drop_table("plugin_metrics")
|
||||
op.drop_table("dns_results")
|
||||
op.drop_table("ping_results")
|
||||
op.drop_table("hosts")
|
||||
op.execute("DROP TYPE IF EXISTS alertstateenum")
|
||||
op.execute("DROP TYPE IF EXISTS alertoperator")
|
||||
op.execute("DROP TYPE IF EXISTS dnsstatus")
|
||||
op.execute("DROP TYPE IF EXISTS pingstatus")
|
||||
op.execute("DROP TYPE IF EXISTS probetype")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Ansible runs table
|
||||
|
||||
Revision ID: 0003_ansible_runs
|
||||
Revises: 0002_core_monitors
|
||||
Create Date: 2026-03-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0003_ansible_runs"
|
||||
down_revision: Union[str, None] = "0002_core_monitors"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ansible_runs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("playbook_path", sa.String(512), nullable=False),
|
||||
sa.Column("inventory_path", sa.String(512), nullable=False),
|
||||
sa.Column("source_name", sa.String(128), nullable=False),
|
||||
sa.Column(
|
||||
"triggered_by",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("running", "success", "failed", "interrupted", name="ansiblerunstatus"),
|
||||
nullable=False,
|
||||
server_default="running",
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("output", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("ansible_runs")
|
||||
op.execute("DROP TYPE IF EXISTS ansiblerunstatus")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""App settings table
|
||||
|
||||
Revision ID: 0004_app_settings
|
||||
Revises: 0003_ansible_runs
|
||||
Create Date: 2026-03-18
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0004_app_settings"
|
||||
down_revision: Union[str, None] = "0003_ansible_runs"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"app_settings",
|
||||
sa.Column("key", sa.String(128), primary_key=True),
|
||||
sa.Column("value_json", sa.Text, nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("app_settings")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Dashboard layout tables
|
||||
|
||||
Revision ID: 0005_dashboards
|
||||
Revises: 0004_app_settings
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0005_dashboards"
|
||||
down_revision: Union[str, None] = "0004_app_settings"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"dashboards",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"dashboard_widgets",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("dashboard_id", sa.Integer,
|
||||
sa.ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("widget_key", sa.String(64), nullable=False),
|
||||
sa.Column("position", sa.Integer, nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_dashboard_widgets_dashboard_id",
|
||||
"dashboard_widgets", ["dashboard_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_dashboard_widgets_dashboard_id", "dashboard_widgets")
|
||||
op.drop_table("dashboard_widgets")
|
||||
op.drop_table("dashboards")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add is_default to dashboards
|
||||
|
||||
Revision ID: 0006_dashboard_is_default
|
||||
Revises: 0005_dashboards
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0006_dashboard_is_default"
|
||||
down_revision: Union[str, None] = "0005_dashboards"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"dashboards",
|
||||
sa.Column("is_default", sa.Boolean, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("dashboards", "is_default")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add owner_id and is_shared to dashboards
|
||||
|
||||
Revision ID: 0007_dashboard_ownership
|
||||
Revises: 0006_dashboard_is_default
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0007_dashboard_ownership"
|
||||
down_revision: Union[str, None] = "0006_dashboard_is_default"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# owner_id nullable — existing dashboards keep NULL (system-owned)
|
||||
op.add_column(
|
||||
"dashboards",
|
||||
sa.Column("owner_id", sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
# Existing dashboards become shared so all users still see them
|
||||
op.add_column(
|
||||
"dashboards",
|
||||
sa.Column("is_shared", sa.Boolean, nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("dashboards", "is_shared")
|
||||
op.drop_column("dashboards", "owner_id")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Dashboard share tokens
|
||||
|
||||
Revision ID: 0008_share_tokens
|
||||
Revises: 0007_dashboard_ownership
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0008_share_tokens"
|
||||
down_revision: Union[str, None] = "0007_dashboard_ownership"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"dashboard_share_tokens",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("dashboard_id", sa.Integer,
|
||||
sa.ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("label", sa.String(128), nullable=False, server_default=""),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by", sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_share_tokens_dashboard_id", "dashboard_share_tokens", ["dashboard_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_share_tokens_hash", "dashboard_share_tokens", ["token_hash"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_share_tokens_hash", "dashboard_share_tokens")
|
||||
op.drop_index("ix_share_tokens_dashboard_id", "dashboard_share_tokens")
|
||||
op.drop_table("dashboard_share_tokens")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Widget variants — add config_json and title to dashboard_widgets
|
||||
|
||||
Revision ID: 0009_widget_variants
|
||||
Revises: 0008_share_tokens
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0009_widget_variants"
|
||||
down_revision: Union[str, None] = "0008_share_tokens"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("dashboard_widgets",
|
||||
sa.Column("title", sa.String(128), nullable=True))
|
||||
op.add_column("dashboard_widgets",
|
||||
sa.Column("config_json", sa.Text, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("dashboard_widgets", "config_json")
|
||||
op.drop_column("dashboard_widgets", "title")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Maintenance windows for alert suppression
|
||||
|
||||
Revision ID: 0010_maintenance_windows
|
||||
Revises: 0009_widget_variants
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0010_maintenance_windows"
|
||||
down_revision: Union[str, None] = "0009_widget_variants"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"maintenance_windows",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("scope_source", sa.String(64), nullable=True),
|
||||
sa.Column("scope_resource", sa.String(255), nullable=True),
|
||||
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("maintenance_windows")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Audit log table
|
||||
|
||||
Revision ID: 0011_audit_log
|
||||
Revises: 0010_maintenance_windows
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0011_audit_log"
|
||||
down_revision: Union[str, None] = "0010_maintenance_windows"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("user_id", sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("username", sa.String(64), nullable=False, server_default="system"),
|
||||
sa.Column("action", sa.String(64), nullable=False),
|
||||
sa.Column("entity_type", sa.String(64), nullable=True),
|
||||
sa.Column("entity_id", sa.String(255), nullable=True),
|
||||
sa.Column("detail_json", sa.Text, nullable=True),
|
||||
)
|
||||
op.create_index("ix_audit_events_timestamp", "audit_events", ["timestamp"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_events_timestamp", "audit_events")
|
||||
op.drop_table("audit_events")
|
||||
@@ -0,0 +1,20 @@
|
||||
from .base import Base
|
||||
from .users import User
|
||||
from .hosts import Host, ProbeType
|
||||
from .monitors import PingResult, DnsResult, PingStatus, DnsStatus
|
||||
from .metrics import PluginMetric
|
||||
from .alerts import AlertRule, AlertState, AlertEvent, AlertOperator, AlertStateEnum
|
||||
from .ansible import AnsibleRun, AnsibleRunStatus
|
||||
from .settings import AppSetting
|
||||
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
||||
|
||||
__all__ = [
|
||||
"Base", "User",
|
||||
"Host", "ProbeType",
|
||||
"PingResult", "DnsResult", "PingStatus", "DnsStatus",
|
||||
"PluginMetric",
|
||||
"AlertRule", "AlertState", "AlertEvent", "AlertOperator", "AlertStateEnum",
|
||||
"AnsibleRun", "AnsibleRunStatus",
|
||||
"AppSetting",
|
||||
"Dashboard", "DashboardWidget", "DashboardShareToken",
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class AlertOperator(str, enum.Enum):
|
||||
gt = ">"
|
||||
lt = "<"
|
||||
gte = ">="
|
||||
lte = "<="
|
||||
eq = "=="
|
||||
ne = "!="
|
||||
|
||||
|
||||
class AlertStateEnum(str, enum.Enum):
|
||||
inactive = "inactive"
|
||||
pending = "pending"
|
||||
firing = "firing"
|
||||
acknowledged = "acknowledged"
|
||||
resolved = "resolved"
|
||||
|
||||
|
||||
class AlertRule(Base):
|
||||
__tablename__ = "alert_rules"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
operator: Mapped[AlertOperator] = mapped_column(Enum(AlertOperator), nullable=False)
|
||||
threshold: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
consecutive_failures_required: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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 AlertState(Base):
|
||||
__tablename__ = "alert_states"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
rule_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
state: Mapped[AlertStateEnum] = mapped_column(
|
||||
Enum(AlertStateEnum), nullable=False, default=AlertStateEnum.inactive
|
||||
)
|
||||
consecutive_failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_transition_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
acknowledged_by: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="SET NULL"), 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):
|
||||
__tablename__ = "alert_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
rule_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
from_state: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
to_state: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
metric_value: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
transitioned_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
email_sent: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
email_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
webhook_sent: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
webhook_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class AnsibleRunStatus(str, enum.Enum):
|
||||
running = "running"
|
||||
success = "success"
|
||||
failed = "failed"
|
||||
interrupted = "interrupted"
|
||||
|
||||
|
||||
class AnsibleRun(Base):
|
||||
__tablename__ = "ansible_runs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
playbook_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
inventory_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
source_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
triggered_by: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
status: Mapped[AnsibleRunStatus] = mapped_column(
|
||||
Enum(AnsibleRunStatus), nullable=False, default=AnsibleRunStatus.running
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
output: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Denormalized so display survives user deletion
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
entity_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Dashboard(Base):
|
||||
__tablename__ = "dashboards"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, default="Default")
|
||||
# NULL owner_id = system dashboard (visible to all, editable by admins)
|
||||
owner_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, default=None,
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# is_shared = True → visible to all logged-in users (read-only to non-owners)
|
||||
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class DashboardShareToken(Base):
|
||||
__tablename__ = "dashboard_share_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
dashboard_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
label: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class DashboardWidget(Base):
|
||||
__tablename__ = "dashboard_widgets"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
dashboard_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
widget_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
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 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ProbeType(str, enum.Enum):
|
||||
tcp = "tcp"
|
||||
icmp = "icmp"
|
||||
|
||||
|
||||
class Host(Base):
|
||||
__tablename__ = "hosts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
address: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
probe_type: Mapped[ProbeType] = mapped_column(Enum(ProbeType), nullable=False, default=ProbeType.tcp)
|
||||
probe_port: Mapped[int] = mapped_column(Integer, nullable=False, default=80)
|
||||
ping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
dns_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
dns_expected_ip: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
poll_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PluginMetric(Base):
|
||||
__tablename__ = "plugin_metrics"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
value: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, Enum, Float, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PingStatus(str, enum.Enum):
|
||||
up = "up"
|
||||
down = "down"
|
||||
|
||||
|
||||
class DnsStatus(str, enum.Enum):
|
||||
resolved = "resolved"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class PingResult(Base):
|
||||
__tablename__ = "ping_results"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
host_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
probed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
status: Mapped[PingStatus] = mapped_column(Enum(PingStatus), nullable=False)
|
||||
response_time_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
|
||||
class DnsResult(Base):
|
||||
__tablename__ = "dns_results"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
host_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
resolved_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
status: Mapped[DnsStatus] = mapped_column(Enum(DnsStatus), nullable=False)
|
||||
resolved_ip: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledscryer.models.base import Base
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Boolean, DateTime, Enum, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin"
|
||||
operator = "operator"
|
||||
viewer = "viewer"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.viewer)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fabledscryer.core.alerts import record_metric
|
||||
from fabledscryer.models.hosts import Host
|
||||
from fabledscryer.models.monitors import DnsResult, DnsStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def dns_check(host: Host, session: AsyncSession) -> None:
|
||||
"""Resolve host and write dns_result + metrics."""
|
||||
result = await _resolve(host.address)
|
||||
|
||||
if result["resolved"]:
|
||||
resolved_ip = result["ip"] # first A/AAAA record (stored in DB)
|
||||
all_ips = result["all_ips"]
|
||||
|
||||
# Check against expected IP: pass only if at least one returned record matches
|
||||
if host.dns_expected_ip and host.dns_expected_ip not in all_ips:
|
||||
status = DnsStatus.failed
|
||||
dns_resolved_metric = 0.0
|
||||
else:
|
||||
status = DnsStatus.resolved
|
||||
dns_resolved_metric = 1.0
|
||||
|
||||
# Detect IP change vs most recent successful resolution
|
||||
prev_result = await session.execute(
|
||||
select(DnsResult)
|
||||
.where(DnsResult.host_id == host.id, DnsResult.status == DnsStatus.resolved)
|
||||
.order_by(DnsResult.resolved_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
prev = prev_result.scalar_one_or_none()
|
||||
ip_changed = 1.0 if (prev and prev.resolved_ip != resolved_ip) else 0.0
|
||||
else:
|
||||
resolved_ip = None
|
||||
status = DnsStatus.failed
|
||||
dns_resolved_metric = 0.0
|
||||
ip_changed = 0.0
|
||||
|
||||
session.add(DnsResult(
|
||||
host_id=host.id,
|
||||
status=status,
|
||||
resolved_ip=resolved_ip,
|
||||
))
|
||||
await session.flush()
|
||||
|
||||
await record_metric(session, "dns", host.name, "resolved", dns_resolved_metric)
|
||||
await record_metric(session, "dns", host.name, "ip_changed", ip_changed)
|
||||
|
||||
|
||||
async def _resolve(address: str) -> dict:
|
||||
"""Resolve hostname. Returns all A/AAAA records; stores first as resolved_ip."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
infos = await loop.run_in_executor(
|
||||
None, socket.getaddrinfo, address, None, socket.AF_UNSPEC, socket.SOCK_STREAM
|
||||
)
|
||||
if infos:
|
||||
all_ips = [info[4][0] for info in infos]
|
||||
return {"resolved": True, "ip": all_ips[0], "all_ips": all_ips}
|
||||
return {"resolved": False, "ip": None, "all_ips": []}
|
||||
except Exception as exc:
|
||||
logger.debug(f"DNS resolution failed for {address}: {exc}")
|
||||
return {"resolved": False, "ip": None, "all_ips": []}
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fabledscryer.core.alerts import record_metric
|
||||
from fabledscryer.models.hosts import Host, ProbeType
|
||||
from fabledscryer.models.monitors import PingResult, PingStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TCP_TIMEOUT = 5.0
|
||||
DEFAULT_TCP_PORT = 80
|
||||
|
||||
|
||||
async def ping_check(host: Host, session: AsyncSession) -> None:
|
||||
"""Probe a single host and write ping_result + metrics."""
|
||||
if host.probe_type == ProbeType.icmp:
|
||||
result = await _icmp_ping(host.address)
|
||||
else:
|
||||
result = await _tcp_ping(host.address, host.probe_port or DEFAULT_TCP_PORT)
|
||||
|
||||
status = PingStatus.up if result["up"] else PingStatus.down
|
||||
session.add(PingResult(
|
||||
host_id=host.id,
|
||||
status=status,
|
||||
response_time_ms=result.get("response_time_ms"),
|
||||
))
|
||||
await session.flush()
|
||||
|
||||
await record_metric(session, "ping", host.name, "up", 1.0 if result["up"] else 0.0)
|
||||
await record_metric(
|
||||
session, "ping", host.name, "response_time_ms",
|
||||
result.get("response_time_ms") or 0.0,
|
||||
)
|
||||
|
||||
|
||||
async def _tcp_ping(address: str, port: int) -> dict:
|
||||
start = time.monotonic()
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(address, port),
|
||||
timeout=TCP_TIMEOUT,
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return {"up": True, "response_time_ms": round((time.monotonic() - start) * 1000, 2)}
|
||||
except Exception:
|
||||
return {"up": False, "response_time_ms": None}
|
||||
|
||||
|
||||
async def _icmp_ping(address: str) -> dict:
|
||||
"""Use system ping command (setuid binary; no raw socket privilege needed in Python)."""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ping", "-c", "1", "-W", "2", address,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
if proc.returncode == 0:
|
||||
return {"up": True, "response_time_ms": round((time.monotonic() - start) * 1000, 2)}
|
||||
return {"up": False, "response_time_ms": None}
|
||||
except Exception as exc:
|
||||
logger.warning(f"ICMP ping failed for {address}: {exc}, falling back to TCP")
|
||||
return await _tcp_ping(address, DEFAULT_TCP_PORT)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Generate NUT (Network UPS Tools) configuration files.
|
||||
|
||||
Called by entrypoint.sh before the NUT daemons start. Reads config either from
|
||||
environment variables (legacy NUT_MANAGED=1 path) or from a JSON file written
|
||||
by the FabledScryer settings page (/data/nut_managed.json).
|
||||
|
||||
Environment variables (used when no --from-file is given):
|
||||
NUT_DRIVER NUT driver (default: snmp-ups)
|
||||
snmp-ups — any UPS with SNMP management card (APC, Eaton,
|
||||
CyberPower, Tripp Lite, Liebert, ...)
|
||||
netxml-ups — Eaton Network Management Card (XML/HTTP)
|
||||
NUT_UPS_NAME Name exposed by upsd (default: ups)
|
||||
Must match 'ups_name' in the UPS plugin settings.
|
||||
NUT_UPS_HOST IP address or hostname of the UPS management card (required)
|
||||
NUT_SNMP_COMMUNITY SNMP community string (default: public) [snmp-ups only]
|
||||
NUT_SNMP_VERSION SNMP version: v1, v2c (default: v1) [snmp-ups only]
|
||||
NUT_USERNAME Optional upsd username for authenticated access
|
||||
NUT_PASSWORD Optional upsd password
|
||||
|
||||
JSON file format (--from-file path):
|
||||
{
|
||||
"ups_host": "192.168.1.x", // required
|
||||
"driver": "snmp-ups",
|
||||
"ups_name": "ups",
|
||||
"snmp_community": "public",
|
||||
"snmp_version": "v1",
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
NUT_CONF_DIR = Path("/etc/nut")
|
||||
NUT_MANAGED_JSON = Path("/data/nut_managed.json")
|
||||
|
||||
|
||||
def _env(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default).strip()
|
||||
|
||||
|
||||
def write_configs(cfg: dict | None = None) -> None:
|
||||
"""Write NUT config files. If cfg is given, use it; otherwise fall back to env vars."""
|
||||
NUT_CONF_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if cfg is not None:
|
||||
driver = cfg.get("driver", "snmp-ups").strip()
|
||||
ups_name = cfg.get("ups_name", "ups").strip()
|
||||
ups_host = cfg.get("ups_host", "").strip()
|
||||
community = cfg.get("snmp_community", "public").strip()
|
||||
snmp_ver = cfg.get("snmp_version", "v1").strip()
|
||||
username = cfg.get("username", "").strip()
|
||||
password = cfg.get("password", "").strip()
|
||||
else:
|
||||
driver = _env("NUT_DRIVER", "snmp-ups")
|
||||
ups_name = _env("NUT_UPS_NAME", "ups")
|
||||
ups_host = _env("NUT_UPS_HOST")
|
||||
community = _env("NUT_SNMP_COMMUNITY", "public")
|
||||
snmp_ver = _env("NUT_SNMP_VERSION", "v1")
|
||||
username = _env("NUT_USERNAME")
|
||||
password = _env("NUT_PASSWORD")
|
||||
|
||||
if not ups_host:
|
||||
print("[nut_setup] ERROR: NUT_UPS_HOST is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ── nut.conf ───────────────────────────────────────────────────────────────
|
||||
(NUT_CONF_DIR / "nut.conf").write_text("MODE=standalone\n")
|
||||
|
||||
# ── ups.conf ───────────────────────────────────────────────────────────────
|
||||
ups_block = [f"[{ups_name}]"]
|
||||
ups_block.append(f" driver = {driver}")
|
||||
|
||||
if driver == "netxml-ups":
|
||||
# Eaton: port is a URL
|
||||
ups_block.append(f" port = http://{ups_host}")
|
||||
else:
|
||||
# snmp-ups and others: port is the IP/hostname
|
||||
ups_block.append(f" port = {ups_host}")
|
||||
if driver == "snmp-ups":
|
||||
ups_block.append(f" community = {community}")
|
||||
ups_block.append(f" snmp_version = {snmp_ver}")
|
||||
|
||||
ups_block.append(f' desc = "Network UPS at {ups_host}"')
|
||||
(NUT_CONF_DIR / "ups.conf").write_text("\n".join(ups_block) + "\n")
|
||||
|
||||
# ── upsd.conf ──────────────────────────────────────────────────────────────
|
||||
(NUT_CONF_DIR / "upsd.conf").write_text("LISTEN 127.0.0.1 3493\n")
|
||||
|
||||
# ── upsd.users ─────────────────────────────────────────────────────────────
|
||||
if username and password:
|
||||
users_content = (
|
||||
f"[{username}]\n"
|
||||
f" password = {password}\n"
|
||||
f" upsmon primary\n"
|
||||
)
|
||||
else:
|
||||
users_content = "# No users configured — unauthenticated access\n"
|
||||
users_path = NUT_CONF_DIR / "upsd.users"
|
||||
users_path.write_text(users_content)
|
||||
users_path.chmod(0o640)
|
||||
|
||||
print(
|
||||
f"[nut_setup] Config written: driver={driver} host={ups_host} ups_name={ups_name}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
cfg = None
|
||||
if "--from-file" in sys.argv:
|
||||
idx = sys.argv.index("--from-file")
|
||||
json_path = Path(sys.argv[idx + 1])
|
||||
with json_path.open() as f:
|
||||
cfg = json.load(f)
|
||||
write_configs(cfg)
|
||||
except Exception as exc:
|
||||
print(f"[nut_setup] ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -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"))
|
||||
@@ -0,0 +1 @@
|
||||
# fabledscryer/settings/__init__.py
|
||||
@@ -0,0 +1,698 @@
|
||||
# fabledscryer/settings/routes.py
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from quart import Blueprint, current_app, render_template, request, redirect, url_for, session
|
||||
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.core.audit import log_audit
|
||||
from fabledscryer.models.users import UserRole
|
||||
from fabledscryer.core.settings import (
|
||||
DEFAULTS, get_all_settings, set_setting,
|
||||
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
|
||||
to_oidc_cfg, to_ldap_cfg,
|
||||
)
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _discover_plugins() -> list[dict]:
|
||||
"""Scan PLUGIN_DIR for plugin.yaml files."""
|
||||
import yaml
|
||||
plugin_dir = Path(current_app.config.get("PLUGIN_DIR", "plugins")).resolve()
|
||||
plugins = []
|
||||
if not plugin_dir.exists():
|
||||
return plugins
|
||||
for entry in sorted(plugin_dir.iterdir()):
|
||||
yaml_path = entry / "plugin.yaml"
|
||||
if entry.is_dir() and yaml_path.exists():
|
||||
try:
|
||||
with yaml_path.open() as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
meta["_dir"] = entry.name
|
||||
plugins.append(meta)
|
||||
except Exception:
|
||||
logger.warning("Could not read %s", yaml_path)
|
||||
return plugins
|
||||
|
||||
|
||||
def _merge_plugin_config(discovered: list[dict], plugins_cfg: dict) -> None:
|
||||
"""Merge stored settings into discovered plugin dicts in-place."""
|
||||
for plugin in discovered:
|
||||
name = plugin["_dir"]
|
||||
stored = plugins_cfg.get(name, {})
|
||||
plugin["_enabled"] = stored.get("enabled", False)
|
||||
defaults = plugin.get("config", {})
|
||||
plugin["_config"] = {**defaults, **{k: v for k, v in stored.items() if k != "enabled"}}
|
||||
|
||||
|
||||
async def _reload_app_config() -> None:
|
||||
"""Reload app.config from DB so settings take effect without restart."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
fresh = await get_all_settings(db)
|
||||
current_app.config.update(
|
||||
SESSION_LIFETIME_HOURS=fresh.get("session.lifetime_hours", 8),
|
||||
DATA_RETENTION_DAYS=fresh.get("data.retention_days", 90),
|
||||
MONITORS_POLL_INTERVAL=fresh.get("monitors.poll_interval_seconds", 60),
|
||||
SMTP=to_smtp_cfg(fresh),
|
||||
WEBHOOK=to_webhook_cfg(fresh),
|
||||
ANSIBLE=to_ansible_cfg(fresh),
|
||||
PLUGINS=to_plugins_cfg(fresh),
|
||||
OIDC=to_oidc_cfg(fresh),
|
||||
LDAP=to_ldap_cfg(fresh),
|
||||
)
|
||||
|
||||
|
||||
# ── Redirect root to general tab ──────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/")
|
||||
@require_role(UserRole.admin)
|
||||
async def index():
|
||||
return redirect(url_for("settings.general"))
|
||||
|
||||
|
||||
# ── General ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/general/")
|
||||
@require_role(UserRole.admin)
|
||||
async def general():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
return await render_template("settings/general.html", settings=settings)
|
||||
|
||||
|
||||
@settings_bp.post("/general/")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_general():
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await set_setting(db, "session.lifetime_hours",
|
||||
int(form.get("session.lifetime_hours", 8)))
|
||||
await set_setting(db, "data.retention_days",
|
||||
int(form.get("data.retention_days", 90)))
|
||||
await set_setting(db, "monitors.poll_interval_seconds",
|
||||
int(form.get("monitors.poll_interval_seconds", 60)))
|
||||
await _reload_app_config()
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"settings.saved", detail={"section": "general"})
|
||||
return redirect(url_for("settings.general"))
|
||||
|
||||
|
||||
# ── Notifications (SMTP + Webhook) ────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/notifications/")
|
||||
@require_role(UserRole.admin)
|
||||
async def notifications():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
return await render_template("settings/notifications.html", settings=settings)
|
||||
|
||||
|
||||
@settings_bp.post("/notifications/")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_notifications():
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
recipients_raw = form.get("smtp.recipients", "")
|
||||
recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()]
|
||||
await set_setting(db, "smtp.host", form.get("smtp.host", ""))
|
||||
await set_setting(db, "smtp.port", int(form.get("smtp.port", 587)))
|
||||
await set_setting(db, "smtp.tls", "smtp.tls" in form)
|
||||
await set_setting(db, "smtp.username", form.get("smtp.username", ""))
|
||||
if form.get("smtp.password"):
|
||||
await set_setting(db, "smtp.password", form.get("smtp.password"))
|
||||
await set_setting(db, "smtp.recipients", recipients)
|
||||
await set_setting(db, "webhook.url", form.get("webhook.url", ""))
|
||||
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
|
||||
await _reload_app_config()
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"settings.saved", detail={"section": "notifications"})
|
||||
return redirect(url_for("settings.notifications"))
|
||||
|
||||
|
||||
# ── Ansible Sources ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_ansible_sources() -> list[dict]:
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
sources = settings.get("ansible.sources", [])
|
||||
return sources if isinstance(sources, list) else []
|
||||
|
||||
|
||||
async def _save_ansible_sources(sources: list[dict]) -> None:
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await set_setting(db, "ansible.sources", sources)
|
||||
await _reload_app_config()
|
||||
|
||||
|
||||
async def _ansible_sources_partial(sources: list[dict]):
|
||||
return await render_template("settings/_ansible_sources.html", sources=sources)
|
||||
|
||||
|
||||
@settings_bp.get("/ansible/")
|
||||
@require_role(UserRole.admin)
|
||||
async def ansible():
|
||||
sources = await _get_ansible_sources()
|
||||
return await render_template("settings/ansible.html", sources=sources)
|
||||
|
||||
|
||||
@settings_bp.post("/ansible/sources/add")
|
||||
@require_role(UserRole.admin)
|
||||
async def ansible_add_source():
|
||||
form = await request.form
|
||||
name = form.get("name", "").strip()
|
||||
src_type = form.get("type", "local").strip()
|
||||
if not name:
|
||||
sources = await _get_ansible_sources()
|
||||
return await _ansible_sources_partial(sources)
|
||||
entry: dict = {"name": name, "type": src_type}
|
||||
if src_type == "git":
|
||||
entry["url"] = form.get("url", "").strip()
|
||||
entry["branch"] = form.get("branch", "main").strip() or "main"
|
||||
try:
|
||||
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
|
||||
except ValueError:
|
||||
entry["pull_interval_seconds"] = 3600
|
||||
else:
|
||||
entry["path"] = form.get("path", "").strip()
|
||||
sources = await _get_ansible_sources()
|
||||
sources.append(entry)
|
||||
await _save_ansible_sources(sources)
|
||||
return await _ansible_sources_partial(sources)
|
||||
|
||||
|
||||
@settings_bp.post("/ansible/sources/<int:idx>/remove")
|
||||
@require_role(UserRole.admin)
|
||||
async def ansible_remove_source(idx: int):
|
||||
sources = await _get_ansible_sources()
|
||||
if 0 <= idx < len(sources):
|
||||
sources.pop(idx)
|
||||
await _save_ansible_sources(sources)
|
||||
return await _ansible_sources_partial(sources)
|
||||
|
||||
|
||||
@settings_bp.post("/ansible/sources/<int:idx>/save")
|
||||
@require_role(UserRole.admin)
|
||||
async def ansible_save_source(idx: int):
|
||||
sources = await _get_ansible_sources()
|
||||
if not (0 <= idx < len(sources)):
|
||||
return await _ansible_sources_partial(sources)
|
||||
form = await request.form
|
||||
src_type = form.get("type", "local").strip()
|
||||
entry: dict = {
|
||||
"name": form.get("name", sources[idx].get("name", "")).strip(),
|
||||
"type": src_type,
|
||||
}
|
||||
if src_type == "git":
|
||||
entry["url"] = form.get("url", "").strip()
|
||||
entry["branch"] = form.get("branch", "main").strip() or "main"
|
||||
try:
|
||||
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
|
||||
except ValueError:
|
||||
entry["pull_interval_seconds"] = 3600
|
||||
else:
|
||||
entry["path"] = form.get("path", "").strip()
|
||||
sources[idx] = entry
|
||||
await _save_ansible_sources(sources)
|
||||
return await _ansible_sources_partial(sources)
|
||||
|
||||
|
||||
@settings_bp.post("/ansible/sources/<int:idx>/sync")
|
||||
@require_role(UserRole.admin)
|
||||
async def ansible_sync_source(idx: int):
|
||||
"""Trigger a git pull for a git source and return a status badge."""
|
||||
sources = await _get_ansible_sources()
|
||||
if not (0 <= idx < len(sources)):
|
||||
return ('<span style="color:var(--red);font-size:0.8rem;">Source not found</span>', 404)
|
||||
source = sources[idx]
|
||||
if source.get("type") != "git":
|
||||
return '<span style="color:var(--text-muted);font-size:0.8rem;">Not a git source</span>'
|
||||
from fabledscryer.ansible.sources import git_pull
|
||||
from fabledscryer.core.settings import to_ansible_cfg
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
ansible_cfg = to_ansible_cfg(settings)
|
||||
from fabledscryer.ansible.sources import get_sources
|
||||
resolved = next((s for s in get_sources(ansible_cfg) if s["name"] == source["name"]), None)
|
||||
if resolved is None:
|
||||
return '<span style="color:var(--red);font-size:0.8rem;">Could not resolve source path</span>'
|
||||
try:
|
||||
await git_pull(resolved)
|
||||
return '<span style="color:var(--green);font-size:0.8rem;">Synced</span>'
|
||||
except Exception as exc:
|
||||
logger.exception("git pull failed for source %r", source["name"])
|
||||
return f'<span style="color:var(--red);font-size:0.8rem;">Sync failed: {exc}</span>'
|
||||
|
||||
|
||||
# ── Auth (OIDC / LDAP) ────────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/auth/")
|
||||
@require_role(UserRole.admin)
|
||||
async def auth_settings():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
return await render_template("settings/auth.html", settings=settings)
|
||||
|
||||
|
||||
@settings_bp.post("/auth/")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_auth_settings():
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
# OIDC
|
||||
await set_setting(db, "oidc.enabled", "oidc.enabled" in form)
|
||||
await set_setting(db, "oidc.discovery_url", form.get("oidc.discovery_url", "").strip())
|
||||
await set_setting(db, "oidc.client_id", form.get("oidc.client_id", "").strip())
|
||||
if form.get("oidc.client_secret"):
|
||||
await set_setting(db, "oidc.client_secret", form.get("oidc.client_secret").strip())
|
||||
await set_setting(db, "oidc.scopes", form.get("oidc.scopes", "openid profile email").strip())
|
||||
await set_setting(db, "oidc.username_claim", form.get("oidc.username_claim", "preferred_username").strip())
|
||||
await set_setting(db, "oidc.email_claim", form.get("oidc.email_claim", "email").strip())
|
||||
await set_setting(db, "oidc.groups_claim", form.get("oidc.groups_claim", "groups").strip())
|
||||
await set_setting(db, "oidc.admin_group", form.get("oidc.admin_group", "").strip())
|
||||
await set_setting(db, "oidc.operator_group", form.get("oidc.operator_group", "").strip())
|
||||
# LDAP
|
||||
await set_setting(db, "ldap.enabled", "ldap.enabled" in form)
|
||||
await set_setting(db, "ldap.host", form.get("ldap.host", "").strip())
|
||||
await set_setting(db, "ldap.port", int(form.get("ldap.port") or 389))
|
||||
await set_setting(db, "ldap.tls", "ldap.tls" in form)
|
||||
await set_setting(db, "ldap.bind_dn", form.get("ldap.bind_dn", "").strip())
|
||||
if form.get("ldap.bind_password"):
|
||||
await set_setting(db, "ldap.bind_password", form.get("ldap.bind_password").strip())
|
||||
await set_setting(db, "ldap.base_dn", form.get("ldap.base_dn", "").strip())
|
||||
await set_setting(db, "ldap.user_filter", form.get("ldap.user_filter", "(uid={username})").strip())
|
||||
await set_setting(db, "ldap.admin_group_dn", form.get("ldap.admin_group_dn", "").strip())
|
||||
await set_setting(db, "ldap.operator_group_dn", form.get("ldap.operator_group_dn", "").strip())
|
||||
await set_setting(db, "ldap.attr_username", form.get("ldap.attr_username", "uid").strip())
|
||||
await set_setting(db, "ldap.attr_email", form.get("ldap.attr_email", "mail").strip())
|
||||
await _reload_app_config()
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"settings.saved", detail={"section": "auth"})
|
||||
# Clear OIDC discovery cache so new settings take effect
|
||||
from fabledscryer.auth.oidc import _discovery_cache
|
||||
_discovery_cache.clear()
|
||||
return redirect(url_for("settings.auth_settings"))
|
||||
|
||||
|
||||
# ── Reports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/reports/")
|
||||
@require_role(UserRole.admin)
|
||||
async def reports():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
return await render_template("settings/reports.html", settings=settings, flash=None)
|
||||
|
||||
|
||||
@settings_bp.post("/reports/")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_reports():
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await set_setting(db, "reports.enabled", "reports.enabled" in form)
|
||||
await set_setting(db, "reports.schedule_day",
|
||||
int(form.get("reports.schedule_day", 6)))
|
||||
await set_setting(db, "reports.schedule_hour",
|
||||
int(form.get("reports.schedule_hour", 8)))
|
||||
return redirect(url_for("settings.reports"))
|
||||
|
||||
|
||||
@settings_bp.post("/reports/send-now/")
|
||||
@require_role(UserRole.admin)
|
||||
async def send_report_now():
|
||||
from fabledscryer.core.reports import send_report
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
ok, message = await send_report(current_app._get_current_object())
|
||||
return await render_template(
|
||||
"settings/reports.html",
|
||||
settings=settings,
|
||||
flash={"ok": ok, "message": message},
|
||||
)
|
||||
|
||||
|
||||
# ── Plugin repository helpers ─────────────────────────────────────────────────
|
||||
|
||||
def _get_plugin_repos(settings: dict) -> list[dict]:
|
||||
"""Return the list of plugin repositories from settings.
|
||||
|
||||
Migrates the legacy plugins.index_url single-URL field to the list format
|
||||
on first access if plugins.repositories is not yet set.
|
||||
"""
|
||||
repos = settings.get("plugins.repositories")
|
||||
if repos and isinstance(repos, list):
|
||||
return repos
|
||||
# Migrate legacy single URL
|
||||
legacy = settings.get("plugins.index_url", "").strip()
|
||||
if legacy:
|
||||
return [{"name": "Default", "url": legacy}]
|
||||
return []
|
||||
|
||||
|
||||
async def _save_plugin_repos(db, repos: list[dict]) -> None:
|
||||
await set_setting(db, "plugins.repositories", repos)
|
||||
|
||||
|
||||
async def _plugin_repos_partial(settings: dict):
|
||||
repos = _get_plugin_repos(settings)
|
||||
return await render_template("settings/_plugin_repos.html", repos=repos)
|
||||
|
||||
|
||||
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
|
||||
"""Extract config values for one plugin from a form submission."""
|
||||
name = plugin["_dir"]
|
||||
enabled = f"plugin.{name}.enabled" in form
|
||||
plugin_cfg: dict = {"enabled": enabled}
|
||||
for cfg_key in plugin.get("config", {}).keys():
|
||||
field_name = f"plugin.{name}.{cfg_key}"
|
||||
default_val = plugin["config"][cfg_key]
|
||||
if isinstance(default_val, list):
|
||||
pass # list configs must be set in plugin.yaml
|
||||
elif isinstance(default_val, dict):
|
||||
sub_dict = {}
|
||||
for sub_key, sub_default in default_val.items():
|
||||
sub_field = f"plugin.{name}.{cfg_key}.{sub_key}"
|
||||
if isinstance(sub_default, bool):
|
||||
sub_dict[sub_key] = sub_field in form
|
||||
elif sub_field in form:
|
||||
raw_sub = form[sub_field]
|
||||
if isinstance(sub_default, int):
|
||||
try:
|
||||
sub_dict[sub_key] = int(raw_sub)
|
||||
except ValueError:
|
||||
sub_dict[sub_key] = sub_default
|
||||
else:
|
||||
sub_dict[sub_key] = raw_sub
|
||||
else:
|
||||
sub_dict[sub_key] = sub_default
|
||||
plugin_cfg[cfg_key] = sub_dict
|
||||
elif field_name in form:
|
||||
raw_val = form[field_name]
|
||||
if isinstance(default_val, bool):
|
||||
plugin_cfg[cfg_key] = raw_val.lower() in ("1", "true", "yes", "on")
|
||||
elif isinstance(default_val, int):
|
||||
try:
|
||||
plugin_cfg[cfg_key] = int(raw_val)
|
||||
except ValueError:
|
||||
plugin_cfg[cfg_key] = default_val
|
||||
else:
|
||||
plugin_cfg[cfg_key] = raw_val
|
||||
# Managed NUT: when nut_managed is on, the NUT daemon runs in this container
|
||||
# — force nut_host/port to localhost so the plugin connects automatically.
|
||||
if name == "ups" and plugin_cfg.get("nut_managed"):
|
||||
plugin_cfg["nut_host"] = "localhost"
|
||||
plugin_cfg["nut_port"] = 3493
|
||||
return plugin_cfg
|
||||
|
||||
|
||||
# ── Plugins list ──────────────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/plugins/")
|
||||
@require_role(UserRole.admin)
|
||||
async def plugins():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
discovered = _discover_plugins()
|
||||
_merge_plugin_config(discovered, to_plugins_cfg(settings))
|
||||
repos = _get_plugin_repos(settings)
|
||||
return await render_template(
|
||||
"settings/plugins.html",
|
||||
discovered_plugins=discovered,
|
||||
repos=repos,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
# ── Per-plugin detail (settings) ──────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/plugins/<name>/")
|
||||
@require_role(UserRole.admin)
|
||||
async def plugin_detail(name: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
discovered = _discover_plugins()
|
||||
plugin = next((p for p in discovered if p["_dir"] == name), None)
|
||||
if plugin is None:
|
||||
return redirect(url_for("settings.plugins"))
|
||||
_merge_plugin_config([plugin], to_plugins_cfg(settings))
|
||||
from fabledscryer.core.plugin_manager import _LOADED_PLUGINS, get_plugin_failures
|
||||
return await render_template(
|
||||
"settings/plugin_detail.html",
|
||||
plugin=plugin,
|
||||
is_loaded=name in _LOADED_PLUGINS,
|
||||
fail_reason=get_plugin_failures().get(name),
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.post("/plugins/<name>/")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_plugin_detail(name: str):
|
||||
form = await request.form
|
||||
discovered = _discover_plugins()
|
||||
plugin = next((p for p in discovered if p["_dir"] == name), None)
|
||||
if plugin is None:
|
||||
return redirect(url_for("settings.plugins"))
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
old_settings = await get_all_settings(db)
|
||||
old_enabled = to_plugins_cfg(old_settings).get(name, {}).get("enabled", False)
|
||||
|
||||
plugin_cfg = _build_plugin_cfg_from_form(plugin, form)
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await set_setting(db, f"plugin.{name}", plugin_cfg)
|
||||
|
||||
await _reload_app_config()
|
||||
from fabledscryer.core.plugin_index import clear_catalog_cache
|
||||
clear_catalog_cache()
|
||||
|
||||
new_enabled = plugin_cfg.get("enabled", False)
|
||||
if old_enabled != new_enabled:
|
||||
action = "plugin.enabled" if new_enabled else "plugin.disabled"
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
action, entity_type="plugin", entity_id=name)
|
||||
if new_enabled and not old_enabled:
|
||||
from fabledscryer.core.plugin_manager import hot_reload_plugin
|
||||
hot_reload_plugin(current_app._get_current_object(), name)
|
||||
|
||||
if name == "ups":
|
||||
_sync_nut_managed_config(form)
|
||||
|
||||
return redirect(url_for("settings.plugin_detail", name=name))
|
||||
|
||||
|
||||
# ── Plugin repository HTMX routes ─────────────────────────────────────────────
|
||||
|
||||
@settings_bp.post("/plugins/repos/add")
|
||||
@require_role(UserRole.admin)
|
||||
async def plugin_repos_add():
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
repos = _get_plugin_repos(settings)
|
||||
url = form.get("url", "").strip()
|
||||
repo_name = form.get("name", "").strip() or url
|
||||
if url:
|
||||
repos.append({"name": repo_name, "url": url})
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await _save_plugin_repos(db, repos)
|
||||
from fabledscryer.core.plugin_index import clear_catalog_cache
|
||||
clear_catalog_cache()
|
||||
return await _plugin_repos_partial({"plugins.repositories": repos})
|
||||
|
||||
|
||||
@settings_bp.post("/plugins/repos/<int:idx>/remove")
|
||||
@require_role(UserRole.admin)
|
||||
async def plugin_repos_remove(idx: int):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
repos = _get_plugin_repos(settings)
|
||||
if 0 <= idx < len(repos):
|
||||
repos.pop(idx)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await _save_plugin_repos(db, repos)
|
||||
from fabledscryer.core.plugin_index import clear_catalog_cache
|
||||
clear_catalog_cache()
|
||||
return await _plugin_repos_partial({"plugins.repositories": repos})
|
||||
|
||||
|
||||
@settings_bp.post("/plugins/repos/<int:idx>/save")
|
||||
@require_role(UserRole.admin)
|
||||
async def plugin_repos_save(idx: int):
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
repos = _get_plugin_repos(settings)
|
||||
if 0 <= idx < len(repos):
|
||||
repos[idx] = {
|
||||
"name": form.get("name", "").strip() or repos[idx]["name"],
|
||||
"url": form.get("url", "").strip() or repos[idx]["url"],
|
||||
}
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await _save_plugin_repos(db, repos)
|
||||
from fabledscryer.core.plugin_index import clear_catalog_cache
|
||||
clear_catalog_cache()
|
||||
return await _plugin_repos_partial({"plugins.repositories": repos})
|
||||
|
||||
|
||||
def _sync_nut_managed_config(form) -> None:
|
||||
"""Write or remove /data/nut_managed.json based on UPS plugin managed NUT settings."""
|
||||
import json
|
||||
nut_managed_path = Path("/data/nut_managed.json")
|
||||
nut_managed = "plugin.ups.nut_managed" in form
|
||||
ups_host = form.get("plugin.ups.nut_ups_host", "").strip()
|
||||
# Only write the file when managed mode is on AND a host is configured.
|
||||
# Without a host, NUT can't start and the container would loop on errors.
|
||||
if nut_managed and ups_host:
|
||||
cfg = {
|
||||
"ups_host": ups_host,
|
||||
"driver": form.get("plugin.ups.nut_driver", "snmp-ups").strip(),
|
||||
"ups_name": form.get("plugin.ups.ups_name", "ups").strip(),
|
||||
"snmp_community": form.get("plugin.ups.nut_snmp_community", "public").strip(),
|
||||
"snmp_version": form.get("plugin.ups.nut_snmp_version", "v1").strip(),
|
||||
"username": form.get("plugin.ups.nut_username", "").strip(),
|
||||
"password": form.get("plugin.ups.nut_password", "").strip(),
|
||||
}
|
||||
try:
|
||||
nut_managed_path.write_text(json.dumps(cfg, indent=2))
|
||||
except OSError as exc:
|
||||
logger.warning("Could not write %s: %s", nut_managed_path, exc)
|
||||
else:
|
||||
try:
|
||||
nut_managed_path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not remove %s: %s", nut_managed_path, exc)
|
||||
|
||||
|
||||
# ── Plugin catalog (HTMX partial) ─────────────────────────────────────────────
|
||||
|
||||
@settings_bp.get("/plugins/catalog/")
|
||||
@require_role(UserRole.admin)
|
||||
async def plugins_catalog():
|
||||
"""HTMX partial: list plugins available in the remote catalog."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
repos = _get_plugin_repos(settings)
|
||||
repo_urls = [r["url"] for r in repos if r.get("url")]
|
||||
|
||||
if not repo_urls:
|
||||
return await render_template(
|
||||
"settings/_catalog_partial.html",
|
||||
catalog=[],
|
||||
installed_names=set(),
|
||||
loaded_names=set(),
|
||||
error="No plugin repositories configured.",
|
||||
)
|
||||
|
||||
from fabledscryer.core.plugin_index import fetch_catalog
|
||||
from fabledscryer.core.plugin_manager import _LOADED_PLUGINS
|
||||
|
||||
force = request.args.get("refresh") == "1"
|
||||
try:
|
||||
catalog = await fetch_catalog(repo_urls, force=force)
|
||||
error = None if catalog else "Catalog is empty or could not be fetched."
|
||||
except Exception as exc:
|
||||
catalog = []
|
||||
error = str(exc)
|
||||
|
||||
discovered = _discover_plugins()
|
||||
installed_names = {p["_dir"] for p in discovered}
|
||||
installed_versions = {p["_dir"]: p.get("version", "0.0.0") for p in discovered}
|
||||
|
||||
return await render_template(
|
||||
"settings/_catalog_partial.html",
|
||||
catalog=catalog,
|
||||
installed_names=installed_names,
|
||||
installed_versions=installed_versions,
|
||||
loaded_names=_LOADED_PLUGINS,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
# ── Plugin install ─────────────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.post("/plugins/install/<name>")
|
||||
@require_role(UserRole.admin)
|
||||
async def install_plugin(name: str):
|
||||
"""Download and install a plugin from the catalog, then hot-reload it."""
|
||||
form = await request.form
|
||||
download_url = form.get("download_url", "").strip()
|
||||
checksum = form.get("checksum_sha256", "").strip()
|
||||
|
||||
if not download_url:
|
||||
return await render_template(
|
||||
"settings/_install_result.html",
|
||||
name=name, success=False,
|
||||
message="No download URL provided.",
|
||||
)
|
||||
|
||||
from fabledscryer.core.plugin_manager import download_and_install_plugin, hot_reload_plugin
|
||||
|
||||
ok, msg = await download_and_install_plugin(
|
||||
current_app._get_current_object(), name, download_url, checksum
|
||||
)
|
||||
if not ok:
|
||||
return await render_template(
|
||||
"settings/_install_result.html",
|
||||
name=name, success=False, message=msg,
|
||||
)
|
||||
|
||||
# Attempt hot-reload
|
||||
ok2, msg2 = hot_reload_plugin(current_app._get_current_object(), name)
|
||||
if ok2:
|
||||
return await render_template(
|
||||
"settings/_install_result.html",
|
||||
name=name, success=True,
|
||||
message=f"Installed and activated. {msg2}",
|
||||
)
|
||||
else:
|
||||
return await render_template(
|
||||
"settings/_install_result.html",
|
||||
name=name, success=True,
|
||||
message=f"Installed. {msg2}",
|
||||
restart_required=True,
|
||||
)
|
||||
|
||||
|
||||
# ── Plugin hot-reload ──────────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.post("/plugins/reload/<name>")
|
||||
@require_role(UserRole.admin)
|
||||
async def reload_plugin(name: str):
|
||||
"""Hot-reload a plugin that was installed but not yet active."""
|
||||
from fabledscryer.core.plugin_manager import hot_reload_plugin
|
||||
ok, msg = hot_reload_plugin(current_app._get_current_object(), name)
|
||||
return await render_template(
|
||||
"settings/_install_result.html",
|
||||
name=name, success=ok, message=msg,
|
||||
restart_required=(not ok and "restart" in msg.lower()),
|
||||
)
|
||||
|
||||
|
||||
# ── App restart ───────────────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.post("/plugins/restart/")
|
||||
@require_role(UserRole.admin)
|
||||
async def restart_app_route():
|
||||
"""Trigger an in-app restart (replaces the process via os.execv)."""
|
||||
import asyncio
|
||||
from fabledscryer.core.plugin_manager import restart_app
|
||||
|
||||
# Schedule the restart slightly after we return the response
|
||||
async def _delayed_restart():
|
||||
await asyncio.sleep(0.5)
|
||||
restart_app()
|
||||
|
||||
asyncio.create_task(_delayed_restart())
|
||||
return await render_template("settings/_restart_pending.html")
|
||||
@@ -0,0 +1,15 @@
|
||||
{#
|
||||
Reusable time-range selector.
|
||||
Requires `current_range` to be in template context.
|
||||
Places a hidden input #time-range (name="range") that HTMX hx-include picks up,
|
||||
plus a visible button group that calls setTimeRange() defined in base.html.
|
||||
#}
|
||||
<div style="display:flex;align-items:center;gap:0.2rem;">
|
||||
<input type="hidden" id="time-range" name="range" value="{{ current_range }}">
|
||||
{% for key in ["1h", "6h", "24h", "7d", "30d"] %}
|
||||
<button type="button"
|
||||
class="range-btn{% if current_range == key %} active{% endif %}"
|
||||
data-range="{{ key }}"
|
||||
onclick="setTimeRange('{{ key }}')">{{ key }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alerts — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">Alerts</h1>
|
||||
|
||||
{% if active %}
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Active</h2>
|
||||
<div class="card-flush" style="margin-bottom:2rem;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>State</th>
|
||||
<th>Rule</th>
|
||||
<th>Resource</th>
|
||||
<th>Metric</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for state, rule in active %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if state.state.value == 'firing' %}
|
||||
<span class="badge badge-red">FIRING</span>
|
||||
{% elif state.state.value == 'acknowledged' %}
|
||||
<span class="badge badge-yellow">ACK</span>
|
||||
{% else %}
|
||||
<span class="badge badge-dim">PENDING</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ rule.name }}</td>
|
||||
<td style="color:var(--text-muted);">{{ rule.resource_name }}</td>
|
||||
<td style="color:var(--text-muted);">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td>
|
||||
<td class="td-actions">
|
||||
{% if state.state.value == 'firing' %}
|
||||
<form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-warn">Acknowledge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:var(--text-muted);margin-bottom:2rem;">No active alerts.</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
|
||||
<h2 class="section-title">Rules</h2>
|
||||
<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>
|
||||
{% if rules %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Condition</th>
|
||||
<th style="text-align:center;">Consec.</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in rules %}
|
||||
<tr {% if not rule.enabled %}style="opacity:0.55;"{% endif %}>
|
||||
<td>
|
||||
{{ 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 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">
|
||||
<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;">
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:var(--text-muted);">No alert rules configured.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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 "{{ w.name }}"?')">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 %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:600px;margin:2rem auto;">
|
||||
<h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">
|
||||
|
||||
<div class="form-group">
|
||||
<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 (0–1)"),
|
||||
("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 0–100"),
|
||||
("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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">{% if rule %}Save Changes{% else %}Create Rule{% endif %}</button>
|
||||
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Browse Playbooks — 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;">Browse Playbooks</h1>
|
||||
<a href="/ansible/" class="btn btn-ghost btn-sm">← Run History</a>
|
||||
</div>
|
||||
|
||||
{% if view_contents is defined %}
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
|
||||
<h3 class="section-title">{{ view_path }}</h3>
|
||||
<a href="/ansible/browse" class="btn btn-ghost btn-sm">← Back to browse</a>
|
||||
</div>
|
||||
<pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not source_data %}
|
||||
{% if view_contents is not defined %}
|
||||
<div class="card">
|
||||
<p style="color:var(--text-muted);">No Ansible sources configured. <a href="/settings/ansible/">Add sources in Settings → Ansible</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% for sd in source_data %}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
|
||||
<h3 class="section-title">{{ sd.source.name }}</h3>
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;background:var(--bg-elevated);padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">{{ sd.source.path }}</span>
|
||||
</div>
|
||||
|
||||
{% if not sd.playbooks %}
|
||||
<p style="color:#606080;font-size:0.9rem;">No playbooks found at this path.</p>
|
||||
{% else %}
|
||||
<table class="table" style="margin-bottom:1rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playbook</th>
|
||||
<th style="width:120px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pb in sd.playbooks %}
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ pb }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">View</a>
|
||||
<a href="#run-form-{{ sd.source.name }}"
|
||||
onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='block';
|
||||
document.getElementById('run-playbook-{{ sd.source.name }}').value='{{ pb }}';
|
||||
document.getElementById('run-playbook-display-{{ sd.source.name }}').value='{{ pb }}';
|
||||
return false;"
|
||||
class="btn btn-sm btn-ghost">Run</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# Run trigger form for this source #}
|
||||
<div id="run-form-{{ sd.source.name }}" style="display:none;background:var(--bg-elevated);border-radius:6px;padding:1rem;border:1px solid var(--border-mid);">
|
||||
<h4 class="section-title" style="margin-bottom:0.75rem;">Trigger Run</h4>
|
||||
<form hx-post="/ansible/runs" hx-target="#run-result-{{ sd.source.name }}" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_name" value="{{ sd.source.name }}">
|
||||
<input type="hidden" id="run-playbook-{{ sd.source.name }}" name="playbook_path" value="">
|
||||
<div class="form-group">
|
||||
<label>Playbook</label>
|
||||
<input type="text" id="run-playbook-display-{{ sd.source.name }}"
|
||||
readonly style="color:var(--text-muted);" value="">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Inventory</label>
|
||||
<select name="inventory_path">
|
||||
{% for inv in sd.inventories %}
|
||||
<option value="{{ inv }}">{{ inv }}</option>
|
||||
{% endfor %}
|
||||
<option value="">— Enter manually below —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Or enter inventory path manually</label>
|
||||
<input type="text" id="manual-inv-{{ sd.source.name }}" placeholder="inventories/production/hosts">
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||
<button type="button" class="btn"
|
||||
onclick="var manual=document.getElementById('manual-inv-{{ sd.source.name }}').value.trim();
|
||||
if(manual){this.closest('form').querySelector('[name=inventory_path]').value=manual;}
|
||||
htmx.trigger(this.closest('form'),'submit');">Execute</button>
|
||||
<a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;"
|
||||
class="btn btn-ghost btn-sm">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
<div id="run-result-{{ sd.source.name }}" style="margin-top:1rem;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ansible Runs — 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;">Ansible Runs</h1>
|
||||
<a href="/ansible/browse" class="btn">Browse Playbooks</a>
|
||||
</div>
|
||||
{% if not runs %}
|
||||
<div class="card">
|
||||
<p style="color:var(--text-muted);">No runs yet. <a href="/ansible/browse">Browse playbooks</a> to begin.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playbook</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
|
||||
<tr>
|
||||
<td style="font-size:0.9rem;">{{ run.playbook_path }}</td>
|
||||
<td style="color:var(--text-muted);font-size:0.9rem;">{{ run.source_name }}</td>
|
||||
<td>
|
||||
<span style="color:{{ status_color }};font-size:0.85rem;font-weight:bold;">{{ run.status.value.upper() }}</span>
|
||||
</td>
|
||||
<td style="color:var(--text-muted);font-size:0.85rem;">{{ run.started_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/ansible/runs/{{ run.id }}" class="btn btn-sm btn-ghost">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Run {{ run.id[:8] }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||
<a href="/ansible/" class="btn btn-ghost btn-sm">← Runs</a>
|
||||
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
|
||||
</div>
|
||||
|
||||
{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
|
||||
<div class="card">
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
|
||||
<span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span>
|
||||
<span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span>
|
||||
<span style="color:var(--text-muted);">Inventory</span><span>{{ run.inventory_path }}</span>
|
||||
<span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span>
|
||||
<span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
|
||||
<span style="color:var(--text-muted);">Started</span><span style="color:var(--text-dim);">{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
|
||||
{% if run.finished_at %}
|
||||
<span style="color:var(--text-muted);">Finished</span><span style="color:var(--text-dim);">{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;">
|
||||
<div style="padding:0.75rem 1rem;background:var(--bg-elevated);border-bottom:1px solid var(--border);">
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">Output</span>
|
||||
</div>
|
||||
{% if run.status.value == "running" %}
|
||||
<pre id="live-output"
|
||||
style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--green);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }}</pre>
|
||||
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:var(--text-muted);">
|
||||
Streaming…
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
|
||||
var pre = document.getElementById('live-output');
|
||||
var status = document.getElementById('run-done-status');
|
||||
es.addEventListener('output', function(e) {
|
||||
pre.textContent += e.data + '\n';
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
});
|
||||
es.addEventListener('done', function(e) {
|
||||
es.close();
|
||||
status.textContent = 'Finished: ' + e.data;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<pre style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--text-muted);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
<div style="background:#0f0f1e;border:1px solid #2a2a4a;border-radius:6px;padding:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:0.75rem;">
|
||||
<span style="color:#a0a000;font-weight:bold;">RUNNING</span>
|
||||
<span style="color:#a0a0c0;font-size:0.9rem;">{{ run.playbook_path }}</span>
|
||||
<a href="/ansible/runs/{{ run.id }}" style="color:#6060c0;font-size:0.82rem;margin-left:auto;">Full view →</a>
|
||||
</div>
|
||||
<pre id="sse-output-{{ run.id }}"
|
||||
style="background:#080810;padding:0.75rem;border-radius:4px;font-family:monospace;font-size:0.78rem;color:#a0c0a0;max-height:400px;overflow-y:auto;white-space:pre-wrap;margin:0 0 0.5rem 0;">
|
||||
</pre>
|
||||
<div id="sse-status-{{ run.id }}" style="font-size:0.85rem;color:#606080;">
|
||||
Streaming output…
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
|
||||
var pre = document.getElementById('sse-output-{{ run.id }}');
|
||||
var status = document.getElementById('sse-status-{{ run.id }}');
|
||||
es.addEventListener('output', function(e) {
|
||||
pre.textContent += e.data + '\n';
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
});
|
||||
es.addEventListener('done', function(e) {
|
||||
es.close();
|
||||
status.textContent = 'Finished: ' + e.data;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log — 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;">Audit Log</h1>
|
||||
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" name="action" value="{{ action_filter }}"
|
||||
placeholder="Filter by action…"
|
||||
style="width:200px;font-size:0.85rem;">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Filter</button>
|
||||
{% if action_filter %}
|
||||
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if events %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width:160px;">Time (UTC)</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Entity</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in events %}
|
||||
{% set e = item.event %}
|
||||
{% set d = item.detail %}
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:nowrap;color:var(--text-muted);">
|
||||
{{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}
|
||||
</td>
|
||||
<td style="font-size:0.85rem;">{{ e.username }}</td>
|
||||
<td>
|
||||
{% set cat = e.action.split('.')[0] %}
|
||||
<span style="
|
||||
font-size:0.78rem;font-family:ui-monospace,monospace;
|
||||
padding:0.15rem 0.4rem;border-radius:3px;
|
||||
background:{% if cat == 'auth' %}var(--bg-elevated){% elif cat == 'plugin' %}var(--gold-dim){% elif cat == 'alert' %}var(--red-dim){% elif cat == 'host' %}var(--green-dim){% elif cat == 'settings' %}var(--bg-elevated){% else %}var(--bg-elevated){% endif %};
|
||||
color:{% if cat == 'auth' %}var(--accent){% elif cat == 'plugin' %}var(--gold){% elif cat == 'alert' %}var(--red){% elif cat == 'host' %}var(--green){% elif cat == 'settings' %}var(--text-muted){% else %}var(--text-muted){% endif %};">
|
||||
{{ e.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);">
|
||||
{% if e.entity_type %}
|
||||
<span style="color:var(--text-dim);">{{ e.entity_type }}/</span>{{ e.entity_id or "" }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
|
||||
{% if d %}
|
||||
{% for k, v in d.items() %}
|
||||
<span style="color:var(--text-dim);">{{ k }}:</span>
|
||||
<span style="font-family:ui-monospace,monospace;">{{ v }}</span>
|
||||
{% if not loop.last %} · {% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style="font-size:0.78rem;color:var(--text-dim);margin-top:0.75rem;">
|
||||
Showing last {{ limit }} events.
|
||||
{% if events | length == limit %}
|
||||
<a href="?limit={{ limit * 2 }}{% if action_filter %}&action={{ action_filter }}{% endif %}">Load more</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<p style="color:var(--text-muted);">No audit events recorded yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:400px;margin:4rem auto;">
|
||||
<div class="card">
|
||||
<h1 style="font-family: var(--font-serif); font-weight: 700; font-size: 2.75rem; color: var(--gold); text-align: center; margin: 0; line-height: 1.1; letter-spacing: 0.02em;">Roundtable</h1>
|
||||
<p style="color: var(--text-dim); font-family: var(--font-serif); font-style: italic; font-size: 1.15rem; text-align: center; margin: 0 0 1.5rem;">Under Watch, Under Care.</p>
|
||||
{% if error %}
|
||||
<div style="color:var(--red);font-size:0.88rem;margin-bottom:1rem;
|
||||
background:var(--red-dim);padding:0.6rem 0.75rem;border-radius:4px;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if oidc_enabled %}
|
||||
<a href="/login/oidc" class="btn" style="width:100%;text-align:center;margin-bottom:1.25rem;display:block;">
|
||||
Sign in with SSO
|
||||
</a>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.25rem;">
|
||||
<div style="flex:1;height:1px;background:var(--border-mid);"></div>
|
||||
<span style="font-size:0.78rem;color:var(--text-dim);">or</span>
|
||||
<div style="flex:1;height:1px;background:var(--border-mid);"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<div class="form-group">
|
||||
<input type="text" name="username" placeholder="Username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn {% if oidc_enabled %}btn-ghost{% endif %}" style="width:100%;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}First-Run Setup — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:400px;margin:4rem auto;">
|
||||
<div class="card">
|
||||
<h2 class="page-title" style="margin-bottom:0.5rem;">First-Run Setup</h2>
|
||||
<p style="margin-bottom:1.5rem;color:var(--text-muted);font-size:0.9rem;">Create the initial admin account.</p>
|
||||
<form method="post" action="/setup">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Admin Account</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Fabled Scryer{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #131315;
|
||||
--bg-card: #1d1d20;
|
||||
--bg-elevated: #24242a;
|
||||
--border: #30303a;
|
||||
--border-mid: #3a3a44;
|
||||
--text: #d4d0c0;
|
||||
--text-muted: #b8b8b0;
|
||||
--text-dim: #8a8a92;
|
||||
--gold: #c8a840;
|
||||
--gold-hover: #d8b850;
|
||||
--gold-dim: #2a2410;
|
||||
--pewter: #8a8a92;
|
||||
--accent: var(--gold);
|
||||
--accent-hover: var(--gold-hover);
|
||||
--green: #4aa86a;
|
||||
--green-dim: #162a1c;
|
||||
--yellow: #d4a840;
|
||||
--yellow-dim: #2a2410;
|
||||
--orange: #c87840;
|
||||
--orange-dim: #2a1a10;
|
||||
--red: #c84048;
|
||||
--red-dim: #2a1014;
|
||||
--font-serif: 'EB Garamond', Georgia, serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
|
||||
|
||||
#candle-glow {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(200, 168, 64, 0.045) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%);
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
background: var(--bg-card);
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border-mid);
|
||||
height: 48px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
nav .brand {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
margin-right: 2rem;
|
||||
letter-spacing: 0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--gold);
|
||||
text-decoration: none;
|
||||
}
|
||||
nav .brand svg { flex-shrink: 0; }
|
||||
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
|
||||
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
|
||||
nav a.nav-end { margin-left: auto; }
|
||||
|
||||
/* Layout */
|
||||
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
|
||||
|
||||
/* Alerts */
|
||||
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.alert-error { background: var(--red-dim); border: 1px solid #6a1820; color: #ffb0b8; }
|
||||
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
|
||||
|
||||
/* Cards */
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
|
||||
|
||||
/* Typography */
|
||||
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 600; color: #c8b4f8; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
|
||||
.section-title { font-family: var(--font-serif); font-size: 0.82rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
h1, h2, h3 { font-family: var(--font-serif); }
|
||||
|
||||
/* Tables */
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
|
||||
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover td { background: var(--bg-elevated); }
|
||||
.table .td-actions { text-align: right; white-space: nowrap; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-serif); }
|
||||
input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
|
||||
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-mid);
|
||||
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||||
select { cursor: pointer; }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
|
||||
.btn:hover { background: var(--accent-hover); color: #fff; }
|
||||
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
|
||||
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
|
||||
.btn-danger { background: #7a1820; color: #ffb0b0; }
|
||||
.btn-danger:hover { background: #9a2030; }
|
||||
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
|
||||
.btn-warn:hover { background: #4a4000; }
|
||||
|
||||
/* Badges / Status */
|
||||
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-red { background: var(--red-dim); color: #ff8090; }
|
||||
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
|
||||
.badge-orange { background: var(--orange-dim); color: var(--orange); }
|
||||
.badge-gold { background: var(--gold-dim); color: var(--gold); }
|
||||
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
|
||||
.dot-down { background: var(--red); }
|
||||
.dot-warn { background: var(--yellow); }
|
||||
.dot-dim { background: var(--text-dim); }
|
||||
|
||||
/* Empty state */
|
||||
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
|
||||
|
||||
/* Stat cards (dashboard) */
|
||||
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
|
||||
|
||||
/* Ping pills */
|
||||
.ping-card { padding: 0.75rem 1.25rem; }
|
||||
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
|
||||
.ping-row:last-child { border-bottom:none; }
|
||||
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
|
||||
.ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
|
||||
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
|
||||
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
|
||||
.pill:hover { opacity:.75; }
|
||||
.pill-empty { background:var(--bg-elevated); }
|
||||
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
|
||||
.ping-cur-good { color:var(--green); }
|
||||
.ping-cur-warn { color:var(--yellow); }
|
||||
.ping-cur-bad { color:var(--orange); }
|
||||
.ping-cur-down { color:var(--red); font-weight:bold; }
|
||||
.ping-cur-nd { color:var(--text-dim); }
|
||||
|
||||
/* Widget headers */
|
||||
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
|
||||
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.widget-link { font-size:0.8rem; color:var(--text-muted); }
|
||||
.widget-link:hover { color:var(--text); }
|
||||
|
||||
/* Time range selector */
|
||||
.range-btn { background:none; border:1px solid var(--border-mid); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
|
||||
.range-btn:hover { background:var(--bg-elevated); color:var(--text); border-color:var(--accent); }
|
||||
.range-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="candle-glow"></div>
|
||||
|
||||
{% if session.user_id is defined %}
|
||||
<nav>
|
||||
<a href="/" class="brand">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="12" r="9.3" fill="#8a8a92" opacity="0.1"/>
|
||||
<path d="M7 10 L7 5.8 L9 7.8 L10.5 5 L12 7.2 L13.5 5 L15 7.8 L17 5.8 L17 10 Z" fill="#c8a840"/>
|
||||
<ellipse cx="12" cy="15.5" rx="5.2" ry="1" fill="#c8a840"/>
|
||||
<rect x="9.6" y="15.5" width="1" height="3.8" fill="#c8a840"/>
|
||||
<rect x="13.4" y="15.5" width="1" height="3.8" fill="#c8a840"/>
|
||||
</svg>
|
||||
Fabled Scryer
|
||||
</a>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/hosts/">Hosts</a>
|
||||
<a href="/hosts/uptime">Uptime</a>
|
||||
<a href="/ping/">Ping</a>
|
||||
<a href="/dns/">DNS</a>
|
||||
<a href="/alerts/">Alerts</a>
|
||||
<a href="/ansible/">Ansible</a>
|
||||
{% if session.user_role == 'admin' %}
|
||||
<a href="/settings/">Settings</a>
|
||||
<a href="/audit/">Audit</a>
|
||||
{% endif %}
|
||||
<a href="/logout" class="nav-end">{{ session.username }}</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function setTimeRange(val) {
|
||||
var input = document.getElementById('time-range');
|
||||
if (input) input.value = val;
|
||||
document.querySelectorAll('.range-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.range === val);
|
||||
});
|
||||
var url = new URL(window.location);
|
||||
url.searchParams.set('range', val);
|
||||
history.replaceState({}, '', url);
|
||||
document.body.dispatchEvent(new CustomEvent('rangeChange'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{% if plugin_failures and session.user_role == 'admin' %}
|
||||
<div style="background:color-mix(in srgb,var(--yellow) 12%,var(--bg-elevated));
|
||||
border:1px solid color-mix(in srgb,var(--yellow) 35%,var(--border));
|
||||
border-radius:6px;padding:0.6rem 1rem;margin-bottom:1rem;
|
||||
font-size:0.84rem;display:flex;align-items:flex-start;gap:0.6rem;">
|
||||
<span style="color:var(--yellow);flex-shrink:0;">⚠</span>
|
||||
<span>
|
||||
<strong style="color:var(--text);">
|
||||
{{ plugin_failures | length }} plugin{{ 's' if plugin_failures | length != 1 }} failed to load.
|
||||
</strong>
|
||||
<a href="/settings/plugins/" style="color:var(--accent);margin-left:0.4rem;">View details →</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
{# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #}
|
||||
<div id="edit-panels" style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start;">
|
||||
|
||||
{# ── Current layout ───────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
|
||||
{% if widgets %}
|
||||
<div id="widget-sort-list" data-dash-id="{{ dashboard.id }}" style="display:grid;gap:0.5rem;">
|
||||
{% for item in widgets %}
|
||||
<div class="card" data-widget-id="{{ item.db.id }}"
|
||||
style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
|
||||
<div class="drag-handle" style="flex-shrink:0;cursor:grab;color:var(--text-muted);
|
||||
font-size:1.1rem;line-height:1;user-select:none;"
|
||||
title="Drag to reorder">⠿</div>
|
||||
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.9rem;">{{ item.title }}</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;">{{ item.defn.description }}</div>
|
||||
</div>
|
||||
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/remove/{{ item.db.id }}"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-danger btn-sm" style="flex-shrink:0;">Remove</button>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;font-size:0.9rem;">
|
||||
No widgets yet. Draw one from the right to begin.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Available widgets ────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Available Widgets</div>
|
||||
{% if addable %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for w in addable %}
|
||||
<div class="card" style="margin-bottom:0;padding:0;overflow:hidden;">
|
||||
<details>
|
||||
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
|
||||
padding:0.85rem 1rem;cursor:pointer;list-style:none;user-select:none;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.875rem;">{{ w.label }}</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
|
||||
</div>
|
||||
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
|
||||
</summary>
|
||||
|
||||
<form method="post"
|
||||
action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
|
||||
hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
|
||||
hx-target="#edit-panels"
|
||||
hx-swap="outerHTML"
|
||||
style="padding:0.75rem 1rem;padding-top:0;border-top:1px solid var(--border);">
|
||||
<div style="display:grid;gap:0.5rem;padding-top:0.75rem;">
|
||||
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
|
||||
Widget title <span style="color:var(--text-dim);">(optional)</span>
|
||||
</label>
|
||||
<input type="text" name="title" placeholder="{{ w.label }}"
|
||||
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
|
||||
border:1px solid var(--border-mid);border-radius:4px;
|
||||
color:var(--text);font-size:0.85rem;">
|
||||
</div>
|
||||
|
||||
{% for p in w.params %}
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
|
||||
{{ p.label }}
|
||||
</label>
|
||||
{% if p.type == "select" %}
|
||||
<select name="param_{{ p.key }}"
|
||||
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
|
||||
border:1px solid var(--border-mid);border-radius:4px;
|
||||
color:var(--text);font-size:0.85rem;">
|
||||
{% for opt in p.options %}
|
||||
<option value="{{ opt.value }}" {% if opt.value == p.default %}selected{% endif %}>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="btn btn-sm" style="width:100%;margin-top:0.1rem;">
|
||||
Add to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
|
||||
No plugin widgets available. Enable plugins in Settings → Plugins.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit: {{ dashboard.name }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Edit: {{ dashboard.name }}</h1>
|
||||
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Done</a>
|
||||
</div>
|
||||
{% include "dashboard/_edit_panels.html" %}
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function initSortable() {
|
||||
var el = document.getElementById('widget-sort-list');
|
||||
if (!el) return;
|
||||
var dashId = el.dataset.dashId;
|
||||
Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
onEnd: function () {
|
||||
var order = Array.from(el.querySelectorAll('[data-widget-id]'))
|
||||
.map(function (row) { return parseInt(row.dataset.widgetId, 10); });
|
||||
fetch('/d/' + dashId + '/edit/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order: order }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Init on first load and after every HTMX swap (add/remove re-renders the list)
|
||||
document.addEventListener('DOMContentLoaded', initSortable);
|
||||
document.addEventListener('htmx:afterSwap', initSortable);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.sortable-ghost { opacity: 0.4; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ dashboard.name }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Header ────────────────────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ dashboard.name }}</h1>
|
||||
{% if dashboard.is_default %}
|
||||
<span class="badge badge-gold" style="font-size:0.7rem;">Default</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
{# Dashboard switcher — shown when more than one dashboard exists #}
|
||||
{% if all_dashboards | length > 1 %}
|
||||
<select onchange="window.location='/d/'+this.value"
|
||||
style="font-size:0.82rem;padding:0.3rem 0.6rem;background:var(--bg-card);border:1px solid var(--border-mid);border-radius:5px;color:var(--text);cursor:pointer;">
|
||||
{% for d in all_dashboards %}
|
||||
<option value="{{ d.id }}" {% if d.id == dashboard.id %}selected{% endif %}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<a href="/d/{{ dashboard.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost btn-sm">Share</a>
|
||||
{% endif %}
|
||||
<a href="/dashboards/" class="btn btn-ghost btn-sm">Dashboards</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Hosts</div>
|
||||
<span class="stat-val">{{ total_hosts }}</span>
|
||||
<span class="stat-label">total</span>
|
||||
<div style="margin-top:0.5rem;"><a href="/hosts/" style="font-size:0.8rem;">Manage →</a></div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Ping</div>
|
||||
{% if ping_up + ping_down == 0 %}
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
|
||||
{% else %}
|
||||
<span class="stat-val" style="color:var(--green);">{{ ping_up }}</span><span class="stat-label">up</span>
|
||||
{% if ping_down %} <span class="stat-val" style="color:var(--red);">{{ ping_down }}</span><span class="stat-label">down</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">DNS</div>
|
||||
{% if dns_ok + dns_fail == 0 %}
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
|
||||
{% else %}
|
||||
<span class="stat-val" style="color:var(--green);">{{ dns_ok }}</span><span class="stat-label">ok</span>
|
||||
{% if dns_fail %} <span class="stat-val" style="color:var(--red);">{{ dns_fail }}</span><span class="stat-label">failed</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Alerts</div>
|
||||
<a href="/alerts/" style="font-size:0.85rem;">View rules →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Widget grid ───────────────────────────────────────────────────────────── #}
|
||||
{% if widgets %}
|
||||
<div style="column-width:380px;column-count:3;column-gap:1rem;">
|
||||
{% for item in widgets %}
|
||||
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="widget-header">
|
||||
<span class="widget-title">{{ item.title }}</span>
|
||||
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
|
||||
</div>
|
||||
<div id="widget-{{ item.db.id }}"
|
||||
hx-get="{{ item.hx_url }}"
|
||||
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="font-size:1.1rem;color:var(--text-muted);margin-bottom:1rem;">No widgets on this dashboard yet.</div>
|
||||
{% if can_edit %}
|
||||
<a href="/d/{{ dashboard.id }}/edit" class="btn">Add Widgets</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboards — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Dashboards</h1>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 300px;gap:1.5rem;align-items:start;">
|
||||
|
||||
{# ── Left: dashboard lists ─────────────────────────────────────────── #}
|
||||
<div style="display:grid;gap:1.5rem;">
|
||||
|
||||
{# My dashboards #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">My Dashboards</div>
|
||||
{% if my_dashboards %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for dash in my_dashboards %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
{% if dash.is_default %}
|
||||
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
|
||||
{% endif %}
|
||||
{% if dash.is_shared %}
|
||||
<span class="badge badge-dim" style="font-size:0.68rem;">Shared</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
|
||||
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
|
||||
<details style="position:relative;">
|
||||
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
|
||||
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">
|
||||
<input type="text" name="name" value="{{ dash.name }}"
|
||||
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ '🔒' if dash.is_shared else '🔗' }}</button>
|
||||
</form>
|
||||
|
||||
{% if not dash.is_default %}
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">★</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if my_dashboards | length > 1 %}
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
You don't have any personal dashboards yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Shared / system dashboards #}
|
||||
{% if shared_dashboards %}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Shared Dashboards</div>
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for dash in shared_dashboards %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
{% if dash.owner_id is none %}
|
||||
<span class="badge badge-dim" style="font-size:0.68rem;">System</span>
|
||||
{% endif %}
|
||||
{% if dash.is_default %}
|
||||
<span class="badge badge-gold" style="font-size:0.68rem;">Global Default</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
|
||||
<a href="/d/{{ dash.id }}" class="btn btn-ghost btn-sm">View</a>
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">★</button>
|
||||
</form>
|
||||
{# Admins can also edit system dashboards #}
|
||||
{% if user_role == 'admin' %}
|
||||
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Right: create new dashboard ──────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/dashboards/create">
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Network Overview" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
|
||||
New dashboards are private by default.<br>
|
||||
Use 🔗 to share with all users.<br>
|
||||
Use ★ to set as your default view.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Share: {{ dashboard.name }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Share: {{ dashboard.name }}</h1>
|
||||
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 320px;gap:1.5rem;align-items:start;max-width:900px;">
|
||||
|
||||
{# ── Active tokens ─────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Active Share Links</div>
|
||||
{% if tokens %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for token in tokens %}
|
||||
{% set expired = token.expires_at is not none and token.expires_at <= now %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;{% if expired %}opacity:0.5;{% endif %}">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.875rem;">
|
||||
{{ token.label or "Unnamed link" }}
|
||||
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
|
||||
</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.15rem;">
|
||||
Created {{ token.created_at.strftime("%Y-%m-%d") }}
|
||||
{% if token.expires_at %}
|
||||
· Expires {{ token.expires_at.strftime("%Y-%m-%d") }}
|
||||
{% else %}
|
||||
· Never expires
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
|
||||
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
No active share links. Create one on the right.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Create new token ──────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/create">
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Label <span style="color:var(--text-muted);font-size:0.78rem;">(optional)</span></label>
|
||||
<input type="text" name="label" placeholder="e.g. Public display screen">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Expires after</label>
|
||||
<select name="expires_in">
|
||||
<option value="">Never</option>
|
||||
<option value="1">1 day</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn">Generate Link</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
|
||||
The link is shown <strong>once</strong> after creation.<br>
|
||||
Revoke it at any time to cut off access immediately.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Share Link Created — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:640px;">
|
||||
<h1 class="page-title">Share Link Created</h1>
|
||||
|
||||
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
|
||||
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
|
||||
Copy this link now — it cannot be shown again
|
||||
</div>
|
||||
|
||||
{% set share_url = request.host_url.rstrip('/') + '/share/' + raw_token %}
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" id="share-url" value="{{ share_url }}" readonly
|
||||
style="flex:1;font-family:ui-monospace,monospace;font-size:0.8rem;background:var(--bg);color:var(--text);">
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('share-url').value).then(()=>this.textContent='Copied!')"
|
||||
class="btn btn-ghost btn-sm" style="white-space:nowrap;">Copy</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted);">
|
||||
{% if label %}<strong>Label:</strong> {{ label }} · {% endif %}
|
||||
{% if expires_at %}<strong>Expires:</strong> {{ expires_at.strftime("%Y-%m-%d") }}
|
||||
{% else %}<strong>Expires:</strong> Never{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.75rem;">
|
||||
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost">Manage Share Links</a>
|
||||
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ dashboard.name }} — Fabled Scryer</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07071a; --bg-card: #0f0f24; --bg-elevated: #17172e;
|
||||
--border: #1e1e3e; --border-mid: #28284c;
|
||||
--text: #d2cef0; --text-muted: #6858a0; --text-dim: #282860;
|
||||
--accent: #7c5ef4; --gold: #c9a84c;
|
||||
--green: #26d96b; --green-dim: #0e3a1e;
|
||||
--yellow: #e6b818; --red: #e83848; --red-dim: #3a0e14;
|
||||
--orange: #e07828;
|
||||
--font-serif: 'Libertinus Serif', Georgia, serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
|
||||
.section-title { font-family: var(--font-serif); font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
|
||||
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover td { background: var(--bg-elevated); }
|
||||
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-red { background: var(--red-dim); color: #ff8090; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
|
||||
.dot-down { background: var(--red); }
|
||||
.dot-warn { background: var(--yellow); }
|
||||
.dot-dim { background: var(--text-dim); }
|
||||
.ping-card { padding: 0.75rem 1.25rem; }
|
||||
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
|
||||
.ping-row:last-child { border-bottom:none; }
|
||||
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
|
||||
.ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
|
||||
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
|
||||
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; }
|
||||
.pill-empty { background:var(--bg-elevated); }
|
||||
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
|
||||
.ping-cur-good { color:var(--green); } .ping-cur-warn { color:var(--yellow); }
|
||||
.ping-cur-bad { color:var(--orange); } .ping-cur-down { color:var(--red); font-weight:bold; }
|
||||
.ping-cur-nd { color:var(--text-dim); }
|
||||
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
|
||||
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
|
||||
header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-mid); height: 48px; font-family: var(--font-serif); }
|
||||
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<span style="font-weight:700;font-size:1rem;background:linear-gradient(110deg,#c8b4f8,#c9a84c);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">
|
||||
{{ dashboard.name }}
|
||||
</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);">Fabled Scryer · read-only view</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% if widgets %}
|
||||
<div style="column-width:380px;column-count:3;column-gap:1rem;">
|
||||
{% for item in widgets %}
|
||||
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="widget-header">
|
||||
<span class="widget-title">{{ item.title }}</span>
|
||||
</div>
|
||||
{# Pass share token as &s= param so require_role allows through #}
|
||||
<div id="widget-{{ item.db.id }}"
|
||||
hx-get="{{ item.hx_url }}&s={{ raw_token }}"
|
||||
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);text-align:center;padding:4rem;font-size:0.9rem;">
|
||||
This dashboard has no widgets.
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}DNS Monitor — 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;">DNS Monitor</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
<div class="card ping-card">
|
||||
<div id="dns-rows"
|
||||
hx-get="/dns/rows"
|
||||
hx-trigger="every {{ poll_interval }}s"
|
||||
hx-swap="innerHTML">
|
||||
{% include "dns/rows.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% if hosts %}
|
||||
{% for host in hosts %}
|
||||
{% set d = latest.get(host.id) %}
|
||||
<div class="ping-row">
|
||||
<div class="ping-meta">
|
||||
<span class="ping-name">{{ host.name }}</span>
|
||||
<span class="ping-addr">{{ host.address }}</span>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
{% if d is none %}
|
||||
<span style="color:var(--text-dim);font-size:0.85rem;">—</span>
|
||||
{% elif d.status.value == 'resolved' %}
|
||||
<span class="dot dot-up" style="margin-right:0.5rem;"></span>
|
||||
<span style="font-size:0.85rem;color:var(--green);">{{ d.resolved_ip or 'resolved' }}</span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" style="margin-right:0.5rem;"></span>
|
||||
<span style="font-size:0.85rem;color:var(--red);">Failed</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ping-cur">
|
||||
{% if d %}
|
||||
<span style="color:var(--text-muted);font-size:0.75rem;">{{ d.resolved_at.strftime('%H:%M:%S') }} UTC</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty" style="padding:0.5rem 0;">No DNS checks yet. <a href="/hosts/">Enable DNS on a host →</a></p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Not Found — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
|
||||
<h1 style="color: var(--gold); font-size: 2.5rem; margin-bottom: 0.5rem;">404</h1>
|
||||
<p style="color: var(--text); font-size: 1.2rem; margin-bottom: 0.25rem;">No such seat at this table.</p>
|
||||
<p style="color: var(--text-dim); margin-bottom: 1.5rem;">The page you sought is not among us.</p>
|
||||
<a href="/" class="btn">Return to the hall</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Error — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
|
||||
<h1 style="color: var(--red); font-size: 2.5rem; margin-bottom: 0.5rem;">500</h1>
|
||||
<p style="color: var(--text); font-size: 1.2rem; margin-bottom: 0.25rem;">The watch has faltered.</p>
|
||||
<p style="color: var(--text-dim); margin-bottom: 1.5rem;">An unexpected error occurred. The cause has been logged.</p>
|
||||
<a href="/" class="btn">Return to the hall</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if host %}Edit Host{% else %}New Host{% endif %} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:560px;margin:2rem auto;">
|
||||
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Address (hostname or IP)</label>
|
||||
<input type="text" name="address" value="{{ host.address if host else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Probe Type</label>
|
||||
<select name="probe_type">
|
||||
{% for pt in probe_types %}
|
||||
<option value="{{ pt.value }}" {% if host and host.probe_type == pt %}selected{% endif %}>
|
||||
{{ pt.value | upper }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>TCP Port (used for TCP probe; ignored for ICMP)</label>
|
||||
<input type="number" name="probe_port" value="{{ host.probe_port if host else 80 }}" min="1" max="65535">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||
<input type="checkbox" name="ping_enabled" {% if not host or host.ping_enabled %}checked{% endif %}>
|
||||
Enable ping monitor
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||
<input type="checkbox" name="dns_enabled" {% if host and host.dns_enabled %}checked{% endif %}>
|
||||
Enable DNS monitor
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Expected IP (optional — leave blank to accept any)</label>
|
||||
<input type="text" name="dns_expected_ip" value="{{ host.dns_expected_ip if host and host.dns_expected_ip else '' }}">
|
||||
</div>
|
||||
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">{% if host %}Save{% else %}Add Host{% endif %}</button>
|
||||
<a href="/hosts/" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Hosts — 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;">Hosts</h1>
|
||||
<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>
|
||||
{% if hosts %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th>Probe</th>
|
||||
<th style="text-align:center;">Ping</th>
|
||||
<th style="text-align:center;">Latency</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for host in hosts %}
|
||||
{% set ping = latest_pings.get(host.id) %}
|
||||
{% set dns = latest_dns.get(host.id) %}
|
||||
{% set ut = uptime.get(host.id, {}) %}
|
||||
<tr>
|
||||
<td>{{ host.name }}</td>
|
||||
<td style="color:var(--text-muted);">{{ host.address }}</td>
|
||||
<td style="color:var(--text-muted);font-size:0.85rem;">
|
||||
{{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if not host.ping_enabled %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
|
||||
{% elif ping is none %}
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">—</span>
|
||||
{% elif ping.status.value == 'up' %}
|
||||
<span class="dot dot-up" title="Up"></span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" title="Down"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;font-size:0.9rem;">
|
||||
{% if ping and ping.response_time_ms is not none %}
|
||||
{% if ping.response_time_ms < 10 %}
|
||||
<span style="color:var(--green);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% elif ping.response_time_ms < 100 %}
|
||||
<span style="color:var(--yellow);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% else %}
|
||||
<span style="color:var(--orange);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if not host.dns_enabled %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
|
||||
{% elif dns is none %}
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">—</span>
|
||||
{% elif dns.status.value == 'resolved' %}
|
||||
<span class="dot dot-up" title="{{ dns.resolved_ip }}"></span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" title="Failed"></span>
|
||||
{% endif %}
|
||||
</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">
|
||||
<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;">
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p style="color:var(--text-muted);">No hosts yet. <a href="/hosts/new">Summon one to the table.</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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 under watch. <a href="/hosts/new">Summon one to the table.</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 %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user