+ )
+ }
+})
+```
+
+### 清单字段
+
+| 字段 | 必填 | 说明 |
+|------|------|------|
+| `id` | ✅ | 反向域名风格的唯一标识,例如 `com.example.cpu` |
+| `version` | ✅ | 语义化版本号 `MAJOR.MINOR.PATCH` |
+| `name` | ✅ | 在组件选择器中展示的名称 |
+| `description` | – | 描述文本 |
+| `author` | – | 作者名或组织 |
+| `category` | ✅ | 取值 `Real-time`、`Charts` 或 `Status` |
+| `sizing.defaultW/H` | ✅ | 在网格中的默认宽 / 高(格子数) |
+| `sizing.minW/H` | ✅ | 允许的最小尺寸 |
+| `sizing.maxW/H` | – | 可选的最大尺寸 |
+| `sizing.strategy` | ✅ | `free` / `fixed` / `aspect-square` / `content-height` |
+| `sdkVersion` | ✅ | SDK 版本范围,例如 `^0.1.0` |
+
+### 打包要求
+
+自定义组件必须输出 **ES Module** 格式,且把以下依赖声明为 `external`:
+
+- `react`
+- `react/jsx-runtime`
+- `@serverbee/widget-sdk`
+
+更重要的是:**JSDoc 清单注释必须原样保留在输出文件顶部**。多数压缩器默认会删掉注释,记得开启「保留特定注释」选项。
+
+Vite + Terser 的典型配置:
+
+```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: {
+ // 保留 @serverbee-widget 清单注释
+ comments: /@serverbee-widget/
+ }
+ }
+ }
+})
+```
+
+### 通过 UI 安装
+
+1. 用管理员账号登录。
+2. 打开 **Settings → Widget Modules**。
+3. 选择 **Upload `.js`** 上传本地文件,或选择 **Import URL** 填入 HTTPS 链接。
+4. 安装成功后,到任意仪表盘点击 **Edit** → **Add Widget**,新组件就会出现在列表里。
+
+### 通过 API 安装
+
+```bash
+# 上传本地文件
+curl -X POST "https://your-host/api/widget-modules" \
+ -H "X-API-Key: $SERVERBEE_API_KEY" \
+ -F file=@my.widget.js
+
+# 从 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"
+```
+
+成功时返回:
+
+```json
+{ "data": { "id": "com.example.hello", "version": "1.0.0" } }
+```
+
+如果同 ID 的组件已存在,会就地升级(版本号、清单、代码都被替换)。
+
+## 方式 C — `.zip` 集合包
+
+集合包让你**用一次上传**安装多个组件。每个组件仍然遵循方式 B 的规则(独立的 JSDoc 清单、独立的 entry 文件),只是被打包进同一个 `.zip` 里,由根目录的 `collection.json` 列举。
+
+### 包结构
+
+```
+my-pack.zip
+├── collection.json
+├── weather/
+│ ├── index.js ← 含 @serverbee-widget 清单
+│ └── icon.svg ← 可选的资源文件
+├── clock/
+│ └── index.js
+└── shared/
+ └── helpers.js ← 可选;当前不会自动注入,需要 entry 文件自行处理
+```
+
+### `collection.json` 格式
+
+```json
+{
+ "widgets": [
+ { "entry": "weather/index.js" },
+ { "entry": "clock/index.js" }
+ ]
+}
+```
+
+约束:
+
+- `entry` 必须是相对路径(不能以 `/` 开头,不能包含 `..`);
+- 必须指向 `.js` 或 `.mjs` 文件;
+- 每个 entry 文件都必须包含合法的 `@serverbee-widget` JSDoc 块;
+- 同一个 `.zip` 内**组件 `id` 必须唯一**——重复 `id` 会被拒绝。
+
+### 资源解析
+
+集合包里的资源(图片、JSON、辅助 JS 等)通过 `GET /api/widget-modules/{id}/{相对路径}` 访问。相对路径会自动以 entry 所在文件夹为根目录拼接:
+
+| 请求 URL | 解析到的 zip 内路径 |
+|----------|--------------------|
+| `/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` |
+
+组件代码里可以这样引用:
+
+```ts
+const iconUrl = `/api/widget-modules/com.example.weather/icon.svg`
+```
+
+注意:不同 `id` 之间不能跨目录互相访问彼此的资源。
+
+### 打包流程
+
+1. 用你喜欢的打包工具(Vite、tsup、esbuild 等)分别为每个组件构建 `index.js`,外部化 React 和 SDK,保留清单注释;
+2. 把所有产物按上面的目录结构放好;
+3. 在包根目录写 `collection.json`;
+4. 用 `zip` 命令压缩:
+
+```bash
+zip -r my-pack.zip collection.json weather/ clock/
+```
+
+### 安装
+
+UI 操作和方式 B 完全一致——直接上传 `.zip` 即可,后端会通过文件首部的 `PK\x03\x04` 魔数自动识别。
+
+```bash
+curl -X POST "https://your-host/api/widget-modules" \
+ -H "X-API-Key: $SERVERBEE_API_KEY" \
+ -F file=@my-pack.zip
+```
+
+集合包安装成功时返回**一个数组**——每个元素对应包里的一个组件:
+
+```json
+{
+ "data": [
+ { "id": "com.example.weather", "version": "1.0.0" },
+ { "id": "com.example.clock", "version": "1.0.0" }
+ ]
+}
+```
+
+每个组件会在数据库中独立成行,可以单独启用、停用或卸载;同时它们共享同一份 zip blob 存储。
+
+## 尺寸策略
+
+`sizing.strategy` 决定组件在仪表盘里如何被调整大小:
+
+- `free` — 用户可在 `minW/H` 到 `maxW/H` 之间自由调整宽高;
+- `fixed` — 锁定 `defaultW/H`,不允许调整;
+- `aspect-square` — 始终保持 1:1,会就近吸附到最接近的层级;
+- `content-height` — 宽度可调,高度由内容决定。
+
+更多布局与编辑细节见[仪表盘与组件](/cn/docs/dashboards)。
+
+## SDK 速览
+
+`@serverbee/widget-sdk` 是 ServerBee 暴露给自定义组件的稳定 API 表面,包含:
+
+- `defineWidget` — 声明组件的入口;
+- `z` / `ZodSchema` — 内嵌的 Zod,用于描述配置项 schema;
+- 实时钩子:`useServers`、`useServer`、`useMetric`、`useCapability`(通过 `useSyncExternalStore` 订阅 WebSocket 实时数据);
+- 业务钩子:`useHistory`、`useTraffic`、`useAlerts`、`useServiceMonitors`、`useUptime`、`useGeoIp`;
+- 宿主钩子:`useTheme`、`useConfigUpdate`;
+- 通用逃生舱:`useApiQuery` / `useApiMutation` 直接调用后端 REST API;
+- `createActionsHelper` 与 `ActionDefinition` —— 在组件上注册按钮操作。
+
+完整 API 见 SDK 自带的 `packages/widget-sdk/README.md`。
+
+## 卸载
+
+在 **Settings → Widget Modules** 里点击组件右侧的删除按钮,或者:
+
+```bash
+curl -X DELETE "https://your-host/api/widget-modules/com.example.hello" \
+ -H "X-API-Key: $SERVERBEE_API_KEY"
+```
+
+内置组件(如 `com.serverbee.hello-world`)不能被卸载,对其调用 DELETE 会返回 `400`。
+
+## 限制与安全
+
+- **大小上限**:上传单个 `.js` 文件或 `.zip` 不超过 **1 MiB**;zip 中单个条目最大 **5 MiB**(解压后);整个 zip 的解压总大小不超过 **32 MiB**,条目数不超过 **64**。
+- **SSRF 防护**:从 URL 安装会先做 DNS 解析,任何落在保留/私有段的 IP 都会被拒绝——回环(`127.0.0.0/8`、`::1`)、私网(`10/8`、`172.16/12`、`192.168/16`)、CGNAT(`100.64/10`)、链路本地(`169.254/16`,含云元数据接口)、IPv6 ULA(`fc00::/7`)和链路本地(`fe80::/10`)、benchmarking、文档、组播、保留段。HTTP 跳转被禁用——3xx 响应直接拒绝,避免公网 URL 通过重定向绕回内网。
+- **Zip-slip 防护**:解压时会拒绝包含 `..` 或绝对路径的条目。
+- **静态解析**:清单必须能用纯正则解析出来;没有任何 `eval` / `Function()` 调用。解析器在源码 > 1 MiB 时直接拒绝。
+- **ID 冲突拒绝**:若上传的 `id` 已属于其他来源的模块(例如试图覆盖内置组件),返回 `409 Conflict`。
+- **SDK 版本校验**:每个清单声明 `sdkVersion` 兼容范围;不在范围内的模块不会被加载。
+- **管理员限定**:安装、卸载接口都要求管理员权限;普通成员只能读取列表与资源。
+- **审计日志**:每次安装/卸载都会写入 audit log,记录操作者、来源、id、版本、code SHA-256。
+- **同源运行**:组件运行在主 SPA 同源环境,请只安装来源可信的代码。
+- **Action 按钮**:通过 `defineWidget({ actions })` 声明的按钮自带确认对话框(`confirm` 配置生效时)、加载状态和成功/失败 toast 提示。
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/cn/index.mdx b/apps/docs/content/docs/cn/index.mdx
index 24a8f172f..79c224932 100644
--- a/apps/docs/content/docs/cn/index.mdx
+++ b/apps/docs/content/docs/cn/index.mdx
@@ -78,7 +78,7 @@ Agent 通过 WebSocket 与 Server 保持长连接,实现实时数据推送。
-
+
diff --git a/apps/docs/content/docs/cn/meta.json b/apps/docs/content/docs/cn/meta.json
index 7e5ec21f5..13f6460a8 100644
--- a/apps/docs/content/docs/cn/meta.json
+++ b/apps/docs/content/docs/cn/meta.json
@@ -21,8 +21,7 @@
"ip-quality",
"capabilities",
"status-page",
- "custom-themes",
- "custom-frontend",
+ "custom-widgets",
"mobile",
"---管理---",
"security",
diff --git a/apps/docs/content/docs/cn/status-page.mdx b/apps/docs/content/docs/cn/status-page.mdx
index 47b38db0f..133ed8424 100644
--- a/apps/docs/content/docs/cn/status-page.mdx
+++ b/apps/docs/content/docs/cn/status-page.mdx
@@ -1,6 +1,6 @@
---
title: 公开状态页
-description: 发布包含事件公告、维护窗口、可用性历史和自定义主题的公开健康状态页。
+description: 发布包含事件公告、维护窗口和可用性历史的公开健康状态页。
icon: Globe
---
@@ -48,7 +48,6 @@ GET /api/status
| 启用 | `enabled` | 禁用后页面返回 404 |
| 黄色可用性阈值 | `uptime_yellow_threshold` | 低于该百分比的日期显示为降级 |
| 红色可用性阈值 | `uptime_red_threshold` | 低于该百分比的日期显示为严重故障 |
-| 主题 | `theme_ref` | 预设/自定义主题引用,或 `null` 跟随后台默认主题 |
当前 API 请求字段使用 `server_ids_json` 和 `status_page_ids_json` 表示选择的 ID。这些字段在请求体中接受 JSON 数组。
@@ -63,7 +62,6 @@ GET /api/status/{slug}
响应包含:
- `page` -- 页面元数据和显示选项
-- `theme` -- 解析后的主题变量
- `servers` -- 选中服务器状态、可用性百分比和 90 天每日可用性数据
- `active_incidents` -- 关联到页面且尚未解决的事件
- `planned_maintenances` -- 关联到页面的活动/计划维护窗口
@@ -158,10 +156,8 @@ GET /api/status/{slug}
}
```
-更新时可以传入 `theme_ref` 设置自定义主题,例如 `"preset:default"` 或自定义主题引用。传 `null` 表示跟随后台默认主题。
-
-
+
diff --git a/apps/docs/content/docs/en/configuration.mdx b/apps/docs/content/docs/en/configuration.mdx
index 514ca0450..7a4a7081d 100644
--- a/apps/docs/content/docs/en/configuration.mdx
+++ b/apps/docs/content/docs/en/configuration.mdx
@@ -51,7 +51,6 @@ There is no admin username/password environment variable. On first start (when n
| `SERVERBEE_AUTH__MAX_SERVERS` | `0` | Maximum servers allowed via enrollment (0 = no limit). Best-effort soft cap |
| `SERVERBEE_SERVER__TRUSTED_PROXIES` | private/loopback CIDRs | CIDR list of trusted reverse proxies. Defaults to RFC 1918 + loopback. Set to `[]` to disable |
| `SERVERBEE_SCHEDULER__TIMEZONE` | `UTC` | Timezone for daily traffic aggregation (e.g. `Asia/Shanghai`) |
-| `SERVERBEE_FEATURE__CUSTOM_THEMES` | `true` | Set `feature.custom_themes` to false to disable user-defined themes. Custom refs are read-coerced to `preset:default` |
| `SERVERBEE_LOG__LEVEL` | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error` |
| `SERVERBEE_LOG__FILE` | `""` | Log file path. Empty means stdout only |
@@ -280,12 +279,6 @@ Raw metric records are collected every 60 seconds and retained for 7 days by def
|-----|------|---------|-------------|
| `timezone` | string | `"UTC"` | Timezone for daily traffic aggregation and billing cycle computation. Use IANA timezone names (e.g. `Asia/Shanghai`, `US/Eastern`) |
-### `[feature]` -- Feature Flags
-
-| Key | Type | Default | Description |
-|-----|------|---------|-------------|
-| `custom_themes` | bool | `true` | Disable user-defined themes when false. Custom refs are read-coerced to `preset:default` |
-
### `[rate_limit]` -- Rate Limiting
| Key | Type | Default | Description |
diff --git a/apps/docs/content/docs/en/custom-frontend.mdx b/apps/docs/content/docs/en/custom-frontend.mdx
deleted file mode 100644
index 97e231b71..000000000
--- a/apps/docs/content/docs/en/custom-frontend.mdx
+++ /dev/null
@@ -1,222 +0,0 @@
----
-title: Custom Frontend Themes
-description: Replace the entire ServerBee dashboard with a custom React/Vue/Svelte SPA uploaded as a .sbtheme package.
----
-
-ServerBee lets administrators swap out the built-in dashboard with any SPA they upload as a `.sbtheme` package. The custom frontend runs at the same origin as the server, uses the same REST and WebSocket API, and can be anything — a stripped-down read-only view, a heavily branded white-label product, or a completely custom monitoring experience.
-
-This is a full SPA replacement, not a plugin or color-theme overlay. When a custom frontend is active, it owns every route (login page, dashboards, settings, everything). The existing [Custom themes](/en/docs/custom-themes) color-variable system and brand settings have no effect while a custom frontend is active; the UI shows a notice explaining this.
-
-## What Can and Cannot Be Customized
-
-| What you can do | What you cannot do |
-|-----------------|-------------------|
-| Replace the full HTML/CSS/JS frontend | Add server-side code, migrations, or Rust crates |
-| Implement your own routing, login page, branding | Access any route under `/api/*`, `/swagger-ui/*`, or `/__system/*` — these are always served by the server |
-| Call any existing REST or WebSocket API | Load external scripts, stylesheets, fonts, or make cross-origin fetch/XHR/WebSocket connections (CSP enforces this) |
-| Bundle any JS framework (React, Vue, Svelte, vanilla) | Embed the frontend in an iframe on another origin (CSP `frame-ancestors: none`) |
-| Use your own colors, fonts, icons, and assets | Ship more than 20 MB of uncompressed content or more than 1000 files |
-| Read the current user session via the existing auth API | Per-user themes — one theme is active system-wide for all users |
-
-**Trust model:** Admin-installed themes run same-origin with the server. They have the same data-access authority as the logged-in user — equivalent to an admin replacing the built-in SPA on disk. CSP is defense-in-depth (it raises the bar for opportunistic exfiltration like remote image beacons and cross-origin script loads) but is **not** a containment boundary. Only install themes you trust.
-
-## Quickstart
-
-**Prerequisites:** [bun](https://bun.sh) and a running ServerBee server.
-
-### 1. Scaffold the starter template
-
-```bash
-npx degit ZingerLittleBee/ServerBee/templates/serverbee-theme-starter my-theme
-cd my-theme
-bun install
-```
-
-The starter contains a minimal Vite + TypeScript SPA with a pre-wired API client (`src/lib/serverbee.ts`) that uses same-origin session authentication.
-
-### 2. Edit and build
-
-Customize `src/App.tsx` and the rest of the source. Run `bun run dev` to iterate locally against any running ServerBee server (configure the dev proxy in `vite.config.ts`).
-
-### 3. Pack
-
-```bash
-bun run pack
-```
-
-This runs a Vite production build, validates the output against the same constraints the server enforces (file sizes, extensions, manifest), and produces a deterministic zip with the `.sbtheme` extension in the project root (e.g. `my-theme-1.0.0.sbtheme`).
-
-### 4. Upload
-
-In ServerBee, go to **Settings → Appearance**. The **Custom Frontend** section appears at the top for admin users. Drag the `.sbtheme` file onto the upload card, or click to browse.
-
-After upload, click **Preview** to open the theme in a new tab without activating it system-wide. When you are satisfied, click **Activate**.
-
-
-Activation is immediately global. Every user who reloads any root-level URL will see the new theme. Use **Preview** to verify before activating. If something goes wrong, visit `?theme=default` to recover (see [Recovery and debugging](#recovery-and-debugging)).
-
-
-## Manifest Reference
-
-Every `.sbtheme` package must contain a `manifest.json` at the root of the zip:
-
-```json
-{
- "schema_version": 1,
- "id": "acme-dashboard",
- "name": "Acme Corp Dashboard",
- "version": "1.2.0",
- "author": "Acme Inc",
- "homepage": "https://acme.example.com",
- "description": "Branded dashboard for Acme.",
- "entry": "index.html",
- "preview": "preview.png"
-}
-```
-
-| Field | Required | Constraints |
-|-------|----------|-------------|
-| `schema_version` | yes | Integer; only `1` is accepted |
-| `id` | yes | Matches `^[a-z][a-z0-9-]{2,63}$` |
-| `name` | yes | 1–64 chars; HTML tags stripped |
-| `version` | yes | Valid semver (e.g. `1.2.0`) |
-| `author` | no | ≤ 64 chars; HTML tags stripped |
-| `homepage` | no | Valid `http(s)://` URL |
-| `description` | no | ≤ 500 chars; HTML tags stripped |
-| `entry` | no | Defaults to `index.html`; path must exist in the package and end with `.html` |
-| `min_serverbee_version` | no | Semver; upload is rejected if the running server version is lower |
-| `preview` | no | Relative path to a preview image; must exist; ≤ 500 KB; `png`, `jpg`, or `webp` only |
-
-
-**`min_serverbee_version` and pre-releases:** semver treats a release like `1.0.0` as **greater** than a pre-release like `1.0.0-alpha.3`. If your target deployment runs a pre-release build, setting `"min_serverbee_version": "1.0.0"` will cause the upload to be rejected. Leave this field out unless you actually depend on a specific newer server feature.
-
-
-The `id` field acts as a theme family identifier. You can upload multiple versions with the same `id`; the server keeps the full history and lets you activate any of them. Uploading a lower version than the newest existing version for the same `id` is rejected with `NO_DOWNGRADE`.
-
-## Size and File Limits
-
-| Limit | Value |
-|-------|-------|
-| Multipart upload hard cap | 25 MB |
-| Total uncompressed content | 20 MB |
-| Single file | 5 MB |
-| File count | 1000 |
-| Per-file path length | 255 chars |
-| Preview image | 500 KB |
-
-**Allowed file extensions** (case-insensitive): `html`, `htm`, `js`, `mjs`, `css`, `png`, `jpg`, `jpeg`, `svg`, `webp`, `gif`, `ico`, `woff`, `woff2`, `ttf`, `otf`, `json`, `txt`, `map`.
-
-The server also enforces a compression-ratio guard: any single zip entry whose decompressed size exceeds its compressed size by more than 100× is rejected (zip bomb defense).
-
-Files rejected outright: directory traversal entries (`../`), absolute paths, Windows drive letters, symlink zip entries, and duplicate entries.
-
-
-Keep documentation, README files, and source maps out of the `.sbtheme` package. They inflate the size budget and the server never serves them. Distribute them alongside the theme source repo instead.
-
-
-## API and WebSocket Reference
-
-A custom frontend calls the same HTTP REST API and WebSocket endpoints as the built-in dashboard. There is no separate "theme SDK."
-
-- **Full REST API reference:** [`/swagger-ui/`](/swagger-ui/) — live on your running server
-- **OpenAPI document:** [`/api-docs/openapi.json`](/api-docs/openapi.json)
-- **Authentication:** The session cookie is set by `POST /api/auth/login` and is automatically included in all same-origin requests. API key authentication via `X-API-Key` header is also supported.
-- **WebSocket (server updates):** Connect to `GET /api/ws/servers` (browser WebSocket). The server pushes `BrowserMessage` frames (JSON) with `FullSync`, `Update`, `ServerOnline`, `ServerOffline`, and other variants.
-- **Terminal:** Binary WebSocket frames on `/api/ws/terminal/:sessionId`. The first 16 bytes are the session UUID; the rest is raw PTY data.
-
-The `/__system/*` prefix is always served by the default SPA and cannot be overridden by a custom theme. It provides the recovery UI and the `POST /__system/clear-recovery` / `POST /__system/clear-preview` endpoints used by the recovery and preview mechanisms.
-
-## CSP Constraints
-
-All responses that serve custom theme files include the following Content Security Policy:
-
-```
-Content-Security-Policy:
- default-src 'self';
- script-src 'self' 'unsafe-inline' 'unsafe-eval';
- style-src 'self' 'unsafe-inline';
- img-src 'self' data: blob:;
- font-src 'self' data:;
- connect-src 'self';
- frame-ancestors 'none';
- base-uri 'self';
- form-action 'self';
-```
-
-**What `connect-src 'self'` means for theme authors:**
-
-- Fetch, XHR, and WebSocket connections are restricted to the same origin. Your theme can freely call `/api/*` and connect to WebSocket endpoints on the same server.
-- Connections to external URLs (e.g. `https://api.example.com`, `wss://external.host`) are blocked by the browser.
-- If your theme bundles analytics, error-reporting SDKs, or CDN-hosted assets that make external network requests, those requests will be blocked.
-
-**What the CSP does not prevent:**
-
-- Top-level navigation exfiltration (`location.href = 'https://...'`). The CSP3 `navigate-to` directive that would cover this is not supported by browsers in practice.
-- Same-origin API access — the theme runs with the user's session and can read anything the user can read.
-
-`unsafe-eval` is included for compatibility with most React, Vue, Svelte, and wasm-bindgen builds.
-
-The default built-in SPA is not affected by these headers. The CSP above applies only to custom theme responses.
-
-## Recovery and Debugging
-
-### Recover from a broken theme
-
-If a custom theme breaks the UI and you cannot navigate to the management page:
-
-1. Visit `/?theme=default` in your browser. The server immediately returns the built-in default SPA regardless of the active theme and sets a recovery cookie (`sb_force_default`) that keeps the origin on the default SPA for one hour.
-2. You are now on the default SPA. Navigate to **Settings → Appearance** and deactivate or delete the broken theme.
-3. To exit recovery manually and return to the custom theme, click **Exit recovery** on the default SPA, or visit `/?theme=active`. This calls `POST /__system/clear-recovery` and clears the cookie.
-
-
-The recovery cookie is browser-wide (origin-scoped) and lasts one hour. Any tab in the same browser that reloads will see the default SPA until recovery is cleared or the cookie expires.
-
-
-### Preview before activating
-
-To safely inspect a theme before making it active system-wide:
-
-1. Upload the theme. It is not activated automatically.
-2. Click **Preview** on the theme card. A dialog explains that preview is browser-wide.
-3. Confirm — a new tab opens at `/?theme=preview:` and a sticky banner appears inside the theme showing remaining time and an **Exit preview** button.
-4. Review the theme. When done, click **Exit preview** in the banner. The tab returns to the default SPA.
-5. Click **Activate** in the management UI to commit system-wide.
-
-Preview uses a 15-minute cookie (`sb_preview_theme`). It is browser-wide: any tab in the same browser that reloads will see the preview theme. If you need the management UI live during a long review, use a separate browser or incognito window.
-
-### Force the active theme
-
-Visit `/?theme=active` to clear both the recovery and preview cookies and serve the currently active theme (or the default if none is active).
-
-### Debug CSP violations
-
-Open the browser **DevTools → Console**. CSP violations appear as errors with the blocked URL and the violated directive. Common causes:
-
-- External fetch or WebSocket call blocked by `connect-src 'self'` — move the request to your own proxy endpoint or remove the external dependency.
-- External font or image blocked by `font-src`/`img-src` — serve assets from within the `.sbtheme` package or use `data:` URIs.
-- `eval()` call blocked — `unsafe-eval` is already included in the CSP for custom themes, so this should not occur. If you still see this error, check whether your browser extension is overriding the CSP.
-
-## Best Practices
-
-### Asset URLs and SPA history routing
-
-Set `base: '/'` in your Vite config (this is the starter default). The server falls back to serving your `index.html` for any deep path that is not a known asset (e.g. `/servers/abc`). With `base: '/'`, asset URLs are absolute (`/assets/app.js`) and resolve correctly regardless of the current SPA route. With `base: './'`, a reload at `/servers/abc` would request `./assets/app.js` → `/servers/assets/app.js` → 404.
-
-### Internationalization
-
-Use the same locale keys as the built-in SPA where possible. The user's preferred language is available via the `Accept-Language` header (server-side) or `navigator.language` (client-side). Consider shipping translations for at least English and Chinese if your theme is intended for wide use.
-
-### Dark mode
-
-Detect the user's color scheme preference with `prefers-color-scheme` or persist it in `localStorage`. The built-in SPA uses OKLCH variables — if you want your theme to interoperate with the existing color-theme infrastructure, expose a `data-theme` attribute on `` and use the same variable names.
-
-### Accessibility
-
-- Ensure sufficient color contrast (WCAG AA: 4.5:1 for normal text, 3:1 for large text).
-- Use semantic HTML and ARIA roles for interactive components.
-- Keyboard navigation must work for all interactive elements.
-
-### Mobile
-
-Test at 375 px viewport width. Avoid fixed-width layouts. The starter includes a responsive base; do not remove the `` tag.
-
diff --git a/apps/docs/content/docs/en/custom-themes.mdx b/apps/docs/content/docs/en/custom-themes.mdx
deleted file mode 100644
index 3a0e21b21..000000000
--- a/apps/docs/content/docs/en/custom-themes.mdx
+++ /dev/null
@@ -1,101 +0,0 @@
----
-title: Custom themes
-description: Build, share, and apply your own ServerBee theme variables.
----
-
-ServerBee includes preset themes, and administrators can also create full custom themes. A custom theme stores OKLCH CSS variables for light and dark mode in the database, so the dashboard and public status pages can resolve the same theme everywhere.
-
-## Concepts
-
-- Preset themes are immutable and ship with the web app.
-- Custom themes are stored in the server database.
-- The active admin theme controls the dashboard theme for all users.
-- Each public status page can use its own theme or follow the active admin theme.
-
-## Create a theme
-
-1. Open **Settings → Appearance**.
-2. In **My themes**, click **New theme**.
-3. Enter a name and choose a preset to fork from.
-4. Edit the light and dark variables in the theme editor.
-5. Save the theme.
-
-## Apply a theme
-
-- Dashboard: click any preset or custom theme card on **Settings → Appearance**.
-- Status page: edit a status page and choose a theme from the **Theme** selector.
-- To inherit the dashboard theme on a status page, choose **Follow admin default**.
-
-## Import and export
-
-Use **Export** in the editor to download a theme JSON file. Use **Import** on the appearance page to upload one.
-
-The import format is:
-
-```json
-{
- "version": 1,
- "name": "Theme name",
- "description": "Optional description",
- "based_on": "default",
- "vars_light": {},
- "vars_dark": {}
-}
-```
-
-Only `version: 1` is accepted.
-
-## Validation
-
-- Every required variable must exist in both light and dark maps.
-- Values must use `oklch(L C H)` or `oklch(L C H / alpha)` syntax.
-- `L` must be between `0` and `1`.
-- `H` must be between `0` and `360`.
-- Alpha must be between `0` and `1`, or between `0%` and `100%`.
-- Chroma has no hard cap. Browsers may gamut-clip values that cannot be displayed.
-
-## Branding and White Label
-
-The same **Settings → Appearance** page also controls basic product branding. Branding settings are stored in the server database and are read by both the dashboard shell and public pages.
-
-| Field | Description |
-|-------|-------------|
-| `site_title` | Browser/app title shown by the UI |
-| `footer_text` | Footer text shown where the UI renders a product footer |
-| `logo_path` | Public path for the uploaded logo, usually `/api/brand/logo` |
-| `favicon_path` | Public path for the uploaded favicon, usually `/api/brand/favicon` |
-
-Public endpoints:
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/settings/brand` | Read brand configuration without authentication |
-| GET | `/api/brand/logo` | Serve uploaded logo |
-| GET | `/api/brand/favicon` | Serve uploaded favicon |
-
-Admin endpoints:
-
-| Method | Path | Description |
-|--------|------|-------------|
-| PUT | `/api/settings/brand` | Update `site_title`, `footer_text`, `logo_path`, and `favicon_path` as JSON |
-| POST | `/api/settings/brand/logo` | Upload a logo via multipart field `file` |
-| POST | `/api/settings/brand/favicon` | Upload a favicon via multipart field `file` |
-
-Logo and favicon uploads accept PNG or ICO files only and are limited to 512 KB. Uploading a new asset replaces the previous asset of the same type.
-
-
-`PUT /api/settings/brand` expects JSON. Image files are uploaded through the dedicated logo/favicon endpoints, not through the JSON update endpoint.
-
-
-## Disable custom themes
-
-Set:
-
-```toml
-[feature]
-custom_themes = false
-```
-
-Or set `SERVERBEE_FEATURE__CUSTOM_THEMES=false`.
-
-When disabled, custom theme mutation endpoints reject writes, and any active `custom:*` reference is resolved as the default preset at read time. Stored themes are preserved.
diff --git a/apps/docs/content/docs/en/custom-widgets.mdx b/apps/docs/content/docs/en/custom-widgets.mdx
new file mode 100644
index 000000000..700a2b00c
--- /dev/null
+++ b/apps/docs/content/docs/en/custom-widgets.mdx
@@ -0,0 +1,294 @@
+---
+title: Custom Widgets
+description: Author custom dashboard widgets with React + Zod, and install them into ServerBee as a single file or a zip collection.
+icon: Puzzle
+---
+
+ServerBee dashboards natively support custom widget modules. Each widget is a standalone ES module that an admin installs once and then drops onto any dashboard, exactly like a built-in widget. This guide covers the two supported install methods:
+
+- **Method B** — a single `.js` file (one widget per file)
+- **Method C** — a `.zip` collection bundle (multiple widgets in one upload, sharing the same build output)
+
+## Concepts
+
+A widget module has three parts:
+
+1. **Static JSDoc manifest** — a `@serverbee-widget {...}` JSON block at the top of the file declaring the widget's `id`, `version`, `name`, `category`, default sizing, required SDK version, and so on. The manifest is **statically parseable**: the server can index it without ever executing the module, which means offline scanning and permission checks work the same way as runtime loading.
+2. **Default export** — the object returned by `defineWidget({ configSchema, component, actions? })`. `configSchema` is a Zod schema describing the configurable fields; `component` is a React component that receives `{ config, size, isEditing, actions }` props at runtime.
+3. **Runtime dependencies** — imported as `react`, `react/jsx-runtime`, and `@serverbee/widget-sdk`. These are **provided by the host** and must be marked `external` at build time; the browser reuses the main app's instances.
+
+### Trust model
+
+Custom widgets are **admin-only to install** and run **same-origin** in the browser, sharing the session of the logged-in user. That means:
+
+- An approved widget can issue any API request that the current user is authorized for.
+- All widgets share the same React context, so do not introduce global side effects that could break sibling widgets.
+- The platform does not sandbox widget code — **only install code you trust**.
+
+## Method B — single `.widget.js` file
+
+### Anatomy
+
+```js
+/**
+ * @serverbee-widget {
+ * "id": "com.example.hello",
+ * "version": "1.0.0",
+ * "name": "Hello",
+ * "description": "A minimal example widget.",
+ * "author": "Your Name",
+ * "category": "Real-time",
+ * "sizing": { "defaultW": 3, "defaultH": 2, "minW": 2, "minH": 2, "strategy": "free" },
+ * "sdkVersion": "^0.1.0"
+ * }
+ */
+import { defineWidget, useServers, useTheme, z } from '@serverbee/widget-sdk'
+
+const ConfigSchema = z.object({
+ greeting: z.string().describe('Greeting text').default('Hello, ServerBee')
+})
+
+export default defineWidget({
+ configSchema: ConfigSchema,
+ component: ({ config }) => {
+ const servers = useServers()
+ const theme = useTheme()
+ const online = servers.filter((s) => s.online).length
+ const { greeting } = config
+ return (
+