From 7834eaefa64269784c38f20e12fc25d0b9382b78 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sun, 8 Nov 2026 22:00:48 -0500 Subject: [PATCH] overview and activity dashboard --- .gitignore | 5 + bot/app.py | 21 +++ bot/dashboard/__init__.py | 0 bot/dashboard/dashboard.py | 28 ++++ bot/dashboard/latency.py | 36 +++++ bot/dashboard/pages/__init__.py | 0 bot/dashboard/pages/activity.py | 94 +++++++++++ bot/dashboard/pages/overview.py | 40 +++++ bot/dashboard/storage.py | 20 +++ bot/dashboard/tests/__init__.py | 0 bot/dashboard/tests/test_gauge.py | 38 +++++ bot/dashboard/tests/test_latency.py | 57 +++++++ bot/dashboard/widget_state.py | 9 ++ bot/dashboard/widgets/__init__.py | 5 + bot/dashboard/widgets/gauge/__init__.py | 3 + bot/dashboard/widgets/gauge/gauge.css | 51 ++++++ bot/dashboard/widgets/gauge/gauge.js | 99 ++++++++++++ bot/dashboard/widgets/gauge/gauge.py | 57 +++++++ bot/extensions/dashboard/__init__.py | 0 .../dashboard/dashboard_extension.py | 150 ++++++++++++++++++ bot/extensions/dashboard/tests/__init__.py | 0 .../tests/test_dashboard_extension.py | 146 +++++++++++++++++ config/development.yaml | 5 + config/production.yaml | 5 + .../20261108200242_create_widget_states.py | 15 ++ pyproject.toml | 7 +- 26 files changed, 890 insertions(+), 1 deletion(-) create mode 100644 bot/dashboard/__init__.py create mode 100644 bot/dashboard/dashboard.py create mode 100644 bot/dashboard/latency.py create mode 100644 bot/dashboard/pages/__init__.py create mode 100644 bot/dashboard/pages/activity.py create mode 100644 bot/dashboard/pages/overview.py create mode 100644 bot/dashboard/storage.py create mode 100644 bot/dashboard/tests/__init__.py create mode 100644 bot/dashboard/tests/test_gauge.py create mode 100644 bot/dashboard/tests/test_latency.py create mode 100644 bot/dashboard/widget_state.py create mode 100644 bot/dashboard/widgets/__init__.py create mode 100644 bot/dashboard/widgets/gauge/__init__.py create mode 100644 bot/dashboard/widgets/gauge/gauge.css create mode 100644 bot/dashboard/widgets/gauge/gauge.js create mode 100644 bot/dashboard/widgets/gauge/gauge.py create mode 100644 bot/extensions/dashboard/__init__.py create mode 100644 bot/extensions/dashboard/dashboard_extension.py create mode 100644 bot/extensions/dashboard/tests/__init__.py create mode 100644 bot/extensions/dashboard/tests/test_dashboard_extension.py create mode 100644 db/migrations/20261108200242_create_widget_states.py diff --git a/.gitignore b/.gitignore index be5dfb5..9d89c01 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,9 @@ logs/*.log config/*.local.yaml .idea *.db + +# Dachshund's event store (SQLite in WAL mode) +dachshund.db +dachshund.db-wal +dachshund.db-shm settings.json \ No newline at end of file diff --git a/bot/app.py b/bot/app.py index e46bc5e..5c68d41 100644 --- a/bot/app.py +++ b/bot/app.py @@ -8,6 +8,7 @@ from pelican import get_runner, get_registry from bot import ada +from bot.dashboard import dashboard from bot.config import BotConfig logger = logging.getLogger(__name__) @@ -26,8 +27,11 @@ def start(config_file: str) -> None: config = BotConfig(config_file) _load_logging(config) + # Before extensions, whose on_load emits to the dashboard. + _load_dashboard(config) _load_extensions(config) _load_database(config) + _start_dashboard(config) _show_app_info(config) ada.bot.start(config=config) @@ -38,6 +42,23 @@ def _load_extensions(config: BotConfig) -> None: ada.bot.load_extension(extension) +def _load_dashboard(config: BotConfig) -> None: + dashboard.init_dachshund(_event_store(config)) + dashboard.build_dashboard() + + +def _start_dashboard(config: BotConfig) -> None: + dashboard.start_dashboard( + path=_event_store(config), + host=config.get("host", section="dashboard", default="127.0.0.1"), + port=int(config.get("port", section="dashboard", default=1212)), + ) + + +def _event_store(config: BotConfig) -> str: + return str(config.get("event_store", section="dashboard", default="dachshund.db")) + + def _load_logging(config: BotConfig) -> None: file_handler: RotatingFileHandler = RotatingFileHandler( f"logs/{config.environment}.log", maxBytes=10000, backupCount=5 diff --git a/bot/dashboard/__init__.py b/bot/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/dashboard/dashboard.py b/bot/dashboard/dashboard.py new file mode 100644 index 0000000..8e9ee9b --- /dev/null +++ b/bot/dashboard/dashboard.py @@ -0,0 +1,28 @@ +from typing import Any + +import dachshund + +from bot.dashboard.pages.activity import activity_page +from bot.dashboard.pages.overview import overview_page +from bot.dashboard.storage import SQLModelStorage + + +def init_dachshund(event_store: str) -> None: + """Stop any previous run and (re)open the dashboard's storage.""" + dachshund.shutdown() + dachshund.default(path=event_store, storage=SQLModelStorage()) + + +def build_dashboard() -> None: + for page in (overview_page, activity_page): + dachshund.include(page) + + +def start_dashboard(**options: Any) -> None: + """Start dachshund, with `options` (`host`, `port`, ...) passed to `init`.""" + dachshund.init( + "Ada", + description="Code Society's Matrix bot", + timezone="America/New_York", + **options, + ) diff --git a/bot/dashboard/latency.py b/bot/dashboard/latency.py new file mode 100644 index 0000000..7a30f7a --- /dev/null +++ b/bot/dashboard/latency.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass + +import dachshund + +EMA_ALPHA = 0.2 + + +@dataclass +class LatencyHealth: + """A running average of latency, weighting recent readings most (EMA).""" + + average_ms: float | None = None + + async def load(self) -> None: + """Rebuild the average from the stored latency readings.""" + self.average_ms = None + + for event in await dachshund.default().events.events("latency"): + value = event.payload.get("value") + + if value is not None: + self._accumulate(value) + + if self.average_ms is not None: + dachshund.emit("average_latency", value=self.average_ms) + + def record(self, latency_ms: int) -> None: + dachshund.emit("latency", value=latency_ms) + self._accumulate(latency_ms) + dachshund.emit("average_latency", value=self.average_ms) + + def _accumulate(self, latency_ms: float) -> None: + if self.average_ms is None: + self.average_ms = latency_ms + else: + self.average_ms += EMA_ALPHA * (latency_ms - self.average_ms) diff --git a/bot/dashboard/pages/__init__.py b/bot/dashboard/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/dashboard/pages/activity.py b/bot/dashboard/pages/activity.py new file mode 100644 index 0000000..d62c487 --- /dev/null +++ b/bot/dashboard/pages/activity.py @@ -0,0 +1,94 @@ +from datetime import timedelta + +from dachshund import Page +from dachshund.widgets import BarChart, Number, TimeSeriesChart + +activity_page = Page("activity", title="Activity", public=True) + +with activity_page.row(): + activity_page.add( + Number( + "active_members", + title="Active Today", + source="message", + y="author", + aggregate="distinct", + over="day", + ) + ) + activity_page.add( + Number( + "daily_message_rate", + title="Messages (last 24h)", + source="message", + aggregate="count", + over=timedelta(hours=24), + ) + ) + activity_page.add( + Number( + "commands_24h", + title="Commands (last 24h)", + source="on_command", + aggregate="count", + over=timedelta(hours=24), + ) + ) + activity_page.add( + Number( + "recent_joins", + title="Joins (last 24h)", + source="member_flow", + y="joined", + aggregate="sum", + over=timedelta(hours=24), + ) + ) + +with activity_page.row(): + activity_page.add( + TimeSeriesChart( + "member_flow", + title="Joins vs Leaves", + y=["joined", "left"], + aggregate="sum", + every=timedelta(days=1), + over=timedelta(days=30), + ) + ) + +with activity_page.row(): + activity_page.add( + BarChart( + "messages_per_day", + title="Messages / day", + source="message", + x="date", + persist=True, + ) + ) + activity_page.add( + BarChart( + "room_messages", + title="Messages per Room", + source="message", + x="room", + persist=True, + ) + ) + +with activity_page.row(): + activity_page.add( + BarChart("commands_per_day", title="Commands / day", x="date", persist=True) + ) + activity_page.add( + BarChart("on_command", title="Calls per Command", x="name", persist=True) + ) + +with activity_page.row(): + activity_page.add( + BarChart("reactions_per_day", title="Reactions / day", x="date", persist=True) + ) + activity_page.add( + BarChart("reaction_emojis", title="Reactions", x="emoji", persist=True) + ) diff --git a/bot/dashboard/pages/overview.py b/bot/dashboard/pages/overview.py new file mode 100644 index 0000000..ce7a662 --- /dev/null +++ b/bot/dashboard/pages/overview.py @@ -0,0 +1,40 @@ +from datetime import timedelta + +from dachshund import Page +from dachshund.widgets import Number, Text, TimeSeriesChart + +from bot.dashboard.widgets.gauge import Gauge + +overview_page = Page("overview", title="Overview", public=True) + +with overview_page.row(): + overview_page.add(Text("summary")) + + with overview_page.column(span=4): + overview_page.add(Number("member_count", title="Members")) + +with overview_page.row(): + with overview_page.column(span=4): + overview_page.add( + Gauge( + "average_latency_gauge", + title="Latency Health", + source="average_latency", + min=0, + max=500, + warning_at=150, + critical_at=300, + unit="ms", + ) + ) + overview_page.add( + Number( + "worst_latency", + title="Worst Latency (24h)", + source="latency", + aggregate="max", + over=timedelta(hours=24), + unit="ms", + ) + ) + overview_page.add(TimeSeriesChart("latency", title="Live Latency")) diff --git a/bot/dashboard/storage.py b/bot/dashboard/storage.py new file mode 100644 index 0000000..f3f8a58 --- /dev/null +++ b/bot/dashboard/storage.py @@ -0,0 +1,20 @@ +import json +from typing import Any + +from dachshund.storage import SyncStorage + +from bot.dashboard.widget_state import WidgetState + + +class SQLModelStorage(SyncStorage): + def _save(self, key: str, value: dict[str, Any]) -> None: + record = WidgetState.find(key) + + if record is None: + WidgetState.create(key=key, value=json.dumps(value)) + else: + record.update(value=json.dumps(value)) + + def _load(self, key: str) -> dict[str, Any] | None: + record = WidgetState.find(key) + return json.loads(record.value) if record else None diff --git a/bot/dashboard/tests/__init__.py b/bot/dashboard/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/dashboard/tests/test_gauge.py b/bot/dashboard/tests/test_gauge.py new file mode 100644 index 0000000..283d734 --- /dev/null +++ b/bot/dashboard/tests/test_gauge.py @@ -0,0 +1,38 @@ +from datetime import timedelta + +import pytest + +from bot.dashboard.widgets.gauge import Gauge + + +def test_zones_default_to_share_of_range() -> None: + gauge = Gauge("latency", min=0, max=200) + + assert gauge.warning_at == 120 + assert gauge.critical_at == 170 + + +def test_rejects_empty_range() -> None: + with pytest.raises(ValueError): + Gauge("latency", min=10, max=10) + + +def test_render_value_carries_zone_config() -> None: + gauge = Gauge( + "latency", + aggregate="average", + over=timedelta(hours=1), + max=500, + warning_at=150, + critical_at=300, + unit="ms", + ) + + assert gauge.render_value(42.5) == { + "value": 42.5, + "min": 0, + "max": 500, + "warning_at": 150, + "critical_at": 300, + "unit": "ms", + } diff --git a/bot/dashboard/tests/test_latency.py b/bot/dashboard/tests/test_latency.py new file mode 100644 index 0000000..d1055f3 --- /dev/null +++ b/bot/dashboard/tests/test_latency.py @@ -0,0 +1,57 @@ +from collections.abc import Iterator +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from bot.dashboard import latency +from bot.dashboard.latency import LatencyHealth + + +@pytest.fixture +def emit() -> Iterator[MagicMock]: + with patch.object(latency.dachshund, "emit") as mock: + yield mock + + +def test_first_reading_is_the_average(emit: MagicMock) -> None: + health = LatencyHealth() + health.record(100) + + assert health.average_ms == 100 + emit.assert_has_calls( + [call("latency", value=100), call("average_latency", value=100)] + ) + + +def test_later_readings_move_average_by_alpha(emit: MagicMock) -> None: + health = LatencyHealth() + health.record(100) + health.record(200) + + assert health.average_ms == pytest.approx(120) + + +async def test_load_replays_stored_readings(emit: MagicMock) -> None: + stored = [SimpleNamespace(payload={"value": v}) for v in (100, None, 200)] + store = SimpleNamespace( + events=SimpleNamespace(events=AsyncMock(return_value=stored)) + ) + + with patch.object(latency.dachshund, "default", return_value=store): + health = LatencyHealth(average_ms=999) + await health.load() + + assert health.average_ms == pytest.approx(120) + emit.assert_called_once_with("average_latency", value=pytest.approx(120)) + + +async def test_load_without_readings_emits_nothing(emit: MagicMock) -> None: + store = SimpleNamespace(events=SimpleNamespace(events=AsyncMock(return_value=[]))) + + with patch.object(latency.dachshund, "default", return_value=store): + health = LatencyHealth() + await health.load() + + assert health.average_ms is None + emit.assert_not_called() diff --git a/bot/dashboard/widget_state.py b/bot/dashboard/widget_state.py new file mode 100644 index 0000000..64088e4 --- /dev/null +++ b/bot/dashboard/widget_state.py @@ -0,0 +1,9 @@ +from sqlmodel import Field +from sqlmodel_toolkit import Model + + +class WidgetState(Model): + __tablename__ = "widget_states" + + key: str = Field(primary_key=True) + value: str = Field(nullable=False) diff --git a/bot/dashboard/widgets/__init__.py b/bot/dashboard/widgets/__init__.py new file mode 100644 index 0000000..b7e2154 --- /dev/null +++ b/bot/dashboard/widgets/__init__.py @@ -0,0 +1,5 @@ +"""Ada's own dachshund widgets, a package each with its script and style.""" + +from bot.dashboard.widgets.gauge import Gauge + +__all__ = ["Gauge"] diff --git a/bot/dashboard/widgets/gauge/__init__.py b/bot/dashboard/widgets/gauge/__init__.py new file mode 100644 index 0000000..c1b1815 --- /dev/null +++ b/bot/dashboard/widgets/gauge/__init__.py @@ -0,0 +1,3 @@ +from bot.dashboard.widgets.gauge.gauge import Gauge + +__all__ = ["Gauge"] diff --git a/bot/dashboard/widgets/gauge/gauge.css b/bot/dashboard/widgets/gauge/gauge.css new file mode 100644 index 0000000..df5d946 --- /dev/null +++ b/bot/dashboard/widgets/gauge/gauge.css @@ -0,0 +1,51 @@ +.card-content.gauge { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; +} + +.gauge-arc { + width: 100%; + max-width: 220px; + height: auto; +} + +.gauge-zone-good { + stroke: var(--online); +} + +.gauge-zone-warning { + stroke: var(--warning); +} + +.gauge-zone-critical { + stroke: var(--offline); +} + +.gauge-needle { + stroke: var(--text); + stroke-width: 2; +} + +.gauge-reading { + display: flex; + align-items: baseline; + gap: 4px; +} + +.gauge-value { + font-family: var(--font-ui); + font-size: 28px; + font-weight: 600; + color: var(--text); +} + +.gauge-unit { + font-family: var(--font-ui); + font-size: 14px; + color: var(--text-muted); +} diff --git a/bot/dashboard/widgets/gauge/gauge.js b/bot/dashboard/widgets/gauge/gauge.js new file mode 100644 index 0000000..ad1e6c7 --- /dev/null +++ b/bot/dashboard/widgets/gauge/gauge.js @@ -0,0 +1,99 @@ +(() => { + "use strict"; + + const RADIUS = 70; + const CENTER_X = 90; + const CENTER_Y = 84; + const STROKE = 14; + const SVG_NS = "http://www.w3.org/2000/svg"; + + const point = (angleDeg, radius) => { + const rad = (angleDeg * Math.PI) / 180; + return [CENTER_X + radius * Math.cos(rad), CENTER_Y - radius * Math.sin(rad)]; + }; + + // Maps a value to its angle on the arc: min sits at 180deg (left), max at + // 0deg (right), sweeping up through 90deg (top). + const angleFor = (value, min, max) => { + const clamped = Math.min(max, Math.max(min, value)); + return 180 - (180 * (clamped - min)) / (max - min); + }; + + const arcPath = (fromAngle, toAngle) => { + const [x1, y1] = point(fromAngle, RADIUS); + const [x2, y2] = point(toAngle, RADIUS); + const largeArc = fromAngle - toAngle > 180 ? 1 : 0; + return `M ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${x2} ${y2}`; + }; + + const arc = (className) => { + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("class", className); + path.setAttribute("fill", "none"); + path.setAttribute("stroke-width", STROKE); + path.setAttribute("stroke-linecap", "round"); + return path; + }; + + const format = (value) => + value == null ? "—" : Number.isInteger(value) ? String(value) : value.toFixed(1); + + const paint = (state, payload) => { + const { min, max, warning_at: warningAt, critical_at: criticalAt, value, unit } = payload; + const warnAngle = angleFor(warningAt, min, max); + const critAngle = angleFor(criticalAt, min, max); + + state.good.setAttribute("d", arcPath(180, warnAngle)); + state.warn.setAttribute("d", arcPath(warnAngle, critAngle)); + state.bad.setAttribute("d", arcPath(critAngle, 0)); + + state.needle.style.display = value == null ? "none" : ""; + + if (value != null) { + const [x, y] = point(angleFor(value, min, max), RADIUS - STROKE); + state.needle.setAttribute("x2", x); + state.needle.setAttribute("y2", y); + } + + state.readout.textContent = format(value); + state.unit.textContent = value == null ? "" : (unit ?? ""); + }; + + window.Dachshund.widget("gauge", { + mount(card, payload) { + const container = card.querySelector(".card-content"); + container.classList.add("gauge"); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 180 100"); + svg.setAttribute("class", "gauge-arc"); + + const good = arc("gauge-zone gauge-zone-good"); + const warn = arc("gauge-zone gauge-zone-warning"); + const bad = arc("gauge-zone gauge-zone-critical"); + + const needle = document.createElementNS(SVG_NS, "line"); + needle.setAttribute("class", "gauge-needle"); + needle.setAttribute("x1", CENTER_X); + needle.setAttribute("y1", CENTER_Y); + + svg.append(good, warn, bad, needle); + + const reading = document.createElement("div"); + reading.className = "gauge-reading"; + const readout = document.createElement("span"); + readout.className = "gauge-value"; + const unit = document.createElement("span"); + unit.className = "gauge-unit"; + reading.append(readout, unit); + + container.append(svg, reading); + + const state = { good, warn, bad, needle, readout, unit }; + paint(state, payload); + + return state; + }, + replace: paint, + }); +})(); diff --git a/bot/dashboard/widgets/gauge/gauge.py b/bot/dashboard/widgets/gauge/gauge.py new file mode 100644 index 0000000..b56dd37 --- /dev/null +++ b/bot/dashboard/widgets/gauge/gauge.py @@ -0,0 +1,57 @@ +from dataclasses import KW_ONLY, dataclass +from pathlib import Path +from typing import Any, ClassVar + +from dachshund.widgets import AggregatedWidget + + +@dataclass +class Gauge(AggregatedWidget): + """A number of its source, as an arc gauge with green/amber/red zones. + + Takes `y`, `aggregate` and `over` like `Number`. `warning_at`/`critical_at` + default to 60%/85% of the way across `[min, max]` when not given. + + ## Example + + ```python + Gauge("latency", aggregate="average", over=timedelta(hours=1), max=500, unit="ms") + ``` + """ + + _: KW_ONLY + + min: float = 0 + max: float = 100 + warning_at: float | None = None + critical_at: float | None = None + unit: str | None = None + + type: ClassVar[str] = "gauge" + op: ClassVar[str] = "replace" + script: ClassVar[Path] = Path(__file__).parent / "gauge.js" + style: ClassVar[Path] = Path(__file__).parent / "gauge.css" + + def __post_init__(self) -> None: + super().__post_init__() + + if self.max <= self.min: + raise ValueError("A gauge's max must be greater than its min.") + + span = self.max - self.min + + if self.warning_at is None: + self.warning_at = self.min + span * 0.6 + if self.critical_at is None: + self.critical_at = self.min + span * 0.85 + + def render_value(self, value: float | None) -> dict[str, Any]: + """`value`, plus the range/zone config the arc needs.""" + return { + "value": value, + "min": self.min, + "max": self.max, + "warning_at": self.warning_at, + "critical_at": self.critical_at, + "unit": self.unit, + } diff --git a/bot/extensions/dashboard/__init__.py b/bot/extensions/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/extensions/dashboard/dashboard_extension.py b/bot/extensions/dashboard/dashboard_extension.py new file mode 100644 index 0000000..d08fabc --- /dev/null +++ b/bot/extensions/dashboard/dashboard_extension.py @@ -0,0 +1,150 @@ +import time +from datetime import date, datetime, timedelta +from typing import cast +from zoneinfo import ZoneInfo + +import dachshund +from matrix import Bot, Context, Extension, Room +from nio import ReactionEvent, RoomMemberEvent, RoomMessageText + +from bot.dashboard.latency import LatencyHealth +from bot.extensions.welcome.welcome_extension import _is_new_join + +extension = Extension("dashboard") + +TIMEZONE = ZoneInfo("America/New_York") +# Bar charts with a bar per date, which keep only the last DAILY_DAYS days. +DAILY_SOURCES = ("commands_per_day", "reactions_per_day") +DAILY_DAYS = 30 +# Dates trimmed each run, so a week of downtime still gets cleaned up. +TRIM_SPAN = 7 + +SUMMARY = """ +# Welcome! +### This dashboard contains various monitoring information about **Ada**, our Matrix bot! +Hopefully, you will find something useful here. + +- [Website](https://codesociety.xyz) +- [Source](https://github.com/Code-Society-Lab/ada) +""" + + +@extension.on_load +def setup() -> None: + dachshund.emit("summary", value=SUMMARY) + _trim_daily_charts() + + +latency_health = LatencyHealth() + + +@extension.hook +async def on_ready() -> None: + await latency_health.load() + _report_counts() + + +@extension.schedule("* * * * *") +async def report_counts() -> None: + _report_counts() + + +@extension.schedule("5 0 * * *") +async def trim_daily_charts() -> None: + _trim_daily_charts() + + +@extension.event +async def on_message(room: Room, event: RoomMessageText) -> None: + _record_message(room, event) + + +@extension.event +async def on_react(_room: Room, event: ReactionEvent) -> None: + _record_reaction(event.key) + + +@extension.event +async def on_member_join(_room: Room, event: RoomMemberEvent) -> None: + if _is_new_join(event): + _record_member_flow(joined=1, left=0) + + +@extension.event +async def on_member_leave(_room: Room, event: RoomMemberEvent) -> None: + if _is_leave(event): + _record_member_flow(joined=0, left=1) + + +@extension.hook +async def on_command(ctx: Context) -> None: + if ctx.command: + _record_command(ctx.command.name) + + +def _record_message(room: Room, event: RoomMessageText) -> None: + latency_health.record(_latency_ms(event.server_timestamp)) + dachshund.emit( + "message", + date=_today(), + author=event.sender, + room=_room_name(room), + value=1, + ) + + +def _record_reaction(emoji: str) -> None: + dachshund.emit("reactions_per_day", date=_today(), value=1) + dachshund.emit("reaction_emojis", emoji=emoji, value=1) + + +def _record_member_flow(*, joined: int, left: int) -> None: + dachshund.emit("member_flow", joined=joined, left=left) + _report_counts() + + +def _record_command(name: str) -> None: + dachshund.emit("on_command", name=name, value=1) + dachshund.emit("commands_per_day", date=_today(), value=1) + + +def _report_counts() -> None: + rooms = cast(Bot, extension.bot).get_rooms() + + dachshund.emit("room_count", value=len(rooms)) + # Distinct users, so someone in several rooms counts once. + members: set[str] = set().union(*(room.users for room in rooms)) + dachshund.emit("member_count", value=len(members)) + + +def _trim_daily_charts(now: date | None = None) -> None: + """Remove the bars older than DAILY_DAYS from the per-day charts.""" + now = now or date.fromisoformat(_today()) + + for days_ago in range(DAILY_DAYS + 1, DAILY_DAYS + 1 + TRIM_SPAN): + old = (now - timedelta(days=days_ago)).isoformat() + + for source in DAILY_SOURCES: + dachshund.emit(source, date=old, _delete=True) + + +def _today() -> str: + """Today's date, the day starting at midnight Eastern.""" + return datetime.now(TIMEZONE).date().isoformat() + + +def _latency_ms(server_timestamp: int) -> int: + """Time between the homeserver receiving an event and Ada seeing it.""" + return max(0, round(time.time() * 1000 - server_timestamp)) + + +def _room_name(room: Room) -> str: + """A public room by name, any other as "private", so its name stays hidden.""" + if room.join_rule != "public": + return "private" + + return room.display_name + + +def _is_leave(event: RoomMemberEvent) -> bool: + return event.membership == "leave" and event.prev_membership == "join" diff --git a/bot/extensions/dashboard/tests/__init__.py b/bot/extensions/dashboard/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/extensions/dashboard/tests/test_dashboard_extension.py b/bot/extensions/dashboard/tests/test_dashboard_extension.py new file mode 100644 index 0000000..17e2db1 --- /dev/null +++ b/bot/extensions/dashboard/tests/test_dashboard_extension.py @@ -0,0 +1,146 @@ +import time +from collections.abc import Iterator +from datetime import date +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock, call, patch + +import pytest +from matrix import Room +from nio import RoomMemberEvent, RoomMessageText + +from bot.extensions.dashboard import dashboard_extension +from bot.extensions.dashboard.dashboard_extension import ( + DAILY_SOURCES, + TRIM_SPAN, + _is_leave, + _latency_ms, + _record_command, + _record_member_flow, + _record_message, + _record_reaction, + _report_counts, + _room_name, + _trim_daily_charts, +) + +TODAY = "2026-11-08" + + +@pytest.fixture +def emit() -> Iterator[MagicMock]: + with patch.object(dashboard_extension.dachshund, "emit") as mock: + yield mock + + +@pytest.fixture(autouse=True) +def today() -> Iterator[None]: + with patch.object(dashboard_extension, "_today", return_value=TODAY): + yield + + +def _room(join_rule: str = "public", users: tuple[str, ...] = ()) -> Room: + room = SimpleNamespace( + join_rule=join_rule, + display_name="General", + users=dict.fromkeys(users), + ) + return cast(Room, room) + + +def _member_event(membership: str, prev_membership: str) -> RoomMemberEvent: + event = SimpleNamespace(membership=membership, prev_membership=prev_membership) + return cast(RoomMemberEvent, event) + + +def test_room_name_shows_public_rooms() -> None: + assert _room_name(_room("public")) == "General" + + +@pytest.mark.parametrize("join_rule", ["invite", "knock", "restricted"]) +def test_room_name_hides_non_public_rooms(join_rule: str) -> None: + assert _room_name(_room(join_rule)) == "private" + + +def test_latency_ms_is_time_since_server_timestamp() -> None: + sent = int(time.time() * 1000) - 250 + assert 250 <= _latency_ms(sent) < 1000 + + +def test_latency_ms_never_negative() -> None: + assert _latency_ms(int(time.time() * 1000) + 10_000) == 0 + + +@pytest.mark.parametrize( + ("membership", "prev_membership", "expected"), + [("leave", "join", True), ("leave", "invite", False), ("join", "leave", False)], +) +def test_is_leave_only_for_joined_members( + membership: str, prev_membership: str, expected: bool +) -> None: + assert _is_leave(_member_event(membership, prev_membership)) is expected + + +def test_trim_daily_charts_deletes_dates_past_window(emit: MagicMock) -> None: + _trim_daily_charts(date(2026, 11, 8)) + + deleted = {c.kwargs["date"] for c in emit.mock_calls} + + assert emit.call_count == TRIM_SPAN * len(DAILY_SOURCES) + assert "2026-10-09" not in deleted # 30 days ago, still shown + assert {"2026-10-08", "2026-10-02"} <= deleted + assert "2026-10-01" not in deleted + + +def test_report_counts_counts_distinct_members(emit: MagicMock) -> None: + rooms = [_room(users=("@a:x", "@b:x")), _room(users=("@b:x", "@c:x"))] + bot = SimpleNamespace(get_rooms=lambda: rooms) + + with patch.object(dashboard_extension.extension, "_bot", bot): + _report_counts() + + emit.assert_has_calls([call("room_count", value=2), call("member_count", value=3)]) + + +def test_record_message_emits_latency_and_message(emit: MagicMock) -> None: + event: Any = SimpleNamespace( + sender="@a:x", server_timestamp=int(time.time() * 1000) + ) + + _record_message(_room("invite"), cast(RoomMessageText, event)) + + assert emit.mock_calls[0].args == ("latency",) + assert emit.mock_calls[1].args == ("average_latency",) + emit.assert_called_with( + "message", date=TODAY, author="@a:x", room="private", value=1 + ) + + +def test_record_reaction_emits_day_and_emoji(emit: MagicMock) -> None: + _record_reaction("🔥") + + emit.assert_has_calls( + [ + call("reactions_per_day", date=TODAY, value=1), + call("reaction_emojis", emoji="🔥", value=1), + ] + ) + + +def test_record_member_flow_refreshes_counts(emit: MagicMock) -> None: + with patch.object(dashboard_extension, "_report_counts") as report: + _record_member_flow(joined=0, left=1) + + emit.assert_called_once_with("member_flow", joined=0, left=1) + report.assert_called_once() + + +def test_record_command_emits_name_and_day(emit: MagicMock) -> None: + _record_command("ping") + + emit.assert_has_calls( + [ + call("on_command", name="ping", value=1), + call("commands_per_day", date=TODAY, value=1), + ] + ) diff --git a/config/development.yaml b/config/development.yaml index 350b58e..cc78ab9 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -16,3 +16,8 @@ extensions: room: "${ADA_MAIN_ROOM|}" weather: api_key: "${ADA_OPENWEATHER_API_KEY|}" + +dashboard: + host: "${DASHBOARD_HOST|127.0.0.1}" + port: "${PORT|4242}" + event_store: "${DACHSHUND_EVENT_STORE_PATH|dachshund.db}" diff --git a/config/production.yaml b/config/production.yaml index 78c0a63..8b00b1a 100644 --- a/config/production.yaml +++ b/config/production.yaml @@ -15,3 +15,8 @@ extensions: room: "!OaOPoyVKqbmPEcMBbt:matrix.org" weather: api_key: "${ADA_OPENWEATHER_API_KEY|}" + +dashboard: + host: "${DASHBOARD_HOST|0.0.0.0}" + port: "${PORT|4242}" + event_store: "${DACHSHUND_EVENT_STORE_PATH|dachshund.db}" diff --git a/db/migrations/20261108200242_create_widget_states.py b/db/migrations/20261108200242_create_widget_states.py new file mode 100644 index 0000000..59477a1 --- /dev/null +++ b/db/migrations/20261108200242_create_widget_states.py @@ -0,0 +1,15 @@ +"""20261108200242 - Create widget states""" + +from pelican import migration, create_table, drop_table + + +@migration.up +def upgrade() -> None: + with create_table("widget_states") as t: + t.string("key", primary_key=True) + t.text("value", nullable=False) + + +@migration.down +def downgrade() -> None: + drop_table("widget_states") diff --git a/pyproject.toml b/pyproject.toml index 357efc0..028911f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,9 @@ dependencies = [ "click==8.3.2", "sqlmodel-toolkit @ git+https://github.com/Code-Society-Lab/sqlmodel-toolkit.git", "pelican-migration>=0.1.2a0", - "requests==2.33.1" + "requests==2.33.1", + "dachshund==0.1.0", + "tzdata", ] [project.optional-dependencies] @@ -35,6 +37,9 @@ ada = "bot.cli:cli" [tool.setuptools.packages.find] include = ["bot*"] +[tool.setuptools.package-data] +bot = ["dashboard/widgets/*/*.js", "dashboard/widgets/*/*.css"] + [tool.pytest.ini_options] testpaths = ["tests", "bot"] asyncio_mode = "auto"