-
Notifications
You must be signed in to change notification settings - Fork 0
Dashboard Integration
🌐 English · Deutsch
This is the developer guide for wiring your own Red-DiscordBot cog into the PDC Web Dashboard. You declare your contributions — widgets, panels, lists, pages — with a handful of decorators and return declarative schemas, never raw HTML. The frontend renders them into a shared, themeable UI.
Two principles make this safe and friction-free: there is no hard dependency (your cog still loads and runs without the dashboard, because the decorators degrade to no-ops), and it coexists with AAA3A's Red-Web-Dashboard (different marker attributes, no collision). You can ship a single cog that integrates with both dashboards at once.

The canonical reference is the dashboardtemplate cog (a fully annotated template that does nothing useful but demonstrates every contribution type) and the smaller dashboardexample cog. Both live in this repository.
Copy the file pdc_dashboard.py 1:1 into your cog folder (you'll find it in any of the integrated cogs, e.g. dashboardtemplate/pdc_dashboard.py). It is the entire integration surface — a single module with no external requirements.
What that file does:
| Behavior | Detail |
|---|---|
| Imports the real machinery | When pdc_webdashboard is installed, it re-exports the decorators and schema classes from pdc_webdashboard.integration. |
| Falls back to no-ops | When pdc_webdashboard is not installed, every decorator becomes a pass-through and every schema class becomes a harmless stub. Your cog loads unchanged. |
Exposes DASHBOARD_AVAILABLE
|
A boolean you can check at runtime if you want to branch on availability. |
| Provides register/unregister helpers |
register_dashboard(cog) and unregister_dashboard(cog) — both safe to call whether or not the dashboard is loaded. |
Then import from your local copy, not from pdc_webdashboard:
from .pdc_dashboard import (
dashboard_widget, dashboard_panel, dashboard_list, dashboard_page,
WidgetData, PanelSchema, PageSchema, Field, Component, SubmitResult,
register_dashboard, unregister_dashboard, DASHBOARD_AVAILABLE,
)If your cog already hard-depends on
pdc_webdashboardyou may import straight frompdc_webdashboard.integrationinstead — but the drop-in copy is recommended so the cog stays usable standalone.
The fallback is fully resilient: even the no-op
@dashboard_panelattaches an.on_submit(and the no-op list attaches its edit/delete handlers), so your cog won't crash when WebDashboard is absent or mid-reload.
Register in cog_load and unregister in cog_unload. Both calls are safe no-ops when the dashboard isn't loaded.
async def cog_load(self) -> None:
register_dashboard(self) # integrates ONLY if WebDashboard is loaded
# ... your own load logic ...
def cog_unload(self) -> None:
unregister_dashboard(self) # always saferegister_dashboard covers the case where your cog is loaded after the dashboard. The dashboard also auto-scans already-loaded cogs when it starts, so the case of loading your cog before the dashboard is handled too — but calling register_dashboard explicitly is the robust path for both orders.
A cog contributes one module to a server's detail page. All of that cog's panels and lists become tabs within that module, ordered by the order= keyword (smaller is further left). This keeps related settings together instead of scattering one isolated page per cog. Use order= to lay out the tabs deliberately — for example a "create" panel at order=25 sitting just left of its list at order=30.
A widget is a small read-only tile on the central board / server overview. The method receives a DashboardContext and returns a WidgetData.
@dashboard_widget("member_count", "Members", size="sm", refresh=60, permission="guild_member")
async def member_count(self, ctx):
return WidgetData.kpi(value=ctx.guild.member_count, label="Members", icon="users")| Argument | Meaning |
|---|---|
identifier, name
|
Stable ID and display title. |
size |
sm · md · lg. |
refresh |
Auto-refresh interval in seconds (optional). |
permission |
Minimum permission level. |
scope |
guild (default) or global. |
WidgetData has convenience constructors for each widget kind:
| Constructor | Renders |
|---|---|
WidgetData.kpi(value, label, …) |
A single metric (with optional trend, icon, intent). |
WidgetData.list(items, …) |
A list of entries. |
WidgetData.chart(series, chart_type=…) |
A mini chart (line · bar · area · doughnut). |
WidgetData.status(state, label, …) |
A status indicator (ok / warn / error). |
WidgetData.markdown(text) |
Safely rendered Markdown. |
A panel is an embedded form (a tab in your module), not a standalone page. The decorated method returns a PanelSchema; the save handler is attached with @<panel>.on_submit and receives a flat {field_key: value} dict.
@dashboard_panel("welcome", "Welcome message", mount="guild_settings",
permission="guild_admin", order=10)
async def welcome_panel(self, ctx):
cfg = await self.config.guild(ctx.guild).welcome()
return PanelSchema(fields=[
Field.switch("enabled", "Enabled", value=cfg["enabled"]),
Field.textarea("message", "Message", value=cfg["message"], max_length=2000),
Field.select("channel", "Channel", channel_options, value=str(cfg["channel"] or "")),
])
@welcome_panel.on_submit
async def save_welcome(self, ctx, data):
await self.config.guild(ctx.guild).welcome.set(data)
return SubmitResult.ok("Saved.")| Argument | Meaning |
|---|---|
mount |
Where the panel embeds: guild_settings (server settings) or bot_settings (global, with scope="global"). |
permission |
Minimum level (default guild_admin). |
order |
Tab order within the module. |
scope |
guild or global. For a global/owner panel use scope="global", mount="bot_settings", permission="bot_owner" — and do not touch ctx.guild (it is None). |
A list renders a table with create/edit/delete actions. The decorated method returns rows [{"id": ..., "cells": {column_key: value, …}}]. Three optional handlers complete the CRUD cycle:
| Handler | Signature | Purpose |
|---|---|---|
@<list>.edit_form |
(ctx, id) -> PanelSchema |
The form shown when editing a row. |
@<list>.on_edit |
(ctx, id, data) -> SubmitResult |
Saves the edited row. |
@<list>.on_delete |
(ctx, id) -> SubmitResult |
Deletes a row. |
Columns are declared on the decorator: columns=[{"key": "name", "label": "Name"}, …]. Pair the list with a small @dashboard_panel ("create" tab) at a lower order= to add new entries.
For the rare case where a panel/tab isn't enough, @dashboard_page registers a full page built from a PageSchema of Component nodes (heading · text · table · chart · panel_ref · divider · grid) — still no raw HTML. Set nav=False to keep it out of the side navigation.
Field builders map one setting to one input. Returning declarative fields (rather than HTML) is what removes the XSS surface entirely.
| Builder | Input |
|---|---|
Field.text(key, label, …) |
Single-line text. |
Field.textarea(key, label, max_length=…, variables=…) |
Multi-line text; variables=[{"token": "{member}", "desc": "Member"}] adds insert-token buttons. |
Field.number(key, label, min=…, max=…) |
Bounded number. |
Field.switch(key, label) |
Boolean toggle. |
Field.select(key, label, options, reload_on_change=…) |
Single choice from options. |
Field.multiselect(key, label, options) |
Multiple choices. |
Field.channel(key, label) / Field.role(key, label)
|
Channel / role pickers. |
Two field options are worth calling out:
-
reload_on_change=Trueon aselect— when the user changes the value, the panel is saved immediately and reloaded. Use it for a setting that other fields depend on (e.g. switching a "profile" or a per-module language so the rest of the form re-renders). -
variables=[…]on atextarea— renders clickable buttons that insert a token ({member},{server}, …) at the cursor, so admins don't have to memorize your placeholder syntax.
A submit handler returns SubmitResult.ok("message") on success or SubmitResult.fail("message", errors={"field_key": "why"}) to surface field-specific validation errors.
Your cog can ship both German and English for its dashboard texts and its Discord output. The drop-in provides three small helpers — import them alongside the decorators, and like everything else they degrade to harmless no-ops when pdc_webdashboard isn't installed:
from .pdc_dashboard import L, tr, tr_langEach helper covers a different surface and resolves its language differently:
| Helper | Use for | Follows |
|---|---|---|
L("Deutsch", "English") |
A decorator name or description — the name of @dashboard_panel / @dashboard_widget / @dashboard_list, and the description= of a @dashboard_list. |
The website UI language (the DE/EN toggle, top-right). Tab titles follow the site. |
tr(ctx, "Deutsch", "English") |
Text returned from a handler — PanelSchema(description=…), a SubmitResult.ok/fail(…) message, a custom submit_label, or WidgetData text. |
The website UI language, via ctx.locale. |
tr_lang(lang, "Deutsch", "English") |
The cog's Discord output — command replies, embeds, DMs. | A per-guild language setting the cog stores itself (NOT the website language). |
Passing a single argument to L (e.g. L("Same text")) uses that text for all languages.
Key rule — field LABELS stay English. Keep one source of truth: only names/descriptions (web language) and Discord output (per-guild) get localized. Don't wrap
Field.*labels.
L localizes what the gateway shows as a tab title; tr localizes what a handler returns, using ctx.locale. The website toggle re-fetches module texts live, so switching DE/EN updates both immediately.
@dashboard_panel("welcome", L("Willkommen", "Welcome"), mount="guild_settings",
permission="guild_admin", order=10)
async def welcome_panel(self, ctx):
cfg = await self.config.guild(ctx.guild).welcome()
return PanelSchema(
description=tr(ctx, "Begrüße neue Mitglieder.", "Greet new members."),
fields=[
# LABELS stay English — one source of truth:
Field.switch("enabled", "Enabled", value=cfg["enabled"]),
Field.textarea("message", "Message", value=cfg["message"], max_length=2000),
],
)
@welcome_panel.on_submit
async def save_welcome(self, ctx, data):
await self.config.guild(ctx.guild).welcome.set(data)
return SubmitResult.ok(tr(ctx, "Gespeichert.", "Saved."))Discord replies follow a language you store per guild, independent of whoever is browsing the dashboard. Wire it up in three steps:
-
Register a default in
register_guild(or your defaults):self.config.register_guild(language="de-DE")
-
Expose it in your settings panel as a
select(note thenameusesL, but the option labels are plain). Usereload_on_change=Trueso the rest of the form can react:Field.select("language", L("Sprache", "Language"), [{"value": "de-DE", "label": "Deutsch"}, {"value": "en-US", "label": "English"}], value=cfg["language"], reload_on_change=True),
…and persist it in your
on_submit. -
Localize each command by reading that setting and wrapping output with
tr_lang:@commands.command() async def hello(self, ctx): lang = await self.config.guild(ctx.guild).language() await ctx.send(tr_lang(lang, "Hallo!", "Hello!"))
The dashboardtemplate and dashboardexample cogs are full working references for all three helpers — copy their patterns verbatim.
The dashboard also lists your cog's commands. A few patterns make them feel native — bilingual descriptions, slash forms, and the gotchas that quietly break them.
A command can show its description in the dashboard's selected language. Add an i18n_desc map to the command's extras=:
@commands.hybrid_command(
name="greet",
description="Greet a member.",
extras={"i18n_desc": {"de-DE": "Ein Mitglied begrüßen.", "en-US": "Greet a member."}},
)
async def greet(self, ctx, member: discord.Member):
...The dashboard's command list resolves i18n_desc against the website language toggle (DE/EN, top-right). The plain description= stays English — that's the text Discord itself shows — and parameter-level @app_commands.describe(...) also stays English. extras={"i18n_desc": …} works on @commands.hybrid_command, @commands.command, @commands.group and @app_commands.command.
To give a command a slash form, use @commands.hybrid_command / @commands.hybrid_group instead of the plain variants.
Keyword-only caveat. A hybrid callback may have at most one keyword-only parameter (the trailing
*, valueconsume-rest). Two or more keyword-only params silently drop the slash half — the command stays prefix-only with no error. Fix: move the extra optional params to positional-with-default before the*:async def ban(self, ctx, member: discord.Member, delete_message_days: app_commands.Range[int, 0, 7] = 0, *, reason: str | None = None): ...
Red leaves newly added app/hybrid slash commands disabled by default. Enable them with [p]slash enablecog <CogClassName> (or per command [p]slash enable <name>), then [p]slash sync. They can also be toggled in the web dashboard's Cogs → Slash tab.
-
Import
app_commandsfromdiscord, notredbot.core. Usefrom discord import app_commands.from redbot.core import app_commandsraises anImportError/AttributeErrorat load and the whole cog fails to load — its prefix and slash commands disappear. - Two keyword-only params on a hybrid drop the slash half silently (see above).
- Forgetting
[p]slash enablecog+[p]slash syncafter adding slash commands — they exist but stay hidden until enabled.
Every contribution method receives a DashboardContext (ctx) with bot, user, guild, member and locale. The call is already authorized server-side against the declared permission before your method runs — you don't re-check permissions, you just read/write data. In a global panel (scope="global") ctx.guild is None; guard accordingly.
Set the minimum level a viewer must hold via permission= on any contribution. Levels are derived server-side from Red's own permission system:
| Level | Who |
|---|---|
authenticated |
Any logged-in Discord user. |
guild_member |
A member of the server. |
guild_mod |
A server moderator (Red's mod role). |
guild_admin |
A server admin (Red's admin role / manage_guild). |
guild_owner |
The server owner. |
bot_owner |
A bot owner. |
Pick the lowest level that still makes sense: widgets are often guild_member, settings panels guild_admin, global/API panels bot_owner.
You can serve both dashboards from the same cog. The PDC markers (__dashboard_widget__, __dashboard_panel__, __dashboard_page__, __dashboard_list__) are distinct from AAA3A's, so the two scanners never interfere, and neither system disables the other. PDC integrates via register_dashboard(self) / the auto-scan and is independent of AAA3A's mixin inheritance — so you can keep inheriting AAA3A's mixin as usual. If you import both dashboard_page symbols, alias one to avoid a name clash.
- Copy
pdc_dashboard.pyinto your cog folder and import the decorators from it. - Decorate your methods (
@dashboard_widget/@dashboard_panel/@dashboard_list/@dashboard_page) and return schemas, never HTML. - Add
@<panel>.on_submit(and list edit/delete handlers) to persist changes via yourConfig. - Call
register_dashboard(self)incog_loadandunregister_dashboard(self)incog_unload. - Load the cog — it works standalone, and lights up automatically when the WebDashboard cog is present.
- WebDashboard — the companion cog and gateway setup.
- Web DashboardStats — a worked example of a stats-driven dashboard contribution.
- Installation · Home
PDC_Redbot_Cogs · GitHub · Issues · Web Dashboard · Built for Red-DiscordBot · 🌐 English / Deutsch
🇬🇧 English
General
Cogs
Engagement & Utility
- Birthday
- StatChannels
- ScheduledMsg
- EventLog
- SocialFeed
- Giveaway
- Leveling
- TempVoice
- Starboard
- Suggestions
- LiveAlerts
Moderation
Fun
Web Dashboard
🇩🇪 Deutsch
Allgemein
Cogs
Engagement & Utility
- Birthday
- StatChannels
- ScheduledMsg
- EventLog
- SocialFeed
- Giveaway
- Leveling
- TempVoice
- Starboard
- Suggestions
- LiveAlerts
Moderation
Fun
Web-Dashboard