Skip to content

Notur Frontend SDK Reference

This document covers the frontend SDK (@notur/sdk), bridge hooks, slot system, theme system, webpack configuration, and CLI tools for extension developers.

Table of Contents


Architecture Overview

The Notur frontend has three layers:

  1. Bridge Runtime (bridge.js) -- Loaded in the panel's HTML. Creates window.__NOTUR__ and provides the PluginRegistry, SlotRenderer, hooks, and theme engine.
  2. Extension SDK (@notur/sdk) -- NPM package used by extension developers. Provides createExtension(), type definitions, and convenience hooks.
  3. Extension Bundles -- Each extension ships a pre-built JS bundle that is loaded after bridge.js and registers itself via createExtension().

React and ReactDOM are not bundled with extensions. They are externalized and use the panel's existing instances via window.React and window.ReactDOM.

Runtime flow

mermaid
flowchart TD
  A["Panel HTML"] --> B["bridge.js"]
  B --> C["window.__NOTUR__ registry + hooks"]
  A --> D["Extension bundle"]
  D --> E["createExtension()"]
  E --> F["registry.registerExtension()"]
  F --> G["SlotRenderer + Router"]
  G --> H["Your components render"]

CLI Tools

The SDK includes command-line tools for extension development.

notur-pack

Package your extension into a .notur archive for distribution. This can be run directly on your development machine without needing access to a Pterodactyl server.

bash
# From your extension directory (using npx, yarn dlx, pnpm dlx, or bunx)
npx notur-pack

# Specify a different path
npx notur-pack /path/to/my-extension

# Custom output filename
npx notur-pack --output my-extension.notur

Output:

Packing My Extension v1.0.0...
  Found 12 files

Created: acme-my-extension-1.0.0.notur
Checksum: a7b5cf768de...

Upload this file to your Pterodactyl admin panel at /admin/notur/extensions

What it creates:

A .notur file is a tar.gz archive containing:

  • All extension files (excluding node_modules, .git, vendor, .idea, .vscode)
  • A checksums.json with SHA-256 hashes for integrity verification
  • A companion .sha256 file with the archive checksum

Installation:

Upload the .notur file via:

  1. Admin UI (recommended): Navigate to /admin/notur/extensions and use the upload form
  2. CLI: php artisan notur:install /path/to/extension.notur

SDK API: createExtension

The primary entry point for registering an extension. Supports two calling conventions:

typescript
import { createExtension } from '@notur/sdk';

// Simplified (recommended) — id at top level, name/version auto-resolved from manifest:
createExtension({
    id: 'acme/analytics',
    slots: [{ slot: 'dashboard.widgets', component: Widget }],
});

// Full — explicit config object (backward compatible):
createExtension({
    config: { id: 'acme/analytics', name: 'Analytics', version: '1.0.0' },
    slots: [{ slot: 'dashboard.widgets', component: Widget }],
});

Parameters

definition: ExtensionDefinition | SimpleExtensionDefinition

Simplified form (recommended):

typescript
type SimpleExtensionDefinition = Omit<ExtensionDefinition, 'config'> & {
    id: string;  // Extension ID — name/version auto-resolved from extension.yaml
};

Full form (backward compatible):

typescript
interface ExtensionDefinition {
    config: ExtensionConfig;
    slots?: SlotConfig[];
    routes?: RouteConfig[];
    cssIsolation?: CssIsolationConfig | boolean;
    onInit?: () => void;
    onDestroy?: () => void;
}

CSS Isolation

Enable the root-class CSS isolation helper by setting cssIsolation:

tsx
createExtension({
    config: { id: 'acme/analytics' },
    cssIsolation: true, // uses the default class: notur-ext--acme-analytics
    slots: [/* ... */],
});

You can also pass an explicit class name:

tsx
createExtension({
    config: { id: 'acme/analytics' },
    cssIsolation: { mode: 'root-class', className: 'notur-ext--acme-analytics' },
});

If frontend.css_isolation is declared in extension.yaml, the SDK will pick it up automatically. If you omit name and version from config, the SDK will auto-fill them from extension.yaml.

Full Example

tsx
import * as React from 'react';
import { createExtension } from '@notur/sdk';

const DashboardWidget: React.FC<{ extensionId: string }> = ({ extensionId }) => {
    return <div>Hello from my extension!</div>;
};

const SettingsPage: React.FC = () => {
    return <div>Settings page content</div>;
};

