diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c731b84..ec7ac3ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-alpha.5] - 2026-05-28 + +### Added + +- **Custom widget system (B/C method)** -- New `@serverbee/widget-sdk` workspace package exposes `defineWidget`, a bundled `z` schema validator, and a typed hook surface (live: `useServers/useServer/useMetric/useCapability` via `useSyncExternalStore`; domain: `useHistory/useTraffic/useAlerts/useServiceMonitors/useUptime/useGeoIp`; host: `useTheme/useConfigUpdate`; escape hatches: `useApiQuery/useApiMutation`). Widgets are authored as a single ESM file with a top-of-file `@serverbee-widget` JSDoc manifest, statically extractable, no `eval`. Admins install via `POST /api/widget-modules` (URL or multipart upload) for single `.js`/`.mjs` files or `.zip` collection bundles with a `collection.json` index. Built-in widgets are emitted by a new Vite nested-build plugin to `apps/web/dist/builtin-widgets/`, embedded into the server binary via rust-embed, and registered at boot +- **Dashboard module rendering** -- `dashboard_widget` gained a `module_id` column; `widget_type='module'` widgets dispatch through the widget registry and render via the SDK component contract. The picker surfaces installed modules under a "Custom Widgets" section; the config dialog renders the module's `configSchema` via the SDK form renderer (real renderers for `z.metricPath`/`z.color`/`z.duration`) with friendly placeholders for missing modules or empty schemas. `ActionButton` from the SDK ships with confirm dialog, pending state, and success/error toast wiring +- **Bilingual widget docs** -- `apps/docs/content/docs/{en,cn}/custom-widgets.mdx` covers method B (single file) and method C (zip bundle) end-to-end: manifest fields, build pipeline with React/SDK externals, install flows, asset resolution rules, SDK surface summary, and the full safety/limits table + +### Changed + +- **Widget install hardening** -- SSRF guard now resolves DNS and rejects any host whose IP falls in a reserved/private range (loopback, RFC 1918, CGNAT, link-local incl. cloud metadata, IPv6 ULA/link-local, benchmarking, documentation, multicast, reserved); HTTP redirects are disabled; uploads enforce a per-route 1 MiB body limit with streaming size accounting; zip extraction caps total uncompressed size at 32 MiB across at most 64 entries; manifest extractor rejects sources > 1 MiB up front; id conflicts across `source_type` (e.g. upload trying to overwrite a builtin) return `409 Conflict`; `dashboard_widget.module_id` is validated against installed modules; SDK declarations carry a `sdkVersion` semver range checked at load time. Install and uninstall events emit audit log entries +- **Runtime bridge** -- `mountRuntimeBridge` now wires the SDK runtime to the live React Query servers cache and the host theme provider via `useSyncExternalStore`, surfaces sonner toasts, and exposes a confirm-dialog request channel. `/runtime/*` import-map shims are served with `Cache-Control: no-cache` to avoid stale shim drift across SPA upgrades. `defineWidget` rejects duplicate action ids + +### Removed + +- **Legacy SPA theme + custom CSS theme system** -- The `spa_theme` package upload feature, `custom_theme` CSS variable system, and seven preset themes are deleted in their entirety (backend service/router/entity, migrations dropping `spa_themes` and `custom_theme`, the appearance settings UI, preset CSS files, `theme_ref` from public status pages, and the `SERVERBEE_FEATURE__CUSTOM_THEMES` config field). The theme provider is collapsed to light/dark/system. The old `custom-themes.mdx` and `custom-frontend.mdx` doc pages are replaced by `custom-widgets.mdx` + ## [1.0.0-alpha.4] - 2026-05-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index e36af316a..159789c0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3302,12 +3302,14 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.6", ] @@ -3970,7 +3972,7 @@ dependencies = [ [[package]] name = "serverbee-agent" -version = "1.0.0-alpha.4" +version = "1.0.0-alpha.5" dependencies = [ "anyhow", "async-trait", @@ -4013,7 +4015,7 @@ dependencies = [ [[package]] name = "serverbee-common" -version = "1.0.0-alpha.4" +version = "1.0.0-alpha.5" dependencies = [ "chrono", "serde", @@ -4024,7 +4026,7 @@ dependencies = [ [[package]] name = "serverbee-server" -version = "1.0.0-alpha.4" +version = "1.0.0-alpha.5" dependencies = [ "a2", "anyhow", @@ -5512,6 +5514,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index ae2104cad..3fabe49ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "1.0.0-alpha.4" +version = "1.0.0-alpha.5" edition = "2024" license = "AGPL-3.0-or-later" repository = "https://github.com/ZingerLittleBee/ServerBee" diff --git a/apps/docs/content/docs/cn/configuration.mdx b/apps/docs/content/docs/cn/configuration.mdx index da4e89c4a..473bc41dd 100644 --- a/apps/docs/content/docs/cn/configuration.mdx +++ b/apps/docs/content/docs/cn/configuration.mdx @@ -57,7 +57,6 @@ ServerBee 使用 [figment](https://github.com/SergioBenitez/Figment) 库加载 | `SERVERBEE_AUTH__MAX_SERVERS` | `0` | 通过注册码接入的最大服务器数(0 = 不限制),尽力软限制 | | `SERVERBEE_SERVER__TRUSTED_PROXIES` | 私有/回环 CIDR | 受信任的反向代理 CIDR 列表,默认信任 RFC 1918 + 回环地址。设为 `[]` 禁用 | | `SERVERBEE_SCHEDULER__TIMEZONE` | `UTC` | 流量日聚合时区(如 `Asia/Shanghai`) | -| `SERVERBEE_FEATURE__CUSTOM_THEMES` | `true` | 将 `feature.custom_themes` 设为 false 可禁用用户自定义主题。自定义引用会在读取时强制转换为 `preset:default` | | `SERVERBEE_LOG__LEVEL` | `info` | 日志级别:`trace`/`debug`/`info`/`warn`/`error` | | `SERVERBEE_LOG__FILE` | `""` | 日志文件路径,留空输出到 stdout | @@ -353,13 +352,6 @@ ip_quality_event_days = 90 # 默认: "UTC" timezone = "UTC" -# --- 功能开关 --- -[feature] -# 是否启用用户自定义主题 -# 设为 false 时,自定义主题引用会在读取时强制转换为 preset:default -# 默认: true -custom_themes = true - # --- GeoIP 地理位置 --- [geoip] # MaxMind 兼容 MMDB 数据库文件路径,路径非空时使用自定义 GeoIP;为空时可在设置页下载 DB-IP Lite diff --git a/apps/docs/content/docs/cn/custom-frontend.mdx b/apps/docs/content/docs/cn/custom-frontend.mdx deleted file mode 100644 index d7485a398..000000000 --- a/apps/docs/content/docs/cn/custom-frontend.mdx +++ /dev/null @@ -1,222 +0,0 @@ ---- -title: 自定义前端主题 -description: 将整个 ServerBee 仪表盘替换为你上传的自定义 React/Vue/Svelte SPA(.sbtheme 包)。 ---- - -ServerBee 允许管理员将内置仪表盘替换为任意上传的 `.sbtheme` 包。自定义前端与服务端同源运行,使用相同的 REST 和 WebSocket API,可以是一个精简的只读视图、深度品牌定制的白标产品,或者完全自定义的监控界面。 - -这是一次完整的 SPA 替换,而非插件或颜色主题叠加。当自定义前端激活后,它接管所有路由(登录页、仪表盘、设置等)。现有的[自定义主题](/cn/docs/custom-themes)颜色变量系统和品牌设置在自定义前端激活期间不起作用,界面会显示相应提示。 - -## 可以和不可以自定义的内容 - -| 可以做的 | 不可以做的 | -|----------|-----------| -| 替换完整的 HTML/CSS/JS 前端 | 添加服务端代码、数据库迁移或 Rust crate | -| 实现自己的路由、登录页和品牌 | 访问 `/api/*`、`/swagger-ui/*` 或 `/__system/*` 下的路由——这些始终由服务端处理 | -| 调用所有现有的 REST 或 WebSocket API | 加载外部脚本、样式表、字体,或发起跨域 fetch/XHR/WebSocket 请求(CSP 限制) | -| 使用任意 JS 框架(React、Vue、Svelte、原生 JS) | 在其他来源的 iframe 中嵌入前端(CSP `frame-ancestors: none`) | -| 使用自己的颜色、字体、图标和资源 | 超过 20 MB 的未压缩内容或超过 1000 个文件 | -| 通过现有认证 API 读取当前用户会话 | 按用户设置主题——全系统只有一个激活主题 | - -**信任模型:** 管理员安装的主题与服务端同源运行,具有与当前登录用户相同的数据访问权限——等同于管理员直接替换磁盘上的内置 SPA。CSP 是纵深防御(可阻止被动资源加载等机会性泄露途径),但**不是**隔离边界。请只安装你信任的主题。 - -## 快速上手 - -**前置条件:** 已安装 [bun](https://bun.sh) 并有一个正在运行的 ServerBee 服务端。 - -### 1. 脚手架初始化 - -```bash -npx degit ZingerLittleBee/ServerBee/templates/serverbee-theme-starter my-theme -cd my-theme -bun install -``` - -Starter 包含一个极简的 Vite + TypeScript SPA,内置预接好的 API 客户端(`src/lib/serverbee.ts`),使用同源 Session 认证。 - -### 2. 编辑与构建 - -修改 `src/App.tsx` 及其他源文件,运行 `bun run dev` 在本地针对任意运行中的 ServerBee 服务端进行迭代开发(在 `vite.config.ts` 中配置 dev proxy)。 - -### 3. 打包 - -```bash -bun run pack -``` - -该命令会执行 Vite 生产构建,按照服务端相同的约束(文件大小、扩展名、manifest)验证输出,并在项目根目录生成一个确定性的 `.sbtheme` zip 文件(如 `my-theme-1.0.0.sbtheme`)。 - -### 4. 上传 - -在 ServerBee 中打开 **设置 → 外观**。管理员用户可以在顶部看到**自定义前端**区域。将 `.sbtheme` 文件拖到上传卡片,或点击选择文件。 - -上传后,点击 **预览** 在新标签页中打开主题,不会影响全局激活状态。确认后,点击 **激活**。 - - -激活操作立即全局生效。每个重新加载根路径的用户都会看到新主题。激活前请使用**预览**进行验证。如果出现问题,可访问 `?theme=default` 进行恢复(参见[恢复与调试](#恢复与调试))。 - - -## Manifest 参考 - -每个 `.sbtheme` 包的 zip 根目录下必须包含 `manifest.json`: - -```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" -} -``` - -| 字段 | 必填 | 约束 | -|------|------|------| -| `schema_version` | 是 | 整数;当前只接受 `1` | -| `id` | 是 | 匹配 `^[a-z][a-z0-9-]{2,63}$` | -| `name` | 是 | 1–64 字符;HTML 标签会被过滤 | -| `version` | 是 | 合法的 semver(如 `1.2.0`) | -| `author` | 否 | ≤ 64 字符;HTML 标签会被过滤 | -| `homepage` | 否 | 合法的 `http(s)://` URL | -| `description` | 否 | ≤ 500 字符;HTML 标签会被过滤 | -| `entry` | 否 | 默认为 `index.html`;路径必须存在于包中且以 `.html` 结尾 | -| `min_serverbee_version` | 否 | semver;上传时若运行中的服务端版本低于此值,上传将被拒绝 | -| `preview` | 否 | 预览图的相对路径;必须存在;≤ 500 KB;仅支持 `png`、`jpg`、`webp` | - - -**`min_serverbee_version` 与预发布版本:** semver 规定正式版(如 `1.0.0`)**大于**预发布版(如 `1.0.0-alpha.3`)。如果你的目标部署运行的是预发布构建,设置 `"min_serverbee_version": "1.0.0"` 会导致上传被拒绝。除非你确实依赖某个更新的服务端特性,否则请不要填写此字段。 - - -`id` 字段作为主题系列标识符。同一 `id` 可以上传多个版本,服务端会保留完整历史,允许激活其中任意一个。上传的版本号低于同 `id` 最新版本时,将以 `NO_DOWNGRADE` 拒绝。 - -## 大小与文件限制 - -| 限制项 | 限制值 | -|--------|--------| -| multipart 上传硬上限 | 25 MB | -| 解压后内容总大小 | 20 MB | -| 单文件大小 | 5 MB | -| 文件数量 | 1000 | -| 单文件路径长度 | 255 字符 | -| 预览图大小 | 500 KB | - -**允许的文件扩展名**(大小写不敏感):`html`、`htm`、`js`、`mjs`、`css`、`png`、`jpg`、`jpeg`、`svg`、`webp`、`gif`、`ico`、`woff`、`woff2`、`ttf`、`otf`、`json`、`txt`、`map`。 - -服务端还会执行压缩比检查:任意单个 zip 条目的解压大小超过压缩大小 100 倍以上时,将被拒绝(防止 zip 炸弹)。 - -以下情况会直接拒绝:目录穿越条目(`../`)、绝对路径、Windows 盘符、符号链接 zip 条目和重复条目。 - - -不要将文档、README 和源码 map 文件放入 `.sbtheme` 包。它们会消耗大小配额,服务端也永远不会提供这些文件。建议将它们放在主题源码仓库中单独分发。 - - -## API 与 WebSocket 参考 - -自定义前端调用与内置仪表盘相同的 HTTP REST API 和 WebSocket 端点,不需要单独的"主题 SDK"。 - -- **完整 REST API 文档:** [`/swagger-ui/`](/swagger-ui/) — 在你的运行实例上实时可用 -- **OpenAPI 文档:** [`/api-docs/openapi.json`](/api-docs/openapi.json) -- **认证:** Session cookie 由 `POST /api/auth/login` 设置,同源请求自动携带。也支持通过 `X-API-Key` 请求头进行 API Key 认证。 -- **WebSocket(服务器更新):** 连接到 `GET /api/ws/servers`(浏览器 WebSocket)。服务端推送 `BrowserMessage` JSON 帧,包含 `FullSync`、`Update`、`ServerOnline`、`ServerOffline` 等变体。 -- **终端:** 在 `/api/ws/terminal/:sessionId` 使用 Binary WebSocket 帧,前 16 字节为 Session UUID,其余为原始 PTY 数据。 - -`/__system/*` 前缀始终由默认 SPA 提供服务,自定义主题无法覆盖。该路径提供恢复 UI 以及 `POST /__system/clear-recovery` / `POST /__system/clear-preview` 端点。 - -## CSP 约束 - -服务自定义主题文件的所有响应都包含以下 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'; -``` - -**`connect-src 'self'` 对主题开发者意味着什么:** - -- fetch、XHR 和 WebSocket 连接限制为同源。主题可以自由调用 `/api/*`,以及连接同一服务端上的 WebSocket 端点。 -- 外部 URL 的连接(如 `https://api.example.com`、`wss://external.host`)会被浏览器拦截。 -- 如果你的主题集成了向外部发起网络请求的埋点、错误上报 SDK 或 CDN 资源,这些请求将被拦截。 - -**CSP 不会阻止的内容:** - -- 顶层导航泄露(`location.href = 'https://...'`)。理论上可覆盖此场景的 CSP3 `navigate-to` 指令在浏览器中实际上不被支持。 -- 同源 API 访问——主题携带用户会话,可以读取用户有权访问的任何数据。 - -`unsafe-eval` 为了兼容大多数 React、Vue、Svelte 和 wasm-bindgen 构建而默认开启。 - -以上头信息仅对自定义主题响应生效,不影响内置默认 SPA。 - -## 恢复与调试 - -### 从损坏的主题中恢复 - -如果自定义主题导致 UI 无法使用,无法导航到管理页面: - -1. 在浏览器中访问 `/?theme=default`。服务端会立即返回内置默认 SPA(无论当前激活的主题是什么),并设置一个恢复 cookie(`sb_force_default`),让该来源在一小时内保持在默认 SPA 上。 -2. 此时你已回到默认 SPA。导航到 **设置 → 外观**,停用或删除损坏的主题。 -3. 要手动退出恢复状态并返回自定义主题,点击默认 SPA 上的**退出恢复**按钮,或访问 `/?theme=active`。这会调用 `POST /__system/clear-recovery` 并清除 cookie。 - - -恢复 cookie 的作用域为整个浏览器(同源),有效期为一小时。在 cookie 过期或恢复被清除之前,同一浏览器中重新加载的任何标签页都会看到默认 SPA。 - - -### 激活前预览 - -在系统范围内激活主题前,可以先安全地预览: - -1. 上传主题。不会自动激活。 -2. 点击主题卡片上的**预览**。弹窗会说明预览是浏览器范围的。 -3. 确认后,新标签页在 `/?theme=preview:` 打开,主题内会出现一个固定横幅,显示剩余时间和**退出预览**按钮。 -4. 查看主题。完成后,点击横幅中的**退出预览**。标签页会返回默认 SPA。 -5. 在管理界面点击**激活**,完成全局切换。 - -预览使用一个有效期 15 分钟的 cookie(`sb_preview_theme`)。它的作用域是整个浏览器:同一浏览器中重新加载的任何标签页都会看到预览主题。如果你需要在长时间预览期间保持管理界面可用,请使用另一个浏览器或无痕窗口。 - -### 强制显示激活主题 - -访问 `/?theme=active` 可同时清除恢复和预览两个 cookie,并返回当前激活的主题(若未激活任何主题,则返回默认 SPA)。 - -### 调试 CSP 违规 - -打开浏览器 **DevTools → Console**。CSP 违规会以错误形式显示,包含被拦截的 URL 和违反的指令。常见原因: - -- 被 `connect-src 'self'` 拦截的外部 fetch 或 WebSocket 请求——将请求迁移到你自己的代理端点,或移除对外部依赖的引用。 -- 被 `font-src`/`img-src` 拦截的外部字体或图片——从 `.sbtheme` 包内提供资源,或改用 `data:` URI。 -- `eval()` 调用被拦截——自定义主题的 CSP 已包含 `unsafe-eval`,正常情况下不应出现此错误。如果仍然报错,检查浏览器扩展是否覆盖了 CSP。 - -## 最佳实践 - -### 资源 URL 与 SPA history 路由 - -在 Vite 配置中设置 `base: '/'`(Starter 默认值)。服务端会将任意未匹配到已知资源的深层路径(如 `/servers/abc`)回退为你的 `index.html`。使用 `base: '/'` 时,资源 URL 为绝对路径(`/assets/app.js`),无论当前 SPA 路由如何都能正确解析。使用 `base: './'` 时,在 `/servers/abc` 刷新会请求 `./assets/app.js` → `/servers/assets/app.js` → 404。 - -### 国际化 - -尽可能使用与内置 SPA 相同的语言键。用户偏好语言可通过 `Accept-Language` 请求头(服务端)或 `navigator.language`(客户端)获取。如果主题面向广泛用户,建议至少提供中英文两套翻译。 - -### 深色模式 - -使用 `prefers-color-scheme` 检测用户配色偏好,或将其持久化到 `localStorage`。内置 SPA 使用 OKLCH 变量——如果希望与现有颜色主题基础设施互操作,可在 `` 上暴露 `data-theme` 属性并使用相同的变量名。 - -### 无障碍 - -- 确保足够的颜色对比度(WCAG AA:正文 4.5:1,大字 3:1)。 -- 为交互组件使用语义化 HTML 和 ARIA role。 -- 所有交互元素必须支持键盘导航。 - -### 移动端 - -在 375 px 视口宽度下进行测试,避免固定宽度布局。Starter 已内置响应式基础,不要删除 `` 标签。 - diff --git a/apps/docs/content/docs/cn/custom-themes.mdx b/apps/docs/content/docs/cn/custom-themes.mdx deleted file mode 100644 index f2aa7eeeb..000000000 --- a/apps/docs/content/docs/cn/custom-themes.mdx +++ /dev/null @@ -1,101 +0,0 @@ ---- -title: 自定义主题 -description: 创建、分享并应用你自己的 ServerBee 主题变量。 ---- - -ServerBee 内置多套预设主题,管理员也可以创建完整的自定义主题。自定义主题会把浅色和深色模式的 OKLCH CSS 变量保存到数据库中,后台和公共状态页都能解析同一套主题。 - -## 概念 - -- 预设主题是应用内置的,不可直接修改。 -- 自定义主题保存在服务端数据库中。 -- 后台激活主题会影响所有用户看到的仪表盘主题。 -- 每个公共状态页可以绑定独立主题,也可以跟随后台激活主题。 - -## 创建主题 - -1. 打开 **设置 → 外观**。 -2. 在 **我的主题** 区域点击 **新建主题**。 -3. 输入名称,并选择一个预设作为基础。 -4. 在编辑器中分别调整浅色和深色变量。 -5. 保存主题。 - -## 应用主题 - -- 后台:在 **设置 → 外观** 中点击任意预设或自定义主题卡片。 -- 状态页:编辑状态页,在 **主题** 下拉框中选择主题。 -- 如果状态页要继承后台主题,选择 **跟随后台默认**。 - -## 导入和导出 - -在编辑器中点击 **导出** 可以下载主题 JSON 文件。在外观页面点击 **导入** 可以上传主题文件。 - -导入格式如下: - -```json -{ - "version": 1, - "name": "Theme name", - "description": "Optional description", - "based_on": "default", - "vars_light": {}, - "vars_dark": {} -} -``` - -当前只接受 `version: 1`。 - -## 校验规则 - -- 浅色和深色变量表都必须包含全部必需变量。 -- 值必须使用 `oklch(L C H)` 或 `oklch(L C H / alpha)` 语法。 -- `L` 必须在 `0` 到 `1` 之间。 -- `H` 必须在 `0` 到 `360` 之间。 -- Alpha 必须在 `0` 到 `1` 之间,或在 `0%` 到 `100%` 之间。 -- Chroma 不设硬上限。浏览器可能会对无法显示的颜色做色域裁切。 - -## 品牌和白标 - -同一个 **设置 → 外观** 页面也用于控制基础品牌信息。品牌设置保存在服务端数据库中,后台外壳和公开页面都会读取。 - -| 字段 | 说明 | -|------|------| -| `site_title` | UI 显示的浏览器/应用标题 | -| `footer_text` | UI 渲染产品页脚时显示的文本 | -| `logo_path` | 上传 Logo 的公开路径,通常为 `/api/brand/logo` | -| `favicon_path` | 上传 Favicon 的公开路径,通常为 `/api/brand/favicon` | - -公开端点: - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/settings/brand` | 无需认证读取品牌配置 | -| GET | `/api/brand/logo` | 返回上传的 Logo | -| GET | `/api/brand/favicon` | 返回上传的 Favicon | - -管理员端点: - -| 方法 | 路径 | 说明 | -|------|------|------| -| PUT | `/api/settings/brand` | 以 JSON 更新 `site_title`、`footer_text`、`logo_path` 和 `favicon_path` | -| POST | `/api/settings/brand/logo` | 通过 multipart 字段 `file` 上传 Logo | -| POST | `/api/settings/brand/favicon` | 通过 multipart 字段 `file` 上传 Favicon | - -Logo 和 Favicon 仅支持 PNG 或 ICO 文件,大小上限为 512 KB。上传新资源会替换同类型旧资源。 - - -`PUT /api/settings/brand` 期望 JSON。图片文件需要通过独立的 logo/favicon 上传端点提交,而不是通过 JSON 更新端点提交。 - - -## 禁用自定义主题 - -设置: - -```toml -[feature] -custom_themes = false -``` - -也可以设置 `SERVERBEE_FEATURE__CUSTOM_THEMES=false`。 - -禁用后,自定义主题写入接口会拒绝请求,任何激活的 `custom:*` 引用会在读取时解析为默认预设。已保存的主题数据会保留。 diff --git a/apps/docs/content/docs/cn/custom-widgets.mdx b/apps/docs/content/docs/cn/custom-widgets.mdx new file mode 100644 index 000000000..d89293928 --- /dev/null +++ b/apps/docs/content/docs/cn/custom-widgets.mdx @@ -0,0 +1,294 @@ +--- +title: 自定义组件 +description: 用 React + Zod 编写自定义仪表盘组件,通过单文件或 zip 集合包安装到 ServerBee。 +icon: Puzzle +--- + +ServerBee 的仪表盘原生支持自定义组件(Widget Module)。每个组件就是一个独立的 ES 模块,由管理员安装后即可像内置组件一样拖拽到任意仪表盘上。本指南介绍两种安装方式: + +- **方式 B** — 单个 `.js` 文件(一个组件 = 一个文件) +- **方式 C** — `.zip` 集合包(一次安装多个组件,可共享同一份发布产物) + +## 概念 + +一个 Widget Module 包含三部分: + +1. **静态 JSDoc 清单** — 文件顶部的 `@serverbee-widget {...}` JSON 块,描述组件的 `id`、`version`、`name`、`category`、默认尺寸、所需 SDK 版本等。清单必须**可静态解析**:服务端不会执行模块代码就能完成索引与权限校验,离线扫描也能识别。 +2. **默认导出** — 调用 `defineWidget({ configSchema, component, actions? })` 返回的对象。`configSchema` 是一个 Zod schema,用来描述组件可配置项;`component` 是一个 React 组件,运行时收到 `{ config, size, isEditing, actions }` props。 +3. **运行时依赖** — 通过 `import` 引用 `react`、`react/jsx-runtime`、`@serverbee/widget-sdk`。这些都是**宿主提供**的依赖,打包时必须声明为 `external`,浏览器加载时会重用主应用已有的实例。 + +### 信任模型 + +自定义组件**仅管理员可安装**,且运行在**同源**的浏览器环境中,与登录用户共享同一份会话。这意味着: + +- 一个被批准的组件可以发出任何当前用户有权访问的 API 请求; +- 组件之间共享同一个全局 React 上下文,不要在组件里塞入会破坏其它组件的全局副作用; +- 平台不沙箱执行组件——所以**只安装你信任的代码**。 + +## 方式 B — 单文件 `.widget.js` + +### 文件结构 + +```js +/** + * @serverbee-widget { + * "id": "com.example.hello", + * "version": "1.0.0", + * "name": "Hello", + * "description": "最小可行示例。", + * "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('问候语').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 ( +
+
{greeting}
+
+ {online} / {servers.length} 在线 · {theme.mode} 模式 +
+
+ ) + } +}) +``` + +### 清单字段 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `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 ( +
+
{greeting}
+
+ {online} / {servers.length} online · {theme.mode} mode +
+
+ ) + } +}) +``` + +### 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 + +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 + +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 + +```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 + +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 + +``` +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 + +```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 + +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 + +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 + +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.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 + +`@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 + +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 + +- **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. + + + + + + diff --git a/apps/docs/content/docs/en/index.mdx b/apps/docs/content/docs/en/index.mdx index 342281cea..aff153794 100644 --- a/apps/docs/content/docs/en/index.mdx +++ b/apps/docs/content/docs/en/index.mdx @@ -57,7 +57,7 @@ All communication between agents and the server happens over WebSocket with JSON - + diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 325c16c5a..f82f07cf3 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -21,8 +21,7 @@ "ip-quality", "capabilities", "status-page", - "custom-themes", - "custom-frontend", + "custom-widgets", "mobile", "---Administration---", "security", diff --git a/apps/docs/content/docs/en/status-page.mdx b/apps/docs/content/docs/en/status-page.mdx index 0baa1bc86..589f811d7 100644 --- a/apps/docs/content/docs/en/status-page.mdx +++ b/apps/docs/content/docs/en/status-page.mdx @@ -1,6 +1,6 @@ --- title: Status Pages -description: Publish public server health pages with incidents, maintenance windows, uptime history, and custom themes. +description: Publish public server health pages with incidents, maintenance windows, and uptime history. icon: Globe --- @@ -48,7 +48,6 @@ Create and manage pages in **Settings → Status Pages**. Each page has its own | Enabled | `enabled` | Disabled pages return 404 | | Yellow uptime threshold | `uptime_yellow_threshold` | Days below this percentage show as degraded | | Red uptime threshold | `uptime_red_threshold` | Days below this percentage show as major outage | -| Theme | `theme_ref` | Preset/custom theme reference, or `null` to follow admin default | The current API request fields use `server_ids_json` and `status_page_ids_json` for selected IDs. These fields accept JSON arrays in request bodies. @@ -63,7 +62,6 @@ GET /api/status/{slug} The response includes: - `page` -- page metadata and display options -- `theme` -- resolved theme variables - `servers` -- selected server statuses, uptime percentages, and 90-day daily uptime data - `active_incidents` -- unresolved incidents linked to the page - `planned_maintenances` -- active/upcoming maintenance windows linked to the page @@ -158,10 +156,8 @@ Create example: } ``` -To set a custom theme during update, pass `theme_ref`, for example `"preset:default"` or a custom theme reference. Use `null` to follow the admin default. - - + diff --git a/apps/web/index.html b/apps/web/index.html index 98d5657dd..cf791b063 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,16 @@
+ diff --git a/apps/web/package.json b/apps/web/package.json index de7f205ae..8de6bc961 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@serverbee/web", "private": true, - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "type": "module", "scripts": { "dev": "vite", @@ -17,6 +17,7 @@ "@base-ui/react": "^1.2.0", "@fontsource-variable/inter": "^5.2.8", "@monaco-editor/react": "^4.7.0", + "@serverbee/widget-sdk": "workspace:*", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.166.7", @@ -62,6 +63,7 @@ "globals": "^16.5.0", "jsdom": "^28.1.0", "openapi-typescript": "^7.13.0", + "tinyglobby": "^0.2.16", "typescript": "~5.9.3", "vite": "^7.2.4", "vite-plugin-pwa": "^1.2.0", diff --git a/apps/web/public/runtime/react-dom.js b/apps/web/public/runtime/react-dom.js new file mode 100644 index 000000000..0c4d882f1 --- /dev/null +++ b/apps/web/public/runtime/react-dom.js @@ -0,0 +1,7 @@ +const rd = globalThis.__SERVERBEE_REACT_DOM__ +if (!rd) { + throw new Error('react-dom shim: host did not mount __SERVERBEE_REACT_DOM__') +} +export default rd +export const createPortal = rd.createPortal +export const flushSync = rd.flushSync diff --git a/apps/web/public/runtime/react-jsx-runtime.js b/apps/web/public/runtime/react-jsx-runtime.js new file mode 100644 index 000000000..71cfdeff5 --- /dev/null +++ b/apps/web/public/runtime/react-jsx-runtime.js @@ -0,0 +1,7 @@ +const j = globalThis.__SERVERBEE_JSX_RUNTIME__ +if (!j) { + throw new Error('jsx-runtime shim: host did not mount __SERVERBEE_JSX_RUNTIME__') +} +export const jsx = j.jsx +export const jsxs = j.jsxs +export const Fragment = j.Fragment diff --git a/apps/web/public/runtime/react.js b/apps/web/public/runtime/react.js new file mode 100644 index 000000000..5f682c39d --- /dev/null +++ b/apps/web/public/runtime/react.js @@ -0,0 +1,22 @@ +const r = globalThis.__SERVERBEE_REACT__ +if (!r) { + throw new Error('react shim: host did not mount __SERVERBEE_REACT__') +} +export default r +export const useState = r.useState +export const useEffect = r.useEffect +export const useMemo = r.useMemo +export const useCallback = r.useCallback +export const useRef = r.useRef +export const useContext = r.useContext +export const useReducer = r.useReducer +export const useLayoutEffect = r.useLayoutEffect +export const createContext = r.createContext +export const Fragment = r.Fragment +export const memo = r.memo +export const forwardRef = r.forwardRef +export const Component = r.Component +export const Children = r.Children +export const cloneElement = r.cloneElement +export const createElement = r.createElement +export const isValidElement = r.isValidElement diff --git a/apps/web/public/runtime/widget-sdk.js b/apps/web/public/runtime/widget-sdk.js new file mode 100644 index 000000000..3c8175510 --- /dev/null +++ b/apps/web/public/runtime/widget-sdk.js @@ -0,0 +1,23 @@ +const ns = globalThis.__SERVERBEE_SDK__ +if (!ns) { + throw new Error('widget-sdk shim: host did not mount __SERVERBEE_SDK__') +} +export const defineWidget = ns.defineWidget +export const z = ns.z +export const createActionsHelper = ns.createActionsHelper +export const renderConfigForm = ns.renderConfigForm +export const useServers = ns.useServers +export const useServer = ns.useServer +export const useMetric = ns.useMetric +export const useCapability = ns.useCapability +export const useApiQuery = ns.useApiQuery +export const useApiMutation = ns.useApiMutation +export const useAlerts = ns.useAlerts +export const useServiceMonitors = ns.useServiceMonitors +export const useTraffic = ns.useTraffic +export const useUptime = ns.useUptime +export const useHistory = ns.useHistory +export const useGeoIp = ns.useGeoIp +export const useTheme = ns.useTheme +export const useConfigUpdate = ns.useConfigUpdate +export const SDK_VERSION = ns.SDK_VERSION diff --git a/apps/web/src/api/spa-themes.ts b/apps/web/src/api/spa-themes.ts deleted file mode 100644 index dc11e235b..000000000 --- a/apps/web/src/api/spa-themes.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { api } from '@/lib/api-client' - -export interface SpaThemeSummary { - author?: string | null - description?: string | null - has_preview: boolean - is_active: boolean - is_superseded: boolean - manifest_id: string - name: string - size_bytes: number - uploaded_at: string - uploaded_by: string - uuid: string - version: string -} - -export interface UploadResult { - is_upgrade_of: { previous_uuid: string; previous_version: string } | null - manifest: Record - preview_url: string | null - size_bytes: number - uuid: string -} - -/** - * Structured error envelope returned by the SPA theme endpoints. - * - * The server responds with `{ error: { code, message, details } }` for known failure modes; - * we surface those fields so callers can render `t(\`errors.${code}\`, details)`. - */ -export interface ApiError extends Error { - code?: string - details?: Record -} - -interface ErrorEnvelope { - error?: { code?: string; message?: string; details?: Record } -} - -async function parseErrorBody(res: Response): Promise { - const text = await res.text().catch(() => '') - let parsed: unknown = null - try { - parsed = text ? JSON.parse(text) : null - } catch { - parsed = null - } - const envelope = (parsed as ErrorEnvelope | null)?.error - const err = new Error(envelope?.message ?? text ?? res.statusText) as ApiError - err.code = envelope?.code - err.details = envelope?.details - return err -} - -export function useSpaThemes() { - return useQuery({ - queryKey: ['spa-themes'], - queryFn: () => api.get('/api/settings/spa-themes') - }) -} - -export function useActiveSpaTheme() { - return useQuery<{ theme_id: string | null }>({ - queryKey: ['active-spa-theme'], - queryFn: () => api.get('/api/settings/active-spa-theme'), - staleTime: 30_000 - }) -} - -export function useActivateSpaTheme() { - const qc = useQueryClient() - return useMutation({ - mutationFn: (themeId: string | null) => - api.put<{ theme_id: string | null }>('/api/settings/active-spa-theme', { theme_id: themeId }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['active-spa-theme'] }) - qc.invalidateQueries({ queryKey: ['spa-themes'] }) - } - }) -} - -export function useDeleteSpaTheme() { - const qc = useQueryClient() - return useMutation({ - mutationFn: async (uuid: string) => { - const res = await fetch(`/api/settings/spa-themes/${uuid}`, { method: 'DELETE', credentials: 'include' }) - if (!res.ok) { - throw await parseErrorBody(res) - } - }, - onSuccess: () => qc.invalidateQueries({ queryKey: ['spa-themes'] }) - }) -} - -export function useUploadSpaTheme() { - const qc = useQueryClient() - return useMutation({ - mutationFn: async (file: File) => { - const fd = new FormData() - fd.append('package', file) - const res = await fetch('/api/settings/spa-themes', { - method: 'POST', - credentials: 'include', - body: fd - }) - if (!res.ok) { - throw await parseErrorBody(res) - } - const text = await res.text() - const parsed = text ? (JSON.parse(text) as { data: UploadResult }) : null - if (!parsed) { - throw new Error('Upload succeeded but response body was empty') - } - return parsed.data - }, - onSuccess: () => qc.invalidateQueries({ queryKey: ['spa-themes'] }) - }) -} diff --git a/apps/web/src/api/themes.ts b/apps/web/src/api/themes.ts deleted file mode 100644 index dfe886405..000000000 --- a/apps/web/src/api/themes.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { api } from '@/lib/api-client' -import type { components } from '@/lib/api-types' - -export type ThemeResolved = components['schemas']['ThemeResolved'] -export type ActiveThemeResponse = components['schemas']['ActiveThemeResponse'] -export type CreateThemeInput = components['schemas']['CreateThemeInput'] -export type ExportPayload = components['schemas']['ExportPayload'] -export type FullTheme = components['schemas']['Theme'] -export type ThemeReferences = components['schemas']['ThemeReferences'] -export type ThemeSummary = components['schemas']['ThemeSummary'] -export type UpdateThemeInput = components['schemas']['UpdateThemeInput'] - -export function useActiveTheme() { - return useQuery({ - queryKey: ['active-theme'], - queryFn: () => api.get('/api/settings/active-theme'), - staleTime: 30_000 - }) -} - -export function useSetActiveTheme() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (ref: string) => api.put('/api/settings/active-theme', { ref }), - onSuccess: (data) => { - queryClient.setQueryData(['active-theme'], data) - queryClient.invalidateQueries({ queryKey: ['active-theme'] }).catch(() => undefined) - } - }) -} - -export function useCustomThemes() { - return useQuery({ - queryKey: ['themes'], - queryFn: () => api.get('/api/settings/themes') - }) -} - -export function useThemeQuery(id: number) { - return useQuery({ - queryKey: ['themes', id], - queryFn: () => api.get(`/api/settings/themes/${id}`), - enabled: Number.isInteger(id) && id > 0 - }) -} - -export function useCreateTheme() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (input: CreateThemeInput) => api.post('/api/settings/themes', input), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['themes'] }).catch(() => undefined) - } - }) -} - -export function useUpdateTheme() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: ({ id, body }: { body: UpdateThemeInput; id: number }) => - api.put(`/api/settings/themes/${id}`, body), - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['themes'] }).catch(() => undefined) - queryClient.invalidateQueries({ queryKey: ['themes', variables.id] }).catch(() => undefined) - queryClient.invalidateQueries({ queryKey: ['active-theme'] }).catch(() => undefined) - } - }) -} - -export function useThemeReferences(id: number) { - return useQuery({ - queryKey: ['themes', id, 'references'], - queryFn: () => api.get(`/api/settings/themes/${id}/references`), - enabled: Number.isInteger(id) && id > 0 - }) -} - -export function useDeleteTheme() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (id: number) => api.delete(`/api/settings/themes/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['themes'] }).catch(() => undefined) - queryClient.invalidateQueries({ queryKey: ['active-theme'] }).catch(() => undefined) - } - }) -} - -export function useDuplicateTheme() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (id: number) => api.post(`/api/settings/themes/${id}/duplicate`, {}), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['themes'] }).catch(() => undefined) - } - }) -} - -export function useExportTheme(id: number) { - return useQuery({ - queryKey: ['themes', id, 'export'], - queryFn: () => api.get(`/api/settings/themes/${id}/export`), - enabled: false - }) -} - -export function useImportTheme() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (payload: ExportPayload) => api.post('/api/settings/themes/import', payload), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['themes'] }).catch(() => undefined) - } - }) -} diff --git a/apps/web/src/api/widget-modules.ts b/apps/web/src/api/widget-modules.ts new file mode 100644 index 000000000..b881d38a5 --- /dev/null +++ b/apps/web/src/api/widget-modules.ts @@ -0,0 +1,113 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/api-client' + +export interface ModuleSummary { + code_sha256: string + enabled: boolean + entry_path: string + id: string + manifest: Record + source_type: string + version: string +} + +export interface ModuleInstallEntry { + id: string + version: string +} + +export type ModuleInstallResult = + | { kind: 'single'; id: string; version: string } + | { kind: 'collection'; widgets: ModuleInstallEntry[] } + +function asInstallResult(data: unknown): ModuleInstallResult { + if (Array.isArray(data)) { + return { + kind: 'collection', + widgets: data as ModuleInstallEntry[] + } + } + const entry = data as ModuleInstallEntry + return { kind: 'single', id: entry.id, version: entry.version } +} + +const LIST_KEY = ['widget-modules'] as const + +export function useWidgetModules() { + return useQuery({ + queryKey: LIST_KEY, + queryFn: () => api.get('/api/widget-modules') + }) +} + +async function readError(res: Response): Promise { + const text = await res.text().catch(() => '') + if (text) { + try { + const parsed = JSON.parse(text) + if (parsed && typeof parsed === 'object' && 'error' in parsed) { + const err = (parsed as { error?: { message?: string } }).error + if (err?.message) { + return err.message + } + } + } catch { + // not JSON; fall through and use raw text + } + return text + } + return `request failed: ${res.status}` +} + +export function useInstallFromUrl() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (url) => { + const res = await fetch(`/api/widget-modules?url=${encodeURIComponent(url)}`, { + method: 'POST', + credentials: 'include' + }) + if (!res.ok) { + throw new Error(await readError(res)) + } + const json = await res.json() + return asInstallResult(json.data) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: LIST_KEY }).catch(() => undefined) + } + }) +} + +export function useInstallFromFile() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (file) => { + const fd = new FormData() + fd.append('file', file) + const res = await fetch('/api/widget-modules', { + method: 'POST', + credentials: 'include', + body: fd + }) + if (!res.ok) { + throw new Error(await readError(res)) + } + const json = await res.json() + return asInstallResult(json.data) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: LIST_KEY }).catch(() => undefined) + } + }) +} + +export function useUninstallWidgetModule() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id) => api.delete(`/api/widget-modules/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: LIST_KEY }).catch(() => undefined) + } + }) +} diff --git a/apps/web/src/builtin-widgets/hello-world.widget.tsx b/apps/web/src/builtin-widgets/hello-world.widget.tsx new file mode 100644 index 000000000..168d2e32e --- /dev/null +++ b/apps/web/src/builtin-widgets/hello-world.widget.tsx @@ -0,0 +1,35 @@ +/** + * @serverbee-widget { + * "id": "com.serverbee.hello-world", + * "version": "1.0.0", + * "name": "Hello World", + * "description": "A minimal builtin widget that displays the SPA theme mode and the count of online servers.", + * "author": "ServerBee", + * "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 as { greeting: string } + return ( +
+
{greeting}
+
+ {online} / {servers.length} online · {theme.mode} mode +
+
+ ) + } +}) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index f168681ef..32d4bf1fb 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -15,6 +15,7 @@ import { LogOut, Monitor, Palette, + Puzzle, Radar, Settings, Shield, @@ -74,6 +75,7 @@ const settingsItems = [ { to: '/settings/api-keys', labelKey: 'nav_api_keys', icon: Key }, { to: '/settings/security', labelKey: 'nav_security', icon: Shield }, { to: '/settings/appearance', labelKey: 'nav_appearance', icon: Palette }, + { to: '/settings/widgets', labelKey: 'nav_widgets', icon: Puzzle, adminOnly: true }, { to: '/settings/audit-logs', labelKey: 'nav_audit_logs', icon: ClipboardList, adminOnly: true }, { to: '/settings/rate-limits', labelKey: 'nav_rate_limits', icon: Gauge, adminOnly: true }, { to: '/settings', labelKey: 'nav_settings', icon: Settings, adminOnly: true } diff --git a/apps/web/src/components/dashboard/dashboard-editor-view.test.tsx b/apps/web/src/components/dashboard/dashboard-editor-view.test.tsx index acb01e546..b796192c3 100644 --- a/apps/web/src/components/dashboard/dashboard-editor-view.test.tsx +++ b/apps/web/src/components/dashboard/dashboard-editor-view.test.tsx @@ -65,11 +65,11 @@ vi.mock('./widget-picker', () => ({ open }: { onOpenChange: (open: boolean) => void - onSelect: (widgetType: string) => void + onSelect: (selection: { type: 'builtin'; widgetType: string }) => void open: boolean }) => open ? ( - ) : null @@ -195,6 +195,7 @@ describe('DashboardEditorView', () => { { id: 'w-1', widget_type: 'stat-number', + module_id: null, title: 'CPU', config_json: { metric: 'avg_cpu' }, grid_x: 4, @@ -260,6 +261,7 @@ describe('DashboardEditorView', () => { { id: 'w-1', widget_type: 'stat-number', + module_id: null, title: 'CPU', config_json: { metric: 'avg_cpu' }, grid_x: 0, @@ -332,6 +334,7 @@ describe('DashboardEditorView', () => { { id: 'w-1', widget_type: 'stat-number', + module_id: null, title: 'CPU updated', config_json: { metric: 'avg_mem' }, grid_x: 0, diff --git a/apps/web/src/components/dashboard/dashboard-editor-view.tsx b/apps/web/src/components/dashboard/dashboard-editor-view.tsx index 9aeda131d..af5cb2100 100644 --- a/apps/web/src/components/dashboard/dashboard-editor-view.tsx +++ b/apps/web/src/components/dashboard/dashboard-editor-view.tsx @@ -10,7 +10,7 @@ import type { Dashboard, DashboardWithWidgets } from '@/lib/widget-types' import { DashboardGrid } from './dashboard-grid' import { DashboardSwitcher } from './dashboard-switcher' import { WidgetConfigDialog } from './widget-config-dialog' -import { WidgetPicker } from './widget-picker' +import { WidgetPicker, type WidgetPickerSelection } from './widget-picker' interface DashboardEditorViewProps { activeDashboardId: string @@ -100,10 +100,28 @@ export function DashboardEditorView({ handleCancel() } - function handlePickerSelect(widgetType: string) { + function handlePickerSelect(selection: WidgetPickerSelection) { setPickerOpen(false) setEditingWidgetId(null) - setConfigWidgetType(widgetType) + if (selection.type === 'module') { + // Modules currently use their own configSchema; we add directly with an empty + // config object instead of opening the legacy config dialog (which is built + // around hard-coded form variants per builtin widget type). + if (isDashboardReady && dashboard) { + const sizing = selection.manifest.sizing + editor.addWidget({ + dashboardId: dashboard.id, + widgetType: 'module', + moduleId: selection.moduleId, + title: selection.manifest.name, + configJson: '{}', + gridW: sizing.defaultW ?? 4, + gridH: sizing.defaultH ?? 3 + }) + } + return + } + setConfigWidgetType(selection.widgetType) setConfigOpen(true) } diff --git a/apps/web/src/components/dashboard/module-widget-host.tsx b/apps/web/src/components/dashboard/module-widget-host.tsx new file mode 100644 index 000000000..bf5a03563 --- /dev/null +++ b/apps/web/src/components/dashboard/module-widget-host.tsx @@ -0,0 +1,70 @@ +import type { ActionsHelper } from '@serverbee/widget-sdk' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { parseConfig } from '@/lib/widget-helpers' +import type { DashboardWidget } from '@/lib/widget-types' +import { registryActions } from '@/widgets-runtime/registry' + +interface ModuleWidgetHostProps { + servers: ServerMetrics[] + widget: DashboardWidget +} + +const NOOP_ACTIONS: ActionsHelper = { + render: () => null +} + +function Placeholder({ message }: { message: string }) { + return ( +
+ {message} +
+ ) +} + +export function ModuleWidgetHost({ widget, servers: _servers }: ModuleWidgetHostProps) { + const { t } = useTranslation('dashboard') + const moduleId = widget.module_id ?? '' + const entry = useMemo(() => (moduleId ? registryActions.get(moduleId) : undefined), [moduleId]) + + const parsed = useMemo(() => { + if (!entry) { + return { ok: false as const, error: '' } + } + const raw = parseConfig(widget.config_json) + try { + const config = entry.module.configSchema.parse(raw) + return { ok: true as const, config } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false as const, error: message } + } + }, [entry, widget.config_json]) + + if (!moduleId) { + return + } + if (!entry) { + return ( + + ) + } + if (!parsed.ok) { + return ( + + ) + } + + const Component = entry.module.component + return ( + + ) +} diff --git a/apps/web/src/components/dashboard/widget-config-dialog.test.tsx b/apps/web/src/components/dashboard/widget-config-dialog.test.tsx index 0a94f2007..da4dc86e1 100644 --- a/apps/web/src/components/dashboard/widget-config-dialog.test.tsx +++ b/apps/web/src/components/dashboard/widget-config-dialog.test.tsx @@ -1,6 +1,8 @@ -import { render, screen } from '@testing-library/react' +import { defineWidget, type WidgetManifest, z } from '@serverbee/widget-sdk' +import { fireEvent, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { registryActions } from '@/widgets-runtime/registry' import { WidgetConfigDialog } from './widget-config-dialog' const translations: Record = { @@ -26,7 +28,12 @@ const translations: Record = { 'common.timeRange.24hours': '24 hours', 'common.timeRange.30days': '30 days', 'common.timeRange.60days': '60 days', - 'common.timeRange.90days': '90 days' + 'common.timeRange.90days': '90 days', + module_not_installed: 'Widget module not installed', + module_not_installed_id: 'Widget module "{{id}}" not installed', + module_config_no_fields: 'This module exposes no configurable fields.', + save: 'Save', + add_widget: 'Add' } vi.mock('react-i18next', () => ({ @@ -126,6 +133,7 @@ const mockServers = [ ] const noop = vi.fn() +const NOT_INSTALLED_RE = /not installed/i describe('WidgetConfigDialog', () => { it('renders metric select for stat-number widget', () => { @@ -282,4 +290,133 @@ describe('WidgetConfigDialog', () => { expect(screen.getByText('60 days')).toBeInTheDocument() expect(screen.getByText('90 days')).toBeInTheDocument() }) + + describe('module widgets', () => { + const moduleId = 'com.test.cfg-dialog' + const fakeManifest: WidgetManifest = { + id: moduleId, + version: '1.0.0', + name: 'Test', + category: 'Real-time', + sizing: { defaultW: 2, defaultH: 2, minW: 1, minH: 1, strategy: 'free' }, + sdkVersion: '^0.1.0' + } + + afterEach(() => { + registryActions.unregister(moduleId) + }) + + it('renders the SDK config form fields for a registered module widget', () => { + const module = defineWidget({ + configSchema: z.object({ + label: z.string().describe('Label') + }), + component: () =>
+ }) + registryActions.register(moduleId, module, fakeManifest) + + const onSubmit = vi.fn() + render( + + ) + + // The renderer surfaces the field label + expect(screen.getByText('Label')).toBeInTheDocument() + // Existing value is loaded into the input + const labelInput = screen.getByDisplayValue('hi') as HTMLInputElement + expect(labelInput).toBeInTheDocument() + + // Type a new value and save + fireEvent.change(labelInput, { target: { value: 'world' } }) + fireEvent.click(screen.getByText('Save')) + expect(onSubmit).toHaveBeenCalledTimes(1) + const [, configJson] = onSubmit.mock.calls[0] + expect(JSON.parse(configJson)).toMatchObject({ label: 'world' }) + }) + + it('shows a placeholder when the module is not installed and disables save', () => { + const onSubmit = vi.fn() + render( + + ) + + expect(screen.getByText(NOT_INSTALLED_RE)).toBeInTheDocument() + const saveButton = screen.getByText('Save') as HTMLButtonElement + expect(saveButton).toBeDisabled() + }) + + it('shows a "no configurable fields" message for modules with empty configSchema', () => { + const module = defineWidget({ + configSchema: z.object({}), + component: () =>
+ }) + registryActions.register(moduleId, module, fakeManifest) + + render( + + ) + + expect(screen.getByText('This module exposes no configurable fields.')).toBeInTheDocument() + }) + }) }) diff --git a/apps/web/src/components/dashboard/widget-config-dialog.tsx b/apps/web/src/components/dashboard/widget-config-dialog.tsx index bc436f578..0123113ad 100644 --- a/apps/web/src/components/dashboard/widget-config-dialog.tsx +++ b/apps/web/src/components/dashboard/widget-config-dialog.tsx @@ -1,3 +1,4 @@ +import { renderConfigForm } from '@serverbee/widget-sdk' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -26,6 +27,7 @@ import type { UptimeTimelineConfig, WidgetConfig } from '@/lib/widget-types' +import { type RegistryEntry, registryActions } from '@/widgets-runtime/registry' interface WidgetConfigDialogProps { onOpenChange: (open: boolean) => void @@ -644,6 +646,25 @@ function useUptimeDaysOptions(t: (key: string) => string): { label: string; valu ] } +function ModuleForm({ + entry, + config, + onChange, + t +}: { + config: Record + entry: RegistryEntry + onChange: (c: Record) => void + t: (key: string) => string +}) { + const schema = entry.module.configSchema + const info = useMemo(() => schema.introspect(), [schema]) + if (info.kind !== 'object' || !info.shape || Object.keys(info.shape).length === 0) { + return

{t('module_config_no_fields')}

+ } + return
{renderConfigForm(schema, config, onChange)}
+} + function UptimeTimelineForm({ config, servers, @@ -688,6 +709,7 @@ function UptimeTimelineForm({ ) } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: dispatcher renders one form per widget_type; refactoring to a table is more work than the value export function WidgetConfigDialog({ open, onOpenChange, @@ -709,6 +731,13 @@ export function WidgetConfigDialog({ }, [widget]) const needsNoConfig = widgetType === 'service-status' || widgetType === 'server-map' + const isModule = widgetType === 'module' + const moduleId = widget?.module_id ?? null + const moduleEntry = useMemo( + () => (isModule && moduleId ? registryActions.get(moduleId) : undefined), + [isModule, moduleId] + ) + const moduleMissing = isModule && !moduleEntry const handleSubmit = () => { // Seed per-widget defaults that the form only shows but doesn't write, @@ -784,12 +813,20 @@ export function WidgetConfigDialog({ t={t} /> )} + {isModule && moduleEntry && } + {moduleMissing && ( +

+ {moduleId ? t('module_not_installed_id').replace('{{id}}', moduleId) : t('module_not_installed')} +

+ )} {needsNoConfig && (

{t('dialogs.widgetConfig.messages.noConfigNeeded')}

)}
- + diff --git a/apps/web/src/components/dashboard/widget-picker.tsx b/apps/web/src/components/dashboard/widget-picker.tsx index 89ba775ef..457630e1a 100644 --- a/apps/web/src/components/dashboard/widget-picker.tsx +++ b/apps/web/src/components/dashboard/widget-picker.tsx @@ -1,3 +1,4 @@ +import type { WidgetManifest } from '@serverbee/widget-sdk' import { Activity, BarChart3, @@ -10,6 +11,7 @@ import { LineChart, List, Network, + Puzzle, Server, TrendingUp } from 'lucide-react' @@ -18,10 +20,15 @@ import { useTranslation } from 'react-i18next' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' import { WIDGET_TYPES, type WidgetCategory } from '@/lib/widget-types' +import { useWidgetRegistry } from '@/widgets-runtime/registry' + +export type WidgetPickerSelection = + | { type: 'builtin'; widgetType: string } + | { type: 'module'; moduleId: string; manifest: WidgetManifest } interface WidgetPickerProps { onOpenChange: (open: boolean) => void - onSelect: (widgetType: string) => void + onSelect: (selection: WidgetPickerSelection) => void open: boolean } @@ -46,6 +53,7 @@ const CATEGORY_ORDER: WidgetCategory[] = ['Real-time', 'Charts', 'Status'] export function WidgetPicker({ onSelect, open, onOpenChange }: WidgetPickerProps) { const { t } = useTranslation('dashboard') + const moduleEntries = useWidgetRegistry((s) => s.modules) const grouped = useMemo(() => { const map = new Map() @@ -58,6 +66,8 @@ export function WidgetPicker({ onSelect, open, onOpenChange }: WidgetPickerProps return map }, []) + const modules = useMemo(() => Array.from(moduleEntries.values()), [moduleEntries]) + return ( @@ -86,7 +96,7 @@ export function WidgetPicker({ onSelect, open, onOpenChange }: WidgetPickerProps className="flex items-start gap-3 rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50" key={widgetType.id} onClick={() => { - onSelect(widgetType.id) + onSelect({ type: 'builtin', widgetType: widgetType.id }) onOpenChange(false) }} type="button" @@ -105,6 +115,49 @@ export function WidgetPicker({ onSelect, open, onOpenChange }: WidgetPickerProps
) })} + +
+

+ {t('picker_custom_widgets', 'Custom Widgets')} +

+ {modules.length === 0 ? ( +

+ {t('picker_no_custom_widgets', 'No custom widgets installed yet.')} +

+ ) : ( +
+ {modules.map((entry) => { + const manifest = entry.manifest + return ( + + ) + })} +
+ )} +
diff --git a/apps/web/src/components/dashboard/widget-renderer.test.tsx b/apps/web/src/components/dashboard/widget-renderer.test.tsx index 3321f1978..6f05024cd 100644 --- a/apps/web/src/components/dashboard/widget-renderer.test.tsx +++ b/apps/web/src/components/dashboard/widget-renderer.test.tsx @@ -1,7 +1,9 @@ +import type { WidgetManifest, WidgetModule } from '@serverbee/widget-sdk' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' import type { DashboardWidget } from '@/lib/widget-types' +import { registryActions, useWidgetRegistry } from '@/widgets-runtime/registry' import { WidgetRenderer } from './widget-renderer' const { renderCounts } = vi.hoisted(() => ({ @@ -62,7 +64,11 @@ vi.mock('./widgets/uptime-timeline-widget', () => ({ UptimeTimelineWidget: () =>
uptime-timeline
})) -function makeWidget(widgetType: string, config: Record = {}): DashboardWidget { +function makeWidget( + widgetType: string, + config: Record = {}, + extra: Partial = {} +): DashboardWidget { return { id: 'w-1', dashboard_id: 'dash-1', @@ -74,7 +80,8 @@ function makeWidget(widgetType: string, config: Record = {}): D grid_w: 4, grid_h: 3, sort_order: 0, - created_at: '2026-03-20T00:00:00Z' + created_at: '2026-03-20T00:00:00Z', + ...extra } } @@ -131,6 +138,8 @@ const WIDGET_TYPES = [ 'uptime-timeline' ] as const +const NOT_INSTALLED_RE = /not installed/i + describe('WidgetRenderer', () => { beforeEach(() => { renderCounts.gauge = 0 @@ -202,4 +211,43 @@ describe('WidgetRenderer', () => { expect(renderCounts.gauge).toBe(2) }) + + describe('module widgets', () => { + beforeEach(() => { + useWidgetRegistry.setState({ modules: new Map(), failures: new Map() }) + }) + + const fakeManifest: WidgetManifest = { + id: 'com.test.fake', + version: '1.0.0', + name: 'Fake', + category: 'Real-time', + sizing: { defaultW: 2, defaultH: 2, minW: 1, minH: 1, strategy: 'free' }, + sdkVersion: '^0.1.0' + } + + function makeFakeModule(component: WidgetModule['component']): WidgetModule { + return { + __brand: 'WidgetModule', + configSchema: { parse: (v: unknown) => v } as unknown as WidgetModule['configSchema'], + component, + actions: [] + } + } + + it('renders the registered module component', () => { + registryActions.register( + 'com.test.fake', + makeFakeModule(() =>
hello from module
), + fakeManifest + ) + render() + expect(screen.getByTestId('fake-module')).toBeInTheDocument() + }) + + it('shows a placeholder when the referenced module is not installed', () => { + render() + expect(screen.getByText(NOT_INSTALLED_RE)).toBeInTheDocument() + }) + }) }) diff --git a/apps/web/src/components/dashboard/widget-renderer.tsx b/apps/web/src/components/dashboard/widget-renderer.tsx index 087c91922..7909f0849 100644 --- a/apps/web/src/components/dashboard/widget-renderer.tsx +++ b/apps/web/src/components/dashboard/widget-renderer.tsx @@ -18,6 +18,7 @@ import type { TrafficBarConfig, UptimeTimelineConfig } from '@/lib/widget-types' +import { ModuleWidgetHost } from './module-widget-host' import { areWidgetServerDependenciesEqual } from './widget-render-dependencies' import { AlertListWidget } from './widgets/alert-list' import { DiskIoWidget } from './widgets/disk-io' @@ -85,6 +86,10 @@ function ErrorFallback() { function WidgetContent({ widget, servers }: WidgetRendererProps) { const config = useMemo(() => parseConfig>(widget.config_json), [widget.config_json]) + if (widget.widget_type === 'module') { + return + } + switch (widget.widget_type) { case 'stat-number': return diff --git a/apps/web/src/components/spa-theme/activate-spa-theme-dialog.test.tsx b/apps/web/src/components/spa-theme/activate-spa-theme-dialog.test.tsx deleted file mode 100644 index a4fa2b600..000000000 --- a/apps/web/src/components/spa-theme/activate-spa-theme-dialog.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import type { SpaThemeSummary } from '@/api/spa-themes' -import '@/lib/i18n' -import { ActivateSpaThemeDialog } from './activate-spa-theme-dialog' - -const ACTIVATE_RE = /^Activate$/ -const CHECKBOX_RE = /I understand/ - -const theme: SpaThemeSummary = { - author: 'Acme Inc.', - description: null, - has_preview: false, - is_active: false, - is_superseded: false, - manifest_id: 'm', - name: 'Acme', - size_bytes: 1, - uploaded_at: '2026-05-26', - uploaded_by: 'u', - uuid: 'u1', - version: '1.0.0' -} - -describe('ActivateSpaThemeDialog', () => { - it('disables confirm until the checkbox is ticked', () => { - const onConfirm = vi.fn() - render() - - const confirmBtn = screen.getByRole('button', { name: ACTIVATE_RE }) as HTMLButtonElement - expect(confirmBtn).toBeDisabled() - - // The Base UI Checkbox renders inside a