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
+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)