createExtension({
    id: 'acme/my-extension',
    slots: [
        {
            slot: 'dashboard.widgets',
            component: DashboardWidget,
            order: 10,
        },
        {
            slot: 'server.subnav',
            component: () => null,  // Nav items use label/icon, not rendered component
            label: 'Settings',
            icon: 'cog',
            permission: 'my-ext.view',
        },
    ],
    routes: [
        {
            area: 'server',
            path: '/my-settings',
            name: 'Settings',
            component: SettingsPage,
            icon: 'cog',
            permission: 'my-ext.view',
        },
    ],
    onInit: () => {
        console.log('Extension initialized');
    },
    onDestroy: () => {
        console.log('Extension destroyed');
    },
});

What happens on registration

  1. createExtension() calls registry.registerExtension() with your slots and routes.
  2. The registry registers each slot and route internally.
  3. The onInit callback is invoked (if provided).
  4. A log message [Notur] Extension registered: {id} v{version} is printed to the console.

SDK Types

ExtensionConfig

typescript
interface ExtensionConfig {
    id: string;        // Extension ID matching extension.yaml (e.g., "acme/analytics")
    name?: string;     // Human-readable name (optional, auto-filled)
    version?: string;  // Semantic version string (optional, auto-filled)
}

SlotConfig

typescript
interface SlotConfig {
    slot: string;                          // Slot ID (see Slot System below)
    component: React.ComponentType<any>;   // React component to render
    order?: number;                        // Render order (lower = first, default: 0)
    priority?: number;                     // Priority (higher renders earlier, default: 0)
    label?: string;                        // Display label (for nav slots)
    icon?: string;                         // Icon name (for nav slots)
    permission?: string;                   // Required permission
    props?: Record<string, any>;           // Static props passed to the component
    when?: SlotRenderCondition;            // Conditional render rules
}

Slots are sorted by priority (higher first), then by order (lower first).

RouteConfig

typescript
interface RouteConfig {
    area: 'server' | 'dashboard' | 'account';  // Which router area
    path: string;                                // Route path (e.g., "/analytics")
    name: string;                                // Display name for nav
    component: React.ComponentType<any>;         // Page component
    icon?: string;                               // Icon for nav item
    permission?: string;                         // Required permission
}

CssIsolationConfig

typescript
interface CssIsolationConfig {
    mode: 'root-class';
    className?: string;
}

NoturApi

The global API exposed on window.__NOTUR__:

typescript
interface NoturApi {
    version: string;
    registry: {
        registerSlot: (registration: any) => void;
        registerRoute: (area: string, route: any) => void;
        registerExtension: (ext: any) => void;
        getSlot: (slotId: string) => any[];
        getRoutes: (area: string) => any[];
        on: (event: string, callback: () => void) => () => void;
    };
    hooks: {
        useSlot: (slotId: string) => any[];
        useExtensionApi: (options: { extensionId: string }) => any;
        useExtensionState: <T>(extensionId: string, initialState: T) => [T, (partial: Partial<T>) => void];
        useNoturTheme: () => any;
        useRoutes: (area: string) => any[];
    };
    unregisterExtension: (id: string) => void;
    emitEvent: (event: string, data?: unknown) => void;
    onEvent: (event: string, callback: (data?: unknown) => void) => () => void;
    SLOT_IDS: Record<string, string>;
}

Access it via:

typescript
import { getNoturApi } from '@notur/sdk';

const api = getNoturApi();  // throws if bridge.js hasn't loaded yet

Conditional Slot Rendering

Use the when field on a slot to control when it renders. It supports:

  • area / areas: 'server' | 'dashboard' | 'account' | 'admin' | 'auth' | 'other'
  • server, dashboard, account, admin, auth: boolean flags
  • path or pathStartsWith: string or array of strings
  • pathIncludes: string or array of strings
  • pathMatches: RegExp or regex string
  • permission: string or array of strings (uses server permissions when available)
typescript
slots: [
    {
        slot: 'server.subnav',
        component: AnalyticsNav,
        when: { server: true, permission: 'analytics.view' },
    },
    {
        slot: 'dashboard.widgets',
        component: DashboardWidget,
        when: { pathStartsWith: '/dashboard' },
        props: { compact: true },
    },
]

Bridge Hooks

These hooks are provided by the bridge runtime and are available on window.__NOTUR__.hooks. They can also be imported in extension code by accessing the global.

useSlot(slotId: SlotId): SlotRegistration[]

Returns all component registrations for a given slot. Subscribes to change events so the consuming component re-renders when extensions register or unregister slot components.

tsx
const { useSlot } = window.__NOTUR__.hooks;

const MyComponent: React.FC = () => {
    const widgets = useSlot('dashboard.widgets');

    return (
        <div>
            {widgets.map((reg, i) => (
                <reg.component key={i} extensionId={reg.extensionId} />
            ))}
        </div>
    );
};

