From 3dfd4a914535fef658bc6d6d45a36791f814744b Mon Sep 17 00:00:00 2001 From: penguinboi Date: Fri, 6 Nov 2026 21:26:25 -0500 Subject: [PATCH 01/11] Use new dachshund aggration --- bot/extensions/dashboard_cog.py | 65 +------- lib/dashboard/dashboard.py | 1 + lib/dashboard/pages/activity.py | 113 +++++++++++-- lib/dashboard/pages/overview.py | 78 +++------ lib/dashboard/sqlmodel_storage.py | 2 +- lib/dashboard/widgets/gauge.py | 7 +- tests/extensions/test_dashboard_cog.py | 154 ++++-------------- tests/lib/dashboard/pages/test_activity.py | 141 +++++++++++++++- tests/lib/dashboard/pages/test_overview.py | 77 +-------- tests/lib/dashboard/test_dashboard.py | 1 + tests/lib/dashboard/widgets/test_gauge.py | 2 +- .../test_dashboard_metrics_service.py | 2 +- 12 files changed, 315 insertions(+), 328 deletions(-) diff --git a/bot/extensions/dashboard_cog.py b/bot/extensions/dashboard_cog.py index eedd1d5..0fd334b 100644 --- a/bot/extensions/dashboard_cog.py +++ b/bot/extensions/dashboard_cog.py @@ -7,7 +7,6 @@ from pytz import timezone from bot.grace import Grace -from bot.models.extensions.widget_state import WidgetState SUMMARY = """ # Welcome! @@ -30,32 +29,12 @@ def __init__(self, bot: Grace): self.jobs: list[Job] = [] self.timezone: tzinfo = timezone("US/Eastern") - self._daily_message_count: int = 0 - self._minutely_message_count: int = 0 - self._minutely_command_count: int = 0 - def cog_load(self) -> None: - self._load_counts() dachshund.emit("summary", value=SUMMARY) self.jobs.append( self.bot.scheduler.add_job(self._report_latency, "interval", minutes=1) ) - self.jobs.append( - self.bot.scheduler.add_job( - self._save_counts, "cron", minute="*/1", timezone=self.timezone - ) - ) - self.jobs.append( - self.bot.scheduler.add_job( - self._reset_daily_count, - "cron", - hour=0, - minute=0, - second=0, - timezone=self.timezone, - ) - ) def cog_unload(self) -> None: for job in self.jobs: @@ -64,37 +43,6 @@ def cog_unload(self) -> None: def _report_latency(self) -> None: self.bot.metrics.record_latency(self.bot.latency_ms) - def _load_counts(self) -> None: - self._daily_message_count = self._load_count("daily_message_rate") - self._minutely_message_count = self._load_count("minutely_message_rate") - self._minutely_command_count = self._load_count("minutely_command_rate") - - dachshund.emit("daily_message_rate", value=self._daily_message_count) - dachshund.emit("minutely_message_rate", value=self._minutely_message_count) - dachshund.emit("minutely_command_rate", value=self._minutely_command_count) - - def _load_count(self, key: str) -> int: - record = WidgetState.find(key) - return int(record.value) if record else 0 - - def _save_counts(self) -> None: - for key, value in ( - ("daily_message_rate", self._daily_message_count), - ("minutely_message_rate", self._minutely_message_count), - ("minutely_command_rate", self._minutely_command_count), - ): - record = WidgetState.find(key) - if record is None: - WidgetState.create(key=key, value=str(value)) - else: - record.update(value=str(value)) - - def _reset_daily_count(self) -> None: - self._daily_message_count = 0 - dachshund.emit("daily_message_rate", value=self._daily_message_count) - - self._save_counts() - def _report_member_count(self) -> None: guild_id = self.bot.app.config.get("client", "guild_id") @@ -117,15 +65,11 @@ async def on_member_remove(self, _member: Member) -> None: dachshund.emit("members") @Cog.listener() - async def on_message(self, _message: Message) -> None: - self._minutely_message_count += 1 - dachshund.emit("minutely_message_rate", value=self._minutely_message_count) - - self._daily_message_count += 1 - dachshund.emit("daily_message_rate", value=self._daily_message_count) + async def on_message(self, message: Message) -> None: dachshund.emit( - "weekly_message_counts", + "message", date=datetime.now(self.timezone).date().isoformat(), + author=None if message.author.bot else str(message.author.id), value=1, ) @@ -134,9 +78,6 @@ async def on_command(self, ctx: Context) -> None: if ctx.command: dachshund.emit("on_command", name=ctx.command.qualified_name, value=1) - self._minutely_command_count += 1 - dachshund.emit("minutely_command_rate", value=self._minutely_command_count) - @Cog.listener() async def on_command_error(self, ctx: Context, error: Exception) -> None: name = ctx.command.qualified_name if ctx.command else "unknown" diff --git a/lib/dashboard/dashboard.py b/lib/dashboard/dashboard.py index a28f1e0..4e6be8f 100644 --- a/lib/dashboard/dashboard.py +++ b/lib/dashboard/dashboard.py @@ -19,6 +19,7 @@ def start_dashboard(context: dict): "Grace", description="Code Society's Discord Bot", host="0.0.0.0", + timezone="US/Eastern", context=context, ) diff --git a/lib/dashboard/pages/activity.py b/lib/dashboard/pages/activity.py index 4e4ecdb..51bc47f 100644 --- a/lib/dashboard/pages/activity.py +++ b/lib/dashboard/pages/activity.py @@ -1,15 +1,104 @@ -from dachshund import BarChart, Number, Page +from typing import Any, Callable + +from dachshund import Page +from dachshund.widgets import BarChart, Number, Table, TableQuery +from discord import Member + +from bot import app activity_page = Page("activity", title="Activity") -activity_page.row( - Number("daily_message_rate", title="Today's Messages Rate"), - BarChart("weekly_message_counts", title="Messages / day", x="date", persist=True), - widths=(1, 2), -) -activity_page.row( - BarChart("on_command", title="Calls Per Command", x="name", persist=True) -) -activity_page.row( - BarChart("command_error", title="Errors Per Command", x="name", persist=True) -) + +MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = { + "id": lambda member: member.id, + "name": lambda member: member.name.lower(), + "nickname": lambda member: (member.nick or "").lower(), + "joined at": lambda member: member.joined_at, +} + + +def _member_row(member: Member) -> dict[str, Any]: + return { + "id": member.id, + "name": member.name, + "nickname": member.nick, + "joined at": member.joined_at.strftime("%a %d %b %Y"), + } + + +def _matches(member: Member, search: str) -> bool: + needle = search.lower() + haystack = (str(member.id), member.name, member.nick or "") + + return any(needle in value.lower() for value in haystack) + + +def load_members(ctx: dict, query: TableQuery) -> tuple[list[dict[str, Any]], int]: + guild = ctx["bot"].get_guild(app.config.get("client", "guild_id")) + members = list(guild.members) if guild else [] + + if query.search: + members = [member for member in members if _matches(member, query.search)] + + if sort_key := MEMBER_SORT_KEYS.get(query.sort_by): + members.sort(key=sort_key, reverse=query.descending) + + page = members[query.offset : query.offset + query.limit] + + return [_member_row(member) for member in page], len(members) + + +with activity_page.row(): + with activity_page.column(span=4): + activity_page.add(Number("member_count")) + activity_page.add( + Number( + "active_members", + title="Active Today", + source="message", + y="author", + aggregate="distinct", + over="day", + ) + ) + activity_page.add( + Table( + "members", + columns=["id", "name", "nickname", "joined at"], + search=True, + page_size=5, + loader=load_members, + ) + ) + +with activity_page.row(): + with activity_page.column(span=4): + activity_page.add( + Number( + "daily_message_rate", + title="Today's Messages Rate", + source="message", + aggregate="count", + over="day", + ) + ) + + activity_page.add( + BarChart( + "weekly_message_counts", + title="Messages / day", + source="message", + x="date", + persist=True, + ) + ) + +with activity_page.row(): + activity_page.add( + BarChart("on_command", title="Calls Per Command", x="name", persist=True) + ) + +with activity_page.row(): + activity_page.add( + BarChart("command_error", title="Errors Per Command", x="name", persist=True) + ) diff --git a/lib/dashboard/pages/overview.py b/lib/dashboard/pages/overview.py index a09c255..e5c9559 100644 --- a/lib/dashboard/pages/overview.py +++ b/lib/dashboard/pages/overview.py @@ -1,57 +1,31 @@ -from dachshund import Number, Page, Table, Text, TimeSeriesChart, BarChart - -from bot import app +from dachshund import Page +from dachshund.widgets import BarChart, Text, TimeSeriesChart from lib.dashboard.widgets.gauge import Gauge overview_page = Page("overview", title="Overview") - -def load_members(ctx: dict, cursor: str | None, page_size: int): - bot = ctx["bot"] - members = [] - - if guild := bot.get_guild(app.config.get("client", "guild_id")): - for member in guild.members: - members.append( - { - "id": member.id, - "name": member.name, - "nickname": member.nick, - "joined at": member.joined_at.strftime("%a %d %b %Y"), - } +with overview_page.row(): + overview_page.add(Text("summary")) + +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", ) - - start = int(cursor) if cursor else 0 - rows = members[start : start + page_size] - next_cursor = str(start + page_size) if start + page_size < len(members) else None - - return rows, next_cursor - - -overview_page.row(Text("summary")) -overview_page.row( - Gauge( - "average_latency_gauge", - title="Latency Health", - source="average_latency", - min=0, - max=500, - warning_at=150, - critical_at=300, - unit="ms", - ), - TimeSeriesChart("latency", title="Live Latency", y=["check", "command"]), - widths=(1, 2), -) -overview_page.row( - Number("member_count"), - Table( - "members", - columns=["id", "name", "nickname", "joined at"], - loader=load_members, - ), - widths=(1, 2), -) -overview_page.row( - BarChart("connection_event", title="Connection Events", x="type", persist=True) -) + ) + overview_page.add( + TimeSeriesChart("latency", title="Live Latency", y=["check", "command"]) + ) + +with overview_page.row(): + overview_page.add( + BarChart("connection_event", title="Connection Events", x="type", persist=True) + ) diff --git a/lib/dashboard/sqlmodel_storage.py b/lib/dashboard/sqlmodel_storage.py index a2c52dc..6cd7d4e 100644 --- a/lib/dashboard/sqlmodel_storage.py +++ b/lib/dashboard/sqlmodel_storage.py @@ -1,7 +1,7 @@ import json from typing import Any -from dachshund.storage.protocol import SyncStorage +from dachshund.storage import SyncStorage from bot.models.extensions.widget_state import WidgetState diff --git a/lib/dashboard/widgets/gauge.py b/lib/dashboard/widgets/gauge.py index f9cbb1f..e2bd505 100644 --- a/lib/dashboard/widgets/gauge.py +++ b/lib/dashboard/widgets/gauge.py @@ -2,8 +2,8 @@ from pathlib import Path from typing import Any, ClassVar, Sequence -from dachshund import Widget -from dachshund.storage.events import Event +from dachshund.storage import Event +from dachshund.widgets import Widget @dataclass @@ -16,7 +16,8 @@ class Gauge(Widget): ## Example ```python - dachshund.row(Gauge("cpu", title="CPU Load", max=100, unit="%")) + with dachshund.row(): + dachshund.add(Gauge("cpu", title="CPU Load", max=100, unit="%")) dachshund.emit("cpu", value=42) ``` """ diff --git a/tests/extensions/test_dashboard_cog.py b/tests/extensions/test_dashboard_cog.py index c3ac981..e0b8812 100644 --- a/tests/extensions/test_dashboard_cog.py +++ b/tests/extensions/test_dashboard_cog.py @@ -3,8 +3,12 @@ import pytest from freezegun import freeze_time -from bot.extensions.dashboard_cog import DashboardCog -from bot.models.extensions.widget_state import WidgetState +from bot.extensions.dashboard_cog import SUMMARY, DashboardCog + + +def _fake_message(author_id=1, bot=False): + author = type("Author", (), {"id": author_id, "bot": bot})() + return type("Message", (), {"author": author})() @pytest.fixture @@ -21,24 +25,6 @@ def test_report_latency__expect_records_latency_from_bot(dashboard_cog): dashboard_cog.bot.metrics.record_latency.assert_called_once_with(50) -def test_reset_daily_count__expect_resets_daily_message_count(dashboard_cog): - """Verify _reset_daily_count resets the daily message counter.""" - dashboard_cog._daily_message_count = 42 - - dashboard_cog._reset_daily_count() - - assert dashboard_cog._daily_message_count == 0 - - -def test_reset_daily_count__expect_persists_zeroed_count(dashboard_cog): - """Verify _reset_daily_count saves the zeroed count so it survives a restart.""" - dashboard_cog._daily_message_count = 42 - - dashboard_cog._reset_daily_count() - - assert WidgetState.find("daily_message_rate").value == "0" - - @patch("bot.extensions.dashboard_cog.dachshund.emit") def test_report_member_count__expect_emits_member_count_for_configured_guild( mock_emit, dashboard_cog @@ -117,79 +103,52 @@ async def test_on_member_remove__expect_a_members_refresh_ping( mock_emit.assert_any_call("members") -@pytest.mark.asyncio -async def test_on_message__expect_increments_minutely_and_daily_counts(dashboard_cog): - """Verify on_message counts the message toward both the minute and day totals.""" - await dashboard_cog.on_message(object()) - - assert dashboard_cog._minutely_message_count == 1 - assert dashboard_cog._daily_message_count == 1 - - @pytest.mark.asyncio @patch("bot.extensions.dashboard_cog.dachshund.emit") -async def test_on_message__expect_emits_running_minutely_and_daily_counts( +async def test_on_message__expect_one_message_event_with_date_and_author( mock_emit, dashboard_cog ): - """Verify on_message emits the running minute and day counts.""" - await dashboard_cog.on_message(object()) - await dashboard_cog.on_message(object()) + """Verify on_message emits one event per message, dated in the cog's timezone.""" + with freeze_time("2025-02-21 03:00:00"): # still the 20th in US/Eastern + await dashboard_cog.on_message(_fake_message(author_id=7)) - mock_emit.assert_any_call("minutely_message_rate", value=2) - mock_emit.assert_any_call("daily_message_rate", value=2) + mock_emit.assert_called_once_with("message", date="2025-02-20", author="7", value=1) @pytest.mark.asyncio @patch("bot.extensions.dashboard_cog.dachshund.emit") -async def test_on_message__expect_emits_a_same_day_delta_for_the_weekly_chart( - mock_emit, dashboard_cog -): - """Verify on_message emits a delta of 1 for today's date, not a running total.""" +async def test_on_message__from_a_bot__expect_no_author(mock_emit, dashboard_cog): + """Verify a bot's message still counts, but not toward active members.""" with freeze_time("2025-02-20 12:00:00"): - await dashboard_cog.on_message(object()) - await dashboard_cog.on_message(object()) - - mock_emit.assert_any_call("weekly_message_counts", date="2025-02-20", value=1) - - -@pytest.mark.asyncio -@patch("bot.extensions.dashboard_cog.dachshund.emit") -async def test_on_command__expect_emits_command_name(mock_emit, dashboard_cog): - """Verify on_command emits the qualified command name.""" - ctx = type("Ctx", (), {"command": type("Cmd", (), {"qualified_name": "ping"})()})() - - await dashboard_cog.on_command(ctx) - - mock_emit.assert_any_call("on_command", name="ping", value=1) + await dashboard_cog.on_message(_fake_message(author_id=99, bot=True)) + mock_emit.assert_called_once_with( + "message", date="2025-02-20", author=None, value=1 + ) -@pytest.mark.asyncio -async def test_on_command__expect_increments_minutely_command_count(dashboard_cog): - """Verify on_command counts the command in addition to emitting.""" - ctx = type("Ctx", (), {"command": type("Cmd", (), {"qualified_name": "ping"})()})() - await dashboard_cog.on_command(ctx) +def _command_ctx(): + command = type("Cmd", (), {"qualified_name": "ping"})() - assert dashboard_cog._minutely_command_count == 1 + return type("Ctx", (), {"command": command})() @pytest.mark.asyncio @patch("bot.extensions.dashboard_cog.dachshund.emit") -async def test_on_command__expect_emits_running_command_count(mock_emit, dashboard_cog): - """Verify on_command emits the running count for the current minute.""" - ctx = type("Ctx", (), {"command": type("Cmd", (), {"qualified_name": "ping"})()})() +async def test_on_command__expect_emits_command_name(mock_emit, dashboard_cog): + """Verify on_command emits the qualified command name.""" + ctx = _command_ctx() - await dashboard_cog.on_command(ctx) await dashboard_cog.on_command(ctx) - mock_emit.assert_any_call("minutely_command_rate", value=2) + mock_emit.assert_any_call("on_command", name="ping", value=1) @pytest.mark.asyncio @patch("bot.extensions.dashboard_cog.dachshund.emit") async def test_on_command_error__expect_emits_command_name(mock_emit, dashboard_cog): """Verify on_command_error emits the qualified command name.""" - ctx = type("Ctx", (), {"command": type("Cmd", (), {"qualified_name": "ping"})()})() + ctx = _command_ctx() await dashboard_cog.on_command_error(ctx, Exception("boom")) @@ -227,65 +186,22 @@ async def test_on_resumed__expect_emits_resume_event(mock_emit, dashboard_cog): mock_emit.assert_called_once_with("connection_event", type="resume", value=1) -def test_load_count__with_no_saved_value__expect_defaults_to_zero(dashboard_cog): - """Verify _load_count returns 0 for a key that was never saved.""" - assert dashboard_cog._load_count("a_key_nobody_saved") == 0 - - -def test_load_counts__with_saved_values__expect_rehydrates_counts(dashboard_cog): - """Verify _load_counts restores counters from previously saved values.""" - dashboard_cog._daily_message_count = 42 - dashboard_cog._minutely_message_count = 7 - dashboard_cog._minutely_command_count = 3 - dashboard_cog._save_counts() - - fresh_cog = DashboardCog(dashboard_cog.bot) - fresh_cog._load_counts() +def test_cog_load__expect_schedules_the_latency_job(dashboard_cog): + """Verify cog_load schedules the latency report every minute.""" + dashboard_cog.cog_load() - assert fresh_cog._daily_message_count == 42 - assert fresh_cog._minutely_message_count == 7 - assert fresh_cog._minutely_command_count == 3 + dashboard_cog.bot.scheduler.add_job.assert_called_once_with( + dashboard_cog._report_latency, "interval", minutes=1 + ) + assert len(dashboard_cog.jobs) == 1 @patch("bot.extensions.dashboard_cog.dachshund.emit") -def test_load_counts__expect_emits_restored_counts(mock_emit, dashboard_cog): - """Verify _load_counts emits the restored counts so widgets update immediately.""" - dashboard_cog._daily_message_count = 5 - dashboard_cog._minutely_message_count = 2 - dashboard_cog._minutely_command_count = 1 - dashboard_cog._save_counts() - - dashboard_cog._load_counts() - - mock_emit.assert_any_call("daily_message_rate", value=5) - mock_emit.assert_any_call("minutely_message_rate", value=2) - mock_emit.assert_any_call("minutely_command_rate", value=1) - - -def test_cog_load__expect_schedules_cron_jobs(dashboard_cog): - """Verify cog_load schedules the latency, count-persistence and day-reset jobs.""" +def test_cog_load__expect_emits_the_summary(mock_emit, dashboard_cog): + """Verify cog_load emits the summary text.""" dashboard_cog.cog_load() - dashboard_cog.bot.scheduler.add_job.assert_any_call( - dashboard_cog._report_latency, - "interval", - minutes=1, - ) - dashboard_cog.bot.scheduler.add_job.assert_any_call( - dashboard_cog._save_counts, - "cron", - minute="*/1", - timezone=dashboard_cog.timezone, - ) - dashboard_cog.bot.scheduler.add_job.assert_any_call( - dashboard_cog._reset_daily_count, - "cron", - hour=0, - minute=0, - second=0, - timezone=dashboard_cog.timezone, - ) - assert len(dashboard_cog.jobs) == 3 + mock_emit.assert_called_once_with("summary", value=SUMMARY) def test_cog_unload__expect_removes_scheduled_jobs(dashboard_cog): diff --git a/tests/lib/dashboard/pages/test_activity.py b/tests/lib/dashboard/pages/test_activity.py index 7121eaa..f142234 100644 --- a/tests/lib/dashboard/pages/test_activity.py +++ b/tests/lib/dashboard/pages/test_activity.py @@ -1,14 +1,18 @@ +from datetime import datetime + import pytest -from dachshund import BarChart, Number, Row +from dachshund import Column, Row +from dachshund.widgets import BarChart, Number, Table, TableQuery -from lib.dashboard.pages.activity import activity_page +from lib.dashboard.pages.activity import activity_page, load_members -def test_activity_page__expect_messages_beside_their_rate_then_one_chart_per_row(): +def test_activity_page__expect_members_beside_their_counts_then_messages(): assert activity_page.rows == [ - Row(["daily_message_rate", "weekly_message_counts"], (1, 2)), - Row(["on_command"]), - Row(["command_error"]), + Row([Column(["member_count", "active_members"], 4), Column(["members"])]), + Row([Column(["daily_message_rate"], 4), Column(["weekly_message_counts"])]), + Row([Column(["on_command"])]), + Row([Column(["command_error"])]), ] assert isinstance(activity_page.widgets["daily_message_rate"], Number) @@ -27,3 +31,128 @@ def test_activity_page__expect_persisted_bar_charts(name, x): assert isinstance(chart, BarChart) assert chart.x == x assert chart.persist is True + + +@pytest.mark.parametrize( + "name, y, aggregate", + [ + ("daily_message_rate", "value", "count"), + ("active_members", "author", "distinct"), + ], +) +def test_activity_page__expect_todays_message_numbers_aggregated_from_messages( + name, y, aggregate +): + number = activity_page.widgets[name] + + assert isinstance(number, Number) + assert number.source == "message" + assert number.y == y + assert number.aggregate == aggregate + assert number.over == "day" + + +def test_activity_page__expect_messages_per_day_fed_by_messages(): + assert activity_page.widgets["weekly_message_counts"].source == "message" + + +def test_activity_page__with_members_table__expect_a_loader(): + members = activity_page.widgets["members"] + + assert isinstance(members, Table) + assert members.loader is load_members + assert members.persist is False + assert members.actions == [] + + +def _fake_bot(guild): + return type("Bot", (), {"get_guild": staticmethod(lambda guild_id: guild)})() + + +def _fake_member(id, name, nick=None, joined_at=datetime(2025, 3, 14)): + return type( + "Member", (), {"id": id, "name": name, "nick": nick, "joined_at": joined_at} + )() + + +def _fake_guild(members): + return type("Guild", (), {"members": members})() + + +def test_load_members__expect_every_member_as_a_row(): + bot = _fake_bot(_fake_guild([_fake_member(1, "ada", nick="Ada L.")])) + + rows, total = load_members({"bot": bot}, TableQuery(limit=5)) + + assert rows == [ + { + "id": 1, + "name": "ada", + "nickname": "Ada L.", + "joined at": "Fri 14 Mar 2025", + } + ] + assert total == 1 + + +def test_load_members__with_offset_and_limit__expect_that_page_and_the_total(): + members = [_fake_member(i, f"user{i}") for i in range(3)] + bot = _fake_bot(_fake_guild(members)) + + first_rows, first_total = load_members({"bot": bot}, TableQuery(limit=2)) + second_rows, second_total = load_members( + {"bot": bot}, TableQuery(offset=2, limit=2) + ) + + assert [row["id"] for row in first_rows] == [0, 1] + assert [row["id"] for row in second_rows] == [2] + assert first_total == second_total == 3 + + +def test_load_members__with_search__expect_matching_name_or_nickname_only(): + members = [ + _fake_member(1, "ada", nick="Countess"), + _fake_member(2, "grace"), + _fake_member(3, "linus", nick="ADA fan"), + ] + bot = _fake_bot(_fake_guild(members)) + + rows, total = load_members({"bot": bot}, TableQuery(search="ada", limit=10)) + + assert [row["id"] for row in rows] == [1, 3] + assert total == 2 + + +def test_load_members__sorted_by_joined_at__expect_date_order_not_text_order(): + members = [ + _fake_member(1, "ada", joined_at=datetime(2025, 3, 14)), # "Fri ..." + _fake_member(2, "grace", joined_at=datetime(2024, 1, 1)), # "Mon ..." + _fake_member(3, "linus", joined_at=datetime(2025, 1, 2)), # "Thu ..." + ] + bot = _fake_bot(_fake_guild(members)) + + rows, _ = load_members( + {"bot": bot}, TableQuery(sort_by="joined at", descending=True, limit=10) + ) + + assert [row["id"] for row in rows] == [1, 3, 2] + + +def test_load_members__sorted_by_nickname__expect_missing_nicknames_first(): + members = [ + _fake_member(1, "ada", nick="zed"), + _fake_member(2, "grace"), + _fake_member(3, "linus", nick="Bob"), + ] + bot = _fake_bot(_fake_guild(members)) + + rows, _ = load_members({"bot": bot}, TableQuery(sort_by="nickname", limit=10)) + + assert [row["id"] for row in rows] == [2, 3, 1] + + +def test_load_members__with_no_guild__expect_no_rows(): + rows, total = load_members({"bot": _fake_bot(None)}, TableQuery(limit=5)) + + assert rows == [] + assert total == 0 diff --git a/tests/lib/dashboard/pages/test_overview.py b/tests/lib/dashboard/pages/test_overview.py index 29503d5..b75f197 100644 --- a/tests/lib/dashboard/pages/test_overview.py +++ b/tests/lib/dashboard/pages/test_overview.py @@ -1,17 +1,14 @@ -from datetime import datetime +from dachshund import Column, Row -from dachshund import Row, Table - -from lib.dashboard.pages.overview import load_members, overview_page +from lib.dashboard.pages.overview import overview_page from lib.dashboard.widgets.gauge import Gauge -def test_overview_page__expect_summary_latency_then_members(): +def test_overview_page__expect_summary_then_latency_then_connections(): assert overview_page.rows == [ - Row(["summary"]), - Row(["average_latency_gauge", "latency"], (1, 2)), - Row(["member_count", "members"], (1, 2)), - Row(["connection_event"]), + Row([Column(["summary"])]), + Row([Column(["average_latency_gauge"], 4), Column(["latency"])]), + Row([Column(["connection_event"])]), ] @@ -21,65 +18,3 @@ def test_overview_page__with_average_latency_gauge__expect_gauge_widget_in_ms(): assert isinstance(gauge, Gauge) assert gauge.source == "average_latency" assert gauge.unit == "ms" - - -def test_overview_page__with_members_table__expect_a_loader(): - members = overview_page.widgets["members"] - - assert isinstance(members, Table) - assert members.loader is load_members - assert members.persist is False - assert members.actions == [] - - -def _fake_bot(guild): - return type("Bot", (), {"get_guild": staticmethod(lambda guild_id: guild)})() - - -def _fake_member(id, name, nick=None, joined_at=datetime(2025, 3, 14)): - return type( - "Member", (), {"id": id, "name": name, "nick": nick, "joined_at": joined_at} - )() - - -def test_load_members__expect_every_member_as_a_row(): - guild = type( - "Guild", - (), - { - "id": 1349086642446532639, - "name": "Code Society", - "members": [_fake_member(1, "ada", nick="Ada L.")], - }, - )() - bot = _fake_bot(guild) - - rows, next_cursor = load_members({"bot": bot}, None, 5) - - assert rows == [ - { - "id": 1, - "name": "ada", - "nickname": "Ada L.", - "joined at": "Fri 14 Mar 2025", - } - ] - assert next_cursor is None - - -def test_load_members__expect_a_next_cursor_when_more_remain(): - members = [_fake_member(i, f"user{i}") for i in range(3)] - guild = type( - "Guild", - (), - {"id": 1349086642446532639, "name": "Code Society", "members": members}, - )() - bot = _fake_bot(guild) - - first_rows, next_cursor = load_members({"bot": bot}, None, 2) - second_rows, second_cursor = load_members({"bot": bot}, next_cursor, 2) - - assert [row["id"] for row in first_rows] == [0, 1] - assert next_cursor == "2" - assert [row["id"] for row in second_rows] == [2] - assert second_cursor is None diff --git a/tests/lib/dashboard/test_dashboard.py b/tests/lib/dashboard/test_dashboard.py index c636a00..b0fb953 100644 --- a/tests/lib/dashboard/test_dashboard.py +++ b/tests/lib/dashboard/test_dashboard.py @@ -19,6 +19,7 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): "average_latency_gauge", "daily_message_rate", "member_count", + "active_members", "members", "weekly_message_counts", "on_command", diff --git a/tests/lib/dashboard/widgets/test_gauge.py b/tests/lib/dashboard/widgets/test_gauge.py index d9e5a07..f2b79cd 100644 --- a/tests/lib/dashboard/widgets/test_gauge.py +++ b/tests/lib/dashboard/widgets/test_gauge.py @@ -1,5 +1,5 @@ import pytest -from dachshund.storage.events import Event +from dachshund.storage import Event from lib.dashboard.widgets.gauge import Gauge diff --git a/tests/services/test_dashboard_metrics_service.py b/tests/services/test_dashboard_metrics_service.py index 54a27d5..63113fc 100644 --- a/tests/services/test_dashboard_metrics_service.py +++ b/tests/services/test_dashboard_metrics_service.py @@ -1,7 +1,7 @@ from unittest.mock import AsyncMock, patch import pytest -from dachshund.storage.events import Event +from dachshund.storage import Event from bot.services.dashboard_metrics_service import DashboardMetrics From 9bf49c8337361f8ea8c8559a6e2f7ba2648c018f Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 01:05:40 -0500 Subject: [PATCH 02/11] Discord oauth --- bot/grace.py | 11 + config/environment.cfg | 2 + config/settings.cfg | 11 + lib/dashboard/auth/__init__.py | 4 + lib/dashboard/auth/oauth.py | 103 +++++++++ lib/dashboard/auth/proxy.py | 95 ++++++++ lib/dashboard/auth/service.py | 84 ++++++++ lib/dashboard/auth/session.py | 75 +++++++ lib/dashboard/auth/settings.py | 49 +++++ lib/dashboard/dashboard.py | 6 +- lib/dashboard/pages/overview.py | 5 +- tests/lib/dashboard/auth/__init__.py | 0 tests/lib/dashboard/auth/settings.py | 10 + tests/lib/dashboard/auth/test_oauth.py | 108 ++++++++++ tests/lib/dashboard/auth/test_service.py | 239 +++++++++++++++++++++ tests/lib/dashboard/auth/test_session.py | 58 +++++ tests/lib/dashboard/auth/test_settings.py | 61 ++++++ tests/lib/dashboard/pages/test_overview.py | 8 +- tests/lib/dashboard/test_dashboard.py | 1 + 19 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 lib/dashboard/auth/__init__.py create mode 100644 lib/dashboard/auth/oauth.py create mode 100644 lib/dashboard/auth/proxy.py create mode 100644 lib/dashboard/auth/service.py create mode 100644 lib/dashboard/auth/session.py create mode 100644 lib/dashboard/auth/settings.py create mode 100644 tests/lib/dashboard/auth/__init__.py create mode 100644 tests/lib/dashboard/auth/settings.py create mode 100644 tests/lib/dashboard/auth/test_oauth.py create mode 100644 tests/lib/dashboard/auth/test_service.py create mode 100644 tests/lib/dashboard/auth/test_session.py create mode 100644 tests/lib/dashboard/auth/test_settings.py diff --git a/bot/grace.py b/bot/grace.py index 8b4c103..c11a0f9 100644 --- a/bot/grace.py +++ b/bot/grace.py @@ -7,6 +7,7 @@ from pretty_help import PrettyHelp from lib.dashboard import dashboard +from lib.dashboard.auth import AuthService, AuthSettings from bot.models.channel import Channel from bot.models.extension import Extension from bot.services.dashboard_metrics_service import DashboardMetrics @@ -22,6 +23,7 @@ def __init__(self, app): self.help_command = PrettyHelp(color=self.default_color) self.metrics = DashboardMetrics() + self.auth: AuthService | None = None @property def default_color(self): @@ -64,6 +66,15 @@ async def setup_hook(self): dashboard.init_dachshund() dashboard.build_dashboard() dashboard.start_dashboard({"bot": self}) + self.auth = AuthService( + self, dashboard.DASHBOARD_URL, AuthSettings.from_config(self.app.config) + ) + await self.auth.start() + + async def close(self): + if self.auth: + await self.auth.stop() + await super().close() async def on_ready(self): logger.info(f"{self.user.name}#{self.user.id} is online and ready to use!") diff --git a/config/environment.cfg b/config/environment.cfg index 4653a04..c9d3d77 100644 --- a/config/environment.cfg +++ b/config/environment.cfg @@ -1,9 +1,11 @@ [production] log_level = INFO +dashboard_auth = True sqlalchemy_echo = False [development] log_level = INFO +dashboard_auth = False sqlalchemy_echo = False [test] diff --git a/config/settings.cfg b/config/settings.cfg index b880271..77db4af 100644 --- a/config/settings.cfg +++ b/config/settings.cfg @@ -13,6 +13,17 @@ token = ${DISCORD_TOKEN} [dachshund] event_store = ${DACHSHUND_EVENT_STORE_PATH} +[dashboard] +; Discord OAuth2 login in front of the dashboard, on unless the environment sets +; dashboard_auth = False. The redirect URI ends in /auth/callback and must also be +; added to the bot's application in the Discord developer portal. Guild +; administrators get in, and members with role_id when it's set. +client_id = ${DISCORD_CLIENT_ID} +client_secret = ${DISCORD_CLIENT_SECRET} +redirect_uri = ${DASHBOARD_REDIRECT_URI} +session_secret = ${DASHBOARD_SESSION_SECRET} +role_id = ${DASHBOARD_ROLE_ID} + [openweather] api_key = ${OPENWEATHER_API} diff --git a/lib/dashboard/auth/__init__.py b/lib/dashboard/auth/__init__.py new file mode 100644 index 0000000..6b93b09 --- /dev/null +++ b/lib/dashboard/auth/__init__.py @@ -0,0 +1,4 @@ +from lib.dashboard.auth.service import AuthService +from lib.dashboard.auth.settings import AuthSettings + +__all__ = ["AuthService", "AuthSettings"] diff --git a/lib/dashboard/auth/oauth.py b/lib/dashboard/auth/oauth.py new file mode 100644 index 0000000..d4d6545 --- /dev/null +++ b/lib/dashboard/auth/oauth.py @@ -0,0 +1,103 @@ +"""Discord OAuth2 login, with the `identify` scope.""" + +import hmac +import secrets + +from aiohttp import BasicAuth, ClientSession, web +from discord.utils import oauth_url + +from lib.dashboard.auth.session import Sessions, redirect, safe_next, set_cookie +from lib.dashboard.auth.settings import AuthSettings + +DISCORD_API_URL = "https://discord.com/api/v10" + + +class DiscordOAuth: + """The login, callback and logout routes.""" + + LOGIN_PATH = "/auth/login" + CALLBACK_PATH = "/auth/callback" + LOGOUT_PATH = "/auth/logout" + + STATE_COOKIE = "grace_oauth_state" + STATE_TTL = 10 * 60 + + def __init__(self, settings: AuthSettings, sessions: Sessions) -> None: + self.settings = settings + self.sessions = sessions + + def routes(self) -> list[web.RouteDef]: + return [ + web.get(self.LOGIN_PATH, self.login), + web.get(self.CALLBACK_PATH, self.callback), + web.get(self.LOGOUT_PATH, self.logout), + ] + + async def login(self, request: web.Request) -> web.Response: + """Send the visitor to Discord, remembering `next` and a fresh `state`.""" + state = secrets.token_urlsafe(32) + response = redirect( + oauth_url( + self.settings.client_id, + redirect_uri=self.settings.redirect_uri, + scopes=["identify"], + state=state, + ) + ) + next_path = safe_next(request.query.get("next")) + set_cookie(response, self.STATE_COOKIE, f"{state}|{next_path}", self.STATE_TTL) + + return response + + async def callback(self, request: web.Request) -> web.Response: + """Finish the login: check `state`, start a session, return to `next`.""" + cookie = request.cookies.get(self.STATE_COOKIE, "") + expected, _, next_path = cookie.partition("|") + state = request.query.get("state", "") + code = request.query.get("code") + + if not expected or not code or not hmac.compare_digest(state, expected): + raise web.HTTPBadRequest(text="Invalid login attempt.") + + try: + user_id = await self.fetch_user_id(code) + except Exception as exc: + raise web.HTTPBadGateway(text="Could not reach Discord.") from exc + + response = redirect(safe_next(next_path)) + response.del_cookie(self.STATE_COOKIE, path="/") + set_cookie(response, Sessions.COOKIE, self.sessions.sign(user_id), Sessions.TTL) + + return response + + async def logout(self, _request: web.Request) -> web.Response: + """End the session and return to the dashboard's home.""" + response = redirect("/") + response.del_cookie(Sessions.COOKIE, path="/") + + return response + + async def fetch_user_id(self, code: str) -> int: + """Exchange an OAuth2 `code` for the id of the Discord user who granted it. + + The token is only used for this, then dropped. + """ + async with ClientSession() as http: + async with http.post( + f"{DISCORD_API_URL}/oauth2/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.settings.redirect_uri, + }, + auth=BasicAuth(self.settings.client_id, self.settings.client_secret), + ) as response: + response.raise_for_status() + token = (await response.json())["access_token"] + + async with http.get( + f"{DISCORD_API_URL}/users/@me", + headers={"Authorization": f"Bearer {token}"}, + ) as response: + response.raise_for_status() + return int((await response.json())["id"]) diff --git a/lib/dashboard/auth/proxy.py b/lib/dashboard/auth/proxy.py new file mode 100644 index 0000000..40e0041 --- /dev/null +++ b/lib/dashboard/auth/proxy.py @@ -0,0 +1,95 @@ +"""Forwards requests, websockets included, to the dashboard.""" + +import asyncio +from typing import Awaitable, Callable, Mapping + +from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType, web +from yarl import URL + +HOP_HEADERS = { + "host", + "connection", + "upgrade", + "keep-alive", + "transfer-encoding", + "content-length", + "content-encoding", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-accept", +} + +CLIENT_SESSION = web.AppKey("proxy_client_session", ClientSession) + +Handler = Callable[[web.Request], Awaitable[web.StreamResponse]] + + +def _headers(headers: Mapping[str, str]) -> dict[str, str]: + return {k: v for k, v in headers.items() if k.lower() not in HOP_HEADERS} + + +async def client_session(app: web.Application): + """Open the session the proxy forwards with, for the app's lifetime.""" + app[CLIENT_SESSION] = ClientSession() + yield + await app[CLIENT_SESSION].close() + + +def proxy_to(upstream: URL) -> Handler: + """A handler forwarding each request to the same path on `upstream`.""" + + async def handler(request: web.Request) -> web.StreamResponse: + session = request.app[CLIENT_SESSION] + target = upstream.join(request.rel_url) + + if request.headers.get("Upgrade", "").lower() == "websocket": + return await _websocket(request, session, target.with_scheme("ws")) + + async with session.request( + request.method, + target, + headers=_headers(request.headers), + data=await request.read(), + allow_redirects=False, + ) as response: + return web.Response( + status=response.status, + body=await response.read(), + headers=_headers(response.headers), + ) + + return handler + + +async def _websocket( + request: web.Request, session: ClientSession, target: URL +) -> web.WebSocketResponse: + client = web.WebSocketResponse() + await client.prepare(request) + + async with session.ws_connect(target, headers=_headers(request.headers)) as server: + pumps = [ + asyncio.create_task(_pump(client, server)), + asyncio.create_task(_pump(server, client)), + ] + # One side closing closes the other. + await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED) + + for pump in pumps: + pump.cancel() + + await client.close() + + return client + + +async def _pump( + source: web.WebSocketResponse | ClientWebSocketResponse, + sink: web.WebSocketResponse | ClientWebSocketResponse, +) -> None: + async for message in source: + if message.type == WSMsgType.TEXT: + await sink.send_str(message.data) + elif message.type == WSMsgType.BINARY: + await sink.send_bytes(message.data) diff --git a/lib/dashboard/auth/service.py b/lib/dashboard/auth/service.py new file mode 100644 index 0000000..b9854bc --- /dev/null +++ b/lib/dashboard/auth/service.py @@ -0,0 +1,84 @@ +"""The dashboard's front door: a Discord login, then a proxy to dachshund.""" + +import logging +from typing import Any +from urllib.parse import urlencode + +from aiohttp import web +from yarl import URL + +from lib.dashboard.auth.oauth import DiscordOAuth +from lib.dashboard.auth.proxy import client_session, proxy_to +from lib.dashboard.auth.session import Sessions +from lib.dashboard.auth.settings import AuthSettings + +logger = logging.getLogger(__name__) + + +class AuthService: + """Serves `upstream` on `PORT`, behind a Discord login when enabled. + + Guild administrators get in, and members with the configured role. + """ + + PORT = 1212 + PUBLIC_PREFIX = "/auth/" + + def __init__(self, bot: Any, upstream: URL, settings: AuthSettings) -> None: + self.bot = bot + self.upstream = upstream + self.settings = settings + self.sessions = Sessions(settings.session_secret) + self.oauth = DiscordOAuth(settings, self.sessions) + self._runner: web.AppRunner | None = None + + def is_allowed(self, user_id: int | None) -> bool: + guild = self.bot.get_guild(self.settings.guild_id) + member = guild.get_member(user_id) if guild and user_id else None + + if member is None: + return False + + return member.guild_permissions.administrator or any( + role.id == self.settings.role_id for role in member.roles + ) + + @web.middleware + async def require_login(self, request: web.Request, handler): + # Runs on the bot's loop, so reading the member cache is safe, and + # losing access ends the session on the next request. + if request.path.startswith(self.PUBLIC_PREFIX) or self.is_allowed( + self.sessions.current_user(request) + ): + return await handler(request) + + if request.method == "GET" and "text/html" in request.headers.get("Accept", ""): + query = urlencode({"next": request.path_qs}) + raise web.HTTPFound(f"{self.oauth.LOGIN_PATH}?{query}") + + raise web.HTTPUnauthorized() + + def build_app(self) -> web.Application: + secured = self.settings.enabled + app = web.Application(middlewares=[self.require_login] if secured else []) + app.cleanup_ctx.append(client_session) + + if secured: + app.add_routes(self.oauth.routes()) + + app.add_routes([web.route("*", "/{tail:.*}", proxy_to(self.upstream))]) + + return app + + async def start(self) -> None: + if not self.settings.enabled: + logger.warning("Dashboard login is off, serving it on this machine only.") + + self._runner = web.AppRunner(self.build_app()) + await self._runner.setup() + await web.TCPSite(self._runner, self.settings.host, self.PORT).start() + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None diff --git a/lib/dashboard/auth/session.py b/lib/dashboard/auth/session.py new file mode 100644 index 0000000..2c54259 --- /dev/null +++ b/lib/dashboard/auth/session.py @@ -0,0 +1,75 @@ +"""Signed session cookies holding a Discord user id.""" + +import hashlib +import hmac +import time + +from aiohttp import web + + +class Sessions: + """Signs and reads session cookies with `secret`.""" + + COOKIE = "grace_session" + TTL = 7 * 24 * 60 * 60 + + def __init__(self, secret: str) -> None: + self._secret = secret.encode() + + def _signature(self, payload: str) -> str: + return hmac.new(self._secret, payload.encode(), hashlib.sha256).hexdigest() + + def sign(self, user_id: int, now: float | None = None) -> str: + """A cookie value for `user_id`, valid for `TTL` seconds.""" + expires_at = int((time.time() if now is None else now) + self.TTL) + payload = f"{user_id}.{expires_at}" + + return f"{payload}.{self._signature(payload)}" + + def read(self, cookie: str | None, now: float | None = None) -> int | None: + """The user id in `cookie`, or `None` if it's missing, tampered with or expired.""" + if not cookie: + return None + + try: + user_id, expires_at, signature = cookie.split(".") + payload = f"{user_id}.{expires_at}" + + if not hmac.compare_digest(signature, self._signature(payload)): + return None + + if int(expires_at) < (time.time() if now is None else now): + return None + + return int(user_id) + except ValueError: + return None + + def current_user(self, request: web.Request) -> int | None: + """The user id of `request`'s session, if it has a valid one.""" + return self.read(request.cookies.get(self.COOKIE)) + + +def safe_next(value: str | None) -> str: + """`value` if it's a path on this site, `/` otherwise.""" + if not value or not value.startswith("/") or value.startswith(("//", "/\\")): + return "/" + + return value + + +def set_cookie(response: web.Response, name: str, value: str, max_age: int) -> None: + response.set_cookie( + name, + value, + max_age=max_age, + path="/", + httponly=True, + secure=True, + samesite="Lax", + ) + + +def redirect(location: str) -> web.Response: + """A 302 to `location`, returned rather than raised so it can set cookies.""" + return web.Response(status=302, headers={"Location": location}) diff --git a/lib/dashboard/auth/settings.py b/lib/dashboard/auth/settings.py new file mode 100644 index 0000000..1ec956b --- /dev/null +++ b/lib/dashboard/auth/settings.py @@ -0,0 +1,49 @@ +"""The dashboard login's settings, read and checked once at startup.""" + +from dataclasses import dataclass +from typing import Any + +REQUIRED = ("client_id", "client_secret", "redirect_uri", "session_secret") + + +@dataclass(frozen=True) +class AuthSettings: + """`[dashboard]` and the environment's `dashboard_auth`. + + The login is on unless the environment sets `dashboard_auth = False`, so + an environment that doesn't mention it never serves the dashboard openly. + """ + + enabled: bool + guild_id: int + client_id: str = "" + client_secret: str = "" + redirect_uri: str = "" + session_secret: str = "" + role_id: int | None = None + + def __post_init__(self) -> None: + missing = [key for key in REQUIRED if self.enabled and not getattr(self, key)] + + if missing: + raise RuntimeError(f"Missing dashboard settings: {', '.join(missing)}.") + + @classmethod + def from_config(cls, config: Any) -> "AuthSettings": + def get(key: str) -> str: + return str(config.get("dashboard", key) or "") + + return cls( + enabled=config.environment.getboolean("dashboard_auth", fallback=True), + guild_id=config.get("client", "guild_id"), + client_id=get("client_id"), + client_secret=get("client_secret"), + redirect_uri=get("redirect_uri"), + session_secret=get("session_secret"), + role_id=int(get("role_id")) if get("role_id") else None, + ) + + @property + def host(self) -> str: + """Where the service listens: only this machine when the login is off.""" + return "0.0.0.0" if self.enabled else "127.0.0.1" diff --git a/lib/dashboard/dashboard.py b/lib/dashboard/dashboard.py index 4e6be8f..4aa4971 100644 --- a/lib/dashboard/dashboard.py +++ b/lib/dashboard/dashboard.py @@ -1,4 +1,5 @@ import dachshund +from yarl import URL from bot import app from lib.dashboard.pages.activity import activity_page @@ -6,6 +7,8 @@ from lib.dashboard.sqlmodel_storage import SQLModelStorage DACHSHUND_EVENT_STORE = app.config.get("dachshund", "event_store", "dachshund.db") +# Only reachable through lib.dashboard.auth, which serves it publicly. +DASHBOARD_URL = URL("http://127.0.0.1:1213") def init_dachshund(): @@ -18,7 +21,8 @@ def start_dashboard(context: dict): dachshund.init( "Grace", description="Code Society's Discord Bot", - host="0.0.0.0", + host=DASHBOARD_URL.host, + port=DASHBOARD_URL.port, timezone="US/Eastern", context=context, ) diff --git a/lib/dashboard/pages/overview.py b/lib/dashboard/pages/overview.py index e5c9559..1b6cd64 100644 --- a/lib/dashboard/pages/overview.py +++ b/lib/dashboard/pages/overview.py @@ -1,5 +1,5 @@ from dachshund import Page -from dachshund.widgets import BarChart, Text, TimeSeriesChart +from dachshund.widgets import BarChart, Logs, Text, TimeSeriesChart from lib.dashboard.widgets.gauge import Gauge overview_page = Page("overview", title="Overview") @@ -25,6 +25,9 @@ TimeSeriesChart("latency", title="Live Latency", y=["check", "command"]) ) +with overview_page.row(): + overview_page.add(Logs("logs", title="Logs")) + with overview_page.row(): overview_page.add( BarChart("connection_event", title="Connection Events", x="type", persist=True) diff --git a/tests/lib/dashboard/auth/__init__.py b/tests/lib/dashboard/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/dashboard/auth/settings.py b/tests/lib/dashboard/auth/settings.py new file mode 100644 index 0000000..e697982 --- /dev/null +++ b/tests/lib/dashboard/auth/settings.py @@ -0,0 +1,10 @@ +from lib.dashboard.auth import AuthSettings + +SETTINGS = AuthSettings( + enabled=True, + guild_id=99, + client_id="1234", + client_secret="client-secret", + redirect_uri="https://grace.example/auth/callback", + session_secret="session-secret", +) diff --git a/tests/lib/dashboard/auth/test_oauth.py b/tests/lib/dashboard/auth/test_oauth.py new file mode 100644 index 0000000..8edcf0d --- /dev/null +++ b/tests/lib/dashboard/auth/test_oauth.py @@ -0,0 +1,108 @@ +from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlparse + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request + +from lib.dashboard.auth.oauth import DiscordOAuth +from lib.dashboard.auth.session import Sessions +from tests.lib.dashboard.auth.settings import SETTINGS + +SESSIONS = Sessions(SETTINGS.session_secret) +STATE = DiscordOAuth.STATE_COOKIE + + +@pytest.fixture +def oauth(): + return DiscordOAuth(SETTINGS, SESSIONS) + + +def _request(path="/", cookies=None): + headers = {} + + if cookies: + headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items()) + + return make_mocked_request("GET", path, headers=headers) + + +@pytest.mark.asyncio +async def test_login__expect_redirect_to_discord_with_state_cookie(oauth): + response = await oauth.login(_request("/auth/login?next=/activity")) + + location = urlparse(response.headers["Location"]) + query = parse_qs(location.query) + state, _, next_path = response.cookies[STATE].value.partition("|") + + assert response.status == 302 + assert f"{location.netloc}{location.path}" == "discord.com/oauth2/authorize" + assert query["client_id"] == ["1234"] + assert query["response_type"] == ["code"] + assert query["scope"] == ["identify"] + assert query["redirect_uri"] == [SETTINGS.redirect_uri] + assert query["state"] == [state] + assert next_path == "/activity" + + +@pytest.mark.asyncio +async def test_login__with_offsite_next__expect_home(oauth): + response = await oauth.login(_request("/auth/login?next=//evil.example")) + + assert response.cookies[STATE].value.endswith("|/") + + +@pytest.mark.asyncio +async def test_callback__with_matching_state__expect_session_and_redirect(oauth): + oauth.fetch_user_id = AsyncMock(return_value=42) + request = _request( + "/auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/activity"} + ) + + response = await oauth.callback(request) + + oauth.fetch_user_id.assert_awaited_once_with("abc") + assert response.status == 302 + assert response.headers["Location"] == "/activity" + assert SESSIONS.read(response.cookies[Sessions.COOKIE].value) == 42 + assert response.cookies[Sessions.COOKIE]["httponly"] is True + assert response.cookies[STATE].value == "" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path, cookies", + [ + ("/auth/callback?code=abc&state=wrong", {STATE: "xyz|/"}), + ("/auth/callback?code=abc&state=xyz", {}), + ("/auth/callback?state=xyz", {STATE: "xyz|/"}), + ("/auth/callback?code=abc&state=", {STATE: "|/"}), + ], +) +async def test_callback__with_bad_state_or_code__expect_bad_request( + oauth, path, cookies +): + oauth.fetch_user_id = AsyncMock(return_value=42) + + with pytest.raises(web.HTTPBadRequest): + await oauth.callback(_request(path, cookies=cookies)) + + oauth.fetch_user_id.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_callback__when_discord_fails__expect_bad_gateway(oauth): + oauth.fetch_user_id = AsyncMock(side_effect=OSError) + request = _request("/auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/"}) + + with pytest.raises(web.HTTPBadGateway): + await oauth.callback(request) + + +@pytest.mark.asyncio +async def test_logout__expect_session_cleared(oauth): + response = await oauth.logout(_request("/auth/logout")) + + assert response.status == 302 + assert response.headers["Location"] == "/" + assert response.cookies[Sessions.COOKIE].value == "" diff --git a/tests/lib/dashboard/auth/test_service.py b/tests/lib/dashboard/auth/test_service.py new file mode 100644 index 0000000..5bf6f99 --- /dev/null +++ b/tests/lib/dashboard/auth/test_service.py @@ -0,0 +1,239 @@ +from contextlib import asynccontextmanager +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from aiohttp import WSMsgType, web +from aiohttp.test_utils import TestClient, TestServer +from yarl import URL + +from lib.dashboard.auth import AuthService, AuthSettings +from lib.dashboard.auth.session import Sessions +from tests.lib.dashboard.auth.settings import SETTINGS + +ADMIN = 42 +STRANGER = 7 +ROLE = 5 +SESSIONS = Sessions(SETTINGS.session_secret) + + +def _member(administrator=False, roles=()): + return SimpleNamespace( + guild_permissions=SimpleNamespace(administrator=administrator), + roles=[SimpleNamespace(id=role) for role in roles], + ) + + +def _bot(members=None, guild=True): + members = {ADMIN: _member(administrator=True)} if members is None else members + guild_obj = MagicMock() + guild_obj.get_member.side_effect = members.get + + bot = MagicMock() + bot.get_guild.return_value = guild_obj if guild else None + return bot + + +def _service(bot=None, settings=SETTINGS, upstream=URL("http://upstream")): + return AuthService(bot or _bot(), upstream, settings) + + +def test_is_allowed__with_administrator__expect_true(): + bot = _bot() + + assert _service(bot).is_allowed(ADMIN) is True + bot.get_guild.assert_called_once_with(99) + + +def test_is_allowed__with_configured_role__expect_true(): + service = _service( + _bot({STRANGER: _member(roles=[1, ROLE])}), replace(SETTINGS, role_id=ROLE) + ) + + assert service.is_allowed(STRANGER) is True + + +def test_is_allowed__with_other_roles__expect_false(): + service = _service( + _bot({STRANGER: _member(roles=[1, 2])}), replace(SETTINGS, role_id=ROLE) + ) + + assert service.is_allowed(STRANGER) is False + + +def test_is_allowed__without_configured_role__expect_only_administrators(): + assert ( + _service(_bot({STRANGER: _member(roles=[ROLE])})).is_allowed(STRANGER) is False + ) + + +def test_is_allowed__when_not_in_guild__expect_false(): + assert _service().is_allowed(STRANGER) is False + + +def test_is_allowed__when_guild_unavailable__expect_false(): + assert _service(_bot(guild=False)).is_allowed(ADMIN) is False + + +def test_is_allowed__without_user__expect_false(): + assert _service().is_allowed(None) is False + + +async def _echo_ws(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + async for message in ws: + if message.type == WSMsgType.TEXT: + await ws.send_str(f"echo {message.data}") + + return ws + + +async def _echo(request): + return web.json_response( + { + "method": request.method, + "path": request.path_qs, + "body": (await request.read()).decode(), + "cookie": request.headers.get("Cookie"), + }, + headers={"X-Upstream": "yes"}, + ) + + +async def _moved(_request): + raise web.HTTPFound("/elsewhere") + + +def _upstream(): + app = web.Application() + app.router.add_get("/_ws", _echo_ws) + app.router.add_get("/moved", _moved) + app.router.add_route("*", "/{tail:.*}", _echo) + return app + + +@asynccontextmanager +async def _client(*, secured): + upstream = TestServer(_upstream()) + await upstream.start_server() + + settings = SETTINGS if secured else AuthSettings(enabled=False, guild_id=99) + app = _service( + upstream=URL(str(upstream.make_url("/"))), settings=settings + ).build_app() + client = TestClient(TestServer(app)) + await client.start_server() + + try: + yield client + finally: + await client.close() + await upstream.close() + + +def _session(user_id): + return {Sessions.COOKIE: SESSIONS.sign(user_id)} + + +@pytest.mark.asyncio +async def test_secured__without_session__page_expect_login_redirect(): + async with _client(secured=True) as client: + response = await client.get( + "/activity?x=1", headers={"Accept": "text/html"}, allow_redirects=False + ) + + location = URL(response.headers["Location"]) + assert response.status == 302 + assert location.path == "/auth/login" + assert location.query["next"] == "/activity?x=1" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["GET", "POST"]) +async def test_secured__without_session__request_expect_unauthorized(method): + async with _client(secured=True) as client: + response = await client.request(method, "/_widgets/logs/page") + + assert response.status == 401 + + +@pytest.mark.asyncio +async def test_secured__with_stranger_session__expect_unauthorized(): + async with _client(secured=True) as client: + response = await client.get("/_widgets/logs/page", cookies=_session(STRANGER)) + + assert response.status == 401 + + +@pytest.mark.asyncio +async def test_secured__without_session__websocket_expect_refused(): + async with _client(secured=True) as client: + with pytest.raises(Exception): + await client.ws_connect("/_ws") + + +@pytest.mark.asyncio +async def test_secured__login_route__expect_open_without_session(): + async with _client(secured=True) as client: + response = await client.get("/auth/login", allow_redirects=False) + + assert response.status == 302 + assert response.headers["Location"].startswith("https://discord.com/") + + +@pytest.mark.asyncio +async def test_secured__with_admin_session__expect_proxied(): + async with _client(secured=True) as client: + response = await client.post( + "/_actions/run?x=1", data="payload", cookies=_session(ADMIN) + ) + + body = await response.json() + assert response.status == 200 + assert response.headers["X-Upstream"] == "yes" + assert body["method"] == "POST" + assert body["path"] == "/_actions/run?x=1" + assert body["body"] == "payload" + + +@pytest.mark.asyncio +async def test_not_secured__expect_proxied_without_session(): + async with _client(secured=False) as client: + response = await client.get("/activity", headers={"Accept": "text/html"}) + + assert response.status == 200 + assert (await response.json())["path"] == "/activity" + + +@pytest.mark.asyncio +async def test_not_secured__login_route__expect_proxied(): + async with _client(secured=False) as client: + response = await client.get("/auth/login") + + assert (await response.json())["path"] == "/auth/login" + + +@pytest.mark.asyncio +async def test_proxy__upstream_redirect__expect_passed_through(): + async with _client(secured=False) as client: + response = await client.get("/moved", allow_redirects=False) + + assert response.status == 302 + assert response.headers["Location"] == "/elsewhere" + + +@pytest.mark.asyncio +async def test_proxy__websocket__expect_messages_both_ways(): + async with _client(secured=True) as client: + client.session.cookie_jar.update_cookies(_session(ADMIN)) + ws = await client.ws_connect("/_ws") + + await ws.send_str("hi") + message = await ws.receive(timeout=2) + await ws.close() + + assert message.type == WSMsgType.TEXT + assert message.data == "echo hi" diff --git a/tests/lib/dashboard/auth/test_session.py b/tests/lib/dashboard/auth/test_session.py new file mode 100644 index 0000000..aaf6b09 --- /dev/null +++ b/tests/lib/dashboard/auth/test_session.py @@ -0,0 +1,58 @@ +import pytest +from aiohttp.test_utils import make_mocked_request + +from lib.dashboard.auth.session import Sessions, safe_next + +SESSIONS = Sessions("session-secret") + + +def test_read__with_signed_cookie__expect_user_id(): + assert SESSIONS.read(SESSIONS.sign(42)) == 42 + + +@pytest.mark.parametrize("cookie", [None, "", "garbage", "42.9999999999.bad"]) +def test_read__with_invalid_cookie__expect_none(cookie): + assert SESSIONS.read(cookie) is None + + +def test_read__with_tampered_user__expect_none(): + _, expires_at, signature = SESSIONS.sign(42).split(".") + + assert SESSIONS.read(f"1.{expires_at}.{signature}") is None + + +def test_read__after_expiry__expect_none(): + cookie = SESSIONS.sign(42, now=0) + + assert SESSIONS.read(cookie, now=Sessions.TTL + 1) is None + + +def test_read__with_other_secret__expect_none(): + assert Sessions("rotated").read(SESSIONS.sign(42)) is None + + +def test_current_user__with_session_cookie__expect_user_id(): + cookie = f"{Sessions.COOKIE}={SESSIONS.sign(42)}" + request = make_mocked_request("GET", "/", headers={"Cookie": cookie}) + + assert SESSIONS.current_user(request) == 42 + + +def test_current_user__without_cookie__expect_none(): + assert SESSIONS.current_user(make_mocked_request("GET", "/")) is None + + +@pytest.mark.parametrize( + "value, expected", + [ + ("/activity", "/activity"), + ("/activity/tab?x=1", "/activity/tab?x=1"), + (None, "/"), + ("", "/"), + ("https://evil.example", "/"), + ("//evil.example", "/"), + ("/\\evil.example", "/"), + ], +) +def test_safe_next(value, expected): + assert safe_next(value) == expected diff --git a/tests/lib/dashboard/auth/test_settings.py b/tests/lib/dashboard/auth/test_settings.py new file mode 100644 index 0000000..ea031d7 --- /dev/null +++ b/tests/lib/dashboard/auth/test_settings.py @@ -0,0 +1,61 @@ +from configparser import ConfigParser +from dataclasses import replace + +import pytest + +from lib.dashboard.auth import AuthSettings +from lib.dashboard.auth.settings import REQUIRED +from tests.lib.dashboard.auth.settings import SETTINGS + +DASHBOARD = { + "client_id": "1234", + "client_secret": "client-secret", + "redirect_uri": "https://grace.example/auth/callback", + "session_secret": "session-secret", +} + + +class FakeConfig: + def __init__(self, environment=None, dashboard=None, guild_id=99): + parser = ConfigParser() + parser.read_dict({"env": environment or {}}) + self.environment = parser["env"] + self._values = {("client", "guild_id"): guild_id} + self._values |= {("dashboard", k): v for k, v in (dashboard or {}).items()} + + def get(self, section, key, *_): + return self._values.get((section, key)) + + +def test_from_config__expect_every_setting(): + config = FakeConfig({"dashboard_auth": "True"}, {**DASHBOARD, "role_id": 5}) + + assert AuthSettings.from_config(config) == replace(SETTINGS, role_id=5) + + +def test_from_config__without_flag__expect_enabled(): + assert AuthSettings.from_config(FakeConfig(dashboard=DASHBOARD)).enabled is True + + +def test_from_config__with_flag_off__expect_disabled_without_settings(): + settings = AuthSettings.from_config(FakeConfig({"dashboard_auth": "False"})) + + assert settings.enabled is False + assert settings.host == "127.0.0.1" + + +@pytest.mark.parametrize("role_id", [None, ""]) +def test_from_config__without_role__expect_none(role_id): + config = FakeConfig(dashboard={**DASHBOARD, "role_id": role_id}) + + assert AuthSettings.from_config(config).role_id is None + + +@pytest.mark.parametrize("key", REQUIRED) +def test_enabled__with_missing_setting__expect_error(key): + with pytest.raises(RuntimeError, match=key): + replace(SETTINGS, **{key: ""}) + + +def test_host__when_enabled__expect_every_interface(): + assert SETTINGS.host == "0.0.0.0" diff --git a/tests/lib/dashboard/pages/test_overview.py b/tests/lib/dashboard/pages/test_overview.py index b75f197..781cda2 100644 --- a/tests/lib/dashboard/pages/test_overview.py +++ b/tests/lib/dashboard/pages/test_overview.py @@ -1,13 +1,15 @@ from dachshund import Column, Row +from dachshund.widgets import Logs from lib.dashboard.pages.overview import overview_page from lib.dashboard.widgets.gauge import Gauge -def test_overview_page__expect_summary_then_latency_then_connections(): +def test_overview_page__expect_summary_then_latency_then_logs_then_connections(): assert overview_page.rows == [ Row([Column(["summary"])]), Row([Column(["average_latency_gauge"], 4), Column(["latency"])]), + Row([Column(["logs"])]), Row([Column(["connection_event"])]), ] @@ -18,3 +20,7 @@ def test_overview_page__with_average_latency_gauge__expect_gauge_widget_in_ms(): assert isinstance(gauge, Gauge) assert gauge.source == "average_latency" assert gauge.unit == "ms" + + +def test_overview_page__with_logs__expect_logs_widget(): + assert isinstance(overview_page.widgets["logs"], Logs) diff --git a/tests/lib/dashboard/test_dashboard.py b/tests/lib/dashboard/test_dashboard.py index b0fb953..60ffa60 100644 --- a/tests/lib/dashboard/test_dashboard.py +++ b/tests/lib/dashboard/test_dashboard.py @@ -16,6 +16,7 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): [ "summary", "latency", + "logs", "average_latency_gauge", "daily_message_rate", "member_count", From b742a875a1b26d4d75ebadd80d97d5bf06a2615f Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 01:46:36 -0500 Subject: [PATCH 03/11] Admin dashboard --- bot/grace.py | 2 +- config/environment.cfg | 2 +- lib/dashboard/auth/proxy.py | 3 +- lib/dashboard/auth/service.py | 38 +++--- lib/dashboard/auth/session.py | 13 +- lib/dashboard/dashboard.py | 7 +- lib/dashboard/pages/activity.py | 98 +++----------- lib/dashboard/pages/admin.py | 68 ++++++++++ lib/dashboard/pages/overview.py | 7 +- tests/lib/dashboard/auth/test_service.py | 144 ++++++++++++++++----- tests/lib/dashboard/auth/test_session.py | 2 + tests/lib/dashboard/pages/test_activity.py | 127 +++--------------- tests/lib/dashboard/pages/test_admin.py | 132 +++++++++++++++++++ tests/lib/dashboard/pages/test_overview.py | 12 +- tests/lib/dashboard/test_dashboard.py | 2 +- 15 files changed, 392 insertions(+), 265 deletions(-) create mode 100644 lib/dashboard/pages/admin.py create mode 100644 tests/lib/dashboard/pages/test_admin.py diff --git a/bot/grace.py b/bot/grace.py index c11a0f9..603ac5a 100644 --- a/bot/grace.py +++ b/bot/grace.py @@ -65,10 +65,10 @@ async def setup_hook(self): self.metrics = await DashboardMetrics.load() dashboard.init_dachshund() dashboard.build_dashboard() - dashboard.start_dashboard({"bot": self}) self.auth = AuthService( self, dashboard.DASHBOARD_URL, AuthSettings.from_config(self.app.config) ) + dashboard.start_dashboard({"bot": self}, **self.auth.dashboard_access()) await self.auth.start() async def close(self): diff --git a/config/environment.cfg b/config/environment.cfg index c9d3d77..a844d43 100644 --- a/config/environment.cfg +++ b/config/environment.cfg @@ -5,7 +5,7 @@ sqlalchemy_echo = False [development] log_level = INFO -dashboard_auth = False +dashboard_auth = True sqlalchemy_echo = False [test] diff --git a/lib/dashboard/auth/proxy.py b/lib/dashboard/auth/proxy.py index 40e0041..13be14f 100644 --- a/lib/dashboard/auth/proxy.py +++ b/lib/dashboard/auth/proxy.py @@ -6,8 +6,9 @@ from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType, web from yarl import URL +# Host is kept, so the dashboard builds its URLs, like a login's `next`, with +# the public address rather than its own. HOP_HEADERS = { - "host", "connection", "upgrade", "keep-alive", diff --git a/lib/dashboard/auth/service.py b/lib/dashboard/auth/service.py index b9854bc..97369b7 100644 --- a/lib/dashboard/auth/service.py +++ b/lib/dashboard/auth/service.py @@ -2,7 +2,6 @@ import logging from typing import Any -from urllib.parse import urlencode from aiohttp import web from yarl import URL @@ -16,13 +15,13 @@ class AuthService: - """Serves `upstream` on `PORT`, behind a Discord login when enabled. + """Serves `upstream` on `PORT`, with a Discord login when enabled. - Guild administrators get in, and members with the configured role. + The dashboard decides which pages are private and asks `authorize`, which + lets in guild administrators and members with the configured role. """ PORT = 1212 - PUBLIC_PREFIX = "/auth/" def __init__(self, bot: Any, upstream: URL, settings: AuthSettings) -> None: self.bot = bot @@ -43,27 +42,24 @@ def is_allowed(self, user_id: int | None) -> bool: role.id == self.settings.role_id for role in member.roles ) - @web.middleware - async def require_login(self, request: web.Request, handler): - # Runs on the bot's loop, so reading the member cache is safe, and - # losing access ends the session on the next request. - if request.path.startswith(self.PUBLIC_PREFIX) or self.is_allowed( - self.sessions.current_user(request) - ): - return await handler(request) + async def authorize(self, request: web.Request) -> bool: + """The dashboard's `authorize`, run on the bot's loop, so reading the + member cache is safe. Losing access ends the session on the next request. + """ + return self.is_allowed(self.sessions.current_user(request)) - if request.method == "GET" and "text/html" in request.headers.get("Accept", ""): - query = urlencode({"next": request.path_qs}) - raise web.HTTPFound(f"{self.oauth.LOGIN_PATH}?{query}") + def dashboard_access(self) -> dict[str, Any]: + """The `authorize` and `login_url` to start the dashboard with, none when the login is off.""" + if not self.settings.enabled: + return {} - raise web.HTTPUnauthorized() + return {"authorize": self.authorize, "login_url": self.oauth.LOGIN_PATH} def build_app(self) -> web.Application: - secured = self.settings.enabled - app = web.Application(middlewares=[self.require_login] if secured else []) + app = web.Application() app.cleanup_ctx.append(client_session) - if secured: + if self.settings.enabled: app.add_routes(self.oauth.routes()) app.add_routes([web.route("*", "/{tail:.*}", proxy_to(self.upstream))]) @@ -74,7 +70,9 @@ async def start(self) -> None: if not self.settings.enabled: logger.warning("Dashboard login is off, serving it on this machine only.") - self._runner = web.AppRunner(self.build_app()) + # No access log: dachshund already logs each request, and these lines, + # logged on the bot's thread, would feed the Logs widget its own refreshes. + self._runner = web.AppRunner(self.build_app(), access_log=None) await self._runner.setup() await web.TCPSite(self._runner, self.settings.host, self.PORT).start() diff --git a/lib/dashboard/auth/session.py b/lib/dashboard/auth/session.py index 2c54259..f6ed841 100644 --- a/lib/dashboard/auth/session.py +++ b/lib/dashboard/auth/session.py @@ -3,6 +3,7 @@ import hashlib import hmac import time +from urllib.parse import urlsplit from aiohttp import web @@ -51,11 +52,17 @@ def current_user(self, request: web.Request) -> int | None: def safe_next(value: str | None) -> str: - """`value` if it's a path on this site, `/` otherwise.""" - if not value or not value.startswith("/") or value.startswith(("//", "/\\")): + """The path and query of `value`, `/` if that isn't a path on this site. + + The dashboard sends its own full URL, behind the proxy, so only the path is kept. + """ + parts = urlsplit(value or "") + path = parts.path + + if not path.startswith("/") or path.startswith(("//", "/\\")): return "/" - return value + return f"{path}?{parts.query}" if parts.query else path def set_cookie(response: web.Response, name: str, value: str, max_age: int) -> None: diff --git a/lib/dashboard/dashboard.py b/lib/dashboard/dashboard.py index 4aa4971..50105c4 100644 --- a/lib/dashboard/dashboard.py +++ b/lib/dashboard/dashboard.py @@ -3,6 +3,7 @@ from bot import app from lib.dashboard.pages.activity import activity_page +from lib.dashboard.pages.admin import admin_page from lib.dashboard.pages.overview import overview_page from lib.dashboard.sqlmodel_storage import SQLModelStorage @@ -17,7 +18,8 @@ def init_dachshund(): dachshund.default(path=DACHSHUND_EVENT_STORE, storage=SQLModelStorage()) -def start_dashboard(context: dict): +def start_dashboard(context: dict, **access) -> None: + """Start dachshund, with `access` (`authorize`, `login_url`) guarding its private pages.""" dachshund.init( "Grace", description="Code Society's Discord Bot", @@ -25,9 +27,10 @@ def start_dashboard(context: dict): port=DASHBOARD_URL.port, timezone="US/Eastern", context=context, + **access, ) def build_dashboard() -> None: - for page in (overview_page, activity_page): + for page in (overview_page, activity_page, admin_page): dachshund.include(page) diff --git a/lib/dashboard/pages/activity.py b/lib/dashboard/pages/activity.py index 51bc47f..db9deb0 100644 --- a/lib/dashboard/pages/activity.py +++ b/lib/dashboard/pages/activity.py @@ -1,88 +1,31 @@ -from typing import Any, Callable - from dachshund import Page -from dachshund.widgets import BarChart, Number, Table, TableQuery -from discord import Member - -from bot import app - -activity_page = Page("activity", title="Activity") - - -MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = { - "id": lambda member: member.id, - "name": lambda member: member.name.lower(), - "nickname": lambda member: (member.nick or "").lower(), - "joined at": lambda member: member.joined_at, -} - - -def _member_row(member: Member) -> dict[str, Any]: - return { - "id": member.id, - "name": member.name, - "nickname": member.nick, - "joined at": member.joined_at.strftime("%a %d %b %Y"), - } - - -def _matches(member: Member, search: str) -> bool: - needle = search.lower() - haystack = (str(member.id), member.name, member.nick or "") - - return any(needle in value.lower() for value in haystack) - - -def load_members(ctx: dict, query: TableQuery) -> tuple[list[dict[str, Any]], int]: - guild = ctx["bot"].get_guild(app.config.get("client", "guild_id")) - members = list(guild.members) if guild else [] - - if query.search: - members = [member for member in members if _matches(member, query.search)] - - if sort_key := MEMBER_SORT_KEYS.get(query.sort_by): - members.sort(key=sort_key, reverse=query.descending) - - page = members[query.offset : query.offset + query.limit] - - return [_member_row(member) for member in page], len(members) +from dachshund.widgets import BarChart, Number +activity_page = Page("activity", title="Activity", public=True) with activity_page.row(): - with activity_page.column(span=4): - activity_page.add(Number("member_count")) - activity_page.add( - Number( - "active_members", - title="Active Today", - source="message", - y="author", - aggregate="distinct", - over="day", - ) + activity_page.add(Number("member_count")) + activity_page.add( + Number( + "active_members", + title="Active Today", + source="message", + y="author", + aggregate="distinct", + over="day", ) + ) activity_page.add( - Table( - "members", - columns=["id", "name", "nickname", "joined at"], - search=True, - page_size=5, - loader=load_members, + Number( + "daily_message_rate", + title="Today's Messages Rate", + source="message", + aggregate="count", + over="day", ) ) with activity_page.row(): - with activity_page.column(span=4): - activity_page.add( - Number( - "daily_message_rate", - title="Today's Messages Rate", - source="message", - aggregate="count", - over="day", - ) - ) - activity_page.add( BarChart( "weekly_message_counts", @@ -97,8 +40,3 @@ def load_members(ctx: dict, query: TableQuery) -> tuple[list[dict[str, Any]], in activity_page.add( BarChart("on_command", title="Calls Per Command", x="name", persist=True) ) - -with activity_page.row(): - activity_page.add( - BarChart("command_error", title="Errors Per Command", x="name", persist=True) - ) diff --git a/lib/dashboard/pages/admin.py b/lib/dashboard/pages/admin.py new file mode 100644 index 0000000..e888e73 --- /dev/null +++ b/lib/dashboard/pages/admin.py @@ -0,0 +1,68 @@ +from typing import Any, Callable + +from dachshund import Page +from dachshund.widgets import BarChart, Logs, Table, TableQuery +from discord import Member + +from bot import app + +admin_page = Page("admin", title="Admin") + + +MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = { + "id": lambda member: member.id, + "name": lambda member: member.name.lower(), + "nickname": lambda member: (member.nick or "").lower(), + "joined at": lambda member: member.joined_at, +} + + +def _member_row(member: Member) -> dict[str, Any]: + return { + "id": member.id, + "name": member.name, + "nickname": member.nick, + "joined at": member.joined_at.strftime("%a %d %b %Y"), + } + + +def _matches(member: Member, search: str) -> bool: + needle = search.lower() + haystack = (str(member.id), member.name, member.nick or "") + + return any(needle in value.lower() for value in haystack) + + +def load_members(ctx: dict, query: TableQuery) -> tuple[list[dict[str, Any]], int]: + guild = ctx["bot"].get_guild(app.config.get("client", "guild_id")) + members = list(guild.members) if guild else [] + + if query.search: + members = [member for member in members if _matches(member, query.search)] + + if sort_key := MEMBER_SORT_KEYS.get(query.sort_by): + members.sort(key=sort_key, reverse=query.descending) + + page = members[query.offset : query.offset + query.limit] + + return [_member_row(member) for member in page], len(members) + + +with admin_page.row(): + admin_page.add(Logs("logs", title="Logs")) + +with admin_page.row(): + admin_page.add( + Table( + "members", + columns=["id", "name", "nickname", "joined at"], + search=True, + page_size=5, + loader=load_members, + ) + ) + +with admin_page.row(): + admin_page.add( + BarChart("command_error", title="Errors Per Command", x="name", persist=True) + ) diff --git a/lib/dashboard/pages/overview.py b/lib/dashboard/pages/overview.py index 1b6cd64..94a3c30 100644 --- a/lib/dashboard/pages/overview.py +++ b/lib/dashboard/pages/overview.py @@ -1,8 +1,8 @@ from dachshund import Page -from dachshund.widgets import BarChart, Logs, Text, TimeSeriesChart +from dachshund.widgets import BarChart, Text, TimeSeriesChart from lib.dashboard.widgets.gauge import Gauge -overview_page = Page("overview", title="Overview") +overview_page = Page("overview", title="Overview", public=True) with overview_page.row(): overview_page.add(Text("summary")) @@ -25,9 +25,6 @@ TimeSeriesChart("latency", title="Live Latency", y=["check", "command"]) ) -with overview_page.row(): - overview_page.add(Logs("logs", title="Logs")) - with overview_page.row(): overview_page.add( BarChart("connection_event", title="Connection Events", x="type", persist=True) diff --git a/tests/lib/dashboard/auth/test_service.py b/tests/lib/dashboard/auth/test_service.py index 5bf6f99..d467ea9 100644 --- a/tests/lib/dashboard/auth/test_service.py +++ b/tests/lib/dashboard/auth/test_service.py @@ -1,15 +1,19 @@ +import asyncio +import logging from contextlib import asynccontextmanager from dataclasses import replace from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from aiohttp import WSMsgType, web -from aiohttp.test_utils import TestClient, TestServer +from aiohttp import ClientSession, WSMsgType, web +from aiohttp.test_utils import TestClient, TestServer, make_mocked_request +from dachshund import Dashboard, Page +from dachshund.widgets import Text from yarl import URL from lib.dashboard.auth import AuthService, AuthSettings -from lib.dashboard.auth.session import Sessions +from lib.dashboard.auth.session import Sessions, safe_next from tests.lib.dashboard.auth.settings import SETTINGS ADMIN = 42 @@ -98,6 +102,7 @@ async def _echo(request): "path": request.path_qs, "body": (await request.read()).decode(), "cookie": request.headers.get("Cookie"), + "host": request.host, }, headers={"X-Upstream": "yes"}, ) @@ -138,46 +143,98 @@ def _session(user_id): return {Sessions.COOKIE: SESSIONS.sign(user_id)} +def test_dashboard_access__when_enabled__expect_authorize_and_login_url(): + service = _service() + + assert service.dashboard_access() == { + "authorize": service.authorize, + "login_url": "/auth/login", + } + + +def test_dashboard_access__when_disabled__expect_nothing(): + service = _service(settings=AuthSettings(enabled=False, guild_id=99)) + + assert service.dashboard_access() == {} + + @pytest.mark.asyncio -async def test_secured__without_session__page_expect_login_redirect(): - async with _client(secured=True) as client: - response = await client.get( - "/activity?x=1", headers={"Accept": "text/html"}, allow_redirects=False - ) +@pytest.mark.parametrize( + "user_id, expected", [(None, False), (STRANGER, False), (ADMIN, True)] +) +async def test_authorize__expect_only_allowed_sessions(user_id, expected): + headers = {} - location = URL(response.headers["Location"]) - assert response.status == 302 - assert location.path == "/auth/login" - assert location.query["next"] == "/activity?x=1" + if user_id: + headers["Cookie"] = f"{Sessions.COOKIE}={SESSIONS.sign(user_id)}" + + request = make_mocked_request("GET", "/", headers=headers) + + assert await _service().authorize(request) is expected + + +@asynccontextmanager +async def _dashboard_client(): + """The service in front of a real dashboard with a public and a private page.""" + service = _service() + dashboard = Dashboard(path=":memory:", **service.dashboard_access()) + + public = Page("open", title="Open", public=True) + private = Page("admin", title="Admin") + + for page, widget in ((public, "hello"), (private, "secret")): + with page.row(): + page.add(Text(widget)) + + dashboard.include(public) + dashboard.include(private) + + upstream = TestServer(dashboard._create_app()) + await upstream.start_server() + service.upstream = URL(str(upstream.make_url("/"))) + + client = TestClient(TestServer(service.build_app())) + await client.start_server() + + try: + yield client + finally: + await client.close() + await upstream.close() @pytest.mark.asyncio -@pytest.mark.parametrize("method", ["GET", "POST"]) -async def test_secured__without_session__request_expect_unauthorized(method): - async with _client(secured=True) as client: - response = await client.request(method, "/_widgets/logs/page") +async def test_secured__public_page__expect_shown_without_session(): + async with _dashboard_client() as client: + response = await client.get("/open") - assert response.status == 401 + assert response.status == 200 @pytest.mark.asyncio -async def test_secured__with_stranger_session__expect_unauthorized(): - async with _client(secured=True) as client: - response = await client.get("/_widgets/logs/page", cookies=_session(STRANGER)) +@pytest.mark.parametrize("user_id", [None, STRANGER]) +async def test_secured__private_page__expect_login_redirect_back_to_it(user_id): + async with _dashboard_client() as client: + cookies = _session(user_id) if user_id else {} + response = await client.get("/admin", cookies=cookies, allow_redirects=False) - assert response.status == 401 + location = URL(response.headers["Location"]) + assert response.status == 302 + assert location.path == "/auth/login" + assert safe_next(location.query["next"]) == "/admin" @pytest.mark.asyncio -async def test_secured__without_session__websocket_expect_refused(): - async with _client(secured=True) as client: - with pytest.raises(Exception): - await client.ws_connect("/_ws") +async def test_secured__private_page__with_admin_session__expect_shown(): + async with _dashboard_client() as client: + response = await client.get("/admin", cookies=_session(ADMIN)) + + assert response.status == 200 @pytest.mark.asyncio -async def test_secured__login_route__expect_open_without_session(): - async with _client(secured=True) as client: +async def test_secured__login_route__expect_discord(): + async with _dashboard_client() as client: response = await client.get("/auth/login", allow_redirects=False) assert response.status == 302 @@ -185,10 +242,10 @@ async def test_secured__login_route__expect_open_without_session(): @pytest.mark.asyncio -async def test_secured__with_admin_session__expect_proxied(): - async with _client(secured=True) as client: +async def test_proxy__expect_request_forwarded_with_cookies(): + async with _client(secured=False) as client: response = await client.post( - "/_actions/run?x=1", data="payload", cookies=_session(ADMIN) + "/_actions/run?x=1", data="payload", cookies={"a": "b"} ) body = await response.json() @@ -197,6 +254,8 @@ async def test_secured__with_admin_session__expect_proxied(): assert body["method"] == "POST" assert body["path"] == "/_actions/run?x=1" assert body["body"] == "payload" + assert body["cookie"] == "a=b" + assert body["host"] == f"{client.host}:{client.port}" @pytest.mark.asyncio @@ -227,8 +286,7 @@ async def test_proxy__upstream_redirect__expect_passed_through(): @pytest.mark.asyncio async def test_proxy__websocket__expect_messages_both_ways(): - async with _client(secured=True) as client: - client.session.cookie_jar.update_cookies(_session(ADMIN)) + async with _client(secured=False) as client: ws = await client.ws_connect("/_ws") await ws.send_str("hi") @@ -237,3 +295,25 @@ async def test_proxy__websocket__expect_messages_both_ways(): assert message.type == WSMsgType.TEXT assert message.data == "echo hi" + + +@pytest.mark.asyncio +async def test_start__expect_no_access_log(caplog, monkeypatch): + service = _service(settings=AuthSettings(enabled=False, guild_id=99)) + monkeypatch.setattr(service, "PORT", 0) + # Test setup (alembic's fileConfig) disables existing loggers. + monkeypatch.setattr(logging.getLogger("aiohttp.access"), "disabled", False) + caplog.set_level(logging.INFO, logger="aiohttp.access") + await service.start() + + try: + host, port = service._runner.addresses[0][:2] + async with ClientSession() as http: + async with http.get(f"http://{host}:{port}/auth/login"): + pass + # The access line is written once the response is done. + await asyncio.sleep(0.05) + finally: + await service.stop() + + assert not [r for r in caplog.records if r.name == "aiohttp.access"] diff --git a/tests/lib/dashboard/auth/test_session.py b/tests/lib/dashboard/auth/test_session.py index aaf6b09..dda9e9c 100644 --- a/tests/lib/dashboard/auth/test_session.py +++ b/tests/lib/dashboard/auth/test_session.py @@ -52,6 +52,8 @@ def test_current_user__without_cookie__expect_none(): ("https://evil.example", "/"), ("//evil.example", "/"), ("/\\evil.example", "/"), + ("http://127.0.0.1:1213/admin?tab=x", "/admin?tab=x"), + ("javascript:alert(1)", "/"), ], ) def test_safe_next(value, expected): diff --git a/tests/lib/dashboard/pages/test_activity.py b/tests/lib/dashboard/pages/test_activity.py index f142234..e88c68f 100644 --- a/tests/lib/dashboard/pages/test_activity.py +++ b/tests/lib/dashboard/pages/test_activity.py @@ -1,20 +1,26 @@ -from datetime import datetime - import pytest from dachshund import Column, Row -from dachshund.widgets import BarChart, Number, Table, TableQuery +from dachshund.widgets import BarChart, Number + +from lib.dashboard.pages.activity import activity_page + -from lib.dashboard.pages.activity import activity_page, load_members +def test_activity_page__expect_public(): + assert activity_page.public is True -def test_activity_page__expect_members_beside_their_counts_then_messages(): +def test_activity_page__expect_counts_then_messages_then_commands(): assert activity_page.rows == [ - Row([Column(["member_count", "active_members"], 4), Column(["members"])]), - Row([Column(["daily_message_rate"], 4), Column(["weekly_message_counts"])]), + Row( + [ + Column(["member_count"]), + Column(["active_members"]), + Column(["daily_message_rate"]), + ] + ), + Row([Column(["weekly_message_counts"])]), Row([Column(["on_command"])]), - Row([Column(["command_error"])]), ] - assert isinstance(activity_page.widgets["daily_message_rate"], Number) @pytest.mark.parametrize( @@ -22,7 +28,6 @@ def test_activity_page__expect_members_beside_their_counts_then_messages(): [ ("weekly_message_counts", "date"), ("on_command", "name"), - ("command_error", "name"), ], ) def test_activity_page__expect_persisted_bar_charts(name, x): @@ -54,105 +59,3 @@ def test_activity_page__expect_todays_message_numbers_aggregated_from_messages( def test_activity_page__expect_messages_per_day_fed_by_messages(): assert activity_page.widgets["weekly_message_counts"].source == "message" - - -def test_activity_page__with_members_table__expect_a_loader(): - members = activity_page.widgets["members"] - - assert isinstance(members, Table) - assert members.loader is load_members - assert members.persist is False - assert members.actions == [] - - -def _fake_bot(guild): - return type("Bot", (), {"get_guild": staticmethod(lambda guild_id: guild)})() - - -def _fake_member(id, name, nick=None, joined_at=datetime(2025, 3, 14)): - return type( - "Member", (), {"id": id, "name": name, "nick": nick, "joined_at": joined_at} - )() - - -def _fake_guild(members): - return type("Guild", (), {"members": members})() - - -def test_load_members__expect_every_member_as_a_row(): - bot = _fake_bot(_fake_guild([_fake_member(1, "ada", nick="Ada L.")])) - - rows, total = load_members({"bot": bot}, TableQuery(limit=5)) - - assert rows == [ - { - "id": 1, - "name": "ada", - "nickname": "Ada L.", - "joined at": "Fri 14 Mar 2025", - } - ] - assert total == 1 - - -def test_load_members__with_offset_and_limit__expect_that_page_and_the_total(): - members = [_fake_member(i, f"user{i}") for i in range(3)] - bot = _fake_bot(_fake_guild(members)) - - first_rows, first_total = load_members({"bot": bot}, TableQuery(limit=2)) - second_rows, second_total = load_members( - {"bot": bot}, TableQuery(offset=2, limit=2) - ) - - assert [row["id"] for row in first_rows] == [0, 1] - assert [row["id"] for row in second_rows] == [2] - assert first_total == second_total == 3 - - -def test_load_members__with_search__expect_matching_name_or_nickname_only(): - members = [ - _fake_member(1, "ada", nick="Countess"), - _fake_member(2, "grace"), - _fake_member(3, "linus", nick="ADA fan"), - ] - bot = _fake_bot(_fake_guild(members)) - - rows, total = load_members({"bot": bot}, TableQuery(search="ada", limit=10)) - - assert [row["id"] for row in rows] == [1, 3] - assert total == 2 - - -def test_load_members__sorted_by_joined_at__expect_date_order_not_text_order(): - members = [ - _fake_member(1, "ada", joined_at=datetime(2025, 3, 14)), # "Fri ..." - _fake_member(2, "grace", joined_at=datetime(2024, 1, 1)), # "Mon ..." - _fake_member(3, "linus", joined_at=datetime(2025, 1, 2)), # "Thu ..." - ] - bot = _fake_bot(_fake_guild(members)) - - rows, _ = load_members( - {"bot": bot}, TableQuery(sort_by="joined at", descending=True, limit=10) - ) - - assert [row["id"] for row in rows] == [1, 3, 2] - - -def test_load_members__sorted_by_nickname__expect_missing_nicknames_first(): - members = [ - _fake_member(1, "ada", nick="zed"), - _fake_member(2, "grace"), - _fake_member(3, "linus", nick="Bob"), - ] - bot = _fake_bot(_fake_guild(members)) - - rows, _ = load_members({"bot": bot}, TableQuery(sort_by="nickname", limit=10)) - - assert [row["id"] for row in rows] == [2, 3, 1] - - -def test_load_members__with_no_guild__expect_no_rows(): - rows, total = load_members({"bot": _fake_bot(None)}, TableQuery(limit=5)) - - assert rows == [] - assert total == 0 diff --git a/tests/lib/dashboard/pages/test_admin.py b/tests/lib/dashboard/pages/test_admin.py new file mode 100644 index 0000000..7070b1d --- /dev/null +++ b/tests/lib/dashboard/pages/test_admin.py @@ -0,0 +1,132 @@ +from datetime import datetime + +from dachshund import Column, Row +from dachshund.widgets import BarChart, Logs, Table, TableQuery + +from lib.dashboard.pages.admin import admin_page, load_members + + +def test_admin_page__expect_private(): + assert admin_page.public is False + + +def test_admin_page__expect_logs_then_members_then_command_errors(): + assert admin_page.rows == [ + Row([Column(["logs"])]), + Row([Column(["members"])]), + Row([Column(["command_error"])]), + ] + + +def test_admin_page__with_logs__expect_logs_widget(): + assert isinstance(admin_page.widgets["logs"], Logs) + + +def test_admin_page__with_command_errors__expect_persisted_bar_chart(): + chart = admin_page.widgets["command_error"] + + assert isinstance(chart, BarChart) + assert chart.x == "name" + assert chart.persist is True + + +def test_admin_page__with_members_table__expect_a_loader(): + members = admin_page.widgets["members"] + + assert isinstance(members, Table) + assert members.loader is load_members + assert members.persist is False + assert members.actions == [] + + +def _fake_bot(guild): + return type("Bot", (), {"get_guild": staticmethod(lambda guild_id: guild)})() + + +def _fake_member(id, name, nick=None, joined_at=datetime(2025, 3, 14)): + return type( + "Member", (), {"id": id, "name": name, "nick": nick, "joined_at": joined_at} + )() + + +def _fake_guild(members): + return type("Guild", (), {"members": members})() + + +def test_load_members__expect_every_member_as_a_row(): + bot = _fake_bot(_fake_guild([_fake_member(1, "ada", nick="Ada L.")])) + + rows, total = load_members({"bot": bot}, TableQuery(limit=5)) + + assert rows == [ + { + "id": 1, + "name": "ada", + "nickname": "Ada L.", + "joined at": "Fri 14 Mar 2025", + } + ] + assert total == 1 + + +def test_load_members__with_offset_and_limit__expect_that_page_and_the_total(): + members = [_fake_member(i, f"user{i}") for i in range(3)] + bot = _fake_bot(_fake_guild(members)) + + first_rows, first_total = load_members({"bot": bot}, TableQuery(limit=2)) + second_rows, second_total = load_members( + {"bot": bot}, TableQuery(offset=2, limit=2) + ) + + assert [row["id"] for row in first_rows] == [0, 1] + assert [row["id"] for row in second_rows] == [2] + assert first_total == second_total == 3 + + +def test_load_members__with_search__expect_matching_name_or_nickname_only(): + members = [ + _fake_member(1, "ada", nick="Countess"), + _fake_member(2, "grace"), + _fake_member(3, "linus", nick="ADA fan"), + ] + bot = _fake_bot(_fake_guild(members)) + + rows, total = load_members({"bot": bot}, TableQuery(search="ada", limit=10)) + + assert [row["id"] for row in rows] == [1, 3] + assert total == 2 + + +def test_load_members__sorted_by_joined_at__expect_date_order_not_text_order(): + members = [ + _fake_member(1, "ada", joined_at=datetime(2025, 3, 14)), # "Fri ..." + _fake_member(2, "grace", joined_at=datetime(2024, 1, 1)), # "Mon ..." + _fake_member(3, "linus", joined_at=datetime(2025, 1, 2)), # "Thu ..." + ] + bot = _fake_bot(_fake_guild(members)) + + rows, _ = load_members( + {"bot": bot}, TableQuery(sort_by="joined at", descending=True, limit=10) + ) + + assert [row["id"] for row in rows] == [1, 3, 2] + + +def test_load_members__sorted_by_nickname__expect_missing_nicknames_first(): + members = [ + _fake_member(1, "ada", nick="zed"), + _fake_member(2, "grace"), + _fake_member(3, "linus", nick="Bob"), + ] + bot = _fake_bot(_fake_guild(members)) + + rows, _ = load_members({"bot": bot}, TableQuery(sort_by="nickname", limit=10)) + + assert [row["id"] for row in rows] == [2, 3, 1] + + +def test_load_members__with_no_guild__expect_no_rows(): + rows, total = load_members({"bot": _fake_bot(None)}, TableQuery(limit=5)) + + assert rows == [] + assert total == 0 diff --git a/tests/lib/dashboard/pages/test_overview.py b/tests/lib/dashboard/pages/test_overview.py index 781cda2..f78cb86 100644 --- a/tests/lib/dashboard/pages/test_overview.py +++ b/tests/lib/dashboard/pages/test_overview.py @@ -1,15 +1,17 @@ from dachshund import Column, Row -from dachshund.widgets import Logs from lib.dashboard.pages.overview import overview_page from lib.dashboard.widgets.gauge import Gauge -def test_overview_page__expect_summary_then_latency_then_logs_then_connections(): +def test_overview_page__expect_public(): + assert overview_page.public is True + + +def test_overview_page__expect_summary_then_latency_then_connections(): assert overview_page.rows == [ Row([Column(["summary"])]), Row([Column(["average_latency_gauge"], 4), Column(["latency"])]), - Row([Column(["logs"])]), Row([Column(["connection_event"])]), ] @@ -20,7 +22,3 @@ def test_overview_page__with_average_latency_gauge__expect_gauge_widget_in_ms(): assert isinstance(gauge, Gauge) assert gauge.source == "average_latency" assert gauge.unit == "ms" - - -def test_overview_page__with_logs__expect_logs_widget(): - assert isinstance(overview_page.widgets["logs"], Logs) diff --git a/tests/lib/dashboard/test_dashboard.py b/tests/lib/dashboard/test_dashboard.py index 60ffa60..a6cdb3d 100644 --- a/tests/lib/dashboard/test_dashboard.py +++ b/tests/lib/dashboard/test_dashboard.py @@ -11,7 +11,7 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): build_dashboard() - assert list(dashboard.pages) == ["overview", "activity"] + assert list(dashboard.pages) == ["overview", "activity", "admin"] assert sorted(dashboard.widgets) == sorted( [ "summary", From f76ee3315fc62cd5f1a6fce92d479affc658a7d3 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 02:04:56 -0500 Subject: [PATCH 04/11] Fix url and log spamming --- lib/dashboard/auth/oauth.py | 4 +-- lib/dashboard/auth/proxy.py | 19 ++++++++++--- lib/dashboard/auth/settings.py | 7 +++++ tests/lib/dashboard/auth/test_oauth.py | 6 ++--- tests/lib/dashboard/auth/test_service.py | 33 ++++++++++++++++++----- tests/lib/dashboard/auth/test_settings.py | 6 +++++ 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/lib/dashboard/auth/oauth.py b/lib/dashboard/auth/oauth.py index d4d6545..f67587c 100644 --- a/lib/dashboard/auth/oauth.py +++ b/lib/dashboard/auth/oauth.py @@ -15,9 +15,9 @@ class DiscordOAuth: """The login, callback and logout routes.""" - LOGIN_PATH = "/auth/login" + LOGIN_PATH = "/login" CALLBACK_PATH = "/auth/callback" - LOGOUT_PATH = "/auth/logout" + LOGOUT_PATH = "/logout" STATE_COOKIE = "grace_oauth_state" STATE_TTL = 10 * 60 diff --git a/lib/dashboard/auth/proxy.py b/lib/dashboard/auth/proxy.py index 13be14f..1ca835c 100644 --- a/lib/dashboard/auth/proxy.py +++ b/lib/dashboard/auth/proxy.py @@ -3,7 +3,13 @@ import asyncio from typing import Awaitable, Callable, Mapping -from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType, web +from aiohttp import ( + ClientSession, + ClientWebSocketResponse, + WSMsgType, + WSServerHandshakeError, + web, +) from yarl import URL # Host is kept, so the dashboard builds its URLs, like a login's `next`, with @@ -65,11 +71,18 @@ async def handler(request: web.Request) -> web.StreamResponse: async def _websocket( request: web.Request, session: ClientSession, target: URL -) -> web.WebSocketResponse: +) -> web.StreamResponse: + # The dashboard answers first, so a refusal, like a 401 for a private page, + # reaches the browser as is. + try: + server = await session.ws_connect(target, headers=_headers(request.headers)) + except WSServerHandshakeError as error: + return web.Response(status=error.status) + client = web.WebSocketResponse() await client.prepare(request) - async with session.ws_connect(target, headers=_headers(request.headers)) as server: + async with server: pumps = [ asyncio.create_task(_pump(client, server)), asyncio.create_task(_pump(server, client)), diff --git a/lib/dashboard/auth/settings.py b/lib/dashboard/auth/settings.py index 1ec956b..1aa5f7b 100644 --- a/lib/dashboard/auth/settings.py +++ b/lib/dashboard/auth/settings.py @@ -28,6 +28,13 @@ def __post_init__(self) -> None: if missing: raise RuntimeError(f"Missing dashboard settings: {', '.join(missing)}.") + # It goes in the login URL as is, so stray text, like an inline comment + # in .env, would break it. + if self.enabled and not self.client_id.isdigit(): + raise RuntimeError( + "The dashboard client_id must be the application's numeric id." + ) + @classmethod def from_config(cls, config: Any) -> "AuthSettings": def get(key: str) -> str: diff --git a/tests/lib/dashboard/auth/test_oauth.py b/tests/lib/dashboard/auth/test_oauth.py index 8edcf0d..881f7b5 100644 --- a/tests/lib/dashboard/auth/test_oauth.py +++ b/tests/lib/dashboard/auth/test_oauth.py @@ -29,7 +29,7 @@ def _request(path="/", cookies=None): @pytest.mark.asyncio async def test_login__expect_redirect_to_discord_with_state_cookie(oauth): - response = await oauth.login(_request("/auth/login?next=/activity")) + response = await oauth.login(_request("/login?next=/activity")) location = urlparse(response.headers["Location"]) query = parse_qs(location.query) @@ -47,7 +47,7 @@ async def test_login__expect_redirect_to_discord_with_state_cookie(oauth): @pytest.mark.asyncio async def test_login__with_offsite_next__expect_home(oauth): - response = await oauth.login(_request("/auth/login?next=//evil.example")) + response = await oauth.login(_request("/login?next=//evil.example")) assert response.cookies[STATE].value.endswith("|/") @@ -101,7 +101,7 @@ async def test_callback__when_discord_fails__expect_bad_gateway(oauth): @pytest.mark.asyncio async def test_logout__expect_session_cleared(oauth): - response = await oauth.logout(_request("/auth/logout")) + response = await oauth.logout(_request("/logout")) assert response.status == 302 assert response.headers["Location"] == "/" diff --git a/tests/lib/dashboard/auth/test_service.py b/tests/lib/dashboard/auth/test_service.py index d467ea9..7d50ff9 100644 --- a/tests/lib/dashboard/auth/test_service.py +++ b/tests/lib/dashboard/auth/test_service.py @@ -108,6 +108,10 @@ async def _echo(request): ) +async def _refuse_ws(_request): + raise web.HTTPUnauthorized() + + async def _moved(_request): raise web.HTTPFound("/elsewhere") @@ -116,6 +120,7 @@ def _upstream(): app = web.Application() app.router.add_get("/_ws", _echo_ws) app.router.add_get("/moved", _moved) + app.router.add_get("/_ws_private", _refuse_ws) app.router.add_route("*", "/{tail:.*}", _echo) return app @@ -148,7 +153,7 @@ def test_dashboard_access__when_enabled__expect_authorize_and_login_url(): assert service.dashboard_access() == { "authorize": service.authorize, - "login_url": "/auth/login", + "login_url": "/login", } @@ -220,7 +225,7 @@ async def test_secured__private_page__expect_login_redirect_back_to_it(user_id): location = URL(response.headers["Location"]) assert response.status == 302 - assert location.path == "/auth/login" + assert location.path == "/login" assert safe_next(location.query["next"]) == "/admin" @@ -235,7 +240,7 @@ async def test_secured__private_page__with_admin_session__expect_shown(): @pytest.mark.asyncio async def test_secured__login_route__expect_discord(): async with _dashboard_client() as client: - response = await client.get("/auth/login", allow_redirects=False) + response = await client.get("/login", allow_redirects=False) assert response.status == 302 assert response.headers["Location"].startswith("https://discord.com/") @@ -270,9 +275,9 @@ async def test_not_secured__expect_proxied_without_session(): @pytest.mark.asyncio async def test_not_secured__login_route__expect_proxied(): async with _client(secured=False) as client: - response = await client.get("/auth/login") + response = await client.get("/login") - assert (await response.json())["path"] == "/auth/login" + assert (await response.json())["path"] == "/login" @pytest.mark.asyncio @@ -309,7 +314,7 @@ async def test_start__expect_no_access_log(caplog, monkeypatch): try: host, port = service._runner.addresses[0][:2] async with ClientSession() as http: - async with http.get(f"http://{host}:{port}/auth/login"): + async with http.get(f"http://{host}:{port}/login"): pass # The access line is written once the response is done. await asyncio.sleep(0.05) @@ -317,3 +322,19 @@ async def test_start__expect_no_access_log(caplog, monkeypatch): await service.stop() assert not [r for r in caplog.records if r.name == "aiohttp.access"] + + +@pytest.mark.asyncio +async def test_proxy__websocket_refused__expect_status_passed_through(): + async with _client(secured=False) as client: + response = await client.get( + "/_ws_private", + headers={ + "Upgrade": "websocket", + "Connection": "Upgrade", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + }, + ) + + assert response.status == 401 diff --git a/tests/lib/dashboard/auth/test_settings.py b/tests/lib/dashboard/auth/test_settings.py index ea031d7..732ed64 100644 --- a/tests/lib/dashboard/auth/test_settings.py +++ b/tests/lib/dashboard/auth/test_settings.py @@ -59,3 +59,9 @@ def test_enabled__with_missing_setting__expect_error(key): def test_host__when_enabled__expect_every_interface(): assert SETTINGS.host == "0.0.0.0" + + +@pytest.mark.parametrize("client_id", ["1234# Developer portal", "abc", "12 34"]) +def test_enabled__with_non_numeric_client_id__expect_error(client_id): + with pytest.raises(RuntimeError, match="client_id"): + replace(SETTINGS, client_id=client_id) From 4a060c5cb948de0943db67f0700165e4cae125ac Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 03:44:29 -0500 Subject: [PATCH 05/11] Completely reworked dashboard --- bot/extensions/dashboard_cog.py | 208 ++++++++- bot/extensions/moderation_cog.py | 12 + bot/extensions/name_moderation_cog.py | 2 + bot/grace.py | 2 + bot/helpers/dashboard_helper.py | 34 ++ bot/services/process_stats_service.py | 60 +++ lib/dashboard/handlers/__init__.py | 4 + lib/dashboard/handlers/extensions.py | 83 ++++ lib/dashboard/handlers/jobs.py | 25 ++ lib/dashboard/handlers/members.py | 48 +++ lib/dashboard/handlers/paging.py | 27 ++ lib/dashboard/pages/activity.py | 76 +++- lib/dashboard/pages/admin.py | 182 ++++++-- lib/dashboard/pages/overview.py | 5 +- lib/dashboard/widgets/recent_table.py | 45 ++ lib/dashboard/widgets/stat.css | 20 + lib/dashboard/widgets/stat.js | 27 ++ lib/dashboard/widgets/stat.py | 39 ++ lib/dashboard/widgets/top_bar_chart.py | 40 ++ tests/extensions/test_dashboard_cog.py | 406 +++++++++++++++++- tests/extensions/test_moderation_cog.py | 73 ++++ tests/extensions/test_name_moderation_cog.py | 10 +- tests/helpers/__init__.py | 0 tests/helpers/test_dashboard_helper.py | 32 ++ tests/lib/dashboard/handlers/__init__.py | 0 .../lib/dashboard/handlers/test_extensions.py | 121 ++++++ tests/lib/dashboard/handlers/test_jobs.py | 51 +++ tests/lib/dashboard/handlers/test_members.py | 96 +++++ tests/lib/dashboard/handlers/test_paging.py | 47 ++ tests/lib/dashboard/pages/test_activity.py | 60 ++- tests/lib/dashboard/pages/test_admin.py | 188 ++++---- tests/lib/dashboard/test_dashboard.py | 26 +- .../dashboard/widgets/test_recent_table.py | 45 ++ tests/lib/dashboard/widgets/test_stat.py | 24 ++ .../dashboard/widgets/test_top_bar_chart.py | 30 ++ tests/services/test_process_stats_service.py | 29 ++ 36 files changed, 2024 insertions(+), 153 deletions(-) create mode 100644 bot/helpers/dashboard_helper.py create mode 100644 bot/services/process_stats_service.py create mode 100644 lib/dashboard/handlers/__init__.py create mode 100644 lib/dashboard/handlers/extensions.py create mode 100644 lib/dashboard/handlers/jobs.py create mode 100644 lib/dashboard/handlers/members.py create mode 100644 lib/dashboard/handlers/paging.py create mode 100644 lib/dashboard/widgets/recent_table.py create mode 100644 lib/dashboard/widgets/stat.css create mode 100644 lib/dashboard/widgets/stat.js create mode 100644 lib/dashboard/widgets/stat.py create mode 100644 lib/dashboard/widgets/top_bar_chart.py create mode 100644 tests/extensions/test_moderation_cog.py create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/test_dashboard_helper.py create mode 100644 tests/lib/dashboard/handlers/__init__.py create mode 100644 tests/lib/dashboard/handlers/test_extensions.py create mode 100644 tests/lib/dashboard/handlers/test_jobs.py create mode 100644 tests/lib/dashboard/handlers/test_members.py create mode 100644 tests/lib/dashboard/handlers/test_paging.py create mode 100644 tests/lib/dashboard/widgets/test_recent_table.py create mode 100644 tests/lib/dashboard/widgets/test_stat.py create mode 100644 tests/lib/dashboard/widgets/test_top_bar_chart.py create mode 100644 tests/services/test_process_stats_service.py diff --git a/bot/extensions/dashboard_cog.py b/bot/extensions/dashboard_cog.py index 0fd334b..9107827 100644 --- a/bot/extensions/dashboard_cog.py +++ b/bot/extensions/dashboard_cog.py @@ -1,12 +1,36 @@ -from datetime import datetime, tzinfo +import os +import platform +import subprocess +import time +from datetime import date, datetime, timedelta, tzinfo import dachshund +import discord from apscheduler.job import Job -from discord import Member, Message -from discord.ext.commands import Cog, Context +from discord import Member, Message, RawReactionActionEvent +from discord.ext.commands import ( + CommandInvokeError, + CommandNotFound, + Cog, + Context, + HybridCommandError, +) from pytz import timezone from bot.grace import Grace +from bot.helpers.dashboard_helper import record_moderation, timestamp, today +from bot.services.process_stats_service import ProcessStats + +STARTED_AT = "_dashboard_started_at" +# 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 + +# Their commands go to the recent moderation table instead of recent commands. +MODERATION_COGS = {"Moderation"} +ERROR_LENGTH = 300 SUMMARY = """ # Welcome! @@ -19,6 +43,76 @@ """ +def _channel_name(message: Message) -> str: + """The channel a message counts toward, as shown publicly. + + A thread counts toward its parent, a DM toward "DM", and a channel + @everyone can't see toward "private", so its name stays hidden. + """ + channel = getattr(message.channel, "parent", None) or message.channel + guild = getattr(channel, "guild", None) + + if guild is None: + return "DM" + + if not channel.permissions_for(guild.default_role).view_channel: + return "private" + + return channel.name + + +def _commit() -> str: + """The deployed commit: Railway's, or the local checkout's.""" + if sha := os.environ.get("RAILWAY_GIT_COMMIT_SHA"): + return sha[:7] + + try: + return subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + timeout=2, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return "unknown" + + +def _emoji_name(payload: RawReactionActionEvent) -> str: + """A unicode emoji as is, a custom one as `:name:`.""" + emoji = payload.emoji + + if emoji.is_custom_emoji(): + return f":{emoji.name or 'custom'}:" + + return str(emoji) + + +def _is_moderation(ctx: Context) -> bool: + """Whether `ctx`'s command belongs to a moderation cog.""" + cog = getattr(ctx, "cog", None) + + return cog is not None and cog.qualified_name in MODERATION_COGS + + +def _elapsed_ms(ctx: Context) -> float | None: + """Time since `on_command` saw `ctx`, `None` if it didn't.""" + started_at = getattr(ctx, STARTED_AT, None) + + if started_at is None: + return None + + return round((time.perf_counter() - started_at) * 1000, 1) + + +def _describe(error: Exception) -> str: + """`error`'s type and message, unwrapped from discord.py's wrappers.""" + while isinstance(error, (CommandInvokeError, HybridCommandError)): + error = error.original + + return f"{type(error).__name__}: {error}"[:ERROR_LENGTH] + + class DashboardCog( Cog, name="Dashboard", description="Feeds bot telemetry to the live dashboard" ): @@ -28,13 +122,31 @@ def __init__(self, bot: Grace): self.bot: Grace = bot self.jobs: list[Job] = [] self.timezone: tzinfo = timezone("US/Eastern") + self.process = ProcessStats() + self.commit = _commit() + self.gateway_events = 0 def cog_load(self) -> None: dachshund.emit("summary", value=SUMMARY) + self._report_process() self.jobs.append( self.bot.scheduler.add_job(self._report_latency, "interval", minutes=1) ) + self.jobs.append( + self.bot.scheduler.add_job(self._report_process, "interval", minutes=1) + ) + + self._trim_daily_charts() + self.jobs.append( + self.bot.scheduler.add_job( + self._trim_daily_charts, + "cron", + hour=0, + minute=5, + timezone=self.timezone, + ) + ) def cog_unload(self) -> None: for job in self.jobs: @@ -43,6 +155,35 @@ def cog_unload(self) -> None: def _report_latency(self) -> None: self.bot.metrics.record_latency(self.bot.latency_ms) + def _report_process(self) -> None: + dachshund.emit("process", **self.process.sample()) + dachshund.emit("deployment", **self._deployment()) + + dachshund.emit("gateway_events", value=self.gateway_events) + self.gateway_events = 0 + + def _trim_daily_charts(self, 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 _deployment(self) -> dict[str, str]: + started = datetime.fromtimestamp(self.process.started_at, self.timezone) + + return { + "environment": self.bot.app.config.current_environment, + "commit": self.commit, + "started": f"{started:%Y-%m-%d %H:%M %Z}", + "uptime": self.process.uptime(), + "discord_py": discord.__version__, + "python": platform.python_version(), + } + def _report_member_count(self) -> None: guild_id = self.bot.app.config.get("client", "guild_id") @@ -58,11 +199,25 @@ async def on_ready(self) -> None: async def on_member_join(self, _member: Member) -> None: self._report_member_count() dachshund.emit("members") + dachshund.emit("member_flow", joined=1, left=0) @Cog.listener() async def on_member_remove(self, _member: Member) -> None: self._report_member_count() dachshund.emit("members") + dachshund.emit("member_flow", joined=0, left=1) + + @Cog.listener() + async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: + if payload.guild_id is None or (payload.member and payload.member.bot): + return + + dachshund.emit("reactions_per_day", date=today(), value=1) + dachshund.emit("reaction_emojis", emoji=_emoji_name(payload), value=1) + + @Cog.listener() + async def on_socket_event_type(self, _event_type: str) -> None: + self.gateway_events += 1 @Cog.listener() async def on_message(self, message: Message) -> None: @@ -70,19 +225,66 @@ async def on_message(self, message: Message) -> None: "message", date=datetime.now(self.timezone).date().isoformat(), author=None if message.author.bot else str(message.author.id), + channel=_channel_name(message), value=1, ) @Cog.listener() async def on_command(self, ctx: Context) -> None: + setattr(ctx, STARTED_AT, time.perf_counter()) + if ctx.command: dachshund.emit("on_command", name=ctx.command.qualified_name, value=1) + dachshund.emit("commands_per_day", date=today(), value=1) + + @Cog.listener() + async def on_command_completion(self, ctx: Context) -> None: + elapsed_ms = _elapsed_ms(ctx) + + if ctx.command and elapsed_ms is not None: + dachshund.emit( + "command_duration", name=ctx.command.qualified_name, value=elapsed_ms + ) + + dachshund.emit("command_outcome", failed=0) + + # Moderation commands log their own row, with details. + if not _is_moderation(ctx): + self._record_command(ctx, "ok", elapsed_ms) @Cog.listener() async def on_command_error(self, ctx: Context, error: Exception) -> None: name = ctx.command.qualified_name if ctx.command else "unknown" dachshund.emit("command_error", name=name, value=1) + # Typos of command names would bury the commands actually run. + if isinstance(error, CommandNotFound): + return + + # 100 per failure, so the average over a window is the error rate in %. + dachshund.emit("command_outcome", failed=100) + + if _is_moderation(ctx): + record_moderation( + f"{name} (failed)", by=str(ctx.author), details=_describe(error) + ) + else: + self._record_command(ctx, "error", _elapsed_ms(ctx), _describe(error)) + + def _record_command( + self, ctx: Context, status: str, elapsed_ms: float | None, error: str = "" + ) -> None: + """A row of the recent commands table.""" + dachshund.emit( + "recent_commands", + time=timestamp(), + command=ctx.command.qualified_name if ctx.command else "unknown", + user=str(ctx.author), + status=status, + ms=elapsed_ms, + error=error, + ) + @Cog.listener() async def on_disconnect(self) -> None: dachshund.emit("connection_event", type="disconnect", value=1) diff --git a/bot/extensions/moderation_cog.py b/bot/extensions/moderation_cog.py index 8418168..c315cef 100644 --- a/bot/extensions/moderation_cog.py +++ b/bot/extensions/moderation_cog.py @@ -8,6 +8,7 @@ from emoji import demojize from bot import app +from bot.helpers.dashboard_helper import record_moderation from bot.helpers.log_helper import danger, notice from bot.models.channel import Channel @@ -45,6 +46,11 @@ async def purge( log.add_field("Reason", reason) await ctx.channel.purge(limit=int(limit) + 1, bulk=True, reason=reason) + record_moderation( + "purge", + by=str(ctx.author), + details=f"{limit} message(s) in #{ctx.channel} · {reason}", + ) await log.send(self.moderation_channel or ctx.channel) @Cog.listener() @@ -114,6 +120,12 @@ async def on_member_join(self, member) -> None: f"Your account needs to be {minimum_account_age} days old or more to join the server." ) await member.guild.kick(user=member, reason="Account age restriction") + record_moderation( + "kick", + member=str(member), + details=f"account {account_age_in_days} day(s) old, " + f"minimum {minimum_account_age}", + ) if self.moderation_channel: await log.send(self.moderation_channel) diff --git a/bot/extensions/name_moderation_cog.py b/bot/extensions/name_moderation_cog.py index e84d7ae..451b9b9 100644 --- a/bot/extensions/name_moderation_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -6,6 +6,7 @@ from bot import app from bot.grace import Grace +from bot.helpers.dashboard_helper import record_moderation from bot.helpers.log_helper import notice from bot.services.random_name_service import make_random_name @@ -47,6 +48,7 @@ async def on_member_join(self, member: Member) -> None: try: good_name = make_random_name() await member.edit(nick=good_name) + record_moderation("rename", member=name, details=f"renamed to {good_name}") log = notice("NAME", f"Username of {name} was changed.") log.add_field( diff --git a/bot/grace.py b/bot/grace.py index 603ac5a..ed9e842 100644 --- a/bot/grace.py +++ b/bot/grace.py @@ -19,6 +19,8 @@ def __init__(self, app): app, intents=Intents.all(), activity=Activity(type=ActivityType.playing, name="::help"), + # For on_socket_event_type, which the dashboard counts. + enable_debug_events=True, ) self.help_command = PrettyHelp(color=self.default_color) diff --git a/bot/helpers/dashboard_helper.py b/bot/helpers/dashboard_helper.py new file mode 100644 index 0000000..f882827 --- /dev/null +++ b/bot/helpers/dashboard_helper.py @@ -0,0 +1,34 @@ +"""Rows for the dashboard's recent commands and moderation tables.""" + +from datetime import datetime + +import dachshund +from pytz import timezone + +TIMEZONE = timezone("US/Eastern") + +MODERATION_SOURCE = "recent_moderation" + + +def today() -> str: + """Today's date, the day starting at midnight Eastern.""" + return datetime.now(TIMEZONE).date().isoformat() + + +def timestamp() -> str: + """Now, as the dashboard's tables show it.""" + return datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S") + + +def record_moderation( + action: str, *, member: str = "", by: str = "auto", details: str = "" +) -> None: + """A row of the recent moderation table: `by` a moderator, or "auto" for Grace's own.""" + dachshund.emit( + MODERATION_SOURCE, + time=timestamp(), + action=action, + member=member, + by=by, + details=details, + ) diff --git a/bot/services/process_stats_service.py b/bot/services/process_stats_service.py new file mode 100644 index 0000000..b84cf52 --- /dev/null +++ b/bot/services/process_stats_service.py @@ -0,0 +1,60 @@ +import os +import resource +import time +from dataclasses import dataclass, field + +# Set on first import, which is once per process: reloading a cog keeps it. +PROCESS_STARTED_AT = time.time() + + +def memory_mb() -> float: + """The process's resident memory, in MB. + + Read from /proc on Linux; elsewhere, the peak, the closest the standard + library offers. + """ + try: + with open("/proc/self/statm") as statm: + pages = int(statm.read().split()[1]) + + return pages * os.sysconf("SC_PAGE_SIZE") / 1024**2 + except (OSError, ValueError, IndexError): + peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + # KB on Linux, bytes on macOS. + return peak / 1024**2 if os.uname().sysname == "Darwin" else peak / 1024 + + +def _cpu_seconds() -> float: + times = os.times() + return times.user + times.system + + +@dataclass +class ProcessStats: + """CPU use since the previous sample, and memory, of this process.""" + + started_at: float = PROCESS_STARTED_AT + _cpu: float = field(default_factory=_cpu_seconds) + _at: float = field(default_factory=time.monotonic) + + def sample(self) -> dict[str, float]: + """Memory in MB, and CPU as a percent of one core since the last sample.""" + cpu, at = _cpu_seconds(), time.monotonic() + elapsed = at - self._at + percent = (cpu - self._cpu) / elapsed * 100 if elapsed > 0 else 0.0 + self._cpu, self._at = cpu, at + + return { + "memory_mb": round(memory_mb(), 1), + "cpu_percent": round(percent, 1), + } + + def uptime(self, now: float | None = None) -> str: + """How long the process has run, like "2 d 3 h 4 min".""" + seconds = int((time.time() if now is None else now) - self.started_at) + days, seconds = divmod(seconds, 86400) + hours, seconds = divmod(seconds, 3600) + minutes = seconds // 60 + parts = [(days, "d"), (hours, "h"), (minutes, "min")] + + return " ".join(f"{n} {unit}" for n, unit in parts if n) or "0 min" diff --git a/lib/dashboard/handlers/__init__.py b/lib/dashboard/handlers/__init__.py new file mode 100644 index 0000000..eaeebd1 --- /dev/null +++ b/lib/dashboard/handlers/__init__.py @@ -0,0 +1,4 @@ +"""What dachshund calls back: table loaders `(ctx, query)` and actions `(ctx, ...)`. + +Pages register them; the layout lives in `lib.dashboard.pages`. +""" diff --git a/lib/dashboard/handlers/extensions.py b/lib/dashboard/handlers/extensions.py new file mode 100644 index 0000000..836e3a3 --- /dev/null +++ b/lib/dashboard/handlers/extensions.py @@ -0,0 +1,83 @@ +"""The extension tables and the actions moving an extension between them. + +Async, so they run on the bot's loop, like the database queries of its cogs. +""" + +from typing import Any + +import dachshund +from dachshund.widgets import TableQuery +from discord.ext.commands import ExtensionAlreadyLoaded, ExtensionNotLoaded + +from bot.classes.state import State +from bot.models.extension import Extension +from lib.dashboard.handlers.paging import Page, page_rows + +COLUMNS = ["extension", "module", "loaded"] + +# Both tables watch this source, so either action refreshes them. +SOURCE = "extensions" + + +def _rows(bot: Any, state: State) -> list[dict[str, Any]]: + loaded = bot.extensions + + return [ + { + "extension": extension.name, + "module": extension.short_module_name, + "loaded": "yes" if extension.module_name in loaded else "no", + # Reaches the actions as `module_name`. + "_module_name": extension.module_name, + } + for extension in Extension.by_state(state).all() + ] + + +async def load_enabled(ctx: dict, query: TableQuery) -> Page: + """The enabled extensions, with whether each is actually loaded.""" + return page_rows(_rows(ctx["bot"], State.ENABLED), query, COLUMNS) + + +async def load_disabled(ctx: dict, query: TableQuery) -> Page: + """The disabled extensions, with whether each is actually loaded.""" + return page_rows(_rows(ctx["bot"], State.DISABLED), query, COLUMNS) + + +def _extension(module_name: str) -> Extension: + extension = Extension.find_by(module_name=module_name) + + if extension is None: + raise ValueError(f"No extension {module_name!r}.") + + return extension + + +async def enable(ctx: dict, module_name: str) -> str: + """Load an extension and keep it enabled across restarts.""" + extension = _extension(module_name) + + try: + await ctx["bot"].load_extension(extension.module_name) + except ExtensionAlreadyLoaded: + pass + + extension.enable() + dachshund.emit(SOURCE) + + return f"{extension.name} enabled." + + +async def disable(ctx: dict, module_name: str) -> str: + """Unload an extension and keep it disabled across restarts.""" + extension = _extension(module_name) + + try: + await ctx["bot"].unload_extension(extension.module_name) + except ExtensionNotLoaded: + pass + + extension.disable() + dachshund.emit(SOURCE) + + return f"{extension.name} disabled." diff --git a/lib/dashboard/handlers/jobs.py b/lib/dashboard/handlers/jobs.py new file mode 100644 index 0000000..f286769 --- /dev/null +++ b/lib/dashboard/handlers/jobs.py @@ -0,0 +1,25 @@ +from dachshund.widgets import TableQuery +from pytz import timezone + +from lib.dashboard.handlers.paging import Page, page_rows + +COLUMNS = ["job", "trigger", "next run"] +TIMEZONE = timezone("US/Eastern") + + +async def load(ctx: dict, query: TableQuery) -> Page: + """The scheduler's jobs, like pending reminders and recurring thread posts.""" + rows = [ + { + "job": job.name, + "trigger": str(job.trigger), + "next run": ( + job.next_run_time.astimezone(TIMEZONE).strftime("%Y-%m-%d %H:%M") + if job.next_run_time + else "paused" + ), + } + for job in ctx["bot"].scheduler.get_jobs() + ] + + return page_rows(rows, query, COLUMNS) diff --git a/lib/dashboard/handlers/members.py b/lib/dashboard/handlers/members.py new file mode 100644 index 0000000..9aa997e --- /dev/null +++ b/lib/dashboard/handlers/members.py @@ -0,0 +1,48 @@ +from typing import Any, Callable + +from dachshund.widgets import TableQuery +from discord import Member + +from bot import app +from lib.dashboard.handlers.paging import Page + +COLUMNS = ["id", "name", "nickname", "joined at"] + +SORT_KEYS: dict[str, Callable[[Member], Any]] = { + "id": lambda member: member.id, + "name": lambda member: member.name.lower(), + "nickname": lambda member: (member.nick or "").lower(), + "joined at": lambda member: member.joined_at, +} + + +def _row(member: Member) -> dict[str, Any]: + return { + "id": member.id, + "name": member.name, + "nickname": member.nick, + "joined at": member.joined_at.strftime("%a %d %b %Y"), + } + + +def _matches(member: Member, search: str) -> bool: + needle = search.lower() + haystack = (str(member.id), member.name, member.nick or "") + + return any(needle in value.lower() for value in haystack) + + +def load(ctx: dict, query: TableQuery) -> Page: + """The guild's members, sorted by their own values rather than their text.""" + guild = ctx["bot"].get_guild(app.config.get("client", "guild_id")) + members = list(guild.members) if guild else [] + + if query.search: + members = [member for member in members if _matches(member, query.search)] + + if sort_key := SORT_KEYS.get(query.sort_by): + members.sort(key=sort_key, reverse=query.descending) + + page = members[query.offset : query.offset + query.limit] + + return [_row(member) for member in page], len(members) diff --git a/lib/dashboard/handlers/paging.py b/lib/dashboard/handlers/paging.py new file mode 100644 index 0000000..a62c20e --- /dev/null +++ b/lib/dashboard/handlers/paging.py @@ -0,0 +1,27 @@ +from typing import Any + +from dachshund.widgets import TableQuery + +Page = tuple[list[dict[str, Any]], int] + + +def page_rows( + rows: list[dict[str, Any]], query: TableQuery, columns: list[str] +) -> Page: + """`query`'s page of `rows`: searched across `columns`, sorted, then sliced.""" + if query.search: + needle = query.search.lower() + rows = [ + row + for row in rows + if any(needle in str(row.get(column, "")).lower() for column in columns) + ] + + if query.sort_by in columns: + rows = sorted( + rows, + key=lambda row: str(row.get(query.sort_by, "")).lower(), + reverse=query.descending, + ) + + return rows[query.offset : query.offset + query.limit], len(rows) diff --git a/lib/dashboard/pages/activity.py b/lib/dashboard/pages/activity.py index db9deb0..db1f501 100644 --- a/lib/dashboard/pages/activity.py +++ b/lib/dashboard/pages/activity.py @@ -1,10 +1,13 @@ +from datetime import timedelta + from dachshund import Page -from dachshund.widgets import BarChart, Number +from dachshund.widgets import BarChart, Number, TimeSeriesChart + +from lib.dashboard.widgets.top_bar_chart import TopBarChart activity_page = Page("activity", title="Activity", public=True) with activity_page.row(): - activity_page.add(Number("member_count")) activity_page.add( Number( "active_members", @@ -18,12 +21,43 @@ activity_page.add( Number( "daily_message_rate", - title="Today's Messages Rate", + title="Messages (last 24h)", source="message", aggregate="count", over="day", ) ) + 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 hour)", + source="member_flow", + y="joined", + aggregate="sum", + over=timedelta(hours=1), + ) + ) + +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( @@ -35,8 +69,44 @@ persist=True, ) ) + activity_page.add( + BarChart( + "channel_messages", + title="Messages per Channel", + source="message", + x="channel", + 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( + TopBarChart( + "reaction_emojis", + title="Top Emojis", + x="emoji", + top=10, + persist=True, + ) + ) diff --git a/lib/dashboard/pages/admin.py b/lib/dashboard/pages/admin.py index e888e73..a579f72 100644 --- a/lib/dashboard/pages/admin.py +++ b/lib/dashboard/pages/admin.py @@ -1,68 +1,174 @@ -from typing import Any, Callable +from datetime import timedelta from dachshund import Page -from dachshund.widgets import BarChart, Logs, Table, TableQuery -from discord import Member +from dachshund.widgets import BarChart, Logs, Number, Table, TimeSeriesChart -from bot import app +from bot.helpers.dashboard_helper import MODERATION_SOURCE +from lib.dashboard.handlers import extensions, jobs, members +from lib.dashboard.widgets.recent_table import RecentTable +from lib.dashboard.widgets.stat import Stat admin_page = Page("admin", title="Admin") +admin_page.action("enable_extension", label="Enable")(extensions.enable) +admin_page.action("disable_extension", label="Disable", confirm=True)( + extensions.disable +) + +# One "deployment" event, a field per card. +DEPLOYMENT = [ + ("environment", "Environment"), + ("commit", "Commit"), + ("started", "Started"), + ("uptime", "Uptime"), + ("discord_py", "discord.py"), + ("python", "Python"), +] -MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = { - "id": lambda member: member.id, - "name": lambda member: member.name.lower(), - "nickname": lambda member: (member.nick or "").lower(), - "joined at": lambda member: member.joined_at, -} - +with admin_page.row(): + for field, title in DEPLOYMENT: + admin_page.add( + Stat(f"deployment_{field}", title=title, source="deployment", y=field) + ) -def _member_row(member: Member) -> dict[str, Any]: - return { - "id": member.id, - "name": member.name, - "nickname": member.nick, - "joined at": member.joined_at.strftime("%a %d %b %Y"), - } +with admin_page.row(): + with admin_page.column(span=4): + admin_page.add( + Number( + "process_memory", + title="Memory", + source="process", + y="memory_mb", + unit="MB", + ) + ) + admin_page.add( + Number( + "process_cpu", + title="CPU", + source="process", + y="cpu_percent", + unit="%", + ) + ) + admin_page.add( + TimeSeriesChart( + "process", + title="Memory (24 h)", + y=["memory_mb"], + aggregate="average", + every=timedelta(minutes=10), + over=timedelta(days=1), + ) + ) +with admin_page.row(): + with admin_page.column(span=4): + admin_page.add( + Number( + "error_rate", + title="Error Rate (last 24h)", + source="command_outcome", + y="failed", + aggregate="average", + over=timedelta(hours=24), + unit="%", + ) + ) + admin_page.add( + TimeSeriesChart( + "gateway_events", + title="Gateway Events / min", + aggregate="average", + every=timedelta(minutes=10), + over=timedelta(days=1), + ) + ) -def _matches(member: Member, search: str) -> bool: - needle = search.lower() - haystack = (str(member.id), member.name, member.nick or "") - return any(needle in value.lower() for value in haystack) +with admin_page.row(): + admin_page.add(Logs("logs", title="Logs")) -def load_members(ctx: dict, query: TableQuery) -> tuple[list[dict[str, Any]], int]: - guild = ctx["bot"].get_guild(app.config.get("client", "guild_id")) - members = list(guild.members) if guild else [] +with admin_page.row(): + admin_page.add( + BarChart( + "command_duration", + title="Average Command Time (ms)", + x="name", + aggregate="average", + persist=True, + ) + ) - if query.search: - members = [member for member in members if _matches(member, query.search)] + admin_page.add( + BarChart("command_error", title="Errors Per Command", x="name", persist=True) + ) - if sort_key := MEMBER_SORT_KEYS.get(query.sort_by): - members.sort(key=sort_key, reverse=query.descending) - page = members[query.offset : query.offset + query.limit] +with admin_page.row(): + admin_page.add( + RecentTable( + "recent_commands", + title="Recent Commands", + columns=["time", "command", "user", "status", "ms", "error"], + keep=100, + ) + ) - return [_member_row(member) for member in page], len(members) + admin_page.add( + RecentTable( + MODERATION_SOURCE, + title="Recent Moderation", + columns=["time", "action", "member", "by", "details"], + keep=100, + ) + ) with admin_page.row(): - admin_page.add(Logs("logs", title="Logs")) + admin_page.add( + Table( + "enabled_extensions", + title="Enabled Extensions", + source=extensions.SOURCE, + columns=extensions.COLUMNS, + page_size=10, + loader=extensions.load_enabled, + actions=["disable_extension"], + ) + ) + admin_page.add( + Table( + "disabled_extensions", + title="Disabled Extensions", + source=extensions.SOURCE, + columns=extensions.COLUMNS, + page_size=10, + loader=extensions.load_disabled, + actions=["enable_extension"], + ) + ) with admin_page.row(): admin_page.add( Table( - "members", - columns=["id", "name", "nickname", "joined at"], - search=True, - page_size=5, - loader=load_members, + "jobs", + title="Scheduled Jobs", + columns=jobs.COLUMNS, + page_size=10, + loader=jobs.load, ) ) + with admin_page.row(): admin_page.add( - BarChart("command_error", title="Errors Per Command", x="name", persist=True) + Table( + "members", + columns=members.COLUMNS, + search=True, + page_size=5, + loader=members.load, + ) ) diff --git a/lib/dashboard/pages/overview.py b/lib/dashboard/pages/overview.py index 94a3c30..b28a53a 100644 --- a/lib/dashboard/pages/overview.py +++ b/lib/dashboard/pages/overview.py @@ -1,5 +1,5 @@ from dachshund import Page -from dachshund.widgets import BarChart, Text, TimeSeriesChart +from dachshund.widgets import BarChart, Number, Text, TimeSeriesChart from lib.dashboard.widgets.gauge import Gauge overview_page = Page("overview", title="Overview", public=True) @@ -7,6 +7,9 @@ with overview_page.row(): overview_page.add(Text("summary")) + with overview_page.column(span=4): + overview_page.add(Number("member_count")) + with overview_page.row(): with overview_page.column(span=4): overview_page.add( diff --git a/lib/dashboard/widgets/recent_table.py b/lib/dashboard/widgets/recent_table.py new file mode 100644 index 0000000..1265c8a --- /dev/null +++ b/lib/dashboard/widgets/recent_table.py @@ -0,0 +1,45 @@ +from dataclasses import KW_ONLY, dataclass +from typing import Any, Sequence + +from dachshund.storage import Event +from dachshund.widgets import Table + + +@dataclass +class RecentTable(Table): + """A row per event of its source, newest first. + + The newest `keep` events stay in the event store, across restarts, where a + plain `Table` only keeps its newest one once events are compacted. + + ## Example + + ```python + with dachshund.row(): + dachshund.add(RecentTable("errors", columns=["command", "error"], keep=50)) + dachshund.emit("errors", command="weather", error="Timeout") + ``` + """ + + _: KW_ONLY + + keep: int = 50 + + def __post_init__(self) -> None: + if self.persist: + raise ValueError( + f"{self.name!r} keeps its rows in the event store, give it " + "`keep` instead of persist=True." + ) + + super().__post_init__() + + if self.keep < 1: + raise ValueError(f"{self.name!r} has keep={self.keep}, keep at least 1.") + + def render(self, events: Sequence[Event]) -> dict[str, Any]: + """The newest `keep` rows, newest first.""" + payload = super().render(events) + payload["rows"] = payload["rows"][-self.keep :][::-1] + + return payload diff --git a/lib/dashboard/widgets/stat.css b/lib/dashboard/widgets/stat.css new file mode 100644 index 0000000..fcb83c9 --- /dev/null +++ b/lib/dashboard/widgets/stat.css @@ -0,0 +1,20 @@ +.card-content.stat { + display: flex; + align-items: center; + min-height: 44px; + overflow: hidden; + container-type: inline-size; +} + +.stat-value { + font-family: var(--font-ui); + font-size: clamp(1rem, 2vw, 1.4rem); + font-size: clamp(1rem, 12cqw, 1.4rem); + font-weight: 600; + line-height: 1.2; + color: var(--text); + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/lib/dashboard/widgets/stat.js b/lib/dashboard/widgets/stat.js new file mode 100644 index 0000000..e9fcac5 --- /dev/null +++ b/lib/dashboard/widgets/stat.js @@ -0,0 +1,27 @@ +(() => { + "use strict"; + + // textContent: the value is application text, never markup. + const paint = (state, payload) => { + const value = payload.value ?? "—"; + state.value.textContent = value; + state.value.title = value; + }; + + window.Dachshund.widget("stat", { + mount(card, payload) { + const container = card.querySelector(".card-content"); + container.classList.add("stat"); + + const value = document.createElement("span"); + value.className = "stat-value"; + container.append(value); + + const state = { value }; + paint(state, payload); + + return state; + }, + replace: paint, + }); +})(); diff --git a/lib/dashboard/widgets/stat.py b/lib/dashboard/widgets/stat.py new file mode 100644 index 0000000..693b7b2 --- /dev/null +++ b/lib/dashboard/widgets/stat.py @@ -0,0 +1,39 @@ +from dataclasses import KW_ONLY, dataclass +from pathlib import Path +from typing import Any, ClassVar, Sequence + +from dachshund.storage import Event +from dachshund.widgets import Widget + + +@dataclass +class Stat(Widget): + """The latest value of a field, as short text on a small card. + + For values that aren't numbers, like a version or a commit, where `Number` + would show "—". Several can read one event, a field each. + + ## Example + + ```python + with dachshund.row(): + dachshund.add(Stat("commit", source="deployment", y="commit")) + dachshund.add(Stat("python", source="deployment", y="python")) + dachshund.emit("deployment", commit="f76ee33", python="3.12.3") + ``` + """ + + _: KW_ONLY + + y: str = "value" + + type: ClassVar[str] = "stat" + op: ClassVar[str] = "replace" + script: ClassVar[Path] = Path(__file__).parent / "stat.js" + style: ClassVar[Path] = Path(__file__).parent / "stat.css" + + def render(self, events: Sequence[Event]) -> dict[str, Any]: + """The newest event's `y`, as text.""" + value = events[-1].payload.get(self.y) if events else None + + return {"value": None if value is None else str(value)} diff --git a/lib/dashboard/widgets/top_bar_chart.py b/lib/dashboard/widgets/top_bar_chart.py new file mode 100644 index 0000000..f05e682 --- /dev/null +++ b/lib/dashboard/widgets/top_bar_chart.py @@ -0,0 +1,40 @@ +from dataclasses import KW_ONLY, dataclass +from typing import Any, Sequence + +from dachshund.storage import Event +from dachshund.widgets import BarChart + + +@dataclass +class TopBarChart(BarChart): + """A `BarChart` of only its `top` biggest bars, biggest first. + + ## Example + + ```python + with dachshund.row(): + dachshund.add(TopBarChart("emojis", x="emoji", top=10, persist=True)) + dachshund.emit("emojis", emoji="🔥", value=1) + ``` + """ + + _: KW_ONLY + + top: int = 10 + + def __post_init__(self) -> None: + super().__post_init__() + + if self.top < 1: + raise ValueError(f"{self.name!r} has top={self.top}, show at least 1.") + + def render(self, events: Sequence[Event]) -> dict[str, Any]: + payload = super().render(events) + bars = sorted( + zip(payload["x"], payload["y"][0]), key=lambda bar: bar[1], reverse=True + )[: self.top] + + payload["x"] = [category for category, _ in bars] + payload["y"] = [[value for _, value in bars]] + + return payload diff --git a/tests/extensions/test_dashboard_cog.py b/tests/extensions/test_dashboard_cog.py index e0b8812..96987e0 100644 --- a/tests/extensions/test_dashboard_cog.py +++ b/tests/extensions/test_dashboard_cog.py @@ -1,14 +1,41 @@ from unittest.mock import patch import pytest +from discord.ext.commands import CommandInvokeError, CommandNotFound from freezegun import freeze_time -from bot.extensions.dashboard_cog import SUMMARY, DashboardCog - - -def _fake_message(author_id=1, bot=False): +from bot.extensions.dashboard_cog import ( + DAILY_DAYS, + DAILY_SOURCES, + ERROR_LENGTH, + TRIM_SPAN, + SUMMARY, + DashboardCog, + _commit, +) + + +def _fake_channel(name="general", parent=None, public=True): + """A guild channel, which @everyone can see when `public`.""" + permissions = type("Permissions", (), {"view_channel": public})() + guild = type("Guild", (), {"default_role": object()})() + + return type( + "Channel", + (), + { + "name": name, + "parent": parent, + "guild": guild, + "permissions_for": lambda self, role: permissions, + }, + )() + + +def _fake_message(author_id=1, bot=False, channel=None): author = type("Author", (), {"id": author_id, "bot": bot})() - return type("Message", (), {"author": author})() + channel = channel or _fake_channel() + return type("Message", (), {"author": author, "channel": channel})() @pytest.fixture @@ -112,7 +139,9 @@ async def test_on_message__expect_one_message_event_with_date_and_author( with freeze_time("2025-02-21 03:00:00"): # still the 20th in US/Eastern await dashboard_cog.on_message(_fake_message(author_id=7)) - mock_emit.assert_called_once_with("message", date="2025-02-20", author="7", value=1) + mock_emit.assert_called_once_with( + "message", date="2025-02-20", author="7", channel="general", value=1 + ) @pytest.mark.asyncio @@ -123,14 +152,130 @@ async def test_on_message__from_a_bot__expect_no_author(mock_emit, dashboard_cog await dashboard_cog.on_message(_fake_message(author_id=99, bot=True)) mock_emit.assert_called_once_with( - "message", date="2025-02-20", author=None, value=1 + "message", date="2025-02-20", author=None, channel="general", value=1 ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "channel, expected", + [ + (_fake_channel("help"), "help"), + (_fake_channel("a question", parent=_fake_channel("forum")), "forum"), + (_fake_channel("moderation_logs", public=False), "private"), + ( + _fake_channel("a thread", parent=_fake_channel("staff", public=False)), + "private", + ), + (type("DMChannel", (), {})(), "DM"), + ], +) +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_message__expect_channel_or_its_parent( + mock_emit, dashboard_cog, channel, expected +): + """Verify threads count toward their parent, DMs toward "DM", and channels + @everyone can't see toward "private".""" + await dashboard_cog.on_message(_fake_message(channel=channel)) + + assert mock_emit.call_args.kwargs["channel"] == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "listener, joined, left", + [("on_member_join", 1, 0), ("on_member_remove", 0, 1)], +) +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_member_flow__expect_join_or_leave_event( + mock_emit, dashboard_cog, listener, joined, left +): + """Verify joins and leaves feed the joins vs leaves chart.""" + await getattr(dashboard_cog, listener)(object()) + + mock_emit.assert_any_call("member_flow", joined=joined, left=left) + + def _command_ctx(): command = type("Cmd", (), {"qualified_name": "ping"})() - return type("Ctx", (), {"command": command})() + return type("Ctx", (), {"command": command, "author": "ada"})() + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command_completion__expect_duration_in_ms(mock_emit, dashboard_cog): + """Verify a command's duration runs from on_command to its completion.""" + ctx = _command_ctx() + + with patch( + "bot.extensions.dashboard_cog.time.perf_counter", side_effect=[10.0, 10.25] + ): + await dashboard_cog.on_command(ctx) + await dashboard_cog.on_command_completion(ctx) + + mock_emit.assert_any_call("command_duration", name="ping", value=250.0) + row = mock_emit.call_args_list[-1] + assert row.args == ("recent_commands",) + assert row.kwargs | {"time": None} == { + "time": None, + "command": "ping", + "user": "ada", + "status": "ok", + "ms": 250.0, + "error": "", + } + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command_completion__moderation_command__expect_no_recent_commands_row( + mock_emit, dashboard_cog +): + """Verify moderation commands go to their own table, with details.""" + await dashboard_cog.on_command_completion(_moderation_ctx()) + + assert "recent_commands" not in [c.args[0] for c in mock_emit.call_args_list] + + +def _moderation_ctx(): + ctx = _command_ctx() + ctx.cog = type("Cog", (), {"qualified_name": "Moderation"})() + return ctx + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.record_moderation") +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command_error__moderation_command__expect_failed_moderation_row( + mock_emit, mock_record, dashboard_cog +): + """Verify a failed moderation command lands in the moderation table, counted too.""" + error = CommandInvokeError(PermissionError("Missing Permissions")) + + await dashboard_cog.on_command_error(_moderation_ctx(), error) + + mock_record.assert_called_once_with( + "ping (failed)", by="ada", details="PermissionError: Missing Permissions" + ) + assert [c.args[0] for c in mock_emit.call_args_list] == [ + "command_error", + "command_outcome", + ] + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command_completion__without_a_start__expect_row_without_duration( + mock_emit, dashboard_cog +): + await dashboard_cog.on_command_completion(_command_ctx()) + + assert [c.args[0] for c in mock_emit.call_args_list] == [ + "command_outcome", + "recent_commands", + ] + assert mock_emit.call_args.kwargs["ms"] is None @pytest.mark.asyncio @@ -152,6 +297,57 @@ async def test_on_command_error__expect_emits_command_name(mock_emit, dashboard_ await dashboard_cog.on_command_error(ctx, Exception("boom")) + mock_emit.assert_any_call("command_error", name="ping", value=1) + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command_error__expect_recent_error_row_unwrapped( + mock_emit, dashboard_cog +): + """Verify the recent commands table gets the original error, not discord.py's wrapper.""" + ctx = _command_ctx() + error = CommandInvokeError(TimeoutError("weather API")) + + with ( + freeze_time("2025-02-21 03:00:00"), + patch( + "bot.extensions.dashboard_cog.time.perf_counter", side_effect=[10.0, 10.5] + ), + ): + await dashboard_cog.on_command(ctx) + await dashboard_cog.on_command_error(ctx, error) + + mock_emit.assert_any_call( + "recent_commands", + time="2025-02-20 22:00:00", + command="ping", + user="ada", + status="error", + ms=500.0, + error="TimeoutError: weather API", + ) + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command_error__with_long_message__expect_truncated( + mock_emit, dashboard_cog +): + await dashboard_cog.on_command_error(_command_ctx(), Exception("x" * 1000)) + + recent = [c for c in mock_emit.call_args_list if c.args[0] == "recent_commands"] + assert len(recent[0].kwargs["error"]) == ERROR_LENGTH + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command_error__with_unknown_command__expect_not_in_recent_commands( + mock_emit, dashboard_cog +): + """Verify command name typos only count, without burying the commands run.""" + await dashboard_cog.on_command_error(_command_ctx(), CommandNotFound("nope")) + mock_emit.assert_called_once_with("command_error", name="ping", value=1) @@ -161,11 +357,11 @@ async def test_on_command_error__without_a_command__expect_emits_unknown( mock_emit, dashboard_cog ): """Verify on_command_error falls back to 'unknown' when ctx.command is missing.""" - ctx = type("Ctx", (), {"command": None})() + ctx = type("Ctx", (), {"command": None, "author": "ada"})() await dashboard_cog.on_command_error(ctx, Exception("boom")) - mock_emit.assert_called_once_with("command_error", name="unknown", value=1) + mock_emit.assert_any_call("command_error", name="unknown", value=1) @pytest.mark.asyncio @@ -186,22 +382,79 @@ async def test_on_resumed__expect_emits_resume_event(mock_emit, dashboard_cog): mock_emit.assert_called_once_with("connection_event", type="resume", value=1) -def test_cog_load__expect_schedules_the_latency_job(dashboard_cog): - """Verify cog_load schedules the latency report every minute.""" +@patch("bot.extensions.dashboard_cog.dachshund.emit") +def test_cog_load__expect_schedules_reports_and_daily_trim(_emit, dashboard_cog): + """Verify cog_load schedules the minute reports and the trim after midnight.""" dashboard_cog.cog_load() - dashboard_cog.bot.scheduler.add_job.assert_called_once_with( + dashboard_cog.bot.scheduler.add_job.assert_any_call( dashboard_cog._report_latency, "interval", minutes=1 ) - assert len(dashboard_cog.jobs) == 1 + dashboard_cog.bot.scheduler.add_job.assert_any_call( + dashboard_cog._report_process, "interval", minutes=1 + ) + dashboard_cog.bot.scheduler.add_job.assert_any_call( + dashboard_cog._trim_daily_charts, + "cron", + hour=0, + minute=5, + timezone=dashboard_cog.timezone, + ) + assert len(dashboard_cog.jobs) == 3 @patch("bot.extensions.dashboard_cog.dachshund.emit") -def test_cog_load__expect_emits_the_summary(mock_emit, dashboard_cog): - """Verify cog_load emits the summary text.""" +def test_cog_load__expect_emits_the_summary_and_process(mock_emit, dashboard_cog): + """Verify cog_load emits the summary, and the process right away.""" dashboard_cog.cog_load() - mock_emit.assert_called_once_with("summary", value=SUMMARY) + mock_emit.assert_any_call("summary", value=SUMMARY) + sources = [c.args[0] for c in mock_emit.call_args_list] + assert sources[:4] == ["summary", "process", "deployment", "gateway_events"] + # Then the trim, so bars missed during downtime go too. + assert set(sources[4:]) == set(DAILY_SOURCES) + + +@patch("bot.extensions.dashboard_cog.dachshund.emit") +def test_report_process__expect_memory_cpu_and_deployment(mock_emit, dashboard_cog): + dashboard_cog.process.sample = lambda: {"memory_mb": 120.5, "cpu_percent": 3.2} + dashboard_cog.process.uptime = lambda: "2 h" + dashboard_cog.process.started_at = 1740106800 # 2025-02-21 03:00 UTC + dashboard_cog.commit = "abc1234" + dashboard_cog.bot.app.config.current_environment = "production" + + with ( + patch("bot.extensions.dashboard_cog.discord.__version__", "2.6.4"), + patch( + "bot.extensions.dashboard_cog.platform.python_version", + return_value="3.12.3", + ), + ): + dashboard_cog._report_process() + + mock_emit.assert_any_call("process", memory_mb=120.5, cpu_percent=3.2) + mock_emit.assert_any_call( + "deployment", + environment="production", + commit="abc1234", + started="2025-02-20 22:00 EST", + uptime="2 h", + discord_py="2.6.4", + python="3.12.3", + ) + + +def test_commit__on_railway__expect_its_short_sha(monkeypatch): + monkeypatch.setenv("RAILWAY_GIT_COMMIT_SHA", "0123456789abcdef") + + assert _commit() == "0123456" + + +def test_commit__without_git__expect_unknown(monkeypatch): + monkeypatch.delenv("RAILWAY_GIT_COMMIT_SHA", raising=False) + + with patch("bot.extensions.dashboard_cog.subprocess.run", side_effect=OSError): + assert _commit() == "unknown" def test_cog_unload__expect_removes_scheduled_jobs(dashboard_cog): @@ -213,3 +466,122 @@ def test_cog_unload__expect_removes_scheduled_jobs(dashboard_cog): for job in jobs: dashboard_cog.bot.scheduler.remove_job.assert_any_call(job.id) + + +def _reaction(emoji, *, guild_id=1, bot=False, member=True): + member = type("Member", (), {"bot": bot})() if member else None + return type( + "Payload", (), {"guild_id": guild_id, "member": member, "emoji": emoji} + )() + + +def _emoji(name, custom=False): + return type( + "Emoji", + (), + { + "name": name, + "is_custom_emoji": lambda self: custom, + "__str__": lambda self: name, + }, + )() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "emoji, expected", + [(_emoji("🔥"), "🔥"), (_emoji("party", custom=True), ":party:")], +) +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_raw_reaction_add__expect_day_and_emoji( + mock_emit, dashboard_cog, emoji, expected +): + with freeze_time("2025-02-21 03:00:00"): + await dashboard_cog.on_raw_reaction_add(_reaction(emoji)) + + mock_emit.assert_any_call("reactions_per_day", date="2025-02-20", value=1) + mock_emit.assert_any_call("reaction_emojis", emoji=expected, value=1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [_reaction(_emoji("🔥"), bot=True), _reaction(_emoji("🔥"), guild_id=None)], +) +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_raw_reaction_add__from_a_bot_or_dm__expect_nothing( + mock_emit, dashboard_cog, payload +): + await dashboard_cog.on_raw_reaction_add(payload) + + mock_emit.assert_not_called() + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_command__expect_counted_for_its_day(mock_emit, dashboard_cog): + with freeze_time("2025-02-21 03:00:00"): + await dashboard_cog.on_command(_command_ctx()) + + mock_emit.assert_any_call("commands_per_day", date="2025-02-20", value=1) + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_command_outcome__expect_0_on_success_and_100_on_failure( + mock_emit, dashboard_cog +): + """Verify the average of `failed` is the error rate in percent.""" + await dashboard_cog.on_command_completion(_command_ctx()) + await dashboard_cog.on_command_error(_command_ctx(), Exception("boom")) + + outcomes = [ + c.kwargs for c in mock_emit.call_args_list if c.args[0] == "command_outcome" + ] + assert outcomes == [{"failed": 0}, {"failed": 100}] + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_command_outcome__unknown_command__expect_not_counted( + mock_emit, dashboard_cog +): + await dashboard_cog.on_command_error(_command_ctx(), CommandNotFound("nope")) + + assert "command_outcome" not in [c.args[0] for c in mock_emit.call_args_list] + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_gateway_events__expect_counted_then_reported_and_reset( + mock_emit, dashboard_cog +): + dashboard_cog.process.sample = lambda: {} + + for _ in range(3): + await dashboard_cog.on_socket_event_type("MESSAGE_CREATE") + + dashboard_cog._report_process() + dashboard_cog._report_process() + + reports = [ + c.kwargs for c in mock_emit.call_args_list if c.args[0] == "gateway_events" + ] + assert reports == [{"value": 3}, {"value": 0}] + + +@patch("bot.extensions.dashboard_cog.dachshund.emit") +def test_trim_daily_charts__expect_dates_past_the_window_deleted( + mock_emit, dashboard_cog +): + from datetime import date + + dashboard_cog._trim_daily_charts(now=date(2025, 3, 31)) + + deleted = {(c.args[0], c.kwargs["date"]) for c in mock_emit.call_args_list} + assert all(c.kwargs["_delete"] is True for c in mock_emit.call_args_list) + # Day 30 back is kept, days 31 to 37 back go. + assert ("commands_per_day", "2025-03-01") not in deleted + assert ("commands_per_day", "2025-02-28") in deleted + assert len(deleted) == TRIM_SPAN * len(DAILY_SOURCES) + assert DAILY_DAYS == 30 diff --git a/tests/extensions/test_moderation_cog.py b/tests/extensions/test_moderation_cog.py new file mode 100644 index 0000000..c0e1a90 --- /dev/null +++ b/tests/extensions/test_moderation_cog.py @@ -0,0 +1,73 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bot.extensions.moderation_cog import ModerationCog + + +@pytest.fixture +def moderation_cog(mock_bot): + mock_bot.get_channel_by_name.return_value = None + return ModerationCog(mock_bot) + + +def _member(account_age_days): + member = AsyncMock() + member.created_at = datetime.now(tz=UTC) - timedelta(days=account_age_days) + member.guild.kick = AsyncMock() + return member + + +@pytest.mark.asyncio +@patch("bot.extensions.moderation_cog.danger") +@patch("bot.extensions.moderation_cog.app.config.get", return_value=30) +@patch("bot.extensions.moderation_cog.record_moderation") +async def test_on_member_join__with_young_account__expect_kick_row( + mock_emit, _config, _danger, moderation_cog +): + member = _member(account_age_days=2) + + await moderation_cog.on_member_join(member) + + member.guild.kick.assert_awaited_once() + mock_emit.assert_called_once_with( + "kick", + member=str(member), + details="account 2 day(s) old, minimum 30", + ) + + +@pytest.mark.asyncio +@patch("bot.extensions.moderation_cog.app.config.get", return_value=30) +@patch("bot.extensions.moderation_cog.record_moderation") +async def test_on_member_join__with_old_account__expect_no_row( + mock_emit, _config, moderation_cog +): + member = _member(account_age_days=365) + + await moderation_cog.on_member_join(member) + + member.guild.kick.assert_not_awaited() + mock_emit.assert_not_called() + + +@pytest.mark.asyncio +@patch("bot.extensions.moderation_cog.danger") +@patch("bot.extensions.moderation_cog.record_moderation") +async def test_purge__expect_purge_row_by_its_moderator( + mock_emit, mock_danger, moderation_cog +): + mock_danger.return_value = MagicMock(send=AsyncMock()) + ctx = MagicMock() + ctx.defer = AsyncMock() + ctx.channel.purge = AsyncMock() + ctx.channel.__str__ = lambda _: "general" + ctx.author.__str__ = lambda _: "mod" + + await moderation_cog.purge.callback(moderation_cog, ctx, 5, "spam") + + ctx.channel.purge.assert_awaited_once() + mock_emit.assert_called_once_with( + "purge", by="mod", details="5 message(s) in #general · spam" + ) diff --git a/tests/extensions/test_name_moderation_cog.py b/tests/extensions/test_name_moderation_cog.py index 01b0d04..1f87e9e 100644 --- a/tests/extensions/test_name_moderation_cog.py +++ b/tests/extensions/test_name_moderation_cog.py @@ -26,8 +26,9 @@ def mock_ctx(): @pytest.mark.asyncio @patch("bot.extensions.name_moderation_cog.notice") @patch("bot.extensions.name_moderation_cog.make_random_name") +@patch("bot.extensions.name_moderation_cog.record_moderation") async def test_name_moderation_on_member_join__with_none_blacklisted_name__expect_nothing( - mock_random, mock_notice, name_moderation_cog + mock_emit, mock_random, mock_notice, name_moderation_cog ): member = AsyncMock() member.display_name = "Normal Name" @@ -39,13 +40,15 @@ async def test_name_moderation_on_member_join__with_none_blacklisted_name__expec member.edit.assert_not_awaited() member.send.assert_not_awaited() mock_notice.assert_not_called() + mock_emit.assert_not_called() @pytest.mark.asyncio +@patch("bot.extensions.name_moderation_cog.record_moderation") @patch("bot.extensions.name_moderation_cog.notice") @patch("bot.extensions.name_moderation_cog.make_random_name") async def test_name_moderation_on_member_join__with_blacklisted_name__expect_change_and_message( - mock_random, mock_notice, name_moderation_cog + mock_random, mock_notice, mock_emit, name_moderation_cog ): member = AsyncMock() member.display_name = "Bad Name" @@ -68,6 +71,9 @@ async def test_name_moderation_on_member_join__with_blacklisted_name__expect_cha ) mock_notice.assert_called_once_with("NAME", "Username of Bad Name was changed.") + mock_emit.assert_called_once_with( + "rename", member="Bad Name", details="renamed to Good Name" + ) mock_log.add_field.assert_called_once_with( "Reason: ", "User Bad Name joined with an inappropriate name, thus it was changed to Good Name.", diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/test_dashboard_helper.py b/tests/helpers/test_dashboard_helper.py new file mode 100644 index 0000000..5d0e836 --- /dev/null +++ b/tests/helpers/test_dashboard_helper.py @@ -0,0 +1,32 @@ +from unittest.mock import patch + +from freezegun import freeze_time + +from bot.helpers.dashboard_helper import MODERATION_SOURCE, record_moderation, timestamp + + +def test_timestamp__expect_eastern_time(): + with freeze_time("2025-02-21 03:00:00"): + assert timestamp() == "2025-02-20 22:00:00" + + +@patch("bot.helpers.dashboard_helper.dachshund.emit") +def test_record_moderation__expect_a_timestamped_row(mock_emit): + with freeze_time("2025-02-21 03:00:00"): + record_moderation("purge", by="mod", details="5 message(s)") + + mock_emit.assert_called_once_with( + MODERATION_SOURCE, + time="2025-02-20 22:00:00", + action="purge", + member="", + by="mod", + details="5 message(s)", + ) + + +@patch("bot.helpers.dashboard_helper.dachshund.emit") +def test_record_moderation__without_moderator__expect_auto(mock_emit): + record_moderation("kick", member="spammer") + + assert mock_emit.call_args.kwargs["by"] == "auto" diff --git a/tests/lib/dashboard/handlers/__init__.py b/tests/lib/dashboard/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/dashboard/handlers/test_extensions.py b/tests/lib/dashboard/handlers/test_extensions.py new file mode 100644 index 0000000..3496cec --- /dev/null +++ b/tests/lib/dashboard/handlers/test_extensions.py @@ -0,0 +1,121 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from dachshund.widgets import TableQuery +from discord.ext.commands import ExtensionAlreadyLoaded, ExtensionNotLoaded + +from bot.classes.state import State +from lib.dashboard.handlers import extensions + + +def _extension(module, state=State.ENABLED): + extension = MagicMock() + extension.module_name = f"bot.extensions.{module}" + extension.short_module_name = module + extension.name = module.replace("_", " ").title() + extension.state = state + return extension + + +def _query(**kwargs): + return TableQuery(**{"offset": 0, "limit": 10, **kwargs}) + + +def _bot(*loaded): + return SimpleNamespace(extensions={module: object() for module in loaded}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "loader, state", + [ + (extensions.load_enabled, State.ENABLED), + (extensions.load_disabled, State.DISABLED), + ], +) +@patch("lib.dashboard.handlers.extensions.Extension") +async def test_load__expect_that_state_only(model, loader, state): + model.by_state.return_value.all.return_value = [_extension("pun_cog")] + + rows, total = await loader({"bot": _bot("bot.extensions.pun_cog")}, _query()) + + model.by_state.assert_called_once_with(state) + assert total == 1 + assert rows == [ + { + "extension": "Pun Cog", + "module": "pun_cog", + "loaded": "yes", + "_module_name": "bot.extensions.pun_cog", + } + ] + + +@pytest.mark.asyncio +@patch("lib.dashboard.handlers.extensions.Extension") +async def test_load__expect_buttons_get_the_full_module_name(model): + """The hidden field reaches the actions without its underscore, so it must + not collide with the shown `module` column.""" + from dachshund.widgets.table.table import _fields + + model.by_state.return_value.all.return_value = [_extension("pun_cog")] + rows, _ = await extensions.load_enabled({"bot": _bot()}, _query()) + + fields = _fields(rows[0]) + assert fields["module"] == "pun_cog" + assert fields["module_name"] == "bot.extensions.pun_cog" + + +@pytest.mark.asyncio +@patch("lib.dashboard.handlers.extensions.Extension") +async def test_load__sorted_and_searched__expect_matching_rows(model): + model.by_state.return_value.all.return_value = [ + _extension("pun_cog"), + _extension("fun_cog"), + _extension("time_cog"), + ] + + rows, total = await extensions.load_enabled( + {"bot": _bot()}, _query(search="UN", sort_by="extension") + ) + + assert total == 2 + assert [row["module"] for row in rows] == ["fun_cog", "pun_cog"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action, bot_method, already, saved", + [ + (extensions.enable, "load_extension", ExtensionAlreadyLoaded, "enable"), + (extensions.disable, "unload_extension", ExtensionNotLoaded, "disable"), + ], +) +@pytest.mark.parametrize("was_already", [False, True]) +@patch("lib.dashboard.handlers.extensions.dachshund.emit") +@patch("lib.dashboard.handlers.extensions.Extension") +async def test_actions__expect_bot_updated_state_saved_and_table_refreshed( + model, mock_emit, action, bot_method, already, saved, was_already +): + extension = _extension("pun_cog") + model.find_by.return_value = extension + side_effect = already("bot.extensions.pun_cog") if was_already else None + bot = MagicMock(**{bot_method: AsyncMock(side_effect=side_effect)}) + + result = await action({"bot": bot}, module_name="bot.extensions.pun_cog") + + getattr(bot, bot_method).assert_awaited_once_with("bot.extensions.pun_cog") + getattr(extension, saved).assert_called_once() + mock_emit.assert_called_once_with("extensions") + assert result == f"Pun Cog {saved}d." + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", [extensions.enable, extensions.disable]) +@patch("lib.dashboard.handlers.extensions.Extension") +async def test_actions__with_unknown_module__expect_error(model, action): + model.find_by.return_value = None + + with pytest.raises(ValueError, match="nope"): + await action({"bot": MagicMock()}, module_name="nope") diff --git a/tests/lib/dashboard/handlers/test_jobs.py b/tests/lib/dashboard/handlers/test_jobs.py new file mode 100644 index 0000000..2843253 --- /dev/null +++ b/tests/lib/dashboard/handlers/test_jobs.py @@ -0,0 +1,51 @@ +from datetime import datetime +from types import SimpleNamespace + +import pytest +from dachshund.widgets import TableQuery + +from lib.dashboard.handlers.jobs import load + + +def _query(**kwargs): + return TableQuery(**{"offset": 0, "limit": 10, **kwargs}) + + +def _job(name, next_run_time, trigger="interval[0:01:00]"): + return SimpleNamespace(name=name, trigger=trigger, next_run_time=next_run_time) + + +@pytest.mark.asyncio +async def test_load__expect_jobs_with_next_run_in_eastern_time(): + from pytz import UTC + + jobs = [ + _job("reminder", UTC.localize(datetime(2026, 11, 7, 15, 30))), + _job("paused", None), + ] + bot = SimpleNamespace(scheduler=SimpleNamespace(get_jobs=lambda: jobs)) + + rows, total = await load({"bot": bot}, _query()) + + assert total == 2 + assert rows == [ + { + "job": "reminder", + "trigger": "interval[0:01:00]", + "next run": "2026-11-07 10:30", + }, + {"job": "paused", "trigger": "interval[0:01:00]", "next run": "paused"}, + ] + + +@pytest.mark.asyncio +async def test_load__sorted_and_paged__expect_that_page_and_the_total(): + jobs = [_job(name, None) for name in ("c", "a", "b")] + bot = SimpleNamespace(scheduler=SimpleNamespace(get_jobs=lambda: jobs)) + + rows, total = await load( + {"bot": bot}, _query(sort_by="job", descending=True, offset=1, limit=1) + ) + + assert total == 3 + assert [row["job"] for row in rows] == ["b"] diff --git a/tests/lib/dashboard/handlers/test_members.py b/tests/lib/dashboard/handlers/test_members.py new file mode 100644 index 0000000..b5fbd2c --- /dev/null +++ b/tests/lib/dashboard/handlers/test_members.py @@ -0,0 +1,96 @@ +from datetime import datetime + +from dachshund.widgets import TableQuery + +from lib.dashboard.handlers.members import load + + +def _fake_bot(guild): + return type("Bot", (), {"get_guild": staticmethod(lambda guild_id: guild)})() + + +def _fake_member(id, name, nick=None, joined_at=datetime(2025, 3, 14)): + return type( + "Member", (), {"id": id, "name": name, "nick": nick, "joined_at": joined_at} + )() + + +def _fake_guild(members): + return type("Guild", (), {"members": members})() + + +def test_load__expect_every_member_as_a_row(): + bot = _fake_bot(_fake_guild([_fake_member(1, "ada", nick="Ada L.")])) + + rows, total = load({"bot": bot}, TableQuery(limit=5)) + + assert rows == [ + { + "id": 1, + "name": "ada", + "nickname": "Ada L.", + "joined at": "Fri 14 Mar 2025", + } + ] + assert total == 1 + + +def test_load__with_offset_and_limit__expect_that_page_and_the_total(): + members = [_fake_member(i, f"user{i}") for i in range(3)] + bot = _fake_bot(_fake_guild(members)) + + first_rows, first_total = load({"bot": bot}, TableQuery(limit=2)) + second_rows, second_total = load({"bot": bot}, TableQuery(offset=2, limit=2)) + + assert [row["id"] for row in first_rows] == [0, 1] + assert [row["id"] for row in second_rows] == [2] + assert first_total == second_total == 3 + + +def test_load__with_search__expect_matching_name_or_nickname_only(): + members = [ + _fake_member(1, "ada", nick="Countess"), + _fake_member(2, "grace"), + _fake_member(3, "linus", nick="ADA fan"), + ] + bot = _fake_bot(_fake_guild(members)) + + rows, total = load({"bot": bot}, TableQuery(search="ada", limit=10)) + + assert [row["id"] for row in rows] == [1, 3] + assert total == 2 + + +def test_load__sorted_by_joined_at__expect_date_order_not_text_order(): + members = [ + _fake_member(1, "ada", joined_at=datetime(2025, 3, 14)), # "Fri ..." + _fake_member(2, "grace", joined_at=datetime(2024, 1, 1)), # "Mon ..." + _fake_member(3, "linus", joined_at=datetime(2025, 1, 2)), # "Thu ..." + ] + bot = _fake_bot(_fake_guild(members)) + + rows, _ = load( + {"bot": bot}, TableQuery(sort_by="joined at", descending=True, limit=10) + ) + + assert [row["id"] for row in rows] == [1, 3, 2] + + +def test_load__sorted_by_nickname__expect_missing_nicknames_first(): + members = [ + _fake_member(1, "ada", nick="zed"), + _fake_member(2, "grace"), + _fake_member(3, "linus", nick="Bob"), + ] + bot = _fake_bot(_fake_guild(members)) + + rows, _ = load({"bot": bot}, TableQuery(sort_by="nickname", limit=10)) + + assert [row["id"] for row in rows] == [2, 3, 1] + + +def test_load__with_no_guild__expect_no_rows(): + rows, total = load({"bot": _fake_bot(None)}, TableQuery(limit=5)) + + assert rows == [] + assert total == 0 diff --git a/tests/lib/dashboard/handlers/test_paging.py b/tests/lib/dashboard/handlers/test_paging.py new file mode 100644 index 0000000..b6889e1 --- /dev/null +++ b/tests/lib/dashboard/handlers/test_paging.py @@ -0,0 +1,47 @@ +import pytest +from dachshund.widgets import TableQuery + +from lib.dashboard.handlers.paging import page_rows + +ROWS = [ + {"name": "Banana", "kind": "fruit"}, + {"name": "apple", "kind": "fruit"}, + {"name": "Carrot", "kind": "vegetable"}, +] +COLUMNS = ["name", "kind"] + + +def _query(**kwargs): + return TableQuery(**{"offset": 0, "limit": 10, **kwargs}) + + +def test_page_rows__expect_every_row_in_order(): + assert page_rows(ROWS, _query(), COLUMNS) == (ROWS, 3) + + +def test_page_rows__with_search__expect_case_insensitive_match_in_any_column(): + rows, total = page_rows(ROWS, _query(search="VEG"), COLUMNS) + + assert (rows, total) == ([ROWS[2]], 1) + + +@pytest.mark.parametrize( + "descending, expected", + [(False, ["apple", "Banana", "Carrot"]), (True, ["Carrot", "Banana", "apple"])], +) +def test_page_rows__sorted__expect_case_insensitive_order(descending, expected): + rows, _ = page_rows(ROWS, _query(sort_by="name", descending=descending), COLUMNS) + + assert [row["name"] for row in rows] == expected + + +def test_page_rows__sorted_by_unknown_column__expect_original_order(): + rows, _ = page_rows(ROWS, _query(sort_by="_hidden"), COLUMNS) + + assert rows == ROWS + + +def test_page_rows__with_offset_and_limit__expect_that_page_and_the_total(): + rows, total = page_rows(ROWS, _query(offset=1, limit=1), COLUMNS) + + assert (rows, total) == ([ROWS[1]], 3) diff --git a/tests/lib/dashboard/pages/test_activity.py b/tests/lib/dashboard/pages/test_activity.py index e88c68f..6725c90 100644 --- a/tests/lib/dashboard/pages/test_activity.py +++ b/tests/lib/dashboard/pages/test_activity.py @@ -1,6 +1,10 @@ +from datetime import timedelta + import pytest from dachshund import Column, Row -from dachshund.widgets import BarChart, Number +from dachshund.widgets import BarChart, Number, TimeSeriesChart + +from lib.dashboard.widgets.top_bar_chart import TopBarChart from lib.dashboard.pages.activity import activity_page @@ -9,17 +13,21 @@ def test_activity_page__expect_public(): assert activity_page.public is True -def test_activity_page__expect_counts_then_messages_then_commands(): +def test_activity_page__expect_counts_then_members_then_messages_then_commands(): assert activity_page.rows == [ Row( [ Column(["member_count"]), Column(["active_members"]), Column(["daily_message_rate"]), + Column(["commands_24h"]), + Column(["recent_joins"]), ] ), - Row([Column(["weekly_message_counts"])]), - Row([Column(["on_command"])]), + Row([Column(["member_flow"])]), + Row([Column(["weekly_message_counts"]), Column(["channel_messages"])]), + Row([Column(["commands_per_day"]), Column(["on_command"])]), + Row([Column(["reactions_per_day"]), Column(["reaction_emojis"])]), ] @@ -27,7 +35,11 @@ def test_activity_page__expect_counts_then_messages_then_commands(): "name, x", [ ("weekly_message_counts", "date"), + ("channel_messages", "channel"), ("on_command", "name"), + ("commands_per_day", "date"), + ("reactions_per_day", "date"), + ("reaction_emojis", "emoji"), ], ) def test_activity_page__expect_persisted_bar_charts(name, x): @@ -59,3 +71,43 @@ def test_activity_page__expect_todays_message_numbers_aggregated_from_messages( def test_activity_page__expect_messages_per_day_fed_by_messages(): assert activity_page.widgets["weekly_message_counts"].source == "message" + + +def test_activity_page__with_recent_joins__expect_joins_summed_over_an_hour(): + number = activity_page.widgets["recent_joins"] + + assert isinstance(number, Number) + assert number.source == "member_flow" + assert number.y == "joined" + assert number.aggregate == "sum" + assert number.over == timedelta(hours=1) + + +def test_activity_page__with_member_flow__expect_daily_joins_and_leaves(): + chart = activity_page.widgets["member_flow"] + + assert isinstance(chart, TimeSeriesChart) + assert chart.y == ["joined", "left"] + assert chart.aggregate == "sum" + assert chart.every == timedelta(days=1) + assert chart.over == timedelta(days=30) + + +def test_activity_page__expect_messages_per_channel_fed_by_messages(): + assert activity_page.widgets["channel_messages"].source == "message" + + +def test_activity_page__with_commands_24h__expect_commands_counted_over_a_day(): + number = activity_page.widgets["commands_24h"] + + assert isinstance(number, Number) + assert number.source == "on_command" + assert number.aggregate == "count" + assert number.over == timedelta(hours=24) + + +def test_activity_page__with_top_emojis__expect_the_ten_most_used(): + chart = activity_page.widgets["reaction_emojis"] + + assert isinstance(chart, TopBarChart) + assert chart.top == 10 diff --git a/tests/lib/dashboard/pages/test_admin.py b/tests/lib/dashboard/pages/test_admin.py index 7070b1d..035e29b 100644 --- a/tests/lib/dashboard/pages/test_admin.py +++ b/tests/lib/dashboard/pages/test_admin.py @@ -1,132 +1,152 @@ -from datetime import datetime +from datetime import timedelta +import pytest from dachshund import Column, Row -from dachshund.widgets import BarChart, Logs, Table, TableQuery +from dachshund.widgets import BarChart, Logs, Table, TimeSeriesChart -from lib.dashboard.pages.admin import admin_page, load_members +from lib.dashboard.widgets.recent_table import RecentTable +from lib.dashboard.widgets.stat import Stat +from lib.dashboard.handlers import extensions, jobs, members +from lib.dashboard.pages.admin import admin_page + + +DEPLOYMENT_FIELDS = [ + "environment", + "commit", + "started", + "uptime", + "discord_py", + "python", +] + + +def test_admin_page__with_deployment__expect_a_stat_card_per_field(): + for field in DEPLOYMENT_FIELDS: + card = admin_page.widgets[f"deployment_{field}"] + + assert isinstance(card, Stat) + assert (card.source, card.y) == ("deployment", field) def test_admin_page__expect_private(): assert admin_page.public is False -def test_admin_page__expect_logs_then_members_then_command_errors(): +def test_admin_page__expect_health_then_commands_then_moderation_then_tables(): assert admin_page.rows == [ + Row([Column([f"deployment_{field}"]) for field in DEPLOYMENT_FIELDS]), + Row([Column(["process_memory", "process_cpu"], 4), Column(["process"])]), + Row([Column(["error_rate"], 4), Column(["gateway_events"])]), Row([Column(["logs"])]), + Row([Column(["command_duration"]), Column(["command_error"])]), + Row([Column(["recent_commands"]), Column(["recent_moderation"])]), + Row([Column(["enabled_extensions"]), Column(["disabled_extensions"])]), + Row([Column(["jobs"])]), Row([Column(["members"])]), - Row([Column(["command_error"])]), ] -def test_admin_page__with_logs__expect_logs_widget(): - assert isinstance(admin_page.widgets["logs"], Logs) - - -def test_admin_page__with_command_errors__expect_persisted_bar_chart(): - chart = admin_page.widgets["command_error"] +def test_admin_page__with_recent_moderation__expect_newest_hundred(): + table = admin_page.widgets["recent_moderation"] - assert isinstance(chart, BarChart) - assert chart.x == "name" - assert chart.persist is True + assert isinstance(table, RecentTable) + assert table.columns == ["time", "action", "member", "by", "details"] + assert table.keep == 100 -def test_admin_page__with_members_table__expect_a_loader(): - members = admin_page.widgets["members"] +@pytest.mark.parametrize( + "name, loader, actions", + [ + ("enabled_extensions", extensions.load_enabled, ["disable_extension"]), + ("disabled_extensions", extensions.load_disabled, ["enable_extension"]), + ], +) +def test_admin_page__with_extension_tables__expect_the_action_moving_them_across( + name, loader, actions +): + table = admin_page.widgets[name] - assert isinstance(members, Table) - assert members.loader is load_members - assert members.persist is False - assert members.actions == [] + assert table.source == "extensions" + assert table.loader is loader + assert table.actions == actions -def _fake_bot(guild): - return type("Bot", (), {"get_guild": staticmethod(lambda guild_id: guild)})() +def test_admin_page__expect_actions_from_the_extension_handlers(): + assert admin_page.actions["enable_extension"].handler is extensions.enable + assert admin_page.actions["disable_extension"].handler is extensions.disable -def _fake_member(id, name, nick=None, joined_at=datetime(2025, 3, 14)): - return type( - "Member", (), {"id": id, "name": name, "nick": nick, "joined_at": joined_at} - )() +def test_admin_page__with_jobs_table__expect_jobs_loader(): + assert admin_page.widgets["jobs"].loader is jobs.load -def _fake_guild(members): - return type("Guild", (), {"members": members})() +def test_admin_page__expect_private_actions_disable_confirmed(): + assert admin_page.actions["disable_extension"].confirm is True + assert not admin_page.actions["enable_extension"].public + assert not admin_page.actions["disable_extension"].public -def test_load_members__expect_every_member_as_a_row(): - bot = _fake_bot(_fake_guild([_fake_member(1, "ada", nick="Ada L.")])) +def test_admin_page__with_process__expect_memory_and_cpu_numbers(): + memory = admin_page.widgets["process_memory"] + cpu = admin_page.widgets["process_cpu"] - rows, total = load_members({"bot": bot}, TableQuery(limit=5)) + assert (memory.source, memory.y, memory.unit) == ("process", "memory_mb", "MB") + assert (cpu.source, cpu.y, cpu.unit) == ("process", "cpu_percent", "%") - assert rows == [ - { - "id": 1, - "name": "ada", - "nickname": "Ada L.", - "joined at": "Fri 14 Mar 2025", - } - ] - assert total == 1 +def test_admin_page__with_command_duration__expect_persisted_average(): + chart = admin_page.widgets["command_duration"] -def test_load_members__with_offset_and_limit__expect_that_page_and_the_total(): - members = [_fake_member(i, f"user{i}") for i in range(3)] - bot = _fake_bot(_fake_guild(members)) + assert isinstance(chart, BarChart) + assert chart.aggregate == "average" + assert chart.persist is True - first_rows, first_total = load_members({"bot": bot}, TableQuery(limit=2)) - second_rows, second_total = load_members( - {"bot": bot}, TableQuery(offset=2, limit=2) - ) - assert [row["id"] for row in first_rows] == [0, 1] - assert [row["id"] for row in second_rows] == [2] - assert first_total == second_total == 3 +def test_admin_page__with_recent_commands__expect_newest_hundred(): + table = admin_page.widgets["recent_commands"] + assert isinstance(table, RecentTable) + assert table.columns == ["time", "command", "user", "status", "ms", "error"] + assert table.keep == 100 -def test_load_members__with_search__expect_matching_name_or_nickname_only(): - members = [ - _fake_member(1, "ada", nick="Countess"), - _fake_member(2, "grace"), - _fake_member(3, "linus", nick="ADA fan"), - ] - bot = _fake_bot(_fake_guild(members)) - rows, total = load_members({"bot": bot}, TableQuery(search="ada", limit=10)) +def test_admin_page__with_error_rate__expect_average_failed_percent_over_a_day(): + number = admin_page.widgets["error_rate"] - assert [row["id"] for row in rows] == [1, 3] - assert total == 2 + assert (number.source, number.y, number.aggregate, number.unit) == ( + "command_outcome", + "failed", + "average", + "%", + ) + assert number.over == timedelta(hours=24) -def test_load_members__sorted_by_joined_at__expect_date_order_not_text_order(): - members = [ - _fake_member(1, "ada", joined_at=datetime(2025, 3, 14)), # "Fri ..." - _fake_member(2, "grace", joined_at=datetime(2024, 1, 1)), # "Mon ..." - _fake_member(3, "linus", joined_at=datetime(2025, 1, 2)), # "Thu ..." - ] - bot = _fake_bot(_fake_guild(members)) +def test_admin_page__with_gateway_events__expect_per_minute_counts_over_a_day(): + chart = admin_page.widgets["gateway_events"] - rows, _ = load_members( - {"bot": bot}, TableQuery(sort_by="joined at", descending=True, limit=10) - ) + assert isinstance(chart, TimeSeriesChart) + assert chart.aggregate == "average" + assert chart.every == timedelta(minutes=10) + assert chart.over == timedelta(days=1) - assert [row["id"] for row in rows] == [1, 3, 2] +def test_admin_page__with_logs__expect_logs_widget(): + assert isinstance(admin_page.widgets["logs"], Logs) -def test_load_members__sorted_by_nickname__expect_missing_nicknames_first(): - members = [ - _fake_member(1, "ada", nick="zed"), - _fake_member(2, "grace"), - _fake_member(3, "linus", nick="Bob"), - ] - bot = _fake_bot(_fake_guild(members)) - rows, _ = load_members({"bot": bot}, TableQuery(sort_by="nickname", limit=10)) +def test_admin_page__with_command_errors__expect_persisted_bar_chart(): + chart = admin_page.widgets["command_error"] - assert [row["id"] for row in rows] == [2, 3, 1] + assert isinstance(chart, BarChart) + assert chart.x == "name" + assert chart.persist is True -def test_load_members__with_no_guild__expect_no_rows(): - rows, total = load_members({"bot": _fake_bot(None)}, TableQuery(limit=5)) +def test_admin_page__with_members_table__expect_a_loader(): + table = admin_page.widgets["members"] - assert rows == [] - assert total == 0 + assert isinstance(table, Table) + assert table.loader is members.load + assert table.persist is False + assert table.actions == [] diff --git a/tests/lib/dashboard/test_dashboard.py b/tests/lib/dashboard/test_dashboard.py index a6cdb3d..c703825 100644 --- a/tests/lib/dashboard/test_dashboard.py +++ b/tests/lib/dashboard/test_dashboard.py @@ -26,6 +26,30 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): "on_command", "command_error", "connection_event", + "commands_24h", + "commands_per_day", + "reactions_per_day", + "reaction_emojis", + "error_rate", + "gateway_events", + "recent_joins", + "member_flow", + "recent_moderation", + "channel_messages", + "enabled_extensions", + "disabled_extensions", + "jobs", + "deployment_environment", + "deployment_commit", + "deployment_started", + "deployment_uptime", + "deployment_discord_py", + "deployment_python", + "process_memory", + "process_cpu", + "process", + "command_duration", + "recent_commands", ] ) - assert list(dashboard.actions) == [] + assert list(dashboard.actions) == ["enable_extension", "disable_extension"] diff --git a/tests/lib/dashboard/widgets/test_recent_table.py b/tests/lib/dashboard/widgets/test_recent_table.py new file mode 100644 index 0000000..df4d7a6 --- /dev/null +++ b/tests/lib/dashboard/widgets/test_recent_table.py @@ -0,0 +1,45 @@ +import pytest +from dachshund.storage.events import Event + +from lib.dashboard.widgets.recent_table import RecentTable + + +def _events(count): + return [Event("errors", {"n": n}) for n in range(count)] + + +def test_render__expect_newest_first(): + table = RecentTable("errors", columns=["n"]) + + rows = table.render(_events(3))["rows"] + + assert [row["fields"]["n"] for row in rows] == [2, 1, 0] + + +def test_render__with_more_than_keep__expect_only_the_newest(): + table = RecentTable("errors", columns=["n"], keep=2) + + rows = table.render(_events(5))["rows"] + + assert [row["fields"]["n"] for row in rows] == [4, 3] + + +def test_keep__expect_honoured_by_compaction(): + from dachshund import Dashboard + from dachshund.runtime.ws import kept_raw + + dashboard = Dashboard(path=":memory:") + with dashboard.row(): + dashboard.add(RecentTable("errors", columns=["n"], keep=50)) + + assert kept_raw(dashboard)["errors"] == 50 + + +def test_persist__expect_error(): + with pytest.raises(ValueError, match="keep"): + RecentTable("errors", columns=["n"], persist=True, key="n") + + +def test_keep__below_one__expect_error(): + with pytest.raises(ValueError, match="keep"): + RecentTable("errors", columns=["n"], keep=0) diff --git a/tests/lib/dashboard/widgets/test_stat.py b/tests/lib/dashboard/widgets/test_stat.py new file mode 100644 index 0000000..3b8564b --- /dev/null +++ b/tests/lib/dashboard/widgets/test_stat.py @@ -0,0 +1,24 @@ +from dachshund.storage.events import Event + +from lib.dashboard.widgets.stat import Stat + + +def test_render__expect_the_newest_events_field_as_text(): + stat = Stat("python", source="deployment", y="python") + events = [ + Event("deployment", {"python": "3.11.0"}), + Event("deployment", {"python": "3.12.3"}), + ] + + assert stat.render(events) == {"value": "3.12.3"} + + +def test_render__with_a_number__expect_text(): + assert Stat("count").render([Event("count", {"value": 7})]) == {"value": "7"} + + +def test_render__without_events_or_field__expect_none(): + stat = Stat("commit", source="deployment", y="commit") + + assert stat.render([]) == {"value": None} + assert stat.render([Event("deployment", {"python": "3.12.3"})]) == {"value": None} diff --git a/tests/lib/dashboard/widgets/test_top_bar_chart.py b/tests/lib/dashboard/widgets/test_top_bar_chart.py new file mode 100644 index 0000000..8a7b2cc --- /dev/null +++ b/tests/lib/dashboard/widgets/test_top_bar_chart.py @@ -0,0 +1,30 @@ +import pytest +from dachshund.storage.events import Event + +from lib.dashboard.widgets.top_bar_chart import TopBarChart + + +def _events(*emojis): + return [Event("emojis", {"emoji": emoji, "value": 1}) for emoji in emojis] + + +def test_render__expect_biggest_bars_first(): + chart = TopBarChart("emojis", x="emoji") + + payload = chart.render(_events("👍", "🔥", "👍", "🎉", "👍", "🔥")) + + assert payload["x"] == ["👍", "🔥", "🎉"] + assert payload["y"] == [[3.0, 2.0, 1.0]] + + +def test_render__with_more_than_top__expect_only_the_biggest(): + chart = TopBarChart("emojis", x="emoji", top=2) + + payload = chart.render(_events("a", "b", "b", "c", "c", "c")) + + assert payload["x"] == ["c", "b"] + + +def test_top__below_one__expect_error(): + with pytest.raises(ValueError, match="top"): + TopBarChart("emojis", x="emoji", top=0) diff --git a/tests/services/test_process_stats_service.py b/tests/services/test_process_stats_service.py new file mode 100644 index 0000000..cdac3e3 --- /dev/null +++ b/tests/services/test_process_stats_service.py @@ -0,0 +1,29 @@ +from unittest.mock import patch + +from bot.services import process_stats_service +from bot.services.process_stats_service import ProcessStats, memory_mb + + +def test_memory_mb__expect_this_process_uses_some(): + assert memory_mb() > 0 + + +def test_sample__expect_cpu_percent_of_one_core_since_last_sample(): + stats = ProcessStats(_cpu=10.0, _at=100.0) + + with ( + patch.object(process_stats_service, "_cpu_seconds", return_value=10.5), + patch.object(process_stats_service.time, "monotonic", return_value=102.0), + patch.object(process_stats_service, "memory_mb", return_value=123.456), + ): + sample = stats.sample() + + assert sample == {"memory_mb": 123.5, "cpu_percent": 25.0} + + +def test_uptime__expect_days_hours_minutes(): + stats = ProcessStats(started_at=0) + + assert stats.uptime(now=2 * 86400 + 3 * 3600 + 4 * 60 + 59) == "2 d 3 h 4 min" + assert stats.uptime(now=3600) == "1 h" + assert stats.uptime(now=30) == "0 min" From c7708390813214500545c5cf85e6d2d1c9405be2 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 04:07:47 -0500 Subject: [PATCH 06/11] Improved login/auth from dashchund --- bot/grace.py | 14 +- config/settings.cfg | 2 +- lib/dashboard/auth/oauth.py | 19 +-- lib/dashboard/auth/proxy.py | 109 ------------ lib/dashboard/auth/service.py | 55 ++----- lib/dashboard/auth/session.py | 2 +- lib/dashboard/auth/settings.py | 2 +- lib/dashboard/dashboard.py | 7 +- lib/dashboard/pages/admin.py | 3 +- tests/lib/dashboard/auth/settings.py | 2 +- tests/lib/dashboard/auth/test_oauth.py | 18 +- tests/lib/dashboard/auth/test_service.py | 192 ++-------------------- tests/lib/dashboard/auth/test_settings.py | 2 +- 13 files changed, 57 insertions(+), 370 deletions(-) delete mode 100644 lib/dashboard/auth/proxy.py diff --git a/bot/grace.py b/bot/grace.py index ed9e842..a5ff2bd 100644 --- a/bot/grace.py +++ b/bot/grace.py @@ -67,16 +67,12 @@ async def setup_hook(self): self.metrics = await DashboardMetrics.load() dashboard.init_dachshund() dashboard.build_dashboard() - self.auth = AuthService( - self, dashboard.DASHBOARD_URL, AuthSettings.from_config(self.app.config) - ) - dashboard.start_dashboard({"bot": self}, **self.auth.dashboard_access()) - await self.auth.start() + self.auth = AuthService(self, AuthSettings.from_config(self.app.config)) - async def close(self): - if self.auth: - await self.auth.stop() - await super().close() + if not self.auth.settings.enabled: + logger.warning("Dashboard login is off, serving it on this machine only.") + + dashboard.start_dashboard({"bot": self}, **self.auth.dashboard_access()) async def on_ready(self): logger.info(f"{self.user.name}#{self.user.id} is online and ready to use!") diff --git a/config/settings.cfg b/config/settings.cfg index 77db4af..2041dc1 100644 --- a/config/settings.cfg +++ b/config/settings.cfg @@ -15,7 +15,7 @@ event_store = ${DACHSHUND_EVENT_STORE_PATH} [dashboard] ; Discord OAuth2 login in front of the dashboard, on unless the environment sets -; dashboard_auth = False. The redirect URI ends in /auth/callback and must also be +; dashboard_auth = False. The redirect URI ends in /_auth/callback and must also be ; added to the bot's application in the Discord developer portal. Guild ; administrators get in, and members with role_id when it's set. client_id = ${DISCORD_CLIENT_ID} diff --git a/lib/dashboard/auth/oauth.py b/lib/dashboard/auth/oauth.py index f67587c..b4a32d0 100644 --- a/lib/dashboard/auth/oauth.py +++ b/lib/dashboard/auth/oauth.py @@ -3,6 +3,8 @@ import hmac import secrets +from typing import Awaitable, Callable + from aiohttp import BasicAuth, ClientSession, web from discord.utils import oauth_url @@ -11,13 +13,11 @@ DISCORD_API_URL = "https://discord.com/api/v10" +Handler = Callable[[web.Request], Awaitable[web.StreamResponse]] -class DiscordOAuth: - """The login, callback and logout routes.""" - LOGIN_PATH = "/login" - CALLBACK_PATH = "/auth/callback" - LOGOUT_PATH = "/logout" +class DiscordOAuth: + """The login, callback and logout handlers, served by the dashboard at `/_auth/`.""" STATE_COOKIE = "grace_oauth_state" STATE_TTL = 10 * 60 @@ -26,12 +26,9 @@ def __init__(self, settings: AuthSettings, sessions: Sessions) -> None: self.settings = settings self.sessions = sessions - def routes(self) -> list[web.RouteDef]: - return [ - web.get(self.LOGIN_PATH, self.login), - web.get(self.CALLBACK_PATH, self.callback), - web.get(self.LOGOUT_PATH, self.logout), - ] + def handlers(self) -> dict[str, Handler]: + """The dashboard's `login`.""" + return {"login": self.login, "callback": self.callback, "logout": self.logout} async def login(self, request: web.Request) -> web.Response: """Send the visitor to Discord, remembering `next` and a fresh `state`.""" diff --git a/lib/dashboard/auth/proxy.py b/lib/dashboard/auth/proxy.py deleted file mode 100644 index 1ca835c..0000000 --- a/lib/dashboard/auth/proxy.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Forwards requests, websockets included, to the dashboard.""" - -import asyncio -from typing import Awaitable, Callable, Mapping - -from aiohttp import ( - ClientSession, - ClientWebSocketResponse, - WSMsgType, - WSServerHandshakeError, - web, -) -from yarl import URL - -# Host is kept, so the dashboard builds its URLs, like a login's `next`, with -# the public address rather than its own. -HOP_HEADERS = { - "connection", - "upgrade", - "keep-alive", - "transfer-encoding", - "content-length", - "content-encoding", - "sec-websocket-key", - "sec-websocket-version", - "sec-websocket-extensions", - "sec-websocket-accept", -} - -CLIENT_SESSION = web.AppKey("proxy_client_session", ClientSession) - -Handler = Callable[[web.Request], Awaitable[web.StreamResponse]] - - -def _headers(headers: Mapping[str, str]) -> dict[str, str]: - return {k: v for k, v in headers.items() if k.lower() not in HOP_HEADERS} - - -async def client_session(app: web.Application): - """Open the session the proxy forwards with, for the app's lifetime.""" - app[CLIENT_SESSION] = ClientSession() - yield - await app[CLIENT_SESSION].close() - - -def proxy_to(upstream: URL) -> Handler: - """A handler forwarding each request to the same path on `upstream`.""" - - async def handler(request: web.Request) -> web.StreamResponse: - session = request.app[CLIENT_SESSION] - target = upstream.join(request.rel_url) - - if request.headers.get("Upgrade", "").lower() == "websocket": - return await _websocket(request, session, target.with_scheme("ws")) - - async with session.request( - request.method, - target, - headers=_headers(request.headers), - data=await request.read(), - allow_redirects=False, - ) as response: - return web.Response( - status=response.status, - body=await response.read(), - headers=_headers(response.headers), - ) - - return handler - - -async def _websocket( - request: web.Request, session: ClientSession, target: URL -) -> web.StreamResponse: - # The dashboard answers first, so a refusal, like a 401 for a private page, - # reaches the browser as is. - try: - server = await session.ws_connect(target, headers=_headers(request.headers)) - except WSServerHandshakeError as error: - return web.Response(status=error.status) - - client = web.WebSocketResponse() - await client.prepare(request) - - async with server: - pumps = [ - asyncio.create_task(_pump(client, server)), - asyncio.create_task(_pump(server, client)), - ] - # One side closing closes the other. - await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED) - - for pump in pumps: - pump.cancel() - - await client.close() - - return client - - -async def _pump( - source: web.WebSocketResponse | ClientWebSocketResponse, - sink: web.WebSocketResponse | ClientWebSocketResponse, -) -> None: - async for message in source: - if message.type == WSMsgType.TEXT: - await sink.send_str(message.data) - elif message.type == WSMsgType.BINARY: - await sink.send_bytes(message.data) diff --git a/lib/dashboard/auth/service.py b/lib/dashboard/auth/service.py index 97369b7..0bcaaf9 100644 --- a/lib/dashboard/auth/service.py +++ b/lib/dashboard/auth/service.py @@ -1,35 +1,26 @@ -"""The dashboard's front door: a Discord login, then a proxy to dachshund.""" +"""Who may see the dashboard's private pages, and the Discord login that decides it.""" -import logging from typing import Any from aiohttp import web -from yarl import URL from lib.dashboard.auth.oauth import DiscordOAuth -from lib.dashboard.auth.proxy import client_session, proxy_to from lib.dashboard.auth.session import Sessions from lib.dashboard.auth.settings import AuthSettings -logger = logging.getLogger(__name__) - class AuthService: - """Serves `upstream` on `PORT`, with a Discord login when enabled. + """Lets in guild administrators and members with the configured role. - The dashboard decides which pages are private and asks `authorize`, which - lets in guild administrators and members with the configured role. + The dashboard decides which pages are private, asks `authorize`, and serves + the Discord login itself. """ - PORT = 1212 - - def __init__(self, bot: Any, upstream: URL, settings: AuthSettings) -> None: + def __init__(self, bot: Any, settings: AuthSettings) -> None: self.bot = bot - self.upstream = upstream self.settings = settings self.sessions = Sessions(settings.session_secret) self.oauth = DiscordOAuth(settings, self.sessions) - self._runner: web.AppRunner | None = None def is_allowed(self, user_id: int | None) -> bool: guild = self.bot.get_guild(self.settings.guild_id) @@ -49,34 +40,12 @@ async def authorize(self, request: web.Request) -> bool: return self.is_allowed(self.sessions.current_user(request)) def dashboard_access(self) -> dict[str, Any]: - """The `authorize` and `login_url` to start the dashboard with, none when the login is off.""" - if not self.settings.enabled: - return {} - - return {"authorize": self.authorize, "login_url": self.oauth.LOGIN_PATH} - - def build_app(self) -> web.Application: - app = web.Application() - app.cleanup_ctx.append(client_session) - - if self.settings.enabled: - app.add_routes(self.oauth.routes()) - - app.add_routes([web.route("*", "/{tail:.*}", proxy_to(self.upstream))]) - - return app - - async def start(self) -> None: + """The dashboard's `host`, and its `authorize` and `login` when the login is on.""" if not self.settings.enabled: - logger.warning("Dashboard login is off, serving it on this machine only.") - - # No access log: dachshund already logs each request, and these lines, - # logged on the bot's thread, would feed the Logs widget its own refreshes. - self._runner = web.AppRunner(self.build_app(), access_log=None) - await self._runner.setup() - await web.TCPSite(self._runner, self.settings.host, self.PORT).start() + return {"host": self.settings.host} - async def stop(self) -> None: - if self._runner is not None: - await self._runner.cleanup() - self._runner = None + return { + "host": self.settings.host, + "authorize": self.authorize, + "login": self.oauth.handlers(), + } diff --git a/lib/dashboard/auth/session.py b/lib/dashboard/auth/session.py index f6ed841..2b7ed14 100644 --- a/lib/dashboard/auth/session.py +++ b/lib/dashboard/auth/session.py @@ -54,7 +54,7 @@ def current_user(self, request: web.Request) -> int | None: def safe_next(value: str | None) -> str: """The path and query of `value`, `/` if that isn't a path on this site. - The dashboard sends its own full URL, behind the proxy, so only the path is kept. + The dashboard sends its own full URL, so only the path is kept. """ parts = urlsplit(value or "") path = parts.path diff --git a/lib/dashboard/auth/settings.py b/lib/dashboard/auth/settings.py index 1aa5f7b..9ab035a 100644 --- a/lib/dashboard/auth/settings.py +++ b/lib/dashboard/auth/settings.py @@ -52,5 +52,5 @@ def get(key: str) -> str: @property def host(self) -> str: - """Where the service listens: only this machine when the login is off.""" + """Where the dashboard listens: only this machine when the login is off.""" return "0.0.0.0" if self.enabled else "127.0.0.1" diff --git a/lib/dashboard/dashboard.py b/lib/dashboard/dashboard.py index 50105c4..fd60b19 100644 --- a/lib/dashboard/dashboard.py +++ b/lib/dashboard/dashboard.py @@ -1,5 +1,4 @@ import dachshund -from yarl import URL from bot import app from lib.dashboard.pages.activity import activity_page @@ -8,8 +7,6 @@ from lib.dashboard.sqlmodel_storage import SQLModelStorage DACHSHUND_EVENT_STORE = app.config.get("dachshund", "event_store", "dachshund.db") -# Only reachable through lib.dashboard.auth, which serves it publicly. -DASHBOARD_URL = URL("http://127.0.0.1:1213") def init_dachshund(): @@ -19,12 +16,10 @@ def init_dachshund(): def start_dashboard(context: dict, **access) -> None: - """Start dachshund, with `access` (`authorize`, `login_url`) guarding its private pages.""" + """Start dachshund, with `access` (`host`, `authorize`, `login`) guarding its private pages.""" dachshund.init( "Grace", description="Code Society's Discord Bot", - host=DASHBOARD_URL.host, - port=DASHBOARD_URL.port, timezone="US/Eastern", context=context, **access, diff --git a/lib/dashboard/pages/admin.py b/lib/dashboard/pages/admin.py index a579f72..7cf0059 100644 --- a/lib/dashboard/pages/admin.py +++ b/lib/dashboard/pages/admin.py @@ -18,9 +18,8 @@ # One "deployment" event, a field per card. DEPLOYMENT = [ ("environment", "Environment"), - ("commit", "Commit"), ("started", "Started"), - ("uptime", "Uptime"), + ("commit", "Commit"), ("discord_py", "discord.py"), ("python", "Python"), ] diff --git a/tests/lib/dashboard/auth/settings.py b/tests/lib/dashboard/auth/settings.py index e697982..5e2ad81 100644 --- a/tests/lib/dashboard/auth/settings.py +++ b/tests/lib/dashboard/auth/settings.py @@ -5,6 +5,6 @@ guild_id=99, client_id="1234", client_secret="client-secret", - redirect_uri="https://grace.example/auth/callback", + redirect_uri="https://grace.example/_auth/callback", session_secret="session-secret", ) diff --git a/tests/lib/dashboard/auth/test_oauth.py b/tests/lib/dashboard/auth/test_oauth.py index 881f7b5..138369d 100644 --- a/tests/lib/dashboard/auth/test_oauth.py +++ b/tests/lib/dashboard/auth/test_oauth.py @@ -29,7 +29,7 @@ def _request(path="/", cookies=None): @pytest.mark.asyncio async def test_login__expect_redirect_to_discord_with_state_cookie(oauth): - response = await oauth.login(_request("/login?next=/activity")) + response = await oauth.login(_request("/_auth/login?next=/activity")) location = urlparse(response.headers["Location"]) query = parse_qs(location.query) @@ -47,7 +47,7 @@ async def test_login__expect_redirect_to_discord_with_state_cookie(oauth): @pytest.mark.asyncio async def test_login__with_offsite_next__expect_home(oauth): - response = await oauth.login(_request("/login?next=//evil.example")) + response = await oauth.login(_request("/_auth/login?next=//evil.example")) assert response.cookies[STATE].value.endswith("|/") @@ -56,7 +56,7 @@ async def test_login__with_offsite_next__expect_home(oauth): async def test_callback__with_matching_state__expect_session_and_redirect(oauth): oauth.fetch_user_id = AsyncMock(return_value=42) request = _request( - "/auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/activity"} + "/_auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/activity"} ) response = await oauth.callback(request) @@ -73,10 +73,10 @@ async def test_callback__with_matching_state__expect_session_and_redirect(oauth) @pytest.mark.parametrize( "path, cookies", [ - ("/auth/callback?code=abc&state=wrong", {STATE: "xyz|/"}), - ("/auth/callback?code=abc&state=xyz", {}), - ("/auth/callback?state=xyz", {STATE: "xyz|/"}), - ("/auth/callback?code=abc&state=", {STATE: "|/"}), + ("/_auth/callback?code=abc&state=wrong", {STATE: "xyz|/"}), + ("/_auth/callback?code=abc&state=xyz", {}), + ("/_auth/callback?state=xyz", {STATE: "xyz|/"}), + ("/_auth/callback?code=abc&state=", {STATE: "|/"}), ], ) async def test_callback__with_bad_state_or_code__expect_bad_request( @@ -93,7 +93,7 @@ async def test_callback__with_bad_state_or_code__expect_bad_request( @pytest.mark.asyncio async def test_callback__when_discord_fails__expect_bad_gateway(oauth): oauth.fetch_user_id = AsyncMock(side_effect=OSError) - request = _request("/auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/"}) + request = _request("/_auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/"}) with pytest.raises(web.HTTPBadGateway): await oauth.callback(request) @@ -101,7 +101,7 @@ async def test_callback__when_discord_fails__expect_bad_gateway(oauth): @pytest.mark.asyncio async def test_logout__expect_session_cleared(oauth): - response = await oauth.logout(_request("/logout")) + response = await oauth.logout(_request("/_auth/logout")) assert response.status == 302 assert response.headers["Location"] == "/" diff --git a/tests/lib/dashboard/auth/test_service.py b/tests/lib/dashboard/auth/test_service.py index 7d50ff9..f69e2db 100644 --- a/tests/lib/dashboard/auth/test_service.py +++ b/tests/lib/dashboard/auth/test_service.py @@ -1,12 +1,9 @@ -import asyncio -import logging from contextlib import asynccontextmanager from dataclasses import replace from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from aiohttp import ClientSession, WSMsgType, web from aiohttp.test_utils import TestClient, TestServer, make_mocked_request from dachshund import Dashboard, Page from dachshund.widgets import Text @@ -39,8 +36,8 @@ def _bot(members=None, guild=True): return bot -def _service(bot=None, settings=SETTINGS, upstream=URL("http://upstream")): - return AuthService(bot or _bot(), upstream, settings) +def _service(bot=None, settings=SETTINGS): + return AuthService(bot or _bot(), settings) def test_is_allowed__with_administrator__expect_true(): @@ -84,83 +81,23 @@ def test_is_allowed__without_user__expect_false(): assert _service().is_allowed(None) is False -async def _echo_ws(request): - ws = web.WebSocketResponse() - await ws.prepare(request) - - async for message in ws: - if message.type == WSMsgType.TEXT: - await ws.send_str(f"echo {message.data}") - - return ws - - -async def _echo(request): - return web.json_response( - { - "method": request.method, - "path": request.path_qs, - "body": (await request.read()).decode(), - "cookie": request.headers.get("Cookie"), - "host": request.host, - }, - headers={"X-Upstream": "yes"}, - ) - - -async def _refuse_ws(_request): - raise web.HTTPUnauthorized() - - -async def _moved(_request): - raise web.HTTPFound("/elsewhere") - - -def _upstream(): - app = web.Application() - app.router.add_get("/_ws", _echo_ws) - app.router.add_get("/moved", _moved) - app.router.add_get("/_ws_private", _refuse_ws) - app.router.add_route("*", "/{tail:.*}", _echo) - return app - - -@asynccontextmanager -async def _client(*, secured): - upstream = TestServer(_upstream()) - await upstream.start_server() - - settings = SETTINGS if secured else AuthSettings(enabled=False, guild_id=99) - app = _service( - upstream=URL(str(upstream.make_url("/"))), settings=settings - ).build_app() - client = TestClient(TestServer(app)) - await client.start_server() - - try: - yield client - finally: - await client.close() - await upstream.close() - - def _session(user_id): return {Sessions.COOKIE: SESSIONS.sign(user_id)} -def test_dashboard_access__when_enabled__expect_authorize_and_login_url(): +def test_dashboard_access__when_enabled__expect_authorize_and_the_login_handlers(): service = _service() + access = service.dashboard_access() - assert service.dashboard_access() == { - "authorize": service.authorize, - "login_url": "/login", - } + assert access["host"] == "0.0.0.0" + assert access["authorize"] == service.authorize + assert sorted(access["login"]) == ["callback", "login", "logout"] -def test_dashboard_access__when_disabled__expect_nothing(): +def test_dashboard_access__when_disabled__expect_this_machine_only(): service = _service(settings=AuthSettings(enabled=False, guild_id=99)) - assert service.dashboard_access() == {} + assert service.dashboard_access() == {"host": "127.0.0.1"} @pytest.mark.asyncio @@ -180,9 +117,11 @@ async def test_authorize__expect_only_allowed_sessions(user_id, expected): @asynccontextmanager async def _dashboard_client(): - """The service in front of a real dashboard with a public and a private page.""" + """A real dashboard with the service's login, a public and a private page.""" service = _service() - dashboard = Dashboard(path=":memory:", **service.dashboard_access()) + access = service.dashboard_access() + access.pop("host") + dashboard = Dashboard(path=":memory:", **access) public = Page("open", title="Open", public=True) private = Page("admin", title="Admin") @@ -194,18 +133,13 @@ async def _dashboard_client(): dashboard.include(public) dashboard.include(private) - upstream = TestServer(dashboard._create_app()) - await upstream.start_server() - service.upstream = URL(str(upstream.make_url("/"))) - - client = TestClient(TestServer(service.build_app())) + client = TestClient(TestServer(dashboard._create_app())) await client.start_server() try: yield client finally: await client.close() - await upstream.close() @pytest.mark.asyncio @@ -225,7 +159,7 @@ async def test_secured__private_page__expect_login_redirect_back_to_it(user_id): location = URL(response.headers["Location"]) assert response.status == 302 - assert location.path == "/login" + assert location.path == "/_auth/login" assert safe_next(location.query["next"]) == "/admin" @@ -240,101 +174,7 @@ async def test_secured__private_page__with_admin_session__expect_shown(): @pytest.mark.asyncio async def test_secured__login_route__expect_discord(): async with _dashboard_client() as client: - response = await client.get("/login", allow_redirects=False) + response = await client.get("/_auth/login", allow_redirects=False) assert response.status == 302 assert response.headers["Location"].startswith("https://discord.com/") - - -@pytest.mark.asyncio -async def test_proxy__expect_request_forwarded_with_cookies(): - async with _client(secured=False) as client: - response = await client.post( - "/_actions/run?x=1", data="payload", cookies={"a": "b"} - ) - - body = await response.json() - assert response.status == 200 - assert response.headers["X-Upstream"] == "yes" - assert body["method"] == "POST" - assert body["path"] == "/_actions/run?x=1" - assert body["body"] == "payload" - assert body["cookie"] == "a=b" - assert body["host"] == f"{client.host}:{client.port}" - - -@pytest.mark.asyncio -async def test_not_secured__expect_proxied_without_session(): - async with _client(secured=False) as client: - response = await client.get("/activity", headers={"Accept": "text/html"}) - - assert response.status == 200 - assert (await response.json())["path"] == "/activity" - - -@pytest.mark.asyncio -async def test_not_secured__login_route__expect_proxied(): - async with _client(secured=False) as client: - response = await client.get("/login") - - assert (await response.json())["path"] == "/login" - - -@pytest.mark.asyncio -async def test_proxy__upstream_redirect__expect_passed_through(): - async with _client(secured=False) as client: - response = await client.get("/moved", allow_redirects=False) - - assert response.status == 302 - assert response.headers["Location"] == "/elsewhere" - - -@pytest.mark.asyncio -async def test_proxy__websocket__expect_messages_both_ways(): - async with _client(secured=False) as client: - ws = await client.ws_connect("/_ws") - - await ws.send_str("hi") - message = await ws.receive(timeout=2) - await ws.close() - - assert message.type == WSMsgType.TEXT - assert message.data == "echo hi" - - -@pytest.mark.asyncio -async def test_start__expect_no_access_log(caplog, monkeypatch): - service = _service(settings=AuthSettings(enabled=False, guild_id=99)) - monkeypatch.setattr(service, "PORT", 0) - # Test setup (alembic's fileConfig) disables existing loggers. - monkeypatch.setattr(logging.getLogger("aiohttp.access"), "disabled", False) - caplog.set_level(logging.INFO, logger="aiohttp.access") - await service.start() - - try: - host, port = service._runner.addresses[0][:2] - async with ClientSession() as http: - async with http.get(f"http://{host}:{port}/login"): - pass - # The access line is written once the response is done. - await asyncio.sleep(0.05) - finally: - await service.stop() - - assert not [r for r in caplog.records if r.name == "aiohttp.access"] - - -@pytest.mark.asyncio -async def test_proxy__websocket_refused__expect_status_passed_through(): - async with _client(secured=False) as client: - response = await client.get( - "/_ws_private", - headers={ - "Upgrade": "websocket", - "Connection": "Upgrade", - "Sec-WebSocket-Version": "13", - "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", - }, - ) - - assert response.status == 401 diff --git a/tests/lib/dashboard/auth/test_settings.py b/tests/lib/dashboard/auth/test_settings.py index 732ed64..44d6710 100644 --- a/tests/lib/dashboard/auth/test_settings.py +++ b/tests/lib/dashboard/auth/test_settings.py @@ -10,7 +10,7 @@ DASHBOARD = { "client_id": "1234", "client_secret": "client-secret", - "redirect_uri": "https://grace.example/auth/callback", + "redirect_uri": "https://grace.example/_auth/callback", "session_secret": "session-secret", } From 3f739a7c426eb591ae1be550bd7560d59697037d Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 04:29:43 -0500 Subject: [PATCH 07/11] move each widgets in their own modules --- lib/dashboard/widgets/__init__.py | 8 ++++++++ lib/dashboard/widgets/gauge/__init__.py | 3 +++ lib/dashboard/widgets/{ => gauge}/gauge.css | 0 lib/dashboard/widgets/{ => gauge}/gauge.js | 0 lib/dashboard/widgets/{ => gauge}/gauge.py | 0 lib/dashboard/widgets/recent_table/__init__.py | 3 +++ lib/dashboard/widgets/{ => recent_table}/recent_table.py | 0 lib/dashboard/widgets/stat/__init__.py | 3 +++ lib/dashboard/widgets/{ => stat}/stat.css | 0 lib/dashboard/widgets/{ => stat}/stat.js | 0 lib/dashboard/widgets/{ => stat}/stat.py | 0 lib/dashboard/widgets/top_bar_chart/__init__.py | 3 +++ .../widgets/{ => top_bar_chart}/top_bar_chart.py | 0 13 files changed, 20 insertions(+) create mode 100644 lib/dashboard/widgets/gauge/__init__.py rename lib/dashboard/widgets/{ => gauge}/gauge.css (100%) rename lib/dashboard/widgets/{ => gauge}/gauge.js (100%) rename lib/dashboard/widgets/{ => gauge}/gauge.py (100%) create mode 100644 lib/dashboard/widgets/recent_table/__init__.py rename lib/dashboard/widgets/{ => recent_table}/recent_table.py (100%) create mode 100644 lib/dashboard/widgets/stat/__init__.py rename lib/dashboard/widgets/{ => stat}/stat.css (100%) rename lib/dashboard/widgets/{ => stat}/stat.js (100%) rename lib/dashboard/widgets/{ => stat}/stat.py (100%) create mode 100644 lib/dashboard/widgets/top_bar_chart/__init__.py rename lib/dashboard/widgets/{ => top_bar_chart}/top_bar_chart.py (100%) diff --git a/lib/dashboard/widgets/__init__.py b/lib/dashboard/widgets/__init__.py index e69de29..b5c5dbb 100644 --- a/lib/dashboard/widgets/__init__.py +++ b/lib/dashboard/widgets/__init__.py @@ -0,0 +1,8 @@ +"""Grace's own dachshund widgets, a package each with its script and style.""" + +from lib.dashboard.widgets.gauge import Gauge +from lib.dashboard.widgets.recent_table import RecentTable +from lib.dashboard.widgets.stat import Stat +from lib.dashboard.widgets.top_bar_chart import TopBarChart + +__all__ = ["Gauge", "RecentTable", "Stat", "TopBarChart"] diff --git a/lib/dashboard/widgets/gauge/__init__.py b/lib/dashboard/widgets/gauge/__init__.py new file mode 100644 index 0000000..a0903cd --- /dev/null +++ b/lib/dashboard/widgets/gauge/__init__.py @@ -0,0 +1,3 @@ +from lib.dashboard.widgets.gauge.gauge import Gauge + +__all__ = ["Gauge"] diff --git a/lib/dashboard/widgets/gauge.css b/lib/dashboard/widgets/gauge/gauge.css similarity index 100% rename from lib/dashboard/widgets/gauge.css rename to lib/dashboard/widgets/gauge/gauge.css diff --git a/lib/dashboard/widgets/gauge.js b/lib/dashboard/widgets/gauge/gauge.js similarity index 100% rename from lib/dashboard/widgets/gauge.js rename to lib/dashboard/widgets/gauge/gauge.js diff --git a/lib/dashboard/widgets/gauge.py b/lib/dashboard/widgets/gauge/gauge.py similarity index 100% rename from lib/dashboard/widgets/gauge.py rename to lib/dashboard/widgets/gauge/gauge.py diff --git a/lib/dashboard/widgets/recent_table/__init__.py b/lib/dashboard/widgets/recent_table/__init__.py new file mode 100644 index 0000000..8c1cf0b --- /dev/null +++ b/lib/dashboard/widgets/recent_table/__init__.py @@ -0,0 +1,3 @@ +from lib.dashboard.widgets.recent_table.recent_table import RecentTable + +__all__ = ["RecentTable"] diff --git a/lib/dashboard/widgets/recent_table.py b/lib/dashboard/widgets/recent_table/recent_table.py similarity index 100% rename from lib/dashboard/widgets/recent_table.py rename to lib/dashboard/widgets/recent_table/recent_table.py diff --git a/lib/dashboard/widgets/stat/__init__.py b/lib/dashboard/widgets/stat/__init__.py new file mode 100644 index 0000000..ea91392 --- /dev/null +++ b/lib/dashboard/widgets/stat/__init__.py @@ -0,0 +1,3 @@ +from lib.dashboard.widgets.stat.stat import Stat + +__all__ = ["Stat"] diff --git a/lib/dashboard/widgets/stat.css b/lib/dashboard/widgets/stat/stat.css similarity index 100% rename from lib/dashboard/widgets/stat.css rename to lib/dashboard/widgets/stat/stat.css diff --git a/lib/dashboard/widgets/stat.js b/lib/dashboard/widgets/stat/stat.js similarity index 100% rename from lib/dashboard/widgets/stat.js rename to lib/dashboard/widgets/stat/stat.js diff --git a/lib/dashboard/widgets/stat.py b/lib/dashboard/widgets/stat/stat.py similarity index 100% rename from lib/dashboard/widgets/stat.py rename to lib/dashboard/widgets/stat/stat.py diff --git a/lib/dashboard/widgets/top_bar_chart/__init__.py b/lib/dashboard/widgets/top_bar_chart/__init__.py new file mode 100644 index 0000000..e8072de --- /dev/null +++ b/lib/dashboard/widgets/top_bar_chart/__init__.py @@ -0,0 +1,3 @@ +from lib.dashboard.widgets.top_bar_chart.top_bar_chart import TopBarChart + +__all__ = ["TopBarChart"] diff --git a/lib/dashboard/widgets/top_bar_chart.py b/lib/dashboard/widgets/top_bar_chart/top_bar_chart.py similarity index 100% rename from lib/dashboard/widgets/top_bar_chart.py rename to lib/dashboard/widgets/top_bar_chart/top_bar_chart.py From b1945c913606e23dd90c4ef3e7320971f19e23dd Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 04:32:19 -0500 Subject: [PATCH 08/11] Add dashboard login table --- lib/dashboard/auth/oauth.py | 22 ++++++- lib/dashboard/auth/service.py | 47 +++++++++++++- lib/dashboard/pages/admin.py | 33 +++++++--- tests/lib/dashboard/auth/test_oauth.py | 53 +++++++++++++++- tests/lib/dashboard/auth/test_service.py | 73 +++++++++++++++++++++- tests/lib/dashboard/pages/test_activity.py | 1 - tests/lib/dashboard/pages/test_admin.py | 14 ++++- tests/lib/dashboard/pages/test_overview.py | 31 ++++++++- 8 files changed, 254 insertions(+), 20 deletions(-) diff --git a/lib/dashboard/auth/oauth.py b/lib/dashboard/auth/oauth.py index b4a32d0..2882faf 100644 --- a/lib/dashboard/auth/oauth.py +++ b/lib/dashboard/auth/oauth.py @@ -14,6 +14,12 @@ DISCORD_API_URL = "https://discord.com/api/v10" Handler = Callable[[web.Request], Awaitable[web.StreamResponse]] +# Told of each login attempt and logout: the request, its outcome and the user, if known. +LoginRecorder = Callable[[web.Request, str, int | None], Awaitable[None]] + + +async def _ignore(_request: web.Request, _result: str, _user_id: int | None) -> None: + pass class DiscordOAuth: @@ -22,9 +28,15 @@ class DiscordOAuth: STATE_COOKIE = "grace_oauth_state" STATE_TTL = 10 * 60 - def __init__(self, settings: AuthSettings, sessions: Sessions) -> None: + def __init__( + self, + settings: AuthSettings, + sessions: Sessions, + record: LoginRecorder = _ignore, + ) -> None: self.settings = settings self.sessions = sessions + self.record = record def handlers(self) -> dict[str, Handler]: """The dashboard's `login`.""" @@ -54,21 +66,27 @@ async def callback(self, request: web.Request) -> web.Response: code = request.query.get("code") if not expected or not code or not hmac.compare_digest(state, expected): + await self.record(request, "invalid", None) raise web.HTTPBadRequest(text="Invalid login attempt.") try: user_id = await self.fetch_user_id(code) except Exception as exc: + await self.record(request, "error", None) raise web.HTTPBadGateway(text="Could not reach Discord.") from exc + await self.record(request, "login", user_id) + response = redirect(safe_next(next_path)) response.del_cookie(self.STATE_COOKIE, path="/") set_cookie(response, Sessions.COOKIE, self.sessions.sign(user_id), Sessions.TTL) return response - async def logout(self, _request: web.Request) -> web.Response: + async def logout(self, request: web.Request) -> web.Response: """End the session and return to the dashboard's home.""" + await self.record(request, "logout", self.sessions.current_user(request)) + response = redirect("/") response.del_cookie(Sessions.COOKIE, path="/") diff --git a/lib/dashboard/auth/service.py b/lib/dashboard/auth/service.py index 0bcaaf9..5f02a44 100644 --- a/lib/dashboard/auth/service.py +++ b/lib/dashboard/auth/service.py @@ -1,14 +1,24 @@ """Who may see the dashboard's private pages, and the Discord login that decides it.""" +import asyncio from typing import Any from aiohttp import web +from bot.helpers.dashboard_helper import record_login + from lib.dashboard.auth.oauth import DiscordOAuth from lib.dashboard.auth.session import Sessions from lib.dashboard.auth.settings import AuthSettings +def client_ip(request: web.Request) -> str: + """The visitor's address: the first X-Forwarded-For hop behind Railway's proxy.""" + forwarded = request.headers.get("X-Forwarded-For", "") + + return forwarded.split(",")[0].strip() or request.remote or "" + + class AuthService: """Lets in guild administrators and members with the configured role. @@ -20,7 +30,7 @@ def __init__(self, bot: Any, settings: AuthSettings) -> None: self.bot = bot self.settings = settings self.sessions = Sessions(settings.session_secret) - self.oauth = DiscordOAuth(settings, self.sessions) + self.oauth = DiscordOAuth(settings, self.sessions, self.record_login) def is_allowed(self, user_id: int | None) -> bool: guild = self.bot.get_guild(self.settings.guild_id) @@ -33,6 +43,41 @@ def is_allowed(self, user_id: int | None) -> bool: role.id == self.settings.role_id for role in member.roles ) + async def _lookup(self, user_id: int) -> tuple[str, bool]: + """The user's name, and whether they may see private pages.""" + guild = self.bot.get_guild(self.settings.guild_id) + user = (guild.get_member(user_id) if guild else None) or self.bot.get_user( + user_id + ) + + return (str(user) if user else "unknown"), self.is_allowed(user_id) + + async def record_login( + self, request: web.Request, result: str, user_id: int | None + ) -> None: + """Log a login attempt or logout to the dashboard logins table. + + The dashboard serves the login on its own thread, so reading the member + cache hops onto the bot's loop. A Discord login without access is + "denied": the session starts, but private pages stay closed. + """ + name, allowed = "", False + + if user_id is not None: + name, allowed = await asyncio.wrap_future( + asyncio.run_coroutine_threadsafe(self._lookup(user_id), self.bot.loop) + ) + + if result == "login" and not allowed: + result = "denied" + + record_login( + result, + user=name, + user_id="" if user_id is None else str(user_id), + ip=client_ip(request), + ) + async def authorize(self, request: web.Request) -> bool: """The dashboard's `authorize`, run on the bot's loop, so reading the member cache is safe. Losing access ends the session on the next request. diff --git a/lib/dashboard/pages/admin.py b/lib/dashboard/pages/admin.py index 7cf0059..3631851 100644 --- a/lib/dashboard/pages/admin.py +++ b/lib/dashboard/pages/admin.py @@ -3,7 +3,7 @@ from dachshund import Page from dachshund.widgets import BarChart, Logs, Number, Table, TimeSeriesChart -from bot.helpers.dashboard_helper import MODERATION_SOURCE +from bot.helpers.dashboard_helper import LOGIN_SOURCE, MODERATION_SOURCE from lib.dashboard.handlers import extensions, jobs, members from lib.dashboard.widgets.recent_table import RecentTable from lib.dashboard.widgets.stat import Stat @@ -124,6 +124,23 @@ ) ) +with admin_page.row(): + with admin_page.column(span=4): + # Overview's card is already named "member_count"; names are unique. + admin_page.add( + Number("admin_member_count", title="Members", source="member_count") + ) + + admin_page.add( + Table( + "members", + columns=members.COLUMNS, + search=True, + page_size=5, + loader=members.load, + ) + ) + with admin_page.row(): admin_page.add( @@ -160,14 +177,12 @@ ) ) - with admin_page.row(): admin_page.add( - Table( - "members", - columns=members.COLUMNS, - search=True, - page_size=5, - loader=members.load, + RecentTable( + LOGIN_SOURCE, + title="Dashboard Logins", + columns=["time", "user", "id", "result", "ip"], + keep=100, ) - ) + ) \ No newline at end of file diff --git a/tests/lib/dashboard/auth/test_oauth.py b/tests/lib/dashboard/auth/test_oauth.py index 138369d..0b7b0ed 100644 --- a/tests/lib/dashboard/auth/test_oauth.py +++ b/tests/lib/dashboard/auth/test_oauth.py @@ -15,7 +15,7 @@ @pytest.fixture def oauth(): - return DiscordOAuth(SETTINGS, SESSIONS) + return DiscordOAuth(SETTINGS, SESSIONS, AsyncMock()) def _request(path="/", cookies=None): @@ -106,3 +106,54 @@ async def test_logout__expect_session_cleared(oauth): assert response.status == 302 assert response.headers["Location"] == "/" assert response.cookies[Sessions.COOKIE].value == "" + + +@pytest.mark.asyncio +async def test_callback__expect_the_login_recorded(oauth): + oauth.fetch_user_id = AsyncMock(return_value=42) + request = _request("/auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/"}) + + await oauth.callback(request) + + oauth.record.assert_awaited_once_with(request, "login", 42) + + +@pytest.mark.asyncio +async def test_callback__with_bad_state__expect_invalid_recorded(oauth): + request = _request("/auth/callback?code=abc&state=wrong", cookies={STATE: "xyz|/"}) + + with pytest.raises(web.HTTPBadRequest): + await oauth.callback(request) + + oauth.record.assert_awaited_once_with(request, "invalid", None) + + +@pytest.mark.asyncio +async def test_callback__when_discord_fails__expect_error_recorded(oauth): + oauth.fetch_user_id = AsyncMock(side_effect=OSError) + request = _request("/auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/"}) + + with pytest.raises(web.HTTPBadGateway): + await oauth.callback(request) + + oauth.record.assert_awaited_once_with(request, "error", None) + + +@pytest.mark.asyncio +async def test_logout__expect_the_sessions_user_recorded(oauth): + request = _request(cookies={Sessions.COOKIE: SESSIONS.sign(42)}) + + await oauth.logout(request) + + oauth.record.assert_awaited_once_with(request, "logout", 42) + + +@pytest.mark.asyncio +async def test_without_recorder__expect_logins_still_work(): + oauth = DiscordOAuth(SETTINGS, SESSIONS) + oauth.fetch_user_id = AsyncMock(return_value=42) + request = _request("/auth/callback?code=abc&state=xyz", cookies={STATE: "xyz|/"}) + + response = await oauth.callback(request) + + assert response.status == 302 diff --git a/tests/lib/dashboard/auth/test_service.py b/tests/lib/dashboard/auth/test_service.py index f69e2db..73a8c4b 100644 --- a/tests/lib/dashboard/auth/test_service.py +++ b/tests/lib/dashboard/auth/test_service.py @@ -1,7 +1,8 @@ +import asyncio from contextlib import asynccontextmanager from dataclasses import replace from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from aiohttp.test_utils import TestClient, TestServer, make_mocked_request @@ -10,6 +11,7 @@ from yarl import URL from lib.dashboard.auth import AuthService, AuthSettings +from lib.dashboard.auth.service import client_ip from lib.dashboard.auth.session import Sessions, safe_next from tests.lib.dashboard.auth.settings import SETTINGS @@ -178,3 +180,72 @@ async def test_secured__login_route__expect_discord(): assert response.status == 302 assert response.headers["Location"].startswith("https://discord.com/") + + +class _Named(SimpleNamespace): + def __str__(self): + return self.name + + +def _login_bot(): + admin = _Named( + name="ada", + guild_permissions=SimpleNamespace(administrator=True), + roles=[], + ) + stranger = _Named( + name="bob", guild_permissions=SimpleNamespace(administrator=False), roles=[] + ) + bot = _bot({ADMIN: admin}) + # Not in the guild, but still a Discord user the bot can name. + bot.get_user.side_effect = {STRANGER: stranger}.get + bot.loop = asyncio.get_running_loop() + return bot + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "result, user_id, expected", + [ + ("login", ADMIN, {"result": "login", "user": "ada", "user_id": "42"}), + ("login", STRANGER, {"result": "denied", "user": "bob", "user_id": "7"}), + ("logout", ADMIN, {"result": "logout", "user": "ada", "user_id": "42"}), + ("invalid", None, {"result": "invalid", "user": "", "user_id": ""}), + ], +) +@patch("lib.dashboard.auth.service.record_login") +async def test_record_login__expect_named_row_denied_without_access( + mock_record, result, user_id, expected +): + request = make_mocked_request("GET", "/_auth/callback") + + await _service(_login_bot()).record_login(request, result, user_id) + + expected_result = expected.pop("result") + mock_record.assert_called_once_with(expected_result, ip="", **expected) + + +@pytest.mark.asyncio +@patch("lib.dashboard.auth.service.record_login") +async def test_record_login__from_another_thread__expect_lookup_on_the_bots_loop( + mock_record, +): + """The dashboard serves the login on its own thread and loop.""" + service = _service(_login_bot()) + request = make_mocked_request("GET", "/_auth/callback") + + await asyncio.to_thread(asyncio.run, service.record_login(request, "login", ADMIN)) + + assert mock_record.call_args.args == ("login",) + assert mock_record.call_args.kwargs["user"] == "ada" + + +@pytest.mark.parametrize( + "headers, expected", + [ + ({"X-Forwarded-For": "203.0.113.7, 10.0.0.1"}, "203.0.113.7"), + ({}, ""), + ], +) +def test_client_ip__expect_the_first_forwarded_hop(headers, expected): + assert client_ip(make_mocked_request("GET", "/", headers=headers)) == expected diff --git a/tests/lib/dashboard/pages/test_activity.py b/tests/lib/dashboard/pages/test_activity.py index 6725c90..3ff067b 100644 --- a/tests/lib/dashboard/pages/test_activity.py +++ b/tests/lib/dashboard/pages/test_activity.py @@ -17,7 +17,6 @@ def test_activity_page__expect_counts_then_members_then_messages_then_commands() assert activity_page.rows == [ Row( [ - Column(["member_count"]), Column(["active_members"]), Column(["daily_message_rate"]), Column(["commands_24h"]), diff --git a/tests/lib/dashboard/pages/test_admin.py b/tests/lib/dashboard/pages/test_admin.py index 035e29b..5ed4140 100644 --- a/tests/lib/dashboard/pages/test_admin.py +++ b/tests/lib/dashboard/pages/test_admin.py @@ -12,9 +12,8 @@ DEPLOYMENT_FIELDS = [ "environment", - "commit", "started", - "uptime", + "commit", "discord_py", "python", ] @@ -40,9 +39,10 @@ def test_admin_page__expect_health_then_commands_then_moderation_then_tables(): Row([Column(["logs"])]), Row([Column(["command_duration"]), Column(["command_error"])]), Row([Column(["recent_commands"]), Column(["recent_moderation"])]), + Row([Column(["admin_member_count"], 4), Column(["members"])]), + Row([Column(["dashboard_logins"])]), Row([Column(["enabled_extensions"]), Column(["disabled_extensions"])]), Row([Column(["jobs"])]), - Row([Column(["members"])]), ] @@ -131,6 +131,14 @@ def test_admin_page__with_gateway_events__expect_per_minute_counts_over_a_day(): assert chart.over == timedelta(days=1) +def test_admin_page__with_logins__expect_newest_hundred(): + table = admin_page.widgets["dashboard_logins"] + + assert isinstance(table, RecentTable) + assert table.columns == ["time", "user", "id", "result", "ip"] + assert table.keep == 100 + + def test_admin_page__with_logs__expect_logs_widget(): assert isinstance(admin_page.widgets["logs"], Logs) diff --git a/tests/lib/dashboard/pages/test_overview.py b/tests/lib/dashboard/pages/test_overview.py index f78cb86..49e15a4 100644 --- a/tests/lib/dashboard/pages/test_overview.py +++ b/tests/lib/dashboard/pages/test_overview.py @@ -1,4 +1,8 @@ +from datetime import timedelta + +import pytest from dachshund import Column, Row +from dachshund.widgets import Number from lib.dashboard.pages.overview import overview_page from lib.dashboard.widgets.gauge import Gauge @@ -10,12 +14,35 @@ def test_overview_page__expect_public(): def test_overview_page__expect_summary_then_latency_then_connections(): assert overview_page.rows == [ - Row([Column(["summary"])]), - Row([Column(["average_latency_gauge"], 4), Column(["latency"])]), + Row([Column(["summary"]), Column(["member_count"], 4)]), + Row( + [ + Column( + ["average_latency_gauge", "avg_response_time", "worst_latency"], 4 + ), + Column(["latency"]), + ] + ), Row([Column(["connection_event"])]), ] +@pytest.mark.parametrize( + "name, source, y, aggregate", + [ + ("avg_response_time", "command_duration", "value", "average"), + ("worst_latency", "latency", "check", "max"), + ], +) +def test_overview_page__expect_24h_response_numbers_in_ms(name, source, y, aggregate): + number = overview_page.widgets[name] + + assert isinstance(number, Number) + assert (number.source, number.y, number.aggregate) == (source, y, aggregate) + assert number.over == timedelta(hours=24) + assert number.unit == "ms" + + def test_overview_page__with_average_latency_gauge__expect_gauge_widget_in_ms(): gauge = overview_page.widgets["average_latency_gauge"] From 58ca3c5d46aacec3562fbeb7fd8016fea555c2a9 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 04:32:55 -0500 Subject: [PATCH 09/11] remove dashboard helper --- bot/extensions/dashboard_cog.py | 24 ++++++++----- bot/extensions/moderation_cog.py | 15 +++++--- bot/extensions/name_moderation_cog.py | 10 ++++-- bot/helpers/dashboard_helper.py | 34 ------------------- lib/dashboard/auth/service.py | 10 +++--- lib/dashboard/pages/admin.py | 8 +++-- lib/dashboard/pages/overview.py | 24 +++++++++++++ .../widgets/recent_table/recent_table.py | 27 ++++++++++++--- tests/helpers/__init__.py | 0 tests/helpers/test_dashboard_helper.py | 32 ----------------- tests/lib/dashboard/test_dashboard.py | 5 ++- 11 files changed, 95 insertions(+), 94 deletions(-) delete mode 100644 bot/helpers/dashboard_helper.py delete mode 100644 tests/helpers/__init__.py delete mode 100644 tests/helpers/test_dashboard_helper.py diff --git a/bot/extensions/dashboard_cog.py b/bot/extensions/dashboard_cog.py index 9107827..a96a275 100644 --- a/bot/extensions/dashboard_cog.py +++ b/bot/extensions/dashboard_cog.py @@ -18,9 +18,9 @@ from pytz import timezone from bot.grace import Grace -from bot.helpers.dashboard_helper import record_moderation, timestamp, today from bot.services.process_stats_service import ProcessStats +TIMEZONE = timezone("US/Eastern") STARTED_AT = "_dashboard_started_at" # Bar charts with a bar per date, which keep only the last DAILY_DAYS days. DAILY_SOURCES = ("commands_per_day", "reactions_per_day") @@ -78,6 +78,11 @@ def _commit() -> str: return "unknown" +def _today() -> str: + """Today's date, the day starting at midnight Eastern.""" + return datetime.now(TIMEZONE).date().isoformat() + + def _emoji_name(payload: RawReactionActionEvent) -> str: """A unicode emoji as is, a custom one as `:name:`.""" emoji = payload.emoji @@ -121,7 +126,7 @@ class DashboardCog( def __init__(self, bot: Grace): self.bot: Grace = bot self.jobs: list[Job] = [] - self.timezone: tzinfo = timezone("US/Eastern") + self.timezone: tzinfo = TIMEZONE self.process = ProcessStats() self.commit = _commit() self.gateway_events = 0 @@ -164,7 +169,7 @@ def _report_process(self) -> None: def _trim_daily_charts(self, now: date | None = None) -> None: """Remove the bars older than DAILY_DAYS from the per-day charts.""" - now = now or date.fromisoformat(today()) + 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() @@ -212,7 +217,7 @@ async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: if payload.guild_id is None or (payload.member and payload.member.bot): return - dachshund.emit("reactions_per_day", date=today(), value=1) + dachshund.emit("reactions_per_day", date=_today(), value=1) dachshund.emit("reaction_emojis", emoji=_emoji_name(payload), value=1) @Cog.listener() @@ -235,7 +240,7 @@ async def on_command(self, ctx: Context) -> None: if ctx.command: dachshund.emit("on_command", name=ctx.command.qualified_name, value=1) - dachshund.emit("commands_per_day", date=today(), value=1) + dachshund.emit("commands_per_day", date=_today(), value=1) @Cog.listener() async def on_command_completion(self, ctx: Context) -> None: @@ -265,8 +270,12 @@ async def on_command_error(self, ctx: Context, error: Exception) -> None: dachshund.emit("command_outcome", failed=100) if _is_moderation(ctx): - record_moderation( - f"{name} (failed)", by=str(ctx.author), details=_describe(error) + dachshund.emit( + "recent_moderation", + action=f"{name} (failed)", + member="", + by=str(ctx.author), + details=_describe(error), ) else: self._record_command(ctx, "error", _elapsed_ms(ctx), _describe(error)) @@ -277,7 +286,6 @@ def _record_command( """A row of the recent commands table.""" dachshund.emit( "recent_commands", - time=timestamp(), command=ctx.command.qualified_name if ctx.command else "unknown", user=str(ctx.author), status=status, diff --git a/bot/extensions/moderation_cog.py b/bot/extensions/moderation_cog.py index c315cef..c55c522 100644 --- a/bot/extensions/moderation_cog.py +++ b/bot/extensions/moderation_cog.py @@ -1,6 +1,8 @@ import logging from datetime import UTC, datetime +import dachshund + logger = logging.getLogger(__name__) from discord import Member, Message, Reaction @@ -8,7 +10,6 @@ from emoji import demojize from bot import app -from bot.helpers.dashboard_helper import record_moderation from bot.helpers.log_helper import danger, notice from bot.models.channel import Channel @@ -46,8 +47,10 @@ async def purge( log.add_field("Reason", reason) await ctx.channel.purge(limit=int(limit) + 1, bulk=True, reason=reason) - record_moderation( - "purge", + dachshund.emit( + "recent_moderation", + action="purge", + member="", by=str(ctx.author), details=f"{limit} message(s) in #{ctx.channel} · {reason}", ) @@ -120,9 +123,11 @@ async def on_member_join(self, member) -> None: f"Your account needs to be {minimum_account_age} days old or more to join the server." ) await member.guild.kick(user=member, reason="Account age restriction") - record_moderation( - "kick", + dachshund.emit( + "recent_moderation", + action="kick", member=str(member), + by="auto", details=f"account {account_age_in_days} day(s) old, " f"minimum {minimum_account_age}", ) diff --git a/bot/extensions/name_moderation_cog.py b/bot/extensions/name_moderation_cog.py index 451b9b9..3a67278 100644 --- a/bot/extensions/name_moderation_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -1,12 +1,12 @@ import logging +import dachshund from discord import Member from discord.errors import Forbidden from discord.ext.commands import Cog from bot import app from bot.grace import Grace -from bot.helpers.dashboard_helper import record_moderation from bot.helpers.log_helper import notice from bot.services.random_name_service import make_random_name @@ -48,7 +48,13 @@ async def on_member_join(self, member: Member) -> None: try: good_name = make_random_name() await member.edit(nick=good_name) - record_moderation("rename", member=name, details=f"renamed to {good_name}") + dachshund.emit( + "recent_moderation", + action="rename", + member=name, + by="auto", + details=f"renamed to {good_name}", + ) log = notice("NAME", f"Username of {name} was changed.") log.add_field( diff --git a/bot/helpers/dashboard_helper.py b/bot/helpers/dashboard_helper.py deleted file mode 100644 index f882827..0000000 --- a/bot/helpers/dashboard_helper.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Rows for the dashboard's recent commands and moderation tables.""" - -from datetime import datetime - -import dachshund -from pytz import timezone - -TIMEZONE = timezone("US/Eastern") - -MODERATION_SOURCE = "recent_moderation" - - -def today() -> str: - """Today's date, the day starting at midnight Eastern.""" - return datetime.now(TIMEZONE).date().isoformat() - - -def timestamp() -> str: - """Now, as the dashboard's tables show it.""" - return datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S") - - -def record_moderation( - action: str, *, member: str = "", by: str = "auto", details: str = "" -) -> None: - """A row of the recent moderation table: `by` a moderator, or "auto" for Grace's own.""" - dachshund.emit( - MODERATION_SOURCE, - time=timestamp(), - action=action, - member=member, - by=by, - details=details, - ) diff --git a/lib/dashboard/auth/service.py b/lib/dashboard/auth/service.py index 5f02a44..48ca21a 100644 --- a/lib/dashboard/auth/service.py +++ b/lib/dashboard/auth/service.py @@ -3,10 +3,9 @@ import asyncio from typing import Any +import dachshund from aiohttp import web -from bot.helpers.dashboard_helper import record_login - from lib.dashboard.auth.oauth import DiscordOAuth from lib.dashboard.auth.session import Sessions from lib.dashboard.auth.settings import AuthSettings @@ -71,10 +70,11 @@ async def record_login( if result == "login" and not allowed: result = "denied" - record_login( - result, + dachshund.emit( + "dashboard_logins", user=name, - user_id="" if user_id is None else str(user_id), + id="" if user_id is None else str(user_id), + result=result, ip=client_ip(request), ) diff --git a/lib/dashboard/pages/admin.py b/lib/dashboard/pages/admin.py index 3631851..10397e3 100644 --- a/lib/dashboard/pages/admin.py +++ b/lib/dashboard/pages/admin.py @@ -3,7 +3,6 @@ from dachshund import Page from dachshund.widgets import BarChart, Logs, Number, Table, TimeSeriesChart -from bot.helpers.dashboard_helper import LOGIN_SOURCE, MODERATION_SOURCE from lib.dashboard.handlers import extensions, jobs, members from lib.dashboard.widgets.recent_table import RecentTable from lib.dashboard.widgets.stat import Stat @@ -110,6 +109,7 @@ RecentTable( "recent_commands", title="Recent Commands", + timezone="US/Eastern", columns=["time", "command", "user", "status", "ms", "error"], keep=100, ) @@ -117,8 +117,9 @@ admin_page.add( RecentTable( - MODERATION_SOURCE, + "recent_moderation", title="Recent Moderation", + timezone="US/Eastern", columns=["time", "action", "member", "by", "details"], keep=100, ) @@ -180,8 +181,9 @@ with admin_page.row(): admin_page.add( RecentTable( - LOGIN_SOURCE, + "dashboard_logins", title="Dashboard Logins", + timezone="US/Eastern", columns=["time", "user", "id", "result", "ip"], keep=100, ) diff --git a/lib/dashboard/pages/overview.py b/lib/dashboard/pages/overview.py index b28a53a..41904b0 100644 --- a/lib/dashboard/pages/overview.py +++ b/lib/dashboard/pages/overview.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from dachshund import Page from dachshund.widgets import BarChart, Number, Text, TimeSeriesChart from lib.dashboard.widgets.gauge import Gauge @@ -24,6 +26,28 @@ unit="ms", ) ) + overview_page.add( + Number( + "avg_response_time", + title="Avg Response (24h)", + source="command_duration", + aggregate="average", + over=timedelta(hours=24), + unit="ms", + ) + ) + overview_page.add( + Number( + "worst_latency", + title="Worst Latency (24h)", + source="latency", + # The minute check, not latency measured by commands. + y="check", + aggregate="max", + over=timedelta(hours=24), + unit="ms", + ) + ) overview_page.add( TimeSeriesChart("latency", title="Live Latency", y=["check", "command"]) ) diff --git a/lib/dashboard/widgets/recent_table/recent_table.py b/lib/dashboard/widgets/recent_table/recent_table.py index 1265c8a..a76f87c 100644 --- a/lib/dashboard/widgets/recent_table/recent_table.py +++ b/lib/dashboard/widgets/recent_table/recent_table.py @@ -1,22 +1,28 @@ from dataclasses import KW_ONLY, dataclass from typing import Any, Sequence +from zoneinfo import ZoneInfo from dachshund.storage import Event from dachshund.widgets import Table +TIME_FORMAT = "%Y-%m-%d %H:%M:%S" + @dataclass class RecentTable(Table): - """A row per event of its source, newest first. + """A row per event of its source, newest first, with when it happened. The newest `keep` events stay in the event store, across restarts, where a - plain `Table` only keeps its newest one once events are compacted. + plain `Table` only keeps its newest one once events are compacted. Each + row's `time` is its event's, in `timezone`. ## Example ```python with dachshund.row(): - dachshund.add(RecentTable("errors", columns=["command", "error"], keep=50)) + dachshund.add( + RecentTable("errors", columns=["time", "command", "error"], keep=50) + ) dachshund.emit("errors", command="weather", error="Timeout") ``` """ @@ -24,6 +30,7 @@ class RecentTable(Table): _: KW_ONLY keep: int = 50 + timezone: str = "UTC" def __post_init__(self) -> None: if self.persist: @@ -32,14 +39,26 @@ def __post_init__(self) -> None: "`keep` instead of persist=True." ) + if self.key is not None: + raise ValueError(f"{self.name!r} shows a row per event, it takes no key.") + super().__post_init__() if self.keep < 1: raise ValueError(f"{self.name!r} has keep={self.keep}, keep at least 1.") + self._zone = ZoneInfo(self.timezone) + def render(self, events: Sequence[Event]) -> dict[str, Any]: - """The newest `keep` rows, newest first.""" + """The newest `keep` rows, newest first, each stamped with its event's time.""" payload = super().render(events) + + # Without a key, rows and events line up one to one. + for row, event in zip(payload["rows"], events): + row["fields"]["time"] = event.at.astimezone(self._zone).strftime( + TIME_FORMAT + ) + payload["rows"] = payload["rows"][-self.keep :][::-1] return payload diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/helpers/test_dashboard_helper.py b/tests/helpers/test_dashboard_helper.py deleted file mode 100644 index 5d0e836..0000000 --- a/tests/helpers/test_dashboard_helper.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest.mock import patch - -from freezegun import freeze_time - -from bot.helpers.dashboard_helper import MODERATION_SOURCE, record_moderation, timestamp - - -def test_timestamp__expect_eastern_time(): - with freeze_time("2025-02-21 03:00:00"): - assert timestamp() == "2025-02-20 22:00:00" - - -@patch("bot.helpers.dashboard_helper.dachshund.emit") -def test_record_moderation__expect_a_timestamped_row(mock_emit): - with freeze_time("2025-02-21 03:00:00"): - record_moderation("purge", by="mod", details="5 message(s)") - - mock_emit.assert_called_once_with( - MODERATION_SOURCE, - time="2025-02-20 22:00:00", - action="purge", - member="", - by="mod", - details="5 message(s)", - ) - - -@patch("bot.helpers.dashboard_helper.dachshund.emit") -def test_record_moderation__without_moderator__expect_auto(mock_emit): - record_moderation("kick", member="spammer") - - assert mock_emit.call_args.kwargs["by"] == "auto" diff --git a/tests/lib/dashboard/test_dashboard.py b/tests/lib/dashboard/test_dashboard.py index c703825..34c3560 100644 --- a/tests/lib/dashboard/test_dashboard.py +++ b/tests/lib/dashboard/test_dashboard.py @@ -22,11 +22,14 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): "member_count", "active_members", "members", + "admin_member_count", "weekly_message_counts", "on_command", "command_error", "connection_event", "commands_24h", + "avg_response_time", + "worst_latency", "commands_per_day", "reactions_per_day", "reaction_emojis", @@ -42,7 +45,6 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): "deployment_environment", "deployment_commit", "deployment_started", - "deployment_uptime", "deployment_discord_py", "deployment_python", "process_memory", @@ -50,6 +52,7 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): "process", "command_duration", "recent_commands", + "dashboard_logins", ] ) assert list(dashboard.actions) == ["enable_extension", "disable_extension"] From 0a0bef24dc1cdabd34ebb1186b0d2acb49a77d0a Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 04:38:46 -0500 Subject: [PATCH 10/11] Remove avg_response_time --- lib/dashboard/pages/admin.py | 2 +- lib/dashboard/pages/overview.py | 10 ------ tests/extensions/test_dashboard_cog.py | 16 ++++++---- tests/extensions/test_moderation_cog.py | 16 +++++++--- tests/extensions/test_name_moderation_cog.py | 10 ++++-- tests/lib/dashboard/auth/test_service.py | 18 +++++------ tests/lib/dashboard/pages/test_admin.py | 9 +++++- tests/lib/dashboard/pages/test_overview.py | 18 +++-------- tests/lib/dashboard/test_dashboard.py | 1 - .../dashboard/widgets/test_recent_table.py | 32 +++++++++++++++++++ 10 files changed, 81 insertions(+), 51 deletions(-) diff --git a/lib/dashboard/pages/admin.py b/lib/dashboard/pages/admin.py index 10397e3..9c66060 100644 --- a/lib/dashboard/pages/admin.py +++ b/lib/dashboard/pages/admin.py @@ -187,4 +187,4 @@ columns=["time", "user", "id", "result", "ip"], keep=100, ) - ) \ No newline at end of file + ) diff --git a/lib/dashboard/pages/overview.py b/lib/dashboard/pages/overview.py index 41904b0..1494f22 100644 --- a/lib/dashboard/pages/overview.py +++ b/lib/dashboard/pages/overview.py @@ -26,16 +26,6 @@ unit="ms", ) ) - overview_page.add( - Number( - "avg_response_time", - title="Avg Response (24h)", - source="command_duration", - aggregate="average", - over=timedelta(hours=24), - unit="ms", - ) - ) overview_page.add( Number( "worst_latency", diff --git a/tests/extensions/test_dashboard_cog.py b/tests/extensions/test_dashboard_cog.py index 96987e0..515321f 100644 --- a/tests/extensions/test_dashboard_cog.py +++ b/tests/extensions/test_dashboard_cog.py @@ -217,8 +217,7 @@ async def test_on_command_completion__expect_duration_in_ms(mock_emit, dashboard mock_emit.assert_any_call("command_duration", name="ping", value=250.0) row = mock_emit.call_args_list[-1] assert row.args == ("recent_commands",) - assert row.kwargs | {"time": None} == { - "time": None, + assert row.kwargs == { "command": "ping", "user": "ada", "status": "ok", @@ -245,22 +244,26 @@ def _moderation_ctx(): @pytest.mark.asyncio -@patch("bot.extensions.dashboard_cog.record_moderation") @patch("bot.extensions.dashboard_cog.dachshund.emit") async def test_on_command_error__moderation_command__expect_failed_moderation_row( - mock_emit, mock_record, dashboard_cog + mock_emit, dashboard_cog ): """Verify a failed moderation command lands in the moderation table, counted too.""" error = CommandInvokeError(PermissionError("Missing Permissions")) await dashboard_cog.on_command_error(_moderation_ctx(), error) - mock_record.assert_called_once_with( - "ping (failed)", by="ada", details="PermissionError: Missing Permissions" + mock_emit.assert_any_call( + "recent_moderation", + action="ping (failed)", + member="", + by="ada", + details="PermissionError: Missing Permissions", ) assert [c.args[0] for c in mock_emit.call_args_list] == [ "command_error", "command_outcome", + "recent_moderation", ] @@ -320,7 +323,6 @@ async def test_on_command_error__expect_recent_error_row_unwrapped( mock_emit.assert_any_call( "recent_commands", - time="2025-02-20 22:00:00", command="ping", user="ada", status="error", diff --git a/tests/extensions/test_moderation_cog.py b/tests/extensions/test_moderation_cog.py index c0e1a90..b305115 100644 --- a/tests/extensions/test_moderation_cog.py +++ b/tests/extensions/test_moderation_cog.py @@ -22,7 +22,7 @@ def _member(account_age_days): @pytest.mark.asyncio @patch("bot.extensions.moderation_cog.danger") @patch("bot.extensions.moderation_cog.app.config.get", return_value=30) -@patch("bot.extensions.moderation_cog.record_moderation") +@patch("bot.extensions.moderation_cog.dachshund.emit") async def test_on_member_join__with_young_account__expect_kick_row( mock_emit, _config, _danger, moderation_cog ): @@ -32,15 +32,17 @@ async def test_on_member_join__with_young_account__expect_kick_row( member.guild.kick.assert_awaited_once() mock_emit.assert_called_once_with( - "kick", + "recent_moderation", + action="kick", member=str(member), + by="auto", details="account 2 day(s) old, minimum 30", ) @pytest.mark.asyncio @patch("bot.extensions.moderation_cog.app.config.get", return_value=30) -@patch("bot.extensions.moderation_cog.record_moderation") +@patch("bot.extensions.moderation_cog.dachshund.emit") async def test_on_member_join__with_old_account__expect_no_row( mock_emit, _config, moderation_cog ): @@ -54,7 +56,7 @@ async def test_on_member_join__with_old_account__expect_no_row( @pytest.mark.asyncio @patch("bot.extensions.moderation_cog.danger") -@patch("bot.extensions.moderation_cog.record_moderation") +@patch("bot.extensions.moderation_cog.dachshund.emit") async def test_purge__expect_purge_row_by_its_moderator( mock_emit, mock_danger, moderation_cog ): @@ -69,5 +71,9 @@ async def test_purge__expect_purge_row_by_its_moderator( ctx.channel.purge.assert_awaited_once() mock_emit.assert_called_once_with( - "purge", by="mod", details="5 message(s) in #general · spam" + "recent_moderation", + action="purge", + member="", + by="mod", + details="5 message(s) in #general · spam", ) diff --git a/tests/extensions/test_name_moderation_cog.py b/tests/extensions/test_name_moderation_cog.py index 1f87e9e..b3d1e7f 100644 --- a/tests/extensions/test_name_moderation_cog.py +++ b/tests/extensions/test_name_moderation_cog.py @@ -26,7 +26,7 @@ def mock_ctx(): @pytest.mark.asyncio @patch("bot.extensions.name_moderation_cog.notice") @patch("bot.extensions.name_moderation_cog.make_random_name") -@patch("bot.extensions.name_moderation_cog.record_moderation") +@patch("bot.extensions.name_moderation_cog.dachshund.emit") async def test_name_moderation_on_member_join__with_none_blacklisted_name__expect_nothing( mock_emit, mock_random, mock_notice, name_moderation_cog ): @@ -44,7 +44,7 @@ async def test_name_moderation_on_member_join__with_none_blacklisted_name__expec @pytest.mark.asyncio -@patch("bot.extensions.name_moderation_cog.record_moderation") +@patch("bot.extensions.name_moderation_cog.dachshund.emit") @patch("bot.extensions.name_moderation_cog.notice") @patch("bot.extensions.name_moderation_cog.make_random_name") async def test_name_moderation_on_member_join__with_blacklisted_name__expect_change_and_message( @@ -72,7 +72,11 @@ async def test_name_moderation_on_member_join__with_blacklisted_name__expect_cha mock_notice.assert_called_once_with("NAME", "Username of Bad Name was changed.") mock_emit.assert_called_once_with( - "rename", member="Bad Name", details="renamed to Good Name" + "recent_moderation", + action="rename", + member="Bad Name", + by="auto", + details="renamed to Good Name", ) mock_log.add_field.assert_called_once_with( "Reason: ", diff --git a/tests/lib/dashboard/auth/test_service.py b/tests/lib/dashboard/auth/test_service.py index 73a8c4b..996f670 100644 --- a/tests/lib/dashboard/auth/test_service.py +++ b/tests/lib/dashboard/auth/test_service.py @@ -207,13 +207,13 @@ def _login_bot(): @pytest.mark.parametrize( "result, user_id, expected", [ - ("login", ADMIN, {"result": "login", "user": "ada", "user_id": "42"}), - ("login", STRANGER, {"result": "denied", "user": "bob", "user_id": "7"}), - ("logout", ADMIN, {"result": "logout", "user": "ada", "user_id": "42"}), - ("invalid", None, {"result": "invalid", "user": "", "user_id": ""}), + ("login", ADMIN, {"result": "login", "user": "ada", "id": "42"}), + ("login", STRANGER, {"result": "denied", "user": "bob", "id": "7"}), + ("logout", ADMIN, {"result": "logout", "user": "ada", "id": "42"}), + ("invalid", None, {"result": "invalid", "user": "", "id": ""}), ], ) -@patch("lib.dashboard.auth.service.record_login") +@patch("lib.dashboard.auth.service.dachshund.emit") async def test_record_login__expect_named_row_denied_without_access( mock_record, result, user_id, expected ): @@ -221,12 +221,11 @@ async def test_record_login__expect_named_row_denied_without_access( await _service(_login_bot()).record_login(request, result, user_id) - expected_result = expected.pop("result") - mock_record.assert_called_once_with(expected_result, ip="", **expected) + mock_record.assert_called_once_with("dashboard_logins", ip="", **expected) @pytest.mark.asyncio -@patch("lib.dashboard.auth.service.record_login") +@patch("lib.dashboard.auth.service.dachshund.emit") async def test_record_login__from_another_thread__expect_lookup_on_the_bots_loop( mock_record, ): @@ -236,7 +235,8 @@ async def test_record_login__from_another_thread__expect_lookup_on_the_bots_loop await asyncio.to_thread(asyncio.run, service.record_login(request, "login", ADMIN)) - assert mock_record.call_args.args == ("login",) + assert mock_record.call_args.args == ("dashboard_logins",) + assert mock_record.call_args.kwargs["result"] == "login" assert mock_record.call_args.kwargs["user"] == "ada" diff --git a/tests/lib/dashboard/pages/test_admin.py b/tests/lib/dashboard/pages/test_admin.py index 5ed4140..7dd3aae 100644 --- a/tests/lib/dashboard/pages/test_admin.py +++ b/tests/lib/dashboard/pages/test_admin.py @@ -40,9 +40,9 @@ def test_admin_page__expect_health_then_commands_then_moderation_then_tables(): Row([Column(["command_duration"]), Column(["command_error"])]), Row([Column(["recent_commands"]), Column(["recent_moderation"])]), Row([Column(["admin_member_count"], 4), Column(["members"])]), - Row([Column(["dashboard_logins"])]), Row([Column(["enabled_extensions"]), Column(["disabled_extensions"])]), Row([Column(["jobs"])]), + Row([Column(["dashboard_logins"])]), ] @@ -131,6 +131,13 @@ def test_admin_page__with_gateway_events__expect_per_minute_counts_over_a_day(): assert chart.over == timedelta(days=1) +@pytest.mark.parametrize( + "name", ["recent_commands", "recent_moderation", "dashboard_logins"] +) +def test_admin_page__recent_tables__expect_times_in_eastern(name): + assert admin_page.widgets[name].timezone == "US/Eastern" + + def test_admin_page__with_logins__expect_newest_hundred(): table = admin_page.widgets["dashboard_logins"] diff --git a/tests/lib/dashboard/pages/test_overview.py b/tests/lib/dashboard/pages/test_overview.py index 49e15a4..1cf1559 100644 --- a/tests/lib/dashboard/pages/test_overview.py +++ b/tests/lib/dashboard/pages/test_overview.py @@ -1,6 +1,5 @@ from datetime import timedelta -import pytest from dachshund import Column, Row from dachshund.widgets import Number @@ -17,9 +16,7 @@ def test_overview_page__expect_summary_then_latency_then_connections(): Row([Column(["summary"]), Column(["member_count"], 4)]), Row( [ - Column( - ["average_latency_gauge", "avg_response_time", "worst_latency"], 4 - ), + Column(["average_latency_gauge", "worst_latency"], 4), Column(["latency"]), ] ), @@ -27,18 +24,11 @@ def test_overview_page__expect_summary_then_latency_then_connections(): ] -@pytest.mark.parametrize( - "name, source, y, aggregate", - [ - ("avg_response_time", "command_duration", "value", "average"), - ("worst_latency", "latency", "check", "max"), - ], -) -def test_overview_page__expect_24h_response_numbers_in_ms(name, source, y, aggregate): - number = overview_page.widgets[name] +def test_overview_page__with_worst_latency__expect_max_check_over_24h_in_ms(): + number = overview_page.widgets["worst_latency"] assert isinstance(number, Number) - assert (number.source, number.y, number.aggregate) == (source, y, aggregate) + assert (number.source, number.y, number.aggregate) == ("latency", "check", "max") assert number.over == timedelta(hours=24) assert number.unit == "ms" diff --git a/tests/lib/dashboard/test_dashboard.py b/tests/lib/dashboard/test_dashboard.py index 34c3560..18dacf5 100644 --- a/tests/lib/dashboard/test_dashboard.py +++ b/tests/lib/dashboard/test_dashboard.py @@ -28,7 +28,6 @@ def test_build_dashboard__expect_every_page_and_widget_included(monkeypatch): "command_error", "connection_event", "commands_24h", - "avg_response_time", "worst_latency", "commands_per_day", "reactions_per_day", diff --git a/tests/lib/dashboard/widgets/test_recent_table.py b/tests/lib/dashboard/widgets/test_recent_table.py index df4d7a6..63cb8bc 100644 --- a/tests/lib/dashboard/widgets/test_recent_table.py +++ b/tests/lib/dashboard/widgets/test_recent_table.py @@ -35,6 +35,38 @@ def test_keep__expect_honoured_by_compaction(): assert kept_raw(dashboard)["errors"] == 50 +def test_render__expect_each_row_stamped_with_its_event_time_in_timezone(): + from datetime import UTC, datetime + + table = RecentTable("errors", columns=["time", "n"], timezone="US/Eastern") + events = [ + Event("errors", {"n": 0}, at=datetime(2025, 2, 21, 3, 0, tzinfo=UTC)), + Event("errors", {"n": 1}, at=datetime(2025, 2, 21, 4, 30, tzinfo=UTC)), + ] + + rows = table.render(events)["rows"] + + assert [row["fields"] for row in rows] == [ + {"n": 1, "time": "2025-02-20 23:30:00"}, + {"n": 0, "time": "2025-02-20 22:00:00"}, + ] + + +def test_render__by_default__expect_utc(): + from datetime import UTC, datetime + + event = Event("errors", {"n": 0}, at=datetime(2025, 2, 21, 3, 0, tzinfo=UTC)) + + rows = RecentTable("errors", columns=["time"]).render([event])["rows"] + + assert rows[0]["fields"]["time"] == "2025-02-21 03:00:00" + + +def test_key__expect_error(): + with pytest.raises(ValueError, match="no key"): + RecentTable("errors", columns=["n"], key="n") + + def test_persist__expect_error(): with pytest.raises(ValueError, match="keep"): RecentTable("errors", columns=["n"], persist=True, key="n") From 38efd3e0bf6d78f4b00b5d9c02503ff7992a0c82 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 7 Nov 2026 04:39:05 -0500 Subject: [PATCH 11/11] Put back true to admin panel in dev environment --- config/environment.cfg | 2 +- tests/lib/dashboard/pages/test_admin.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/config/environment.cfg b/config/environment.cfg index a844d43..c9d3d77 100644 --- a/config/environment.cfg +++ b/config/environment.cfg @@ -5,7 +5,7 @@ sqlalchemy_echo = False [development] log_level = INFO -dashboard_auth = True +dashboard_auth = False sqlalchemy_echo = False [test] diff --git a/tests/lib/dashboard/pages/test_admin.py b/tests/lib/dashboard/pages/test_admin.py index 7dd3aae..f5f7523 100644 --- a/tests/lib/dashboard/pages/test_admin.py +++ b/tests/lib/dashboard/pages/test_admin.py @@ -9,7 +9,6 @@ from lib.dashboard.handlers import extensions, jobs, members from lib.dashboard.pages.admin import admin_page - DEPLOYMENT_FIELDS = [ "environment", "started",