Skip to content

Dashboard Integration

Domekologe edited this page Jun 30, 2026 · 1 revision

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.

Screenshot: a cog's widget and settings panel rendered in the PDC dashboard

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.


The drop-in pattern

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_webdashboard you may import straight from pdc_webdashboard.integration instead — but the drop-in copy is recommended so the cog stays usable standalone.

The fallback is fully resilient: even the no-op @dashboard_panel attaches 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.


Lifecycle: register conditionally

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 safe

register_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.


The "one module with tabs" model

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.


Contribution types

@dashboard_widget — a tile on the board

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.

@dashboard_panel — a contextual form

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

@dashboard_list — a managed table

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.

@dashboard_page — a full standalone page

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.


Declarative fields

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=True on a select — 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 a textarea — 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.


Localization (i18n)

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_lang

Each 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 handlerPanelSchema(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.

Web-facing texts: L and tr

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 output: a per-guild language setting

Discord replies follow a language you store per guild, independent of whoever is browsing the dashboard. Wire it up in three steps:

  1. Register a default in register_guild (or your defaults):

    self.config.register_guild(language="de-DE")
  2. Expose it in your settings panel as a select (note the name uses L, but the option labels are plain). Use reload_on_change=True so 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.

  3. 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!"))

Reference cogs

The dashboardtemplate and dashboardexample cogs are full working references for all three helpers — copy their patterns verbatim.


Commands in the dashboard

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.

Bilingual command descriptions

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.

Hybrid commands (a slash form)

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 *, value consume-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):
    ...

Enabling slash commands in Red

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.

Common pitfalls

  • Import app_commands from discord, not redbot.core. Use from discord import app_commands. from redbot.core import app_commands raises an ImportError/AttributeError at 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 sync after adding slash commands — they exist but stay hidden until enabled.

The context object

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.


Permission levels

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.


Coexisting with AAA3A

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.


Checklist

  1. Copy pdc_dashboard.py into your cog folder and import the decorators from it.
  2. Decorate your methods (@dashboard_widget / @dashboard_panel / @dashboard_list / @dashboard_page) and return schemas, never HTML.
  3. Add @<panel>.on_submit (and list edit/delete handlers) to persist changes via your Config.
  4. Call register_dashboard(self) in cog_load and unregister_dashboard(self) in cog_unload.
  5. Load the cog — it works standalone, and lights up automatically when the WebDashboard cog is present.

See also

Clone this wiki locally