Behavior:

  • Returns an empty array if the bridge hasn't initialized yet.
  • Polls every 50ms until the bridge is ready (handles late initialization).
  • Listens to both slot:{slotId} and extension:registered events for re-renders.

useExtensionApi<T>(options: UseExtensionApiOptions)

HTTP client scoped to an extension's namespaced API endpoints.

tsx
const { useExtensionApi } = window.__NOTUR__.hooks;

const MyComponent: React.FC = () => {
    const api = useExtensionApi({ extensionId: 'acme/analytics' });

    React.useEffect(() => {
        api.get('/stats').then(data => console.log(data));
    }, []);

    return (
        <div>
            {api.loading && <p>Loading...</p>}
            {api.error && <p>Error: {api.error}</p>}
            {api.data && <pre>{JSON.stringify(api.data, null, 2)}</pre>}
        </div>
    );
};

Options:

typescript
interface UseExtensionApiOptions {
    extensionId: string;   // Your extension ID
    baseUrl?: string;      // Override the default base URL
}

Default base URL: /api/client/notur/{extensionId}

Returned object:

Property/MethodTypeDescription
dataT | nullLatest response data
loadingbooleanWhether a request is in flight
errorstring | nullError message from last failed request
get(path)(path: string) => Promise<T>GET request
post(path, body?)(path: string, body?: any) => Promise<T>POST request
put(path, body?)(path: string, body?: any) => Promise<T>PUT request
patch(path, body?)(path: string, body?: any) => Promise<T>PATCH request
delete(path)(path: string) => Promise<T>DELETE request
request(path, options?)(path: string, options?: RequestInit) => Promise<T>Custom request

Features:

  • Automatically includes CSRF tokens for mutation methods (POST, PUT, PATCH, DELETE).
  • Uses credentials: 'same-origin' for cookie-based Pterodactyl auth.
  • Guards against state updates on unmounted components.
  • Handles 204 No Content responses (e.g., successful DELETE).
  • Extracts structured error messages from Laravel error responses.

useExtensionState<T>(extensionId: string, initialState: T): [T, setState, resetState]

Shared state scoped to an extension. All components from the same extension share one store instance, so state changes are reflected everywhere.

tsx
const { useExtensionState } = window.__NOTUR__.hooks;

const Counter: React.FC = () => {
    const [state, setState, resetState] = useExtensionState('acme/analytics', {
        count: 0,
        lastUpdated: null as string | null,
    });

    return (
        <div>
            <p>Count: {state.count}</p>
            <button onClick={() => setState({ count: state.count + 1, lastUpdated: new Date().toISOString() })}>
                Increment
            </button>
            <button onClick={resetState}>Reset</button>
        </div>
    );
};

Return value:

