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 with exactly three dot-separated segments, <tld>.<author>.<name>, for example dev.0x7d8.test. The first segment is 2 to 6 lowercase letters, the author 3 to 30 and the name 4 to 30 characters of lowercase letters, digits and hyphens.
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.
# create a new extension from the template, using the dotted package name
panel-rs extensions init dev.0x7d8.test # <-- replace this with your package nameThe command derives the underscored identifier itself and lays out everything below, including a frontend/tsconfig.json and an empty migrations/ directory.
Frontend
backend-extensions/
(package_identifier)/
frontend/
package.json # REQUIRED file containing additional dependencies
tsconfig.json # generated by `extensions init`, extends the Panel's config
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 translationsCompatibility 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.
{
"name": "extension",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"shared": "workspace:*"
}
}src/index.ts
import { Extension, ExtensionContext } from 'shared';
import type { CSSVariablesResolver, 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();The template that extensions init writes only contains the three fields and initialize; the theme, resolver and call methods above are optional overrides of the base class, shown here so you know they exist.
Backend
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 entrypointCargo.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
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
license_text = "..." # optional licence text shown to the operatorextensions init fills panel_version with the version of the Panel you generated the extension on, so a fresh extension declares >=<current version> rather than >=1.1.0.
WARNING
panel_version is checked when the extension is installed, by panel-rs extensions add, panel-rs extensions update and the admin upload, and again by the heavy image on every rebuild, since a rebuild re-adds every archive. 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 it has to match the panel version doing the install. On the heavy image that means an extension pinned below the version you just pulled fails the whole rebuild, with the failure reason naming it, and the Panel keeps running the previous binary until you remove or update that extension. Heavy images before 1.2.3 skipped the compatibility half of the check and only enforced the 1.1.0 floor.
src/lib.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)
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 migrationINFO
Auto-generate the migration files by running panel-rs database-migrator create <package_name> with the dotted package name - it prompts for a snake_case migration name and creates the directory with the correct timestamp and both files for you to fill in. It writes through the database/extension-migrations/<identifier> compatibility symlink, which points at the migrations/ directory above.
up.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 statements to rollback the migration, for example:
DROP TABLE IF EXISTS dev_0x7d8_test_table;