feat: rename to FabledScryer, multi-dashboard system, plugin management, branding

- Rename package fablednetmon → fabledscryer throughout
- Multi-dashboard: ownership, per-user defaults, HTMX edit (add/remove/reorder)
- Read-only share tokens scoped to individual dashboards
- Dashboard edit is HTMX-driven (no page reloads)
- Plugin management system: remote catalog, download/install, hot-reload, in-app restart
- plugin_index.py: fetch/cache remote index.yaml; default URL → bvandeusen/fabledscryer-plugins
- plugin_manager.py: download_and_install_plugin, hot_reload_plugin, restart_app
  - ZIP extraction handles GitHub archive formats (name-v1.0.0/, name-main/)
- Settings split into tabbed sections: General, Notifications, Ansible, Plugins
- Plugins tab: catalog browser (HTMX), install/activate/update/restart actions
- UI/branding: dark palette (#07071a), crystal ball SVG logo, animated star field,
  Libertinus Serif applied to headings, nav, labels, and section titles
- Widget registry (core/widgets.py) for dashboard plugin integration
- UPS widget.html (dashboard card) and settings/_tabs.html include
- Migrations 0005–0008: dashboards, is_default, ownership, share tokens
- docs/plugins/: writing-a-plugin.md updated with publishing guide,
  index.yaml.example template for fabledscryer-plugins repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 18:27:56 -04:00
parent 165a202ba4
commit 230b542015
121 changed files with 4820 additions and 715 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
View File
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from datetime import datetime, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from sqlalchemy import select
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator
from fabledscryer.models.users import UserRole
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
_OPERATORS = [(op.value, op.value) for op in AlertOperator]
@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("/rules/new")
@require_role(UserRole.operator)
async def new_rule():
return await render_template("alerts/rules_form.html", rule=None, operators=_OPERATORS)
@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)
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):
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:
await db.delete(rule)
return redirect(url_for("alerts.list_alerts"))
@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"))
View File
+132
View File
@@ -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))
+169
View File
@@ -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)
+113
View File
@@ -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"))
+264
View File
@@ -0,0 +1,264 @@
# fabledscryer/app.py
from __future__ import annotations
import asyncio
from pathlib import Path
from quart import Quart
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,
)
_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),
)
else:
app.config.update(
SESSION_LIFETIME_HOURS=8,
DATA_RETENTION_DAYS=90,
MONITORS_POLL_INTERVAL=60,
SMTP={},
WEBHOOK={},
ANSIBLE={},
PLUGINS={},
)
# ── 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
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)
# ── 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. 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"}
# ── 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)
app._task_registry.extend([
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)
View File
+42
View File
@@ -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")
+77
View File
@@ -0,0 +1,77 @@
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 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"))
return await render_template("auth/login.html")
@auth_bp.post("/login")
async def login_post():
from quart import current_app
form = await request.form
username = form.get("username", "").strip()
password = form.get("password", "").encode()
user = await get_user_by_username(current_app, username)
if not user or not user.is_active:
return await render_template("auth/login.html", error="Invalid credentials"), 400
if not bcrypt.checkpw(password, user.password_hash.encode()):
return await render_template("auth/login.html", error="Invalid credentials"), 400
login_user(user)
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
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)
return redirect(url_for("dashboard.index"))
+14
View File
@@ -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)
+81
View File
@@ -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
View File
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fabledscryer.models.alerts import (
AlertEvent,
AlertOperator,
AlertRule,
AlertState,
AlertStateEnum,
)
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()
# 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")
+35
View File
@@ -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__}")
+82
View File
@@ -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
+24
View File
@@ -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]
+100
View File
@@ -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}
+77
View File
@@ -0,0 +1,77 @@
# 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
_cache: tuple[dict, float] | None = None # (raw_data, fetched_at monotonic)
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_catalog(index_url: str, force: bool = False) -> list[CatalogPlugin]:
"""Fetch and parse the remote plugin catalog.
Results are cached in-process for _CACHE_TTL seconds. Pass force=True to
bypass the cache. On network failure, stale cached data is returned if
available; otherwise an empty list is returned.
"""
global _cache
import httpx
import yaml
now = time.monotonic()
if not force and _cache is not None:
raw, fetched_at = _cache
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(index_url)
resp.raise_for_status()
raw = yaml.safe_load(resp.text) or {}
_cache = (raw, now)
logger.info(
"Plugin catalog fetched from %s: %d plugin(s)",
index_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", index_url)
if _cache is not None:
raw, _ = _cache
logger.warning("Returning stale cached catalog")
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
return []
def clear_catalog_cache() -> None:
"""Invalidate the in-process catalog cache."""
global _cache
_cache = None
+333
View File
@@ -0,0 +1,333 @@
# 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()
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():
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():
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:
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
continue
if meta.get("name") != 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):
logger.error(
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
name, min_ver, fabledscryer.__version__,
)
continue
except Exception:
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)
defaults = meta.get("config", {})
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
plugins_cfg[name] = {"enabled": True, **defaults, **user_overrides}
# Import the plugin package
try:
module = importlib.import_module(name)
except ImportError:
logger.exception("Plugin %r: import failed, skipping", name)
continue
# Validate required exports
if not hasattr(module, "setup"):
logger.error("Plugin %r: missing required export 'setup', skipping", name)
continue
if not hasattr(module, "get_scheduled_tasks"):
logger.error(
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
)
continue
# Call setup
try:
module.setup(app)
except Exception:
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:
logger.exception("Plugin %r: get_blueprint() raised", name)
# 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:
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
_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
try:
mdir = plugin_dir / name / "migrations"
if mdir.exists():
from fabledscryer.core.migration_runner import run_plugin_migrations
run_plugin_migrations(app.config["DATABASE_URL"], [mdir])
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
defaults = meta.get("config", {})
stored_cfg = app.config.get("PLUGINS", {}).get(name, {})
user_overrides = {k: v for k, v in stored_cfg.items() if k != "enabled"}
merged = {"enabled": True, **defaults, **user_overrides}
app.config["PLUGINS"][name] = merged
# Import
try:
module = importlib.import_module(name)
except ImportError 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)
+42
View File
@@ -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")
+164
View File
@@ -0,0 +1,164 @@
# 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://raw.githubusercontent.com/bvandeusen/fabledscryer-plugins/main/index.yaml",
}
# ─────────────────────────────────────────────────────────────────────────────
# 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_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
+44
View File
@@ -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]
+76
View File
@@ -0,0 +1,76 @@
# 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
# for the widget to be available.
#
# Forward-compatibility notes:
# - Step 8 (widget variants) will add multiple entries per plugin and a
# "variant" sub-key rather than one entry per plugin.
# - Step 9 (plugin management) will allow external plugins to register
# entries here at load time via register_widget().
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, # built-in; always available
"poll": True,
},
"dns": {
"key": "dns",
"label": "DNS",
"description": "DNS resolution status for all monitored hosts",
"hx_url": "/dns/rows",
"detail_url": "/dns/",
"plugin": None,
"poll": True,
},
"traefik": {
"key": "traefik",
"label": "Traefik",
"description": "Proxy request rates, service health, and recent errors",
"hx_url": "/plugins/traefik/widget",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
},
"unifi": {
"key": "unifi",
"label": "UniFi Network",
"description": "WAN status, client counts, active alarms",
"hx_url": "/plugins/unifi/widget",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
},
"ups": {
"key": "ups",
"label": "UPS",
"description": "Battery charge, runtime estimate, load percentage",
"hx_url": "/plugins/ups/widget",
"detail_url": "/plugins/ups/",
"plugin": "ups",
"poll": True,
},
}
def register_widget(definition: dict) -> None:
"""Register a widget at runtime (used by external plugins in step 9)."""
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
View File
+557
View File
@@ -0,0 +1,557 @@
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timezone, timedelta
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
out.append({"db": w, "defn": defn})
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)
placed_keys = {w.widget_key for w in widgets}
addable = [w for w in available if w["key"] not in placed_keys]
resolved = [
{"db": w, "defn": WIDGET_REGISTRY[w.widget_key]}
for w in widgets if w.widget_key in WIDGET_REGISTRY
]
return dash, resolved, addable
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")
if widget_key not in WIDGET_REGISTRY:
abort(400)
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)
if not any(w.widget_key == widget_key for w in widgets):
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,
))
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/move/<int:widget_id>/<direction>")
@require_role(UserRole.viewer)
async def move_widget(dash_id: int, widget_id: int, direction: str):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
if direction not in ("up", "down"):
abort(400)
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)
idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None)
if idx is not None:
swap_idx = (idx - 1) if direction == "up" else (idx + 1)
if 0 <= swap_idx < len(widgets):
widgets[idx].position, widgets[swap_idx].position = (
widgets[swap_idx].position, widgets[idx].position
)
return await _panels_response(dash_id)
# ── 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,
)
+20
View File
@@ -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
View File
+52
View File
@@ -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)
View File
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
from quart import Blueprint, render_template, request, redirect, url_for, current_app
from sqlalchemy import select, func
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.hosts import Host, ProbeType
from fabledscryer.models.monitors import PingResult, DnsResult
from fabledscryer.models.users import UserRole
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
@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()}
return await render_template(
"hosts/list.html",
hosts=hosts,
latest_pings=latest_pings,
latest_dns=latest_dns,
)
@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)
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):
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:
await db.delete(host)
return redirect(url_for("hosts.list_hosts"))
View File
+69
View File
@@ -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()
+23
View File
@@ -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")
+20
View File
@@ -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",
]
+81
View File
@@ -0,0 +1,81 @@
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 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)
+34
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
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)
+29
View File
@@ -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)
)
+19
View File
@@ -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)
)
+45
View File
@@ -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)
+18
View File
@@ -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),
)
+27
View File
@@ -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)
View File
+71
View File
@@ -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": []}
+69
View File
@@ -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)
View File
+127
View File
@@ -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"))
+1
View File
@@ -0,0 +1 @@
# fabledscryer/settings/__init__.py
+344
View File
@@ -0,0 +1,344 @@
# 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
from fabledscryer.auth.middleware import require_role
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,
)
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),
)
# ── 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()
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()
return redirect(url_for("settings.notifications"))
# ── Ansible Sources ───────────────────────────────────────────────────────────
@settings_bp.get("/ansible/")
@require_role(UserRole.admin)
async def ansible():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template("settings/ansible.html", settings=settings)
@settings_bp.post("/ansible/")
@require_role(UserRole.admin)
async def save_ansible():
form = await request.form
sources_raw = form.get("ansible.sources", "[]")
try:
sources = json.loads(sources_raw)
if not isinstance(sources, list):
sources = []
except json.JSONDecodeError:
sources = []
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "ansible.sources", sources)
await _reload_app_config()
return redirect(url_for("settings.ansible"))
# ── Plugins ───────────────────────────────────────────────────────────────────
@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))
return await render_template(
"settings/plugins.html",
discovered_plugins=discovered,
settings=settings,
)
@settings_bp.post("/plugins/")
@require_role(UserRole.admin)
async def save_plugins():
form = await request.form
discovered = _discover_plugins()
async with current_app.db_sessionmaker() as db:
async with db.begin():
# Save plugin index URL if provided
index_url = form.get("plugins.index_url", "").strip()
await set_setting(db, "plugins.index_url", index_url)
for plugin in discovered:
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, 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
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()
return redirect(url_for("settings.plugins"))
# ── 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)
index_url = settings.get("plugins.index_url", "").strip()
if not index_url:
return await render_template(
"settings/_catalog_partial.html",
catalog=[],
installed_names=set(),
loaded_names=set(),
error="No plugin index URL 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(index_url, 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}
return await render_template(
"settings/_catalog_partial.html",
catalog=catalog,
installed_names=installed_names,
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")
+15
View File
@@ -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>
+92
View File
@@ -0,0 +1,92 @@
{% 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>
<a class="btn" href="/alerts/rules/new">New Rule</a>
</div>
{% if rules %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Condition</th>
<th>Consec.</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<td>{{ rule.name }}</td>
<td style="color:var(--text-muted);">
{{ rule.source_module }}/{{ rule.resource_name }}:{{ rule.metric_name }}
{{ rule.operator.value }} {{ rule.threshold }}
</td>
<td style="color:var(--text-muted);">{{ rule.consecutive_failures_required }}</td>
<td>
{% if rule.enabled %}<span class="badge badge-green">yes</span>{% else %}<span class="badge badge-dim">no</span>{% endif %}
</td>
<td class="td-actions">
<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,49 @@
{% extends "base.html" %}
{% block title %}New Alert Rule — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">New Alert Rule</h1>
<div class="card">
<form method="post" action="/alerts/rules">
<div class="form-group">
<label>Rule Name</label>
<input type="text" name="name" required autofocus placeholder="e.g. Home server down">
</div>
<div class="form-group">
<label>Source Module</label>
<input type="text" name="source_module" required placeholder="ping, dns, traefik, ...">
</div>
<div class="form-group">
<label>Resource Name</label>
<input type="text" name="resource_name" required placeholder="host name, router name, ...">
</div>
<div class="form-group">
<label>Metric Name</label>
<input type="text" name="metric_name" required placeholder="up, response_time_ms, ...">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator">
{% for val, label in operators %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold</label>
<input type="number" name="threshold" step="any" required placeholder="0.5">
</div>
</div>
<div class="form-group">
<label>Consecutive failures required before FIRING (default: 1)</label>
<input type="number" name="consecutive_failures_required" value="1" min="1">
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">Create Rule</button>
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
+101
View File
@@ -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/" style="color:#a0a0ff;font-size:0.9rem;">← 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" style="color:#a0a0ff;font-size:0.85rem;">← Back to browse</a>
</div>
<pre style="background:#0a0a1a;padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:#c0c0c0;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:#606080;">No Ansible sources configured. Add sources under <code>ansible.sources</code> in <code>config.yaml</code>.</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:#404060;font-size:0.8rem;background:#12122a;padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
<span style="color:#505070;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 %}
+43
View File
@@ -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 trigger a run.</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/" style="color:#6060c0;font-size:0.9rem;">← Runs</a>
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
</div>
{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}.get(run.status.value, "#808080") %}
<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:#606080;">ID</span><span style="color:#a0a0c0;font-family:monospace;">{{ run.id }}</span>
<span style="color:#606080;">Playbook</span><span style="color:#e0e0e0;">{{ run.playbook_path }}</span>
<span style="color:#606080;">Inventory</span><span style="color:#e0e0e0;">{{ run.inventory_path }}</span>
<span style="color:#606080;">Source</span><span style="color:#e0e0e0;">{{ run.source_name }}</span>
<span style="color:#606080;">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
<span style="color:#606080;">Started</span><span style="color:#a0a0c0;">{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
{% if run.finished_at %}
<span style="color:#606080;">Finished</span><span style="color:#a0a0c0;">{{ 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:#12122a;border-bottom:1px solid #2a2a4a;">
<span style="color:#a0a0c0;font-size:0.85rem;">Output</span>
</div>
{% if run.status.value == "running" %}
<pre id="live-output"
style="margin:0;padding:1rem;background:#080810;font-size:0.78rem;color:#a0c0a0;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:#606080;">
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:#080810;font-size:0.78rem;color:#a0c0a0;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>
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Login — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:400px;margin:4rem auto;">
<div class="card">
<h2 class="page-title">Sign In</h2>
<form method="post" action="/login">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" autofocus required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
</div>
{% endblock %}
+25
View File
@@ -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 %}
+355
View File
@@ -0,0 +1,355 @@
<!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>
<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;
--accent-hover: #9070f8;
--gold: #c9a84c;
--gold-dim: #221b00;
--green: #26d96b;
--green-dim: #0e3a1e;
--yellow: #e6b818;
--yellow-dim: #3a3000;
--orange: #e07828;
--orange-dim: #3a1e00;
--red: #e83848;
--red-dim: #3a0e14;
--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; }
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; }
/* Star field */
#star-field { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
@keyframes star-orbit { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes star-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* 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;
background: linear-gradient(110deg, #c8b4f8 0%, var(--gold) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
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>
{# Star field — orbits around top-left corner, populated by JS below #}
<div id="star-field"></div>
{% if session.user_id is defined %}
<nav>
<a href="/" class="brand">
{# Crystal ball logo #}
<svg width="20" height="24" viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="orb-grad" cx="38%" cy="32%" r="62%">
<stop offset="0%" stop-color="#6040c8"/>
<stop offset="55%" stop-color="#2a1468"/>
<stop offset="100%" stop-color="#0e0830"/>
</radialGradient>
<radialGradient id="glow-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#c9a84c" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#c9a84c" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Ball body -->
<circle cx="10" cy="10" r="9" fill="url(#orb-grad)" stroke="#7c5ef4" stroke-width="0.5"/>
<!-- Inner gold glow -->
<circle cx="10" cy="10" r="5" fill="url(#glow-grad)"/>
<!-- Reflection highlight -->
<ellipse cx="7.5" cy="7" rx="2.2" ry="1.1" fill="white" opacity="0.14" transform="rotate(-25 7.5 7)"/>
<!-- Inner star -->
<path d="M10 7.2 L10.45 8.85 L12.2 8.85 L10.88 9.9 L11.35 11.55 L10 10.55 L8.65 11.55 L9.12 9.9 L7.8 8.85 L9.55 8.85 Z"
fill="#c9a84c" opacity="0.55"/>
<!-- Stand neck -->
<path d="M8.2 19 L8.9 15.8 L11.1 15.8 L11.8 19" fill="none" stroke="#5040a0" stroke-width="0.75" stroke-linejoin="round"/>
<!-- Stand base -->
<ellipse cx="10" cy="19.5" rx="4" ry="1" fill="none" stroke="#5040a0" stroke-width="0.75"/>
</svg>
Fabled Scryer
</a>
<a href="/">Dashboard</a>
<a href="/hosts/">Hosts</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>
{% 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 error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
(function () {
var field = document.getElementById('star-field');
if (!field) return;
var W = window.innerWidth;
var H = window.innerHeight;
var maxR = Math.sqrt(W * W + H * H);
// Subtle purple glow anchoring the field to the top-left corner
var glow = document.createElement('div');
glow.style.cssText = 'position:absolute;width:500px;height:500px;left:-250px;top:-250px;border-radius:50%;background:radial-gradient(circle,rgba(124,94,244,0.06) 0%,transparent 65%);pointer-events:none;';
field.appendChild(glow);
// Compute polygon points for a 5-pointed star inside a size×size box
function starPoints(size) {
var cx = size / 2, cy = size / 2;
var R = size / 2 * 0.95; // outer radius
var r = R * 0.382; // inner radius (golden ratio)
var pts = [];
for (var i = 0; i < 5; i++) {
var oa = (i * 72 - 90) * Math.PI / 180;
var ia = (i * 72 - 90 + 36) * Math.PI / 180;
pts.push((cx + R * Math.cos(oa)).toFixed(2) + ',' + (cy + R * Math.sin(oa)).toFixed(2));
pts.push((cx + r * Math.cos(ia)).toFixed(2) + ',' + (cy + r * Math.sin(ia)).toFixed(2));
}
return pts.join(' ');
}
// 22 wizard-hat stars: [size(px), opacity, isGold, orbitDuration(s), spinDuration(s)]
var defs = [
[230, 0.09, true, 200, 28],
[144, 0.08, true, 260, 40],
[ 92, 0.10, false, 150, 22],
[190, 0.07, true, 310, 35],
[116, 0.09, false, 175, 18],
[260, 0.07, true, 280, 45],
[ 80, 0.11, false, 135, 20],
[164, 0.08, true, 230, 32],
[104, 0.09, false, 165, 24],
[200, 0.07, true, 295, 38],
[ 70, 0.10, false, 120, 16],
[150, 0.08, true, 245, 30],
[ 88, 0.09, false, 140, 21],
[210, 0.07, true, 320, 42],
[128, 0.08, false, 180, 26],
[ 76, 0.10, true, 160, 19],
[175, 0.07, true, 290, 36],
[ 96, 0.09, false, 155, 23],
[220, 0.07, true, 305, 44],
[110, 0.08, false, 170, 25],
[ 84, 0.09, true, 235, 31],
[140, 0.08, false, 185, 27],
];
var n = defs.length;
defs.forEach(function (def, i) {
var size = def[0], opacity = def[1], isGold = def[2], orbitDur = def[3], spinDur = def[4];
// Spread evenly across a ~110° fan from top-left, small random jitter
var angleDeg = 4 + (i / (n - 1)) * 108 + (Math.random() * 8 - 4);
var angle = angleDeg * Math.PI / 180;
var radius = (0.15 + (i / n) * 0.7 + Math.random() * 0.15) * maxR * 0.88 + 60;
// Star center position in parent coords
var cx = Math.cos(angle) * radius;
var cy = Math.sin(angle) * radius;
var left = cx - size / 2;
var top = cy - size / 2;
var orbitDelay = -(Math.random() * orbitDur);
var spinDelay = -(Math.random() * spinDur);
var color = isGold ? '#c9a84c' : '#c8b4f8';
// Outer wrapper handles orbit — transform-origin points back to (0,0) corner
var wrapper = document.createElement('div');
wrapper.style.cssText = [
'position:absolute',
'left:' + left + 'px',
'top:' + top + 'px',
'width:' + size + 'px',
'height:'+ size + 'px',
'opacity:' + opacity,
'transform-origin:' + (-left) + 'px ' + (-top) + 'px',
'animation:star-orbit ' + orbitDur + 's ' + orbitDelay + 's linear infinite',
'pointer-events:none'
].join(';');
// Inner SVG handles spin — transform-origin defaults to 50% 50% (star center)
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', size);
svg.setAttribute('height', size);
svg.setAttribute('viewBox', '0 0 ' + size + ' ' + size);
svg.style.cssText = 'display:block;animation:star-spin ' + spinDur + 's ' + spinDelay + 's linear infinite;';
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
poly.setAttribute('points', starPoints(size));
poly.setAttribute('fill', color);
svg.appendChild(poly);
wrapper.appendChild(svg);
field.appendChild(wrapper);
});
}());
</script>
</body>
</html>
@@ -0,0 +1,70 @@
{# 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 320px;gap:1.5rem;align-items:start;">
{# ── Current layout ───────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
{% if widgets %}
<div style="display:grid;gap:0.5rem;">
{% for item in widgets %}
<div class="card" style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<div style="display:flex;flex-direction:column;gap:2px;flex-shrink:0;">
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/up"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-ghost btn-sm"
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
{% if loop.first %}disabled{% endif %}></button>
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/down"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-ghost btn-sm"
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
{% if loop.last %}disabled{% endif %}></button>
</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.9rem;">{{ item.defn.label }}</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 — add some from the panel on the right.
</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.85rem 1rem;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;">
<div>
<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>
<button hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-sm" style="flex-shrink:0;">Add</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
All available widgets are on this dashboard.
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,9 @@
{% 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 %}
@@ -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 %}&nbsp;<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 %}&nbsp;<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.defn.label }}</span>
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
</div>
<div id="widget-{{ item.db.id }}"
hx-get="{{ item.defn.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 %}
+132
View File
@@ -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 }} &nbsp;·&nbsp;{% 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,97 @@
<!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>
<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.defn.label }}</span>
</div>
{# Pass share token as ?s= param so require_role allows through #}
<div id="widget-{{ item.db.id }}"
hx-get="{{ item.defn.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>
+16
View File
@@ -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 %}
+29
View File
@@ -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-enabled hosts. <a href="/hosts/">Enable DNS on a host →</a></p>
{% endif %}
+53
View File
@@ -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 %}
+84
View File
@@ -0,0 +1,84 @@
{% 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>
<a class="btn" href="/hosts/new">Add Host</a>
</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></th>
</tr>
</thead>
<tbody>
{% for host in hosts %}
{% set ping = latest_pings.get(host.id) %}
{% set dns = latest_dns.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>
<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 configured yet. <a href="/hosts/new">Add one.</a></p>
</div>
{% endif %}
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Ping Monitor — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">Ping Monitor</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
{# ── Threshold settings ─────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<h2 class="section-title" style="margin-bottom:1rem;">Latency Thresholds</h2>
<form method="post" action="/ping/settings"
style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-end;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#1a6632;vertical-align:middle;margin-right:4px;"></span>
Good below (ms)
</label>
<input type="number" name="good_ms" value="{{ good_ms }}" min="1" max="9999" style="width:100px;">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#6b5c00;vertical-align:middle;margin-right:4px;"></span>
Warn above (ms)
</label>
<input type="number" name="warn_ms" value="{{ warn_ms }}" min="1" max="9999" style="width:100px;">
</div>
<button type="submit" class="btn" style="padding:0.4rem 1rem;">Save</button>
<span style="color:#505070;font-size:0.8rem;align-self:center;">
Above warn = <span style="color:#c06020;"></span> orange&nbsp;&nbsp;|&nbsp;&nbsp;No response = <span style="color:#c03030;"></span> red
</span>
</form>
</div>
{# ── Live host rows ─────────────────────────────────────────────────────── #}
<div class="card ping-card">
<div id="ping-rows"
hx-get="/ping/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{# HTMX fragment — included in both /ping/ page and dashboard widget #}
{% macro pill_bg(p) %}
{%- if p is none or p.status.value == 'down' or p.response_time_ms is none -%}
#6a1515
{%- elif p.response_time_ms <= good_ms -%}
#1a6632
{%- elif p.response_time_ms <= warn_ms -%}
#6b5c00
{%- else -%}
#7a3800
{%- endif -%}
{% endmacro %}
{% macro pill_title(p) %}
{%- if p is none -%}No data
{%- elif p.status.value == 'down' -%}Down — {{ p.probed_at.strftime('%H:%M:%S') }} UTC
{%- else -%}{{ "%.1f"|format(p.response_time_ms) }} ms — {{ p.probed_at.strftime('%H:%M:%S') }} UTC
{%- endif -%}
{% endmacro %}
{% if hosts %}
{% for host in hosts %}
{% set host_pings = pings_by_host.get(host.id, []) %}
{% set last = host_pings[-1] if host_pings else none %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name">{{ host.name }}</span>
<span class="ping-addr">{{ host.address }}</span>
</div>
<div class="ping-pills">
{% for _ in range([30 - host_pings|length, 0]|max) %}
<span class="pill pill-empty" title="No data"></span>
{% endfor %}
{% for p in host_pings %}
<span class="pill" style="background:{{ pill_bg(p) }};" title="{{ pill_title(p) }}"></span>
{% endfor %}
</div>
<div class="ping-cur">
{% if last is none %}
<span class="ping-cur-nd"></span>
{% elif last.status.value == 'down' or last.response_time_ms is none %}
<span class="ping-cur-down">DOWN</span>
{% elif last.response_time_ms <= good_ms %}
<span class="ping-cur-good">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
{% elif last.response_time_ms <= warn_ms %}
<span class="ping-cur-warn">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
{% else %}
<span class="ping-cur-bad">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
{% endif %}
</div>
{% if uptime_pcts is defined %}
{% set pct = uptime_pcts.get(host.id) %}
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;color:
{% if pct is none %}var(--text-dim)
{% elif pct >= 99 %}var(--green)
{% elif pct >= 95 %}var(--yellow)
{% else %}var(--red){% endif %};">
{% if pct is not none %}{{ "%.1f"|format(pct) }}%{% else %}—{% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color:#505070;font-size:0.85rem;padding:0.5rem 0;">No ping-enabled hosts. <a href="/hosts/" style="color:#7070c0;">Add hosts →</a></p>
{% endif %}
@@ -0,0 +1,98 @@
{# settings/_catalog_partial.html — HTMX partial for plugin catalog #}
<div id="plugin-catalog">
{% if error %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;margin-bottom:0.75rem;">
{{ error }}
</div>
{% endif %}
{% if catalog %}
<div style="display:grid;gap:0.75rem;">
{% for plugin in catalog %}
{% set is_installed = plugin.name in installed_names %}
{% set is_loaded = plugin.name in loaded_names %}
<div class="card" style="margin-bottom:0;padding:1rem 1.25rem;">
<div style="display:flex;align-items:flex-start;gap:1rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
<span style="font-weight:600;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
{% if is_loaded %}
<span class="badge badge-green" style="font-size:0.68rem;">Active</span>
{% elif is_installed %}
<span class="badge" style="background:var(--bg-elevated);color:var(--text-muted);font-size:0.68rem;">Installed</span>
{% endif %}
{% for tag in plugin.tags %}
<span style="font-size:0.68rem;padding:0.1em 0.4em;background:var(--bg-elevated);color:var(--text-muted);border-radius:3px;">{{ tag }}</span>
{% endfor %}
</div>
{% if plugin.description %}
<div style="font-size:0.82rem;color:var(--text-muted);margin-top:0.25rem;">{{ plugin.description }}</div>
{% endif %}
<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.15rem;display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
{% if plugin.author %}<span>by {{ plugin.author }}</span>{% endif %}
{% if plugin.license %}<span style="color:var(--text-muted);">{{ plugin.license }}</span>{% endif %}
{% if plugin.min_app_version %}<span style="color:var(--text-muted);">requires v{{ plugin.min_app_version }}+</span>{% endif %}
</div>
</div>
<div style="display:flex;gap:0.5rem;flex-shrink:0;align-items:center;">
{% if plugin.homepage %}
<a href="{{ plugin.homepage }}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Docs</a>
{% elif plugin.repository_url %}
<a href="{{ plugin.repository_url }}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Source</a>
{% endif %}
{% if is_loaded %}
{# Already active — update available if versions differ #}
<form method="post"
action="/settings/plugins/install/{{ plugin.name }}"
hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Update</button>
</form>
{% elif is_installed %}
{# Installed but not active — offer hot-reload #}
<form method="post"
action="/settings/plugins/reload/{{ plugin.name }}"
hx-post="/settings/plugins/reload/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
</form>
{% else %}
{# Not installed — offer install #}
<form method="post"
action="/settings/plugins/install/{{ plugin.name }}"
hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
</form>
{% endif %}
</div>
</div>
{# Inline result placeholder — replaced by _install_result.html after action #}
<div id="catalog-result-{{ plugin.name }}"></div>
</div>
{% endfor %}
</div>
{% elif not error %}
<div style="color:var(--text-muted);font-size:0.85rem;">No plugins found in catalog.</div>
{% endif %}
</div>
@@ -0,0 +1,20 @@
{# settings/_install_result.html — inline result after install/reload action #}
<div id="catalog-result-{{ name }}"
style="margin-top:0.6rem;padding:0.5rem 0.75rem;border-radius:5px;font-size:0.82rem;
background:{% if success %}var(--green-dim){% else %}var(--red-dim){% endif %};
color:{% if success %}var(--green){% else %}#ff8090{% endif %};">
{{ message }}
{% if restart_required %}
&nbsp;·&nbsp;
<form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/"
hx-target="#restart-banner"
hx-swap="outerHTML"
style="display:inline;margin:0;">
<button type="submit"
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
Restart now
</button>
</form>
{% endif %}
</div>
@@ -0,0 +1,6 @@
{# settings/_restart_pending.html — shown after restart is triggered #}
<div id="restart-banner"
style="padding:0.75rem 1rem;border-radius:6px;background:var(--gold-dim);
border:1px solid var(--gold);color:var(--gold);font-size:0.85rem;">
Restart initiated — the app will reload in a moment. Refresh this page in a few seconds.
</div>
@@ -0,0 +1,16 @@
{# settings/_tabs.html — include at top of each settings section #}
<h1 class="page-title">Settings</h1>
<div style="display:flex;gap:0;border-bottom:1px solid var(--border-mid);margin-bottom:1.5rem;">
{% set tabs = [
("general", "General", "/settings/general/"),
("notifications", "Notifications", "/settings/notifications/"),
("ansible", "Ansible", "/settings/ansible/"),
("plugins", "Plugins", "/settings/plugins/"),
] %}
{% for key, label, href in tabs %}
<a href="{{ href }}"
style="padding:0.6rem 1.1rem;font-size:0.875rem;border-bottom:2px solid {{ 'var(--accent)' if active_tab == key else 'transparent' }};color:{{ 'var(--text)' if active_tab == key else 'var(--text-muted)' }};font-weight:{{ '500' if active_tab == key else 'normal' }};text-decoration:none;">
{{ label }}
</a>
{% endfor %}
</div>
@@ -0,0 +1,22 @@
{# fabledscryer/templates/settings/ansible.html #}
{% extends "base.html" %}
{% block title %}Settings — Ansible — Fabled Scryer{% endblock %}
{% block content %}
{% set active_tab = "ansible" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/ansible/">
<div class="card" style="max-width:720px;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Ansible Sources</h2>
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:1rem;">
JSON array of source objects. Each requires <code>name</code>, <code>type</code>
(<code>git</code> or <code>local</code>), and either <code>url</code> or <code>path</code>.
</p>
<textarea name="ansible.sources" rows="12"
style="font-family:ui-monospace,monospace;font-size:0.85rem;width:100%;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
</div>
<div style="margin-top:1rem;">
<button type="submit" class="btn">Save</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,38 @@
{# fabledscryer/templates/settings/general.html #}
{% extends "base.html" %}
{% block title %}Settings — General — Fabled Scryer{% endblock %}
{% block content %}
{% set active_tab = "general" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/general/">
<div class="card" style="max-width:640px;">
<h2 class="section-title" style="margin-bottom:1.25rem;">General</h2>
<div class="form-group">
<label>Session lifetime <span style="color:var(--text-muted);font-size:0.8rem;">(hours)</span></label>
<input type="number" name="session.lifetime_hours" min="1" max="720"
value="{{ settings['session.lifetime_hours'] }}" style="max-width:120px;">
</div>
<div class="form-group">
<label>Data retention <span style="color:var(--text-muted);font-size:0.8rem;">(days)</span></label>
<input type="number" name="data.retention_days" min="1"
value="{{ settings['data.retention_days'] }}" style="max-width:120px;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">Old ping/DNS results older than this are pruned by the cleanup job.</div>
</div>
<div class="form-group">
<label>Monitor poll interval <span style="color:var(--text-muted);font-size:0.8rem;">(seconds)</span></label>
<input type="number" name="monitors.poll_interval_seconds" min="10"
value="{{ settings['monitors.poll_interval_seconds'] }}" style="max-width:120px;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">Requires restart to take effect.</div>
</div>
</div>
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
<button type="submit" class="btn">Save</button>
<span style="font-size:0.82rem;color:var(--text-muted);">Changes to session lifetime take effect immediately.</span>
</div>
</form>
{% endblock %}
+157
View File
@@ -0,0 +1,157 @@
{# fabledscryer/templates/settings/index.html #}
{% extends "base.html" %}
{% block title %}Settings — Fabled Scryer{% endblock %}
{% block content %}
<h1 class="page-title">Settings</h1>
<form method="post" action="/settings/">
<div style="display:grid;gap:1.5rem;">
{# ── General ──────────────────────────────────────────────────────────── #}
<div class="card">
<h2 class="section-title" style="margin-bottom:1rem;">General</h2>
<div class="form-group">
<label>Session lifetime (hours)</label>
<input type="number" name="session.lifetime_hours" min="1" max="720"
value="{{ settings['session.lifetime_hours'] }}">
</div>
<div class="form-group">
<label>Data retention (days)</label>
<input type="number" name="data.retention_days" min="1"
value="{{ settings['data.retention_days'] }}">
</div>
<div class="form-group">
<label>Monitor poll interval (seconds)</label>
<input type="number" name="monitors.poll_interval_seconds" min="10"
value="{{ settings['monitors.poll_interval_seconds'] }}">
</div>
</div>
{# ── SMTP ─────────────────────────────────────────────────────────────── #}
<div class="card">
<h2 class="section-title" style="margin-bottom:1rem;">Email (SMTP)</h2>
<div class="form-group">
<label>SMTP host</label>
<input type="text" name="smtp.host" value="{{ settings['smtp.host'] }}" placeholder="mail.example.com">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Port</label>
<input type="number" name="smtp.port" value="{{ settings['smtp.port'] }}">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:1.5rem;">
<input type="checkbox" name="smtp.tls" id="smtp-tls"
{% if settings['smtp.tls'] %}checked{% endif %}>
<label for="smtp-tls" style="margin:0;">Use TLS</label>
</div>
</div>
<div class="form-group">
<label>Username</label>
<input type="text" name="smtp.username" value="{{ settings['smtp.username'] }}" autocomplete="off">
</div>
<div class="form-group">
<label>Password <span style="color:#606080;font-size:0.8rem;">(leave blank to keep current)</span></label>
<input type="password" name="smtp.password" placeholder="••••••••" autocomplete="new-password">
</div>
<div class="form-group">
<label>Recipients <span style="color:#606080;font-size:0.8rem;">(comma-separated)</span></label>
<input type="text" name="smtp.recipients"
value="{{ settings['smtp.recipients'] | join(', ') }}"
placeholder="admin@example.com, ops@example.com">
</div>
</div>
{# ── Webhook ──────────────────────────────────────────────────────────── #}
<div class="card">
<h2 class="section-title" style="margin-bottom:1rem;">Webhook</h2>
<div class="form-group">
<label>Webhook URL <span style="color:#606080;font-size:0.8rem;">(leave blank to disable)</span></label>
<input type="text" name="webhook.url" value="{{ settings['webhook.url'] }}"
placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group">
<label>Payload template <span style="color:#606080;font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
<textarea name="webhook.template" rows="3" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
</div>
</div>
{# ── Ansible Sources ──────────────────────────────────────────────────── #}
<div class="card">
<h2 class="section-title" style="margin-bottom:1rem;">Ansible Sources</h2>
<p style="color:#606080;font-size:0.85rem;margin-bottom:0.75rem;">JSON array of source objects. Each requires <code>name</code>, <code>type</code> (git or local), and either <code>url</code> or <code>path</code>.</p>
<textarea name="ansible.sources" rows="8" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
</div>
{# ── Plugins ──────────────────────────────────────────────────────────── #}
{% if discovered_plugins %}
<div class="card">
<h2 class="section-title" style="margin-bottom:1rem;">Plugins</h2>
{% for plugin in discovered_plugins %}
<div style="border:1px solid #2a2a4a;border-radius:4px;padding:1rem;margin-bottom:0.75rem;">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem;">
<input type="checkbox" name="plugin.{{ plugin._dir }}.enabled"
id="plugin-{{ plugin._dir }}-enabled"
{% if plugin._enabled %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-enabled"
style="margin:0;font-weight:bold;color:#c0c0e0;">
{{ plugin.get('name', plugin._dir) }}
<span style="color:#606080;font-size:0.8rem;font-weight:normal;">
v{{ plugin.get('version', '?') }} — {{ plugin.get('description', '') }}
</span>
</label>
</div>
{% for cfg_key, cfg_default in plugin.get('config', {}).items() %}
{% if cfg_default is mapping %}
{# Nested dict — render each sub-key as its own field #}
<div style="margin-left:1.5rem;margin-bottom:0.6rem;">
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.35rem;text-transform:uppercase;letter-spacing:0.04em;">{{ cfg_key }}</div>
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
{% for sub_key, sub_default in cfg_default.items() %}
<div class="form-group" style="margin-left:1rem;">
{% if sub_default is sameas false or sub_default is sameas true %}
<div style="display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
style="margin:0;font-size:0.85rem;">{{ sub_key }}</label>
</div>
{% else %}
<label style="font-size:0.85rem;">{{ sub_key }}</label>
<input type="text"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
value="{{ sub_cfg.get(sub_key, sub_default) }}">
{% endif %}
</div>
{% endfor %}
</div>
{% elif cfg_default is sameas false or cfg_default is sameas true %}
<div class="form-group" style="margin-left:1.5rem;display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}" style="margin:0;font-size:0.85rem;">{{ cfg_key }}</label>
</div>
{% else %}
<div class="form-group" style="margin-left:1.5rem;">
<label style="font-size:0.85rem;">{{ cfg_key }}</label>
<input type="text" name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
value="{{ plugin._config.get(cfg_key, cfg_default) }}">
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div style="margin-top:1.5rem;">
<button type="submit" class="btn">Save Settings</button>
<span style="color:#606080;font-size:0.85rem;margin-left:1rem;">
Changes take effect immediately. Monitor interval, plugin enable/disable require a restart.
</span>
</div>
</form>
{% endblock %}
@@ -0,0 +1,74 @@
{# fabledscryer/templates/settings/notifications.html #}
{% extends "base.html" %}
{% block title %}Settings — Notifications — Fabled Scryer{% endblock %}
{% block content %}
{% set active_tab = "notifications" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/notifications/">
<div style="display:grid;gap:1.25rem;max-width:640px;">
{# ── SMTP ──────────────────────────────────────────────────────────────── #}
<div class="card">
<h2 class="section-title" style="margin-bottom:1.25rem;">Email (SMTP)</h2>
<div class="form-group">
<label>SMTP host</label>
<input type="text" name="smtp.host" value="{{ settings['smtp.host'] }}"
placeholder="mail.example.com">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Port</label>
<input type="number" name="smtp.port" value="{{ settings['smtp.port'] }}">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:1.5rem;">
<input type="checkbox" name="smtp.tls" id="smtp-tls"
{% if settings['smtp.tls'] %}checked{% endif %}>
<label for="smtp-tls" style="margin:0;">Use TLS</label>
</div>
</div>
<div class="form-group">
<label>Username</label>
<input type="text" name="smtp.username" value="{{ settings['smtp.username'] }}"
autocomplete="off">
</div>
<div class="form-group">
<label>Password <span style="color:var(--text-muted);font-size:0.8rem;">(leave blank to keep current)</span></label>
<input type="password" name="smtp.password" placeholder="••••••••" autocomplete="new-password">
</div>
<div class="form-group">
<label>Recipients <span style="color:var(--text-muted);font-size:0.8rem;">(comma-separated)</span></label>
<input type="text" name="smtp.recipients"
value="{{ settings['smtp.recipients'] | join(', ') }}"
placeholder="admin@example.com, ops@example.com">
</div>
</div>
{# ── Webhook ───────────────────────────────────────────────────────────── #}
<div class="card">
<h2 class="section-title" style="margin-bottom:1.25rem;">Webhook</h2>
<div class="form-group">
<label>URL <span style="color:var(--text-muted);font-size:0.8rem;">(leave blank to disable)</span></label>
<input type="text" name="webhook.url" value="{{ settings['webhook.url'] }}"
placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group">
<label>Payload template <span style="color:var(--text-muted);font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
<textarea name="webhook.template" rows="4"
style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
</div>
</div>
</div>
<div style="margin-top:1rem;">
<button type="submit" class="btn">Save</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,161 @@
{# fabledscryer/templates/settings/plugins.html #}
{% extends "base.html" %}
{% block title %}Settings — Plugins — Fabled Scryer{% endblock %}
{% block content %}
{% set active_tab = "plugins" %}
{% include "settings/_tabs.html" %}
{# ── Restart banner (replaced by _restart_pending.html after action) #}
<div id="restart-banner"></div>
{# ── Installed Plugins ─────────────────────────────────────────────────────── #}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div class="section-title">Installed Plugins</div>
<form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/"
hx-target="#restart-banner"
hx-swap="outerHTML"
onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')"
style="margin:0;">
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
</form>
</div>
{% if not discovered_plugins %}
<div class="card" style="color:var(--text-muted);font-size:0.9rem;">
No plugins found in the plugin directory.
</div>
{% else %}
<form method="post" action="/settings/plugins/">
<div style="display:grid;gap:1rem;max-width:720px;">
{# Plugin index URL #}
<div class="card" style="padding:1rem 1.25rem;">
<div class="section-title" style="margin-bottom:0.6rem;">Plugin Index URL</div>
<div class="form-group" style="margin:0;">
<input type="url" name="plugins.index_url"
value="{{ settings.get('plugins.index_url', '') }}"
placeholder="https://raw.githubusercontent.com/your-org/fabledscryer-plugins/main/index.yaml"
style="font-size:0.85rem;">
</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.4rem;">
URL to the remote <code>index.yaml</code> used by the plugin catalog below.
</div>
</div>
{% for plugin in discovered_plugins %}
<div class="card" style="padding:1.25rem;">
{# Plugin header: enable toggle + name/description #}
<div style="display:flex;align-items:flex-start;gap:0.75rem;margin-bottom:{% if plugin.get('config') %}1rem{% else %}0{% endif %};">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.enabled"
id="plugin-{{ plugin._dir }}-enabled"
{% if plugin._enabled %}checked{% endif %}
style="margin-top:0.2rem;flex-shrink:0;">
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;cursor:pointer;">
<span style="font-weight:600;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.4rem;">v{{ plugin.get('version', '?') }}</span>
{% if plugin.get('description') %}
<div style="color:var(--text-muted);font-size:0.83rem;margin-top:0.15rem;font-weight:normal;">{{ plugin.description }}</div>
{% endif %}
</label>
</div>
{# Config fields — only shown when there are config keys #}
{% if plugin.get('config') %}
<div style="border-top:1px solid var(--border);padding-top:1rem;display:grid;gap:0.6rem;">
{% for cfg_key, cfg_default in plugin.config.items() %}
{% if cfg_default is mapping %}
{# Nested dict group #}
<div style="padding:0.6rem 0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.6rem;">{{ cfg_key }}</div>
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
<div style="display:grid;gap:0.5rem;">
{% for sub_key, sub_default in cfg_default.items() %}
{% if sub_default is sameas false or sub_default is sameas true %}
<div style="display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
style="margin:0;font-size:0.85rem;color:var(--text);">{{ sub_key }}</label>
</div>
{% else %}
<div class="form-group" style="margin:0;">
<label style="font-size:0.82rem;">{{ sub_key }}</label>
<input type="text"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
value="{{ sub_cfg.get(sub_key, sub_default) }}">
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% elif cfg_default is sameas false or cfg_default is sameas true %}
<div style="display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}"
style="margin:0;font-size:0.85rem;color:var(--text);">{{ cfg_key }}</label>
</div>
{% else %}
<div class="form-group" style="margin:0;">
<label style="font-size:0.82rem;">{{ cfg_key }}</label>
<input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
{% if 'password' in cfg_key or 'secret' in cfg_key %}placeholder="••••••••"{% endif %}>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
<button type="submit" class="btn">Save</button>
<span style="font-size:0.82rem;color:var(--text-muted);">Enable/disable changes take effect after a restart.</span>
</div>
</form>
{% endif %}
{# ── Plugin Catalog ────────────────────────────────────────────────────────── #}
<div style="margin-top:2rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div class="section-title">Plugin Catalog</div>
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
hx-get="/settings/plugins/catalog/?refresh=1"
hx-target="#plugin-catalog"
hx-swap="outerHTML">
Refresh
</button>
</div>
{% set index_url = settings.get('plugins.index_url', '') %}
{% if index_url %}
<div id="plugin-catalog"
hx-get="/settings/plugins/catalog/"
hx-trigger="load"
hx-swap="outerHTML">
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">Loading catalog…</div>
</div>
{% else %}
<div id="plugin-catalog" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
Configure a Plugin Index URL above to browse available plugins.
</div>
{% endif %}
</div>
{% endblock %}