diff --git a/bot/extensions/dashboard_cog.py b/bot/extensions/dashboard_cog.py index eedd1d5..a96a275 100644 --- a/bot/extensions/dashboard_cog.py +++ b/bot/extensions/dashboard_cog.py @@ -1,13 +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.models.extensions.widget_state import WidgetState +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") +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! @@ -20,6 +43,81 @@ """ +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 _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 + + 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,31 +126,29 @@ class DashboardCog( def __init__(self, bot: Grace): self.bot: Grace = bot 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 + self.timezone: tzinfo = TIMEZONE + self.process = ProcessStats() + self.commit = _commit() + self.gateway_events = 0 def cog_load(self) -> None: - self._load_counts() 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._save_counts, "cron", minute="*/1", timezone=self.timezone - ) + self.bot.scheduler.add_job(self._report_process, "interval", minutes=1) ) + + self._trim_daily_charts() self.jobs.append( self.bot.scheduler.add_job( - self._reset_daily_count, + self._trim_daily_charts, "cron", hour=0, - minute=0, - second=0, + minute=5, timezone=self.timezone, ) ) @@ -64,36 +160,34 @@ 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_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") @@ -110,38 +204,95 @@ 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_message(self, _message: Message) -> None: - self._minutely_message_count += 1 - dachshund.emit("minutely_message_rate", value=self._minutely_message_count) + 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) - self._daily_message_count += 1 - dachshund.emit("daily_message_rate", value=self._daily_message_count) + @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: dachshund.emit( - "weekly_message_counts", + "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) - self._minutely_command_count += 1 - dachshund.emit("minutely_command_rate", value=self._minutely_command_count) + 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): + 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)) + + 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", + 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..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 @@ -45,6 +47,13 @@ async def purge( log.add_field("Reason", reason) await ctx.channel.purge(limit=int(limit) + 1, bulk=True, reason=reason) + dachshund.emit( + "recent_moderation", + action="purge", + member="", + 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 +123,14 @@ 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") + 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}", + ) 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..3a67278 100644 --- a/bot/extensions/name_moderation_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -1,5 +1,6 @@ import logging +import dachshund from discord import Member from discord.errors import Forbidden from discord.ext.commands import Cog @@ -47,6 +48,13 @@ async def on_member_join(self, member: Member) -> None: try: good_name = make_random_name() await member.edit(nick=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/grace.py b/bot/grace.py index 8b4c103..a5ff2bd 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 @@ -18,10 +19,13 @@ 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) self.metrics = DashboardMetrics() + self.auth: AuthService | None = None @property def default_color(self): @@ -63,7 +67,12 @@ 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, AuthSettings.from_config(self.app.config)) + + 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/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/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..2041dc1 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..2882faf --- /dev/null +++ b/lib/dashboard/auth/oauth.py @@ -0,0 +1,118 @@ +"""Discord OAuth2 login, with the `identify` scope.""" + +import hmac +import secrets + +from typing import Awaitable, Callable + +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" + +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: + """The login, callback and logout handlers, served by the dashboard at `/_auth/`.""" + + STATE_COOKIE = "grace_oauth_state" + STATE_TTL = 10 * 60 + + 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`.""" + 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`.""" + 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): + 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: + """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="/") + + 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/service.py b/lib/dashboard/auth/service.py new file mode 100644 index 0000000..48ca21a --- /dev/null +++ b/lib/dashboard/auth/service.py @@ -0,0 +1,96 @@ +"""Who may see the dashboard's private pages, and the Discord login that decides it.""" + +import asyncio +from typing import Any + +import dachshund +from aiohttp import web + +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. + + The dashboard decides which pages are private, asks `authorize`, and serves + the Discord login itself. + """ + + 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.record_login) + + 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 + ) + + 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" + + dachshund.emit( + "dashboard_logins", + user=name, + id="" if user_id is None else str(user_id), + result=result, + 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. + """ + return self.is_allowed(self.sessions.current_user(request)) + + def dashboard_access(self) -> dict[str, Any]: + """The dashboard's `host`, and its `authorize` and `login` when the login is on.""" + if not self.settings.enabled: + return {"host": self.settings.host} + + 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 new file mode 100644 index 0000000..2b7ed14 --- /dev/null +++ b/lib/dashboard/auth/session.py @@ -0,0 +1,82 @@ +"""Signed session cookies holding a Discord user id.""" + +import hashlib +import hmac +import time +from urllib.parse import urlsplit + +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: + """The path and query of `value`, `/` if that isn't a path on this site. + + The dashboard sends its own full URL, so only the path is kept. + """ + parts = urlsplit(value or "") + path = parts.path + + if not path.startswith("/") or path.startswith(("//", "/\\")): + return "/" + + return f"{path}?{parts.query}" if parts.query else path + + +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..9ab035a --- /dev/null +++ b/lib/dashboard/auth/settings.py @@ -0,0 +1,56 @@ +"""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)}.") + + # 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: + 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 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 a28f1e0..fd60b19 100644 --- a/lib/dashboard/dashboard.py +++ b/lib/dashboard/dashboard.py @@ -2,6 +2,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 @@ -14,15 +15,17 @@ 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` (`host`, `authorize`, `login`) guarding its private pages.""" dachshund.init( "Grace", description="Code Society's Discord Bot", - host="0.0.0.0", + 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/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 4e4ecdb..db1f501 100644 --- a/lib/dashboard/pages/activity.py +++ b/lib/dashboard/pages/activity.py @@ -1,15 +1,112 @@ -from dachshund import BarChart, Number, Page - -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) -) +from datetime import timedelta + +from dachshund import Page +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( + "active_members", + title="Active Today", + source="message", + y="author", + aggregate="distinct", + over="day", + ) + ) + activity_page.add( + Number( + "daily_message_rate", + title="Messages (last 24h)", + source="message", + aggregate="count", + over="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( + BarChart( + "weekly_message_counts", + title="Messages / day", + source="message", + x="date", + 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 new file mode 100644 index 0000000..9c66060 --- /dev/null +++ b/lib/dashboard/pages/admin.py @@ -0,0 +1,190 @@ +from datetime import timedelta + +from dachshund import Page +from dachshund.widgets import BarChart, Logs, Number, Table, TimeSeriesChart + +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"), + ("started", "Started"), + ("commit", "Commit"), + ("discord_py", "discord.py"), + ("python", "Python"), +] + +with admin_page.row(): + for field, title in DEPLOYMENT: + admin_page.add( + Stat(f"deployment_{field}", title=title, source="deployment", y=field) + ) + +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), + ) + ) + + +with admin_page.row(): + admin_page.add(Logs("logs", title="Logs")) + + +with admin_page.row(): + admin_page.add( + BarChart( + "command_duration", + title="Average Command Time (ms)", + x="name", + aggregate="average", + persist=True, + ) + ) + + admin_page.add( + BarChart("command_error", title="Errors Per Command", x="name", persist=True) + ) + + +with admin_page.row(): + admin_page.add( + RecentTable( + "recent_commands", + title="Recent Commands", + timezone="US/Eastern", + columns=["time", "command", "user", "status", "ms", "error"], + keep=100, + ) + ) + + admin_page.add( + RecentTable( + "recent_moderation", + title="Recent Moderation", + timezone="US/Eastern", + columns=["time", "action", "member", "by", "details"], + keep=100, + ) + ) + +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( + 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( + "jobs", + title="Scheduled Jobs", + columns=jobs.COLUMNS, + page_size=10, + loader=jobs.load, + ) + ) + +with admin_page.row(): + admin_page.add( + RecentTable( + "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 a09c255..1494f22 100644 --- a/lib/dashboard/pages/overview.py +++ b/lib/dashboard/pages/overview.py @@ -1,57 +1,48 @@ -from dachshund import Number, Page, Table, Text, TimeSeriesChart, BarChart +from datetime import timedelta -from bot import app +from dachshund import Page +from dachshund.widgets import BarChart, Number, 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"), - } +overview_page = Page("overview", title="Overview", public=True) + +with overview_page.row(): + overview_page.add(Text("summary")) + + with overview_page.column(span=4): + overview_page.add(Number("member_count")) + +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( + 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"]) + ) + +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/__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 90% rename from lib/dashboard/widgets/gauge.py rename to lib/dashboard/widgets/gauge/gauge.py index f9cbb1f..e2bd505 100644 --- a/lib/dashboard/widgets/gauge.py +++ b/lib/dashboard/widgets/gauge/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/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/recent_table.py b/lib/dashboard/widgets/recent_table/recent_table.py new file mode 100644 index 0000000..a76f87c --- /dev/null +++ b/lib/dashboard/widgets/recent_table/recent_table.py @@ -0,0 +1,64 @@ +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, 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. Each + row's `time` is its event's, in `timezone`. + + ## Example + + ```python + with dachshund.row(): + dachshund.add( + RecentTable("errors", columns=["time", "command", "error"], keep=50) + ) + dachshund.emit("errors", command="weather", error="Timeout") + ``` + """ + + _: KW_ONLY + + keep: int = 50 + timezone: str = "UTC" + + 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." + ) + + 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, 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/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/stat.css b/lib/dashboard/widgets/stat/stat.css new file mode 100644 index 0000000..fcb83c9 --- /dev/null +++ b/lib/dashboard/widgets/stat/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/stat.js b/lib/dashboard/widgets/stat/stat.js new file mode 100644 index 0000000..e9fcac5 --- /dev/null +++ b/lib/dashboard/widgets/stat/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/stat.py b/lib/dashboard/widgets/stat/stat.py new file mode 100644 index 0000000..693b7b2 --- /dev/null +++ b/lib/dashboard/widgets/stat/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/__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/top_bar_chart.py b/lib/dashboard/widgets/top_bar_chart/top_bar_chart.py new file mode 100644 index 0000000..f05e682 --- /dev/null +++ b/lib/dashboard/widgets/top_bar_chart/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 c3ac981..515321f 100644 --- a/tests/extensions/test_dashboard_cog.py +++ b/tests/extensions/test_dashboard_cog.py @@ -1,10 +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 DashboardCog -from bot.models.extensions.widget_state import WidgetState +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})() + channel = channel or _fake_channel() + return type("Message", (), {"author": author, "channel": channel})() @pytest.fixture @@ -21,24 +52,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 @@ -118,45 +131,161 @@ async def test_on_member_remove__expect_a_members_refresh_ping( @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()) +@patch("bot.extensions.dashboard_cog.dachshund.emit") +async def test_on_message__expect_one_message_event_with_date_and_author( + mock_emit, dashboard_cog +): + """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_called_once_with( + "message", date="2025-02-20", author="7", channel="general", value=1 + ) + + +@pytest.mark.asyncio +@patch("bot.extensions.dashboard_cog.dachshund.emit") +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(_fake_message(author_id=99, bot=True)) + + mock_emit.assert_called_once_with( + "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"})() - assert dashboard_cog._minutely_message_count == 1 - assert dashboard_cog._daily_message_count == 1 + return type("Ctx", (), {"command": command, "author": "ada"})() @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_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 == { + "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 on_message emits the running minute and day counts.""" - await dashboard_cog.on_message(object()) - await dashboard_cog.on_message(object()) + """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] - mock_emit.assert_any_call("minutely_message_rate", value=2) - mock_emit.assert_any_call("daily_message_rate", value=2) + +def _moderation_ctx(): + ctx = _command_ctx() + ctx.cog = type("Cog", (), {"qualified_name": "Moderation"})() + return ctx @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( +async def test_on_command_error__moderation_command__expect_failed_moderation_row( mock_emit, dashboard_cog ): - """Verify on_message emits a delta of 1 for today's date, not a running total.""" - with freeze_time("2025-02-20 12:00:00"): - await dashboard_cog.on_message(object()) - await dashboard_cog.on_message(object()) + """Verify a failed moderation command lands in the moderation table, counted too.""" + error = CommandInvokeError(PermissionError("Missing Permissions")) - mock_emit.assert_any_call("weekly_message_counts", date="2025-02-20", value=1) + await dashboard_cog.on_command_error(_moderation_ctx(), error) + + 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", + ] + + +@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 @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"})()})() + ctx = _command_ctx() await dashboard_cog.on_command(ctx) @@ -164,34 +293,62 @@ async def test_on_command__expect_emits_command_name(mock_emit, dashboard_cog): @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"})()})() +@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 = _command_ctx() - await dashboard_cog.on_command(ctx) + await dashboard_cog.on_command_error(ctx, Exception("boom")) - assert dashboard_cog._minutely_command_count == 1 + 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__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_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", + command="ping", + user="ada", + status="error", + ms=500.0, + error="TimeoutError: weather API", + ) - await dashboard_cog.on_command(ctx) - await dashboard_cog.on_command(ctx) - mock_emit.assert_any_call("minutely_command_rate", value=2) +@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__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"})()})() - - await dashboard_cog.on_command_error(ctx, Exception("boom")) +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) @@ -202,11 +359,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 @@ -227,67 +384,81 @@ 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() - - assert fresh_cog._daily_message_count == 42 - assert fresh_cog._minutely_message_count == 7 - assert fresh_cog._minutely_command_count == 3 - - @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_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_any_call( - dashboard_cog._report_latency, - "interval", - minutes=1, + 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._report_process, "interval", minutes=1 ) dashboard_cog.bot.scheduler.add_job.assert_any_call( - dashboard_cog._reset_daily_count, + dashboard_cog._trim_daily_charts, "cron", hour=0, - minute=0, - second=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_and_process(mock_emit, dashboard_cog): + """Verify cog_load emits the summary, and the process right away.""" + dashboard_cog.cog_load() + + 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): """Verify cog_unload removes every job scheduled by cog_load.""" dashboard_cog.cog_load() @@ -297,3 +468,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..b305115 --- /dev/null +++ b/tests/extensions/test_moderation_cog.py @@ -0,0 +1,79 @@ +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.dachshund.emit") +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( + "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.dachshund.emit") +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.dachshund.emit") +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( + "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 01b0d04..b3d1e7f 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.dachshund.emit") 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.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( - 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,13 @@ 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( + "recent_moderation", + action="rename", + member="Bad Name", + by="auto", + 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/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..5e2ad81 --- /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..0b7b0ed --- /dev/null +++ b/tests/lib/dashboard/auth/test_oauth.py @@ -0,0 +1,159 @@ +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, AsyncMock()) + + +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 == "" + + +@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 new file mode 100644 index 0000000..996f670 --- /dev/null +++ b/tests/lib/dashboard/auth/test_service.py @@ -0,0 +1,251 @@ +import asyncio +from contextlib import asynccontextmanager +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +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.service import client_ip +from lib.dashboard.auth.session import Sessions, safe_next +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): + return AuthService(bot or _bot(), 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 + + +def _session(user_id): + return {Sessions.COOKIE: SESSIONS.sign(user_id)} + + +def test_dashboard_access__when_enabled__expect_authorize_and_the_login_handlers(): + service = _service() + access = service.dashboard_access() + + 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_this_machine_only(): + service = _service(settings=AuthSettings(enabled=False, guild_id=99)) + + assert service.dashboard_access() == {"host": "127.0.0.1"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "user_id, expected", [(None, False), (STRANGER, False), (ADMIN, True)] +) +async def test_authorize__expect_only_allowed_sessions(user_id, expected): + headers = {} + + 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(): + """A real dashboard with the service's login, a public and a private page.""" + service = _service() + access = service.dashboard_access() + access.pop("host") + dashboard = Dashboard(path=":memory:", **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) + + client = TestClient(TestServer(dashboard._create_app())) + await client.start_server() + + try: + yield client + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_secured__public_page__expect_shown_without_session(): + async with _dashboard_client() as client: + response = await client.get("/open") + + assert response.status == 200 + + +@pytest.mark.asyncio +@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) + + 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__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_discord(): + async with _dashboard_client() as client: + response = await client.get("/_auth/login", allow_redirects=False) + + 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", "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.dachshund.emit") +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) + + mock_record.assert_called_once_with("dashboard_logins", ip="", **expected) + + +@pytest.mark.asyncio +@patch("lib.dashboard.auth.service.dachshund.emit") +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 == ("dashboard_logins",) + assert mock_record.call_args.kwargs["result"] == "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/auth/test_session.py b/tests/lib/dashboard/auth/test_session.py new file mode 100644 index 0000000..dda9e9c --- /dev/null +++ b/tests/lib/dashboard/auth/test_session.py @@ -0,0 +1,60 @@ +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", "/"), + ("http://127.0.0.1:1213/admin?tab=x", "/admin?tab=x"), + ("javascript:alert(1)", "/"), + ], +) +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..44d6710 --- /dev/null +++ b/tests/lib/dashboard/auth/test_settings.py @@ -0,0 +1,67 @@ +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" + + +@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) 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 7121eaa..3ff067b 100644 --- a/tests/lib/dashboard/pages/test_activity.py +++ b/tests/lib/dashboard/pages/test_activity.py @@ -1,24 +1,44 @@ +from datetime import timedelta + import pytest -from dachshund import BarChart, Number, Row +from dachshund import Column, Row +from dachshund.widgets import BarChart, Number, TimeSeriesChart + +from lib.dashboard.widgets.top_bar_chart import TopBarChart from lib.dashboard.pages.activity import activity_page -def test_activity_page__expect_messages_beside_their_rate_then_one_chart_per_row(): +def test_activity_page__expect_public(): + assert activity_page.public is True + + +def test_activity_page__expect_counts_then_members_then_messages_then_commands(): assert activity_page.rows == [ - Row(["daily_message_rate", "weekly_message_counts"], (1, 2)), - Row(["on_command"]), - Row(["command_error"]), + Row( + [ + Column(["active_members"]), + Column(["daily_message_rate"]), + Column(["commands_24h"]), + Column(["recent_joins"]), + ] + ), + 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"])]), ] - assert isinstance(activity_page.widgets["daily_message_rate"], Number) @pytest.mark.parametrize( "name, x", [ ("weekly_message_counts", "date"), + ("channel_messages", "channel"), ("on_command", "name"), - ("command_error", "name"), + ("commands_per_day", "date"), + ("reactions_per_day", "date"), + ("reaction_emojis", "emoji"), ], ) def test_activity_page__expect_persisted_bar_charts(name, x): @@ -27,3 +47,66 @@ 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_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 new file mode 100644 index 0000000..f5f7523 --- /dev/null +++ b/tests/lib/dashboard/pages/test_admin.py @@ -0,0 +1,166 @@ +from datetime import timedelta + +import pytest +from dachshund import Column, Row +from dachshund.widgets import BarChart, Logs, Table, TimeSeriesChart + +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", + "started", + "commit", + "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_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(["admin_member_count"], 4), Column(["members"])]), + Row([Column(["enabled_extensions"]), Column(["disabled_extensions"])]), + Row([Column(["jobs"])]), + Row([Column(["dashboard_logins"])]), + ] + + +def test_admin_page__with_recent_moderation__expect_newest_hundred(): + table = admin_page.widgets["recent_moderation"] + + assert isinstance(table, RecentTable) + assert table.columns == ["time", "action", "member", "by", "details"] + assert table.keep == 100 + + +@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 table.source == "extensions" + assert table.loader is loader + assert table.actions == actions + + +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 test_admin_page__with_jobs_table__expect_jobs_loader(): + assert admin_page.widgets["jobs"].loader is jobs.load + + +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_admin_page__with_process__expect_memory_and_cpu_numbers(): + memory = admin_page.widgets["process_memory"] + cpu = admin_page.widgets["process_cpu"] + + assert (memory.source, memory.y, memory.unit) == ("process", "memory_mb", "MB") + assert (cpu.source, cpu.y, cpu.unit) == ("process", "cpu_percent", "%") + + +def test_admin_page__with_command_duration__expect_persisted_average(): + chart = admin_page.widgets["command_duration"] + + assert isinstance(chart, BarChart) + assert chart.aggregate == "average" + assert chart.persist is True + + +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_admin_page__with_error_rate__expect_average_failed_percent_over_a_day(): + number = admin_page.widgets["error_rate"] + + assert (number.source, number.y, number.aggregate, number.unit) == ( + "command_outcome", + "failed", + "average", + "%", + ) + assert number.over == timedelta(hours=24) + + +def test_admin_page__with_gateway_events__expect_per_minute_counts_over_a_day(): + chart = admin_page.widgets["gateway_events"] + + assert isinstance(chart, TimeSeriesChart) + assert chart.aggregate == "average" + assert chart.every == timedelta(minutes=10) + 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"] + + 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) + + +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(): + table = admin_page.widgets["members"] + + assert isinstance(table, Table) + assert table.loader is members.load + assert table.persist is False + assert table.actions == [] diff --git a/tests/lib/dashboard/pages/test_overview.py b/tests/lib/dashboard/pages/test_overview.py index 29503d5..1cf1559 100644 --- a/tests/lib/dashboard/pages/test_overview.py +++ b/tests/lib/dashboard/pages/test_overview.py @@ -1,85 +1,41 @@ -from datetime import datetime +from datetime import timedelta -from dachshund import Row, Table +from dachshund import Column, Row +from dachshund.widgets import Number -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_public(): + assert overview_page.public is True + + +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"]), Column(["member_count"], 4)]), + Row( + [ + Column(["average_latency_gauge", "worst_latency"], 4), + Column(["latency"]), + ] + ), + Row([Column(["connection_event"])]), ] +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) == ("latency", "check", "max") + 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"] 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..18dacf5 100644 --- a/tests/lib/dashboard/test_dashboard.py +++ b/tests/lib/dashboard/test_dashboard.py @@ -11,19 +11,47 @@ 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", "latency", + "logs", "average_latency_gauge", "daily_message_rate", "member_count", + "active_members", "members", + "admin_member_count", "weekly_message_counts", "on_command", "command_error", "connection_event", + "commands_24h", + "worst_latency", + "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_discord_py", + "deployment_python", + "process_memory", + "process_cpu", + "process", + "command_duration", + "recent_commands", + "dashboard_logins", ] ) - assert list(dashboard.actions) == [] + assert list(dashboard.actions) == ["enable_extension", "disable_extension"] 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/lib/dashboard/widgets/test_recent_table.py b/tests/lib/dashboard/widgets/test_recent_table.py new file mode 100644 index 0000000..63cb8bc --- /dev/null +++ b/tests/lib/dashboard/widgets/test_recent_table.py @@ -0,0 +1,77 @@ +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_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") + + +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_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 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"