<!-- concepts/activity-logging.md -->

---
title: Activity Logging
description: Write audit log entries from your extension so operators can see who did what, using the same activity system the Panel uses.
---

# Activity Logging

Let's say you just shipped an admin endpoint that lets operators update a critical setting, or a server endpoint that nukes a user's files. Great. Now imagine six months later someone goes "who the hell deleted my world folder" and you have absolutely no way to answer. Awkward. This is why every mutation in the Panel ends with an activity log entry, and it's why your extension should do the same.

The good news is that logging is basically free - you extract a logger from your route handler, call `.log(...).await`, and the Panel handles the rest. IP, user agent, timestamp, and who did it are all captured automatically, so your payload only has to describe *what* happened.

## The Three Loggers

There are three logger extractors, one per activity scope. Pick the one that matches the router you're in - if you're in an admin route use the admin logger, if you're in a server route use the server logger, etc. Mixing them across router types doesn't really make sense.

| Logger | Import from | Shows up in | Use from |
| ------ | ----------- | ----------- | -------- |
| `GetAdminActivityLogger` | `shared::models::admin_activity` | Panel-wide admin audit log | `add_admin_api_router` routes |
| `GetServerActivityLogger` | `shared::models::server` | The server's activity tab | `add_client_server_api_router` routes |
| `GetUserActivityLogger` | `shared::models::user_activity` | The user's account activity page | `add_client_api_router` routes |

All three have the same `.log(event_name, payload).await` signature, so once you've used one you've used all of them. The only thing that changes is *who sees the entry*.

## Logging an Event

Here's the pattern, using the admin logger as an example:

```rs
use shared::{
    GetState,
    models::{admin_activity::GetAdminActivityLogger, user::GetPermissionManager},
    response::{ApiResponse, ApiResponseResult},
};

#[utoipa::path(put, path = "/", responses(
    (status = OK, body = inline(Response)),
), request_body = inline(Payload))]
pub async fn route(
    state: GetState,
    permissions: GetPermissionManager,
    activity_logger: GetAdminActivityLogger,
    shared::Payload(data): shared::Payload<Payload>,
) -> ApiResponseResult {
    permissions.has_admin_permission("extensions.manage")?;

    // ... do the actual work

    activity_logger
        .log(
            "settings:extensions:update",
            serde_json::json!({
                "extension": "dev.yourname.test",
                "changed_fields": ["api_url", "enable_feature"],
            }),
        )
        .await;

    ApiResponse::new_serialized(Response {}).ok()
}
```

The two arguments are the event name and the JSON payload. That's it. Swap `GetAdminActivityLogger` for `GetServerActivityLogger` or `GetUserActivityLogger` and the call looks exactly the same - only the extractor type changes.

## Event Names

The Panel uses a specific convention for event names, and your extension should match it for consistency with the rest of the audit log:

- **Colons separate scopes**, narrowing from broadest to most specific.
- **Dots separate the sub-action** at the end.

So `server:version.install` reads as "in the server scope, under version, the install sub-action was performed". `settings:extensions:update` reads as "in the settings scope, under extensions, update". The rule of thumb: colons are namespacing, dots are verbs.

Some examples of well-formed event names:

| Event name | What it represents |
| ---------- | ------------------ |
| `settings:extensions:update` | Extension settings were updated in the admin panel |
| `server:version.install` | A server had a new version installed |
| `server:backup.create` | A backup was created for a server |
| `user:apikey.revoke` | A user revoked one of their API keys |

If you're adding a new event type, try to fit it into an existing scope rather than inventing a new top-level one - users scanning the audit log will have a much easier time filtering `server:*` than trying to remember that your extension uses `minecraftstuff:*`. When in doubt, prefix with the resource you're acting on (`server`, `node`, `settings`, `user`) and let the sub-action carry the extension-specific meaning.

## Payloads

The second argument is any `serde_json::Value` (or anything that serializes into one). It shows up in the audit UI as structured data that admins can inspect when investigating an event.

A good payload answers the question "if I saw this log entry with no other context, would I understand what happened?" Include the IDs of anything that was touched, any before/after values that matter, and any decision points the code took. Don't include things that are already captured automatically - IP address, user agent, timestamp, and the acting user's ID are all added for you by the Panel.

```rs
// Good: tells you what changed and on which resource
activity_logger
    .log(
        "server:backup.create",
        serde_json::json!({
            "backup_id": backup.uuid,
            "name": backup.name,
            "size_bytes": backup.size,
            "locked": backup.locked,
        }),
    )
    .await;

// Bad: tells you nothing you couldn't already tell from the event name
activity_logger
    .log("server:backup.create", serde_json::json!({}))
    .await;

// Also bad: duplicates automatic fields
activity_logger
    .log(
        "server:backup.create",
        serde_json::json!({
            "user_id": user.id, // already captured
            "timestamp": chrono::Utc::now(), // already captured
            "backup_id": backup.uuid, // this one is actually useful
        }),
    )
    .await;
```

::: info
An empty payload `serde_json::json!({})` is valid and won't break anything - it's fine for events where the event name genuinely says everything (e.g. `session.logout`). But most of the time there's *something* worth capturing, and you'll thank yourself later for including it.
:::

## When to Log

As a rule of thumb, log anything that:

- **Mutates persistent state** - database writes, file changes, external API calls that create or modify something.
- **Has security implications** - permission changes, credential updates, admin actions.
- **Would be useful to an operator debugging later** - installs, migrations, one-shot maintenance actions.

You generally don't need to log pure reads (`GET` handlers), idempotent no-ops, or user-facing toggles that only affect the user themselves and have no security impact (e.g. a UI theme preference). When in doubt, lean toward logging - a slightly noisy audit log is a lot more useful than a quiet one when something goes wrong.

## Logging from Helper Functions

The activity logger is an axum extractor, which means it's only available inside route handlers. If the code that actually does the work lives in a helper module, you have two options:

1. **Pass the logger down.** The logger is cheap to clone, so you can just take it as an argument to your helper function. This is the cleanest approach and makes the dependency obvious.

2. **Return the data to log, and log in the handler.** Have your helper return enough information for the handler to build the payload, then call `.log(...)` at the top level. This keeps helpers agnostic of the logging system.

Option 2 is usually nicer - logging is a presentation concern, and keeping it out of your business logic means those helpers are easier to test and reuse from contexts that aren't HTTP handlers (like background jobs or CLI commands).


---

<!-- concepts/background-tasks-and-shutdown-handlers.md -->

---
title: Background Tasks and Shutdown Handlers
description: Run extension work on your own schedule with background tasks, and clean up properly when the Panel shuts down.
---

# Background Tasks and Shutdown Handlers

Routes and CLI commands both run in response to someone asking for something - a user hits an endpoint, an operator types a command. But sometimes your extension needs to do work on its own schedule: poll an external API every few minutes, expire stale cache entries hourly, or flush pending writes before the process exits. That's what background tasks and shutdown handlers are for.

Neither of these is particularly exciting - they're both "register a function, the Panel calls it at the right time" - but there are a couple of gotchas worth knowing (primary instance gating, how to actually get a schedule out of an otherwise-tight loop, what shutdown does to in-flight work) that the rest of this page covers.

## Background Tasks

A background task is an async function that the Panel runs in a loop for the lifetime of the process. You register it in `initialize_background_tasks`:

```rs
use shared::{
    State,
    extensions::{Extension, background_tasks::BackgroundTaskBuilder},
};
use std::time::Duration;

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize_background_tasks(
        &mut self,
        _state: State,
        builder: BackgroundTaskBuilder,
    ) -> BackgroundTaskBuilder {
        builder
            .add_task("refresh-cache", |state| async move {
                crate::cache::refresh(&state).await?;
                tokio::time::sleep(Duration::from_secs(300)).await;
                Ok(())
            })
            .await;

        builder
    }
}
```

A few things to unpack here.

**The loop function runs in a tight loop.** Whatever function you pass to `add_task` gets called again immediately after it returns. There is no built-in sleep between iterations. This is intentional - different tasks want different cadences, and forcing a single model (cron-like, fixed-interval, adaptive) would be wrong for half the use cases. But it means **you are responsible for pacing your own task**: sleep at the end of each iteration to wait for the next one, or sleep in the middle if you want to wake up on a schedule and do some work. A loop function that returns instantly with no sleep will hot-spin and consume 100% of a CPU core.

The typical shape is "do work, then sleep":

```rs
builder
    .add_task("refresh-every-5-minutes", |state| async move {
        do_the_work(&state).await?;
        tokio::time::sleep(Duration::from_secs(300)).await;
        Ok(())
    })
    .await;
```

**Errors don't kill the task.** If your loop function returns `Err(...)`, the error is logged and sent to Sentry, and then the loop function is called again on the next iteration. This is the right default for background work - you want a transient network failure to be logged and retried, not to silently stop the task for the rest of the process's lifetime. If you want to give up after N consecutive failures, track that yourself inside the closure.

**Panics don't kill the task either.** The Panel catches panics via `catch_unwind`, logs them, and records the panic message as the task's last error. Unlike errors, though, a panic *does* terminate the loop - the task won't run again until the process restarts. Panics indicate programmer bugs rather than operational failures, so this is reasonable behavior, but be aware of it if you're writing defensive code.

### Scheduled Tasks (Cron)

If your task needs to run on a strict schedule - at midnight every day, or at the top of every hour - calculating the exact sleep duration manually inside an `add_task` loop is tedious. For these cases, use `add_cron_task`:

```rs
use std::str::FromStr;

builder
    .add_cron_task(
        "daily-database-cleanup",
        croner::Cron::from_str("0 0 0 * * *").unwrap(),
        |state| async move {
            crate::cleanup::run_daily(&state).await?;
            Ok(())
        }
    )
    .await;

```

**The Panel handles the pacing.** Unlike `add_task`, you do *not* need to sleep at the end of a cron task. When your function returns, the Panel automatically calculates the time until the next matching cron tick and sleeps for you before calling your function again.

Everything else behaves exactly like `add_task`: errors are logged and retried at the next scheduled tick, panics permanently park the task until a restart, and registrations silently overwrite previous tasks with the same name.

> Also, keep in mind, the cron syntax is "second minute hour day month weekday" - that extra seconds field is a common gotcha for folks used to the more traditional "minute hour day month weekday" format. If your task isn't running when you expect, double-check your cron expression.

### Primary Instance Only

::: warning
**Background tasks only run on the primary instance.** If the Panel is deployed with multiple instances behind a load balancer (a standard setup for HA), only the instance marked `app_primary` will actually execute registered tasks (both `add_task` and `add_cron_task`). Calls on other instances are silently no-ops.

This prevents the obvious "every instance runs the same cron, so the job runs N times" problem. But it means you cannot rely on your background task to run if the operator hasn't designated a primary - in practice every deployment has one, but extension authors occasionally forget and wonder why their job never fires in dev setups with `app_primary = false`.

If you need work to run *on every instance* rather than just the primary - for example, flushing per-instance caches - a background task is the wrong tool. Consider a shutdown handler (which runs everywhere) or a short-lived spawned task inside a request handler.
:::

### Naming

Task names are `&'static str` and must be unique within your extension. The name shows up in logs and in any task-status UI the Panel exposes, so pick something descriptive - `refresh-mcjars-cache` rather than `task1`. If you register two tasks with the same name, the second registration silently overwrites the first, which is almost never what you want.

## Shutdown Handlers

A shutdown handler is the mirror image: instead of running periodically for the process's lifetime, it runs once when the Panel is shutting down gracefully. Register it in `initialize_shutdown_handlers`:

```rs
use shared::{
    State,
    extensions::{Extension, shutdown_handlers::ShutdownHandlerBuilder},
};

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize_shutdown_handlers(
        &mut self,
        _state: State,
        builder: ShutdownHandlerBuilder,
    ) -> ShutdownHandlerBuilder {
        builder
            .add_handler("flush-pending-writes", |state| async move {
                crate::buffer::flush_all(&state).await?;
                Ok(())
            })
            .await;

        builder
    }
}

```

The handler gets called once during graceful shutdown, before the process exits. The Panel awaits it - if you take ten seconds to flush, the process waits ten seconds. This is the right place for:

* Flushing in-memory buffers to the database
* Committing pending work that can't be safely restarted
* Closing external connections cleanly (telling an upstream service "I'm going away", logging out of a session, etc.)
* Writing final metrics or a "shutdown complete" log line

### Shutdown Handlers Run Everywhere

Unlike background tasks, shutdown handlers run on **every** instance, primary or not. This is because every instance has its own local state that might need cleanup - even non-primary instances have connections to close and buffers to flush.

If your handler's logic only makes sense on the primary (e.g. it's wrapping up coordinator-style work), gate it yourself:

```rs
builder
    .add_handler("coordinator-cleanup", |state| async move {
        if !state.env.app_primary {
            return Ok(());
        }
        crate::coordinator::release_lock(&state).await?;
        Ok(())
    })
    .await;

```

### Interaction with Background Tasks

When shutdown starts, **background tasks are aborted abruptly.** Their `JoinHandle` is dropped and the loop is cancelled at its next `.await` point - whatever the task was mid-way through doing gets dropped. This means:

* Anything your background task writes needs to be atomic from the database's perspective. Don't use background tasks for "start a multi-step transaction, commit at the end" workflows where abrupt cancellation leaves data half-written.
* If your background task has persistent state that needs flushing, **do the flushing in a shutdown handler, not in the task's own cleanup code**. A shutdown handler is the only hook that's guaranteed to run during shutdown; your background task's code after the work is already cancelled and won't execute.

The typical pairing is a background task that accumulates work in memory and a shutdown handler that flushes whatever's accumulated:

```rs
async fn initialize_background_tasks(
    &mut self,
    _state: State,
    builder: BackgroundTaskBuilder,
) -> BackgroundTaskBuilder {
    builder
        .add_task("accumulate-metrics", |state| async move {
            crate::metrics::sample_and_buffer(&state).await?;
            tokio::time::sleep(Duration::from_secs(10)).await;
            Ok(())
        })
        .await;

    builder
}

async fn initialize_shutdown_handlers(
    &mut self,
    _state: State,
    builder: ShutdownHandlerBuilder,
) -> ShutdownHandlerBuilder {
    builder
        .add_handler("flush-metrics", |state| async move {
            crate::metrics::flush_buffer(&state).await?;
            Ok(())
        })
        .await;

    builder
}

```

With this shape, the background task accumulates samples in-memory at a 10-second cadence, and on shutdown the handler flushes whatever's left regardless of whether the task was mid-iteration when shutdown hit.

### Shutdown Errors

If a shutdown handler returns `Err(...)` or panics, the error is logged and sent to Sentry, and the Panel moves on to the next handler - one broken handler doesn't block the others from running. This is deliberate: shutdown is your last chance to clean up, so the Panel tries everything rather than bailing out on the first failure.

That said, design your handlers to succeed. A handler that panics loses whatever cleanup you intended, and the Panel can't tell you "your flush never happened" after the process exits.

### Not Every Shutdown Runs Handlers

Shutdown handlers only run on **graceful** shutdown - the Panel receiving a `SIGTERM` or equivalent and cleanly winding down. If the process is killed with `SIGKILL`, crashes from an unrecoverable error, or is terminated by the OS out-of-memory killer, handlers don't get a chance to run. Don't rely on shutdown handlers for correctness - they're a best-effort cleanup pass, not a durability guarantee. Anything that *must* be persisted should be persisted at the point of the state change, not deferred until shutdown.


---

<!-- concepts/cli-commands.md -->

---
title: CLI Commands
description: Ship operator-facing shell commands with your extension instead of HTTP routes.
---

# CLI Commands

Sometimes the right way to ship a feature isn't an HTTP route - it's a command the operator runs from the shell. Maybe you want a way to regenerate cached data without hitting an HTTP endpoint, a one-off migration helper, a support tool to disable 2FA for a locked-out user, or a `version` command that prints build info. Calagopus lets extensions add their own top-level commands to the `panel-rs` CLI, and this page is about how.

If you've used [clap](https://docs.rs/clap) before, you already know most of what you need - the extension side is a thin wrapper that lets you register command groups and individual commands with the Panel's root CLI parser. If you haven't used clap, the examples here should be enough to get you going, and clap's docs fill in the details when you need them.

## Registering a Command Group

CLI registration happens in your extension's `initialize_cli` method - the CLI counterpart to `initialize_router` and `initialize_permissions`:

```rs
use shared::{
    State,
    extensions::{Extension, commands::CliCommandGroupBuilder},
};

mod commands;

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize_cli(
        &mut self,
        _env: Option<&std::sync::Arc<shared::env::Env>>,
        builder: CliCommandGroupBuilder,
    ) -> CliCommandGroupBuilder {
        builder.add_group("my-tool", "Tools for the my-tool extension.", commands::commands)
    }
}
```

The `add_group` call creates a top-level subcommand on the root CLI, and the closure (or function pointer, like `commands::commands` above) populates its children. With the code above, users run:

```bash
panel-rs my-tool version
panel-rs my-tool disable-2fa --user alice
panel-rs my-tool --help
```

::: warning
**The name you pass to `add_group` becomes a top-level subcommand, unprefixed.** `add_group("my-tool", ...)` makes `panel-rs my-tool ...` - there's no automatic namespacing, the same way admin routes aren't namespaced for you either. Pick a group name unlikely to collide with the core CLI or other extensions. Something tied to your extension (like `minecraft-versions` or `egg-changer`) is a safe default; a generic name like `tools` or `admin` is asking for trouble.
:::

You can nest groups inside groups by calling `add_group` again from within a child builder - useful if your extension has several categories of commands and you want `panel-rs my-tool users disable-2fa` rather than a flat list. For most extensions a single group with a handful of commands is plenty.

## The File-System Convention

Just like routes, commands follow a file-system idiom that keeps things easy to navigate once you have more than one or two. The convention is:

```bash
backend/src/
  lib.rs # contains initialize_cli, calls commands::commands through add_group
  commands/
    mod.rs # wires up the child modules
    create.rs # one command per file
    disable_2fa.rs
    reset_password.rs
```

The `mod.rs` is intentionally boring - it declares the child modules and defines a single function that registers them all on a builder:

```rs
use shared::extensions::commands::CliCommandGroupBuilder;

mod create;
mod disable_2fa;
mod reset_password;

pub fn commands(cli: CliCommandGroupBuilder) -> CliCommandGroupBuilder {
    cli.add_command(
        "create",
        "Creates a new user for the Panel.",
        create::CreateCommand,
    )
    .add_command(
        "disable-2fa",
        "Disables two-factor authentication for a user.",
        disable_2fa::Disable2FACommand,
    )
    .add_command(
        "reset-password",
        "Resets a user's password.",
        reset_password::ResetPasswordCommand,
    )
}
```

That function then gets passed straight to `add_group`, as shown in the previous section. Once you've written this `mod.rs` once you basically copy its shape and just swap out the command list.

If you nest a subgroup, do the same thing one level deeper - a `commands/users/mod.rs` exports its own `commands` function, and the parent `commands/mod.rs` calls `.add_group("users", "...", users::commands)` to wire it in.

## Writing a Command

A command is a type that implements `CliCommand<A>`, where `A` is a `clap::Args`-derived struct describing the command's arguments. The trait has two methods:

```rs
pub trait CliCommand<A: Args> {
    fn get_command(&self, command: Command) -> Command;
    fn get_executor(self) -> Box<ExecutorFunc>;
}
```

- **`get_command`** lets you customize the `clap::Command` beyond what `#[derive(Args)]` produces - adding aliases, tweaking help, registering arguments not expressible as struct fields. For simple commands, just return `command` unchanged.
- **`get_executor`** returns the async function that runs when the command is invoked. It receives an `Option<Arc<Env>>` (the parsed environment, if available) and the `ArgMatches` from clap, and returns `Result<i32, anyhow::Error>` where the `i32` is the process exit code.

Here's the simplest possible command - prints a version string, takes no arguments:

```rs
use clap::Args;

#[derive(Args)]
pub struct VersionArgs;

pub struct VersionCommand;

impl shared::extensions::commands::CliCommand<VersionArgs> for VersionCommand {
    fn get_command(&self, command: clap::Command) -> clap::Command {
        command
    }

    fn get_executor(self) -> Box<shared::extensions::commands::ExecutorFunc> {
        Box::new(|_env, _arg_matches| {
            Box::pin(async move {
                println!("my-tool version {}", env!("CARGO_PKG_VERSION"));

                Ok(0)
            })
        })
    }
}
```

A few details worth highlighting:

- **The command struct is a unit struct** (`pub struct VersionCommand;`), not an enum or a config-holding struct. It has no state of its own - all the state lives in the `Args` and in whatever you construct inside the executor.
- **The `Args` struct describes the arguments.** For a command with no arguments, it's an empty unit struct with `#[derive(Args)]`. For a command with flags, add fields with `#[arg(...)]` attributes - see the next section.
- **Exit code `0` means success**, following Unix convention. Non-zero codes indicate failure; pick sensibly numbered codes if you need to distinguish failure modes (`1` for generic failure is fine for most extensions).
- **The executor is wrapped in `Box::new(|env, arg_matches| Box::pin(async move { ... }))`.** That's unavoidable boilerplate - `ExecutorFunc` is a boxed trait object returning a pinned future. Copy-paste the shape; the interesting code lives inside the `async move {}` block.

## Arguments

For a command that takes arguments, define them as fields on the `Args` struct with clap's derive attributes, then parse them inside the executor with `A::from_arg_matches(&arg_matches)?`:

```rs
use clap::{Args, FromArgMatches};

#[derive(Args)]
pub struct Disable2FAArgs {
    #[arg(
        long = "user",
        help = "the username, email or uuid of the user to disable 2FA for"
    )]
    user: Option<String>,
}

pub struct Disable2FACommand;

impl shared::extensions::commands::CliCommand<Disable2FAArgs> for Disable2FACommand {
    fn get_command(&self, command: clap::Command) -> clap::Command {
        command
    }

    fn get_executor(self) -> Box<shared::extensions::commands::ExecutorFunc> {
        Box::new(|env, arg_matches| {
            Box::pin(async move {
                let args = Disable2FAArgs::from_arg_matches(&arg_matches)?;

                // args.user is now Option<String>
                // ... rest of the command

                Ok(0)
            })
        })
    }
}
```

Whatever clap supports, you get - positional arguments, flags, value parsers, defaults, global arguments, required-vs-optional, `ValueEnum`-derived choices. The `CliCommandGroupBuilder` itself adds one implicit global flag, `--debug` / `-d`, which is available on every subcommand without you declaring it.

For arguments that can't be expressed purely with `#[arg(...)]` attributes, use `get_command` to augment the `clap::Command` directly. This is an escape hatch, not the path of least resistance - most commands don't need it.

## Accessing State

Commands often need the Panel's state - the database, the settings store, the shared HTTP client. That state isn't available by default because not every command needs it (a `version` command that prints a string shouldn't pay the cost of connecting to the database), so you opt in by constructing it yourself:

```rs
let state = shared::AppState::new_cli(env).await?;
```

`AppState::new_cli` takes the `env: Option<Arc<Env>>` parameter from your executor and returns a fully-initialized `State` - the same type your HTTP handlers get. From there you use it exactly like you would in a route, including querying the database and reading settings.

Here's a fuller example - a command that disables 2FA for a user, looking them up by username, email, or UUID, and prompting interactively if no `--user` flag was passed:

```rs
use clap::{Args, FromArgMatches};
use colored::Colorize;
use compact_str::ToCompactString;
use dialoguer::{Input, theme::ColorfulTheme};
use shared::models::ByUuid;
use std::io::IsTerminal;

#[derive(Args)]
pub struct Disable2FAArgs {
    #[arg(
        long = "user",
        help = "the username, email or uuid of the user to disable 2FA for"
    )]
    user: Option<String>,
}

pub struct Disable2FACommand;

impl shared::extensions::commands::CliCommand<Disable2FAArgs> for Disable2FACommand {
    fn get_command(&self, command: clap::Command) -> clap::Command {
        command
    }

    fn get_executor(self) -> Box<shared::extensions::commands::ExecutorFunc> {
        Box::new(|env, arg_matches| {
            Box::pin(async move {
                let args = Disable2FAArgs::from_arg_matches(&arg_matches)?;
                let state = shared::AppState::new_cli(env).await?;

                let user = match args.user {
                    Some(user) => user,
                    None => {
                        if std::io::stdout().is_terminal() {
                            Input::with_theme(&ColorfulTheme::default())
                                .with_prompt("Username, Email or UUID")
                                .interact_text()?
                        } else {
                            eprintln!(
                                "{}",
                                "user arg is required when not running in an interactive terminal"
                                    .red()
                            );
                            return Ok(1);
                        }
                    }
                };

                let user = if let Ok(uuid) = user.parse() {
                    shared::models::user::User::by_uuid_optional(&state.database, uuid).await
                } else if user.contains('@') {
                    shared::models::user::User::by_email(&state.database, &user).await
                } else {
                    shared::models::user::User::by_username(&state.database, &user).await
                }?;

                let Some(user) = user else {
                    eprintln!("{}", "user not found".red());
                    return Ok(1);
                };

                if !user.totp_enabled {
                    eprintln!(
                        "{}",
                        "two-factor authentication is not enabled for this user".red()
                    );
                    return Ok(1);
                }

                shared::models::user_recovery_code::UserRecoveryCode::delete_by_user_uuid(
                    &state.database,
                    user.uuid,
                )
                .await?;

                sqlx::query!(
                    "UPDATE users
                    SET totp_enabled = false, totp_last_used = NULL, totp_secret = NULL
                    WHERE users.uuid = $1",
                    user.uuid
                )
                .execute(state.database.write())
                .await?;

                eprintln!(
                    "2FA has been disabled for the user {}",
                    user.uuid.to_compact_string().cyan()
                );

                Ok(0)
            })
        })
    }
}
```

A few patterns worth pulling out of this:

- **Build state early**, right after parsing args. If it fails (no database, malformed env), the user sees the error before any other work happens.
- **Interactive fallback for missing arguments**, guarded by `std::io::stdout().is_terminal()`. When the command is run interactively, prompt for what's missing; when it's piped or scripted, fail fast with a clear error message and exit `1`. The [dialoguer](https://docs.rs/dialoguer) crate handles the prompt rendering.
- **Use `eprintln!` for status and error messages**, not `println!`. Success output (like the "2FA disabled" line at the end) goes to stderr too in this example because it's user-facing information rather than a machine-readable value. Reserve `println!` for output that should be pipeable to another command.
- **Use [colored](https://docs.rs/colored) for ANSI output**. Red for errors, cyan for identifiers, green for success - matches the convention used across the Panel's built-in CLI.

## The `env` Parameter

The `env: Option<Arc<Env>>` your executor receives reflects whether the Panel's environment file was loaded successfully:

- **`Some(env)`** - the env file was parsed. The Panel has config, a valid database connection is constructable, and everything the runtime expects is in place.
- **`None`** - the env file couldn't be read or parsed. This happens during install/setup commands that need to run *before* the Panel is properly configured - think a `generate-env` or `first-time-setup` helper.

For most commands you want `Some(env)` and you'll bail out on `None`. `AppState::new_cli(env)` will fail gracefully if the env is `None` and the command can't proceed without state, so in practice you can just `?` it and get the right behavior.

If your command specifically needs to work *without* the env (e.g. it's the command that generates the env file in the first place), branch on `env.is_some()` and handle the cases explicitly - you've seen this pattern in the core Panel's `service install` command, which optionally starts the service with `systemctl enable --now` if the env is present, but registers the service for later startup if not.

## Exit Codes

Return `Ok(0)` for success and `Ok(non_zero)` for failure - scripts calling your command will check `$?` and expect Unix conventions. Don't return `Err(...)` for user-facing failures like "user not found" or "file already exists"; those are expected outcomes and should print a helpful message to stderr and return a non-zero exit code. Reserve `Err(...)` for genuine unexpected failures (database connection lost, filesystem I/O error) that you want propagated with a stack trace in debug mode.

A simple convention that works fine for most extensions: `0` for success, `1` for any expected failure with a printed error, and bubble up `Err(...)` for anything else.


---

<!-- concepts/email-templates.md -->

---
title: Email Templates
description: Add or override the MiniJinja templates Calagopus renders its emails from.
---

# Email Templates

Calagopus sends emails for things like password resets, account creation notifications, and SMTP connection tests. Each of those emails is rendered from a **template** - a chunk of HTML with [MiniJinja](https://docs.rs/minijinja) placeholders for variable substitution. By default the Panel ships its own templates baked into the binary, but operators can override them through the admin UI (different brand voice, different language, different layout entirely if they want), and **extensions can register their own templates** so that emails their extension sends are subject to the same override flow as the core ones.

That last part is what this page is about. If your extension sends emails - notification when a backup completes, alerts when a server hits a resource limit, custom welcome flow - you should ship your email content as a template rather than hardcoding the HTML in your Rust code. That way operators can customize it, and your extension fits into the same admin UI everyone already knows how to use.

## What a Template Looks Like

A template is a small Rust struct: an identifier, a list of available variable names, a default subject line, the default body content as a string (typically `include_str!`'d from an HTML file in your extension's source tree), and whether the template is enabled by default. You don't store templates yourself - the Panel manages persistence. You just declare them and the framework handles the override-and-fall-back machinery.

```rs
use shared::extensions::email_templates::EmailTemplate;

EmailTemplate {
    identifier: "dev.0x7d8.test.welcome",
    available_variables: vec!["user", "invite_link"],
    default_subject: "{{ settings.app.name }} - Welcome",
    default_content: include_str!("../mails/welcome.html"),
    default_enabled: true,
}
```

The five fields, in order:

- **`identifier`** is a `&'static str` that uniquely names this template. It's how your code looks the template up later when sending an email, and it's how the admin UI keys overrides in the database. Identifiers are global across the whole Panel - core templates and every extension share one namespace - so prefix yours with your package name (e.g. `dev.0x7d8.test.welcome`, not just `welcome`) to avoid collisions.

- **`available_variables`** is a `Vec<&'static str>` listing the variables your template can use. This is **metadata for the admin UI** - it shows operators which variables are available to put into the template - not enforcement. The actual rendering uses whatever variables the calling code passes; a typo in an override that references a non-existent variable will just render as nothing rather than error. Keep this list accurate so operators editing the template have something to work from.

- **`default_subject`** is the email subject line, as a MiniJinja template string. It supports the same <code v-pre>{{ variable }}</code> syntax as the body - <code v-pre>{{ settings.app.name }}</code> works here just as it does in the body content. Operators can override the subject through the admin UI independently of the body.

- **`default_content`** is the template body, a MiniJinja-formatted HTML string. It's `&'static str` because it's typically `include_str!`'d from a file in your extension at build time. Operators can override it through the admin UI; if no override is set, your default is used.

- **`default_enabled`** controls whether the template is enabled out of the box. If `false`, `send_template` and `send_template_foreground` silently skip sending when no operator override is in place. Use this for opt-in notifications (e.g. "server installed" alerts) where most operators probably don't want the email unless they actively turn it on.

::: info
Every template implicitly gets a `settings` variable in addition to whatever you declare - it's the Panel's app settings, accessible as <code v-pre>{{ settings.app.name }}</code>, <code v-pre>{{ settings.app.url }}</code>, etc. Two things happen automatically: `settings` is appended to your `available_variables` list during finalization (so it shows up in the admin UI even if you didn't list it), and it's injected into the rendering context by `send_template` / `send_template_foreground` at send time. You should not pass `settings` yourself in the context - whatever you pass gets overwritten by the framework-provided value anyway.
:::

## Registering Templates

Templates are registered through the `initialize_email_templates` method on your `Extension` trait, which gets handed an `ExtensionEmailTemplateBuilder`:

```rs
use shared::{
    State,
    extensions::{
        Extension,
        email_templates::{EmailTemplate, ExtensionEmailTemplateBuilder},
    },
};

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize_email_templates(
        &mut self,
        _state: State,
        builder: ExtensionEmailTemplateBuilder,
    ) -> ExtensionEmailTemplateBuilder {
        builder.add_template(EmailTemplate {
            identifier: "dev.0x7d8.test.welcome",
            available_variables: vec!["user", "invite_link"],
            default_subject: "{{ settings.app.name }} - Welcome",
            default_content: include_str!("../mails/welcome.html"),
            default_enabled: true,
        })
    }
}
```

The builder method is `add_template(template) -> Self`, returning the builder so you can chain multiple registrations. There's no separate "list of all templates" - each call adds one.

::: warning Duplicate identifiers are silently dropped
If you call `add_template` with an identifier that's already registered (by core or by another extension that loaded before yours), the call is a silent no-op - the existing template wins, your call has no effect. There is no error, no log message, no panic. Pick a unique identifier (prefix with your package name) and you'll never hit this.
:::

The directory layout most extensions use looks like this:

```bash
backend/
  mails/
    welcome.html # MiniJinja template, included via include_str!
  src/
    lib.rs # registers the templates in initialize_email_templates
```

Storing the templates as separate `.html` files (rather than inline string literals) keeps the Rust code readable.

## Writing the Template Content

Template content is a [MiniJinja](https://docs.rs/minijinja) template - close to Jinja2 if you've used Python templating, with the same <code v-pre>{{ variable }}</code> and `{% control %}` syntax. A minimal welcome email might look like:

```html
<p>Hello {{ user.name }},</p>

<p>Your account at {{ settings.app.name }} is ready to go.</p>

<p>
  Click <a href="{{ invite_link }}">here</a> to set a password and log in.
  This link expires in {{ user.invite_expiry_hours }} hours.
</p>
```

<code v-pre>{{ user.name }}</code> and <code v-pre>{{ user.invite_expiry_hours }}</code> use field access - MiniJinja can dot-walk into structs that get serialized into the rendering context. The shape of `user` is whatever the sending code passed; if you registered `available_variables: vec!["user"]` and your sender passes `user => some_user_struct`, the template can access any of that struct's serialized fields. <code v-pre>{{ invite_link }}</code> is a simple string variable; <code v-pre>{{ settings.app.name }}</code> is the implicit settings variable, accessible without you passing anything.

::: warning Default templates should be in English
The `default_content` you ship with your extension should be written in English, regardless of where you or your users are. Operators who want a different language adjust the template content through the admin UI on a per-deployment basis - the override system is the localization story for emails. Don't try to ship multiple language variants by registering separate identifiers per language; that just creates fragmentation that operators can't sensibly customize.
:::

## Sending an Email Using Your Template

Sending an email uses `state.mail.send_template` (fire-and-forget) or `state.mail.send_template_foreground` (awaits and propagates errors). Both methods look up the template, check whether it's enabled, resolve the subject and body (applying any operator overrides), and then send:

```rs
use shared::{
    State,
    response::{ApiResponse, ApiResponseResult},
};

async fn send_welcome_email(
    state: &State,
    user: &shared::models::user::User,
    invite_link: &str,
) -> Result<(), anyhow::Error> {
    state
        .mail
        .send_template(
            state,
            "dev.0x7d8.test.welcome",
            user.email.clone(),
            minijinja::context! {
                user => user,
                invite_link => invite_link,
            },
        )
        .await;

    Ok(())
}
```

Note the absence of `?` on the `send_template` call - it returns nothing meaningful (it spawns a tokio task and any failure is logged from inside the task). If you use `send_template_foreground` instead, you'd propagate errors with `.await?`.

The four arguments to `send_template` / `send_template_foreground`: the `State`, the template identifier, the recipient address, and the MiniJinja context for variable substitution.

A few notes on this pattern:

- **The subject comes from the template, not your code.** Both the subject and body are stored in the template and can be overridden by operators. The subject is itself a MiniJinja template string, so <code v-pre>{{ settings.app.name }}</code> and other variables work there too.
- **If the template is disabled, the send is silently skipped.** `send_template` returns immediately with no error; `send_template_foreground` returns `Ok(())`. A `tracing::debug` message is emitted so you can see it in logs. Check `default_enabled` on your template definition if you're wondering why emails aren't sending.
- **The 15-second cache still applies.** Template content and the enabled/disabled state are cached from the database for 15 seconds. A change made in the admin UI won't be visible to senders for up to that long.
- **`send_template` vs `send_template_foreground` is about who handles failures.** `send_template` returns almost immediately and spawns a tokio task for the actual send - SMTP errors, network errors, and rendering errors are logged from inside the task and the user-facing request is unaffected. `send_template_foreground` does everything in your async context and propagates errors back. Use `send_template` for fire-and-forget notifications; use `send_template_foreground` when the send result actually matters to your code (e.g. an SMTP connection test, where the whole point is to know whether it worked).

## Overriding Core Templates

You can also modify a template the core Panel ships, rather than registering a new one. The use case here is narrow but real: maybe you ship an extension that customizes a deployment's email branding (say, replacing the default password-reset template with one that matches your customer's brand), and you don't want to make every operator manually paste content into the admin UI.

Use `mutate_template` for this:

```rs
async fn initialize_email_templates(
    &mut self,
    _state: State,
    builder: ExtensionEmailTemplateBuilder,
) -> ExtensionEmailTemplateBuilder {
    builder.mutate_template("password_reset", |template| {
        template.default_subject = "Password Reset - My Brand";
        template.default_content = include_str!("../mails/branded_password_reset.html");
    })
}
```

`mutate_template` finds the template by identifier and runs your closure against it, letting you change any field - `default_content` (the most common case), `default_subject`, or `default_enabled`. If no template with that identifier exists, the closure is silently skipped.

The core templates available for mutation, as of this writing, are:

- `account_created` - sent when a new user account is created. Variables: `user`, `reset_link`.
- `password_reset` - sent when a user requests a password reset. Variables: `user`, `reset_link`.
- `email_verification` - sent when a user must confirm an email address (on registration or an email change). Variables: `user`, `email`, `verification_link`.
- `two_factor_code` - sent when a user requests an email two-factor login code. Variables: `user`, `code`.
- `connection_test` - sent by the admin SMTP test feature. No variables (other than the implicit `settings`).
- `added_to_server` - sent when a user is added as a subuser to a server. Variables: `server`, `server_link`.
- `removed_from_server` - sent when a user is removed as a subuser from a server. Variables: `server`.
- `server_installed` - sent when a server finishes installing. Variables: `server`, `server_link`. Disabled by default.
- `server_restored` - sent when a server backup is restored. Variables: `server`, `server_link`. Disabled by default.

::: warning Don't extend `available_variables` on a core template
The variables list reflects what the calling code actually passes when sending. If you add `"server_count"` to the `password_reset` template's variable list but the password-reset code path never passes a `server_count`, operators will see it in the UI as available but every reference to it in their template will render as nothing. If you need extra variables, register your own template under a new identifier instead and use it from your own code.
:::

Mutating core templates is a sharp tool - it changes behavior other parts of the Panel and other extensions depend on. Same general guidance as mutating core permissions or intercepting routes: do it sparingly, document why, and prefer adding your own template alongside the core one when you can.

## What Operators See

The whole point of using the template system rather than hardcoded HTML is that operators get a UI for editing your templates. From the admin panel's "Email Templates" page they can:

- See the list of all registered templates (core and extension-provided), each labeled by its identifier
- See the available variables for each template, so they know what they can reference
- Toggle a template on or off (independently of the default) - disabled templates are silently skipped at send time
- Edit the subject line, replacing your default with their own customized version (the subject supports the same MiniJinja syntax as the body)
- Edit the body content, replacing your default with their own customized version
- Reset the subject and/or content back to the default at any time

Overrides are per-template and stored in the Panel's database, so they persist across restarts and are shared across panel instances. Resetting deletes the database row for that field, falling back to your `default_subject` / `default_content` immediately.

## Where to Go From Here

Most extensions only need `add_template` and `send_template` - the rest of this is for less common cases. If you're shipping a feature that sends email, register a template, write your default HTML and subject in English, and use it. The override flow happens for free.

A few things this page didn't cover that you might want to look into separately:

- **Triggering sends from background tasks.** Nothing about email is route-specific - you can call `state.mail.send_template` from a `BackgroundTaskBuilder` task or a `ShutdownHandlerBuilder` handler the same way you'd call it from a route. See [Background Tasks and Shutdown Handlers](./background-tasks-and-shutdown-handlers.md).
- **Per-recipient customization beyond the context.** The MiniJinja context is per-call, so for things like "include this user's recent activity in the email," compute the activity, pass it as a variable, and render it in the template. That's standard usage; no special API.
- **Conditionally suppressing sends.** If you want users to opt out of certain emails (or operators to disable specific email types globally), the `default_enabled` field and the operator-facing enabled toggle handle the operator-global case. For per-user opt-out, check whatever flag you've set up on the user before calling `send_template` - the email-template system doesn't have a built-in per-user suppression mechanism, but the call sites are your code anyway.


---

<!-- concepts/events.md -->

---
title: Events
description: Listen to panel events from your extension and run code when they fire.
---

# Events

You may be writing an extension and wondering, how can I delete all of my systems files when someone renames their account? Or maybe not, but still, you want to be able to listen to an event and run some code when it happens. This is where the Panel's event system comes in.

There are different kinds of events that structs can emit, usually, it's the following:

| Trait | Description |
| ----- | ----------- |
| [`EventEmittingModel`](https://cratedocs.calagopus.com/shared/models/trait.EventEmittingModel) | This may sound common but is actually the least used event emitter, it's for very specific events that usually ONLY apply to the model they are on, for example, the `Server` model emits an event when it's reinstalled, which is something that only applies to the `Server` model, and it wouldn't make sense for other models to be able to emit this event. |
| [`CreatableModel`](https://cratedocs.calagopus.com/shared/models/trait.CreatableModel) | This is a more common event emitter, it emits events when a model is created, this is useful for when you want to run some code when a model is created, for example, you may want to create a default configuration for an extension when a new server is created. |
| [`UpdatableModel`](https://cratedocs.calagopus.com/shared/models/trait.UpdatableModel) | This is also a common event emitter, it emits events when a model is updated, this is useful for when you want to run some code when a model is updated, for example, you may want to update some configuration for an extension when a server is renamed. |
| [`DeletableModel`](https://cratedocs.calagopus.com/shared/models/trait.DeletableModel) | This is also a common event emitter, it emits events when a model is deleted, this is useful for when you want to run some code when a model is deleted, for example, you may want to clean up some data for an extension when a server is deleted. |
| [`DuplicableModel`](https://cratedocs.calagopus.com/shared/models/trait.DuplicableModel) | This emits events when a model is duplicated (such as duplicating a role, location, node, egg, egg configuration, mount, announcement, oauth provider, schedule or schedule step). It works just like the create/update/delete emitters, except the model handed to your handlers is the *source* model being duplicated. This is useful for when you want to copy along your own extension's data for the new copy, or cancel a duplication. |

Listening to these events is pretty straightforward, however it does change slightly between the `EventEmittingModel` trait and the other three, so we will go over them separately.

## Listening to `EventEmittingModel` Events

For this example, let's use the [`Server`](https://cratedocs.calagopus.com/shared/models/server/struct.Server) model, which emits an event when it is reinstalled. To listen to this event, we need to use the trait and we can basically already listen.

```rs
use shared::{
    State,
    extensions::Extension,
    models::{
        EventEmittingModel,
        server::{Server, ServerEvent},
    },
};

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize(&mut self, _state: State) {
        tracing::info!("dev_0x7d8_test extension initialize called");

        // its important to note that you should not call this multiple times, otherwise you will be registering multiple listeners and your code will run multiple times when the event is emitted
        Server::register_event_handler(async |_state, event| {
            match &*event {
                ServerEvent::InstallStarted { server, .. } => {
                    tracing::info!("install started for server: {}", server.name);
                }
                ServerEvent::InstallCompleted { server, successful } => {
                    tracing::info!(
                        "install completed for server: {}, successful: {}",
                        server.name,
                        successful
                    );
                }
                _ => {}
            }

            Ok(())
        });
    }
}
```

Relatively straightforward, you just call the `register_event_handler` function on the model you want to listen to events from, and then you match on the event that is emitted and run your code accordingly. Note that registering is a plain synchronous call - only the handler closure itself is async.

To see all models that support this, you can check the implementors [in the cratedocs](https://cratedocs.calagopus.com/shared/models/trait.EventEmittingModel#implementors).

## Listening to `CreatableModel`, `UpdatableModel`, `DeletableModel` and `DuplicableModel` Events

These are a bit more complex, Rust's type system is working overtime with the implementation of these, however you don't have to worry about it too much.

Each of these traits actually exposes **two** kinds of hooks: a *before* hook and an *after* hook. The before hook runs before the database operation happens, and is the one you've probably been using already; it lets you modify the options, the query builder, or cancel the operation entirely by returning an error. The after hook runs after the database operation has completed, but still inside the same transaction, so you can use it to react to the result of the operation while still being able to fail the whole thing if something goes wrong (returning an error from an after hook will roll back the transaction along with everything the before hooks and the operation itself did).

When to use which? A good rule of thumb: if you want to *influence* how the operation happens, use the before hook. If you want to *react* to the operation having happened (for example, because you need the resulting model's UUID, or because you want your side-effects to only run if the operation actually succeeded), use the after hook.

::::tabs
=== CreatableModel

```rs
use shared::{
    State,
    extensions::Extension,
    models::{CreatableModel, ListenerPriority, server::Server},
};

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize(&mut self, _state: State) {
        tracing::info!("dev_0x7d8_test extension initialize called");

        // its important to note that you should not call this multiple times, otherwise you will be registering multiple listeners and your code will run multiple times when the event is emitted
        Server::register_create_handler(
            ListenerPriority::Normal,
            |options, _query_builder, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!("creating server with name: {}", options.name);
                    Ok(())
                })
            },
        );

        // and the after hook, which runs once the server has actually been created
        Server::register_after_create_handler(
            ListenerPriority::Normal,
            |result, options, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!(
                        "server created with name: {} (result available now)",
                        options.name
                    );
                    Ok(())
                })
            },
        );
    }
}
```

What's important to note here is the `ListenerPriority`, which is an enum that determines the order in which the listeners are called. Huh? But why was that not needed for the `EventEmittingModel` events? Well, that's because those events are run whenever they see fit, you do not have influence over whether they will be cancelled or similar. By name, it's an Emitter, it emits events, you listen to them, but you don't have influence over them. However, with these events, you do have influence over them, for example, with the `CreatableModel` events, you can cancel the creation of the model by returning an error in the handler, or you can modify the options that are used to create the model. This is where the `ListenerPriority` comes in, it determines the order in which the listeners are called, and if a listener returns an error, the listeners with lower priority will not be called.

Here's an overview of the parameters of the before handler function (registered with `register_create_handler`):

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `options` | `&mut CreateOptions` | The options that are used to create the model, you can modify these options to change how the model is created. |
| `query_builder` | `&mut InsertQueryBuilder` | The query builder that is used to create the model, you can use this to set additional fields on the model that are not in the options, for example, you can set a `created_by_extension` field to true to indicate that the model was created by an extension. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The sqlx database transaction that is used to create the model after all listeners ran, you can use this to run additional queries that are part of the creation of the model, for example, you can create a default configuration for an extension in the database as part of the creation of a server. |

And the after handler function (registered with `register_after_create_handler`):

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `result` | `&mut CreateResult` | The result of the creation, this is the value that will be returned from `create`. You can mutate it if you have a reason to, but more commonly you'll just read from it (for example, to grab a freshly assigned UUID for whatever follow-up work you want to do). |
| `options` | `&CreateOptions` | The options that were used to create the model. Immutable here, since the creation has already happened - modifying them at this point wouldn't change anything. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The same sqlx transaction the creation was performed in. Returning an error here will roll back the whole thing, including the creation itself. |

To see all models that support this, you can check the implementors of the `CreatableModel` trait [in the cratedocs](https://cratedocs.calagopus.com/shared/models/trait.CreatableModel#implementors).

=== UpdatableModel

```rs
use shared::{
    State,
    extensions::Extension,
    models::{ListenerPriority, UpdatableModel, server::Server},
};

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize(&mut self, _state: State) {
        tracing::info!("dev_0x7d8_test extension initialize called");

        // its important to note that you should not call this multiple times, otherwise you will be registering multiple listeners and your code will run multiple times when the event is emitted
        Server::register_update_handler(
            ListenerPriority::Normal,
            |server, options, _query_builder, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!(
                        "updating server with name: {} (new name: {})",
                        server.name,
                        options.name.as_ref().unwrap_or(&server.name)
                    );
                    Ok(())
                })
            },
        );

        // and the after hook, which runs once the server has actually been updated
        Server::register_after_update_handler(
            ListenerPriority::Normal,
            |server, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!("server updated, current name is now: {}", server.name);
                    Ok(())
                })
            },
        );
    }
}
```

What's important to note here is the `ListenerPriority`, which is an enum that determines the order in which the listeners are called. Huh? But why was that not needed for the `EventEmittingModel` events? Well, that's because those events are run whenever they see fit, you do not have influence over whether they will be cancelled or similar. By name, it's an Emitter, it emits events, you listen to them, but you don't have influence over them. However, with these events, you do have influence over them, for example, with the `UpdatableModel` events, you can cancel the update of the model by returning an error in the handler, or you can modify the options that are used to update the model. This is where the `ListenerPriority` comes in, it determines the order in which the listeners are called, and if a listener returns an error, the listeners with lower priority will not be called.

Here's an overview of the parameters of the before handler function (registered with `register_update_handler`):

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `server` | `&mut Server` | The model that is being updated, you can modify this model to change how the update works, in general, that's only useful for modifying fields that are not in the options, since the fields that are in the options will override the fields in the model, but you can use this to set additional fields on the model that are not in the options, for example, you can set an `updated_by_extension` field to true to indicate that the model was updated by an extension. |
| `options` | `&mut UpdateOptions` | The options that are used to update the model after all listeners ran, you can modify these options to change how the model is updated. |
| `query_builder` | `&mut UpdateQueryBuilder` | The query builder that is used to update the model, you can use this to set additional fields on the model that are not in the options, for example, you can set an `updated_by_extension` field to true to indicate that the model was updated by an extension. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The sqlx database transaction that is used to update the model after all listeners ran, you can use this to run additional queries that are part of the update of the model, for example, you can update a configuration for an extension in the database as part of the update of a server. |

And the after handler function (registered with `register_after_update_handler`):

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `server` | `&mut Server` | The model in its post-update state. The fields from the options have already been applied at this point, so you're looking at what the model actually is now, not what it was before. You can still mutate it if you need to, though it's a bit of an unusual thing to want to do. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The same sqlx transaction the update was performed in. Returning an error here will roll back the whole thing, including the update itself. |

Note that there's no `options` parameter in the after hook - by the time it runs, the options have already been consumed by the update, and the post-update model itself is the source of truth for what changed.

To see all models that support this, you can check the implementors of the `UpdatableModel` trait [in the cratedocs](https://cratedocs.calagopus.com/shared/models/trait.UpdatableModel#implementors).

=== DeletableModel

```rs
use shared::{
    State,
    extensions::Extension,
    models::{DeletableModel, ListenerPriority, server::Server},
};

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize(&mut self, _state: State) {
        tracing::info!("dev_0x7d8_test extension initialize called");

        // its important to note that you should not call this multiple times, otherwise you will be registering multiple listeners and your code will run multiple times when the event is emitted
        Server::register_delete_handler(
            ListenerPriority::Normal,
            |server, _options, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!("deleting server with name: {}", server.name);
                    Ok(())
                })
            },
        );

        // and the after hook, which runs once the server has actually been deleted
        Server::register_after_delete_handler(
            ListenerPriority::Normal,
            |server, _options, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!("server with name {} has been deleted", server.name);
                    Ok(())
                })
            },
        );
    }
}
```

What's important to note here is the `ListenerPriority`, which is an enum that determines the order in which the listeners are called. Huh? But why was that not needed for the `EventEmittingModel` events? Well, that's because those events are run whenever they see fit, you do not have influence over whether they will be cancelled or similar. By name, it's an Emitter, it emits events, you listen to them, but you don't have influence over them. However, with these events, you do have influence over them, for example, with the `DeletableModel` events, you can cancel the deletion of the model by returning an error in the handler, or you can modify the options that are used to delete the model. This is where the `ListenerPriority` comes in, it determines the order in which the listeners are called, and if a listener returns an error, the listeners with lower priority will not be called.

Here's an overview of the parameters of the before handler function (registered with `register_delete_handler`):

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `server` | `&Server` | The model that is being deleted, you can use this to get information about the model that is being deleted, for example, you can get the name of the server that is being deleted to clean up some data for that server. |
| `options` | `&DeleteOptions` | The options that are used to delete the model, you can use this to get information about how the model is being deleted, for example, you can check if the deletion is supposed to be forceful to decide whether you want to allow the deletion or not. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The sqlx database transaction that is used to delete the model after all listeners ran, you can use this to run additional queries that are part of the deletion of the model, for example, you can clean up some data for an extension in the database as part of the deletion of a server. |

And the after handler function (registered with `register_after_delete_handler`), which has the same shape:

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `server` | `&Server` | The model that was just deleted. Still readable in memory of course (Rust doesn't make the value disappear just because the row did), so you can use it to clean up extension data, files on disk, or anything else keyed off this model. |
| `options` | `&DeleteOptions` | The options that were used to delete the model, in case your cleanup logic needs to know whether the deletion was forceful or not. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The same sqlx transaction the deletion was performed in. Returning an error here will roll back the whole thing, including the deletion itself - useful if your cleanup is critical and you'd rather keep the row around than have it gone with no extension data cleaned up. |

To see all models that support this, you can check the implementors of the `DeletableModel` trait [in the cratedocs](https://cratedocs.calagopus.com/shared/models/trait.DeletableModel#implementors).

=== DuplicableModel

```rs
use shared::{
    State,
    extensions::Extension,
    models::{DuplicableModel, ListenerPriority, role::Role},
};

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize(&mut self, _state: State) {
        tracing::info!("dev_0x7d8_test extension initialize called");

        // its important to note that you should not call this multiple times, otherwise you will be registering multiple listeners and your code will run multiple times when the event is emitted
        Role::register_duplicate_handler(
            ListenerPriority::Normal,
            |role, options, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!(
                        "duplicating role {} into new role with name: {}",
                        role.name,
                        options.name
                    );
                    Ok(())
                })
            },
        );

        // and the after hook, which runs once the duplicate has actually been created
        Role::register_after_duplicate_handler(
            ListenerPriority::Normal,
            |role, duplicated, _options, _state, _transaction| {
                Box::pin(async move {
                    tracing::info!(
                        "role {} was duplicated into new role {} ({})",
                        role.name,
                        duplicated.name,
                        duplicated.uuid
                    );
                    Ok(())
                })
            },
        );
    }
}
```

What's important to note here is the `ListenerPriority`, which is an enum that determines the order in which the listeners are called. Huh? But why was that not needed for the `EventEmittingModel` events? Well, that's because those events are run whenever they see fit, you do not have influence over whether they will be cancelled or similar. By name, it's an Emitter, it emits events, you listen to them, but you don't have influence over them. However, with these events, you do have influence over them, for example, with the `DuplicableModel` events, you can cancel the duplication of the model by returning an error in the handler. This is where the `ListenerPriority` comes in, it determines the order in which the listeners are called, and if a listener returns an error, the listeners with lower priority will not be called.

The big thing to keep in mind with duplication is the source model. The before hook is only handed the **source** model - the one being duplicated from - since the copy doesn't exist yet; unlike the create hooks it gets no query builder, so the new copy is constructed from the source model's fields plus the `options`, and the `options` are your only window into what's actually changing (for example, the new name). The after hook, on the other hand, runs once the copy has been inserted, so it receives *both* the source model **and** the freshly created duplicate (mutably), letting you react to the actual result - for example, to grab its newly assigned UUID.

Here's an overview of the parameters of the before handler function (registered with `register_duplicate_handler`):

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `model` | `&Self` | The *source* model that is being duplicated. You can read from it to decide what your handler should do, for example, to copy along your extension's own data that is keyed off the source model onto the new copy. |
| `options` | `&DuplicateOptions` | The options that are used to duplicate the model, these hold the values that differ from the source (for example, the new name). Note that this is immutable, so you cannot change how the duplicate is created here. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The sqlx database transaction that the duplication runs in, you can use this to run additional queries that are part of the duplication, for example, copying your extension's data for the new model. |

And the after handler function (registered with `register_after_duplicate_handler`), which runs *after* the duplicate row has been inserted, still inside the same transaction (so returning an error from it will roll back the whole duplication):

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `model` | `&Self` | The *source* model that was duplicated from. Still readable so you can compare it against the new copy or key your follow-up work off it. |
| `duplicated` | `&mut Self` | The freshly created duplicate, as it now exists in the database (with its newly assigned UUID and any other generated fields). You can mutate it if you have a reason to, but more commonly you'll just read from it, for example, to grab the new UUID to wire up your extension's own data for the copy. |
| `options` | `&DuplicateOptions` | The options that were used to duplicate the model. |
| `state` | `&State` | The state of the application, you can use this to access the database or other models. |
| `transaction` | `&mut Transaction` | The same sqlx transaction the duplication was performed in. Returning an error here will roll back the whole thing, including the duplicate itself. |

To see all models that support this, you can check the implementors of the `DuplicableModel` trait [in the cratedocs](https://cratedocs.calagopus.com/shared/models/trait.DuplicableModel#implementors).

::::


---

<!-- concepts/extending-models.md -->

---
title: Extending Models
description: Attach your extension data to the Panel models and API objects.
---

# Extending Models

So you've read the rest of the docs and you're feeling pretty good - you can add routes, register settings, ship a UI, the whole nine yards. But everything you've built so far has been *next to* the Panel's existing data model, never *part* of it. You can store a setting that says "the subdomain limit for server X is 5", but you can't put a column directly on the `servers` table that everyone else who queries servers will see. And you can't get that column to appear in API responses, or get the existing admin "create server" form to know about it, without a lot of glue code.

Calagopus has a system for exactly this: **model extensions**. Your extension can register itself against a core model (like `Server`, `Node`, `User`, etc.), declare extra columns it owns, hook into the existing create / update / delete flows, and even extend the API structs that get sent over the wire. To core code and other extensions, your data looks like part of the model. To you, it's clearly your domain.

This page walks through the pattern end-to-end using a worked example - one that adds a per-server subdomain limit, fully integrated with the existing feature-limits system. By the end you'll know how to:

- Add columns to a core table via migration and surface them on the model
- Hook into create and update flows to populate those columns
- Extend API response and request structs so the columns appear in the JSON
- Register frontend form components that slot into the existing admin pages
- Read your extension's data from both backend code and frontend components

## The Two Sides: Models and API Structs

Before we get into the code, it's worth understanding the divide. Calagopus has two related but distinct concepts that both get extended by this system:

**Models** are the database-backed types - `Server`, `Node`, `User`, `NestEgg`, `ServerSchedule`, and so on. They're what the rest of the codebase deals with internally, and they're *your* extension's interface to the database. When you add a column to the `servers` table and want code that queries `Server` to know about it, you're extending the model.

**API structs** are the over-the-wire types - `ApiServer`, `ApiAdminServer`, `ApiServerFeatureLimits`, etc. These are what get serialized into JSON for HTTP responses and deserialized from JSON in request bodies. They're documented in the OpenAPI spec, they're consumed by the frontend, and they're often a *projection* of a model rather than a 1:1 mirror (e.g. `ApiServer` hides internal columns like deletion timestamps, exposes derived fields, and so on). When you want your extra column to appear in the JSON, you're extending the API struct.

The two extension surfaces are independent. You can extend a model without extending its API struct (your column exists in the database but doesn't appear in JSON). You can extend an API struct without extending the model (you compute and expose a derived value). Most extensions do both.

The full list of extendable models and API structs is at [cratedocs - Extendible implementors](https://cratedocs.calagopus.com/shared/trait.Extendible#implementors). Anything that implements `Extendible` can be extended; anything that doesn't can't.

## Worked Example: Subdomain Limits

The example we'll walk through is a per-server subdomain limit. Imagine your Panel deployment runs a service that gives each server one or more subdomains pointing at it - `myserver.yourpanel.example` and so on. You want admins to be able to cap the number of subdomains each server is allowed, the way they can already cap Databases, Backups and more. That's exactly the shape of a feature limit, so we'll extend `ApiServerFeatureLimits`.

The plan: add a `subdomains` column to `servers`, plumb it through the model layer so it loads with every `Server`, hook into the create and update flows so admins can set it via the existing endpoints, extend `ApiServerFeatureLimits` so the value appears in the API response, register frontend form components for the admin create-server and edit-server pages, and finally show how to *read* the limit from both your backend routes and your frontend components.

::: info
Note that we're naming the field just `subdomains`, not `max_subdomains`. The `ApiServerFeatureLimits` struct is the cap-defining surface by definition - every field in it represents a maximum. Prefixing with `max_` is redundant; just like the core fields `databases`, `backups`, ... aren't prefixed, your extension fields shouldn't be either. The "max" is implicit.
:::

### The Migration

First, the database. You need a migration that adds your column to the existing table, with both an up and a down script:

```sql
-- up.sql
ALTER TABLE "servers" ADD COLUMN "subdomains" integer NOT NULL DEFAULT 0;
```

```sql
-- down.sql
ALTER TABLE "servers" DROP COLUMN "subdomains";
```

Defaults on `NOT NULL` columns are basically required, since rows already exist when your extension is installed and they need *some* value for the new column. `0` is a sensible default for a limit field - if you read it as "no subdomains allowed by default" rather than "unlimited," operators get safe defaults out of the box.

::: warning
**Always provide both `up.sql` and `down.sql`.** The down migration should drop your columns in reverse order. If an operator uninstalls your extension or rolls back a release, leaving orphan columns on a core table is a recipe for a broken Panel.
:::

### Defining the Model Extension

Now create a `model.rs` in your extension's backend `src/`. This is where the SELECT-side magic happens - you tell the Panel "these are my columns" and "here's how to deserialize a row that includes them":

```rs
use serde::{Deserialize, Serialize};
use shared::models::{ModelExtension, SafeModelExtension, server::Server};
use sqlx::{Row, postgres::PgRow};
use std::collections::BTreeMap;

#[derive(Serialize, Deserialize)]
pub struct ServerExtensionData {
    pub subdomains: i32,
}

pub struct ServerExtension;

impl SafeModelExtension for ServerExtension {
    type Value = ServerExtensionData;

    fn name() -> &'static str {
        ServerExtension.extension_name()
    }
}

impl ModelExtension for ServerExtension {
    fn extension_name(&self) -> &'static str {
        "dev.yourname.subdomains"
    }

    fn extended_columns(&self, prefix: &str) -> BTreeMap<&'static str, compact_str::CompactString> {
        BTreeMap::from([(
            "servers.subdomains",
            compact_str::format_compact!("{prefix}subdomains"),
        )])
    }

    fn map_extended(
        &self,
        prefix: &str,
        row: &PgRow,
    ) -> Result<shared::models::ModelExtensionMapType, shared::database::DatabaseError> {
        Ok(Box::new(ServerExtensionData {
            subdomains: row
                .try_get(compact_str::format_compact!("{prefix}subdomains").as_str())?,
        }))
    }
}
```

Three things to unpack here.

**`ServerExtensionData`** is your extension's view of its own columns - a plain struct, no derives required beyond `Serialize`/`Deserialize`. Anyone reading a `Server` and looking up your extension's data will get this struct back.

**`extended_columns`** declares which columns from the underlying table your extension cares about. The map keys are the fully-qualified column names (with table prefix, like `"servers.subdomains"`); the values are the aliased names the Panel uses in its actual SELECT statement, prefixed by whatever the caller passed in. The prefix mechanic exists because the same model can appear multiple times in a query (e.g. a server joined to a related server) and each instance needs uniquely-aliased columns.

**`map_extended`** is the row-to-struct mapper. Given a `PgRow` and the same prefix, pull your fields out and return them boxed up. Errors here become `DatabaseError`s and bubble up to whoever was loading the model.

The `SafeModelExtension` impl is a small piece of name-keying boilerplate - it lets other code look up your extension's data via a typed handle (`Server::parse_model_extension::<ServerExtension>()`) rather than a stringly-typed lookup. You almost always want this.

### The Extended API Struct

You also need an "extended" API struct that mirrors the field shape of whichever API struct you're extending. This is the type that flows over the wire - what clients send when setting your fields and what they receive when reading them back:

```rs
use garde::Validate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(ToSchema, Validate, Serialize, Deserialize)]
pub struct ExtendedApiServerFeatureLimits {
    #[garde(range(min = 0))]
    #[schema(minimum = 0)]
    pub subdomains: Option<i32>,
}
```

This is a real API struct - `ToSchema` for OpenAPI documentation, `Validate` from garde so request bodies get validated like any other payload (see [Routing - Request Bodies and Validation](./routing.md#request-bodies-and-validation) for the validation system), `Serialize`/`Deserialize` for the wire format.

The settable field is wrapped in `Option<T>`. **This is on purpose, and it's important.** Existing API clients - the Panel's own frontend, third-party integrations, scripts written before your extension was installed - have no idea your field exists. When they hit the update endpoint, they're sending payloads that don't include `subdomains` at all. With `Option<T>`, "field absent" deserializes to `None`, your update handler reads `None` and skips the column, and the existing value stays untouched. Backwards compatibility, for free.

The alternative would be a non-optional field with a default value (`#[serde(default)]`), but that's a footgun: every update from a client that doesn't know about your field would set the column to whatever the default is. If your field is `subdomains: i32` defaulting to `0`, every untouched update silently zeros out the limit on every server. By the time anyone notices, the data is gone. `Option<T>` makes "the client didn't send this" a distinct case from "the client wants this set to 0," and your handler treats them differently.

### Registering Everything

All four registrations - the model extension, the create handler, the update handler, and the API struct extension - happen in your `Extension::initialize`:

```rs
use shared::{
    Extendible, State,
    extensions::Extension,
    models::{
        BaseModel, CreatableModel, ListenerPriority, UpdatableModel,
        server::{ApiServerFeatureLimits, Server},
    },
};

mod model;

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize(&mut self, _state: State) {
        // 1. Register the SELECT-side hook
        Server::register_model_extension(model::ServerExtension);

        // 2. Hook into CREATE so admins can set the field when creating a server
        Server::register_create_handler(
            ListenerPriority::Normal,
            |options, query_builder, _state, _transaction| {
                Box::pin(async move {
                    if let Ok(extended) = options
                        .feature_limits
                        .parse_extended::<model::ExtendedApiServerFeatureLimits>()
                        && let Some(value) = extended.subdomains
                    {
                        query_builder.set("subdomains", value.unwrap_or(0)); // the unwrap_or is due to the NOT NULL constraint on the column - if the client sent null, treat it as 0 on create
                    } else {
                        query_builder.set("subdomains", 0); // default value for new servers if not provided, since the API struct field is optional and the database column is NOT NULL
                    }
                    Ok(())
                })
            },
        );

        // 3. Hook into UPDATE so admins can edit the field on existing servers
        Server::register_update_handler(
            ListenerPriority::Normal,
            |_server, options, query_builder, _state, _transaction| {
                Box::pin(async move {
                    if let Some(feature_limits) = &options.feature_limits
                        && let Ok(extended) =
                            feature_limits.parse_extended::<model::ExtendedApiServerFeatureLimits>()
                        && let Some(value) = extended.subdomains
                    {
                        query_builder.set("subdomains", value);
                    }
                    Ok(())
                })
            },
        );

        // 4. Extend the API struct so the field appears in JSON
        ApiServerFeatureLimits::extend_validated(
            |server, _state| {
                Box::pin(
                    async move { Ok(server.parse_model_extension::<model::ServerExtension>()?) },
                )
            },
            |_limits, extension, _state| model::ExtendedApiServerFeatureLimits {
                subdomains: Some(extension.subdomains),
            },
        );
    }
}
```

Four calls, four different surfaces. Let's go through each.

## Lifecycle Handlers

The `register_create_handler` and `register_update_handler` calls are part of the `CreatableModel` / `UpdatableModel` lifecycle handler systems, which are documented fully on the [Events](./events.md) page along with `register_delete_handler`. This page won't rehash that documentation - go read the events page if you haven't already, the parameter list for the closure is over there.

What's worth pointing out *here* is how the lifecycle handlers interact with model extensions. Both closures receive an `options` argument carrying the incoming payload - the same payload the core create/update logic uses. When that payload includes an extended struct (here, `feature_limits`), you call `parse_extended::<YourExtensionStruct>()` to pull out *your* extension's slice of it as a typed Rust struct.

### Create

```rs
Server::register_create_handler(
    ListenerPriority::Normal,
    |options, query_builder, _state, _transaction| {
        Box::pin(async move {
            if let Ok(extended) = options
                .feature_limits
                .parse_extended::<model::ExtendedApiServerFeatureLimits>()
                && let Some(value) = extended.subdomains
            {
                query_builder.set("subdomains", value);
            }
            Ok(())
        })
    },
);
```

The create handler runs as part of the INSERT flow. Your closure gets a `query_builder` you can mutate to add columns to the INSERT statement, and the core insert will execute the resulting SQL with all extensions' columns merged in.

Notice that on create, `options.feature_limits` is the struct directly (not an `Option`), since every server creation must include a feature_limits payload. On update, it's `Option<...>` - clients can omit the whole feature_limits block when updating other fields. Adjust your handler shape accordingly, or factor the shared logic into a helper function the way most extensions do once they have more than a couple of fields.

### Update

```rs
Server::register_update_handler(
    ListenerPriority::Normal,
    |_server, options, query_builder, _state, _transaction| {
        Box::pin(async move {
            if let Some(feature_limits) = &options.feature_limits
                && let Ok(extended) =
                    feature_limits.parse_extended::<model::ExtendedApiServerFeatureLimits>()
                && let Some(value) = extended.subdomains
            {
                query_builder.set("subdomains", value);
            }
            Ok(())
        })
    },
);
```

The update handler is structurally similar but the closure also receives `server` (the current state of the model being updated) as the first argument. You can read from `server` if your update logic needs the existing values - for instance, validating that a new limit isn't being lowered below the number of subdomains already in use, or detecting transitions you want to react to.

`parse_extended` returns a `Result` because the extended slice might not be present (e.g. an older client didn't include the field) or might fail to deserialize (e.g. malformed input). Handle both - log, fall back, or set a default - whatever's right for your domain. Then mutate the `query_builder` to add your column to the UPDATE statement.

### Deletes

For deletes, register `register_delete_handler` if you need to do cleanup beyond what the foreign-key constraints handle. Most extensions don't - `ON DELETE CASCADE` and similar SQL-level mechanics handle the simple cases. See [Events](./events.md) for the full signature.

::: info
**Read the events page if you haven't.** The cancellation semantics and priority-ordering rules of the lifecycle handlers (returning `Err(...)` cancels the operation and skips lower-priority handlers) apply to model extensions just like everything else. If you're returning errors to enforce business rules, make sure you understand how that interacts with other extensions registering on the same model.
:::

## Extending API Structs

The fourth call - `ApiServerFeatureLimits::extend_validated(...)` - is what makes your extension's data appear in the JSON the Panel sends and accepts. This is the `Extendible` trait at work, and it's separate from the model extension because not every model column needs to be exposed (some are internal-only) and not every API field has a backing column (some are computed).

The `extend_validated` call takes two closures:

```rs
ApiServerFeatureLimits::extend_validated(
    |server, _state| {
        Box::pin(
            async move { Ok(server.parse_model_extension::<model::ServerExtension>()?) },
        )
    },
    |_limits, extension, _state| model::ExtendedApiServerFeatureLimits {
        subdomains: Some(extension.subdomains),
    },
);
```

The **first closure** is the "fetch": given the parent struct (here, a `Server`), produce your extension's data. Usually this is a one-liner that calls `parse_model_extension` to read the data your `ModelExtension` already loaded. If your extension's data isn't backed by a model extension (e.g. it's computed on the fly), this is where you'd compute it.

The **second closure** is the "project": given the original `ApiServerFeatureLimits` and your extension's data, produce the extended struct. This is where you decide which fields to expose and how to shape them. Note that the original `_limits` is passed in but not used in this example - if you wanted your extended fields to depend on the core ones, this is where you'd combine them.

Once registered, the extended struct gets merged into the JSON that flows in and out of any endpoint that uses `ApiServerFeatureLimits`. Inbound, your fields are validated by garde; outbound, they appear alongside the core fields. The OpenAPI spec is updated automatically.

## The Frontend Side

The backend is now fully wired - you can `POST /api/admin/servers` or `PATCH /api/admin/servers/{uuid}` with `{ "feature_limits": { "subdomains": 5 } }` in the body and the server will get its limit set. But the existing admin form for creating and editing servers doesn't know about your new field.

The server create and update forms are rendered by the Panel's form engine, and extensions hook into them through the form registry - the same mechanism documented in full on the [Forms](./forms.md) page. You register a slot for the form IDs `admin.servers.create` and `admin.servers.update`, using a `transform` to insert your field into the rendered field list:

```ts
import { Extension, ExtensionContext } from 'shared';
import { z } from 'zod';
import { type FieldDef, insertFieldsAfter } from '@/elements/form-engine/index.ts';

class SubdomainsExtension extends Extension {
  public initialize(ctx: ExtensionContext): void {
    ctx.extensionRegistry.enterForms((forms) => {
      for (const formId of ['admin.servers.create', 'admin.servers.update'] as const) {
        forms.extend(formId, {
          zodShape: {
            featureLimits: z.object({
              subdomains: z.number().int().min(0),
            }),
          },
          initialValues: {
            featureLimits: {
              subdomains: 0,
            },
          },
          transform: (fields) =>
            insertFieldsAfter(fields, 'featureLimits.schedules', {
              type: 'number',
              name: 'featureLimits.subdomains',
              label: 'Subdomains',
              required: true,
            } satisfies FieldDef),
        });
      }
    });
  }
}

export default new SubdomainsExtension();
```

Field `name`s are Mantine form paths, so the dotted `featureLimits.subdomains` binds into the nested `featureLimits` object in the form's values - the same shape the core fields use. `insertFieldsAfter` anchors your field right after the built-in schedules limit; if the anchor isn't present in a particular render of the form, the transform leaves the fields untouched, which keeps it safe on forms that render their fields in multiple sections.

The `zodShape` and `initialValues` here are doing more than just validation and defaults. Both are **deep-merged** into the core form's schema and initial values, which is why the nested `featureLimits` object extends the built-in feature-limit validation instead of replacing it. And crucially, the core create/update API files pass the registered zod shapes to `serializeForApi` (via `formExtensionSchemas(formId)`), so declaring your field in `zodShape` is what gets its value serialized into the request body - as `feature_limits.subdomains`, right where your backend's `parse_extended` expects it. A field that only exists in `transform` renders and validates, but never leaves the browser.

## Reading Your Extension's Data

The whole point of this exercise is so your extension can *use* the data it stores. The access pattern is pleasingly symmetric on the two sides: wherever a model is loaded, you parse your typed slice back out of it - `parse_model_extension` on the backend, your own Zod schema on the frontend.

### From Backend Routes

Since `ServerExtension` is registered, every place in the codebase that loads a `Server` automatically loads your columns alongside it - no special query needed. To pull your typed view back out of a loaded `Server`, call `parse_model_extension` with the extension's marker type:

```rs
let extension = server.parse_model_extension::<model::ServerExtension>()?;
let limit = extension.subdomains;
```

That's the entire access pattern. `parse_model_extension` returns your `ServerExtensionData` struct (the inner `Value` type from the `SafeModelExtension` impl), and from there it's just struct-field access. The `?` propagates a `DatabaseError` if your extension wasn't loaded for some reason - in practice this only happens if someone constructed a `Server` manually without going through the normal query path, which is rare.

In a real route handler, it looks like this:

```rs
use axum::http::StatusCode;
use shared::{
    GetState,
    models::{server::GetServer, user::GetPermissionManager},
    response::{ApiResponse, ApiResponseResult},
};

#[utoipa::path(post, path = "/", responses(
    (status = OK, body = inline(Response)),
), request_body = inline(Payload))]
pub async fn route(
    state: GetState,
    permissions: GetPermissionManager,
    server: GetServer,
    shared::Payload(data): shared::Payload<Payload>,
) -> ApiResponseResult {
    permissions.has_server_permission("subdomains.create")?;

    let extension = server.parse_model_extension::<crate::model::ServerExtension>()?;
    let limit = extension.subdomains;

    let current_count = count_existing_subdomains(&state, server.uuid).await?;

    if current_count >= limit {
        return ApiResponse::error(format!(
            "maximum number of subdomains reached"
        ))
        .with_status(StatusCode::EXPECTATION_FAILED)
        .ok();
    }

    // ... actually create the subdomain

    ApiResponse::new_serialized(Response { /* ... */ }).ok()
}
```

A couple of things worth pointing out:

- **The same access pattern works in any context that has a loaded `Server`** - route handlers, lifecycle handlers, background tasks, CLI commands, anywhere. If you've got the model, you've got the extension data.
- **The data is read-only via `parse_model_extension`.** To *change* the value, go through the normal update flow - submit a PATCH to the admin update endpoint with the new `feature_limits.subdomains`, and your update handler from earlier in this page will write it.

#### Writing via Direct Calls to `Server::update`

If your extension wants to update a server from inside its own code - say, a CLI command that adjusts limits in bulk, a background task that recalculates them, or a route handler that wraps `update` with extra logic - you can call `Server::update` directly with a constructed `ApiServerFeatureLimits` instead of going through an HTTP endpoint. This works the same way it does for any other update path, with one wrinkle: when you build the API object yourself, you need to remember to include your extension's fields. Otherwise the update goes through with only the core fields set, and your `subdomains` column never gets touched.

The `Extendible` trait gives you a method for exactly this:

```rs
fn insert_extension<E: Serialize>(&mut self, ext_value: E) -> Result<(), anyhow::Error>;
```

Construct the core API object normally, then call `insert_extension` with your extended struct *before* passing the whole thing to `update`:

```rs
use shared::{Extendible, models::server::{ApiServerFeatureLimits, Server}};
 
let mut feature_limits = ApiServerFeatureLimits {
    backups: 5,
    databases: 5,
    allocations: 5,
    schedules: 5,
};
 
feature_limits.insert_extension(model::ExtendedApiServerFeatureLimits {
    subdomains: Some(10),
})?;
 
server
    .update(shared::models::server::UpdateServerOptions {
        feature_limits: Some(feature_limits),
        ..Default::default()
    })
    .await?;
```

What `insert_extension` is doing under the hood is serializing your extended struct into the same internal blob that `parse_extended` reads from - the bridge between your typed extension struct and the type-erased "extension data" inside the API object. Without it, the API object has no record of your extension's fields, and your update handler's `parse_extended::<ExtendedApiServerFeatureLimits>()` call will return an error or a struct with `None` everywhere, depending on the exact shape.

The same applies to any other API struct you've extended via `extend_validated` - construct the core object, call `insert_extension` with your extended view, then use the API object as normal. This is the only place the manual-construction path differs from the deserialized-from-JSON path; once `insert_extension` has been called, everything downstream behaves identically to a request that came in over the wire.

### From Frontend Components

On the frontend, your extended fields are in the JSON the Panel returns when it loads a server - because of the `extend_validated` call. So you don't need a separate fetch and you don't need a custom API endpoint.

The Panel's API layer validates every response against its core Zod schemas (see [Frontend API Calls](./frontend-api.md)), and those core schemas are declared with `z.looseObject`. Any key the schema doesn't declare is camelCased and kept on the parsed object as-is, so your extension's fields sit alongside the core ones. There is no wrapper property to unpack and no helper to call.

They aren't typed, though: TypeScript only knows about the core schema's fields. Declare a Zod schema for your extension's slice (camelCase keys, same conventions as any response schema) and parse the node your fields live on. A plain `z.object` ignores the core keys it doesn't declare, so you can hand it the whole node:

```tsx
import { z } from 'zod';
import { useServerStore } from '@/stores/server.ts';

const subdomainsExtensionSchema = z.object({
  subdomains: z.number(),
});

export default function SubdomainsCard() {
  const server = useServerStore((s) => s.server);
  const { subdomains: limit } = subdomainsExtensionSchema.parse(server.featureLimits);

  return (
    <div>
      <p>You can create up to {limit} subdomain{limit === 1 ? '' : 's'} on this server.</p>
    </div>
  );
}
```

Note that you parse `server.featureLimits`, not `server` - extension fields sit on the node your backend extension actually extended. Keys arrive camelCased like every other response field: the backend's `subdomains` column arrives as `subdomains`, and a hypothetical `custom_flag` would arrive as `customFlag`. Mark fields `.optional()` if older panel versions, or a panel running without your extension, might not send them.

Two things to keep in mind:

- **Parse, don't trust.** The values ride on the object unvalidated - the core schema declared nothing about them. Running your own schema over the node is what turns them into typed data, and it fails loudly when your extension and the panel disagree.
- **This is read-only**, same as `parse_model_extension` on the backend. To change the value, submit it through the update flow like any other field.

## Rewriting Variable Rules

`ServerVariable::register_rules_handler` is a different kind of model hook: it lets you rewrite a variable's validation rules just before they are handed to the client. It is also the only way to make an egg variable's input *dynamic*.

```rs
use shared::models::server_variable::ServerVariable;

ServerVariable::register_rules_handler(ListenerPriority::Normal, |server, env_variable, rules| {
    Box::pin(async move {
        if env_variable == "MINECRAFT_VERSION" {
            rules.clear();
            rules.push("required".into());
            rules.push(format!("in:{}", fetch_versions(server).await?.join(",")).into());
        }

        Ok(())
    })
});
```

The rules do more than validate. They also decide **which input widget the panel renders**: `boolean` (or an `in:` of `1,0` / `true,false`) becomes a switch, any other `in:a,b,c` becomes a dropdown built from that list, `numeric` becomes a number input, and so on. Rewriting `in:` at request time is therefore how you build something like a version selector whose options come from an upstream API rather than being baked into the egg.

Two caveats. The handler only runs on the client startup endpoints, meaning reading and updating a server's variables. It does not run on admin variable routes, server creation, or the remote endpoint Wings uses, so it shapes what the user sees rather than what ends up stored. You also cannot invent new rule *types*: the rule set is fixed, so your handler composes existing rules rather than adding a validator of its own. Unlike the other handler families, this one returns nothing, so a registered rules handler cannot be unregistered.

## Where to Go From Here

The model extension you've built is now a first-class citizen of the `Server` model. You can:

- **Read your data anywhere a `Server` is loaded** by calling `server.parse_model_extension::<ServerExtension>()`. Other extensions can do the same, even if you didn't tell them about your data.
- **Add custom routes** that operate on your fields, using all the patterns from [Routing](./routing.md). Inside those routes, treat your data exactly like core columns - they were loaded by the same query, after all.
- **Register additional create / update / delete handlers** if you need more complex behavior (e.g. enforcing business rules, denormalizing data into other tables, emitting custom events when your fields change).
- **Extend other API structs** if your data should appear in places beyond `ApiServerFeatureLimits`. The `Extendible` trait works on most API struct types - the [implementor list](https://cratedocs.calagopus.com/shared/trait.Extendible#implementors) is the authoritative reference.

Model extensions are one of the more involved patterns Calagopus exposes, but they're also the most powerful - once you've gone through the dance once, you've got code that reads exactly like core code that ships with the Panel, and the rest of the ecosystem can interact with your data without knowing or caring that it came from an extension.


---

<!-- concepts/file-storage.md -->

---
prev: true
next: false
---
# File Storage

Some extensions need to store files - user-uploaded avatars, generated reports, exported backups, cached external assets, anything that doesn't fit into a database row. The Panel handles this through a `Storage` abstraction available at `state.storage`, which routes file operations to either the local filesystem or an S3-compatible bucket depending on what the operator has configured.

The big win of going through `state.storage` rather than calling `tokio::fs` directly is that your extension Just Works regardless of the deployment shape. An operator running everything on a single VPS gets a filesystem-backed install; an operator running a horizontally-scaled deployment configures S3 in their settings; your extension code is identical in both cases. Same `store(...)`, same `remove(...)`, same public URLs handed back to clients.

This page covers the storage API, the convention around well-known directory prefixes, and a handful of escape hatches for cases the high-level API doesn't fit.

## The Three Operations You'll Actually Use

Most extensions only ever need three methods on `state.storage`:

- **`store(path, data, content_type)`** - write a file. Takes any `AsyncRead` for the body, returns the number of bytes written.
- **`remove(path)`** - delete a file by path. Takes an `Option`, so `None` is a no-op; it's also a no-op if the file doesn't exist.
- **`retrieve_urls()`** - get a helper that turns paths into publicly-accessible URLs.

Here's a concrete example - storing a user-uploaded image and returning a URL the frontend can use to display it:

```rs
use shared::{
    GetState,
    models::{server::GetServer, user::GetPermissionManager},
    response::{ApiResponse, ApiResponseResult},
};
use uuid::Uuid;

pub async fn route(
    state: GetState,
    permissions: GetPermissionManager,
    server: GetServer,
    mut multipart: axum::extract::Multipart,
) -> ApiResponseResult {
    permissions.has_server_permission("my-feature.upload")?;

    let field = multipart
        .next_field()
        .await?
        .ok_or_else(|| ApiResponse::error("no file uploaded"))?;

    let content_type = field
        .content_type()
        .map(String::from)
        .unwrap_or_else(|| "application/octet-stream".to_string());

    let path = format!(
        "privatedata/extensions/dev.yourname.my-feature/{}/{}.bin",
        server.uuid,
        Uuid::new_v4(),
    );

    let body = field.bytes().await?;
    let bytes_written = state
        .storage
        .store(&path, std::io::Cursor::new(body), &content_type)
        .await?;

    let urls = state.storage.retrieve_urls().await?;
    let url = urls.get_url(&path);

    ApiResponse::new_serialized(serde_json::json!({
        "url": url,
        "size": bytes_written,
    }))
    .ok()
}
```

A few things to call out:

- **`store` accepts any `AsyncRead`** - direct from a multipart upload, from a `tokio::fs::File`, from a `Cursor` over an in-memory buffer, from an HTTP stream, anything. No need to buffer the whole file into memory unless you want to.
- **The path is yours to construct.** No `id`-keyed lookup, no auto-generated names. The path you pass to `store` is the path used to retrieve and remove later. UUIDs in the filename are a good idea if you don't want users to be able to guess each other's filenames.
- **`retrieve_urls()` returns a helper, not a URL directly.** This is because it needs to read the storage settings, and that's an awaited operation - getting the helper once and calling `.get_url(...)` repeatedly is cheaper than awaiting per call. Hold on to it for the lifetime of your handler if you're constructing many URLs.

::: info
The path **must not** contain `..`, start with `/`, or be empty. The store method enforces this and returns an error if you violate it. This is your built-in protection against path-traversal bugs, but only if you're using `state.storage.store` directly - if you reach for the lower-level cap filesystem API (covered later), you have to enforce it yourself.
:::

## The Well-Known Directory Prefixes

Storage paths are global - whatever you write to `foo/bar.txt` is reachable as `foo/bar.txt` to everyone using the same backend. To keep extensions, the core Panel, and admin operations from stepping on each other, there's a convention around top-level directories.

| Prefix | Public? | What it's for |
| --- | --- | --- |
| `assets/` | Yes | Admin assets - logos, branding images, anything the admin panel needs publicly available. |
| `avatars/` | Yes | User avatar images. Structure is `avatars/{user_uuid}/{random}.webp`. **Don't write here directly** unless you're confident; the core Panel manages this and a botched write can leave a user with a missing or corrupted avatar. |
| `publicdata/` | Yes | Currently unused by the base Panel; available for extension use. Suggested structure: `publicdata/extensions/{your.package.identifier}/...`. |
| `privatedata/` | No | Not publicly accessible. Same suggested structure as `publicdata/`: `privatedata/extensions/{your.package.identifier}/...`. |

The "publicly accessible" distinction is enforced at the storage layer - paths under public prefixes are reachable via the URL `retrieve_urls().get_url(path)` returns, and paths under `privatedata/` aren't. If you write to `privatedata/...` and then call `get_url(...)` on it, the returned URL won't actually serve the file; it's the responsibility of your extension's own routes to authenticate-and-serve from `privatedata/`.

The most common pattern for extensions:

- **User-facing files that anyone with the link can see** (display avatars, exported chart images, etc.) → `publicdata/extensions/dev.yourname.my-feature/...`
- **Files that should require authentication** (private user data, internal exports, anything sensitive) → `privatedata/extensions/dev.yourname.my-feature/...` and serve them through your own permissioned route.

Both prefixes accept the same path shape. Picking between them is purely about whether you want anyone-with-the-URL access or not.

::: warning Don't reach for `assets/` or `avatars/` unless you mean it
The `assets/` prefix is for admin-managed branding; the `avatars/` prefix is structured around user UUIDs and managed by core. Writing into either of these from your extension can collide with core Panel operations - extensions should default to `publicdata/` or `privatedata/` and only use `assets/` or `avatars/` if you have a specific, documented reason to be touching that namespace.
:::

## Getting Public URLs

`retrieve_urls()` returns a `StorageUrlRetriever` that wraps the current settings. Call `.get_url(path)` on it to turn a storage path into a URL the frontend can hit:

```rs
let urls = state.storage.retrieve_urls().await?;

let avatar_url = urls.get_url("publicdata/extensions/dev.yourname.my-feature/some-image.webp");
// Filesystem backend: "https://panel.example.com/publicdata/extensions/dev.yourname.my-feature/some-image.webp"
// S3 backend:         "https://cdn.example.com/publicdata/extensions/dev.yourname.my-feature/some-image.webp"
```

The format depends on the configured driver: filesystem-backed installs serve through the Panel itself (URL prefixed with `app.url`), S3-backed installs serve from the configured `public_url` (typically a CDN). Either way, your extension code doesn't care - you get a URL string, you hand it to the frontend.

For paths under `privatedata/`, `get_url(...)` will still return *a* URL, but hitting it won't serve the file. Treat the returned value as a path identifier for your own routes' use, not as something to expose to clients.

## Storing Streamed Data

The `store` signature takes `impl tokio::io::AsyncRead`, which means you can pipe directly from a download, a multipart upload, or any other async source without buffering the whole thing into memory:

```rs
// Streaming a file from another HTTP service
let response = state.client.get(remote_url).send().await?;
let stream = response
    .bytes_stream()
    .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
let mut reader = tokio_util::io::StreamReader::new(stream);

let bytes = state
    .storage
    .store(
        "publicdata/extensions/dev.yourname.my-feature/cached-asset.bin",
        &mut reader,
        "application/octet-stream",
    )
    .await?;
```

This is also the right pattern for large files - if a user is uploading a 500 MB report and you store it through the streaming API, peak memory use stays low.

## Listing Files

For admin-style "show me what's in this directory" use cases, there's `state.storage.list(base, directory, page, per_page)`:

```rs
let page = state
    .storage
    .list("publicdata/extensions/dev.yourname.my-feature", "", 1, 50)
    .await?;

for asset in page.data {
    println!("{} ({} bytes) - {}", asset.name, asset.size, asset.url);
}
```

Returns a paginated list of `StorageAsset` entries, each with name, size, URL, creation time, and a flag indicating whether it's a directory or a file. Directories sort before files, both alphabetically.

`list` is shallow - it shows you the immediate children of `directory` and nothing deeper. Each returned `name` is the entry's path relative to `base`, so within a subdirectory you'll get `images/logo.png` rather than a bare `logo.png`.

This method is primarily used by the admin panel's asset-browser UI; most extensions don't need it. If you're tracking your own files (you wrote them, you know where they are), prefer keeping a record in your own database table - that's both faster than listing the storage backend and more flexible for the kinds of queries your code actually wants to do.

## Searching by Name

Where `list` is shallow, `state.storage.search(base, directory, search, limit)` walks the entire subtree below `directory` and returns every entry whose **own name** contains `search`, matched case-insensitively:

```rs
let results = state
    .storage
    .search("publicdata/extensions/dev.yourname.my-feature", "", "report", 50)
    .await?;

for asset in results {
    println!("{} ({} bytes)", asset.name, asset.size);
}
```

The matching is on the last path segment only, not the full path - a search for `report` finds `exports/q3-report.pdf` and the directory `reports/`, but not `reports/summary.pdf` (whose own name contains no `report`). Results are the same `StorageAsset` shape `list` returns, with `name` again relative to `base`, and directories sorted before files.

Two differences from `list` worth internalising:

- **It isn't paginated.** You get a flat `Vec<StorageAsset>` of at most `limit` entries. There's no page cursor and no total count - if more matched than `limit`, the extras are simply not returned and nothing tells you so.
- **An empty `search` is an error**, not a "match everything". If your caller might pass a blank string, branch to `list` instead of forwarding it.

::: warning Searching is O(subtree), and on S3 that's network
Neither backend can do substring matching itself. On filesystem the Panel walks the directory tree; on S3 it issues `ListObjectsV2` **without a delimiter** across the whole prefix and filters the keys client-side, pulling up to 1000 keys per round trip. A search over a prefix holding tens of thousands of objects is many sequential API calls, and the cost falls on every keystroke if you wire it to a live-updating input.

An internal scan cap bounds this at 10,000 entries walked per call. Past that the walk stops and you get whatever matched so far - silently, with no indication the results were cut short. So on a large bucket, a broad search is not just slow, it's *incomplete*.

Debounce anything user-driven, scope the search to the narrowest `directory` you can, and treat the result as a browse aid rather than an authoritative answer to "does this file exist".
:::

The same advice as `list` applies, only more strongly: if the files are yours, index them in your own database table and query that. `search` exists for browse-style UIs over storage nobody has indexed - it's what the admin panel's asset browser uses - not as a general lookup mechanism.

## Temporary Files

Sometimes you need a file briefly - to write some intermediate output, to hand a path to a subprocess, to do anything that involves "I need this file to exist for the next ten seconds and then disappear." **Don't use `state.storage` for this.** That's persistent storage; using it for ephemeral data means your operator's S3 bill goes up and you have to remember to delete the file when you're done.

The right tool is the [tempfile](https://docs.rs/tempfile) crate, which is already in the workspace. You'll be able to use it directly:

```rs
use tempfile::NamedTempFile;

let mut tempfile = NamedTempFile::new()?;
// write to tempfile, hand it to a subprocess, do whatever
// drops on scope exit, file is deleted automatically
```

Use `state.storage` for things you want to keep; use `tempfile` for things you want to throw away.

## Cap Filesystem Access (Escape Hatch)

For cases where the high-level API isn't enough - random-access reads, complex directory traversal, file metadata operations - you can get a [cap-std](https://docs.rs/cap-std)-style filesystem rooted at a specific path under the storage base. This is **only available when the storage driver is Filesystem**; on S3 deployments, this method returns `None` and you're stuck with the high-level API.

```rs
use std::path::Path;

let settings = state.settings.get().await?;
let cap_fs = match settings.storage_driver.get_cap_filesystem("publicdata/extensions/dev.yourname.my-feature").await {
    Some(Ok(fs)) => fs,
    Some(Err(err)) => return Err(err.into()),
    None => {
        // S3 backend - fall back to the high-level API
        return Err(ApiResponse::error("operation requires filesystem storage backend"));
    }
};

// Now use cap_fs.async_read_dir, cap_fs.async_metadata, etc.
```

::: warning Path traversal is a real risk here
**The path you pass to `get_cap_filesystem` must not contain user input.** That argument is the *root* of the resulting cap filesystem, and a `..` or absolute-path injection at that point would let the user escape the storage area entirely. The whole reason cap-std exists is to safely contain user-controlled paths *inside* an opened filesystem, not when opening it.

The pattern is:
- Hardcode the root (or build it from values you fully control, like your own extension identifier)
- Open the cap filesystem rooted there
- *Then* let user input flow into operations on the opened filesystem - those operations are sandboxed to the root and `..` segments can't escape

If you put user input in the call to `get_cap_filesystem` itself, you've defeated the protection.
:::

A few more reasons most extensions shouldn't reach for this:

- It only works on filesystem deployments. If you ship an extension that requires it, operators on S3 can't use your extension.
- The high-level `store` / `remove` / `retrieve_urls` API does what most code needs and is portable across both backends.
- For temporary scratch space (which is the most common reason people *think* they need raw filesystem access), the [tempfile](https://docs.rs/tempfile) crate is what you actually want.

If you have a specific, justifiable reason to need cap-filesystem access - large directory operations, things the high-level API doesn't expose - this is the supported path. But default to the high-level API and only reach for this when you've established you actually need it.


---

<!-- concepts/forms.md -->

---
title: Forms
description: Let your extension participate in the Panel form system.
---

# Forms

Panel forms are used everywhere: creating servers, editing users, configuring nodes, defining backup targets, tweaking application settings. The forms extension system lets your extension participate in any of those forms, adding new fields, providing their validation rules and default values, or tweaking properties of existing fields, without touching the built-in form components.

The mechanism is simple. Every extensible form has a string ID. You call `enterForms` in your `initialize` method, call `extend` with the form ID and a slot describing your changes, and the Panel merges your slot into the form the next time it renders.

## Registering

Inside your extension's `initialize` method:

```ts
import { ExtensionContext } from 'shared';
import { z } from 'zod';
import { type FieldDef, insertFieldsAfter } from '@/elements/form-engine/index.ts';

public initialize(ctx: ExtensionContext): void {
  ctx.extensionRegistry.enterForms((forms) =>
    forms.extend('admin.servers.create', {
      zodShape: {
        customIdentifier: z.string().max(64),
      },
      initialValues: {
        customIdentifier: '',
      },
      transform: (fields) => [
        ...fields,
        {
          type: 'text',
          name: 'customIdentifier',
          label: 'Custom Identifier',
          description: 'An internal label used by your provisioning system.',
        } satisfies FieldDef,
      ],
    }),
  );
}
```

`enterForms` gives you the `FormRegistry`. Calling `.extend(formId, slot)` registers a **slot**, a bundle of Zod schema additions, their initial values, and a field-list transform. Multiple extensions can each register a slot for the same form and they all compose cleanly; slots are applied in registration order, with each `transform` receiving the field list produced by the previous one.

Form IDs are a typed union (`FormId`), so a typo is a compile error. The registered IDs cover the admin create/update forms (`admin.servers.create`, `admin.servers.update`, `admin.nodes.createOrUpdate`, `admin.users.createOrUpdate`, ...), the admin settings forms (`admin.settings.application`, `admin.settings.captcha.turnstile`, ...) and more - see `RegisteredFormIds` in `@/elements/form-engine/types.ts` for the authoritative list.

## The Slot

Each `extend` call takes a `FormExtensionSlot` object. The slot is **Zod-first**: `zodShape` is the required source of truth for the fields your extension adds, and `initialValues` is typed (and required) from it - TypeScript infers the exact value types, nested objects included, so a missing or mistyped default is a compile error:

```ts
interface FormExtensionSlot<S extends ZodFieldShape> {
  zodShape: S; // Zod schema for the fields your extension adds
  initialValues: InferFieldShape<S>; // default values, typed from zodShape
  transform?: FieldTransform<...>; // (fields: FieldDef[]) => FieldDef[]
}
```

A slot that only tweaks existing fields (no new ones) passes empty objects for both: `{ zodShape: {}, initialValues: {}, transform: ... }`.

### `transform`

A function that receives the form's current field definitions and returns a new list. This is how you add fields, move them, tweak existing ones, or remove them - anything you can express as an array transformation. Four helpers in `@/elements/form-engine/index.ts` cover the common cases:

```ts
import { insertFieldsAfter, insertFieldsBefore, removeField, updateField } from '@/elements/form-engine/index.ts';
```

| Helper | Description |
| ------ | ----------- |
| `insertFieldsBefore(fields, name, ...insert)` | Insert one or more fields immediately before the named field |
| `insertFieldsAfter(fields, name, ...insert)` | Insert one or more fields immediately after the named field |
| `updateField(fields, name, (field) => field)` | Replace the named field with the result of the callback |
| `removeField(fields, name)` | Drop the named field from the list |

The insert helpers return the field list **unchanged** when the anchor field isn't present. That's deliberate: some form IDs render their fields in multiple sections (server create/update, for example), and your transform runs against each section - the no-op behavior means your field only lands in the section that actually contains your anchor. To unconditionally add a field at the start or end, spread the array yourself: `(fields) => [...fields, myField]` appends, `(fields) => [myField, ...fields]` prepends.

Overriding an existing field is `updateField` with a spread:

```ts
transform: (fields) =>
  updateField(fields, 'name', (field) => ({
    ...field,
    label: 'Server Name (internal)',
    description: 'Must match your naming convention: env-region-number.',
  })),
```

Don't change the `name` of an existing field (it's the key the form values are bound by), and be conservative about removing built-in fields - other extensions' transforms may be anchoring on them.

### `zodShape`

A record mapping field names to Zod types. The Panel **deep-merges** this into the form's Zod schema so that your new fields participate in validation. Because the merge is deep, you can extend nested objects without replacing the core validation for their existing keys:

```ts
zodShape: {
  customIdentifier: z.string().min(1).max(64),
  featureLimits: z.object({
    subdomains: z.number().int().min(0), // merged into the core featureLimits object
  }),
},
```

`zodShape` also drives **payload serialization**: the core API endpoints pass the registered shapes to `serializeForApi` (via `formExtensionSchemas(formId)`), so only fields declared here make it into the submitted request body. A field that exists only in your `transform` renders and can be typed into, but its value never leaves the browser. Declare every field you add.

Only provide entries for **new fields your extension adds**. To prevent conflicts, don't overwrite built-in field names.

### `initialValues`

The initial (empty-state) values for the fields in your `zodShape` - the type is inferred from the shape, so every declared field needs a default of the right type. These get deep-merged into the form's initial state, so the form doesn't start with `undefined` for your new fields (and nested defaults extend the core defaults instead of replacing them):

```ts
initialValues: {
  customIdentifier: '',
  enableFeatureX: false,
},
```

## Field Types

The fields inside a form (and the ones your `transform` produces) are `FieldDef` objects, a discriminated union keyed on `type`. Every type except `divider` and `custom` shares a set of base properties:

**Base properties (all field types except `divider` and `custom`):**

| Property | Type | Description |
| -------- | ---- | ----------- |
| `name` | `string` | Field name, must match the form value key (Mantine paths, so dots address nested values) |
| `label` | `LazyString` | Label shown above the input |
| `description` | `LazyString?` | Helper text shown below the label |
| `tooltip` | `ReactNode?` | Tooltip content shown on an info icon next to the label |
| `required` | `boolean?` | Shows an asterisk and enforces the field is non-empty |
| `advanced` | `boolean?` | Hidden unless the user has enabled Advanced Mode |
| `colSpan` | `'full' \| 1` | `'full'` stretches across both columns; omit for the default half-width |
| `when` | `(values) => boolean` | Receives the current form values; field is hidden when this returns `false` |

`LazyString` is `string | (() => string)`. The function form is resolved at render time, which is what lets you pass translation getters from module scope (your `initialize` runs long before any form renders): `label: () => t('myext.fields.customIdentifier')`.

### Text fields

```ts
{ type: 'text', name: '...', label: '...', props?: Partial<TextInputProps> }
{ type: 'password', name: '...', label: '...', props?: Partial<PasswordInputProps> }
{ type: 'textarea', name: '...', label: '...', rows?: number, props?: Partial<TextareaProps> }
```

### Numeric

```ts
{ type: 'number', name: '...', label: '...', props?: Partial<NumberInputProps> }
```

### Boolean

```ts
{ type: 'switch', name: '...', label: '...', props?: Partial<SwitchProps> }
{ type: 'checkbox', name: '...', label: '...', props?: Partial<CheckboxProps> }
```

### Selection

```ts
{ type: 'select', name: '...', label: '...', options: { value: string; label: LazyString }[], props?: Partial<SelectProps> }
{ type: 'multiselect', name: '...', label: '...', options: { value: string; label: LazyString }[], props?: Partial<MultiSelectProps> }
{ type: 'multiselectgroup', name: '...', label: '...', data: { group: LazyString; items: { value: string; label: LazyString }[] }[] }
{ type: 'autocomplete', name: '...', label: '...', options?: string[], props?: Partial<AutocompleteProps> }
```

### Date / time

```ts
{ type: 'date', name: '...', label: '...', props?: Partial<DateTimePickerProps> }
```

### Tags

```ts
{
  type: 'tags',
  name: '...',
  label: '...',
  placeholder?: LazyString,
  allowReordering?: boolean,
  allowDuplicates?: boolean,
}
```

Stores a `string[]` value. Users can type entries and press Enter to add them to the list.

### Size

```ts
{ type: 'size', name: '...', label: '...', mode: 'b' | 'mb', min: number }
```

A numeric input with byte/megabyte units. Store the value as a number in your Zod schema.

### Localized text

```ts
{
  type: 'localizedtext',
  name: '...',
  label: '...',
  translationsName: string,  // the field name holding the translations map
  languages: string[],
}
{
  type: 'localizedtextarea',
  name: '...',
  label: '...',
  translationsName: string,
  languages: string[],
  rows?: number,
}
```

Renders a text input paired with per-language override inputs. The main value lives under `name`; the translations object lives under `translationsName`. You need both keys in your `zodShape` and `initialValues`.

### Divider

```ts
{
  type: 'divider',
  name: '...',
  label?: LazyString,
  switchName?: string,       // optional: renders a switch on the divider, bound to this form value
  switchLabel?: LazyString,
  switchProps?: Partial<SwitchProps>,
  advanced?: boolean,
  when?: (values) => boolean,
}
```

A section divider with an optional label and an optional inline switch (useful for "enable this whole section" toggles). It has no value of its own unless you use `switchName`.

### Custom

```ts
{
  type: 'custom',
  name: '...',
  label?: LazyString,
  advanced?: boolean,
  colSpan?: ColSpan,
  when?: (values) => boolean,
  render: (form: UseFormReturnType<T>) => ReactNode,
}
```

For anything the built-in types don't cover. The `render` prop receives the full Mantine form object so you can call `form.getInputProps`, `form.setFieldValue`, read `form.values`, and so on. Use this as an escape hatch, not a default, the built-in types cover most cases and compose more predictably.

## A Complete Example

An extension that adds a "Provisioning Tag" and "Enable Monitoring" field to the server creation form, each validated by Zod:

```ts
import { Extension, ExtensionContext } from 'shared';
import { z } from 'zod';
import { type FieldDef, insertFieldsAfter } from '@/elements/form-engine/index.ts';

class MyExtension extends Extension {
  public initialize(ctx: ExtensionContext): void {
    ctx.extensionRegistry.enterForms((forms) =>
      forms.extend('admin.servers.create', {
        zodShape: {
          provisioningTag: z.string().max(128),
          monitoringEnabled: z.boolean(),
        },
        initialValues: {
          provisioningTag: '',
          monitoringEnabled: false,
        },
        transform: (fields) => [
          ...insertFieldsAfter(fields, 'description', {
            type: 'text',
            name: 'provisioningTag',
            label: 'Provisioning Tag',
            description: 'Passed to the provisioning system on first start.',
            colSpan: 'full',
          } satisfies FieldDef),
          {
            type: 'switch',
            name: 'monitoringEnabled',
            label: 'Enable Monitoring',
            advanced: true,
          } satisfies FieldDef,
        ],
      }),
    );
  }
}

export default new MyExtension();
```

Because both fields are declared in `zodShape`, their values ride along in the submitted request body alongside the built-in fields (`provisioning_tag` and `monitoring_enabled` after snake_casing). Your backend route receives them and can act on them however it needs to - see [Extending Models](./extending-models.md) for how to persist per-server data on the backend.

## Advanced Mode

Fields marked `advanced: true` are hidden by default. The Panel exposes an **Advanced Mode** toggle (the synced user setting `form_engine::advanced_mode`, so it follows the operator across devices) that shows all advanced fields globally. This is the right tool for fields that most operators will never need, configuration that's correct by default and only relevant in non-standard setups. When in doubt, don't mark a field advanced; it's better to show an unfamiliar field than to hide one someone needs.

## Conditional Fields

The `when` function lets you show or hide a field based on the current form values:

```ts
transform: (fields) => [
  ...fields,
  {
    type: 'text',
    name: 'customDriverPath',
    label: 'Driver Path',
    when: (values) => values.driver === 'custom',
  } satisfies FieldDef,
],
```

`when` is called on every render with the latest form values. The field is rendered when it returns `true` and omitted when it returns `false`. This is pure display logic, the field's value stays in the form state even when `when` returns `false`, so it can be safely submitted without losing user input if the condition toggles.


---

<!-- concepts/frontend-api.md -->

---
title: Frontend API Calls
description: Make HTTP requests from your extension React components to its backend routes.
---

# Frontend API Calls

So you've got a backend route registered, it runs, it returns data, beautiful. Now you need your React components to actually *talk* to it. This page covers how extensions make HTTP requests to their own backend routes (or any Panel route, really) from the frontend - the axios instance, how keys get transformed between the Rust backend's `snake_case` and the frontend's `camelCase`, how to structure your API files, and a few gotchas around data shapes that'll save you pain later.

## The Axios Instance

Your frontend has a pre-configured axios instance available - auth tokens, base URL, and error interceptors are all wired up already, so you never instantiate your own:

```ts
import { axiosInstance } from '@/api/axios.ts';
```

The instance itself does **no key transformation** - what the backend sends is what you get, and what you send is what goes out on the wire. Key conversion between the backend's `snake_case` and idiomatic frontend `camelCase` is handled per-endpoint by schema-driven helpers, described next.

::: info Coming from an older Panel version?
Earlier builds auto-camelCased every JSON response in an interceptor and shipped a second `untransformedAxiosInstance` for opting out. Both are gone - the interceptor transform was replaced by the schema-based system below, which knows exactly which keys are field names (transform them) and which are data (leave them alone).
:::

## Schemas and the Transform Helpers

Every response and request body flows through a Zod schema plus one of these helpers from `@/lib/api-transform.ts`:

```ts
import { parseFromApi, parsePaginationFromApi, serializeForApi } from '@/lib/api-transform.ts';
```

You define a schema per resource with **camelCase keys**, matching the snake_case keys your backend returns:

```ts
import { z } from 'zod';

export const itemSchema = z.object({
  id: z.string(),
  name: z.string(),
  maxRetries: z.number(), // backend sends `max_retries`
  createdAt: z.coerce.date(), // backend sends `created_at` as an ISO string
});
```

- **`parseFromApi(schema, data)`** - for incoming responses. It walks the raw data guided by the schema (nested objects, arrays, records, and unions all work), remaps each snake_case wire key to your camelCase schema key, then validates with the schema. If validation fails it logs a detailed breakdown to the console (which field, what it got, which API file called it) and throws, so backend/schema mismatches surface loudly during development instead of silently producing `undefined`s downstream.
- **`parsePaginationFromApi(schema, raw)`** - for paginated list responses. Pass it the raw paginated object (`{ total, per_page, page, data }`) and it returns a `Pagination<T>` with each entry run through `parseFromApi`.
- **`serializeForApi(schema, data, extraSchemas?)`** - for outgoing request bodies. The reverse direction: camelCase keys in your typed object become snake_case on the wire. Fields that are `undefined` are skipped entirely. The optional third argument is an array of additional schemas whose serialized output is deep-merged into the result - this is how the core endpoints for extensible forms include extension-registered fields: they pass `formExtensionSchemas(formId)`, which returns the `zodShape`s extensions registered for that form (see [Forms](./forms.md)).

Fields a backend extension added to a *core* response need no special helper: the core schemas are `z.looseObject`, so any key they don't declare is camelCased and kept on the parsed object alongside the core fields. Run your own Zod schema over that node to get typed values back - see [Extending Models](./extending-models.md#from-frontend-components) for the full pattern.

Two properties of the transform worth knowing:

- **It's schema-guided, not blind.** Only keys that appear in the schema (or in one of the `extraSchemas`) are transformed - and, for `serializeForApi`, only those keys are *sent*; anything else in the object is dropped. A field typed as `z.record(...)` has its keys passed through verbatim in both directions, so maps whose keys are data (user-provided names, UUIDs, enum constants) survive untouched. The old "auto-transform mangled my map keys" trap no longer exists.
- **Requests and responses are symmetric.** Define the schema once and use it on both sides; there's no separate "remember to snake_case your request body by hand" step anymore.

## One File Per Endpoint

The convention is one file per endpoint in `src/api/`, each with a single default export. This keeps import sites clean (`import getItems from '@/api/getItems.ts'`) and makes each endpoint independently mockable, refactorable, and greppable.

Here's the canonical shape for a GET:

```ts
import { z } from 'zod';
import { axiosInstance } from '@/api/axios.ts';
import { parseFromApi } from '@/lib/api-transform.ts';
import { itemSchema } from '../lib/schemas.ts';

export default async (serverUuid: string, itemType: string): Promise<z.infer<typeof itemSchema>[]> => {
  const { data } = await axiosInstance.get(
    `/api/client/servers/${serverUuid}/my-feature/items/${itemType}`,
  );
  return data.items.map((item: unknown) => parseFromApi(itemSchema, item));
};
```

For a paginated list:

```ts
import { z } from 'zod';
import { axiosInstance } from '@/api/axios.ts';
import { parsePaginationFromApi } from '@/lib/api-transform.ts';
import { itemSchema } from '../lib/schemas.ts';

export default async (page: number, search?: string): Promise<Pagination<z.infer<typeof itemSchema>>> => {
  const { data } = await axiosInstance.get('/api/admin/extensions/dev.yourname.test/items', {
    params: { page, search },
  });
  return parsePaginationFromApi(itemSchema, data.items);
};
```

And for a mutation that takes a request body:

```ts
import { z } from 'zod';
import { axiosInstance } from '@/api/axios.ts';
import { serializeForApi } from '@/lib/api-transform.ts';

export const updateItemSchema = z.object({
  name: z.string().optional(),
  enabled: z.boolean().optional(),
});

export default async (serverUuid: string, itemId: string, data: z.infer<typeof updateItemSchema>): Promise<void> => {
  await axiosInstance.put(
    `/api/client/servers/${serverUuid}/my-feature/items/${itemId}`,
    serializeForApi(updateItemSchema, data),
  );
};
```

A few things worth noticing:

- **URLs are hardcoded.** There's no path helper - you just interpolate the server UUID (and any other path params) directly into the string. The `/api/admin/...`, `/api/client/...`, `/api/client/servers/{uuid}/...` prefixes match the router type you registered the route under on the backend (see [Routing](./routing.md)).
- **Note the top-level unwrap happens before the parse.** The backend wraps payloads in a keyed object (`data.items`, `data.item`, ...) - the wrapper key comes off the wire in snake_case, so access it with the exact key your backend sends (`data.node_mounts`, not `data.nodeMounts`) and hand the inner value to `parseFromApi` / `parsePaginationFromApi`.
- **The function takes path/query params as arguments and the request body as the last argument.** This is a convention, not a rule, but it keeps call sites predictable.
- **Schemas are colocated or shared.** A schema used by a single endpoint can live in that file; schemas shared across endpoints belong in a `src/lib/schemas.ts` in your extension. (The Panel keeps its own in `@/lib/schemas/` - you can import those when consuming core Panel resources, but define your own for your own endpoints.)
- **Destructure `data` off the axios response.** `axiosInstance.get(...)` returns an object with `data`, `status`, `headers`, and so on - you almost always only care about `data`. Destructuring at the call site (`const { data } = await ...`) keeps the function short and makes the response shape obvious.

## Handling Errors

The Panel ships a utility called `httpErrorToHuman` that turns any axios error - whether it's a network failure, a validation 400 with a field list, or a raw 500 - into a single human-readable string. The standard pattern is to plug that string straight into a toast:

```tsx
import { httpErrorToHuman } from '@/api/axios.ts';
import { useToast } from '@/providers/ToastProvider.tsx';
import updateItem from '@/api/updateItem.ts';

export default function EditItemForm({ serverUuid, itemId }: Props) {
  const { addToast } = useToast();
  const [saving, setSaving] = useState(false);

  const onSave = (values: UpdateItemData) => {
    setSaving(true);

    updateItem(serverUuid, itemId, values)
      .then(() => {
        addToast('Item updated.', 'success');
      })
      .catch((err) => {
        addToast(httpErrorToHuman(err), 'error');
      })
      .finally(() => setSaving(false));
  };

  // ... render
}
```

This three-callback shape (`.then` success toast → `.catch` error toast → `.finally` loading reset) is what you'll see across the Panel's own codebase, and matching it keeps behavior consistent for users across the UI. `httpErrorToHuman` already knows how to unpack the standard error shape your backend returns (see [Response Types and Errors](./routing.md#response-types-and-errors) in the routing docs), so you almost never need to inspect the raw error yourself.

::: info
If you need to branch on *what* went wrong - e.g. show a different message for a conflict vs a not-found - you can check `err.response?.status` before calling `httpErrorToHuman`. But for the overwhelmingly common case of "something went wrong, show the user a readable explanation", just pass the whole error to the helper.
:::

## Designing API Shapes

A few principles that will save you headaches down the line. These are about the *shape* of the JSON your backend returns, not the frontend code that consumes it - but since the frontend is where you feel the pain, it makes sense to cover them here.

### Maps keyed by user input: use `z.record`

Consider a route that returns categories keyed by their (user-provided) names:

```jsonc
{
  "categories": {
    "VANILLA": { "items": [...] },
    "FORGE": { "items": [...] },
    "paper plugins": { "items": [...] }
  }
}
```

Those keys are *data* - they come from the user, a database, or an external API, and you don't control their casing. Type the field as a record and the transform leaves the keys alone while still transforming the field names *inside* the values:

```ts
const responseSchema = z.object({
  categories: z.record(z.string(), z.object({ items: z.array(itemSchema) })),
});
```

That said, arrays of objects are still usually the better shape - they preserve ordering, are easier to iterate and render, and make the "name" an explicit, validated field:

```jsonc
{
  "categories": [
    { "name": "VANILLA", "items": [...] },
    { "name": "paper plugins", "items": [...] }
  ]
}
```

**Rule of thumb:** if the keys of an object are something a human or another system *typed in*, prefer making them values in an array. If you do want a keyed structure, `z.record` handles it correctly - the keys round-trip untouched.

## Data-Fetching Hooks

Writing raw `useEffect` + `useState` fetches is tedious and error-prone. The Panel ships five hooks that cover the common patterns; they handle loading state, error toasts, and TanStack Query wiring for you. Because they toast internally, adding your own toast around one of them double-reports the same event - see [Toasts the data hooks already raise](./toasts.md#toasts-the-data-hooks-already-raise) for what each one covers and how to opt out.

All five are in `@/plugins/`:

```ts
import { useResource } from '@/plugins/useResource.ts';
import { usePollingResource } from '@/plugins/usePollingResource.ts';
import { useSearchableResource } from '@/plugins/useSearchableResource.ts';
import { useSearchablePaginatedTable } from '@/plugins/useSearchablePaginatedTable.ts';
import { useResourceForm } from '@/plugins/useResourceForm.ts';
```

### Query Keys

TanStack Query identifies cached data by a query key - an array compared structurally. `invalidateQueries` invalidates every cached query whose key starts with the given prefix (exact matching is off by default), so your key structure determines how broadly invalidation spreads.

The Panel's own code builds keys using `@/lib/queryKeys.ts`. **Extensions must not import from that file.** Those keys are Panel internals, and sharing a prefix with a Panel query means invalidation can flush Panel caches or vice versa. Define your own inline arrays instead, namespaced under `'extensions'` and your extension ID:

```ts
queryKey: ['extensions', 'dev.yourname.test', 'items']
queryKey: ['extensions', 'dev.yourname.test', 'items', serverUuid]
```

Because of prefix matching, invalidating `['extensions', 'dev.yourname.test', 'items']` also invalidates `['extensions', 'dev.yourname.test', 'items', serverUuid]` and any deeper keys. Use narrow keys when you only want one server's cache to re-fetch; use the shorter prefix when you want everything under that namespace to re-fetch.

The hooks append their own dynamic segments to the key you supply - your `deps` array (if any) and a final object containing the current search term and/or page number. You never include those yourself.

### `useResource` - simple fetches

Use this when you need to fetch a resource and don't need search or pagination. It wraps TanStack Query's `useQuery`, automatically shows an error toast on failure, and returns `refetch` and `invalidate` helpers.

```tsx
import { useResource } from '@/plugins/useResource.ts';
import getFeatureSettings from '@/api/getFeatureSettings.ts';

export default function FeatureSettings({ serverUuid }: { serverUuid: string }) {
  const { data, loading, refetch, invalidate } = useResource({
    queryKey: ['extensions', 'dev.yourname.test', 'settings', serverUuid],
    queryFn: () => getFeatureSettings(serverUuid),
  });

  if (loading) return <Spinner />;

  return (
    <>
      <p>Current limit: {data?.limit}</p>
      <Button onClick={refetch}>Refresh</Button>
    </>
  );
}
```

`data` is `T | undefined` - it starts as `undefined` before the first fetch resolves, so guard it with optional chaining. `loading` is `isFetching` from TanStack Query, which is `true` whenever a request is in flight, including background re-fetches triggered by `invalidate`. If you want to show a spinner only on the initial load and not on background re-fetches, check `loading && !data` instead of just `loading`.

**`invalidate`** calls `queryClient.invalidateQueries({ queryKey })`. The query is marked stale and re-fetches in the background if any component is subscribed. The component continues rendering existing data until the re-fetch completes. Use this after a mutation - e.g. after a save in a child component, invalidate the parent's fetch so it picks up the change.

**`refetch`** calls the TanStack Query `refetch` function returned by `useQuery`, which fires the query immediately regardless of staleness. Use this for an explicit user-triggered refresh.

**`enabled`** is passed directly to `useQuery`. When `false`, the query never fires - `data` stays `undefined` and `loading` is `false`. Use it to gate fetches on values that may not be ready yet:

```ts
const { data } = useResource({
  queryKey: ['extensions', 'dev.yourname.test', 'items', serverUuid],
  queryFn: () => getItems(serverUuid),
  enabled: !!serverUuid,
});
```

**`silent`** suppresses the automatic error toast. `error` is always returned regardless - `silent` only controls whether the hook itself reacts to it - so you can take over error handling yourself:

```ts
const { data, error } = useResource({
  queryKey: ['extensions', 'dev.yourname.test', 'items', serverUuid],
  queryFn: () => getItems(serverUuid),
  silent: true,
});
```

### `usePollingResource` - fetches that re-run on an interval

Use this when you need to re-fetch a resource on a fixed interval - a build status, a transfer's progress, anything that changes on the backend while the user watches. It's `useResource` plus an `interval`, and an optional `stopWhen` predicate that halts the polling once the data reaches a terminal state. It returns the same `{ data, loading, error, refetch, invalidate }` as `useResource`.

```tsx
import { usePollingResource } from '@/plugins/usePollingResource.ts';
import getJobStatus from '@/api/getJobStatus.ts';

export default function JobStatus({ jobId }: { jobId: string }) {
  const { data } = usePollingResource({
    queryKey: ['extensions', 'dev.yourname.test', 'jobs', jobId],
    queryFn: () => getJobStatus(jobId),
    interval: 5000,
    stopWhen: (status) => status.done,
  });

  return <p>State: {data?.state ?? 'loading'}</p>;
}
```

**`interval`** is the poll period in milliseconds. It's passed to TanStack Query's `refetchInterval`, so the timer resets after each fetch resolves rather than firing on a fixed wall-clock schedule.

**`stopWhen`** receives the latest `data` and returns `true` to stop polling. Polling halts as soon as it returns `true`; if the data later moves back to a non-terminal state - for example you optimistically write `isBuilding: true` into the cache with `queryClient.setQueryData` to kick off a new build - the interval restarts automatically. Omit it to poll forever while the component is mounted.

**`pollInBackground`** (default `false`) maps to `refetchIntervalInBackground`. By default TanStack Query pauses interval re-fetches while the browser tab is unfocused; pass `true` when the poll must keep running in the background - e.g. so a completion toast still fires if the user switches tabs mid-build.

Polling also stops while the query is in an error state, so a persistently failing endpoint isn't hammered every interval. It resumes automatically on the next successful fetch - for example when the tab regains focus (TanStack Query re-fetches on window focus by default) or when you call `refetch` - so transient failures recover on their own.

**`enabled`** and **`silent`** behave exactly as in `useResource`. Note there is no per-fetch success callback (TanStack Query v5 removed `onSuccess` from `useQuery`) - to run a side-effect when the polled value crosses into a terminal state, watch `data` in a `useEffect` and detect the transition yourself:

```tsx
const wasRunningRef = useRef(false);
useEffect(() => {
  const running = data?.isBuilding;
  if (running === undefined) return;
  if (wasRunningRef.current && !running) {
    // build just finished
  }
  wasRunningRef.current = running;
}, [data?.isBuilding]);
```

### `useSearchableResource` - searchable dropdowns

Use this when populating a `<Select>` or `<MultiSelect>` from a backend search endpoint. The hook maintains two separate pieces of state: `search`, which is updated immediately as the user types and bound directly to the input, and an internal debounced copy that drives the actual query. This keeps the input snappy while throttling network requests.

The fetcher signature is `(search: string) => Promise<Pagination<T>>`. The global `Pagination<T>` type is:

```ts
interface Pagination<T> {
  total: number;
  perPage: number;
  page: number;
  data: T[];
}
```

This is exactly what `parsePaginationFromApi` returns, so a paginated API file plugs straight in. The hook unwraps `data?.data ?? []` for you, so the returned `items` field is directly `T[]`.

```tsx
import { useSearchableResource } from '@/plugins/useSearchableResource.ts';
import getItems from '@/api/getItems.ts';
import Select from '@/elements/input/Select.tsx';

export default function ItemPicker({ serverUuid }: { serverUuid: string }) {
  const items = useSearchableResource<Item>({
    queryKey: ['extensions', 'dev.yourname.test', 'items', serverUuid],
    fetcher: (search) => getItems(serverUuid, search),
  });

  return (
    <Select
      label='Item'
      data={items.items.map((item) => ({ label: item.name, value: item.uuid }))}
      searchable
      searchValue={items.search}
      onSearchChange={items.setSearch}
      loading={items.loading}
    />
  );
}
```

The hook builds the final query key as `[...queryKey, ...deps, { search: debouncedSearch }]`. Each distinct debounced search term gets its own cache entry, so typing the same string twice in a row hits the cache on the second pass without firing a network request. The default debounce is 150ms; pass `debounceMs` to override it.

**`deps`** is an array of values that participate in two ways: they're spread into the query key (so changing one invalidates that cache entry rather than overwriting it), and the query is gated on `deps.every(Boolean)` when `deps` is non-empty. Pass it when the fetcher depends on something that may not yet be available:

```ts
const items = useSearchableResource<Item>({
  queryKey: ['extensions', 'dev.yourname.test', 'items', serverUuid],
  fetcher: (search) => getItems(serverUuid, search),
  deps: [serverUuid],
});
```

**`canRequest`** is an additional gate on top of `deps`. The query runs only when `canRequest` is true *and* every element of `deps` is truthy (or `deps` is empty). Default is `true`. Use it for conditions that aren't naturally part of the fetcher's argument list - permission flags, parent-component readiness, modal-open state.

**`defaultSearchValue`** sets the initial value for both `search` and the internal debounced state on mount. This is useful in edit forms where you already know the currently selected item's name and want it to appear in the input without the user having to open and search the dropdown:

```ts
const items = useSearchableResource<Item>({
  queryKey: ['extensions', 'dev.yourname.test', 'items'],
  fetcher: (search) => getItems(search),
  defaultSearchValue: existing?.name,
});
```

### `useSearchablePaginatedTable` - paginated tables

Use this for full table pages with search and pagination. It manages page and search state, syncs both to URL search params, renders previous data while the next page loads via TanStack Query's `placeholderData: keepPreviousData`, and calls `setStoreData` when fresh data arrives. The actual paginated data lives in your store, not in the hook's return value - the hook drives the store, and the component reads from the store directly.

```tsx
import { useSearchablePaginatedTable } from '@/plugins/useSearchablePaginatedTable.ts';
import getMyItems from '@/api/getMyItems.ts';
import Table from '@/elements/Table.tsx';
import { useMyStore } from '@/stores/myStore.ts';
import { useTranslations } from '@/providers/TranslationProvider.tsx';

export default function MyItemsTable({ serverUuid }: { serverUuid: string }) {
  const { t } = useTranslations();
  const { items, setItems } = useMyStore();

  const { loading, search, setSearch, page, setPage } = useSearchablePaginatedTable({
    queryKey: ['extensions', 'dev.yourname.test', 'items', serverUuid],
    fetcher: (page, search) => getMyItems(serverUuid, page, search),
    setStoreData: setItems,
  });

  return (
    <Table
      columns={[
        t('common.table.columns.name'),
        t('common.table.columns.created'),
        ''
      ]}
      loading={loading}
      pagination={items}
      onPageSelect={setPage}
    >
      {items.data.map((item) => (
        <ItemRow key={item.uuid} item={item} />
      ))}
    </Table>
  );
}
```

The fetcher signature is `(page: number, search: string) => Promise<T>`. The hook builds the final query key as `[...queryKey, ...deps, { page, search: debouncedSearch }]`, so each page/search combination has its own cache entry.

**URL params:** On mount, the hook initialises `search` from `?search=` and `page` from `?page=` in the URL (the page param is parsed and ignored unless it's a finite integer `>= 1`). Whenever either changes, `setSearchParams` is called with both values, which replaces the entire search string - any other params in the URL will be dropped. The immediate `search` value (not debounced) is written to the URL on every keystroke; the debounced copy is what drives the query. Clearing the search field bypasses the debounce entirely and immediately resets both the debounced state and the query.

**Pagination auto-correction:** When a fetch returns, the hook inspects `total`, `perPage`, and `page` on the response (or on `response[paginationKey]` if `paginationKey` is set) *before* calling `setStoreData`. If the current page exceeds the last valid page, it calls `setPage(totalPages)` and skips the store update for this fetch - the resulting re-fetch will populate the store. If the total is zero and the current page isn't 1, it resets to page 1 the same way. Only when the page is already valid does `setStoreData` get called with the response. This handles the common case of deleting the last item on a page.

**`paginationKey`** handles responses where the paginated shape is nested under a key. Without it, the hook looks for `total`, `perPage`, and `page` at the root of the fetcher's return value. With it, it looks at `response[paginationKey]` for those fields, while still passing the full response to `setStoreData`:

```ts
useSearchablePaginatedTable({
  queryKey: ['extensions', 'dev.yourname.test', 'items', serverUuid],
  fetcher: getMyItems,
  setStoreData: setItems,
  paginationKey: 'items',
  // fetcher returns: { items: { data, total, perPage, page } }
});
```

**`modifyParams: false`** disables URL param reading and writing entirely. Use this when the table is inside a modal or a sub-panel where touching the URL would be wrong. **`initialPage`** sets the starting page when `modifyParams` is false or when the URL has no `?page=` param.

**`deps`** behaves the same as in `useSearchableResource` - spread into the query key so each combination gets its own cache entry. Unlike `useSearchableResource`, there's no `deps.every(Boolean)` gate here, so use `canRequest` if you need to block the fetch on a precondition.

**`canRequest`** gates the query - when `false`, no fetch fires. Default is `true`.

### `useResourceForm` - create / update / delete forms

Use this for forms that manage a single resource's full lifecycle. It takes a Mantine `useForm` instance and your API functions, then handles loading state, success/error toasts, cache invalidation, and navigation.

```tsx
import { useForm } from '@mantine/form';
import { zod4Resolver } from 'mantine-form-zod-resolver';
import { z } from 'zod';
import { useResourceForm } from '@/plugins/useResourceForm.ts';
import createItem from '@/api/createItem.ts';
import updateItem from '@/api/updateItem.ts';
import deleteItem from '@/api/deleteItem.ts';

const schema = z.object({
  name: z.string().min(1),
  enabled: z.boolean(),
});

export default function ItemCreateOrUpdate({ existing }: { existing?: Item }) {
  const form = useForm<z.infer<typeof schema>>({
    initialValues: {
      name: existing?.name ?? '',
      enabled: existing?.enabled ?? true,
    },
    validate: zod4Resolver(schema),
    validateInputOnBlur: true,
  });

  const { loading, doCreateOrUpdate, doDelete } = useResourceForm({
    form,
    createFn: () => createItem(schema.parse(form.getValues())),
    updateFn: existing ? () => updateItem(existing.uuid, schema.parse(form.getValues())) : undefined,
    deleteFn: existing ? () => deleteItem(existing.uuid) : undefined,
    doUpdate: !!existing,
    basePath: '/admin/my-feature/items',
    resourceName: 'Item',
  });

  return (
    <form onSubmit={form.onSubmit(() => doCreateOrUpdate(false, ['extensions', 'dev.yourname.test', 'items']))}>
      <TextInput label='Name' {...form.getInputProps('name')} />
      <Switch label='Enabled' {...form.getInputProps('enabled', { type: 'checkbox' })} />
      <Group>
        <Button type='submit' loading={loading}>Save</Button>
        {!existing && (
          <Button onClick={() => doCreateOrUpdate(true)} loading={loading}>
            Save & Stay
          </Button>
        )}
        {existing && (
          <Button color='red' onClick={doDelete} loading={loading}>Delete</Button>
        )}
      </Group>
    </form>
  );
}
```

**`doUpdate`** is the flag that determines which function is called. When `true`, `doCreateOrUpdate` calls `updateFn`; when `false`, it calls `createFn`. Pass `!!existing` or equivalent - the hook trusts this flag completely and does not inspect anything else to decide.

**`createFn` and `updateFn`** are zero-argument closures (from the consumer's perspective - the public `doCreateOrUpdate` signature doesn't expose a way to pass arguments through). They capture form values from the surrounding scope via `form.getValues()` and run schema validation inside the closure.

**`doCreateOrUpdate(stay, bustCacheKey)`:**

- `stay: boolean` controls what happens after a successful create. When `false`, the hook navigates to `${basePath}/${result.uuid}`. This requires your `createFn` to return an object with a `uuid: string` field (the hook has a `HasUuid` constraint on the generic). When `true`, navigation is skipped and any fields listed in `toResetOnStay` are reset to their initial values, allowing the user to create another item without leaving the page. `stay` has no effect on updates - they never navigate or reset, regardless.
- `bustCacheKey` is an optional query key to invalidate on success. `queryClient.invalidateQueries` is called with this key after both creates and updates. Pass your list key here so any mounted table re-fetches after a save.

**`toResetOnStay`** is an array of field name strings to reset when `stay` is `true`. Only those specific fields are reset; the rest of the form retains its values. This is useful when some fields (a category, a server) should persist across repeated creates, but others (a name) should clear:

```ts
useResourceForm({
  // ...
  toResetOnStay: ['name'],
});
```

**`resourceName`** is interpolated directly into the toast messages: `"Item created."`, `"Item updated."`, `"Item deleted."`. After a delete, the hook also calls `navigate(basePath)` to return to the list.

**`setLoading`** is exposed in the return value so you can drive the shared loading flag from outside the hook. Use it when an extra action button in the same component needs to participate in the same disabled/loading state - flip it `true` before your own async work and `false` in a `finally`.

## Summary

| Situation | Use |
| --------- | --- |
| Any response | `axiosInstance` + `parseFromApi(schema, data.<wrapper_key>)` |
| Paginated list response | `parsePaginationFromApi(schema, data.<wrapper_key>)` |
| Any request with a body | `axiosInstance` + `serializeForApi(schema, data)` |
| Map whose object *keys* are data | type the field as `z.record(...)` - keys pass through untouched |
| Any error from any of the above | `httpErrorToHuman(err)` into a toast |
| Simple one-off data fetch | `useResource` |
| Data that must re-fetch on an interval | `usePollingResource` |
| Searchable `<Select>` or `<MultiSelect>` options | `useSearchableResource` |
| Full paginated table with search | `useSearchablePaginatedTable` |
| Create / update / delete form | `useResourceForm` |

Keep your API files one-endpoint-per-file with a default export, define one Zod schema per resource and run every request and response through the `api-transform` helpers, and match the three-callback success/error/loading pattern for any call triggered by user action. Define query keys inline as extension-namespaced arrays - never import from `@/lib/queryKeys.ts`.


---

<!-- concepts/mounting-ui.md -->

---
title: Mounting UI
description: Render your extension React components inside the Panel pages.
---

# Mounting UI

So far the docs have covered the backend side - routes, permissions, settings, activity. But none of that is any use unless the Panel's UI actually *shows* something to a user. This page is about the frontend entry point: how your extension gets React components onto actual pages in the Panel.

Two things do the work:

1. **The `Extension` class** you export from `src/index.ts`, which declares a couple of default mount points (the admin extension card, the admin extension configuration page).
2. **The `ExtensionRegistry`**, accessed via `ctx.extensionRegistry` inside your `initialize()` method, which lets you push components into existing Panel pages and add entirely new routes to the sidebar.

Together these cover the big four: "add a summary to the admin extension card", "let the admin configure your extension", "drop a widget into an existing page", and "add a whole new page". Which is most of what most extensions need.

## The Extension Class

Every extension's frontend entry point is a class extending `Extension`, whose instance you default-export:

```ts
import { Extension, ExtensionContext } from 'shared';
import MyConfigurationPage from './ConfigurationPage.tsx';
import MyCardSummary from './CardSummary.tsx';

class MyExtension extends Extension {
  public cardConfigurationPage: React.FC | null = MyConfigurationPage;
  public cardComponent: React.FC | null = MyCardSummary;
  public cardIcon: React.ReactNode = <FontAwesomeIcon icon={faRocket} />;

  public initialize(ctx: ExtensionContext): void {
    // register additional UI through ctx.extensionRegistry - see below
  }
}

export default new MyExtension();
```

Three fields on the class map directly to admin-panel surfaces:

- **`cardComponent`** - a React component rendered inside your extension's card in the admin panel's extension list. Good for a quick at-a-glance summary: "42 items installed", "last sync 2 minutes ago", a small health indicator. Keep it compact, it's sharing space with other extensions. Set to `null` if you don't need it.

- **`cardConfigurationPage`** - a React component shown when an admin clicks the Configure button on your extension's card. It's mounted at `/admin/extensions/<your-package-identifier>` automatically - you don't need to register a route for it. Set to `null` if your extension has nothing to configure.

- **`cardIcon`** - a React node that overrides the icon shown next to your extension's name in the admin extension list (defaults to a generic puzzle-piece). Pass any element - a `<FontAwesomeIcon />`, an `<img>`, an inline SVG - and it's dropped into the row's icon slot as-is; the surrounding styled container is kept, so you're swapping just the glyph. Leave it `null` to keep the default puzzle piece.

::: info
**Configuration pages are already wrapped for you.** The route shell that mounts your `cardConfigurationPage` at `/admin/extensions/<id>` provides the admin layout, navigation, and title bar - your component just returns its content (a `<div>`, a `<Stack>`, whatever). This is the *only* exception. Every other route you add needs to wrap its own content (see [Container Wrappers](#container-wrappers) below).
:::

The `initialize()` method runs once when the Panel loads your extension, and receives a `ctx: ExtensionContext` that exposes the registry. Any UI beyond the two class fields gets added here.

## Slotting Into Existing Pages

The Panel's built-in pages expose named **slot points** where extensions can inject components. As an example, the Egg Changer extension uses this to drop a card onto the server settings page:

```ts
public initialize(ctx: ExtensionContext): void {
  ctx.extensionRegistry.pages.server.settings.enterSettingContainers((containers) =>
    containers.appendComponent(EggChangerContainer),
  );
}
```

Walking this left to right: `ctx.extensionRegistry.pages` is the tree of built-in pages with slot points. `.server.settings` navigates to the server settings page. `.enterSettingContainers(...)` enters the "setting containers" slot on that page, handing you a container object whose methods control what gets added and where.

Each slot has two methods for adding components:

- **`appendComponent(Component)`** - adds to the end of the slot
- **`prependComponent(Component)`** - adds to the beginning of the slot

The component you pass takes no props - it's a self-contained feature card that reads whatever state it needs from Panel stores (see [Reading Panel State](#reading-panel-state) below).

### Ordering Between Entries

Append and prepend get you to the ends. If you need to land *between* existing entries, the slot points use Tailwind's `order-` utility classes on each entry to control visual position. Stock entries count up by 10 starting at `order-10`, so `order-10`, `order-20`, `order-30`, and so on - leaving plenty of gaps for extensions to slot in at `order-15` or `order-25` without having to renumber anything.

Since the exact numbers depend on what's already there in the version of the Panel you're targeting, **inspect the page in DevTools** to find the existing orders: right-click an entry next to where you want to land, inspect, and look for the `order-<n>` class on the wrapper. Then set your own component's outer element to an order that falls in the right gap:

```tsx
export default function MyServerSettingsCard() {
  return (
    <TitleCard title='...' className='order-25'>
      {/* ... */}
    </TitleCard>
  );
}
```

This is a slightly manual process but it's flexible and it avoids the common "ordering API where nobody agrees on priorities" mess. Most extensions won't need to care - appending to the end is usually fine.

### Full Slot-Point Surface

The page tree under `ctx.extensionRegistry.pages` is large and evolves as the Panel grows new slot points. Rather than enumerate it here (and go stale immediately), see the typedocs: [ExtensionRegistry](https://typedocs.calagopus.com/classes/extensions_shared_src_registries.ExtensionRegistry). Look for `pages.*` and follow the types to find the container method you need.

## Adding New Routes

When slotting into an existing page isn't enough - you have a whole new feature that deserves its own sidebar entry and URL - use the route registry. The Minecraft version changer extension does this to add a "Versions" tab to every server:

```ts
import { faCube } from '@fortawesome/free-solid-svg-icons';

public initialize(ctx: ExtensionContext): void {
  ctx.extensionRegistry.enterRoutes((routes) =>
    routes.addServerRoute({
      name: () => getExtTranslations().t('pages.server.versions.title', {}),
      icon: faCube,
      path: '/minecraft/versions',
      element: MinecraftVersionsPage,
    }),
  );
}
```

`enterRoutes(...)` gives you a `RouteRegistry` with one `add*Route` method per route type. The route types correspond 1:1 with the Panel's major navigation scopes:

| Method | Definition type | Where it appears | Has name/icon? | Has permission? |
| ------ | --------------- | ---------------- | -------------- | --------------- |
| `addGlobalRoute` | `GlobalRouteDefinition` | Top-level, no layout | No | No |
| `addAuthenticationRoute` | `GlobalRouteDefinition` | Inside the auth flow | No | No |
| `addAccountRoute` | `RouteDefinition` | User's account area | Yes | No |
| `addAdminRoute` | `AdminRouteDefinition` | Admin panel sidebar | Yes | Yes |
| `addServerRoute` | `ServerRouteDefinition` | Per-server sidebar (like "Console", "Files", etc.) | Yes | Yes |

### Route Definition Fields

The definition types form a small hierarchy, each level adding fields. These are the fields you'll actually fill in:

**Always (`GlobalRouteDefinition` and everything that extends it):**

- **`path: string`** - the URL relative to the route type's base. A path of `/minecraft/versions` passed to `addServerRoute` becomes something like `/server/<uuid>/minecraft/versions` in the actual URL. Leading slash required.
- **`element: FC`** - the React component to render when the route matches. Remember it needs to wrap itself in the appropriate container (see [Container Wrappers](#container-wrappers) below).
- **`exact?: boolean`** - standard react-router exact-matching flag. Leave unset unless you know you need it.
- **`filter?: () => boolean`** - called at render time; if it returns `false` the route is skipped as though it wasn't registered. Useful for feature flags ("only show this route if the extension's setting is enabled"), conditional UI ("only show if the server has a specific egg type"), or environment checks. The function runs on every render, so keep it cheap - a boolean check on a store value, not a network call.

::: warning
`filter` is not for egg route filtering, The Panel already has such system built-in via Egg Configurations. Use `filter` for extension-specific conditions only.
:::

**Additionally for named routes (`RouteDefinition` - account, admin, server):**

- **`name: string | (() => string) | undefined`** - the label shown in the sidebar. A plain string works for untranslated labels. For translated labels, pass a function that returns the translated string - this way the label re-evaluates when the user switches language. `undefined` is valid if for some reason you want a route with no sidebar entry (though in that case you probably want `addGlobalRoute` instead).
- **`icon?: IconDefinition`** - a FontAwesome icon definition (e.g. `faCube` from `@fortawesome/free-solid-svg-icons`). Optional, but sidebar entries look noticeably worse without one - include one unless you're specifically going for a text-only look.

**Additionally for permissioned routes (`AdminRouteDefinition`, `ServerRouteDefinition`):**

- **`permission?: string | string[] | null`** - the permission node(s) required to see this route. A single string requires that permission; an array passes if the user holds *any* of them, so use it for "this page is reachable through more than one grant", not to require a combination. Routes whose permission check fails are hidden from the sidebar and inaccessible via direct URL. This is the frontend counterpart to `has_server_permission` on the backend - see [Permissions](./permissions.md) for how permission nodes map to what the user can do. `null` or omitted means no permission required (which is the right default for most user-facing features; reach for `permission` when you have something gated).

**Additionally for admin routes (`AdminRouteDefinition`):**

- **`category?: string`** - the admin sidebar is grouped into labelled categories, in this order: System, Infrastructure, Users & Access, Nests & Eggs, Databases, Storage. Set `category` to one of the built-in category keys (`'infrastructure'`, `'eggs'`, `'databases'`, `'storage'`, `'access'`, `'system'`) to place your route under that heading. An unknown or omitted `category` puts the route in an unlabelled group at the bottom of the sidebar, below the built-in categories.

### A More Complete Example

Putting a few of these together - a server route with a translated name, an icon, gated on a permission, and conditionally hidden unless the extension is enabled for the current server:

```ts
ctx.extensionRegistry.enterRoutes((routes) =>
  routes.addServerRoute({
    name: () => getExtTranslations().t('pages.server.myfeature.title', {}),
    icon: faCube,
    path: '/my-feature',
    element: MyFeaturePage,
    permission: 'settings.my-feature',
    filter: () => {
      const egg = useServerStore.getState().server.egg;
      return egg.features.includes('minecraft');
    },
  }),
);
```

Note the `.getState()` call inside `filter` - since `filter` runs outside React (it's called by the router, not a component), you can't use the hook form `useServerStore(...)`. Zustand's `.getState()` gives you a synchronous snapshot which is what you want here.

### Container Wrappers

Unlike configuration pages, **route components don't get page chrome for free**. Your `element` is rendered inside the layout for its route type, but inside that layout you still need to wrap your content in the right **content container**, which handles the title, header, and other per-page chrome. Importing from `@/elements/containers/`:

| Route type | Wrapper | Notes |
| ---------- | ------- | ----- |
| `addGlobalRoute`, `addAuthenticationRoute` | `ContentContainer` | Minimal - sets the browser tab title, renders children directly |
| `addAccountRoute` | `AccountContentContainer` | Full account page chrome |
| `addAdminRoute` (top-level) | `AdminContentContainer` | Full admin page chrome |
| `addAdminRoute` (under a tabbed page with SubNavigation) | `AdminSubContentContainer` | For admin pages that live as a tab underneath a parent page |
| `addServerRoute` | `ServerContentContainer` | Full server page chrome |

The non-minimal containers all take the same core props - `title` (required - the page header), plus optionals like `subtitle`, `search` / `setSearch` (wires up a search input in the header), `contentRight` (a ReactNode rendered on the right of the header, for buttons), and `fullscreen`. A typical server page looks like this:

```tsx
import ServerContentContainer from '@/elements/containers/ServerContentContainer.tsx';

export default function MyServerPage() {
  const { t: tExt } = useExtTranslations();

  return (
    <ServerContentContainer title={tExt('pages.server.myfeature.title', {})}>
      {/* your page content */}
    </ServerContentContainer>
  );
}
```

Note that these containers have a `registry` prop. **You don't need it.** It's used by the stock Panel to register its own slot points inside pages, not by extensions - ignore it.

### Ordering Routes

By default, admins can reorder routes (for admin and server sidebars) from the admin panel UI. **This is the source of truth.** If an admin has set a custom order, that wins. You generally don't need to think about where your route lands - it'll appear somewhere sensible by default, and admins will move it if they want.

If you really do want to influence the default ordering (before an admin customizes it), use an interceptor - see the next section. But most of the time: just `addServerRoute(...)` and let the admin panel handle placement.

## Interceptors

Alongside each `add*Route` method there's a matching `add*RouteInterceptor`. Interceptors receive the full array of routes of their type (including routes registered by core and by other extensions) and can mutate it. They run after all extensions have registered, so the array you see is the final list before the Panel renders it:

```ts
public initialize(ctx: ExtensionContext): void {
  ctx.extensionRegistry.enterRoutes((routes) =>
    routes.addServerRouteInterceptor((items) => {
      // reorder, or replace a stock page's element with your own
    }),
  );
}
```

Two legitimate uses:

- **Changing default route order.** Move entries around to land in a specific spot. This is the *default* order only - if an admin has set a custom order in the admin panel, their ordering wins and your interceptor's work is discarded. So interceptors are for "sensible default before anyone customizes it", not for guaranteed placement.

- **Replacing a stock page entirely.** Find the route you want to replace by its path, swap its `element` for your own component. This lets an extension take over a built-in page - useful when you want to offer a different UX for an existing Panel feature.

::: warning
Interceptors are a sharp tool. They run against every route of that type, including routes from core and routes from other extensions that registered before you. A buggy interceptor can break functionality the user didn't even know came from your extension, and replacing a stock page's element means you now own the responsibility of keeping that page working as the Panel evolves.

Use interceptors sparingly, keep their mutations narrowly scoped (filter to the one route you care about by path, don't iterate over everything), and prefer the simple `add*Route` methods for anything you can express that way.
:::

The same interceptor pattern exists for every route type - `addGlobalRouteInterceptor`, `addAccountRouteInterceptor`, `addAdminRouteInterceptor`, `addAuthenticationRouteInterceptor`.

## Reading Panel State

Components you slot into existing pages (and pages you register as new routes) usually need to know *which* server or user the current page is about. The Panel exposes this through Zustand stores, which your components import directly:

```tsx
import { useServerStore } from '@/stores/server.ts';
import { useGlobalStore } from '@/stores/global.ts';
```

- **`useServerStore`** - the currently-viewed server (on server pages). Exposes `server`, `updateServer`, and related state.
- **`useGlobalStore`** - app-wide state like available languages, feature flags, user info.

Subscribe with a selector to avoid re-rendering when unrelated fields change:

```tsx
const uuid = useServerStore((state) => state.server.uuid);
const egg = useServerStore((state) => state.server.egg);
```

Or destructure if you need multiple fields and don't mind the extra re-renders:

```tsx
const { server, updateServer } = useServerStore();
```

This is standard Zustand - if you haven't used it before, the [Zustand docs](https://zustand.docs.pmnd.rs/) are short and cover everything.

## The Rest of the Registry

This page covered the two patterns you'll actually use in most extensions - slotting components into existing pages, and adding new routes. The `ExtensionRegistry` surface is considerably larger than this, and designed to let extensions do almost anything with the UI. Rather than enumerate it (and go stale every time it grows), the authoritative reference is the typedocs:

**[ExtensionRegistry typedocs](https://typedocs.calagopus.com/classes/extensions_shared_src_registries.ExtensionRegistry)**

Start there when you need something beyond what's shown on this page. Most slot points and registries follow the same `enter*()` / `add*()` shape as the two patterns above, so once you've used one, the rest are mostly discoverable.


---

<!-- concepts/permissions.md -->

---
title: Permissions
description: Gate your extension routes and UI behind the Panel permission system with your own permission keys.
---

# Permissions

Every mutating route in your extension should be gated on a permission check - something like `permissions.has_server_permission("settings.egg-changer")?` at the top of the handler. The routing docs already covered *how* to do that check; this page is about the other side, which is **where those permission strings come from** and how you register them so admins can actually grant or revoke them in the UI.

The short version: extensions declare their permissions through the `ExtensionPermissionsBuilder` in their `initialize_permissions` method, exactly like routes are declared through `ExtensionRouteBuilder` in `initialize_router`. The Panel then exposes those permissions in the permission-picker UI, so users assigning subusers to a server or admins configuring roles can toggle each one individually.

## The Three Permission Scopes

There are three independent permission surfaces, one per "who's being permissioned":

| Scope | Who it applies to | Where it's checked |
| ----- | ----------------- | ------------------ |
| **User permissions** | A user's own account scope (things they can do that aren't server-specific) | Client routes that aren't server-scoped |
| **Server permissions** | Subusers on a specific server | Client-server routes |
| **Admin permissions** | Admin roles | Admin routes |

These map 1:1 to the `has_user_permission(...)`, `has_server_permission(...)`, and `has_admin_permission(...)` methods you've already seen on `GetPermissionManager`. The scope you add a permission under has to match the scope of the route that checks it - an admin permission can't be checked by `has_server_permission` and vice versa.

## The Shape of a Permission

Permissions are organized into **groups**, and each group contains one or more named permissions. The underlying types are:

```rs
pub struct PermissionGroup {
    pub description: &'static str,
    pub permissions: IndexMap<&'static str, &'static str>,
}
```

A group has a description (what the group is about, shown in the permission picker), and a map of permission names to their descriptions. So a group like `settings` might contain permissions `read`, `update`, and `egg-changer`, each with its own blurb explaining what granting that permission does.

**The dotted strings you see in permission checks are `<group_name>.<permission_name>`.** `settings.egg-changer` means "the `egg-changer` permission inside the `settings` group". This is the full "permission node" - the group name isn't just organizational, it's part of the identifier.

::: info
Use **kebab-case** for both group names and permission names - `egg-changer`, not `eggChanger` or `egg_changer` or `EggChanger`. This matches the convention the core Panel uses and keeps the permission picker visually consistent.
:::

## Registering Permissions

Permissions are declared by implementing the `initialize_permissions` method on your `Extension` trait. The signature is almost identical to `initialize_router`:

```rs
use shared::{
    State,
    extensions::{Extension, ExtensionPermissionsBuilder},
    permissions::PermissionGroup,
};
use indexmap::IndexMap;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize_permissions(
        &mut self,
        _state: State,
        builder: ExtensionPermissionsBuilder,
    ) -> ExtensionPermissionsBuilder {
        builder.add_server_permission_group(
            "my-feature",
            PermissionGroup {
                description: "Permissions for the My Feature extension.",
                permissions: IndexMap::from([
                    ("read", "Allows viewing My Feature data on this server."),
                    ("update", "Allows changing My Feature settings on this server."),
                ]),
            },
        )
    }
}
```

The builder exposes three `add_*_permission_group` methods (one per scope), each taking a group name and a `PermissionGroup`. All three return `Self`, so you can chain as many as you need. Once registered, your permissions show up in the UI automatically, and you can check them from your handlers:

```rs
permissions.has_server_permission("my-feature.read")?;
permissions.has_server_permission("my-feature.update")?;
```

## Giving a Group an Icon

New groups you register show up in the permission-picker UI with no icon by default, which looks a bit bare next to the core Panel's groups. Giving your group an icon is optional but recommended - it makes the picker scannable and signals to users at a glance what the group is for.

Icons are attached **from the frontend**, not the backend. This is a presentation concern and lives on the extension registry, not the permission builder:

```tsx
import { Extension, ExtensionContext } from 'shared';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCube } from '@fortawesome/free-solid-svg-icons';

class MyExtension extends Extension {
  public cardConfigurationPage: React.FC | null = null;
  public cardComponent: React.FC | null = null;

  public initialize(ctx: ExtensionContext): void {
    ctx.extensionRegistry.permissionIcons.addServerPermissionIcon(
      'my-feature',
      <FontAwesomeIcon icon={faCube} />,
    );
  }
}

export default new MyExtension();
```

The registry exposes one method per scope, matching the three on the backend builder:

- `addUserPermissionIcon(groupName, icon)`
- `addAdminPermissionIcon(groupName, icon)`
- `addServerPermissionIcon(groupName, icon)`

Each takes the **group name** (not a permission node - icons are per-group, not per-permission) and a `ReactNode`. The `ReactNode` is usually a `<FontAwesomeIcon>` to match the rest of the Panel's visual style, but technically any React element works.

::: info
Registering an icon for a group name that doesn't exist is harmless - the icon is simply never rendered. This means you can safely register icons for groups you've only *mutated* rather than created, and it means adding icons before you've finalized the group name won't crash anything. But in the typical case, add the icon for the same group name you registered in `initialize_permissions` on the backend.
:::

This also works for core groups you've mutated - if you add a new permission to the existing `settings` group on the backend (see the next section), you usually *don't* want to also replace the core icon, but the capability exists if you really need to.

## Mutating Core Permission Groups

Here's where it gets interesting. Instead of creating a brand-new group for one or two permissions, you can **add your permission to an existing core group**. This is usually the right call when your permission is conceptually part of an existing surface area - for example, a permission that lets a subuser change their server's egg fits cleanly into the existing `settings` group on server permissions, rather than needing its own top-level group.

The builder exposes `mutate_*_permission_group` methods for this:

```rs
async fn initialize_permissions(
    &mut self,
    _state: State,
    builder: ExtensionPermissionsBuilder,
) -> ExtensionPermissionsBuilder {
    builder.mutate_server_permission_group("settings", |group| {
        group.add_permission("egg-changer", "Allows updating the egg of a server.");
    })
}
```

Your check then reads `permissions.has_server_permission("settings.egg-changer")?` - it looks exactly like a core permission to the rest of the codebase, because as far as the permission system is concerned, it is one.

**When to pick which:**

- **Add a new group** when you're introducing a whole area of functionality - a new page, a new resource type, something that would warrant its own section in the permission picker. Users scanning permissions should see "ah, this is all the X extension's stuff" at a glance.
- **Mutate an existing group** when you're adding one or two permissions that fit naturally into something that already exists. Server operators shouldn't have to learn a new top-level category just because you added one checkbox.

Most extensions end up doing at least some of the second pattern - the `settings` group in particular is a common target, since a lot of extensions add configuration surfaces that conceptually live under "settings".

::: warning
**You can also remove permissions from core groups.** The `ExtensionPermissionsBuilder` fields are `pub`, and `PermissionGroup.permissions` is a mutable `IndexMap`, which means nothing at the API level stops you from calling `.remove(...)` inside a `mutate_*` closure to strip permissions the core Panel defined.

**Don't.** Removing permissions the Panel defined will break core UI that depends on them, silently lock users out of functionality they're supposed to have, and if another extension is registered after yours, its behavior becomes load-order-dependent in ways that are genuinely hard to debug. The capability exists because the API is consistent, not because it's a supported workflow. If you think you need to remove a core permission, what you probably actually want is to *check* a different permission in your own code, or to introduce an admin-level toggle that your extension can gate on itself.

Mutating existing permissions (e.g. changing a description) is a similarly sharp tool - possible, but you're modifying UI that other parts of the Panel and other extensions see. Do it rarely and deliberately.
:::

## Checking Permissions in Routes

This is covered more fully in the [routing page](./routing.md#writing-a-route-handler), but for completeness: once a permission is registered, you check it at the top of your handler with `GetPermissionManager`:

```rs
pub async fn route(
    state: GetState,
    permissions: GetPermissionManager,
    mut server: GetServer,
) -> ApiResponseResult {
    permissions.has_server_permission("settings.egg-changer")?;

    // ... rest of the handler
}
```

The `?` bubbles a `403 Forbidden` with the permission node embedded in the message if the current user doesn't have it. Always put the check before any work that could leak data or side effects - treat it as the first line of the handler body.

## Checking Permissions on the Frontend

Backend checks are the security boundary - they're what actually stops a request from doing something the user shouldn't. But you almost always want the frontend to also know about permissions, so you can hide UI the user can't use rather than letting them click a button that just returns 403. For that, the Panel ships a `Can` component in two flavors:

```tsx
import { ServerCan, AdminCan } from '@/elements/Can.tsx';
```

Use `ServerCan` for server permissions (inside server routes / pages) and `AdminCan` for admin permissions. There's no `UserCan` component because user permissions are only relevant for API key scopes, not for UI.

The basic usage is to wrap any UI that requires a permission, passing the permission node as `action`:

```tsx
<ServerCan action='settings.egg-changer'>
  <TitleCard title='Change Egg' icon={<FontAwesomeIcon icon={faEgg} />}>
    {/* ... entire feature UI ... */}
  </TitleCard>
</ServerCan>
```

If the user has the permission, the children render. If they don't, nothing renders - the whole block is hidden. This is the "if you can't use it, you don't see it exists" pattern, and it's the right default for most feature-level UI.

### Props

| Prop | Type | Purpose |
| ---- | ---- | ------- |
| `action` | `string \| string[]` | The permission node(s) to check. A single string requires that one permission; an array requires all of them by default. |
| `matchAny` | `boolean` | When `action` is an array, require *any* of them instead of *all*. Defaults to `false`. |
| `renderOnCant` | `ReactNode \| null` | What to render when the user lacks permission. Defaults to nothing (the children are simply hidden). |
| `cantSave` | `boolean` | (`AdminCan` only) Render a disabled Save button with an explanatory tooltip when the user can't save. |
| `cantDelete` | `boolean` | (`AdminCan` only) Render a disabled Delete button with an explanatory tooltip when the user can't delete. |
| `children` | `ReactNode` | What to render when the user has permission. |

### Choosing between "hide" and "show disabled"

The default behavior (hide everything) is usually right for **whole features** - if a subuser can't use your egg-changer at all, showing them an empty card that's grayed out is just noise. Hide the card.

But for **inline actions inside a feature they *can* otherwise see**, a disabled button with a tooltip is often better UX. The user sees what they *would* be able to do, gets a clear signal they can't, and can ask their admin for the right permission if they need it. That's what `cantSave` and `cantDelete` are for on `AdminCan` - they render a stock disabled button with a "you don't have permission to save" tooltip:

```tsx
<AdminCan action='extensions.manage' cantSave>
  <Button onClick={doSave}>Save</Button>
</AdminCan>
```

For custom disabled states, pass your own `renderOnCant`:

```tsx
<ServerCan
  action='settings.my-feature'
  renderOnCant={<Tooltip label='Your admin has not granted you this permission.'><Button disabled>Apply</Button></Tooltip>}
>
  <Button onClick={doApply}>Apply</Button>
</ServerCan>
```

### Multiple permissions

Pass an array to require multiple permissions. By default **all** must be present:

```tsx
{/* User must have BOTH permissions */}
<ServerCan action={['settings.my-feature', 'settings.advanced']}>
  <AdvancedControls />
</ServerCan>
```

Add `matchAny` to require just one of them:

```tsx
{/* User must have EITHER permission */}
<ServerCan action={['files.read', 'files.write']} matchAny>
  <FileList />
</ServerCan>
```

### Imperative checks

If you need to branch on permissions in logic rather than in JSX - e.g. deciding whether to show a column in a table based on one field, or whether to include an action in a dropdown - you can call the hooks directly:

```tsx
import { useServerPermissions, useCan } from '@/plugins/usePermissions.ts';

function MyComponent() {
  const canEditMatrix = useServerPermissions('settings.egg-changer');
  const canEdit = useCan(canEditMatrix, false);

  const actions = [
    { label: 'View', onClick: doView },
    ...(canEdit ? [{ label: 'Edit', onClick: doEdit }] : []),
  ];

  return <Dropdown actions={actions} />;
}
```

Prefer the `<Can>` components for rendering decisions and the hooks only for cases where the JSX wrapper is awkward.

::: warning
Frontend permission checks are a UX layer, not a security layer. Never skip the backend check just because you wrapped the UI in `<ServerCan>` - a user can trivially hit your API with curl or by editing the frontend bundle. The `<Can>` components decide what's *visible*; `has_server_permission(...)?` on the backend decides what's *allowed*. Both are needed for correct behavior.
:::

## Design Guidelines

A few things to keep in mind when designing your permission surface:

- **Err on the side of more granular permissions, not fewer.** If your extension has both a read-only view and a mutation, that's two permissions (`read` and `update`), not one. Operators want to grant subusers the ability to *look* at things without also being able to change them.

- **Permission names should describe capabilities, not implementation details.** `egg-changer` is good because it tells the user what granting this permission *lets someone do*. A name like `post-switch-endpoint` would be bad because it describes the route, not the capability - if you ever restructure your routes, the permission name becomes a lie.

- **Descriptions are shown to humans configuring permissions.** Write them as a full sentence, starting with "Allows ..." or similar, describing what the permission lets someone do in user-facing terms. Avoid jargon that only makes sense if you've read the code.

- **Don't register permissions you never check.** A permission that doesn't gate anything just confuses operators who turn it on and wonder why nothing changes.

- **Once a permission is live, its name is effectively a public API.** Renaming it breaks every role configuration that referenced it, and existing subusers lose access to the functionality it guarded. If you need to rename one, plan for a migration path (register the new name, keep checking the old for a release or two, deprecate the old in a later version).


---

<!-- concepts/quick-actions.md -->

# Quick Actions

The quick actions palette is the Panel's command bar: the modal that opens on `Mod+Space` (or from the button above the sidebar) and lets a user type "restart", hit Enter, and be done. It aggregates everything the user can do from where they currently stand - power actions, navigation, page-specific operations, a server search - into one searchable list, and your extension can put its own entries in there.

There are three surfaces, and which one you want depends on how long your action should live:

| Surface | Registered where | Lives for |
| ------- | ---------------- | --------- |
| **Global actions** | `enterQuickActions` in `initialize()` | The whole session |
| **Page actions** | The `useQuickActions` hook in a component | As long as that component is mounted |
| **Modes** | The `useQuickActionModes` hook, or `enterQuickActions` for static ones | Activated by a prefix the user types |

::: tip
Nothing is injected into an action - `perform` and `isVisible` take no arguments and close over whatever they need. A global action registered in `initialize()` therefore has no React state available to it, so anything that depends on the current page, server or user belongs in a component and the `useQuickActions` hook.
:::

::: info
Quick actions arrived in Panel 1.2.0. On 1.1.x there is no palette and no `quickActions` registry, so an extension using this API won't build against an older Panel.
:::

## Registering a Global Action

Global actions go in your `initialize()` method, through `enterQuickActions`:

```ts
import { faBroom } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Extension, ExtensionContext } from 'shared';
import { getExtTranslations } from './translations.ts';

class MyExtension extends Extension {
  public initialize(ctx: ExtensionContext): void {
    ctx.extensionRegistry.enterQuickActions((quickActions) =>
      quickActions.addAction({
        id: 'dev.0x7d8.cleanup.purgeLogs',
        category: 'power',
        label: () => getExtTranslations().t('quickAction.purgeLogs', {}),
        keywords: ['clean', 'logs', 'purge'],
        icon: <FontAwesomeIcon icon={faBroom} />,
        scopes: ['server'],
        permission: 'files.delete',
        perform: () => purgeLogs(),
      }),
    );
  }
}
```

`addAction`, `addMode` and `addCategory` all return the registry, so you can chain them.

### Definition Fields

| Field | Type | What it does |
| ----- | ---- | ------------ |
| `id` | `string` | Identity of the action. Used as the palette's list key, so it must be unique - prefix it with your package identifier. |
| `category` | `string` | The id of the category this action groups under. A core id (see below) or one you registered. |
| `label` | `string \| (() => string)` | What the user reads. Pass a function for translated labels so it re-resolves when the language changes. |
| `description?` | `string \| (() => string)` | Secondary text, rendered dimmed and right-aligned on the row. Good for a value, a shortcut hint, or a target path. |
| `content?` | `ReactNode` | Rendered under the label, for what a string can't express - an avatar, a badge, a status dot. Core's server and user modes put an avatar and name here. |
| `path?` | `string` | The URL the action navigates to, for actions that are really links. Only the `/` mode reads it, showing it as the row's description and matching the typed term against it. |
| `keywords?` | `string[]` | Extra search terms. Matched with the same substring rule as the label, so `['mkdir']` makes "New Directory" findable by typing `mkdir`. |
| `icon?` | `ReactNode` | Any node, so `<FontAwesomeIcon icon={faBroom} />` for a FontAwesome glyph or an `<img>` for something custom. |
| `scopes?` | `('dashboard' \| 'server' \| 'admin')[]` | Where the action shows up. Omitted means all three. |
| `permission?` | `string \| string[]` | Server permission node(s) required. An array passes if *any* of them match. |
| `adminPermission?` | `string \| true` | `true` requires the user be an admin at all; a string requires that admin permission node. |
| `danger?` | `boolean` | Renders the row in red and highlights it red when selected. For destructive things - the core "Kill" and "Log out" actions use it. |
| `isVisible?` | `() => boolean` | Last-word visibility check, run on every palette render. Use it for state, not permissions. |
| `perform` | `() => void` | Runs when the user picks the action. |

The scope of an action is derived from the current URL, not from where you registered it: `/server/<id>/...` is `server`, `/admin/...` is `admin`, everything else is `dashboard`.

::: warning
`permission` is checked against the *current server's* permissions, and outside server scope there are none - so an action with `permission` set is silently hidden on dashboard and admin pages. If you want a permission-gated action that also appears outside a server, pair `permission` with `scopes: ['server']` and register a separate unpermissioned action for the other scopes.
:::

### Reaching Page State

There is no context object. `isVisible` and `perform` are plain closures, which means a global action registered in `initialize()` can only reach module-level things - your own helpers, an API call, `getQuickActionsStore()`. Everything React-shaped (the current server, the websocket, `navigate`, the logged-in user) comes from hooks, so an action that needs any of it has to be registered from a mounted component with `useQuickActions`.

Core works the same way, and where it registers an action tells you its lifetime: the power actions come from a component the *server router* mounts, so they exist exactly as long as a server is open and never need a scope check, while navigation and logout are built by the palette itself. Prefer that over a globally-registered action with an `isVisible` that inspects the URL:

```tsx
import { useNavigate } from 'react-router';
import { useQuickActions } from '@/plugins/useQuickActions.ts';
import { useServerStore } from '@/stores/server.ts';

export default function MyServerWidget() {
  const navigate = useNavigate();
  const server = useServerStore((state) => state.server);
  const serverState = useServerStore((state) => state.state);

  useQuickActions([
    {
      id: 'dev.0x7d8.cleanup.purgeLogs',
      category: 'power',
      label: () => getExtTranslations().t('quickAction.purgeLogs', {}),
      permission: 'files.delete',
      isVisible: () => serverState === 'offline',
      perform: () => purgeLogs(server.uuid),
    },
  ]);

  return /* ... */;
}
```

The palette closes itself *before* calling `perform`, so you never need to close it from an action - only mode items, which own their `onSelect` outright, have to do that themselves.

`isVisible` runs during the palette's render, on every keystroke. Keep it to synchronous reads of values your component already has, or a Zustand `.getState()`, and never call hooks or fire requests from it.

::: warning
Because the definitions array is re-read from the palette's render rather than yours, `label`, `isVisible` and `perform` must not contain hooks. Close over the values instead, the way the example above closes over `serverState`.
:::

## Categories

Every item belongs to a category, which is the labelled group it renders under. The Panel ships eight:

| Id | Group heading | Order | Contains |
| -- | ------------- | ----- | -------- |
| `math` | Math | 10 | The `=` calculator result |
| `page` | Page | 20 | Actions the current page registered |
| `pageNavigation` | Page Navigation | 25 | The tabs of the current page's sub-navigation, registered by the Panel. Stacked tab bars use `pageNavigation:<depth>`, ordered so the innermost leads |
| `power` | Power | 30 | Start / stop / restart / kill |
| `servers` | Servers | 40 | Server search results, from the `#` mode and the dashboard's no-prefix search |
| `users` | Users | 45 | User search results from the admin-only `@` mode |
| `navigation` | Navigation | 50 | Sidebar routes for the current scope |
| `account` | Account | 60 | Log out |

Reuse one of those ids when your action fits the group - a power-adjacent action belongs under Power, not under a category of its own. When it doesn't fit, register your own:

```ts
import { faDragon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

ctx.extensionRegistry.enterQuickActions((quickActions) =>
  quickActions.addCategory({
    id: 'dev.0x7d8.cleanup',
    label: () => getExtTranslations().t('quickAction.category', {}),
    icon: <FontAwesomeIcon icon={faDragon} />,
    order: 25,
  }),
);
```

`label` takes a plain string or a getter, same as on an action. `icon` is a `ReactNode` here too, though it's rendered as a group heading rather than a row.

Groups render by `order`, lowest first, with ties broken alphabetically on the resolved label. A group whose heading repeats the one directly above it drops the text and keeps only the icon, which is how a page's stacked tab bars read as one section - so two categories sharing a label render as one headed group followed by unnamed ones. Core categories hold the numbers in the table above, and a category without an `order` falls back to 100, which puts it after core and sorted by label among the other extension categories. The `order: 25` in the example above lands the group between Page and Power, so pick a number when you care where your actions sit relative to core's.

Extension categories are merged last, so registering one with a core id such as `page` or `power` overrides that group's label, icon and order for the whole palette. That's occasionally useful and more often a mistake.

An action pointing at a category nobody registered still renders: the raw category id becomes the group heading. If you see `dev.0x7d8.cleanup` as a heading, you forgot the `addCategory` call.

## Page-Scoped Actions

An action that only makes sense on one page shouldn't be registered globally with an `isVisible` that checks the URL. Use the `useQuickActions` hook instead, from a component that's mounted on that page:

```tsx
import { faFileCirclePlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useQuickActions } from '@/plugins/useQuickActions.ts';
import { CORE_QUICK_ACTION_CATEGORIES } from '@/lib/coreQuickActions.tsx';

export default function MyServerPage() {
  const [selected, setSelected] = useState<string[]>([]);

  useQuickActions([
    {
      id: 'dev.0x7d8.cleanup.purgeSelected',
      category: CORE_QUICK_ACTION_CATEGORIES.page,
      label: () => getExtTranslations().t('quickAction.purgeSelected', {}),
      icon: <FontAwesomeIcon icon={faFileCirclePlus} />,
      isVisible: () => selected.length > 0,
      perform: () => purge(selected),
    },
  ]);

  return /* ... */;
}
```

The hook takes the same `QuickActionDefinition[]` as `addAction`, plus an optional second argument to switch registration on and off:

```ts
useQuickActions(definitions, !loading);
```

Actions registered this way disappear when the component unmounts, and they go through exactly the same scope, permission and visibility filtering as global ones. The `page` category exists for them, and it's what the Panel's own file manager uses for its file and selection actions.

The array is re-read on every palette render rather than captured at mount, so closures over component state stay current: `selected` in the example above is always the live value, and you never have to re-register. The tradeoff is that `label`, `isVisible` and `perform` are called from the palette's render rather than yours, so they cannot contain hooks.

::: tip
Tabs are handled for you. Every `SubNavigation` puts its visible tabs into the palette under `pageNavigation`, so items you add through a page's `subNavigation` registry (see [Mounting UI](./mounting-ui.md)) are reachable from the palette without registering anything. Where two tab bars stack - a nest and one of its eggs - each bar gets its own group, innermost first, so the two "General" tabs stay apart. Only the first of those groups is named; the rest repeat its icon without the heading.
:::

::: info
Something has to render the component for the hook to fire. A page you registered with `addServerRoute` works, and so does a component you slotted into a core page - see [Mounting UI](./mounting-ui.md). If you want actions present on a page you don't own, register them globally with an `isVisible` check instead.
:::

## Modes

Modes turn the palette into something other than a list filter when the query starts with a given prefix. Core ships four:

| Prefix | Mode | Available |
| ------ | ---- | --------- |
| `=` | Evaluates a math expression and offers to copy the result | Everywhere |
| `#` | Searches servers and jumps to one, keeping the page you are on | Everywhere |
| `@` | Searches users and jumps to one | Admin scope, with `users.*` |
| `/` | Path-based navigation search over the sidebar routes and the current page's tabs, showing each URL alongside its name - a tab shows its path relative to its own tab bar, since the page's own URL is mostly uuids | Everywhere |

Two of those show what modes can do beyond a flat list. `#` **changes shape by scope**: outside admin it lists the servers you can access and jumps to the client area - from inside a server it keeps the page you are on, the way the sidebar's switcher does, and picking the server you are already on returns to its console - while in admin (with `servers.*`) it searches *every* server, renders each owner's avatar and username via `content`, and jumps to the admin view instead. `@` is **conditional** — it isn't registered at all outside admin, so its footer hint disappears and `@` falls through to ordinary label matching. A mode is just an entry in the array you return, so omitting it is all "conditional" means:

```ts
useQuickActionModes([
  mathMode,
  serversMode,
  ...(canSearchUsers ? [usersMode] : []),
]);
```

A mode's items are a plain array, built from the query the user has typed. That query lives in the palette's store, so a mode is written from a component with `useQuickActionModes`, the same way page actions are written with `useQuickActions`:

```tsx
import { faUser } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useQuickActionModes, useQuickActionTerm } from '@/plugins/useQuickActions.ts';
import { useQuickActionsStore } from '@/stores/quickActions.ts';

const PREFIX = '%';

export default function PlayerMode() {
  const setOpen = useQuickActionsStore((state) => state.setOpen);
  const term = useQuickActionTerm(PREFIX);
  const players = usePlayerSearch(term);

  useQuickActionModes([
    {
      id: 'dev.0x7d8.players',
      prefix: PREFIX,
      hint: () => getExtTranslations().t('quickAction.playerHint', {}),
      loading: players.loading,
      items: players.items.map((player) => ({
        key: `player:${player.uuid}`,
        category: 'dev.0x7d8.cleanup',
        label: player.name,
        description: player.uuid,
        icon: <FontAwesomeIcon icon={faUser} />,
        onSelect: () => {
          setOpen(false);
          kickPlayer(player.uuid);
        },
      })),
    },
  ]);

  return null;
}
```

`useQuickActionTerm(prefix)` gives you the query with your prefix stripped and trimmed, or `null` while the query doesn't start with it - so `null` means "my mode isn't active" and `''` means "active, nothing typed yet". Gate your fetching on it, the way core's `@` mode only requests servers while `term !== null`.

| Field | Type | What it does |
| ----- | ---- | ------------ |
| `id` | `string` | Identity of the mode |
| `prefix` | `string` | The string that activates it. Usually one punctuation character |
| `hint` | `string \| (() => string)` | Shown next to the prefix in the palette's footer bar, so users can discover the mode |
| `items?` | `QuickActionItem[]` | The rows your mode contributes |
| `map?` | `(item) => QuickActionItem \| null` | Filters and rewrites the *normal* rows while your mode is active. `null` drops one |
| `loading?` | `boolean` | Shows the palette's spinner while your mode is fetching |

A few behaviours shape how you write one:

- The palette picks the first mode whose prefix the query starts with, and core modes are registered first, so `=`, `#` and `/` are unavailable to you - as is `@` in admin scope. Prefixes core does not use include `%`, `~`, `&`, `:` and `!`.
- While a mode is active the label and keyword matching is bypassed, and whatever `items` holds is shown as-is. Filtering on the term is your job, which is what makes modes useful for computed and fetched results that no substring match would find.
- Actions and navigation entries survive only if your `map` returns them, so a mode with just `items` gives the user a single-purpose list. Implement `map` when you want to *narrow* the existing list instead of replacing it, the way core's `/` mode keeps only the two navigation categories and hangs each route's path off `description`.
- Build `items` for the active term only. The array is read on every palette render, so keep it cheap - do the fetching in a hook and map its results, don't compute anything heavy inline.

Items you build yourself use `QuickActionItem` rather than a definition, which is a slightly different shape: `key` (unique across the whole list), `category`, `label` and `description` as already-resolved strings rather than getters, plus optional `content`, `path`, `keywords`, `icon`, `danger`, and a required `onSelect`. The palette filters none of these for you, checking neither scope nor permissions, so verify whatever matters before you offer an item. It also doesn't close itself for mode items the way it does for actions, so call `setOpen(false)` from `onSelect` when the item should dismiss the palette.

### Static Modes

`quickActions.addMode(...)` from `initialize()` still works and takes the same shape, but a mode registered there has no way to read the query, so its `items` can only ever be a fixed list. Reach for it for a small constant menu behind a prefix, and use the hook for anything that reacts to what the user types.

::: info
Something has to render the component for `useQuickActionModes` to fire, exactly as with page actions - and a mode registered from a page-scoped component only exists while that page is open. For a mode that should work everywhere, mount the component through a global slot; see [Mounting UI](./mounting-ui.md).
:::

## Opening the Palette Yourself

The palette's open state is a Zustand store, so you can drive it from your own UI - a button in a card, a step in an onboarding flow:

```tsx
import { useQuickActionsStore } from '@/stores/quickActions.ts';

export default function MyCard() {
  const setOpen = useQuickActionsStore((state) => state.setOpen);

  return <Button onClick={() => setOpen(true)}>Search</Button>;
}
```

The store also holds `query`, which is what the user has typed - that's the value `useQuickActionTerm` reads. Outside React, `getQuickActionsStore()` from the same module gives you a synchronous snapshot with the same `setOpen`, `toggle` and `setQuery`. The built-in `Mod+Space` shortcut and the sidebar trigger go through that same store. If you want your own key binding for something, the adjacent `enterShortcuts` registry lets you register a shortcut with a default binding that users can rebind from their account settings.

## A Worked Example

Putting the pieces together - an extension that registers its own category, one server-scoped action gated on a permission, and one destructive dashboard action. Both are stateless, so they can live in `initialize()`; anything that needed the current server's state would move into a component with `useQuickActions`:

```ts
import { faBroom, faTrash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Extension, ExtensionContext } from 'shared';
import { getExtTranslations } from './translations.ts';

const CATEGORY = 'dev.0x7d8.cleanup';

class CleanupExtension extends Extension {
  public initialize(ctx: ExtensionContext): void {
    ctx.extensionRegistry.enterQuickActions((quickActions) =>
      quickActions
        .addCategory({
          id: CATEGORY,
          label: () => getExtTranslations().t('quickAction.category', {}),
          icon: <FontAwesomeIcon icon={faBroom} />,
        })
        .addAction({
          id: 'dev.0x7d8.cleanup.purgeLogs',
          category: CATEGORY,
          label: () => getExtTranslations().t('quickAction.purgeLogs', {}),
          keywords: ['clean', 'logs'],
          icon: <FontAwesomeIcon icon={faBroom} />,
          scopes: ['server'],
          permission: 'files.delete',
          perform: () => purgeLogs(),
        })
        .addAction({
          id: 'dev.0x7d8.cleanup.purgeAll',
          category: CATEGORY,
          label: () => getExtTranslations().t('quickAction.purgeAll', {}),
          icon: <FontAwesomeIcon icon={faTrash} />,
          danger: true,
          scopes: ['dashboard'],
          adminPermission: true,
          perform: () => purgeEverything(),
        }),
    );
  }
}

export default new CleanupExtension();
```

## Guidelines

- Prefix your ids with your package identifier. They're the palette's list keys, and a collision with core or another extension leaves one of the two actions unreachable.
- Pass a getter for `label` rather than a string, since the palette re-resolves labels on render: a getter follows a language switch and a literal doesn't. See [Translations](./translations.md).
- Add keywords for anything users would search by another name. The match is a plain case-insensitive substring over label and keywords, without fuzzy matching or stemming, so "mkdir" only finds "New Directory" if you put it there.
- Reserve `danger` for destructive actions, so that a red row keeps meaning "this one you can't undo".
- Confirm destructive things. `perform` fires straight off an Enter keypress, so if the action is irreversible, have `perform` open a modal instead of doing the work. Core's "Kill" and "Log out" actions do exactly that, each registered from a component that owns its own confirmation modal.
- Don't register the same thing twice. An action that's also a sidebar route is already in the palette under Navigation, and a tab you added to a page's sub-navigation is already there under Page Navigation.


---

<!-- concepts/routing.md -->

---
title: Routing
description: Register backend HTTP routes for your extension and wire the frontend to them.
---

# Routing

Okay so you have an extension, cool. But unless you want it to sit there looking pretty, you probably want the frontend to actually talk to the backend at some point. Maybe you want to expose a list of servers, let an admin update a setting, or just return a cheeky "hello world". This is where routing comes in, and Calagopus makes it pretty painless - you register your routes through an `ExtensionRouteBuilder` in your `lib.rs`, and then the Panel mounts them onto the main app for you. No plumbing, no middleware wiring, no authentication code to write. Nice.

Under the hood, routes are just [axum](https://docs.rs/axum) routes wrapped in [utoipa_axum](https://docs.rs/utoipa-axum) so that they automatically show up in the Panel's OpenAPI docs. If you've written axum code before, you already know 90% of this.

## The Router Types

The `ExtensionRouteBuilder` exposes seven different routers, each mounted at a different base path with different authentication and extractor behavior. You pick the one(s) you need, the rest just don't get registered.

| Builder method | Mount point | Auth | Typical use |
| -------------- | ----------- | ---- | ----------- |
| `add_global_router` | `/` | None | Public endpoints, webhooks, health checks |
| `add_auth_api_router` | `/api/auth` | None | Custom auth flows (OAuth callbacks, SSO) |
| `add_admin_api_router` | `/api/admin` | User session + Permission check | Admin-only endpoints, settings, statistics |
| `add_client_api_router` | `/api/client` | User session | User-scoped endpoints that aren't tied to a server |
| `add_client_server_api_router` | `/api/client/servers/{server}` | User session + server access check | The most common one - anything a user does to one of their servers |
| `add_remote_api_router` | `/api/remote` | Node token | Endpoints called by Wings nodes |
| `add_remote_server_api_router` | `/api/remote/servers/{server}` | Node token + server scope | Endpoints called by Wings about a specific server |

The important thing to understand is that **authentication and permission middleware is already applied by the parent router** for every method except `add_global_router` and `add_auth_api_router`. You don't write auth code, you just check specific permissions inside your handler (more on that further down).

All seven methods return `Self`, so you can chain as many as you like. Calling the same method twice is additive - the second call receives the router you built in the first, so you can split registration across files if your extension grows.

## Registering Routes

Routes are registered by implementing the `initialize_router` method on your `Extension` trait. Here's a minimal example that registers one admin endpoint and one server-scoped endpoint:

```rs
use shared::{
    State,
    extensions::{Extension, ExtensionRouteBuilder},
};

mod routes;

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize_router(
        &mut self,
        state: State,
        builder: ExtensionRouteBuilder,
    ) -> ExtensionRouteBuilder {
        builder
            .add_admin_api_router(|routes| {
                routes.nest(
                    "/extensions/dev.yourname.test",
                    routes::admin::router(&state),
                )
            })
            .add_client_server_api_router(|routes| {
                routes.nest("/my-feature", routes::server::router(&state))
            })
    }
}
```

A couple of things to notice. First, **always make sure your routes do not collide with other extensions or the panel itself**. The Panel doesn't do anything to prevent collisions, so if two extensions both register a route at `/api/client/servers/{server}/foo`, utoipa will panic on startup and the process exits. That's a pretty hard failure mode, so just be considerate and pick paths that are unlikely to clash. Calling your routes `/config` is really just asking for trouble, but `/extensions/dev.yourname.test/config` is perfectly reasonable.

How pretty those paths need to be is up to you and depends on who's going to call them. For an admin API that only your extension's own frontend talks to, a namespace like `/extensions/dev.yourname.test/settings` is fine - it's ugly, but it's guaranteed not to collide, and nobody's typing it by hand. For a client API that end users might call with their API key, a cleaner path like `/my-feature` makes for a much nicer public surface. Both are valid choices, pick the one that fits your use case.

Second, each builder method takes a closure that receives an `OpenApiRouter<State>` and returns one. You use standard axum router methods on it - `.nest(...)`, `.route(...)`, `.merge(...)`, whatever. There's no magic.

## The File-System Convention

Before we look at an actual route handler, a quick word on how to organize your files. This isn't enforced by the framework - you *could* put every route in `lib.rs` - but following the convention makes your extension way easier to navigate, and matches how the core Panel is laid out.

The idea is simple: **your file tree should mirror your URL tree**. For an example extension that registers routes at `/api/client/servers/{server}/my-feature/...` and `/api/admin/extensions/dev.yourname.test/...`, the backend would look like this:

```bash
backend/src/
  lib.rs # registers the routers, nests into routes::admin and routes::server
  routes/
    mod.rs # just `pub mod admin; pub mod server;`
    admin/
      mod.rs # nests settings, statistics
      settings.rs # GET and PUT on /settings
      statistics/
        mod.rs # nests total
        total.rs # GET on /statistics/total
    server/
      mod.rs # nests items
      items/
        mod.rs # nests /{item}, also has its own GET on /items
        _item_/
          mod.rs # nests /{variant}, also has its own GET on /items/{item}
          _variant_.rs # GET on /items/{item}/{variant}
```

The rules:

- Each directory has a `mod.rs` that nests its children.
- Leaf files contain the actual handlers for that URL segment.
- **A filename or folder wrapped in underscores like `_item_`** is a convention signalling "this segment is a path parameter" - it still has to be wired up as `{item}` in the parent's `.nest(...)` call, the underscores don't do anything automatically, they're just a readability cue so you know at a glance which files are parameterized.
- A `mod.rs` can also have its own handlers, not just nests. For example, `items/mod.rs` responds to `GET /items` *and* nests `/{item}` - no need for a separate `index.rs`.

::: info
If you're coming from Next.js or SvelteKit, you already know this pattern - it's the same file-based routing idea, just done manually through `mod.rs` wiring instead of auto-discovered by a bundler. Keeping the underscore convention for params means you can grep your codebase for `_server_` and find every route that touches a server ID.
:::

Here's what a typical `routes/admin/mod.rs` looks like - it's intentionally boring, just wiring:

```rs
use shared::State;
use utoipa_axum::router::OpenApiRouter;

mod settings;
mod statistics;

pub fn router(state: &State) -> OpenApiRouter<State> {
    OpenApiRouter::new()
        .nest("/settings", settings::router(state))
        .nest("/statistics", statistics::router(state))
        .with_state(state.clone())
}
```

Every `mod.rs` follows this exact shape - declare the child modules, `OpenApiRouter::new()`, nest each child under its URL segment, `.with_state(state.clone())`, done. Once you've written two of these you've written all of them.

## Writing a Route Handler

Okay now the interesting part. Here's the idiom we use for every leaf route file - one inner module per HTTP method, then a `router()` function at the bottom. Let's look at a `settings.rs` that exposes both a `GET` and a `PUT`:

```rs
use super::State;
use utoipa_axum::{router::OpenApiRouter, routes};

mod get {
    use serde::Serialize;
    use shared::{
        GetState,
        models::user::GetPermissionManager,
        response::{ApiResponse, ApiResponseResult},
    };
    use utoipa::ToSchema;

    #[derive(ToSchema, Serialize)]
    struct Response<'a> {
        #[schema(inline)]
        settings: &'a crate::settings::ExtensionSettingsData,
    }

    #[utoipa::path(get, path = "/", responses(
        (status = OK, body = inline(Response)),
    ))]
    pub async fn route(state: GetState, permissions: GetPermissionManager) -> ApiResponseResult {
        permissions.has_admin_permission("settings.read")?;

        let settings = state.settings.get().await?;
        let extension_settings: &crate::settings::ExtensionSettingsData =
            settings.find_extension_settings()?;

        ApiResponse::new_serialized(Response {
            settings: extension_settings,
        })
        .ok()
    }
}

mod put {
    // ... see "Request Bodies and Validation" below
}

pub fn router(state: &State) -> OpenApiRouter<State> {
    OpenApiRouter::new()
        .routes(routes!(get::route))
        .routes(routes!(put::route))
        .with_state(state.clone())
}
```

Let's dissect this, because there's a lot going on in a small amount of code.

**The `mod get { ... }` wrapper.** Each HTTP method gets its own inner module. This isn't arbitrary - it lets you name the handler function `route` (instead of `get_settings`, `put_settings`, etc.) and lets each method define its own `Response`, `Payload`, and imports without collisions. When you have four methods on one endpoint, the consistency really pays off.

**The `#[utoipa::path(...)]` attribute.** This is what makes your route show up in the auto-generated OpenAPI spec. The first argument is the HTTP method, `path = "/"` means "whatever the parent nested me under", and `responses(...)` tells utoipa what shape to document. `body = inline(Response)` inlines the response schema rather than referencing it by name - this is what you'll want almost always for per-route types.

**The extractors.** `GetState` gives you the Panel's `State`, `GetPermissionManager` gives you a permission checker for the current session. These are shared aliases that do the right thing depending on which router you're in - inside an admin router, `GetPermissionManager` checks admin permissions; inside a client-server router, it checks user-plus-server permissions. You never manually parse an auth token.

**The permission check.** `permissions.has_admin_permission("settings.read")?` returns a `DisplayError` (which `?` bubbles up as a `403 Forbidden`) if the current admin doesn't have that permission node. Always do this as the first line of the handler so you don't accidentally leak data before the check.

**The return type.** `ApiResponseResult` is an alias for `Result<ApiResponse, ApiResponse>` - yes, both sides are `ApiResponse`, which is a bit unusual but intentional. See [Response Types and Errors](#response-types-and-errors) below.

**The `routes!` macro.** At the bottom, `.routes(routes!(get::route))` is the utoipa-axum macro that both registers the handler with axum *and* adds its schema to the OpenAPI spec. You call it once per method. If you have both GET and PUT on the same path you chain `.routes(routes!(get::route)).routes(routes!(put::route))`.

## Path Parameters

Path parameters are extracted with `axum::extract::Path<T>`. The file-system convention puts them in `_name_.rs` (or `_name_/mod.rs`) files, but the actual parameter name is defined in the `.nest(...)` call of the parent. Here's an `_item_/_variant_.rs` file that responds to `GET /api/client/servers/{server}/my-feature/items/{item}/{variant}`:

```rs
use super::State;
use utoipa_axum::{router::OpenApiRouter, routes};

mod get {
    use axum::extract::Path;
    use serde::Serialize;
    use shared::{
        GetState,
        models::user::GetPermissionManager,
        response::{ApiResponse, ApiResponseResult},
    };
    use utoipa::ToSchema;

    #[derive(ToSchema, Serialize)]
    struct Response {
        details: String,
    }

    #[utoipa::path(get, path = "/", responses(
        (status = OK, body = inline(Response)),
    ), params(
        (
            "server" = uuid::Uuid,
            description = "The server ID",
            example = "123e4567-e89b-12d3-a456-426614174000",
        ),
        (
            "item" = String,
            description = "The item identifier",
            example = "example-item",
        ),
        (
            "variant" = String,
            description = "The variant identifier",
            example = "v1",
        ),
    ))]
    pub async fn route(
        state: GetState,
        permissions: GetPermissionManager,
        Path((_server, item, variant)): Path<(String, String, String)>,
    ) -> ApiResponseResult {
        permissions.has_server_permission("files.read")?;

        ApiResponse::new_serialized(Response {
            details: format!("{item}/{variant}"),
        })
        .ok()
    }
}

pub fn router(state: &State) -> OpenApiRouter<State> {
    OpenApiRouter::new()
        .routes(routes!(get::route))
        .with_state(state.clone())
}
```

And the parent `_item_/mod.rs` that wires `{variant}` into the URL:

```rs
pub fn router(state: &State) -> OpenApiRouter<State> {
    OpenApiRouter::new()
        .routes(routes!(get::route))
        .nest("/{variant}", _variant_::router(state))
        .with_state(state.clone())
}
```

A few things worth pointing out:

- **`Path<(String, String, String)>` extracts params in URL order**, from outermost to innermost. On a client-server route, `{server}` always comes first because it's part of the parent mount point - even though you didn't register it yourself. If you don't need it, destructure it as `_server` like above.
- **`params(...)` in `#[utoipa::path]` documents every param**, including inherited ones like `server`. The Panel uses this to generate a clickable API reference, so fill in `description` and `example` - your future self will thank you when debugging.
- **Watch out for Rust keywords.** If your parameter has a name like `type`, `ref`, `move`, or similar, you'll need Rust's raw identifier syntax to use it as a variable: `r#type`. This is a Rust thing, not a Calagopus thing, but it trips people up.

## Request Bodies and Validation

For routes that accept a request body, define a `Payload` struct with `serde::Deserialize` and (optionally) `garde::Validate` for validation. Here's a `PUT` handler that accepts a few optional fields:

```rs
mod put {
    use axum::http::StatusCode;
    use garde::Validate;
    use serde::{Deserialize, Serialize};
    use shared::{
        ApiError, GetState,
        models::user::GetPermissionManager,
        response::{ApiResponse, ApiResponseResult},
    };
    use utoipa::ToSchema;

    #[derive(ToSchema, Validate, Deserialize)]
    pub struct Payload {
        #[garde(url, length(chars, min = 1, max = 255))]
        #[schema(format = "url", min_length = 1, max_length = 255)]
        api_url: Option<compact_str::CompactString>,

        #[garde(skip)]
        enable_feature: Option<bool>,
    }

    #[derive(ToSchema, Serialize)]
    struct Response {}

    #[utoipa::path(put, path = "/", responses(
        (status = OK, body = inline(Response)),
    ), request_body = inline(Payload))]
    pub async fn route(
        state: GetState,
        permissions: GetPermissionManager,
        shared::Payload(data): shared::Payload<Payload>,
    ) -> ApiResponseResult {
        if let Err(errors) = shared::utils::validate_data(&data) {
            return ApiResponse::new_serialized(ApiError::new_strings_value(errors))
                .with_status(StatusCode::BAD_REQUEST)
                .ok();
        }

        permissions.has_admin_permission("extensions.manage")?;

        // ... apply the update, save, etc.
        ApiResponse::new_serialized(Response {}).ok()
    }
}
```

Key points:

- **`#[garde(...)]` attributes describe validation rules**; `#[schema(...)]` attributes describe the OpenAPI schema. Keep them in sync - if you say `min_length = 1` in the schema, also enforce it with garde, otherwise the API docs lie.
- **`shared::Payload<T>` is the recommended extractor for JSON bodies** - it handles content-type negotiation and gives you better error messages than plain `axum::Json<T>`. Both work, but prefer `shared::Payload` unless you have a specific reason not to.
- **Validation doesn't run automatically.** Call `shared::utils::validate_data(&data)` yourself and return a `400 Bad Request` with `ApiError::new_strings_value(errors)` on failure. Do this *before* the permission check if you want 400s to take precedence over 403s (which is usually what you want, since validation errors are more informative).

::: warning
Do not put `Option<Option<T>>` fields in your payload without understanding what you're doing. That pattern is used with `#[serde(with = "::serde_with::rust::double_option")]` to distinguish between "field absent" (leave unchanged) and "field explicitly set to null" (clear it). It's a nice pattern for PATCH-style updates, but if you just want "update if provided", a plain `Option<T>` is what you want.
:::

## Response Types and Errors

Every handler returns `ApiResponseResult`, which is `Result<ApiResponse, ApiResponse>`. Yes, both sides are the same type - an `ApiResponse` is just a status code, headers, and a body, and errors happen to use the same shape as successes. The distinction between `Ok(...)` and `Err(...)` only matters for the `?` operator.

The success path uses `ApiResponse::new_serialized(...).ok()`:

```rs
ApiResponse::new_serialized(Response { count: 42 }).ok()
```

`new_serialized` handles content negotiation via the `Accept` header - the same handler can respond with JSON, MessagePack, or XML depending on what the client asks for. You don't have to do anything to opt in, it just works.

For non-200 responses, chain `.with_status(StatusCode::...)` before `.ok()`:

```rs
ApiResponse::new_serialized(ApiError::new_strings_value(errors))
    .with_status(StatusCode::BAD_REQUEST)
    .ok()
```

### Bubbling errors with `?`

Anything that converts to `anyhow::Error` - which includes `sqlx::Error`, `reqwest::Error`, and most Panel errors - can be bubbled up with `?`. The conversion is smart: `DatabaseError::Validation` becomes a `400 Bad Request` with the field errors; `DatabaseError::InvalidRelation` becomes a `400`; anything else becomes a `500 Internal Server Error` (and gets logged and sent to Sentry). So for the common case of "something failed, I don't want to handle it specifically", you just `?` and move on:

```rs
let settings = state.settings.get().await?; // 500 if the DB is down, handled for you
```

### Custom-status errors with `DisplayError`

For cases where you want to return a specific error message with a specific status code - and especially when the error happens deep in a call stack that doesn't have direct access to `ApiResponse` - use `shared::response::DisplayError`. It implements `std::error::Error`, so you can return it through any function whose error type is `anyhow::Error`, and the `From` impl on `ApiResponse` will automatically pick up the status and message:

```rs
use shared::response::DisplayError;

fn find_item(id: &str) -> Result<Item, anyhow::Error> {
    lookup(id).ok_or_else(|| {
        DisplayError::new("item not found")
            .with_status(StatusCode::NOT_FOUND)
            .into()
    })
}

// in your handler:
let item = find_item(&id)?; // bubbles as 404 "item not found"
```

This is the idiomatic way to signal "this specific thing went wrong, here's the status I want" from somewhere that isn't the handler itself. A helper function three or four calls deep has no access to `ApiResponse`, and threading it back up through every layer would be miserable - but every one of those layers already returns `anyhow::Error`, which means `DisplayError` slots in cleanly. You specify the HTTP response at the point where the error *happens*, which is usually where you have the best information about what went wrong, and the handler doesn't need to care.

`DisplayError::new` defaults to `400 Bad Request` if you don't call `.with_status(...)`.

### Shortcut for simple error responses

For the common "return a 400 with a message" case directly inside a handler, there's a shortcut:

```rs
return Err(ApiResponse::error("username is already taken"));
```

`ApiResponse::error` is a convenience constructor that wraps the string in the standard error shape and sets the status to `400 Bad Request`. You can still chain `.with_status(...)` to change the code.

## Activity Logging

Mutating routes should usually log an activity entry so it's auditable. There are three loggers available as extractors:

- `GetAdminActivityLogger` from `shared::models::admin_activity` - for admin routes
- `GetServerActivityLogger` from `shared::models::server` - for client-server routes
- `GetUserActivityLogger` from `shared::models::user_activity` - for client routes that aren't server-scoped

All three work the same way - extract one in your handler, call `.log(event_name, json_payload).await`, and you're done. Event-name conventions and the structure of the JSON payload are covered in the [Activity Logging](./activity-logging.md) page.

## Hiding Routes from API Docs

Every pattern above uses `OpenApiRouter` + `routes!(...)`, which automatically generates OpenAPI documentation. Sometimes you don't want that - maybe you're exposing a public callback URL that shouldn't show up in the admin-facing API reference, or an internal endpoint that's only called by your frontend and doesn't need to be part of the public surface.

To opt a route out of OpenAPI docs, register it with **plain axum routing** instead of `routes!`:

```rs
.add_global_router(|routes| {
    routes.route(
        "/my-extension/callback",
        axum::routing::get(callback::route),
    )
})
```

The route still works exactly the same - it gets mounted, it receives requests, extractors work normally. It just doesn't get a `#[utoipa::path]` attribute and therefore doesn't appear in the generated spec. You can mix both styles on the same router: use `.routes(routes!(...))` for the ones you want documented and `.route(...)` for the ones you don't.

::: info
This works on any of the seven builder methods, not just `add_global_router`. If you want one admin endpoint to be undocumented, you can use `.route(...)` inside an `add_admin_api_router` call.
:::

## Calling Your Routes from the Frontend

Once your routes are registered, your frontend code can call them with the axios instance that's already set up for you. The URL depends on which router you registered under:

| Router | URL shape from frontend |
| ------ | ----------------------- |
| `add_admin_api_router` | `/api/admin/<whatever path you nested under>` |
| `add_client_api_router` | `/api/client/<whatever path you nested under>` |
| `add_client_server_api_router` | `/api/client/servers/${uuid}/<whatever path you nested under>` |
| `add_global_router` | `/<whatever path you nested under>` |

```ts
import { axiosInstance } from '@/api/axios';

export default async (uuid: string, item: string) => {
  const { data } = await axiosInstance.get(
    `/api/client/servers/${uuid}/my-feature/items/${item}`,
  );
  return data;
};
```

`axiosInstance` does no key conversion itself - responses arrive with the exact `snake_case` keys your backend sent. To work with idiomatic `camelCase` on the frontend, define a Zod schema for the response and run it through `parseFromApi` from `@/lib/api-transform.ts` (and `serializeForApi` for request bodies), which remaps and validates the data in one step.

For a full guide on structuring frontend API calls, see [Frontend API Calls](./frontend-api.md).


---

<!-- concepts/settings.md -->

---
title: Settings
description: Store your extension configuration with the Panel settings system instead of hardcoding values.
---

# Settings

Let's say you're making the best extension in the universe. Impossible? Maybe. But you're facing a BIG issue - you need an API key to make it work. So where do you put it? Hardcoding is out, environment variables mean the operator has to restart the Panel to change them, and storing a config file next to your binary is a nightmare to keep in sync across deployments. What you actually want is for the operator to open the admin panel, paste the key into a text field, click Save, and have it Just Work.

That's what settings are for. The settings API lets you declare what data your extension needs to persist, the Panel stores it in its database, and your handlers can read or update it like any other Rust struct.

The whole thing boils down to three steps: define a struct, tell the Panel how to turn it into rows and back, then point the Panel at the deserializer so it knows to use your types. This page walks through all three, and a few patterns that will save you pain along the way.

## Defining Your Settings Struct

First, create a `settings.rs` file in your extension's `src/` directory. Inside it, define a struct holding every piece of data your extension wants to persist:

```rs
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(ToSchema, Serialize, Deserialize)]
pub struct ExtensionSettingsData {
    pub api_key: compact_str::CompactString,
    pub collect_secret_government_telemetry: bool,
}
```

The only hard requirements are `serde::Serialize` and `serde::Deserialize` (since Rust needs to know how to read and write each field) and that the struct and every field are `Send + Sync`. `ToSchema` from utoipa is also standard - if you're ever going to expose settings through a route, utoipa needs it to generate the OpenAPI schema.

## Serializing and Deserializing

Now the interesting part. The Panel stores extension settings in a key-value shape in the database - one row per (extension, key) pair, with the value as a string. That's the storage format you're mapping your struct onto. You implement two traits that describe the mapping: `SettingsSerializeExt` to turn your struct into key-value pairs, and `SettingsDeserializeExt` to rebuild your struct from those pairs.

You have two options for how the keys line up with your struct's fields: **one key per field**, or **one key for the whole struct**. Both work; pick based on what the data looks like.

### Pattern 1: One Key Per Field (`write_raw_setting`)

Use this when your fields are simple scalars (strings, numbers, booleans) and you want the option to encrypt, transform, or otherwise treat each field differently. Each `write_raw_setting` call writes one key with a string value; `take_raw_setting` reads one back:

```rs
use compact_str::ToCompactString;
use serde::{Deserialize, Serialize};
use shared::extensions::settings::{
    ExtensionSettings, SettingsDeserializeExt, SettingsDeserializer, SettingsSerializeExt,
    SettingsSerializer,
};
use utoipa::ToSchema;

#[derive(ToSchema, Serialize, Deserialize)]
pub struct ExtensionSettingsData {
    pub api_key: compact_str::CompactString,
    pub collect_secret_government_telemetry: bool,
}

#[async_trait::async_trait]
impl SettingsSerializeExt for ExtensionSettingsData {
    async fn serialize(
        &self,
        serializer: SettingsSerializer,
    ) -> Result<SettingsSerializer, anyhow::Error> {
        let database = serializer.database.clone();

        Ok(serializer
            .write_raw_setting(
                "api_key",
                base32::encode(
                    base32::Alphabet::Z,
                    database.encrypt(self.api_key.clone()).await?.as_slice(),
                ),
            )
            .write_raw_setting(
                "collect_secret_government_telemetry",
                self.collect_secret_government_telemetry.to_compact_string(),
            ))
    }
}

pub struct ExtensionSettingsDataDeserializer;

#[async_trait::async_trait]
impl SettingsDeserializeExt for ExtensionSettingsDataDeserializer {
    async fn deserialize_boxed(
        &self,
        mut deserializer: SettingsDeserializer<'_>,
    ) -> Result<ExtensionSettings, anyhow::Error> {
        Ok(Box::new(ExtensionSettingsData {
            api_key: match deserializer.take_raw_setting("api_key") {
                Some(encoded) => {
                    let decoded = base32::decode(base32::Alphabet::Z, &encoded)
                        .ok_or_else(|| anyhow::anyhow!("Failed to decode API key from base32"))?;
                    deserializer.database.decrypt(decoded).await?
                }
                None => "".into(),
            },
            collect_secret_government_telemetry: deserializer
                .take_raw_setting("collect_secret_government_telemetry")
                .and_then(|s| s.parse().ok())
                .unwrap_or(false),
        }))
    }
}
```

There's a lot here but it's all the same two ideas. On the serialize side, `write_raw_setting(key, value)` tells the Panel to save that key as that value, chained once per field. On the deserialize side, `take_raw_setting(key)` pulls it back as an `Option<String>` - you decide what to do if it's missing (in the example above, fall back to empty string or `false`).

The example does a couple of extra things worth calling out:

- **Encryption** - the `api_key` is encrypted before being written and decrypted on read, using `serializer.database.encrypt(...)` / `.decrypt(...)`. The Panel provides this helper specifically for settings that are secrets. Wrap it in `base32::encode` / `decode` because `write_raw_setting` takes a `String`, not bytes.
- **Graceful defaults** - every field falls back to a default value if the setting doesn't exist in the database. Missing keys happen on first startup before anyone's set anything, on fresh installs, and any time you add a new field to an existing extension without writing a migration. Never assume a key will be there.

### Pattern 2: One Key for the Whole Struct (`write_serde_setting`)

Use this when your data is more complex than scalar fields - a `Vec<T>`, a nested struct, a `HashMap`, anything that doesn't round-trip cleanly as a single string. Instead of mapping each field to its own key, you treat the whole struct (or a sub-field of it) as one serde-serializable blob under a single key:

```rs
use serde::{Deserialize, Serialize};
use shared::extensions::settings::{
    ExtensionSettings, SettingsDeserializeExt, SettingsDeserializer, SettingsSerializeExt,
    SettingsSerializer,
};
use utoipa::ToSchema;

#[derive(ToSchema, Serialize, Deserialize)]
pub struct ItemGroup {
    pub name: compact_str::CompactString,
    pub items: Vec<uuid::Uuid>,
    pub enabled: bool,
}

#[derive(ToSchema, Serialize, Deserialize)]
pub struct ExtensionSettingsData {
    pub item_groups: Vec<ItemGroup>,
}

#[async_trait::async_trait]
impl SettingsSerializeExt for ExtensionSettingsData {
    async fn serialize(
        &self,
        serializer: SettingsSerializer,
    ) -> Result<SettingsSerializer, anyhow::Error> {
        Ok(serializer.write_serde_setting("item_groups", &self.item_groups)?)
    }
}

pub struct ExtensionSettingsDataDeserializer;

#[async_trait::async_trait]
impl SettingsDeserializeExt for ExtensionSettingsDataDeserializer {
    async fn deserialize_boxed(
        &self,
        deserializer: SettingsDeserializer<'_>,
    ) -> Result<ExtensionSettings, anyhow::Error> {
        Ok(Box::new(ExtensionSettingsData {
            item_groups: deserializer
                .read_serde_setting("item_groups")
                .unwrap_or_else(|_| Vec::new()),
        }))
    }
}
```

Much shorter. `write_serde_setting(key, &value)?` does the serde dance internally (the value ends up as JSON in the database) and `read_serde_setting(key)` reads it back into any `Deserialize` type. If the key is missing or the stored value fails to deserialize, you get an `Err`, which the example above handles by falling back to an empty `Vec`.

### Mixing Both

You can mix patterns in one `serialize`/`deserialize` implementation - use `write_raw_setting` for scalar fields that benefit from per-field treatment (encryption, validation at read time) and `write_serde_setting` for fields that are inherently complex. The key space is shared, so just don't use the same key twice.

### Choosing Between Them

- **`write_raw_setting`** when you need per-field control - encryption, custom encoding, stricter fallback logic, or you specifically want each setting inspectable as its own row in the database.
- **`write_serde_setting`** when the field is a collection or struct that doesn't flatten nicely to a single string. Saves a lot of boilerplate.

For a settings struct with a mix of scalar secrets and complex configuration, using both is perfectly normal.

## Wiring It Up

You've got a struct and ser/deser logic. Now tell the Panel to use them. Over in your `lib.rs`, implement the `settings_deserializer` method on your `Extension` trait:

```rs
use shared::{State, extensions::Extension};
use std::sync::Arc;

mod settings;

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn settings_deserializer(
        &self,
        _state: State,
    ) -> shared::extensions::settings::ExtensionSettingsDeserializer {
        Arc::new(settings::ExtensionSettingsDataDeserializer)
    }
}
```

That's it for wiring. Return an `Arc` of your deserializer and the Panel will route every "settings for this extension" read through it.

## Reading Settings

Once wired up, you access your settings through `state.settings`. The read path looks like this:

```rs
async fn load_api_key(state: &State) -> Result<String, anyhow::Error> {
    let settings = state.settings.get().await?;
    let ext_settings: &settings::ExtensionSettingsData =
        settings.find_extension_settings()?;

    Ok(ext_settings.api_key.to_string())
}
```

Two calls. `state.settings.get().await?` gives you a snapshot of the current settings store. `.find_extension_settings::<T>()?` finds your extension's slice of that snapshot and downcasts it to your struct type. From there it's just a reference - read fields as normal.

The type parameter on `find_extension_settings` is usually inferred from the annotation on the left-hand side (`let ext_settings: &ExtensionSettingsData = ...`), but you can also call it as `.find_extension_settings::<ExtensionSettingsData>()?` if the surrounding code doesn't disambiguate.

::: info
**How the settings store actually works.** Under the hood, `state.settings` is backed by two `RwLock`s holding the same data, with an "active" pointer that switches between them on every mutation. Reads always follow the active pointer; writes mutate the inactive one, then flip the pointer.

The upshot: **reads usually don't block on ongoing writes**. A long `get_mut` holding the write lock isn't stalling every request in your Panel - readers keep going against the active buffer the whole time. Writes themselves are serialized (one writer at a time), since writers need exclusive access to the inactive buffer.

Don't rely on "reads never block" as an absolute, though. There's a brief window between pointer swap and lock acquisition on the newly-inactive buffer where a reader can be held up. It's usually microseconds, but if you're writing latency-sensitive code you should assume the worst case is "a read might block briefly."
:::

## Writing Settings

The write path mirrors reads but goes through `get_mut` and ends with an explicit `save`:

```rs
async fn update_settings(state: &State) -> Result<(), anyhow::Error> {
    let mut settings = state.settings.get_mut().await?;
    let ext_settings: &mut settings::ExtensionSettingsData =
        settings.find_mut_extension_settings()?;

    ext_settings.api_key = "my_secret_api_key".into();
    ext_settings.collect_secret_government_telemetry = true;

    settings.save().await?;

    Ok(())
}
```

The important part is **`settings.save().await?` at the end** - without it, your changes stay in memory and get discarded when the guard drops. `save` is what serializes everything and persists it to the database. Forgetting it is the single most common settings bug, so develop the reflex.

Writes are serialized - only one writer can hold `get_mut` at a time, so don't hold the mutable guard longer than you need to. Grab it, mutate, save, drop. Readers won't be blocked by your write in the common case (see the callout above), but other writers will wait, and holding the guard across unrelated async work is still asking for trouble.

::: warning
The `save()` call serializes **every** extension's settings, not just yours. That's how the Panel keeps everything consistent, but it does mean calling `save` is heavier than a single-extension write. Batch related changes into one `get_mut` + `save` block rather than saving after every field change.
:::

## Reading Settings From a Route

The most common place you'll actually do this is inside a route handler. The routing page covered this briefly; here it is in full context:

```rs
#[utoipa::path(get, path = "/", responses(
    (status = OK, body = inline(Response)),
))]
pub async fn route(state: GetState, permissions: GetPermissionManager) -> ApiResponseResult {
    permissions.has_admin_permission("settings.read")?;

    let settings = state.settings.get().await?;
    let ext_settings: &crate::settings::ExtensionSettingsData =
        settings.find_extension_settings()?;

    ApiResponse::new_serialized(Response {
        api_key_present: !ext_settings.api_key.is_empty(),
    })
    .ok()
}
```

For update routes (typically `PUT` on the same path), follow the patterns from [Routing](./routing.md#request-bodies-and-validation) - accept a payload, validate it, permission-check, then do the `get_mut` / mutate / `save` dance inside the handler.

## Building the Admin Form

The Panel doesn't automatically build a settings UI from your struct - it has no idea whether `collect_secret_government_telemetry` should be a checkbox or a dropdown or a free-text field, and it can't infer validation rules from types. That's your job: ship a settings page as your `cardConfigurationPage` (see [Mounting UI](./mounting-ui.md)) that calls your own GET/PUT routes to read and update the data.

The pattern every Calagopus form follows is: **`@mantine/form` for state management + Zod for validation + custom inputs from `@/elements/input/`.** It's a stable stack that composes well, and once you've written one form the rest are mostly copy-and-tweak.

### The Schema

Start with a Zod schema for your settings data. This is the source of truth for both the form's shape and its validation rules. Drop it in `src/lib/schemas.ts`:

```ts
import { z } from 'zod';

export const extensionSettingsSchema = z.object({
  apiKey: z.string().min(1).max(255),
  collectSecretGovernmentTelemetry: z.boolean(),
});
```

Note the **camelCase field names** - this is the frontend, and the same schema is what you hand to `parseFromApi` / `serializeForApi` in your API files, which map the backend's snake_case keys to these camelCase names (and back) and validate the data. See [Frontend API Calls](./frontend-api.md) for the transformation rules.

### The Form Component

Here's the canonical shape of a configuration page. It loads settings on mount, renders form fields bound to the schema, and saves on submit:

```tsx
import { Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { zod4Resolver } from 'mantine-form-zod-resolver';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { httpErrorToHuman } from '@/api/axios.ts';
import Button from '@/elements/Button.tsx';
import Switch from '@/elements/input/Switch.tsx';
import TextInput from '@/elements/input/TextInput.tsx';
import TitleCard from '@/elements/TitleCard.tsx';
import { useToast } from '@/providers/ToastProvider.tsx';
import getSettings from './api/settings/getSettings.ts';
import updateSettings from './api/settings/updateSettings.ts';
import { extensionSettingsSchema } from './lib/schemas.ts';

export default function ConfigurationPage() {
  const { addToast } = useToast();
  const [loading, setLoading] = useState(false);

  const form = useForm<z.infer<typeof extensionSettingsSchema>>({
    initialValues: {
      apiKey: '',
      collectSecretGovernmentTelemetry: false,
    },
    validateInputOnBlur: true,
    validate: zod4Resolver(extensionSettingsSchema),
  });

  useEffect(() => {
    getSettings()
      .then((settings) => form.setValues(settings))
      .catch((err) => addToast(httpErrorToHuman(err), 'error'));
  }, []);

  const doSave = () => {
    setLoading(true);

    updateSettings(form.values)
      .then(() => addToast('Settings saved.', 'success'))
      .catch((err) => addToast(httpErrorToHuman(err), 'error'))
      .finally(() => setLoading(false));
  };

  return (
    <TitleCard title='Extension Settings'>
      <form onSubmit={form.onSubmit(doSave)}>
        <Stack>
          <TextInput
            label='API Key'
            placeholder='sk_...'
            {...form.getInputProps('apiKey')}
          />

          <Switch
            label='Collect secret government telemetry'
            description='Please do not enable this unless you know what you are doing.'
            {...form.getInputProps('collectSecretGovernmentTelemetry', { type: 'checkbox' })}
          />

          <Button type='submit' loading={loading} disabled={!form.isValid()} className='w-fit!'>
            Save
          </Button>
        </Stack>
      </form>
    </TitleCard>
  );
}
```

Let's unpack the moving parts.

**`useForm<z.infer<typeof schema>>({ ... })`** creates the form object. The type parameter gives you IntelliSense on every field name and value type, which becomes massively useful once your settings have more than a handful of fields. The config object needs three things: `initialValues` (placeholder data before your GET response arrives - use "empty" values matching the schema shape), `validateInputOnBlur: true` (run validation when a field loses focus, rather than only on submit), and `validate: zod4Resolver(schema)` (wire the Zod schema in as the actual validator).

**`form.getInputProps(path, options?)`** spreads the right set of props onto a field - `value`, `onChange`, `error`, and so on. For checkbox-like inputs (`Switch`, checkboxes) pass `{ type: 'checkbox' }` so the resolver binds `checked` instead of `value`. For nested paths, use dot notation: `form.getInputProps('eggGroups.0.name')` works fine, and is type-safe thanks to the Zod-derived form type.

**`form.onSubmit(callback)`** wraps a submit handler so it only fires if the form is valid. Validation errors get attached to fields automatically and render as red text under each input.

**`form.isValid()`** is safe to call during render - it's a synchronous boolean reflecting whether current values pass the schema. Good for disabling the submit button when there are outstanding errors, as shown above.

**The save pattern** is the standard three-callback shape from [Frontend API Calls](./frontend-api.md#handling-errors): `.then` success toast → `.catch` error toast → `.finally` loading reset. `httpErrorToHuman` unpacks whatever the backend returned into a user-friendly string.

### Custom Inputs

The inputs under `@/elements/input/` (`TextInput`, `Switch`, `MultiSelect`, `PasswordInput`, `TextArea`, etc.) are styled wrappers around their Mantine equivalents. Use these rather than raw `@mantine/core` inputs where possible.

For inputs that don't have a prebuilt wrapper, or when you need something specialized (a drag-and-drop ordered list, a code editor, a file picker), build your own component that accepts `value` / `onChange` / `error` props and use `getInputProps` the same way. Mantine's form API doesn't care what component you're rendering, only that it honors the prop contract.

### Handling Nested / Complex State

If your settings are a `Vec<T>` on the backend (stored via `write_serde_setting`), the form state will be an array in the Zod schema. Mantine's form handles arrays natively with `form.insertListItem`, `form.removeListItem`, `form.reorderListItem`, and dotted path access (`eggGroups.0.name`).

### Wiring It Up

Export your configuration page as the default, then point your `Extension` class at it:

```ts
import { Extension, ExtensionContext } from 'shared';
import ConfigurationPage from './ConfigurationPage.tsx';

class MyExtension extends Extension {
  public cardConfigurationPage: React.FC | null = ConfigurationPage;
  public cardComponent: React.FC | null = null;

  public initialize(_ctx: ExtensionContext): void {}
}

export default new MyExtension();
```

The Panel mounts the page at `/admin/extensions/<your-package-identifier>` automatically. The admin layout, navigation, and breadcrumbs are all provided by the shell - your component just returns its content. See [Mounting UI](./mounting-ui.md#the-extension-class) for the full story on how this wiring works.


---

<!-- concepts/speaking-game-protocols.md -->

---
title: Speaking Game Protocols
description: Talk to the game running inside a server container directly, below the high-level Wings API.
---

# Speaking Game Protocols

Most of what an extension does to a server goes through the high-level Wings API - start it, stop it, read a file, pull a backup. But sometimes you want to talk to the *game* running inside the container, not the container itself. Render a live Minecraft MOTD on a server card, scrape the current player count for a stats page, ping a server to check whether it's actually accepting connections rather than just "running" from Docker's point of view - all of these mean speaking the game's own wire protocol over a raw socket.

The problem is that the game server's port lives on the node, behind whatever firewall the operator set up, and your extension runs inside the Panel - which may be on a completely different machine. You can't just `TcpStream::connect` to it. That's what **query tunnels** are for: you ask Wings to open a socket to one of the server's ports *from the node's side*, and Wings proxies the bytes back to you over a WebSocket. From your extension's perspective you get a normal async socket; the fact that it's tunnelled through Wings is invisible.

This page covers the tunnel API and walks through two complete examples - a Minecraft Server List Ping over TCP, and a GameSpy query over UDP.

## The Tunnel API

You open a tunnel from a [`WingsClient`](./routing.md), which you get from the server's node:

```rs
let client = server
    .node
    .fetch_cached(&state.database)
    .await?
    .api_client(&state.database)
    .await?;
```

The client exposes two methods, one per transport:

| Method | Returns | Shape |
| ------ | ------- | ----- |
| `open_tunnel_tcp(server, port)` | `QueryTcpTunnel` | Implements `AsyncRead` + `AsyncWrite` - use it like any `tokio` socket |
| `open_tunnel_udp(server, port)` | `QueryUdpTunnel` | A datagram socket with `send(&[u8])` / `recv(&mut [u8])` |

Both return `Result<_, ApiHttpError>`. `ApiHttpError` converts into `anyhow::Error` but doesn't implement `std::error::Error` itself, which is why the examples below detour through `anyhow::Error::from` when mapping it into an `std::io::Error` (add `anyhow = { workspace = true }` to your dependencies if you follow that pattern).

Both take the server's `uuid` and a `u16` port. **The port is the port the game is listening on inside the container** - in almost every case that's the server's primary allocation, which you can read off the server model:

```rs
let allocation = server
    .allocation
    .as_ref()
    .ok_or_else(|| ApiResponse::error("server has no primary allocation"))?;

let port = allocation.allocation.port as u16; // stored as i32, narrow to u16
```

A few things to know before you start writing protocol code:

- **The TCP tunnel is a real `AsyncRead + AsyncWrite`.** Bring `tokio::io::{AsyncReadExt, AsyncWriteExt}` into scope and you get `read_exact`, `write_all`, `read_u8`, and friends for free. Internally each WebSocket binary frame becomes a chunk of the read stream, so a single `read_exact(&mut buf)` may span several frames or stop partway through one - exactly like a normal TCP socket. Don't assume one `read` equals one logical message; frame your reads yourself.
- **The UDP tunnel is message-oriented.** `send` writes one datagram, `recv` reads one datagram into your buffer and returns the number of bytes. There's no stream reassembly because UDP has no stream.
- **`recv` on the UDP tunnel has a built-in 5-second timeout.** If the node doesn't get a reply in time you get an `io::ErrorKind::TimedOut`. This is deliberate - a UDP query to a dead server would otherwise hang forever, since there's no connection to break. The TCP tunnel has no such timeout; wrap it in `tokio::time::timeout` yourself if you need one.
- **A refused UDP connection surfaces as `io::ErrorKind::ConnectionRefused`.** If Wings can't reach the port at all, the first `recv` returns this rather than timing out. Handle it as "server is down" rather than bubbling a raw 500.
- **Datagrams are capped at `MAX_DATAGRAM_SIZE` (64 KiB).** That's `wings_api::tunnel::MAX_DATAGRAM_SIZE`. Size your `recv` buffer to it and you'll never truncate a reply.

::: info
Query tunnels are read-write sockets, not a read-only "status" API. Nothing stops you from sending arbitrary bytes to the game port - which is exactly what makes them useful for protocols that need a handshake. With that power comes the usual footgun: don't expose an endpoint that lets an untrusted user pick the port and shovel arbitrary bytes into it, or you've built an SSRF gadget pointed at the node's network. Pin the port to the server's own allocation like the examples below do.
:::

## Example: Minecraft MOTD over TCP (Server List Ping)

Modern Minecraft (Java Edition) answers the [Server List Ping](https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping) on its normal game port over TCP. The flow is: send a *handshake* packet asking to move into the status state, send an empty *status request*, then read back a single JSON blob describing the server - including its MOTD, player counts, and version.

Every packet is length-prefixed and uses Minecraft's VarInt encoding, so we need two small helpers first:

```rs
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};

async fn write_varint<W: AsyncWrite + Unpin>(w: &mut W, mut value: i32) -> std::io::Result<()> {
    loop {
        let mut byte = (value & 0x7F) as u8;
        value = ((value as u32) >> 7) as i32;
        if value != 0 {
            byte |= 0x80; // continuation bit
        }
        w.write_all(&[byte]).await?;
        if value == 0 {
            return Ok(());
        }
    }
}

async fn read_varint<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<i32> {
    let mut result = 0i32;
    let mut shift = 0u32;
    loop {
        let byte = r.read_u8().await?;
        result |= ((byte & 0x7F) as i32) << shift;
        if byte & 0x80 == 0 {
            return Ok(result);
        }
        shift += 7;
        if shift >= 35 {
            return Err(std::io::Error::other("varint too long"));
        }
    }
}
```

Now the query itself. We build the handshake body in a buffer, length-prefix it, write it, then do the same for the (empty) status request:

```rs
use wings_api::client::WingsClient;

/// Returns the raw status JSON the server reports (MOTD lives under `description`).
pub async fn query_minecraft(
    client: &WingsClient,
    server: uuid::Uuid,
    host: &str,
    port: u16,
) -> std::io::Result<serde_json::Value> {
    let mut tunnel = client
        .open_tunnel_tcp(server, port)
        .await
        .map_err(|err| std::io::Error::other(anyhow::Error::from(err)))?;

    // --- Handshake packet (id 0x00) ---
    let mut body: Vec<u8> = Vec::new();
    write_varint(&mut body, 0x00).await?; // packet id
    write_varint(&mut body, -1).await?; // protocol version (-1 = "just querying")
    write_varint(&mut body, host.len() as i32).await?;
    body.extend_from_slice(host.as_bytes());
    body.extend_from_slice(&port.to_be_bytes()); // server port, unsigned short, big-endian
    write_varint(&mut body, 1).await?; // next state: 1 = status

    write_varint(&mut tunnel, body.len() as i32).await?;
    tunnel.write_all(&body).await?;

    // --- Status request packet (id 0x00, empty body) ---
    write_varint(&mut tunnel, 1).await?; // length
    write_varint(&mut tunnel, 0x00).await?; // packet id
    tunnel.flush().await?;

    // --- Status response ---
    let _packet_len = read_varint(&mut tunnel).await?;
    let _packet_id = read_varint(&mut tunnel).await?; // 0x00
    let json_len = read_varint(&mut tunnel).await? as usize;

    let mut json = vec![0u8; json_len];
    tunnel.read_exact(&mut json).await?;

    serde_json::from_slice(&json).map_err(std::io::Error::other)
}
```

A couple of things worth pointing out:

- **The `host` you pass into the handshake is cosmetic for most servers** - vanilla ignores it, but servers behind a proxy (BungeeCord, Velvet, forced-host setups) route on it, so pass the address players actually connect with. The server's allocation IP is a reasonable default.
- **We send the protocol version as `-1`.** When you only want the status, you don't have to pretend to be a specific client version; `-1` is the conventional "I'm just pinging" value and avoids "outdated client/server" rejections.
- **`read_exact` does the framing for us.** Because the tunnel is a real `AsyncRead`, we can read the exact JSON length even if it arrives split across multiple WebSocket frames - the `tokio` extension trait loops until it has every byte.

The MOTD is the `description` field of the returned JSON. Depending on the server it's either a plain string or a [chat component](https://minecraft.wiki/w/Raw_JSON_text_format) object, so handle both:

```rs
let status = query_minecraft(&client, server.uuid, &allocation.allocation.ip, port).await?;

let motd = match &status["description"] {
    serde_json::Value::String(s) => s.clone(),
    serde_json::Value::Object(_) => status["description"]["text"]
        .as_str()
        .unwrap_or_default()
        .to_string(),
    _ => String::new(),
};

let online = status["players"]["online"].as_i64().unwrap_or(0);
let max = status["players"]["max"].as_i64().unwrap_or(0);
```

## Example: GameSpy query over UDP

The other common pattern is the [GameSpy / UT3 query protocol](https://minecraft.wiki/w/Query) that runs over UDP. Bedrock-adjacent servers, many source-engine-style games, and Minecraft's own optional `enable-query` listener all speak it. Unlike the TCP ping it's a two-step challenge-response: you ask for a challenge token, then echo it back in your stat request so the server knows you're not a spoofed source address.

Because it's UDP, we use `open_tunnel_udp` and its `send` / `recv` pair instead of stream reads:

```rs
use wings_api::{client::WingsClient, tunnel::MAX_DATAGRAM_SIZE};

const MAGIC: [u8; 2] = [0xFE, 0xFD];
const TYPE_HANDSHAKE: u8 = 0x09;
const TYPE_STAT: u8 = 0x00;

/// Returns the server's MOTD via the basic GameSpy stat query.
pub async fn query_gamespy(
    client: &WingsClient,
    server: uuid::Uuid,
    port: u16,
) -> std::io::Result<String> {
    let mut tunnel = client
        .open_tunnel_udp(server, port)
        .await
        .map_err(|err| std::io::Error::other(anyhow::Error::from(err)))?;

    // Session id - only the low nibble of each byte is significant, so keep it small.
    let session_id: i32 = 1;

    // --- Step 1: handshake to obtain a challenge token ---
    let mut packet = Vec::new();
    packet.extend_from_slice(&MAGIC);
    packet.push(TYPE_HANDSHAKE);
    packet.extend_from_slice(&session_id.to_be_bytes());
    tunnel.send(&packet).await?;

    let mut buf = vec![0u8; MAX_DATAGRAM_SIZE];
    let n = tunnel.recv(&mut buf).await?; // ConnectionRefused/TimedOut if the server is down

    // Response: type (1) + session id (4) + null-terminated ASCII challenge integer.
    let token_str = std::str::from_utf8(&buf[5..n])
        .map_err(std::io::Error::other)?
        .trim_end_matches('\0');
    let challenge: i32 = token_str
        .trim()
        .parse()
        .map_err(std::io::Error::other)?;

    // --- Step 2: basic stat request, echoing the challenge token ---
    let mut packet = Vec::new();
    packet.extend_from_slice(&MAGIC);
    packet.push(TYPE_STAT);
    packet.extend_from_slice(&session_id.to_be_bytes());
    packet.extend_from_slice(&challenge.to_be_bytes());
    tunnel.send(&packet).await?;

    let n = tunnel.recv(&mut buf).await?;

    // Basic stat response: type (1) + session id (4) + null-terminated fields,
    // the first of which is the MOTD.
    let body = &buf[5..n];
    let motd_end = body.iter().position(|&b| b == 0).unwrap_or(body.len());
    let motd = String::from_utf8_lossy(&body[..motd_end]).into_owned();

    Ok(motd)
}
```

Notes specific to the GameSpy flow:

- **The challenge token must be echoed as a big-endian `i32`.** The server sends it back as an ASCII *string* in the handshake response (e.g. `"9513307"`), and you parse it to an integer and re-encode it as four bytes. Forgetting the string→int→bytes round-trip is the single most common mistake here.
- **Lean on the built-in timeout.** Notice there's no `tokio::time::timeout` wrapping these `recv` calls - the UDP tunnel already gives up after 5 seconds and hands you a `TimedOut`. For a query that's the right behavior: a server that doesn't answer in 5 seconds isn't going to.
- **The "basic" stat only gives you a handful of fields.** MOTD, game type, map, player count, max players, and host. There's also a "full" stat (send `0x00` followed by four extra `0x00` padding bytes) that returns a richer key/value section plus the player list - same tunnel, just a longer request and a more involved parse.

## Wiring It Into a Route

Neither helper is useful on its own - put them behind a [client-server route](./routing.md) so the frontend can render the result. A minimal handler that returns a server's live MOTD:

```rs
mod get {
    use serde::Serialize;
    use shared::{
        ApiError, GetState,
        models::{server::GetServer, user::GetPermissionManager},
        response::{ApiResponse, ApiResponseResult},
    };
    use utoipa::ToSchema;

    #[derive(ToSchema, Serialize)]
    struct Response {
        motd: String,
        players_online: i64,
        players_max: i64,
    }

    #[utoipa::path(get, path = "/", responses(
        (status = OK, body = inline(Response)),
        (status = UNAUTHORIZED, body = ApiError),
    ), params(
        ("server" = uuid::Uuid, description = "The server ID"),
    ))]
    pub async fn route(
        state: GetState,
        permissions: GetPermissionManager,
        server: GetServer,
    ) -> ApiResponseResult {
        permissions.has_server_permission("control.console")?;

        let allocation = server
            .allocation
            .as_ref()
            .ok_or_else(|| ApiResponse::error("server has no primary allocation"))?;

        let client = server
            .node
            .fetch_cached(&state.database)
            .await?
            .api_client(&state.database)
            .await?;

        let status = crate::query::query_minecraft(
            &client,
            server.uuid,
            &allocation.allocation.ip,
            allocation.allocation.port as u16,
        )
        .await
        .map_err(|_| ApiResponse::error("could not reach the server"))?;

        ApiResponse::new_serialized(Response {
            motd: status["description"]["text"]
                .as_str()
                .or_else(|| status["description"].as_str())
                .unwrap_or_default()
                .to_string(),
            players_online: status["players"]["online"].as_i64().unwrap_or(0),
            players_max: status["players"]["max"].as_i64().unwrap_or(0),
        })
        .ok()
    }
}
```

::: warning
Querying a game server hits the network and can take up to several seconds when the server is unreachable. Don't do it on a hot path that's called for every server in a list on every page load - you'll serialize a pile of multi-second timeouts and make the whole Panel feel broken. Cache the result (a short TTL in memory, or a [background task](./background-tasks-and-shutdown-handlers.md) that refreshes known servers on a schedule and stashes the latest MOTD), and have the route read from the cache rather than querying live every time.
:::


---

<!-- concepts/theming.md -->

---
title: Theming
description: Restyle the Panel from an extension, beyond slotting components into existing pages.
---

# Theming

Most extensions are happy with how the Panel already looks. You slot a card onto a page, register a route, and you inherit the surrounding chrome for free (see [Mounting UI](./mounting-ui.md)). But sometimes you need to change the *look* itself: recolor the whole Panel to match your brand, restyle every `Button` at once, or replace a stock component with your own everywhere it shows up.

There are three layers for this, from broadest to most surgical:

1. **The Mantine theme** - `initializeMantineTheme()` returns a theme override that gets merged across all extensions and applied globally. This is the one you want for colors, fonts, radii, default component props - anything Mantine's theming already models.
2. **CSS variables and Tailwind tokens** - ship your own `app.css` to override the `--mantine-color-*` variables and the Tailwind tokens the Panel defines. This is for the stuff that lives below the Mantine theme object, like chart colors.
3. **Hookable components** - every element in the Panel's component library is *hookable*. You can intercept its props, wrap its rendered output, or replace its implementation entirely, and your change applies everywhere that component is used (core pages included).

Rule of thumb: pick the broadest layer that does the job. Drop down to hookable components only when the theme and CSS layers can't get you there.

## The Mantine Theme

The Panel runs on [Mantine](https://mantine.dev/), and Mantine's theme object is where colors, the primary color, fonts, spacing, radii, and per-component default props all live. Your extension chips in by overriding `initializeMantineTheme()` on your `Extension` class:

```ts
import { Extension, ExtensionContext } from 'shared';
import type { MantineThemeOverride } from '@mantine/core';

class MyExtension extends Extension {
  public initializeMantineTheme(ctx: ExtensionContext): MantineThemeOverride {
    return {
      primaryColor: 'grape',
      defaultRadius: 'md',
      fontFamily: 'Inter, sans-serif',
      components: {
        Button: {
          defaultProps: {
            variant: 'light',
          },
        },
      },
    };
  }
}

export default new MyExtension();
```

### How overrides combine

`initializeMantineTheme()` runs once at load, on every installed extension. The Panel deep-merges each returned override - in installation order - into one object, runs it through Mantine's `createTheme`, and hands it to the top-level `MantineProvider`. So:

- Your override is **merged, not replacing**. You only name what you want to change; everything else keeps its Panel default.
- **Multiple extensions can contribute.** Two extensions setting `primaryColor` is last-writer-wins (later install order wins). Two extensions setting *different* keys both take effect.
- It's a deep merge, so nested stuff like `components.Button.defaultProps` combines key-by-key instead of clobbering the whole `components` map.

::: info
The Panel mounts `MantineProvider` with `defaultColorScheme='dark'`. Most of its own styling is tuned for dark first, with light mode as a supported alternate. If your override touches colors, check both schemes before you ship.
:::

### What goes here

Anything Mantine already models: `colors` (including a custom 10-shade palette), `primaryColor`, `primaryShade`, `fontFamily` / `headings`, `defaultRadius`, `spacing`, `shadows`, and per-component `defaultProps` / `classNames` / `styles`. The full surface is upstream - see Mantine's [theme object](https://mantine.dev/theming/theme-object/) and [styles overview](https://mantine.dev/styles/styles-overview/) - and all of it is fair game inside the object you return.

::: info
Setting a component's `defaultProps` in the theme is usually a cleaner way to restyle a whole class of component than a props interceptor. If all you want is "every `Button` defaults to `variant='light'`", the theme is the simpler tool. Reach for an interceptor only when the change depends on the incoming props.
:::

## The CSS Variables Resolver

The theme object and a static `app.css` sit at two extremes: the theme is fully theme-aware but only reaches what Mantine models, while `app.css` reaches any variable but is static - it can't see your palette or react to it. The resolver is the bridge. `initializeMantineCssResolver()` hands you the resolved theme and lets you compute CSS variables from it, so your values stay in lockstep with the palette, `primaryColor`, and the active color scheme.

Override it on your `Extension` class. Most extensions don't need it, so the default returns `null` (contribute nothing):

```ts
import { Extension, ExtensionContext } from 'shared';
import type { CSSVariablesResolver } from '@mantine/core';

class MyExtension extends Extension {
  public initializeMantineCssResolver(ctx: ExtensionContext): CSSVariablesResolver | null {
    return (theme) => {
      const brand = theme.colors[theme.primaryColor];

      return {
        variables: {},
        dark: {
          '--chart-series-1': brand[4],
          '--chart-series-2': brand[8],
        },
        light: {
          '--chart-series-1': brand[6],
          '--chart-series-2': brand[2],
        },
      };
    };
  }
}

export default new MyExtension();
```

A resolver returns three buckets:

- **`variables`** - emitted regardless of color scheme.
- **`light`** - emitted under the light scheme only.
- **`dark`** - emitted under the dark scheme only.

The Panel feeds the result into the top-level `MantineProvider`'s `cssVariablesResolver`, which renders the variables into a `<style>` tag scoped by scheme. Because the values come from `theme`, a later extension that changes `primaryColor` in `initializeMantineTheme()` automatically reshades everything your resolver derived from it - no second edit on your side.

### How resolvers combine

Unlike `initializeMantineTheme()`, resolvers are **not merged**. The Panel walks installed extensions in installation order and uses the **first one that returns a non-null resolver** - every extension after it is ignored entirely, buckets and all. There's no per-variable merge: a single resolver wins outright. Returning `null` opts out cleanly and lets the Panel fall through to the next extension.

When that one resolver runs, Mantine calls it with the **already-merged** theme - every extension's `initializeMantineTheme()` override is folded in first - so you derive from the final palette, not your own slice of it.

::: warning
Because only the first non-null resolver is used, two extensions that both ship a resolver don't cooperate - the later one silently contributes nothing. If you need variables that another extension's resolver also sets, you can't rely on merging here; prefer scoping CSS variable overrides to your own surfaces, or set theme-derived values through `initializeMantineTheme()` (which *does* deep-merge) where Mantine models them.
:::

::: info
The resolver sees the merged theme but runs separately from it. If your variable is purely theme-derived and Mantine already models the target (a palette shade, a spacing value), set it in `initializeMantineTheme()` instead - the resolver is for variables Mantine *doesn't* model but that you still want computed from the theme, like the chart colors above.
:::

::: info
Reach for the resolver over `app.css` only when the value has to track the theme. A fixed `--chart-grid-color: #2a2a2a` belongs in `app.css`; a chart series color derived from `theme.colors[theme.primaryColor]` belongs here, so it follows palette changes and both schemes for free.
:::

## CSS Variables and Tailwind Tokens

Below the theme object sits a layer of raw CSS custom properties. The Panel resolves Mantine's theme into `--mantine-color-*` variables on `:root`, scoped by color scheme through the `[data-mantine-color-scheme="dark"]` / `[data-mantine-color-scheme="light"]` attributes. It also defines its own tokens - the font stack, server-status colors, chart colors - in its `app.css`.

Your extension can ship its own `src/app.css`, and the build picks it up automatically and folds it into the final stylesheet. That lets you override variables the theme object doesn't reach. The chart colors are the common case - they're plain CSS vars, not part of the Mantine theme, so this is the only way to retheme them:

```css
/* my-extension/src/app.css */

:root[data-mantine-color-scheme="dark"] {
  --chart-grid-color: #2a2a2a;
  --chart-tick-color: #e5e7eb;
  --chart-series-1: #c084fc;
  --chart-series-2: #f0abfc;
}

:root[data-mantine-color-scheme="light"] {
  --chart-grid-color: #e5e7eb;
  --chart-tick-color: #374151;
  --chart-series-1: #9333ea;
  --chart-series-2: #c026d3;
}
```

The Panel defines four series slots, `--chart-series-1` through `--chart-series-4`; a chart with more series than that cycles back through them. Each slot is a single color used for the series' line and, at reduced opacity, its gradient fill - there's no separate fill variable to set.

Same trick works for the app background (`--mantine-color-body`) or any other `--mantine-color-*` variable.

A couple of things to watch:

- **Scheme scoping matters.** These vars are defined separately under the dark and light selectors. Override them under the matching scheme selector (or both) - not bare `:root` - or the more specific scheme rule wins and your value gets ignored.
- **Load order between extensions isn't guaranteed.** If two extensions fight over the same variable, the winner comes down to bundling order, which you don't control. Keep overrides scoped to your own surfaces where you can, and treat global variable overrides as a deliberate "theme extension" thing.
- **Prefer the theme when a value is theme-derived.** Overriding `--mantine-color-grape-7` in CSS works, but if Mantine would normally compute it from your palette, set it in `initializeMantineTheme()` instead so both schemes and all the derived shades stay consistent.

## Hookable Components

Every element in the Panel's component library - `Button`, `Card`, `Modal`, `Spinner`, the inputs, all of it - gets wrapped before it's exported. Instead of exporting the bare component, each module does:

```ts
export default makeComponentHookable(Button);
```

`makeComponentHookable` returns a thin wrapper that's a **process-wide singleton**. Importing `@/elements/Button.tsx` from anywhere - core code or your extension - gives you the *same* wrapper instance. It exposes three methods, and because the instance is shared, registering a hook changes every render of that component across the whole Panel:

```ts
Button.addPropsInterceptor((props) => props); // transform incoming props
Button.addRenderInterceptor((element, props) => element); // wrap/replace the output
Button.replaceBaseComponent(MyButton); // swap the implementation entirely
```

### Where to register hooks

Register hooks in your extension's `initialize()`. It runs once, before React renders anything, so the interceptors are in place before the first paint and apply uniformly from the very first render:

```ts
import { Extension, ExtensionContext } from 'shared';
import Button from '@/elements/Button.tsx';

class MyExtension extends Extension {
  public initialize(ctx: ExtensionContext): void {
    Button.addPropsInterceptor((props) => ({
      ...props,
      radius: 'xl',
    }));
  }
}

export default new MyExtension();
```

::: warning
Don't register hooks from inside a React component - not in a render body, an effect, or an event handler. The interceptor arrays are append-only with no dedupe, so registering on every render or click stacks duplicate hooks forever and will eventually misbehave. `initialize()` is the one right place.
:::

### `addPropsInterceptor` - transform props before render

The interceptor gets the props the component was called with and returns the props it should actually render with. Use it to force, default, or rewrite props everywhere a component is used:

```ts
Button.addPropsInterceptor((props) => ({
  ...props,
  // push every non-destructive button to our brand variant,
  // but leave red (destructive) buttons alone
  variant: props.color === 'red' ? props.variant : 'gradient',
}));
```

Interceptors run in registration order, each getting the previous one's output, so multiple extensions (and multiple hooks from the same extension) compose. Always spread the incoming props and return a superset - returning a fresh object that drops fields breaks the component for every caller, core included.

### `addRenderInterceptor` - wrap or replace the output

Where a props interceptor changes *what the component renders with*, a render interceptor changes *what comes out*. It gets the already-created React element plus the (post-props-interceptor) props, and returns the element to actually render. Good for wrapping the component in extra markup, or swapping it out conditionally:

```tsx
import { cloneElement } from 'react';

Button.addRenderInterceptor((element, props) => (
  <div className='relative inline-block'>
    {element}
    {props.loading && <span className='my-extension-sparkle' />}
  </div>
));
```

You can also `cloneElement(element, ...)` to tweak the element rather than wrap it, or return something else entirely. Like props interceptors, these stack in registration order.

### `replaceBaseComponent` - swap the implementation

The blunt one. `replaceBaseComponent` swaps out the underlying component the wrapper renders, leaving the singleton wrapper (and any interceptors already registered on it) in place. Every import site now renders your version instead of the Panel's:

```tsx
import Button, { type ButtonProps } from '@/elements/Button.tsx';

function MyButton(props: ButtonProps) {
  // your own implementation - has to honor the same props contract
  return <button className='my-button' onClick={props.onClick} {...} />;
}

// inside initialize():
Button.replaceBaseComponent(MyButton);
```

::: warning
This is a sharp tool, same family as route interceptors (see [Mounting UI → Interceptors](./mounting-ui.md#interceptors)). You're taking over a component that core pages depend on, so:

- **Honor the exact props contract.** Your replacement has to accept and correctly handle every prop the original did (`ButtonProps` here), or you break callers you've never seen.
- **You own it now.** When the Panel evolves its `Button`, your replacement won't follow along - that maintenance is yours.
- **Last replacement wins.** If two extensions both `replaceBaseComponent` the same component, only the last one in install order survives. Nothing merges.

Replace a base component only when intercepting props or wrapping the render genuinely can't get you there. For most "make it look different" goals, the theme, a props interceptor, or a render interceptor is the safer bet.
:::

### Compound and sub-components

Some elements ship sub-components, and each is independently hookable. `Spinner` carries `Spinner.Centered` and `Spinner.Suspense`, for instance, and `Modal` is exported alongside `ModalFooter`. Each is its own hookable wrapper, so hook them separately:

```ts
import Spinner from '@/elements/Spinner.tsx';

Spinner.Centered.addPropsInterceptor((props) => ({ ...props, size: 48 }));
```

Hooking the parent (`Spinner`) doesn't hook the children (`Spinner.Centered`), and vice versa - they're distinct singletons.

### Caveats for all three

- **Everything is global.** There's no per-page or per-extension scoping. A hook on `Button` changes the Panel's own buttons too. That's the whole point of the system, but it means a careless interceptor has a big blast radius.
- **Order is install order**, which you don't control. Don't write hooks that assume they run first or last.
- **Keep interceptors pure and cheap.** They run on every render of a heavily-used component. No expensive work, no side effects.

## Choosing the Right Layer

| You want to... | Use |
| -------------- | --- |
| Recolor the Panel, change fonts, radii, spacing | `initializeMantineTheme()` |
| Set a default prop on a whole class of component | Mantine theme `components.X.defaultProps` |
| Retheme charts, or override a CSS variable | Your extension's `src/app.css` |
| Force or rewrite props on one component everywhere | `addPropsInterceptor` |
| Wrap or conditionally swap a component's output | `addRenderInterceptor` |
| Replace a component's implementation wholesale | `replaceBaseComponent` (last resort) |

When more than one layer would work, prefer the one higher up the list - it's less coupled to internals and less likely to surprise anyone.


---

<!-- concepts/toasts.md -->

---
title: Toasts
description: Show transient feedback from your extension with the Panel toast system.
---

# Toasts

Toasts are the Panel's transient feedback channel: the little cards that slide into a corner after you save a form or hit an error. Every "Settings saved." and "Something went wrong." in the Panel is a toast, and your extension raises them through the same API core pages use. There's no separate notification system for extensions, so your feedback lands in the corner the user is already watching.

## The `useToast` Hook

Everything goes through one hook, `useToast`, exported from the Panel's `ToastProvider`:

```tsx
import { useToast } from '@/providers/ToastProvider.tsx';
import Button from '@/elements/Button.tsx';

export default function MyCard() {
  const { addToast } = useToast();

  return <Button onClick={() => addToast('Everything worked.', 'success')}>Do the thing</Button>;
}
```

Before you reach for it, check whether something already toasts for you - the Panel's data-fetching hooks raise error and success toasts internally, so a lot of the obvious cases are already covered. See [Toasts the data hooks already raise](#toasts-the-data-hooks-already-raise).

The hook hands you four things:

| Member | What it does |
| ------ | ------------ |
| `addToast(message, type?, actions?)` | Shows a toast, returns its numeric id |
| `dismissToast(id)` | Removes a toast early |
| `toastPosition` | The corner the current user has chosen |

The provider sits above the whole app, so the hook works from anywhere you render: a route you registered, a component you slotted into a core page, a modal, a form submit handler. It throws if called outside the provider, which in practice only happens if you call it outside React entirely - see [Toasting Outside React](#toasting-outside-react).

## Types

The second argument picks the toast's color and, implicitly, its tone. There are four, and `success` is the default when you omit the argument:

| Type | Color | Use it for |
| ---- | ----- | ---------- |
| `success` | Green | The thing the user asked for happened |
| `error` | Red | It didn't, and they need to know why |
| `warning` | Yellow | It happened, but with a caveat worth reading |
| `info` | Teal | Neutral status with no success/failure framing |

```ts
addToast('Backup created.', 'success');
addToast('Node is unreachable.', 'error');
addToast('Saved, but the node will need a restart.', 'warning');
addToast('Import started in the background.', 'info');
```

::: info
Default-to-`success` means `addToast('Saved.')` is a valid one-liner, but spell the type out anyway on anything that isn't obviously a success. It reads better at the call site and it's one less thing to get wrong when someone later copies your line for an error path.
:::

## Messages

The message is a `ReactNode`, not a `string`, so anything React can render works, not just plain text. In practice most toasts are a single translated line.

For translated messages, run the string through `.md()` when it contains Markdown. The Panel augments `String.prototype` with it, and it renders through a sanitized Markdown component (links get scheme-checked, raw HTML stays off unless you pass `{ html: true }`):

```tsx
import { useToast } from '@/providers/ToastProvider.tsx';
import { useExtTranslations } from './translations.ts';

export default function DeleteButton({ fileName }: Props) {
  const { addToast } = useToast();
  const { t } = useExtTranslations();

  const onDelete = () => {
    // 'toast.deleted' is e.g. "Deleted **{file}**."
    addToast(t('toast.deleted', { file: fileName }).md(), 'success');
  };

  // ... render
}
```

Keep messages short. The toast card is a fixed 288px wide and long text just wraps into a wall - if you need to explain something properly, put it on the page and use the toast to point at it. See [Translations](./translations.md) for wiring up your extension's own strings, and note that the base Panel already ships plenty of generic ones you can reuse.

## Actions

A toast can carry action buttons - small icon buttons rendered inside the card, to the left of the close button. They're for the "and now what" follow-up: jump to the thing you just created, or undo it.

An action is `{ name, icon, disabled?, onClick }`:

```tsx
import { faFolderOpen } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from 'react-router';
import { useToast } from '@/providers/ToastProvider.tsx';

export default function ImportCard({ serverUuid }: Props) {
  const { addToast } = useToast();
  const navigate = useNavigate();

  const onImported = (path: string) => {
    addToast('Import finished.', 'success', [
      {
        name: 'Show files',
        icon: faFolderOpen,
        onClick: () => navigate(`/server/${serverUuid}/files?directory=${path}`),
      },
    ]);
  };

  // ... render
}
```

`name` isn't rendered as a label - it becomes the button's tooltip, so the `icon` has to carry the meaning on its own. Pick an obvious one and keep `name` to a couple of words.

When actions are the only extra you need, there's a shorthand overload that skips the type and defaults to `success`:

```ts
addToast('Import finished.', [{ name: 'Show files', icon: faFolderOpen, onClick: onShow }]);
```

A few behaviors worth knowing:

- **`onClick` may return a promise.** If it does, the button shows a loading spinner until the promise settles. Handy for actions that hit the API.
- **Actions fire exactly once.** The button guards against a second click and disables itself after the first, so an async action can't be double-submitted by an impatient user.
- **Clicking an action doesn't dismiss the toast.** If your action should close it, capture the id from `addToast` and call `dismissToast` yourself.
- **`disabled: true`** renders the button greyed out from the start, for an action you want visible but not yet available.

::: warning
Actions live and die with the toast, which disappears on its own after a few seconds. That makes them fine for *shortcuts* - a faster way to do something the user could also do by navigating - and a poor fit for anything that's the only way to complete a flow. If missing the button means the user is stuck, it doesn't belong in a toast.
:::

## Undoable Toasts

"Did it, here's an undo button" is common enough that the Panel ships a purpose-built hook for it rather than making you hand-roll the action every time. `useUndoableToast` wraps `addToast` with an Undo action *and* registers the same undo in a scoped history, so the user can trigger it either by clicking the button or by pressing the undo shortcut. The file manager uses it for renames, moves, and permission changes.

The hook takes a scope and returns an `addUndoableToast(message, undo)` function:

```tsx
import { useUndoableToast } from '@/plugins/useUndoableToast.ts';

export default function RenameModal({ server, file }: Props) {
  const addUndoableToast = useUndoableToast(`server:${server.uuid}:my-extension`);

  const onRenamed = (from: string, to: string) => {
    addUndoableToast(`Renamed ${to}.`, () => renameThing(server.uuid, to, from));
  };

  // ... render
}
```

That one call gets you a success toast carrying an Undo button (a left-arrow icon, tooltipped with the Panel's translated `common.button.undo`), plus an entry in the undo history. You don't wire the action yourself and you don't dismiss the toast in your callback - clicking Undo takes the toast down before running your function.

### Writing the undo callback

The callback is just "do the inverse operation". It can return a promise, which the action button turns into a loading spinner, and it's responsible for its own feedback - the Panel doesn't toast anything on your behalf when an undo runs. The shape the file manager uses:

```tsx
addUndoableToast('Renamed 3 files.', () =>
  renameFiles({ uuid: server.uuid, root: directory, files: reversedRenames })
    .then(({ renamed }) => {
      if (renamed < 1) {
        addToast('The rename could not be undone.', 'error');
        return;
      }

      addToast('Rename undone.', 'success');
      invalidate();
    })
    .catch((err) => addToast(httpErrorToHuman(err), 'error')),
);
```

Two things that matter here. First, **an undo can fail** - the file may have moved on, the API may reject it - so check the result and say so rather than silently doing nothing. Second, **invalidate your queries in the undo path too**. The undo mutates state exactly like the original action did, and nothing re-fetches for you.

### Actions that can't be undone

Pass `null` instead of a function when the operation isn't reversible, and you get a plain success toast with no Undo button, so the call site doesn't have to branch:

```tsx
// recursive chmod can't be walked back; a single-file one can
const undo = wasRecursive ? null : () => restorePermissions(file, oldMode);

addUndoableToast('Permissions updated.', undo);
```

Compute the `undo` once and let the hook decide whether to render the button. That's how `FilePermissionsModal` handles it, and it keeps "is this undoable" as one expression instead of two toast call sites.

### Scopes and the undo history

The scope string is how the keyboard shortcut finds the right entry. Undo entries from every part of the Panel land in one shared store, tagged with the scope you passed, and `runLastUndoEntry(scope)` pulls the most recent live entry for that scope only:

```ts
import { runLastUndoEntry } from '@/stores/undoHistory.ts';

// bound to the general Ctrl/Cmd+Z shortcut
runLastUndoEntry(`server:${server.uuid}:files`);
```

Scope your extension's entries to something unique and stable - include the server or resource id if the undo is per-resource, the way the file manager's `server:{uuid}:files` does. If you register your own keyboard shortcut, point its callback at `runLastUndoEntry` with the same scope string you pass to `useUndoableToast`.

The store has a few properties worth knowing about:

- **Entries expire with the toast.** An entry's lifetime is `toastTimeout` from when it was pushed, so the undo shortcut stops working at the same moment the toast disappears. Expired entries are pruned as new ones arrive.
- **Entries are one-shot.** Running an undo removes it, whether it was triggered by the button or the shortcut, so there's no way to fire the same undo twice.
- **The history holds 10 entries, globally.** It's shared across all scopes, and the oldest fall off. In practice the timeout expires entries long before the cap bites, but don't build anything that assumes a deep undo stack.
- **There's no redo.** Undoing doesn't push an inverse entry. If you want "undo the undo", raise another undoable toast from inside your undo callback.

::: info
`useUndoableToast` is a convenience layer over the same `addToast` actions described above - nothing stops you from building your own Undo action by hand. Use the hook anyway when the semantics fit. Wiring it yourself means reimplementing the shortcut integration and the dismiss-on-undo behavior, and keeping your label and icon in sync with the ones users see everywhere else.
:::

## Dismissing and Lifetime

Every toast auto-dismisses after `toastTimeout`, which is 7500ms. It's a module constant, not a per-toast option, so you can't make a toast stickier or shorter, and hovering doesn't pause the timer. If you need something to stay on screen until acknowledged, use a modal or an inline alert on the page instead.

To take a toast down early, hold onto the id `addToast` gives you:

```ts
const { addToast, dismissToast } = useToast();

const id = addToast('Uploading...', 'info');

await uploadEverything();

dismissToast(id);
addToast('Upload complete.', 'success');
```

If you need the timeout value yourself - to line up an animation, say - import it rather than hardcoding `7500`:

```ts
import { toastTimeout } from '@/providers/contexts/toastContext.ts';
```

Toasts stack in the order they're raised, and nothing dedupes them. Firing one per item in a loop produces one card per item, all fighting for the same corner. Collapse those into a single summary toast ("Deleted 12 files.") before you raise it.

## Position

Which corner toasts appear in is a **user preference**, not an extension setting. It's the synced user setting `app::toast_position`, edited from the account page's Preferences card. The six options are `top_left`, `top_center`, `top_right`, `bottom_left`, `bottom_center`, and `bottom_right`.

::: warning
The context exposes `toastPosition` for reading only - use it if you need to position something of your own relative to the stack. Don't write the underlying setting from an extension: it moves *every* toast in the Panel, core ones included, away from the corner the user deliberately chose.
:::

## Toasting Outside React

Unlike translations, which expose a module-scope `getTranslations()` for use outside components, toasts are hook-only - there's no `getToast()`. Code that runs outside the React tree, like a background upload loop or a websocket handler, has to be handed `addToast` from something that *is* inside the tree.

The Panel's own upload manager solves this with a small externals object: the module keeps a mutable slot, and a component fills it in an effect.

```ts
// my-extension/src/lib/worker.ts
import type { ReactNode } from 'react';
import type { ToastType } from '@/providers/contexts/toastContext.ts';

let addToast: ((message: ReactNode, type?: ToastType) => void) | null = null;

export function setWorkerExternals(ext: { addToast: typeof addToast }): void {
  addToast = ext.addToast;
}

export function onJobFailed(error: string): void {
  addToast?.(error, 'error');
}
```

```tsx
// somewhere that renders inside the app
import { useEffect } from 'react';
import { useToast } from '@/providers/ToastProvider.tsx';
import { setWorkerExternals } from './lib/worker.ts';

export default function MyWorkerBridge() {
  const { addToast } = useToast();

  useEffect(() => {
    setWorkerExternals({ addToast });
  }, [addToast]);

  return null;
}
```

Note the optional call (`addToast?.(...)`). The module can run before any component has mounted, so treat "no toast available yet" as normal rather than an error - the same reason `copyToClipboard`'s helpers take `addToast` as an optional argument.

## Toasting API Errors

The standard error path is `httpErrorToHuman` straight into an error toast:

```tsx
import { httpErrorToHuman } from '@/api/axios.ts';
import { useToast } from '@/providers/ToastProvider.tsx';

const onSave = (values: MyData) => {
  setSaving(true);

  updateThing(values)
    .then(() => addToast('Saved.', 'success'))
    .catch((err) => addToast(httpErrorToHuman(err), 'error'))
    .finally(() => setSaving(false));
};
```

This `.then` / `.catch` / `.finally` shape is what the Panel uses everywhere. See [Frontend API Calls → Handling Errors](./frontend-api.md#handling-errors) for the full treatment.

::: info
Not every failure deserves a toast. Field-level validation belongs on the field (see [Forms](./forms.md)), and an empty list belongs in the list. Save the toast for things the user can't see the result of by looking at the page they're already on.
:::

### Toasts the data hooks already raise

Write that pattern by hand only when you're calling the API directly. The Panel's [data-fetching hooks](./frontend-api.md#data-fetching-hooks) call `useToast` internally and toast on your behalf - if you're using one of them, adding your own toast on top produces two cards for one event.

| Hook | What it toasts for you |
| ---- | ---------------------- |
| `useResource` | Fetch errors, as `httpErrorToHuman(error)` |
| `usePollingResource` | Fetch errors, same as above |
| `useSearchableResource` | Fetch errors |
| `useSearchablePaginatedTable` | Fetch errors |
| `useResourceForm` | Create / update / delete success, **and** errors on all three |
| `useModalForm` | Submit errors |

The opt-outs differ, and two of the hooks don't have one:

- **`useResource` and `usePollingResource`** take `silent: true`, which suppresses the error toast while still returning `error`. Reach for it when you want to render the failure inline instead.
- **`useModalForm`** takes an `onError` callback that *replaces* the built-in toast entirely. Pass it and you own the error path; omit it and you get `httpErrorToHuman` in an error toast.
- **`useSearchableResource` and `useSearchablePaginatedTable`** always toast fetch errors, with no way to opt out.
- **`useResourceForm`** always toasts too, and its success messages are built from the `resourceName` you pass (`"Item created."`, `"Item updated."`, `"Item deleted."`). If you want different wording, that argument is the lever, not a second toast.

::: warning
The fetch-error toasts fire from an effect on `error`, so a query that keeps failing - a poll against a down node, say - toasts each time the error updates. `usePollingResource`'s `retryOnError` bounds that by stopping the poll after N consecutive failures; on a long-lived poll where the failure is already visible on the page, `silent: true` is usually the kinder choice.
:::

## Styling Toasts

Toasts render through the Panel's `Notification` element, which is a [hookable component](./theming.md#hookable-components). If you're building a theme extension and want every toast restyled, intercept `Notification` rather than trying to reach into the toast provider:

```ts
import Notification from '@/elements/Notification.tsx';

// inside initialize():
Notification.addPropsInterceptor((props) => ({ ...props, radius: 'xl' }));
```

The per-type colors (`green`, `red`, `yellow`, `teal`) come from the Mantine palette, so redefining those colors in `initializeMantineTheme()` reshades toasts along with everything else. See [Theming](./theming.md) for both layers.

::: warning
`Notification` is used for more than toasts, and the hook is global. Restyling it changes the Panel's own toasts too - which is the point for a theme extension, but a surprise if you were only trying to tweak your own.
:::


---

<!-- concepts/translations.md -->

---
title: Translations
description: Declare and use translated strings in your extension with the Panel integrated translation system.
---

# Translations

The Panel ships with an integrated translation system, and your extension gets to use it for free - no backend wiring, no separate i18n library, no string-bundle nightmares. You declare your translation keys once with their English values, the framework handles type-safe lookup at runtime, and operators (or you, if you're shipping translations yourself) can ship JSON files for other languages that automatically get picked up when a user has that language selected.

This page covers the whole lifecycle: declaring translation keys for your extension, using them in your React components and TypeScript code, and shipping translations for languages other than English.

## Defining Translations

All Panel translations use English as the base language. Every key needs an English translation; the framework uses that as the fallback if a translation for the user's selected language is unavailable. Practically speaking, this means **English is non-negotiable, every other language is optional**.

Create a `translations.ts` file in your extension's frontend `src/` directory:

```ts
import { defineTranslations } from 'shared';

const translations = defineTranslations({
  items: {},
  translations: {},
});

export const useExtTranslations = translations.useTranslations.bind(translations);
export const getExtTranslations = translations.getTranslations.bind(translations);

export default translations;
```

This is the empty skeleton - you'll fill in the `items` and `translations` objects with your actual keys.

::: info
`defineTranslations` infers the types of every key you add. So `t('helloWrld', {})` (typo) or `t('fileCount', {})` (missing the `files` interpolation variable) will be a TypeScript compile error before it's a runtime error. You don't need to maintain a separate type definition - the call site is the contract.
:::

### Item Translations

The `items` object is for keys that have a count attached - "1 File" vs "2 Files," "1 User" vs "5 Users." The framework uses [`Intl.PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) under the hood, which means the same key automatically does the right plural form for whatever language is active (English has just `one`/`other`; Russian and Polish have several; Japanese has none).

```ts
import { defineEnglishItem, defineTranslations } from 'shared';

const translations = defineTranslations({
  items: {
    file: defineEnglishItem('File', 'Files'),
  },
  translations: {},
});

export const useExtTranslations = translations.useTranslations.bind(translations);
export const getExtTranslations = translations.getTranslations.bind(translations);

export default translations;
```

`defineEnglishItem(singular, plural)` is the English-specific helper - English only distinguishes between one and many, so two strings is enough. The framework expands this internally to all six plural categories (`zero`, `one`, `two`, `few`, `many`, `other`), with everything that isn't `one` getting the plural form. For non-English translation files (covered later), authors fill in each plural category for their language individually.

### Regular Translations

The `translations` object is for everything else - labels, sentences, paragraphs, anything without a count. Keys can be nested as deeply as you like; the access path uses dot notation:

```ts
import { defineEnglishItem, defineTranslations } from 'shared';

const translations = defineTranslations({
  items: {
    file: defineEnglishItem('File', 'Files'),
  },
  translations: {
    helloWorld: 'Hello World',
    fileCount: 'You have {files}',
    pages: {
      home: {
        title: 'My Extension',
        welcome: 'Welcome back, {username}!',
      },
    },
  },
});

export const useExtTranslations = translations.useTranslations.bind(translations);
export const getExtTranslations = translations.getTranslations.bind(translations);

export default translations;
```

Anything wrapped in `{braces}` is an **interpolation variable** - a placeholder you fill in at use time. The base Panel uses these heavily (`'Showing {start} to {end} of {total} results.'`, for example), and you can do the same. The variable name in the template (`{files}`, `{username}`) needs to match what you pass in the call site object; both are type-checked.

::: info Look at the base Panel for naming conventions
The Panel's own translations are at [frontend/src/translations.ts](https://github.com/calagopus/panel/blob/main/frontend/src/translations.ts) and they're worth a skim before you invent your own structure. The convention used by core is `pages.<section>.<page>.<element>` (e.g. `pages.account.home.title`), with leaf categories like `button`, `modal`, `form`, `toast`, `error`, `alert`, `tooltip`, `placeholder`. Following the same shape in your extension makes operators' translation files less arbitrary to navigate.
:::

## Using Translations in Your Extension Code

You'll typically destructure `useExtTranslations` to keep its name distinct from the base Panel's `useTranslations`. The convention is to alias to `tExt`, `tExtReact`, and `tExtItem` so callsites read clearly:

```tsx
import { useExtTranslations } from './translations.ts';

export default function MyComponent() {
  const { t: tExt, tReact: tExtReact, tItem: tExtItem } = useExtTranslations();

  return (
    <div>
      <h1>{tExt('helloWorld', {})}</h1>
      <p>{tExt('fileCount', { files: tExtItem('file', 5) })}</p>
      <h2>{tExt('pages.home.title', {})}</h2>
    </div>
  );
}
```

For non-React contexts (utility functions, async API handlers, anything outside a component), use `getExtTranslations` instead - same API surface, no React dependencies.

```ts
import { getExtTranslations } from './translations.ts';

export function buildEmailSubject(filesCount: number): string {
  const { t, tItem } = getExtTranslations();
  return t('email.fileSummary.subject', { files: tItem('file', filesCount) });
}
```

### The Full Method Surface

Both `useExtTranslations()` and `getExtTranslations()` return an object with these methods:

| Method | Returns | Use when |
| --- | --- | --- |
| `t(key, values)` | `string` | Plain text rendering. Most cases. |
| `tReact(key, values)` | `ReactNode` | Your interpolation values include React nodes (links, icons, styled spans). Renders the surrounding text as Markdown. |
| `tItem(key, count)` | `string` | Pluralized count rendering. Returns "5 Files" / "1 File" / etc. |
| `setLanguage(lang)` | `void` | Programmatically switch the user's active language. |
| `language` | `string` | The currently active language code (e.g. `'en'`, `'de'`, `'es'`). |

`setLanguage` and `language` are useful when building a custom language picker or reacting to the active language elsewhere in your code. The Panel itself ships a language picker in user account settings, so you usually don't need to wire your own - but the hooks are there if you do.

### Markdown in Translations

Translations support Markdown. There are two ways to render it, depending on what you're putting into the translation:

**`.md()` on the result of `t()`** for plain Markdown with no React content:

```tsx
const { t: tExt } = useExtTranslations();

return <p>{tExt('myMarkdownTranslation', {}).md()}</p>;
```

This is a `String.prototype` extension - calling `.md()` on any string returns a React element rendering it as Markdown. So it works on the result of `t(...)` (since that returns a string), but it would equally work on any other string you have lying around.

**`tReact()` for translations that interpolate React nodes:**

```tsx
const { tReact: tExtReact } = useExtTranslations();

return (
  <p>
    {tExtReact('userMessage', {
      user: <strong>{currentUser.name}</strong>,
      link: <a href="/help">help center</a>,
    })}
  </p>
);
```

`tReact` does the markdown rendering for the surrounding text *and* splices ReactNode values into the right places at the placeholders. This is the only way to get React content into a translated string while keeping the surrounding text translatable.

**Pick by what you're putting in, not by what you're rendering:**

- All your interpolation values are strings or numbers? → use `t(...)`, optionally with `.md()` if you need markdown
- One or more interpolation values is a React node? → use `tReact(...)`

`tReact` *also* renders markdown for the non-ReactNode parts of the translation, so a translation like `'Welcome **{name}**'` works fine through `tReact` even with a string `name` - but if you don't actually need React interpolation, `t(...).md()` is the simpler option.

### Missing Keys

If you call `t`, `tReact`, or `tItem` with a key that doesn't exist (in the active language *or* in the English base), the call **throws an Error**. There is no runtime fallback to the key name, no empty string, no warning - it throws and your component bubbles up to the error boundary.

This is intentional. With TypeScript-inferred key types, missing keys are caught at compile time before they reach runtime. The runtime throw is a backstop for cases where the key is constructed dynamically (e.g. `t(\`status.${state}\`, {})` where `state` is a value from an API), and in those cases throwing is what you want - you'd rather know immediately that your data has a value the translation system can't handle than ship a UI showing literal "status.frobnicated" to users.

If you have genuinely-dynamic keys, narrow the input set with a TypeScript union or a runtime check before calling the translation function:

```ts
const STATES = ['running', 'stopped', 'starting'] as const;
type State = (typeof STATES)[number];

function statusLabel(state: State): string {
  // No dynamic-key footgun: TypeScript knows `state` is one of the three.
  return tExt(`status.${state}`, {});
}
```

## Shipping Custom Translations with Your Extension

By default, the Panel only uses the English translations you defined in `translations.ts`. To ship translations for other languages, drop a JSON file per language into your extension's `public/translations/<language>/` directory:

```yml
frontend/extensions/
  (package_name_with_underscores)/
    public/translations/
      es/
        dev.yourname.extension.json
      de/
        dev.yourname.extension.json
```

The filename is your package identifier - if your `Metadata.toml` says `package_name = "dev.0x7d8.test"`, the file is `dev.0x7d8.test.json`. The directory says the language code (`es` for Spanish, `de` for German, etc.).

To get a starter file with the right shape, generate the English equivalent first:

```bash
pnpm build:translations
```

You'll find the result at `public/translations/en/dev.yourname.extension.json`. Copy it, replace the English strings with translations for your target language, save under the right language folder. Repeat per language.

The shape of the JSON file is **flat** - `items` and `translations` at the top level, with keys exactly as you defined them in `translations.ts`. The framework handles namespacing across extensions internally; you don't put your package identifier inside the JSON, only on the filesystem path.

```json
{
  "items": {
    "file": {
      "zero": "{count} archivos",
      "one": "{count} archivo",
      "two": "{count} archivos",
      "few": "{count} archivos",
      "many": "{count} archivos",
      "other": "{count} archivos"
    }
  },
  "translations": {
    "helloWorld": "Hola Mundo",
    "fileCount": "Tienes {files}",
    "pages": {
      "home": {
        "title": "Mi Extensión",
        "welcome": "¡Bienvenido de vuelta, {username}!"
      }
    }
  }
}
```

For non-English item translations, you fill in **each plural category your language uses individually**. English collapses into singular and plural; Spanish does the same; Russian uses three or more distinct forms (`one`, `few`, `many`); Japanese only has `other`. The [Unicode CLDR plural rules table](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) is the authoritative reference for which categories your language needs.

::: warning Never ship incomplete translations
If you ship a Spanish translation file, every translation key your extension uses needs a Spanish value. Users with their language set to Spanish will see your Spanish file, and any missing key falls back to the **English** translation - mixed-language UIs look broken and confused. If you'd rather not ship Spanish at all than ship 80% of it, that's fine: users get the English fallback for the whole extension, which is consistent.

You can check for missing keys in a translation file by running:

```bash
pnpm diff:translations public/translations/es/dev.yourname.extension.json
```

This compares your target file against the generated English file and reports any keys present in English but missing in the target.
:::

## Using Base Panel Translations

You don't have to redefine translations the base Panel already provides. Common things like "Save", "Cancel", "Delete", "Loading...", "Are you sure?" - those are all in [the base Panel translation file](https://github.com/calagopus/panel/blob/main/frontend/src/translations.ts) and accessible from your extension via the **base** translation hook (not the extension-specific one):

```tsx
import { useTranslations } from '@/providers/TranslationProvider.tsx';

export default function MyConfirmDialog() {
  const { t } = useTranslations();

  return (
    <Modal>
      <Button>{t('common.button.cancel', {})}</Button>
      <Button>{t('common.button.confirm', {})}</Button>
    </Modal>
  );
}
```

When you mix base and extension translations in the same component, alias them clearly:

```tsx
import { useTranslations } from '@/providers/TranslationProvider.tsx';
import { useExtTranslations } from './translations.ts';

export default function MyComponent() {
  const { t } = useTranslations();
  const { t: tExt, tItem: tExtItem } = useExtTranslations();

  return (
    <div>
      <h1>{tExt('pages.home.title', {})}</h1>
      <p>{tExt('fileCount', { files: tExtItem('file', 5) })}</p>
      <Button>{t('common.button.save', {})}</Button>
    </div>
  );
}
```

The `t` / `tExt` split makes it obvious at a glance whether a key lives in the base Panel or your extension - useful when you're searching the codebase later trying to find where a label came from.

## A Note on Sample File Generation

For the example `translations.ts` on this page, with `helloWorld`, `fileCount`, the `pages.home.*` block, and the `file` item, the generated `en/dev.yourname.extension.json` would look like:

```json
{
  "items": {
    "file": {
      "zero": "{count} Files",
      "one": "{count} File",
      "two": "{count} Files",
      "few": "{count} Files",
      "many": "{count} Files",
      "other": "{count} Files"
    }
  },
  "translations": {
    "helloWorld": "Hello World",
    "fileCount": "You have {files}",
    "pages": {
      "home": {
        "title": "My Extension",
        "welcome": "Welcome back, {username}!"
      }
    }
  }
}
```

Use this as the starting point for any non-English translation file - same structure, just with the values replaced.


---

<!-- concepts/update-checks-and-extension-calls.md -->

---
title: Update Checks and Extension Calls
description: Implement check_for_updates and process_call, the two standalone hooks on the Extension trait.
---

# Update Checks and Extension Calls

The `Extension` trait has a couple of methods that don't fit the "register stuff with a builder" mould of the other hooks - `check_for_updates` and `process_call`. They're both small, they're both independently optional, and neither has enough surface area to deserve a full page of its own. So this page covers both in one pass.

## Update Checks

`check_for_updates` is how your extension tells the Panel "a newer version of me exists." The Panel calls it on startup and every 12 hours after that, and whatever you return surfaces in the Panel's unified updates page alongside panel-core and node updates.

You implement it on your `Extension` trait:

```rs
use shared::{
    State,
    extensions::{Extension, ExtensionUpdateInfo},
};

#[derive(Default)]
pub struct ExtensionStruct;

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn check_for_updates(
        &self,
        state: State,
        current_version: &semver::Version,
    ) -> Result<Option<ExtensionUpdateInfo>, anyhow::Error> {
        let latest: semver::Version = fetch_latest_version_somehow(&state).await?;

        if latest > *current_version {
            Ok(Some(ExtensionUpdateInfo {
                version: latest,
                changes: vec![
                    compact_str::format_compact!("{latest}: fixed the thing that was broken"),
                    compact_str::format_compact!("{latest}: added a new feature"),
                ],
            }))
        } else {
            Ok(None)
        }
    }
}
```

The return shape:

- **`Ok(None)`** - you're up to date, nothing to show.
- **`Ok(Some(ExtensionUpdateInfo { version, changes }))`** - there's a newer version. `version` is the new `semver::Version`, `changes` is a list of human-readable changelog lines. The changes list *should* cover everything between the current version and the new one, so if there have been multiple releases since the user's version, walk your changelog and collect every entry that's newer than `current_version`.
- **`Err(...)`** - something went wrong (network issue, malformed response from your update server, etc.). The Panel logs it and tries again on the next cycle; the user sees no update notification.

An empty `changes` vec is fine - the Panel will still show the update as available, it just won't render a changelog list.

### Where to Check

`check_for_updates` receives the `State`, so you have the full Panel context available. Most extensions fetch from an HTTP endpoint - a GitHub releases API, your own "product info" server, whatever:

```rs
async fn check_for_updates(
    &self,
    state: State,
    current_version: &semver::Version,
) -> Result<Option<ExtensionUpdateInfo>, anyhow::Error> {
    let release_info: ReleaseInfo = state
        .client
        .get("https://api.github.com/repos/you/your-extension/releases/latest")
        .send()
        .await?
        .json()
        .await?;

    let latest = semver::Version::parse(&release_info.tag_name.trim_start_matches('v'))?;

    if latest > *current_version {
        Ok(Some(ExtensionUpdateInfo {
            version: latest,
            changes: release_info.body.lines().map(compact_str::CompactString::from).collect(),
        }))
    } else {
        Ok(None)
    }
}
```

Use `state.client` (the Panel's shared reqwest client) rather than instantiating your own - it has sensible defaults for timeouts, user agents, and connection pooling already set up.

### Caching

`check_for_updates` runs on every Panel startup and every 12 hours. If your update source is rate-limited (GitHub's unauthenticated API, for example, allows 60 requests/hour per IP), wrap the fetch in `state.cache.cached(...)` so repeated calls during development or after restart storms don't burn through your quota:

```rs
let release_info: ReleaseInfo = state
    .cache
    .cached("my-extension::latest-release", 60 * 60, || async {
        state
            .client
            .get("https://api.github.com/repos/you/your-extension/releases/latest")
            .send()
            .await?
            .json()
            .await
    })
    .await?;
```

The cache key should be scoped to your extension (prefix with your package name) and the TTL should be long enough to matter - an hour is usually fine since the Panel only calls this every 12 hours anyway.

### Not Implementing It

`check_for_updates` has a default implementation that returns `Ok(None)`, so if your extension doesn't need update checking - it's distributed through a marketplace that handles updates itself, or it's a single-use internal tool - you just don't override the method and the Panel silently skips you on every update cycle.

## Extension Calls

`process_call` is how your extension exposes a callable surface to *other extensions* running in the same Panel. It's a synchronous in-process RPC - not HTTP, not events, just one extension calling a function on another.

The sending side looks like this:

```rs
let result: Option<ExtensionCallValue> = state
    .extensions
    .call("myorg.my-ext:get-thing", &[
        Box::new("some-id".to_string()),
    ])
    .await;
```

And the receiving side is the `process_call` method on your `Extension` trait:

```rs
use shared::extensions::{Extension, ExtensionCallValue};

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn process_call(
        &self,
        name: &str,
        args: &[ExtensionCallValue],
    ) -> Option<ExtensionCallValue> {
        match name {
            "myorg.my-ext:get-thing" => {
                let id = args.get(0)?.downcast_ref::<String>()?;
                let thing = lookup_thing(id).await.ok()?;
                Some(Box::new(thing))
            }
            _ => None,
        }
    }
}
```

There is a lot going on in a small amount of code - here is what each part does.

### `ExtensionCallValue` is `Box<dyn Any + Send + Sync>`

Values passed in and out of calls are type-erased Rust values, not serialized JSON. Concretely:

```rs
pub type ExtensionCallValue = Box<dyn std::any::Any + Send + Sync>;
```

This has two big implications.

**You can pass any Rust type that's `Send + Sync + 'static`.** Strings, numbers, your own structs, `Vec<T>`, `HashMap<K, V>`, whatever. No `serde::Serialize` bound, no JSON round-trip, no field renames. The value goes from caller to callee as a pointer.

**Both sides must agree on the concrete type at the downcast site.** The caller boxes up a `String`; the callee does `.downcast_ref::<String>()`. If the callee asks for `.downcast_ref::<&str>()` instead, the downcast returns `None` - even though the data is "really" a string, the type tag doesn't match. There's no structural matching, no coercion, no "well it's close enough." The types have to match exactly.

::: warning
A common mistake is to forget that even same-shaped types with different names are different - `MyStruct` and `OtherStruct` with the same fields are still different types as far as `Box<dyn Any>` is concerned. If you want to share complex data structures across extensions, serialize them into an Intermediate Representation (IR) - a `serde_json::Value`, for example - and pass the IR through the call. That way both sides just need to agree on the IR schema, not the Rust type.
:::

This makes extension calls fast (no serialization) and flexible (any type works), but it also means the contract between two extensions is an unwritten agreement about the exact type of each positional argument and the return. Document that contract somewhere both extensions can see, or it's going to break mysteriously when one side changes.

### Dispatch is "First Extension to Return `Some` Wins"

When someone calls `state.extensions.call("some-name", &args)`, the Panel iterates through every loaded extension in registration order and calls its `process_call` with that name. The first extension whose `process_call` returns `Some(value)` wins - the remaining extensions are never asked.

This means:

- **Your `process_call` must return `None` for names it doesn't recognize.** If you blanket-match everything, you'll start eating calls intended for other extensions that happen to be loaded after yours. The `_ => None` catch-all in the example above is mandatory, not optional.
- **Call names are a shared namespace across every extension.** If two extensions both claim the name `get-user`, whichever loaded first will handle every call to that name, and the other will silently never run. You have no way to target a specific extension from the caller side.
- **Load order matters.** The Panel doesn't guarantee any particular extension load order, so your call name needs to be unique across the entire ecosystem, not just unique within your extension. The convention - same as admin routes and CLI group names - is to prefix with your package name: `"dev.yourname.my-ext:get-thing"` rather than `"get-thing"`.

::: warning
A call name collision is silent - two extensions claiming the same name both compile, both load, and both run, but only one ever gets invoked and there's no warning. Pick names that can't collide: prefix with your package name, and if you're wrapping a common verb, prefix the verb with a scope too (`dev.yourname.my-ext:thing.get` rather than just `dev.yourname.my-ext:get`).
:::

### When to Use It (and When Not To)

Extension calls are the right tool when:

- You're building a suite of extensions that need to share data or functionality, and you control both sides.
- You want one extension to expose a library-like API (lookup helpers, computed state) to others.
- The value being passed is complex enough that HTTP round-trips would be silly overhead.

They're the wrong tool when:

- The "extension" is conceptually a separate process or service. HTTP routes are what you want.
- The caller and receiver are in the same extension. Just call the function directly; there's no reason to go through the dispatcher.
- The data needs to cross a trust boundary. `Box<dyn Any>` dispatch has no schema validation - if the receiver gets a different type than it expects, it returns `None` and the caller has no idea why. Only use extension calls between extensions you control or trust.

### Picking Between `process_call` and `process_call_owned`

The trait has two methods - `process_call(&self, name, &args)` and `process_call_owned(&self, name, Vec<args>)`. The default `process_call_owned` implementation just delegates to `process_call(&args)`, so you normally only implement `process_call` and get the owned version for free.

Override `process_call_owned` only if you specifically need to consume the args (move out of them, for example to avoid cloning a large value). For most extensions, implementing `process_call` alone is enough.


---

<!-- concepts/user-settings.md -->

---
title: User Settings
description: Store per-user preferences that sync across devices, without writing any backend code.
---

# User Settings

The [Settings](/docs/panel/extensions/concepts/settings) page covers configuration the *operator* manages - one value for the whole installation, guarded behind the admin area. But plenty of things aren't the operator's call at all: which view mode a user prefers, a collapsed sidebar section, a per-user toggle your extension renders in its UI. Historically that kind of thing ended up in `localStorage`, which means it silently resets the moment the user opens the Panel on another device.

User settings fix that. They are a per-user key-value store on the Panel: values are arbitrary JSON, they sync across every device the user logs in on, and - unlike global settings - you don't declare a struct, register a deserializer, or write any backend code to add one. Pick a key, write a value, done.

## How It Works

Every user has their own map of `key -> JSON value`, persisted by the Panel and served at `/api/client/account/settings`. The frontend keeps a local replica so reads are instant (even before the network round-trip), writes apply optimistically and flush to the server in debounced batches, and other devices pick the values up when they load the Panel.

Because the browser is the source of truth for these values, the backend deliberately does not validate their shape - your frontend code validates on read with a zod schema and falls back to a default when the stored value is missing or malformed. Don't store anything here that the server must be able to trust.

## Naming Your Keys

Keys are namespaced with `::`, exactly like the global settings table. Use your extension identifier as the namespace so you can never collide with the Panel or another extension:

```
dev.example.myextension::view_mode
```

Bare namespaces such as `app::`, `file_manager::`, `console::`, `dashboard::`, `server::`, `shortcuts::` and `form_engine::` belong to the Panel itself - don't write into them.

## Frontend API

Everything lives in `@/lib/userSettings.ts`. The main entry point is the `useUserSetting` hook - it reads reactively, parses with your schema, and returns a setter that syncs:

```tsx
import { z } from 'zod';
import { useUserSetting } from '@/lib/userSettings.ts';

function MyExtensionPanel() {
  const [viewMode, setViewMode] = useUserSetting(
    'dev.example.myextension::view_mode',
    z.enum(['list', 'grid']),
    'list',
  );

  return <SegmentedControl value={viewMode} onChange={setViewMode} data={['list', 'grid']} />;
}
```

The setter also accepts an updater function (`setViewMode((current) => ...)`), like `useState`.

Outside components there are plain functions:

```ts
import { getUserSetting, removeUserSetting, setUserSetting, subscribeUserSetting } from '@/lib/userSettings.ts';

const mode = getUserSetting('dev.example.myextension::view_mode', z.enum(['list', 'grid']), 'list');
setUserSetting('dev.example.myextension::view_mode', 'grid');
removeUserSetting('dev.example.myextension::view_mode'); // deletes the key everywhere
const unsubscribe = subscribeUserSetting('dev.example.myextension::view_mode', (value) => {
  // fires whenever the effective value changes, e.g. after a sync
});
```

### Device-Local Values

Some preferences shouldn't follow the user around - anything derived from the hardware in front of them (touch input, installed software, attached audio devices). For those, write with `setUserSettingLocal` instead: the value is stored per device and never sent to the server, but reads through `useUserSetting`/`getUserSetting` work exactly the same, with the local value taking precedence.

Users can also pin any synced setting to one device themselves, through the scope menu the Panel renders next to a setting's label. Three functions back that menu:

| Function | What it does |
| --- | --- |
| `overrideUserSettingLocally(key, value)` | Pins a value to this device. No-op for keys in `DEVICE_ONLY_SETTING_KEYS`. |
| `clearUserSettingOverride(key)` | Drops the device override, so the account value applies again. |
| `pushUserSettingToAccount(key)` | Sends the current device value up as the new account value and clears the override. |

## Backend Access

Extensions rarely need to read user settings server-side, but when they do, `user.get_settings()` returns the user's settings. Values deserialize into your own serde types on demand - no registration anywhere:

```rs
#[derive(serde::Deserialize, Default)]
#[serde(default)]
pub struct MyPrefs {
    pub view_mode: compact_str::CompactString,
}

let settings = user.get_settings(&state.database).await?;
let prefs: Option<MyPrefs> = settings.get("dev.example.myextension::prefs");
```

Reads are cached for 60 seconds per user and invalidated on write, so this is cheap to call from routes. The returned `UserSettings` is read-only and derefs to the raw `key -> serde_json::Value` map. To write, use `user.get_settings_mut(&state.database)` instead: it takes a per-user writer lock, derefs mutably so you `insert`/`remove` on the map directly, and persists everything (including removals) with `settings.save(&state.database)`.

## Rules of the Road

- **JSON `null` deletes.** Sending `null` for a key over the API removes it, so `null` is not a storable value. Model "unset" as key absence and let your zod fallback handle it.
- **There are limits.** Operators control how many keys a user may have and how large one value may be (`Max Synced Settings` and `Max Synced Setting Size` in the admin user settings, 512 keys / 16 KiB by default). Store preferences, not documents.
- **Impersonation is read-only.** While an admin impersonates a user they see the user's settings, but writes are rejected by the server and skipped by the frontend - an admin browsing around can't silently rewrite someone's preferences.
- **Unbounded collections need one key.** If you keep per-item flags (dismissals, expanded groups), store one map-valued key rather than one key per item, and prune stale entries when you write.


---

<!-- dev-environment.md -->

---
title: Development Environment
description: Set up a development environment for building Calagopus Panel extensions.
---

# Setting up your Development Environment

This guide walks through setting up a development environment for creating extensions for the Calagopus Panel.

## Prerequisites

Before you begin, ensure you have the following installed on your machine:

- Node.js (version 24 or higher)
- pnpm (version 11 or higher)
- Rust (latest stable version)
- A code editor (e.g., Visual Studio Code)
- Git (any reasonable version)
- A PostgreSQL server (version 16 or higher) for the database
- A Redis server (version 7 or higher) for caching

## Installing the Panel Locally

### Step 1: Clone the Repository

Clone the Calagopus Panel repository:

```bash
git clone https://github.com/calagopus/panel.git calagopus-panel
cd calagopus-panel
```

### Step 2: Install Dependencies

Install the dependencies with pnpm:

```bash
# Frontend dependencies
cd frontend
pnpm install
cd ..

# Database dependencies (technically optional)
cd database
pnpm install
cd ..
```

### Step 3: Set Up Environment Variables

Copy the `.env.example` file to `.env` and modify it as needed:

```bash
cp .env.example .env
```

Configure PostgreSQL/Redis and your app encryption keys in the `.env` file.

### Step 4: Build the Project

Run the following from the root directory:

```bash
# build frontend, required to build the backend
cd frontend
pnpm build
cd ..

# migrate database
SQLX_OFFLINE=true cargo run -p database-migrator -- migrate

# build & run backend
SQLX_OFFLINE=true cargo run
```

### Step 5: Running the Development Server

With a working backend, run the frontend development server:

```bash
cd frontend
pnpm dev

# start dev server on port 8081
# pnpm dev --port 8081

# backend is on port 9999
# BACKEND_PORT=9999 pnpm dev
```

By default, the frontend is available at `http://localhost:5173`; the dev server proxies API requests to the backend at `http://localhost:8000`. If your backend uses a different port, set the `BACKEND_PORT` environment variable.

## Updating the Development Environment

Pull the latest changes from the main repository:

```bash
rm Cargo.lock frontend/pnpm-lock.yaml # remove lockfiles to avoid git conflicts
git pull # if there are additional conflicts, resolve them here
```

Then rebuild the project:

```bash
# build frontend, required to build the backend
cd frontend
pnpm install # install any new dependencies
pnpm build
cd ..

# migrate database
SQLX_OFFLINE=true cargo run -p database-migrator -- migrate

# build & run backend
SQLX_OFFLINE=true cargo run
```


---

<!-- disabling-extensions.md -->

---
title: Disabling Extensions
description: Turn an installed extension off without uninstalling or recompiling it, on any Calagopus Panel deployment.
---

# Disabling Extensions

Disabling an extension keeps it installed but stops the Panel from running it. On the next start, the Panel skips the extension's backend entrypoints, and the frontend never runs its entrypoint - so its routes, permissions, background tasks, model extensions, settings and pages are simply not there.

Unlike installing and uninstalling, this needs **no recompile**, which means it works on every deployment: the regular `:latest` and `:nightly` images, the `:heavy` variants, the all-in-one image and a plain binary install.

::: tip Reach for this when an extension misbehaves
An extension that breaks the Panel - a failing migration, a panic during startup, a route that takes the API down - can be disabled to get the Panel back on its feet, without losing the extension or its data. Its migrations are skipped while it is disabled and applied again when you enable it.
:::

## Disabling an Extension

Open the Panel's extension management page under **Admin → Extensions** and flip the switch on the extension's card. It requires the `extensions.manage` admin permission.

The change is stored right away, but the Panel only picks it up when it starts, so the card shows a **Pending restart** badge until then. On the `:heavy` images the alert above the extension list has a **Restart the panel** button; everywhere else, restart the Panel the way your deployment normally does (`docker compose restart web`, `systemctl restart calagopus`, ...).

Once the Panel comes back, the extension's card shows a **Disabled** badge, and its **Configure** button is greyed out - the configuration page is registered by the entrypoint that no longer runs.

## What Happens to its Data

Nothing is removed. Disabling only stops code from running:

- **Settings** the extension stored stay in the database, untouched, and come back exactly as they were when you enable it again.
- **Migrations** are not rolled back. New migrations shipped by an update are not applied while the extension is disabled, they run the next time the Panel starts with the extension enabled - before its entrypoints run.
- **Permissions** the extension added stay valid on roles, subusers and API keys. They are not offered in the permission pickers while the extension is disabled, but existing grants survive and take effect again once you enable it.

## Things to Know

- **Its columns stay, its hooks do not.** An extension that added a column to a core table through a migration and filled it from a create handler no longer fills it while disabled. If that column is `NOT NULL` without a default, creating the model it extends will fail until you enable the extension again.
- **Other extensions calling into it** get nothing back: extension calls are not dispatched to a disabled extension, and looking up its settings from another extension fails.
- **CLI commands stay registered.** They are set up before the Panel reads which extensions are disabled, so an extension's own commands remain available.
- **Its frontend code is still shipped, but inert.** The browser still downloads and evaluates a disabled extension's frontend module - it just never runs its entrypoint, and the extension's stylesheet is loaded in a disabled state, so none of its CSS applies. What does survive is what gets baked into the shared bundle: a core component the extension replaces through `overrides.ts` stays replaced. Uninstall the extension if you need the original component back.

## Uninstalling Instead

If you want the extension gone rather than paused, see [Uninstalling Extensions](/docs/panel/extensions/uninstalling-extensions) - that removes its code and requires a recompile, so it needs a `:heavy` image or a development environment.


---

<!-- file-structure.md -->

---
title: Extension File Structure
description: How an extension is laid out on disk across its frontend, backend, and database parts.
---

# Extension File Structure

Extensions are split into three main parts: Frontend, Backend, and Database.

All Extensions have a package name, which is defined in the backend `Cargo.toml` file, and is also required in the `Metadata.toml` file.
These are semi-java-like package names, so they should be all lowercase, and can contain dots, for example `dev.0x7d8.test`.

Package name with underscores (also referred to as package identifiers) means dots are replaced with underscores, so `dev.0x7d8.test` turns into `dev_0x7d8_test`.

## Initializing an Extension

Use the extension templates to get an extension up and running quickly.

```bash
# create a new extension from the template, replace the name with your package name with underscores
panel-rs extensions init dev.0x7d8.test # <-- replace this with your package name
```

## Frontend

```bash
backend-extensions/
  (package_identifier)/
    frontend/
    package.json # REQUIRED file containing additional dependencies
    public/ # optional directory to include static files,
      file1.jpg # this file would be available at <url>/file1.jpg
    src/ # REQUIRED directory for typescript src
      app.css # optional css file, bundled as its own chunk so it can be disabled with the extension
      index.(ts|tsx) # REQUIRED file containing extension entrypoint
      translations.ts # optional file containing extension translations
```

::: info Compatibility symlinks
Everything an extension owns lives under `backend-extensions/<package_identifier>/`. The older paths `frontend/extensions/<identifier>` and `database/extension-migrations/<identifier>` still exist as symlinks so existing tooling keeps working, and which of the pair is the real directory depends on the container type. Treat `backend-extensions/` as canonical: deleting or copying "the directory" through the legacy path may only move a link.
:::

### package.json

Add dependencies for your frontend extension code to this `package.json`, or leave it as-is - the required extension code and all dependencies of the base panel are already available.

```json
{
  "name": "extension",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "main": "src/index.ts",
  "types": "src/index.ts",
  "dependencies": {
    "shared": "workspace:*"
  }
}
```

### src/index.ts

```ts
import { Extension, ExtensionContext } from 'shared';
import type { MantineThemeOverride } from '@mantine/core';

// the class name doesn't matter much, but naming it after your package is advisable
class Dev0x7d8TestExtension extends Extension {
  public cardConfigurationPage: React.FC | null = null;
  public cardComponent: React.FC | null = null;
  public cardIcon: React.ReactNode = null;

  // Your extension entrypoint, this runs when the page is loaded
  public initialize(ctx: ExtensionContext): void {
    console.log('Dev0x7d8TestExtension initialized!', ctx);
  }

  // Your extension mantine theme entrypoint, this runs when the page is loaded
  public initializeMantineTheme(ctx: ExtensionContext): MantineThemeOverride {
    return {};
  }

  // Your extension can also provide a resolver for css variables, this runs when the page is loaded
  public initializeMantineCssResolver(ctx: ExtensionContext): CSSVariablesResolver | null {
    return null;
  }

  /**
   * Your extension call processor, this can be called by other extensions to interact with yours,
   * if the call does not apply to your extension, simply return `ctx.skip()` to continue the matching process.
   *
   * Optimally (if applies) make sure your calls are globally unique, for example by prepending them with `yourauthorname_yourextensioname_`
   */
  public processCall(ctx: ExtensionContext, name: string, args: object): unknown {
    return ctx.skip();
  }
  
  // https://typedocs.calagopus.com/classes/extensions_shared_src_extension.Extension
}

export default new Dev0x7d8TestExtension();
```

## Backend

```bash
backend-extensions/
  (package_name_with_underscores)/
    Cargo.toml # REQUIRED file containing extension identifier (again), Author information and dependencies
    Metadata.toml # REQUIRED file containing additional extension information
    src/ # REQUIRED directory for backend rust src
      lib.rs # REQUIRED file containing extension backend entrypoint
```

### Cargo.toml

```toml
[package]
name = "dev_0x7d8_test" # once again, package name with underscores
description = "Test John Pork effortlessly." # short description of your extension
authors = ["0x7d8"] # authors of your extension
version = "1.0.0" # version of your extension
edition = { workspace = true }

[dependencies]
shared = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
```

### Metadata.toml

```toml
package_name = "dev.0x7d8.test" # package name without underscores
name = "0x7d8 Extension Test" # human-readable name of your extension
panel_version = ">=1.1.0" # panel version requirement of your extension, must be a valid semver comparator
```

::: warning
`panel_version` is enforced when the extension is loaded. The requirement must rule out panel versions older than `1.1.0` - a requirement like `>=1.0.0` (which would *admit* pre-1.1.0 panels) is declined outright, and an extension whose requirement doesn't match the running panel version won't load either.
:::

### src/lib.rs

```rs
use shared::{State, extensions::Extension};

#[derive(Default)]
pub struct ExtensionStruct; // must be named this, must implement Default and Send

#[async_trait::async_trait]
impl Extension for ExtensionStruct {
    async fn initialize(&mut self, _state: State) {
        tracing::info!("dev_0x7d8_test extension initialize called");
    }
    
    // https://cratedocs.calagopus.com/shared/extensions/trait.Extension
}
```

## Database (optional)

```bash
backend-extensions/
  (package_identifier)/
    migrations/
    (yyyymmddhhmmss)_migration_name/
      up.sql # REQUIRED file containing the SQL statements to apply the migration
      down.sql # REQUIRED file containing the SQL statements to rollback the migration
```

::: info
Auto-generate the migration files by running `panel-rs database-migrator create <package_name>` - this creates a new migration with the correct timestamp and file structure for you to fill in.
:::

### up.sql

```sql
-- SQL statements to apply the migration, for example:
CREATE TABLE IF NOT EXISTS dev_0x7d8_test_table (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

### down.sql

```sql
-- SQL statements to rollback the migration, for example:
DROP TABLE IF EXISTS dev_0x7d8_test_table;
```


---

<!-- getting-your-extension-ready.md -->

---
title: Getting your Extension ready
description: Pre-export checks and packaging steps that turn a finished extension into the .c7s.zip file users install.
---

# Getting your Extension ready

Once you've finished developing your extension, there are a few things to do before it's ready to ship. This guide walks through the pre-export checks you should run, then how to produce the `.c7s.zip` file that users will install.

This guide assumes you already have a working [Development Environment](./dev-environment.md) and an extension initialized under `backend-extensions/<package_identifier>` (see [Extension File Structure](./file-structure.md) for how the layout works). Throughout this guide, `<package_name>` refers to your extension's regular (dotted) package name - for example, `dev.0x7d8.test`. The exported file uses the underscored form of that same name (e.g. `dev_0x7d8_test.c7s.zip`).

## Pre-export checks

These checks are not strictly required for `panel-rs extensions export` to succeed, but you should run them before every export. They catch the vast majority of issues that would otherwise bite your users on install.

### Backend: format and lint

Run these from the Panel repository root.

```bash
cargo fmt
cargo clippy
```

`cargo fmt` rewrites your extension's Rust code to match the project's formatting style. It edits files in place, so always commit or stash before running it if you want to review the diff.

`cargo clippy` runs Rust's linter against your code. Clippy warnings often point at real correctness issues (misuse of `unwrap`, unnecessary clones, suspicious patterns), not just style - read them rather than silencing them. Your extension should produce no new warnings before you export.

### Frontend: format, lint, and build check

Run these from the `frontend/` directory.

```bash
cd frontend
pnpm biome:fix-unsafe
pnpm build:ci
```

`pnpm biome:fix` runs [Biome](https://biomejs.dev) with its auto-fixes enabled. This normalizes formatting and fixes a broader set of lint issues - after running it once, run it again to see things that you need to manually look at. Like `cargo fmt`, it edits files in place, so commit or stash before running if you want to review the diff.

`pnpm build:ci` does a full production frontend build with all extensions compiled in. If your extension has a TypeScript error, a bad import, or a missing dependency in its `package.json`, this is where you'll catch it. The exported `.c7s.zip` ships your source, not a build artifact, so users will hit the same error on their side if you skip this step.

::: warning
If `pnpm build:ci` fails because of an extension other than yours, that extension has problems of its own - but your extension still won't build cleanly alongside it. Fix what you own; for anything else, reach out to that extension's author or temporarily disable it while you iterate.
:::

## Exporting the extension

Once the checks above pass, run the export command from the Panel repository root:

```bash
panel-rs extensions export <package_name>
```

For example, for a package named `dev.0x7d8.test`:

```bash
panel-rs extensions export dev.0x7d8.test
```

This bundles the frontend, backend, database migrations, `Cargo.toml`, and `Metadata.toml` for that extension into a single `.c7s.zip` file and writes it to `./exported-extensions/` relative to your current directory. The output filename uses the underscored form of the package name:

```bash
ls -lh ./exported-extensions/
# dev_0x7d8_test.c7s.zip
```

That `.c7s.zip` is the file you distribute. Users can install it through either a `:heavy` Docker deployment or a local dev environment - see [Installing Extensions](./installing-extensions.md) for their side of the process.

## Shipping checklist

Before you publish a release, quickly run through:

- `cargo fmt` produced no unexpected changes (or you reviewed and committed them).
- `cargo clippy` is clean, or every remaining warning is something you've deliberately decided to accept.
- `pnpm biome:fix-unsafe` produced no unexpected changes.
- `pnpm build:ci` succeeds.
- The `version` field in your extension's `Cargo.toml` has been bumped if this is a new release.
- The panel version requirement in your extension's `Metadata.toml` is still correct - bump it if your extension relies on APIs added in a newer Panel release.
- `panel-rs extensions export <package_name>` produced a `.c7s.zip` in `./exported-extensions/`.
- You've test-installed the exported `.c7s.zip` into a clean Panel instance to confirm it works end-to-end.

The last step is the one most often skipped and most often responsible for broken releases. Do it.


---

<!-- index.md -->

---
description: "Extend the Calagopus panel with native Rust extensions: installing, developing, and publishing extensions plus core concept guides."
---

# Extensions

Extensions are the primary way to extend the functionality of the Panel. They allow you to add backend logic, frontend interfaces, database migrations, and deep system integrations in a structured and controlled way.

For a high-level architectural breakdown of how extensions work within the system, see the [Technical Overview section](../overview.md#technical-overview).

## Getting Started

If you are new to extensions, start here:

- [Installing Extensions](./installing-extensions.md) - Install `.c7s.zip` extensions in the Panel
- [Uninstalling Extensions](./uninstalling-extensions.md) - How to remove extensions and what happens to their data
- [Disabling Extensions](./disabling-extensions.md) - Turn an extension off without uninstalling or recompiling it
- [Switching to the Heavy Image](./switching-to-the-heavy-image.md) - Use the `:heavy` Docker image variant to enable extension support in a containerized setup
- [Patching and Adding Translations](./patching-translations.md) - Override the Panel's built-in translations or add a new language on the heavy image
- [Development Environment](./dev-environment.md) - Set up a local environment for building extensions
- [Extension File Structure](./file-structure.md) - How extensions are structured across frontend, backend, and database layers

## Concepts

Extensions are built around several core systems:

- [Theming](./concepts/theming.md) - Customize the look and feel of the Panel interface
- [Events](./concepts/events.md) - React to system and user actions
- [Settings](./concepts/settings.md) - Define configurable extension options
- [Routing](./concepts/routing.md) - Add custom backend routes
- [Permissions](./concepts/permissions.md) - Control access to extension features
- [CLI Commands](./concepts/cli-commands.md) - Extend the Panel command line interface
- [Background Tasks and Shutdown Handlers](./concepts/background-tasks-and-shutdown-handlers.md) - Run async or lifecycle-based logic
- [Update Checks and Extension Calls](./concepts/update-checks-and-extension-calls.md) - Communicate between extensions and system services
- [Frontend API Calls](./concepts/frontend-api.md) - Interact with backend APIs from the UI
- [Activity Logging](./concepts/activity-logging.md) - Record extension-related actions
- [Translations](./concepts/translations.md) - Provide multi-language support
- [Mounting UI](./concepts/mounting-ui.md) - Inject UI elements into the Panel interface
- [Quick Actions](./concepts/quick-actions.md) - Add entries, categories and prefix modes to the command palette
- [Toasts](./concepts/toasts.md) - Show transient feedback to the user
- [Extending Models](./concepts/extending-models.md) - Add fields to existing data models
- [Email Templates](./concepts/email-templates.md) - Define custom email templates for notifications and communications
- [Speaking Game Protocols](./concepts/speaking-game-protocols.md) - Open raw TCP/UDP tunnels to a server's port to query it directly
- [File Storage](./concepts/file-storage.md) - Manage files and directories within the Panel
- [Forms](./concepts/forms.md) - Add fields to the Panel's existing forms
- [User Settings](./concepts/user-settings.md) - Store per-user preferences that sync across devices

## Installation Methods

Extensions can be installed in multiple ways depending on your setup:

- Docker-based installation (requires `:heavy` or `:nightly-heavy` image)
- Development environment installation
- Manual `.c7s.zip` placement into the extensions directory

See [Installing Extensions](./installing-extensions.md) for full instructions.

## Structure Overview

Extensions follow a standardized multi-part structure consisting of:

- Frontend (React-based UI layer)
- Backend (Rust-based logic layer)
- Database migrations (optional)

This structure defines how extensions are loaded, initialized, and integrated into the Panel.

For a complete breakdown of directories, required files, package naming, and extension entrypoints, see [Extension File Structure](./file-structure.md).


---

<!-- installing-extensions.md -->

---
title: Installing Extensions
description: Install a .c7s.zip extension into your Calagopus Panel.
---

# Installing Extensions

Extensions ship as `.c7s.zip` files - a single archive containing both backend and frontend code. This page covers how to install one in your Calagopus Panel.

::: warning Requires the `:heavy` image or a development environment
Installing extensions requires the Panel to compile new code (yours, plus whatever the extension brings) at install time. The regular `:latest` and `:nightly` Docker images don't include the toolchain for that. You need either:

- The `:heavy` or `:nightly-heavy` Docker image variant, or
- A full local development environment

If you're not on the heavy docker image already, switch to the heavy variant first. See [Switching to the heavy image](./switching-to-the-heavy-image.md).
:::

## Install an Extension

The steps depend on which environment you're running. Pick the matching tab:

::::tabs
=== With Docker

Once your stack is on `:heavy` or `:nightly-heavy`, you have two options.

**Option 1: Upload through the admin UI.** Open the Panel's extension management page, drop the `.c7s.zip` file into the upload area, and the Panel handles the rest - install, compile, and load.

![Placeholder: extension upload UI](./admin-extensions-ui-empty.webp)

**Option 2: Drop the file in directly and restart.** Copy the `.c7s.zip` into the Panel's `extensions/` data directory (with the default heavy compose stack, that's `./build/extensions` relative to your compose file), then restart the container:

```bash
docker compose restart web
```

The Panel detects the new file on startup and installs it. Watch the progress in the admin UI from Option 1, or wait - it shouldn't take more than a minute or two, even for complex extensions.

=== With Development Environment

Add the extension source to your tree:

```bash
panel-rs extensions add path/to/extension.c7s.zip
```

That gets the source in place but doesn't compile it yet. To compile and apply:

```bash
panel-rs extensions apply --profile balanced
```

The `balanced` profile compiles the backend with cargo's `heavy-release` profile - production-grade optimization but a lot faster to compile than `release`. If you're iterating locally and want faster compile times, use `--profile dev` instead, which compiles with cargo's `dev` profile. Don't ship `dev`-built binaries to production; the speed comes at a real performance cost.

::: details Manual frontend + backend builds
If you'd rather drive the build steps yourself instead of going through `extensions apply`:

```bash
cd frontend
pnpm i # extensions may bring new dependencies
pnpm build:fast

cd ..
cargo b --profile heavy-release
# binary lands at ./target/heavy-release/panel-rs
```

Same end result; just more granular if you're debugging a build issue. If you manually added files/reset your internal-list meta extension, run `panel-rs extensions resync` to refresh the internal state before building.
:::

::::


---

<!-- patching-translations.md -->

---
title: Patching and Adding Translations
description: Patch the Panel translations or add whole languages from the heavy image, with JSON override files deep-merged over the shipped strings.
---

# Patching and Adding Translations

The heavy image lets you patch the Panel's own translations - reword a label, fix a phrasing you don't like, or fill in keys for a language - without forking the Panel or rebuilding the image yourself. You drop small JSON override files into a directory, trigger a rebuild, and the heavy image deep-merges your changes on top of the base translations the Panel ships.

::: info This is the operator-level mechanism, not the extension one
This page is about overriding the **base Panel's** translations as an operator running the heavy image. If you're an extension author and want to ship translations *with your extension*, that's a different (and simpler) workflow - see [Concepts → Translations](./concepts/translations.md). The two don't conflict: extensions declare their own keys, and this override mechanism patches whatever ends up in the final translation files.

This only works on the **heavy image**. If you're still on the regular image, start with [Switching to the Heavy Image](./switching-to-the-heavy-image.md).
:::

## How It Works

When you switched to the heavy image you added a `./build/translations` volume mount that maps to `/app/translations` inside the container:

```yml
volumes:
  - ./build/translations:/app/translations
```

The heavy image's entrypoint does three things:

1. **Copies the Panel's base translation files** into `/app/translations/` - one flat JSON file per language (`en.json`, `de.json`, `es.json`, `fr.json`, …). This runs on every boot and after every rebuild, so these top-level files are *regenerated from the shipped Panel* each time. They are there for you to read, as the reference for what keys exist.
2. **Stages your changes, before the frontend is built.** Any top-level `<lang>.json` that isn't one of the shipped filenames is staged as a new language, and every `*.json` in `/app/translations/overrides/` is deep-merged onto the base language with the **same filename**. So `overrides/en.json` merges into `en.json`, `overrides/de.json` into `de.json`, and so on.
3. **Builds the frontend from that staged set.** The result is compiled into the Panel binary, which is where the Panel serves translations from - so your changes only take effect through a rebuild (see [Applying Your Changes](#applying-your-changes)).

Mapped back to your host, the override directory is:

```text
./build/translations/overrides/
```

::: warning Edit the overrides, never the top-level files
The top-level files for languages the Panel already ships (`./build/translations/en.json`, etc.) are **overwritten from the base Panel on every boot**, and edits to them are both wiped and ignored by the build. The `overrides/` directory is the durable, upgrade-safe place to put your changes - because step 1 regenerates the base and step 2 reapplies your overrides on top, your patches survive Panel upgrades automatically (as long as the keys still exist).
:::

## The Merge

The merge is a recursive deep-merge:

- **Objects are merged key-by-key**, recursively. You only name the keys you want to change; everything else keeps the Panel's value.
- **Strings, numbers, and arrays are replaced** wholesale at the leaf.

This means an override file is small - it mirrors only the slice of the structure you're touching, not the whole file.

## File Shape

Each language file has two top-level keys, `items` and `translations`, exactly like the [extension translation files](./concepts/translations.md#shipping-custom-translations-with-your-extension):

- **`translations`** - regular strings, nested as deeply as the Panel nests them. The Panel's convention is `pages.<section>.<page>.<element>` with leaf categories like `button`, `modal`, `form`, `toast`, `error`. Anything in `{braces}` is an interpolation variable - keep it intact or the string breaks.
- **`items`** - pluralized count strings, each with the six CLDR plural categories (`zero`, `one`, `two`, `few`, `many`, `other`).

The base file you're patching against is the Panel's [`frontend/src/translations.ts`](https://github.com/calagopus/panel/blob/main/frontend/src/translations.ts) (the generated JSON lives at `./build/translations/en.json` on your host once the container has booted once - open it to find the exact key path you want to change).

## Patching an Existing String

Say you want to reword the English account page title and tweak a button label. Find the keys in `en.json`, then create `./build/translations/overrides/en.json` containing **only** those keys, nested to match:

```json
{
  "translations": {
    "pages": {
      "account": {
        "home": {
          "title": "My Dashboard"
        }
      }
    },
    "common": {
      "button": {
        "save": "Save changes"
      }
    }
  }
}
```

Everything else in `en.json` is left untouched - the merge only overwrites `pages.account.home.title` and `common.button.save`.

## Filling In or Fixing a Translated Language

The exact same mechanism works for any language the Panel ships. To override a German string, create `./build/translations/overrides/de.json`:

```json
{
  "translations": {
    "common": {
      "button": {
        "save": "Speichern"
      }
    }
  }
}
```

If you're filling in a pluralized item, provide every plural category your language uses. The [Unicode CLDR plural rules table](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) is the authoritative reference for which categories a language needs.

```json
{
  "items": {
    "server": {
      "zero": "{count} Server",
      "one": "{count} Server",
      "two": "{count} Server",
      "few": "{count} Server",
      "many": "{count} Server",
      "other": "{count} Server"
    }
  }
}
```

You can patch any language the Panel already ships. To see what's available, look at the top-level `<lang>.json` files under `./build/translations/` after a rebuild, or hit `GET /api/languages`.

## Adding a Brand-New Language

For a language the Panel doesn't ship yet, you don't use `overrides/` - those *merge into* an existing file, and there's nothing to merge into. Instead, drop a top-level file straight into the translations volume:

```text
./build/translations/<lang>.json
```

The easiest way to start is to copy the shipped English file and translate it in place:

```bash
cp ./build/translations/en.json ./build/translations/eo.json
# then translate the values in eo.json
```

On the next rebuild this file is compiled into the Panel binary alongside the shipped languages, so the new language is **served** at `/translations/<lang>.json` *and* listed by `/api/languages`, which is what populates the language picker in account settings. The picker labels it using the browser's own locale data, so a valid language code shows up under its proper name with nothing further to register. (The boot-time copy of the shipped defaults only writes over their own filenames - it leaves your extra file alone.)

Pick a filename the Panel doesn't already ship. A top-level file whose name matches a shipped language is treated as the regenerated base copy and ignored - to change a shipped language, use `overrides/` instead.

You don't have to translate everything up front. Any key you leave out falls back to its **English** value, so a partial file is perfectly usable - users on that language just see English for whatever you haven't translated yet, and you can fill more in over time. Where your language uses extra plural forms, fill in the CLDR categories that apply (`zero`, `one`, `two`, `few`, `many`, `other`); the [Unicode CLDR plural rules table](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) is the reference.

::: info overrides/ vs. a top-level file
A file in `overrides/` *patches* a language - merged on top, so it can be a tiny partial. A file directly in `./build/translations/` *is* a language - it stands on its own (with English filling any gaps). Use the first to tweak a shipped language, the second to introduce a new one.
:::

## Applying Your Changes

Translations are compiled into the binary, so they take effect **through a rebuild**. After you add or edit a language file or an override, trigger one:

- **From the admin UI:** go to the extensions management page and click **Rebuild**. (Requires the `extensions.manage` admin permission.)
- It also runs automatically as part of any extension install/uninstall rebuild.
- A `docker compose restart` picks the change up too - the entrypoint notices the translations differ from whatever the cached binary was built with and rebuilds on boot.

Rebuilds are cached by the combination of your installed extensions *and* your translation files, so a translations-only edit is enough to invalidate the cache and produce a real build. Once it finishes, reload the Panel and your strings are live.

::: info The rebuild takes a while, and the Panel stays up
A rebuild compiles the frontend and the binary. The Panel keeps serving on the previous binary while that happens and switches over when it finishes, so this isn't downtime - but your strings won't change until it completes. Watch the extension build log if you want to follow along.
:::

::: warning Reverting a change also needs a rebuild
Removing a language file or an override doesn't take effect until the next rebuild either. Because the cache key follows your translation files, deleting them usually returns you to a binary that was already built and cached, which makes that particular rebuild fast or instant.
:::

## Caveats

- **Override existing keys, don't invent new ones.** The override is merged into the base file, but the Panel's frontend only *reads* keys it knows about. Adding a key the Panel never references does nothing; patch keys that already exist.
- **Keep interpolation variables intact.** If the original string is `Showing {start} to {end} of {total} results.`, your override has to keep `{start}`, `{end}`, and `{total}` - dropping or renaming them breaks the rendered string.
- **Patches follow the base, not the other way around.** Because the base is regenerated every rebuild, if a Panel upgrade renames or removes a key, your override for the old key simply has nothing to merge into and silently stops applying. After a major upgrade, skim the diff in `./build/translations/en.json` if a patched string reverts.
- **Valid JSON only.** A malformed override file is logged and skipped during the rebuild rather than applied - check the extension build log if a change doesn't take.


---

<!-- switching-to-the-heavy-image.md -->

---
title: Switching to the Heavy Image
description: Swap from the regular Docker image to the heavy variant that supports extensions.
---

# Switching to the Heavy Image

If you're running the regular `:latest` (or `:nightly`) Docker image and want to start using extensions, you'll need to switch to the heavy variant - `:heavy` (or `:nightly-heavy`). This page walks through the swap.

The migration itself is small: edit two things in your `compose.yml`, restart the stack, done. No database migration, no data export, no settings to re-enter. Your existing data carries over because the heavy image runs against the same Postgres, the same Redis, the same volumes.

::: warning Confirm system requirements first
The heavy image needs notably more CPU and disk than the regular image, because it ships with the Rust toolchain and Node.js needed to recompile the Panel when you install or uninstall extensions. RAM also spikes during a build. Before switching, double-check your host meets the minimum requirements for the heavy image: see [Panel - Minimum Requirements](../overview.md#minimum-requirements).

If you're on a tight VPS or a low-spec home server, you may want to scale up before the swap rather than discover mid-build that you've run out of RAM.
:::

## Handle Your Encryption Key Carefully

This is the one part of the migration that's actually dangerous to mess up.

Both the stock and heavy compose files contain `APP_ENCRYPTION_KEY=CHANGEME` as a placeholder. **You almost certainly already replaced that with a real key when you first set up the Panel.** That key is what encrypts secrets in your database (API tokens, SMTP passwords, anything sensitive). If you lose the key or change it, every previously-encrypted value becomes unrecoverable - your Panel will very likely become non-functional until you clean up the database and re-enter any secrets.

Two rules:

- **Preserve the exact same `APP_ENCRYPTION_KEY` value** when you edit your compose. Don't regenerate it. Don't treat `CHANGEME` in the heavy compose example below as something to randomize - that's the placeholder; your real value goes in its place.
- **Treat your compose file as a secret.** Don't commit it to a public repo, don't paste it into a support channel, don't share screenshots without redacting it. Anyone with the encryption key can decrypt the secrets stored in your database.

::: details I lost my encryption key. Now what?
Stop the Panel, restore the database from a backup taken before the key was lost (if you have one), and start over from there. Without a backup or the original key, the encrypted columns in the database are permanently unrecoverable - the practical recovery is to start with a fresh database, which means losing all your existing data and settings. Review your backup strategy and secret management practices to prevent it from happening again.
:::

## The Swap

Stop the stack:

```bash
docker compose down
```

Open `compose.yml` and make two changes to the `web` service.

**Change 1: Update the image tag.** Match your current tag to the heavy equivalent:

| You're on | Switch to |
| --- | --- |
| `:latest` | `:heavy` |
| `:nightly` | `:nightly-heavy` |
| `:aio` | `:heavy-aio` |
| `:nightly-aio` | `:nightly-heavy-aio` |

::: warning AIO operators: stay on the AIO track
If you're on an `:aio` variant (panel + Wings bundled in one container), switch to the **`-aio`** heavy variant - `:heavy-aio` or `:nightly-heavy-aio`. Switching from `:aio` directly to plain `:heavy` will break your bundled Wings node, since plain `:heavy` doesn't include Wings. The migration is otherwise structurally identical between AIO and non-AIO.
:::

```diff
 services:
   web:
-    image: ghcr.io/calagopus/panel:latest
+    image: ghcr.io/calagopus/panel:heavy
```

**Change 2: Add four new volume mounts** for the build artifacts the heavy image produces. These get added to the existing `volumes:` block under `web`:

```diff
     volumes:
       - ./data:/var/lib/calagopus
       - ./logs:/var/log/calagopus
+      - ./build/binaries:/app/binaries
+      - ./build/translations:/app/translations
+      - ./build/extensions:/app/extensions
+      - ./build/extension-migrations:/app/repo/database/extension-migrations
```

The `./build/` host directories don't need to exist beforehand - Docker creates them on startup as needed.

Bring the stack back up:

```bash
docker compose up -d
```

That's it. The heavy image starts, picks up the same database and existing volumes, and from this point on you can install extensions through the admin UI. See [Installing Extensions](./installing-extensions.md).

## Reverting

If you decide the heavy image isn't for you (resource pressure, you're not actually using extensions), reverting is the same dance in reverse:

1. Edit `compose.yml`, change the heavy tag back to its non-heavy equivalent (e.g. `:heavy` → `:latest`, `:heavy-aio` → `:aio`).
2. `docker compose down` then `docker compose up -d`.

The stock image ignores anything under `./build/*` and starts cleanly. You don't need to uninstall extensions first, and you don't need to remove the four extra volume mounts you added (though you can clean them up if you want a tidy compose file). Your data carries over the same way it did in the original swap.

The extension code in `./build/extensions` stays on disk while you're on the stock image, so if you switch back to heavy later, your previously-installed extensions are still there and recompile on startup. Otherwise remove them from the compose and delete them from disk.


---

<!-- uninstalling-extensions.md -->

---
title: Uninstalling Extensions
description: Remove an extension from your Calagopus Panel, and what happens to its data when you do.
---

# Uninstalling Extensions

This page covers how to remove an extension from your Calagopus Panel - and what happens to its data when you do (it stays put).

If you only want to stop an extension from running for a while, you don't have to uninstall it: [Disabling Extensions](./disabling-extensions.md) turns one off in place, with no rebuild and nothing removed.

::: warning Data is not removed when an extension is uninstalled
Uninstalling an extension removes its code, not its data. The settings the extension stored, any rows it wrote into core tables via model extensions, and any tables/columns its migrations created **all stay in the database**. The Panel deliberately leaves them alone, because:

- If you reinstall the same extension later (after troubleshooting, or to upgrade to a newer version), it picks up exactly where it left off - users don't lose their configuration, server data isn't wiped.
- If you've decided to permanently get rid of an extension, the data it left behind is yours to inspect, archive, or drop on your own terms.

There's an opt-in `--remove-migrations` flag on the dev-environment uninstall command that will roll back the extension's migrations during removal. **It's not recommended.** Migrations rolling back means dropping columns, dropping tables, deleting data. If you're confident you want it gone, run a manual database cleanup with a backup in hand instead - that way you know exactly what's getting deleted.
:::

::: warning Requires the `:heavy` image or a development environment
Same as installing: the regular `:latest` and `:nightly` Docker images don't have the toolchain to recompile after an extension is removed. You need either the `:heavy` / `:nightly-heavy` Docker image variant, or a full local development environment.
:::

## Uninstall an Extension

Pick the matching tab for your environment:

::::tabs
=== With Docker

Two ways to uninstall, same end result.

**Option 1: Remove through the admin UI.** Open the Panel's extension management page, find the extension you want to remove, and click Uninstall. The Panel handles the recompile and reload.

![Placeholder: extension uninstall UI](./admin-extensions-ui-remove.webp)

**Option 2: Delete the file and restart.** Remove the `.c7s.zip` from the Panel's `extensions/` data directory (with the default heavy compose stack, that's `./build/extensions` relative to your compose file), then restart the container:

```bash
docker compose restart web
```

The Panel notices the file is gone on startup and uninstalls accordingly.

=== With Development Environment

Run the remove command with the extension's package name (the same identifier from its `Metadata.toml`):

```bash
panel-rs extensions remove dev.yourname.extension
```

That removes the extension source from your tree but leaves the existing compiled binary running. To recompile without it:

```bash
panel-rs extensions apply --profile balanced
```

Same `apply` command you'd use after installing - it rebuilds the Panel against whatever extensions are currently in the source tree.

::: details Removing migrations as well
If you're absolutely certain you want the extension's database changes rolled back along with its code, there's a `--remove-migrations` flag:

```bash
panel-rs extensions remove dev.yourname.extension --remove-migrations
```

This runs the extension's `down.sql` migrations during removal. **Read the data-persistence warning at the top of this page first.** This is destructive, irreversible without a backup, and almost always not what you want - prefer manually cleaning up the database after you've confirmed what's actually in there.
:::

::::

## After Uninstalling

The Panel's no longer running the extension's code, but the extension's data is still in the database. What you do next depends on why you uninstalled:

- **Troubleshooting** - reinstall the same `.c7s.zip`, the extension picks back up with all its previous data intact. The Panel doesn't track "this extension was uninstalled and reinstalled" as a distinct state; it just sees the source is back and compiles it in.
- **Upgrading to a new version** - install the new `.c7s.zip` directly, no need to uninstall the old one first. The Panel handles the swap, and the extension's own migration scripts handle any schema changes between versions.
- **Permanent removal** - the extension's columns and tables linger in your database. If you want them gone, write SQL to drop them manually after taking a backup. Look at the extension's `migrations/` directory (in the original `.c7s.zip` if you still have it) to see exactly what got created.