IndexTypeDescription
[0]TCurrent state
[1](partial: Partial<T>) => voidMerge partial state (like React's setState)
[2]() => voidReset state back to initialState

Features:

  • State store is automatically created on first use and cleaned up when the last subscriber unmounts.
  • Uses a pub/sub pattern internally -- changes propagate to all subscribers immediately.
  • The store is keyed by extension ID, so different extensions never share state.

useNoturTheme(): Record<string, string>

Access the current Notur theme CSS custom properties.

tsx
const { useNoturTheme } = window.__NOTUR__.hooks;

const ThemedBox: React.FC = () => {
    const theme = useNoturTheme();

    return (
        <div style={{
            background: theme['--notur-bg-secondary'],
            color: theme['--notur-text-primary'],
            borderRadius: theme['--notur-radius-md'],
        }}>
            Themed content
        </div>
    );
};

SDK Hooks

These hooks are exported from @notur/sdk and provide access to Pterodactyl panel context.

useServerContext(): ServerContext | null

Access the current server context. Only available on server-scoped pages.

tsx
import { useServerContext } from '@notur/sdk';

const ServerInfo: React.FC = () => {
    const server = useServerContext();

    if (!server) return <p>Not on a server page</p>;

    return (
        <div>
            <p>Server: {server.name}</p>
            <p>UUID: {server.uuid}</p>
            <p>Status: {server.status}</p>
        </div>
    );
};

ServerContext shape:

typescript
interface ServerContext {
    uuid: string;
    name: string;
    node: string;
    isOwner: boolean;
    status: string | null;
    permissions: string[];
}

useUserContext(): UserContext | null

Access the current user information.

tsx
import { useUserContext } from '@notur/sdk';

const UserGreeting: React.FC = () => {
    const user = useUserContext();

    if (!user) return null;
    return <p>Hello, {user.username}!</p>;
};

UserContext shape:

typescript
interface UserContext {
    uuid: string;
    username: string;
    email: string;
    isAdmin: boolean;
}

The hook first tries to read from window.PterodactylUser. If unavailable, it falls back to fetching /api/client/account.

usePermission(permission: string): boolean

Check if the current user has a specific permission in the current server context.

tsx
import { usePermission } from '@notur/sdk';

const AdminPanel: React.FC = () => {
    const canAdmin = usePermission('analytics.admin');

    if (!canAdmin) return null;
    return <div>Admin-only content</div>;
};

Permission resolution:

  • Returns true if the user is the server owner (isOwner).
  • Returns true if the user's permissions include * (wildcard).
  • Returns true if the user's permissions include the exact permission string.
  • Returns false if not on a server page (no server context).

Slot System

Slots are predefined injection points in the Pterodactyl panel where extensions can render components.

Slot render lifecycle

mermaid
sequenceDiagram
  participant Ext as "Extension bundle"
  participant Reg as "PluginRegistry"
  participant UI as "Panel UI"
  Ext->>Reg: "registerExtension(slots, routes)"
  Reg-->>UI: "emit slot change events"
  UI->>Reg: "useSlot(slotId)"
  Reg-->>UI: "list of registrations"
  UI-->>UI: "render each component in order"

Available Slot IDs

ConstantSlot IDLocationTypeDescription
NAVBARnavbarNavigation barportalTop navigation bar
NAVBAR_LEFTnavbar.leftNavigation barportalNavbar left area (near logo)
NAVBAR_BEFOREnavbar.beforeNavigation barportalNavbar items (before built-ins)
NAVBAR_AFTERnavbar.afterNavigation barportalNavbar items (after built-ins)
SERVER_SUBNAVserver.subnavServer sub-navigationnavServer sub-navigation
SERVER_SUBNAV_BEFOREserver.subnav.beforeServer sub-navigationnavServer sub-navigation (before built-ins)
SERVER_SUBNAV_AFTERserver.subnav.afterServer sub-navigationnavServer sub-navigation (after built-ins)
SERVER_HEADERserver.headerServer areaportalServer header area
SERVER_PAGEserver.pageServer arearouteServer area page
SERVER_FOOTERserver.footerServer areaportalServer footer area
SERVER_TERMINAL_BUTTONSserver.terminal.buttonsServer consoleportalTerminal power buttons
SERVER_CONSOLE_HEADERserver.console.headerServer consoleportalConsole page header
SERVER_CONSOLE_INFO_BEFOREserver.console.info.beforeServer consoleportalConsole info (before details)
SERVER_CONSOLE_INFO_AFTERserver.console.info.afterServer consoleportalConsole info (after details)
SERVER_CONSOLE_SIDEBARserver.console.sidebarServer consoleportalConsole sidebar area
SERVER_CONSOLE_COMMANDserver.console.commandServer consoleportalConsole command row
SERVER_CONSOLE_FOOTERserver.console.footerServer consoleportalConsole page footer
SERVER_FILES_ACTIONSserver.files.actionsServer filesportalFile manager toolbar
SERVER_FILES_HEADERserver.files.headerServer filesportalFile manager header
SERVER_FILES_FOOTERserver.files.footerServer filesportalFile manager footer
SERVER_FILES_DROPDOWNserver.files.dropdownServer filesportalFile manager dropdown items
SERVER_FILES_EDIT_BEFOREserver.files.edit.beforeServer filesportalFile editor (before content)
SERVER_FILES_EDIT_AFTERserver.files.edit.afterServer filesportalFile editor (after content)
SERVER_DATABASES_BEFOREserver.databases.beforeServer databasesportalDatabases page (before content)
SERVER_DATABASES_AFTERserver.databases.afterServer databasesportalDatabases page (after content)
SERVER_SCHEDULES_BEFOREserver.schedules.beforeServer schedulesportalSchedules list (before content)
SERVER_SCHEDULES_AFTERserver.schedules.afterServer schedulesportalSchedules list (after content)
SERVER_SCHEDULES_EDIT_BEFOREserver.schedules.edit.beforeServer schedulesportalSchedule editor (before content)
SERVER_SCHEDULES_EDIT_AFTERserver.schedules.edit.afterServer schedulesportalSchedule editor (after content)
SERVER_USERS_BEFOREserver.users.beforeServer usersportalUsers page (before content)
SERVER_USERS_AFTERserver.users.afterServer usersportalUsers page (after content)
SERVER_BACKUPS_BEFOREserver.backups.beforeServer backupsportalBackups page (before content)
SERVER_BACKUPS_AFTERserver.backups.afterServer backupsportalBackups page (after content)
SERVER_BACKUPS_DROPDOWNserver.backups.dropdownServer backupsportalBackup row dropdown items
SERVER_NETWORK_BEFOREserver.network.beforeServer networkportalNetwork page (before content)
SERVER_NETWORK_AFTERserver.network.afterServer networkportalNetwork page (after content)
SERVER_STARTUP_BEFOREserver.startup.beforeServer startupportalStartup page (before content)
SERVER_STARTUP_AFTERserver.startup.afterServer startupportalStartup page (after content)
SERVER_SETTINGS_BEFOREserver.settings.beforeServer settingsportalSettings page (before content)
SERVER_SETTINGS_AFTERserver.settings.afterServer settingsportalSettings page (after content)
DASHBOARD_HEADERdashboard.headerDashboardportalDashboard header area
DASHBOARD_WIDGETSdashboard.widgetsDashboardportalDashboard widgets
DASHBOARD_SERVERLIST_BEFOREdashboard.serverlist.beforeDashboard server listportalDashboard server list (before)
DASHBOARD_SERVERLIST_AFTERdashboard.serverlist.afterDashboard server listportalDashboard server list (after)
DASHBOARD_SERVERROW_NAME_BEFOREdashboard.serverrow.name.beforeDashboard server rowportalDashboard server row name (before)
DASHBOARD_SERVERROW_NAME_AFTERdashboard.serverrow.name.afterDashboard server rowportalDashboard server row name (after)
DASHBOARD_SERVERROW_DESCRIPTION_BEFOREdashboard.serverrow.description.beforeDashboard server rowportalDashboard server row description (before)
DASHBOARD_SERVERROW_DESCRIPTION_AFTERdashboard.serverrow.description.afterDashboard server rowportalDashboard server row description (after)
DASHBOARD_SERVERROW_LIMITSdashboard.serverrow.limitsDashboard server rowportalDashboard server row resource limits
DASHBOARD_FOOTERdashboard.footerDashboardportalDashboard footer area
DASHBOARD_PAGEdashboard.pageDashboardrouteDashboard page
ACCOUNT_HEADERaccount.headerAccountportalAccount header area
ACCOUNT_PAGEaccount.pageAccountrouteAccount page
ACCOUNT_FOOTERaccount.footerAccountportalAccount footer area
ACCOUNT_SUBNAVaccount.subnavAccount sub-navigationnavAccount sub-navigation
ACCOUNT_SUBNAV_BEFOREaccount.subnav.beforeAccount sub-navigationnavAccount sub-navigation (before built-ins)
ACCOUNT_SUBNAV_AFTERaccount.subnav.afterAccount sub-navigationnavAccount sub-navigation (after built-ins)
ACCOUNT_OVERVIEW_BEFOREaccount.overview.beforeAccountportalAccount overview (before content)
ACCOUNT_OVERVIEW_AFTERaccount.overview.afterAccountportalAccount overview (after content)
ACCOUNT_API_BEFOREaccount.api.beforeAccountportalAccount API (before content)
ACCOUNT_API_AFTERaccount.api.afterAccountportalAccount API (after content)
ACCOUNT_SSH_BEFOREaccount.ssh.beforeAccountportalAccount SSH (before content)
ACCOUNT_SSH_AFTERaccount.ssh.afterAccountportalAccount SSH (after content)
AUTH_CONTAINER_BEFOREauth.container.beforeAuthenticationportalAuthentication container (before content)
AUTH_CONTAINER_AFTERauth.container.afterAuthenticationportalAuthentication container (after content)

Slot Types

  • portal -- Component is rendered into a container <div> via React portals. Use for widgets, buttons, and inline content.
  • nav -- Component metadata (label, icon) is used to render a navigation item. The actual page component is registered as a route.
  • route -- Component is rendered as a full page when the user navigates to the registered path.

Using Slot IDs in Code

The slot IDs are available as constants:

typescript
// From bridge runtime
const { SLOT_IDS } = window.__NOTUR__;

// Constants:
SLOT_IDS.NAVBAR                         // 'navbar'
SLOT_IDS.NAVBAR_LEFT                    // 'navbar.left'
SLOT_IDS.NAVBAR_BEFORE                  // 'navbar.before'
SLOT_IDS.NAVBAR_AFTER                   // 'navbar.after'
SLOT_IDS.SERVER_SUBNAV                  // 'server.subnav'
SLOT_IDS.SERVER_SUBNAV_BEFORE           // 'server.subnav.before'
SLOT_IDS.SERVER_SUBNAV_AFTER            // 'server.subnav.after'
SLOT_IDS.SERVER_HEADER                  // 'server.header'
SLOT_IDS.SERVER_PAGE                    // 'server.page'
SLOT_IDS.SERVER_FOOTER                  // 'server.footer'
SLOT_IDS.SERVER_TERMINAL_BUTTONS        // 'server.terminal.buttons'
SLOT_IDS.SERVER_CONSOLE_HEADER          // 'server.console.header'
SLOT_IDS.SERVER_CONSOLE_INFO_BEFORE     // 'server.console.info.before'
SLOT_IDS.SERVER_CONSOLE_INFO_AFTER      // 'server.console.info.after'
SLOT_IDS.SERVER_CONSOLE_SIDEBAR         // 'server.console.sidebar'
SLOT_IDS.SERVER_CONSOLE_COMMAND         // 'server.console.command'
SLOT_IDS.SERVER_CONSOLE_FOOTER          // 'server.console.footer'
SLOT_IDS.SERVER_FILES_ACTIONS           // 'server.files.actions'
SLOT_IDS.SERVER_FILES_HEADER            // 'server.files.header'
SLOT_IDS.SERVER_FILES_FOOTER            // 'server.files.footer'
SLOT_IDS.SERVER_FILES_DROPDOWN          // 'server.files.dropdown'
SLOT_IDS.SERVER_FILES_EDIT_BEFORE       // 'server.files.edit.before'
SLOT_IDS.SERVER_FILES_EDIT_AFTER        // 'server.files.edit.after'
SLOT_IDS.SERVER_DATABASES_BEFORE        // 'server.databases.before'
SLOT_IDS.SERVER_DATABASES_AFTER         // 'server.databases.after'
SLOT_IDS.SERVER_SCHEDULES_BEFORE        // 'server.schedules.before'
SLOT_IDS.SERVER_SCHEDULES_AFTER         // 'server.schedules.after'
SLOT_IDS.SERVER_SCHEDULES_EDIT_BEFORE   // 'server.schedules.edit.before'
SLOT_IDS.SERVER_SCHEDULES_EDIT_AFTER    // 'server.schedules.edit.after'
SLOT_IDS.SERVER_USERS_BEFORE            // 'server.users.before'
SLOT_IDS.SERVER_USERS_AFTER             // 'server.users.after'
SLOT_IDS.SERVER_BACKUPS_BEFORE          // 'server.backups.before'
SLOT_IDS.SERVER_BACKUPS_AFTER           // 'server.backups.after'
SLOT_IDS.SERVER_BACKUPS_DROPDOWN        // 'server.backups.dropdown'
SLOT_IDS.SERVER_NETWORK_BEFORE          // 'server.network.before'
SLOT_IDS.SERVER_NETWORK_AFTER           // 'server.network.after'
SLOT_IDS.SERVER_STARTUP_BEFORE          // 'server.startup.before'
SLOT_IDS.SERVER_STARTUP_AFTER           // 'server.startup.after'
SLOT_IDS.SERVER_SETTINGS_BEFORE         // 'server.settings.before'
SLOT_IDS.SERVER_SETTINGS_AFTER          // 'server.settings.after'
SLOT_IDS.DASHBOARD_HEADER               // 'dashboard.header'
SLOT_IDS.DASHBOARD_WIDGETS              // 'dashboard.widgets'
SLOT_IDS.DASHBOARD_SERVERLIST_BEFORE    // 'dashboard.serverlist.before'
SLOT_IDS.DASHBOARD_SERVERLIST_AFTER     // 'dashboard.serverlist.after'
SLOT_IDS.DASHBOARD_SERVERROW_NAME_BEFORE // 'dashboard.serverrow.name.before'
SLOT_IDS.DASHBOARD_SERVERROW_NAME_AFTER // 'dashboard.serverrow.name.after'
SLOT_IDS.DASHBOARD_SERVERROW_DESCRIPTION_BEFORE // 'dashboard.serverrow.description.before'
SLOT_IDS.DASHBOARD_SERVERROW_DESCRIPTION_AFTER // 'dashboard.serverrow.description.after'
SLOT_IDS.DASHBOARD_SERVERROW_LIMITS     // 'dashboard.serverrow.limits'
SLOT_IDS.DASHBOARD_FOOTER               // 'dashboard.footer'
SLOT_IDS.DASHBOARD_PAGE                 // 'dashboard.page'
SLOT_IDS.ACCOUNT_HEADER                 // 'account.header'
SLOT_IDS.ACCOUNT_PAGE                   // 'account.page'
SLOT_IDS.ACCOUNT_FOOTER                 // 'account.footer'
SLOT_IDS.ACCOUNT_SUBNAV                 // 'account.subnav'
SLOT_IDS.ACCOUNT_SUBNAV_BEFORE          // 'account.subnav.before'
SLOT_IDS.ACCOUNT_SUBNAV_AFTER           // 'account.subnav.after'
SLOT_IDS.ACCOUNT_OVERVIEW_BEFORE        // 'account.overview.before'
SLOT_IDS.ACCOUNT_OVERVIEW_AFTER         // 'account.overview.after'
SLOT_IDS.ACCOUNT_API_BEFORE             // 'account.api.before'
SLOT_IDS.ACCOUNT_API_AFTER              // 'account.api.after'
SLOT_IDS.ACCOUNT_SSH_BEFORE             // 'account.ssh.before'
SLOT_IDS.ACCOUNT_SSH_AFTER              // 'account.ssh.after'
SLOT_IDS.AUTH_CONTAINER_BEFORE          // 'auth.container.before'
SLOT_IDS.AUTH_CONTAINER_AFTER           // 'auth.container.after'

Theme System

Notur provides a CSS custom property-based theme system that derives its defaults from the Pterodactyl panel's styles.

CSS Custom Properties

All Notur theme variables are prefixed with --notur-. Extensions should use these variables for consistent styling.

Colors

VariableDefaultPurpose
--notur-primary#0967d2Primary accent color
--notur-primary-light#47a3f3Light primary
--notur-primary-dark#03449eDark primary
--notur-secondary#7c8b9aSecondary color
--notur-success#27ab83Success / positive
--notur-danger#e12d39Danger / destructive
--notur-warning#f7c948Warning
--notur-info#2bb0edInformational

Backgrounds

VariableDefaultPurpose
--notur-bg-primary#0b0d10Primary background
--notur-bg-secondaryrgba(17, 19, 24, 0.68)Secondary background (cards, inputs)
--notur-bg-tertiaryrgba(25, 28, 35, 0.8)Tertiary background (hover states)

Text

VariableDefaultPurpose
--notur-text-primary#f1f5f9Primary text
--notur-text-secondary#cbd5e1Secondary text
--notur-text-muted#94a3b8Muted / placeholder text

Borders and Radius

VariableDefaultPurpose
--notur-borderrgba(148, 163, 184, 0.18)Border color
--notur-radius-sm6pxSmall border radius
--notur-radius-md12pxMedium border radius
--notur-radius-lg18pxLarge border radius

Glass Effects

VariableDefaultPurpose
--notur-glass-bgrgba(17, 19, 24, 0.55)Glass surface background
--notur-glass-borderrgba(255, 255, 255, 0.12)Glass border
--notur-glass-highlightrgba(255, 255, 255, 0.06)Subtle highlight for glass edges
--notur-glass-shadow0 16px 40px rgba(0, 0, 0, 0.55)Soft depth shadow
--notur-glass-blur16pxBackdrop blur amount

Typography

VariableDefaultPurpose
--notur-font-sans-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serifSans-serif font stack
--notur-font-mono"Fira Code", "JetBrains Mono", monospaceMonospace font stack

Using Theme Variables

css
.my-widget {
    background: var(--notur-glass-bg, var(--notur-bg-secondary));
    color: var(--notur-text-primary);
    border: 1px solid var(--notur-glass-border, var(--notur-border));
    border-radius: var(--notur-radius-lg);
    box-shadow: var(--notur-glass-shadow, 0 12px 30px rgba(0, 0, 0, 0.35));
    backdrop-filter: blur(var(--notur-glass-blur, 12px));
    font-family: var(--notur-font-sans);
    padding: 1rem;
}

.my-widget .title {
    color: var(--notur-primary);
    font-weight: 600;
}

.my-widget .danger-text {
    color: var(--notur-danger);
}

Or inline with the useNoturTheme hook (see Bridge Hooks above).

Theme Extraction

On initialization, the bridge runtime attempts to extract theme values from the live panel DOM using three strategies:

  1. CSS custom properties -- Reads --ptero-* properties from :root and maps them to --notur-* equivalents.
  2. Computed styles -- Probes known panel elements (e.g., #app background color, input borders) and derives Notur variables.
  3. Stylesheet scanning -- Scans all loaded stylesheets for --ptero-* declarations in :root rules.

If no values can be extracted, the defaults listed above are applied.

Theme Extensions

Theme extensions can override CSS custom properties by shipping a CSS file that redefines :root properties:

css
/* my-theme.css */
:root {
    --notur-primary: #6366f1;
    --notur-bg-primary: #0f172a;
    --notur-bg-secondary: #1e293b;
    --notur-text-primary: #e2e8f0;
}

Default Glass Surfaces

The bridge runtime applies glass styling by default to dashboard widgets and extension route pages. If you want the same look elsewhere, wrap your UI with these classes:

html
<div class="notur-surface notur-surface--card">
  <!-- your content -->
</div>

You can also use notur-surface--page for full-page layouts.


Webpack Configuration

The SDK ships a base webpack configuration for building extension frontend bundles.

Using the Base Config

js
// webpack.config.js
const base = require('@notur/sdk/webpack.extension.config');

module.exports = {
    ...base,
    entry: './resources/frontend/src/index.tsx',
    output: {
        ...base.output,
        filename: 'extension.js',
        path: __dirname + '/resources/frontend/dist',
    },
};

What the Base Config Does

  • Sets React and ReactDOM as externals (uses window.React and window.ReactDOM).
  • Configures TypeScript/TSX compilation via ts-loader or babel-loader.
  • Outputs a single bundle file suitable for browser loading.
  • Sets libraryTarget: 'umd' for compatibility.

Building

bash
# Production build (using npx, yarn dlx, pnpm dlx, or bunx)
npx webpack --mode production

# Development build (with source maps)
npx webpack --mode development

Important Notes

  • Do not bundle React. The panel already includes React. Your extension's webpack config must externalize it:
    js
    externals: {
        'react': 'React',
        'react-dom': 'ReactDOM',
    }
  • Output goes to resources/frontend/dist/ by convention.
  • The output filename should match the frontend.bundle field in your extension.yaml.
  • Bundles are loaded in the browser after bridge.js, so window.__NOTUR__ is guaranteed to exist.

SDK Exports

The @notur/sdk package exports:

typescript
// Functions
export { createExtension } from './createExtension';
export { getNoturApi } from './types';

// Types
export type { ExtensionConfig, SlotConfig, RouteConfig, ExtensionDefinition, SimpleExtensionDefinition, NoturApi } from './types';

// Hooks
export { useServerContext } from './hooks/useServerContext';
export { useUserContext } from './hooks/useUserContext';
export { usePermission } from './hooks/usePermission';
export { useExtensionConfig } from './hooks/useExtensionConfig';
export { useNoturEvent, useEmitEvent } from './hooks/useNoturEvent';
export { useNavigate } from './hooks/useNavigate';
export { createScopedEventChannel } from './events';

useExtensionConfig(extensionId: string, options?): { config, loading, error, refresh }

Fetches public settings declared in extension.yaml under admin.settings with public: true.

tsx
import { useExtensionConfig } from '@notur/sdk';

const AnalyticsWidget: React.FC = () => {
    const { config, loading, error } = useExtensionConfig('acme/server-analytics');

    if (loading) return <p>Loading settings...</p>;
    if (error) return <p>Failed to load settings: {error}</p>;

    return <p>Mode: {config.mode ?? 'default'}</p>;
};

Options:

  • baseUrl (string): override the API base URL (default /api/client/notur)
  • initial (object): initial settings value before fetch completes
  • pollInterval (number): refresh interval in milliseconds (enables live reload)

Live reload example:

tsx
const { config } = useExtensionConfig('acme/server-analytics', { pollInterval: 5000 });

useNoturEvent(event: string, callback: (data?: unknown) => void): void

Subscribe to a Notur event. The callback is invoked whenever the specified event is emitted. The subscription is automatically cleaned up when the component unmounts.

tsx
import { useNoturEvent } from '@notur/sdk';

const Notifications: React.FC = () => {
    useNoturEvent('analytics:updated', (data) => {
        console.log('Analytics data updated:', data);
    });

    return <div>Listening for events...</div>;
};

useEmitEvent(): (event: string, data?: unknown) => void

Returns a function to emit a Notur event. Other extensions or components subscribed to the event via useNoturEvent or onEvent will receive the data.

tsx
import { useEmitEvent } from '@notur/sdk';

const RefreshButton: React.FC = () => {
    const emit = useEmitEvent();

    return (
        <button onClick={() => emit('analytics:refresh', { force: true })}>
            Refresh
        </button>
    );
};

createScopedEventChannel(extensionId: string): { eventName, emit, on }

Creates a namespaced event channel to prevent collisions with other extensions on the shared event bus.

tsx
import { createScopedEventChannel } from '@notur/sdk';

const channel = createScopedEventChannel('acme/server-analytics');

channel.emit('refresh', { source: 'widget' });
const unsubscribe = channel.on('refresh', (payload) => {
    console.log('Scoped event payload:', payload);
});

The generated event name format is:

  • ext:{extensionId}:{eventName}
  • Example: ext:acme/server-analytics:refresh

useNavigate(): (path: string) => void

Returns a navigation function that integrates with the panel's client-side router. Use this instead of window.location for SPA-style navigation.

tsx
import { useNavigate } from '@notur/sdk';

const GoToSettings: React.FC = () => {
    const navigate = useNavigate();

    return (
        <button onClick={() => navigate('/account/settings')}>
            Go to Settings
        </button>
    );
};

Released under the MIT License.