{greeting}
{online} / {servers.length} online · {theme.mode} mode
)
}
})
```
### Manifest fields [#manifest-fields]
| Field | Required | Notes |
| ------------------- | -------- | ----------------------------------------------------------- |
| `id` | yes | Reverse-DNS-style unique identifier, e.g. `com.example.cpu` |
| `version` | yes | Semantic version `MAJOR.MINOR.PATCH` |
| `name` | yes | Display name in the widget picker |
| `description` | – | Free-form description |
| `author` | – | Author name or organization |
| `category` | yes | One of `Real-time`, `Charts`, `Status` |
| `sizing.defaultW/H` | yes | Default width / height in grid cells |
| `sizing.minW/H` | yes | Minimum allowed size |
| `sizing.maxW/H` | – | Optional maximum size |
| `sizing.strategy` | yes | `free` / `fixed` / `aspect-square` / `content-height` |
| `sdkVersion` | yes | SDK version range, e.g. `^0.1.0` |
### Build requirements [#build-requirements]
A custom widget must be built as an **ES module** with the following dependencies marked `external`:
* `react`
* `react/jsx-runtime`
* `@serverbee/widget-sdk`
Just as important: the **JSDoc manifest comment must be preserved verbatim at the top of the output**. Most minifiers strip comments by default, so explicitly opt into preserving the manifest comment.
A typical Vite + Terser config:
```ts
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: 'src/index.tsx',
formats: ['es'],
fileName: () => 'index.js'
},
rollupOptions: {
external: ['react', 'react/jsx-runtime', '@serverbee/widget-sdk']
},
minify: 'terser',
terserOptions: {
format: {
// Keep the @serverbee-widget manifest comment.
comments: /@serverbee-widget/
}
}
}
})
```
### Install via UI [#install-via-ui]
1. Sign in as an admin.
2. Open **Settings → Widget Modules**.
3. Choose **Upload `.js`** to upload a local file, or **Import URL** to fetch over HTTPS.
4. Once installed, open any dashboard, click **Edit → Add Widget**, and the new widget appears in the picker.
### Install via API [#install-via-api]
```bash
# Upload a local file
curl -X POST "https://your-host/api/widget-modules" \
-H "X-API-Key: $SERVERBEE_API_KEY" \
-F file=@my.widget.js
# Fetch from URL
curl -X POST "https://your-host/api/widget-modules?url=https://cdn.example.com/my.widget.js" \
-H "X-API-Key: $SERVERBEE_API_KEY"
```
On success:
```json
{ "data": { "id": "com.example.hello", "version": "1.0.0" } }
```
If a widget with the same `id` already exists, it is upgraded in place — the version, manifest, and code are all replaced.
## Method C — `.zip` collection bundle [#method-c--zip-collection-bundle]
A collection lets you install **multiple widgets in a single upload**. Each widget still follows the Method B rules (its own JSDoc manifest, its own entry file). They are simply packaged together in one `.zip`, and a root-level `collection.json` enumerates them.
### Package layout [#package-layout]
```
my-pack.zip
├── collection.json
├── weather/
│ ├── index.js ← contains the @serverbee-widget manifest
│ └── icon.svg ← optional asset
├── clock/
│ └── index.js
└── shared/
└── helpers.js ← optional; not auto-injected — entry files must import as needed
```
### `collection.json` schema [#collectionjson-schema]
```json
{
"widgets": [
{ "entry": "weather/index.js" },
{ "entry": "clock/index.js" }
]
}
```
Constraints:
* `entry` must be a relative path (no leading `/`, no `..`).
* Must point to a `.js` or `.mjs` file.
* Every entry file must contain a valid `@serverbee-widget` JSDoc block.
* Widget `id`s must be **unique within the bundle** — duplicates are rejected.
### Asset resolution [#asset-resolution]
Assets inside the bundle (images, JSON, helper scripts, etc.) are served via `GET /api/widget-modules/{id}/{relative-path}`. The relative path is resolved against the **folder of the entry file**:
| Request URL | Resolved zip path |
| -------------------------------------------------- | ------------------ |
| `/api/widget-modules/com.example.weather/index.js` | `weather/index.js` |
| `/api/widget-modules/com.example.weather/icon.svg` | `weather/icon.svg` |
| `/api/widget-modules/com.example.clock/index.js` | `clock/index.js` |
Reference assets from widget code like this:
```ts
const iconUrl = `/api/widget-modules/com.example.weather/icon.svg`
```
Note: widgets cannot reach into each other's folders — asset resolution is scoped per `id`.
### Build and pack [#build-and-pack]
1. Use your bundler of choice (Vite, tsup, esbuild, ...) to build each widget's `index.js`, externalizing React and the SDK and preserving the manifest comment.
2. Lay the build outputs out in the directory structure above.
3. Write `collection.json` at the package root.
4. Zip it:
```bash
zip -r my-pack.zip collection.json weather/ clock/
```
### Install [#install]
The UI flow is identical to Method B — just upload the `.zip`. The server sniffs the `PK\x03\x04` magic bytes and dispatches automatically.
```bash
curl -X POST "https://your-host/api/widget-modules" \
-H "X-API-Key: $SERVERBEE_API_KEY" \
-F file=@my-pack.zip
```
A collection install returns an **array** — one entry per widget in the bundle:
```json
{
"data": [
{ "id": "com.example.weather", "version": "1.0.0" },
{ "id": "com.example.clock", "version": "1.0.0" }
]
}
```
Each widget gets its own row in the database and can be enabled, disabled, or uninstalled individually. They share a single underlying zip blob for storage.
## Sizing strategies [#sizing-strategies]
`sizing.strategy` controls how a widget can be resized on the dashboard:
* `free` — user can resize freely between `minW/H` and `maxW/H`.
* `fixed` — locked to `defaultW/H`; resizing is disabled.
* `aspect-square` — always 1:1; snaps to the nearest tier.
* `content-height` — width is resizable; height is driven by content.
See [Dashboards & Widgets](/en/docs/dashboards) for more on layout and editing.
## SDK surface at a glance [#sdk-surface-at-a-glance]
`@serverbee/widget-sdk` is the stable API exposed to custom widgets. It includes:
* `defineWidget` — declares a widget's entry point.
* `z` / `ZodSchema` — bundled Zod for describing config schemas.
* Live hooks: `useServers`, `useServer`, `useMetric`, `useCapability` (subscribe to the live WebSocket store via `useSyncExternalStore`).
* Domain hooks: `useHistory`, `useTraffic`, `useAlerts`, `useServiceMonitors`, `useUptime`, `useGeoIp`.
* Host hooks: `useTheme`, `useConfigUpdate`.
* Generic escape hatches: `useApiQuery` / `useApiMutation` for direct REST calls.
* `createActionsHelper` and `ActionDefinition` for registering action buttons on a widget.
See `packages/widget-sdk/README.md` in the repo for the complete API reference.
## Uninstall [#uninstall]
Click the delete button next to a widget in **Settings → Widget Modules**, or:
```bash
curl -X DELETE "https://your-host/api/widget-modules/com.example.hello" \
-H "X-API-Key: $SERVERBEE_API_KEY"
```
Built-in widgets (for example `com.serverbee.hello-world`) cannot be uninstalled — `DELETE` against them returns `400`.
## Limits and safety [#limits-and-safety]
* **Size caps**: a single `.js` file or `.zip` may not exceed **1 MiB** on upload; each entry inside a zip is capped at **5 MiB** uncompressed; the whole zip is capped at **32 MiB** total uncompressed across at most **64 entries**.
* **SSRF protection**: URL installs resolve DNS and reject any host whose IP falls in a reserved/private range — loopback (`127.0.0.0/8`, `::1`), private networks (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64/10`), link-local (`169.254/16` — includes cloud metadata endpoints), IPv6 ULA (`fc00::/7`) and link-local (`fe80::/10`), benchmarking, documentation, multicast, and reserved ranges. HTTP redirects are disabled — 3xx responses are rejected outright so a public URL cannot pivot into the private network.
* **Zip-slip protection**: extraction rejects entries with `..` or absolute paths.
* **Static parsing only**: the manifest must be extractable with a plain regex — there is no `eval` / `Function()` involved in installation. The extractor itself rejects sources > 1 MiB up front.
* **ID conflict rejection**: an upload whose `id` already belongs to a module from a different source (e.g. trying to overwrite a built-in with an upload) is refused with `409 Conflict`.
* **SDK version gating**: each manifest declares `sdkVersion` as a semver range; the loader refuses to register a module whose required range does not match the host SDK.
* **Admin-gated**: install and uninstall endpoints require admin privileges; members can only list and fetch served assets.
* **Audit logged**: every install and uninstall is recorded in the audit log with the actor, source, id, version, and code SHA-256.
* **Same-origin execution**: widgets run in the same browsing context as the main SPA — only install code from sources you trust.
* **Action buttons** declared via `defineWidget({ actions })` get a built-in confirm dialog (when `confirm` is set), pending state, and toast notifications on success/failure.