From f3dd006a3bde296aba25e0f2d5469e0bf66de66b Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 18:26:31 +0800 Subject: [PATCH 01/43] docs: clarify domain and ip access setup --- apps/docs/content/docs/cn/configuration.mdx | 2 +- apps/docs/content/docs/cn/deployment.mdx | 28 +++++++++++ apps/docs/content/docs/cn/quick-start.mdx | 49 ++++++++++++++++++-- apps/docs/content/docs/en/configuration.mdx | 4 +- apps/docs/content/docs/en/deployment.mdx | 26 ++++++++++- apps/docs/content/docs/en/quick-start.mdx | 51 +++++++++++++++++++-- 6 files changed, 147 insertions(+), 13 deletions(-) diff --git a/apps/docs/content/docs/cn/configuration.mdx b/apps/docs/content/docs/cn/configuration.mdx index e48400d1..33dccfed 100644 --- a/apps/docs/content/docs/cn/configuration.mdx +++ b/apps/docs/content/docs/cn/configuration.mdx @@ -121,7 +121,7 @@ ServerBee 使用 [figment](https://github.com/SergioBenitez/Figment) 库加载 | `SERVERBEE_DATABASE__PATH` | `serverbee.db` | SQLite 数据库文件路径(相对于 data_dir) | | `SERVERBEE_DATABASE__MAX_CONNECTIONS` | `10` | 数据库连接池最大连接数 | | `SERVERBEE_AUTH__SESSION_TTL` | `86400` | Session 有效期(秒),默认 24 小时 | -| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Cookie 的 Secure 标记,开发环境设为 `false` | +| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Cookie 的 Secure 标记。仅当浏览器通过普通 HTTP 访问 ServerBee 时设为 `false`,例如 IP 直连的快速开始安装 | | `SERVERBEE_RATE_LIMIT__LOGIN_MAX` | `5` | 15 分钟内每 IP 最大登录尝试次数 | | `SERVERBEE_RATE_LIMIT__REGISTER_MAX` | `3` | 15 分钟内每 IP 最大 Agent 注册次数 | | `SERVERBEE_UPGRADE__RELEASE_BASE_URL` | `https://github.com/ZingerLittleBee/ServerBee/releases` | Agent 升级 Release 资产的基础 URL | diff --git a/apps/docs/content/docs/cn/deployment.mdx b/apps/docs/content/docs/cn/deployment.mdx index 8d4381b1..e9a95aff 100644 --- a/apps/docs/content/docs/cn/deployment.mdx +++ b/apps/docs/content/docs/cn/deployment.mdx @@ -240,6 +240,30 @@ sudo journalctl -u serverbee-server --since "1 hour ago" ## Nginx 反向代理 +### 访问地址和 Cookie 配置 + +先确定一个对外访问地址,并让浏览器地址、Agent `server_url` 和 Cookie 配置保持一致: + +| 场景 | 对外地址 | `auth.secure_cookie` | Docker 环境变量 | Agent 地址 | +|------|----------|----------------------|-----------------|------------| +| IP 直连,普通 HTTP | `http://203.0.113.10:9527` | `false` | `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://203.0.113.10:9527` | +| 域名 + HTTPS 反向代理 | `https://monitor.example.com` | `true` | `SERVERBEE_AUTH__SECURE_COOKIE=true` 或不设置该变量 | `https://monitor.example.com` | + +使用域名访问时,需要先添加 DNS `A` 或 `AAAA` 记录指向服务器 IP,再用 Nginx、Caddy 或 Traefik 终止 HTTPS,并把请求反向代理到 ServerBee 的 `127.0.0.1:9527`。 + +如果你是通过快速开始脚本安装的 Server,脚本会为了 HTTP 直连写入 `auth.secure_cookie = false`。迁移到 HTTPS 前,请修改 `/etc/serverbee/server.toml`: + +```toml +[auth] +secure_cookie = true +``` + +然后重启 Server: + +```bash +sudo systemctl restart serverbee-server +``` + ### 基础配置 ```nginx title="/etc/nginx/sites-available/serverbee" @@ -402,6 +426,10 @@ server_url = "https://monitor.example.com" ServerBee 自身不处理 TLS 终止。所有 HTTPS/WSS 加密由前置的反向代理(Nginx/Caddy)处理。这种架构简化了 Server 的实现,同时也便于统一管理证书。 + +使用 HTTPS 时,请保持 `auth.secure_cookie = true`。设为 `false` 可能仍然可以登录,但会去掉浏览器的 Secure Cookie 保护,不适合生产环境。 + + ## 备份与恢复 ### 备份 diff --git a/apps/docs/content/docs/cn/quick-start.mdx b/apps/docs/content/docs/cn/quick-start.mdx index 8066ae53..565bb4d5 100644 --- a/apps/docs/content/docs/cn/quick-start.mdx +++ b/apps/docs/content/docs/cn/quick-start.mdx @@ -28,6 +28,8 @@ services: - "9527:9527" volumes: - serverbee-data:/data + environment: + - SERVERBEE_AUTH__SECURE_COOKIE=false restart: unless-stopped volumes: @@ -46,6 +48,23 @@ docker compose up -d 启动完成后,打开浏览器访问 `http://your-server-ip:9527` 即可进入管理面板。 + +上面的 Compose 示例关闭了 Cookie 的 `Secure` 标记,因为快速开始使用的是普通 HTTP。迁移到 HTTPS 后,请设置 `SERVERBEE_AUTH__SECURE_COOKIE=true`。 + + +## 使用 IP 访问还是域名访问 + +ServerBee 可以直接用服务器 IP 访问,也可以通过域名和反向代理访问。需要额外配置的地方取决于浏览器最终使用的是普通 HTTP 还是 HTTPS。 + +| 访问方式 | 浏览器地址 | Server Cookie 配置 | Agent `server_url` | 额外配置 | +|----------|------------|--------------------|--------------------|----------| +| IP 直连,普通 HTTP | `http://your-server-ip:9527` | `auth.secure_cookie = false` 或 `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://your-server-ip:9527` | 防火墙放行 `9527` 端口 | +| 域名 + HTTPS | `https://monitor.example.com` | `auth.secure_cookie = true` 或 `SERVERBEE_AUTH__SECURE_COOKIE=true` | `https://monitor.example.com` | DNS 指向服务器 IP,并在 ServerBee 前面配置 Nginx、Caddy 或 Traefik | + + +如果你先按快速开始使用 HTTP,之后迁移到域名和 HTTPS,请把 `secure_cookie` 改回 `true`,重启 ServerBee,并把已安装 Agent 的 Server 地址更新为新的 `https://` 地址。 + + ## 方式二:安装脚本 适用于 Linux 服务器,一键完成安装、配置和 systemd 服务注册。 @@ -53,7 +72,7 @@ docker compose up -d **安装 Server:** ```bash -curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server -y ``` 安装脚本会自动完成以下操作: @@ -65,6 +84,14 @@ curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/depl 5. 创建 systemd service unit 6. 启动服务并设置开机自启 +首次启动时,Server 会自动创建管理员账号并随机生成密码,该密码只会在服务日志中打印一次。使用以下命令查看: + +```bash +sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS' +``` + +安装脚本会写入 `auth.secure_cookie = false`,保证普通 HTTP 快速开始地址可以在浏览器中正常登录。迁移到 HTTPS 后,请在 `/etc/serverbee/server.toml` 中改回 `true` 并重启 Server。 + **安装 Agent:** 在需要监控的每台服务器上执行: @@ -89,14 +116,21 @@ sudo serverbee uninstall agent # 卸载 Agent sudo serverbee uninstall server --purge # 卸载服务端并清除数据 ``` +验证 Server 是否运行: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-server -n 80 --no-pager +``` + ## 首次登录 1. 打开浏览器访问 `http://your-server-ip:9527` -2. 使用默认账户 `admin` 和你设置的密码登录 -3. 如果未设置密码,查看 Server 启动日志获取自动生成的密码 +2. 使用默认账户 `admin` 和 Server 启动日志中的随机密码登录 +3. 首次登录时按页面提示完成强制密码修改,可同时选择新的用户名 -首次登录后请立即修改默认密码。进入「设置」页面即可修改。 +自动生成的密码只会在日志中显示一次。请在日志轮转前复制,并在暴露到公网前完成首次密码修改。 ## 添加第一台 Agent @@ -121,6 +155,13 @@ curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/depl 脚本会自动检测平台架构、下载对应二进制、写入配置、注册 systemd 服务并启动 Agent。 +验证 Agent 是否已连接并开始上报: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-agent -n 80 --no-pager +``` + 更多采集、日志等可调选项请参考 [Agent 配置](/cn/docs/agent)和[完整配置参考](/cn/docs/configuration)。 首次启动时,Agent 会自动向 Server 注册:注册码在首次注册成功时即被消费,Agent 获取每台服务器专属的 Token 并写回配置文件。后续启动改用持久化的 Token,无需再提供注册码。如果某个 Agent 丢失了 Token 需要重新接入,请在设置页重新生成一个新的注册码。 diff --git a/apps/docs/content/docs/en/configuration.mdx b/apps/docs/content/docs/en/configuration.mdx index b74f3210..4348d270 100644 --- a/apps/docs/content/docs/en/configuration.mdx +++ b/apps/docs/content/docs/en/configuration.mdx @@ -115,7 +115,7 @@ There is no admin username/password environment variable. On first start (when n | `SERVERBEE_DATABASE__PATH` | `serverbee.db` | SQLite database file path (relative to data_dir) | | `SERVERBEE_DATABASE__MAX_CONNECTIONS` | `10` | Maximum database connection pool size | | `SERVERBEE_AUTH__SESSION_TTL` | `86400` | Session token TTL in seconds (default 24h) | -| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Set Secure flag on session cookies. Set `false` for HTTP-only dev | +| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Set the Secure flag on session cookies. Use `false` only when the browser accesses ServerBee over plain HTTP, such as direct IP quick-start installs | | `SERVERBEE_RATE_LIMIT__LOGIN_MAX` | `5` | Max login attempts per IP within 15-minute window | | `SERVERBEE_RATE_LIMIT__REGISTER_MAX` | `3` | Max agent registrations per IP within 15-minute window | | `SERVERBEE_UPGRADE__RELEASE_BASE_URL` | `https://github.com/ZingerLittleBee/ServerBee/releases` | Base URL for agent upgrade release assets | @@ -182,7 +182,7 @@ Agent top-level keys use single underscore. Nested keys use `__` (double undersc |-----|------|---------|-------------| | `session_ttl` | i64 | `86400` | Session cookie lifetime in seconds (24 hours) | | `max_servers` | u32 | `0` | Maximum servers allowed via enrollment (0 = no limit). Best-effort soft cap | -| `secure_cookie` | bool | `true` | Set the `Secure` flag on session cookies. Disable only for HTTP-only development | +| `secure_cookie` | bool | `true` | Set the `Secure` flag on session cookies. Use `false` only when the browser accesses ServerBee over plain HTTP | ### `[admin]` -- Initial Admin Account diff --git a/apps/docs/content/docs/en/deployment.mdx b/apps/docs/content/docs/en/deployment.mdx index f2935c77..f33be130 100644 --- a/apps/docs/content/docs/en/deployment.mdx +++ b/apps/docs/content/docs/en/deployment.mdx @@ -232,6 +232,30 @@ See the [Agent Setup](/en/docs/agent) guide for a complete systemd service unit Running ServerBee behind a reverse proxy is strongly recommended for production. It provides TLS termination, HTTP/2, and additional security headers. +### Access URL and Cookie Settings + +Choose one public access URL and keep the browser URL, agent `server_url`, and cookie setting aligned: + +| Scenario | Public URL | `auth.secure_cookie` | Docker Environment Variable | Agent URL | +|----------|------------|----------------------|-----------------------------|-----------| +| Direct IP over HTTP | `http://203.0.113.10:9527` | `false` | `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://203.0.113.10:9527` | +| Domain through HTTPS reverse proxy | `https://monitor.example.com` | `true` | `SERVERBEE_AUTH__SECURE_COOKIE=true` or omit the variable | `https://monitor.example.com` | + +For domain access, create a DNS `A` or `AAAA` record pointing to the server, terminate HTTPS in Nginx, Caddy, or Traefik, and proxy traffic to ServerBee on `127.0.0.1:9527`. + +If you installed with the quick-start script, it writes `auth.secure_cookie = false` for direct HTTP access. Before switching that same installation to HTTPS, update `/etc/serverbee/server.toml`: + +```toml +[auth] +secure_cookie = true +``` + +Then restart the server: + +```bash +sudo systemctl restart serverbee-server +``` + ### Nginx ```nginx @@ -355,7 +379,7 @@ server_url = "https://monitor.example.com" The agent automatically handles WebSocket connections using the provided URL. -When using HTTPS, ensure `auth.secure_cookie = true` in your server configuration (this is the default). If you set it to `false`, session cookies will not be sent over HTTPS connections, breaking browser authentication. +When using HTTPS, keep `auth.secure_cookie = true` in your server configuration. Leaving it `false` may still allow login, but it removes the browser's Secure cookie protection and is not appropriate for production. ## Backup and Restore diff --git a/apps/docs/content/docs/en/quick-start.mdx b/apps/docs/content/docs/en/quick-start.mdx index 618b27ac..919b098b 100644 --- a/apps/docs/content/docs/en/quick-start.mdx +++ b/apps/docs/content/docs/en/quick-start.mdx @@ -27,6 +27,8 @@ services: - "9527:9527" volumes: - serverbee-data:/data + environment: + - SERVERBEE_AUTH__SECURE_COOKIE=false volumes: serverbee-data: @@ -44,16 +46,41 @@ On first start the server auto-creates an admin account with a randomly generate The dashboard is now available at `http://your-server-ip:9527`. + +The compose file above disables the `Secure` cookie flag because this quick start uses plain HTTP. When you move behind HTTPS, set `SERVERBEE_AUTH__SECURE_COOKIE=true`. + + +## IP Address vs Domain Access + +ServerBee can be accessed directly by IP address or through a domain name. The required cookie and URL settings depend on whether the browser uses plain HTTP or HTTPS. + +| Access Mode | Browser URL | Server Cookie Setting | Agent `server_url` | Extra Setup | +|-------------|-------------|-----------------------|--------------------|-------------| +| Direct IP, plain HTTP | `http://your-server-ip:9527` | `auth.secure_cookie = false` or `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://your-server-ip:9527` | Open port `9527` in your firewall | +| Domain with HTTPS | `https://monitor.example.com` | `auth.secure_cookie = true` or `SERVERBEE_AUTH__SECURE_COOKIE=true` | `https://monitor.example.com` | Point DNS to the server IP and put Nginx, Caddy, or Traefik in front of ServerBee | + + +If you started with the quick-start HTTP settings and later move to a domain with HTTPS, change `secure_cookie` back to `true`, restart ServerBee, and update existing agents to use the new `https://` server URL. + + ## Option 2: Install Script Download and run the install script for a quick binary installation: ```bash -curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server -y ``` This will download the appropriate binary for your platform, create a systemd service, and start ServerBee automatically. +On first start the server auto-creates an admin account with a randomly generated password and prints it once to the service logs. Retrieve it with: + +```bash +sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS' +``` + +The install script writes `auth.secure_cookie = false` so browser login works on the plain HTTP quick-start URL. When you move behind HTTPS, set it back to `true` in `/etc/serverbee/server.toml` and restart the server. + After installation, manage your ServerBee instance with the `serverbee` CLI: ```bash @@ -65,16 +92,23 @@ sudo serverbee config set # Update config sudo serverbee uninstall server --purge # Uninstall + remove data ``` +Verify the server is running: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-server -n 80 --no-pager +``` + ## First Login 1. Open your browser and navigate to `http://your-server-ip:9527` -2. Log in with the default credentials: +2. Log in with the first-run credentials: - **Username:** `admin` - - **Password:** `admin` (or the password you configured) -3. **Change the default password immediately** via Settings > Security + - **Password:** the randomly generated password from the startup logs +3. Complete the required password change on first login -If you are using the default password, the server will display a prominent warning in the logs at startup. Always change the default password before exposing the server to the internet. +The generated password is shown only once in the logs. Copy it before logs rotate, then complete the forced password change before exposing the server to the internet. ## Adding Your First Agent @@ -108,6 +142,13 @@ On its first start, the agent will: You should see the new server appear in your dashboard within a few seconds. +Verify the agent is connected and reporting: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-agent -n 80 --no-pager +``` + When the agent can read a stable machine identifier, repeated auto-registration from the same host reuses the existing server entry instead of creating duplicate placeholders. From 308699d22626d32c1dd7aca350922efee5d75cb6 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 18:27:09 +0800 Subject: [PATCH 02/43] fix(deploy): harden noninteractive install flow --- deploy/install.sh | 57 ++++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 83629494..f45c4141 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -143,6 +143,11 @@ get_latest_version() { echo "$tag" } +docker_image_tag() { + local version="$1" + echo "${version#v}" +} + get_local_ip() { ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' \ || hostname -I 2>/dev/null | awk '{print $1}' \ @@ -395,6 +400,9 @@ install_binary_server() { cat > "${CONFIG_DIR}/server.toml" << TOML [server] data_dir = "${DATA_DIR}" + +[auth] +secure_cookie = false TOML if [ -n "$PASSWORD" ]; then cat >> "${CONFIG_DIR}/server.toml" << TOML @@ -512,8 +520,9 @@ install_docker_server() { check_docker check_unmanaged_container "server" - local version + local version image_tag version=$(get_latest_version) + image_tag=$(docker_image_tag "$version") mkdir -p "$DOCKER_DIR" "$CONFIG_DIR" @@ -543,7 +552,7 @@ TOML cat > "${DOCKER_DIR}/docker-compose.server.yml" << YAML services: serverbee-server: - image: ghcr.io/zingerlittlebee/serverbee-server:${version} + image: ghcr.io/zingerlittlebee/serverbee-server:${image_tag} container_name: serverbee-server ports: - "9527:9527" @@ -551,6 +560,7 @@ services: - serverbee-data:/data environment: - SERVERBEE_ADMIN__USERNAME=admin + - SERVERBEE_AUTH__SECURE_COOKIE=false ${password_env:+${password_env} } restart: unless-stopped healthcheck: @@ -577,8 +587,9 @@ install_docker_agent() { check_docker check_unmanaged_container "agent" - local version + local version image_tag version=$(get_latest_version) + image_tag=$(docker_image_tag "$version") mkdir -p "$CONFIG_DIR" @@ -602,7 +613,7 @@ TOML cat > "${DOCKER_DIR}/docker-compose.agent.yml" << YAML services: serverbee-agent: - image: ghcr.io/zingerlittlebee/serverbee-agent:${version} + image: ghcr.io/zingerlittlebee/serverbee-agent:${image_tag} container_name: serverbee-agent privileged: true network_mode: host @@ -635,11 +646,11 @@ print_server_result() { if [ -n "$PASSWORD" ]; then echo " Password: ${PASSWORD}" elif [ "$METHOD" = "docker" ]; then - echo " Password: (auto-generated, check: docker compose -f ${DOCKER_DIR}/docker-compose.server.yml logs | grep 'Generated admin password')" + echo " Password: (auto-generated, check: docker compose -f ${DOCKER_DIR}/docker-compose.server.yml logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" elif has_systemd; then - echo " Password: (auto-generated, check: sudo journalctl -u serverbee-server | grep 'Generated admin password')" + echo " Password: (auto-generated, check: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" else - echo " Password: (auto-generated, check process output for 'Generated admin password')" + echo " Password: (auto-generated, check process output for 'FIRST-RUN ADMIN CREDENTIALS')" fi echo "" echo " Docs: ${DOCS_URL}/en/docs/configuration" @@ -735,7 +746,7 @@ cmd_install() { # Prompt for component-specific params if [ "$COMPONENT" = "server" ]; then - if [ -z "$PASSWORD" ] && [ "$YES" != true ]; then + if [ -z "$PASSWORD" ] && [ "$YES" != true ] && [ -t 0 ]; then echo "" read -rp "Admin password (Enter to skip, auto-generated on first start): " PASSWORD fi @@ -953,6 +964,8 @@ upgrade_binary() { upgrade_docker() { local component="$1" version="$2" local compose_file="${DOCKER_DIR}/docker-compose.${component}.yml" + local image_tag + image_tag=$(docker_image_tag "$version") if [ ! -f "$compose_file" ]; then error "Compose file not found: $compose_file" @@ -960,7 +973,7 @@ upgrade_docker() { # Update image tag in compose file local image_base="ghcr.io/zingerlittlebee/serverbee-${component}" - sed -i.bak "s|${image_base}:[^ ]*|${image_base}:${version}|" "$compose_file" && rm -f "${compose_file}.bak" + sed -i.bak "s|${image_base}:[^ ]*|${image_base}:${image_tag}|" "$compose_file" && rm -f "${compose_file}.bak" docker compose -f "$compose_file" pull docker compose -f "$compose_file" up -d @@ -1326,17 +1339,21 @@ cmd_config() { fi done else - echo "" - echo " Restart service to apply changes?" - read -rp " [y/N]: " confirm - if [[ "$confirm" =~ ^[yY] ]]; then - for entry in "${MANAGED_COMPONENTS[@]}"; do - local comp="${entry%%:*}" - local method="${entry##*:}" - if [[ "$target" == "$comp" || "$target" == "both" ]]; then - cmd_service_single "$comp" "$method" "restart" - fi - done + if [ -t 0 ]; then + echo "" + echo " Restart service to apply changes?" + read -rp " [y/N]: " confirm + if [[ "$confirm" =~ ^[yY] ]]; then + for entry in "${MANAGED_COMPONENTS[@]}"; do + local comp="${entry%%:*}" + local method="${entry##*:}" + if [[ "$target" == "$comp" || "$target" == "both" ]]; then + cmd_service_single "$comp" "$method" "restart" + fi + done + fi + else + warn "Non-interactive mode detected; services were not restarted. Re-run with -y to restart automatically, or restart manually." fi fi return From 4639540ca4968fe0c6c13ca9c00c29716e59aab0 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 18:41:46 +0800 Subject: [PATCH 03/43] feat(deploy): add domain https setup --- apps/docs/content/docs/cn/deployment.mdx | 8 + apps/docs/content/docs/cn/quick-start.mdx | 11 + apps/docs/content/docs/en/deployment.mdx | 8 + apps/docs/content/docs/en/quick-start.mdx | 11 + deploy/install.sh | 321 +++++++++++++++++++++- 5 files changed, 357 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/cn/deployment.mdx b/apps/docs/content/docs/cn/deployment.mdx index e9a95aff..0c3e02ec 100644 --- a/apps/docs/content/docs/cn/deployment.mdx +++ b/apps/docs/content/docs/cn/deployment.mdx @@ -264,6 +264,14 @@ secure_cookie = true sudo systemctl restart serverbee-server ``` +如果 ServerBee 已经通过安装脚本部署,也可以使用内置命令自动完成 Caddy HTTPS 配置: + +```bash +sudo serverbee domain setup --domain monitor.example.com --email admin@example.com -y +``` + +该命令会校验域名 DNS 是否解析到当前服务器,安装并配置 Caddy,把 ServerBee 改为只监听 `127.0.0.1:9527`,并把 `auth.secure_cookie` 设置为 `true`。如果 DNS 还没生效,命令会停止并打印需要添加的 `A`/`AAAA` 记录。 + ### 基础配置 ```nginx title="/etc/nginx/sites-available/serverbee" diff --git a/apps/docs/content/docs/cn/quick-start.mdx b/apps/docs/content/docs/cn/quick-start.mdx index 565bb4d5..28f74339 100644 --- a/apps/docs/content/docs/cn/quick-start.mdx +++ b/apps/docs/content/docs/cn/quick-start.mdx @@ -65,6 +65,17 @@ ServerBee 可以直接用服务器 IP 访问,也可以通过域名和反向代 如果你先按快速开始使用 HTTP,之后迁移到域名和 HTTPS,请把 `secure_cookie` 改回 `true`,重启 ServerBee,并把已安装 Agent 的 Server 地址更新为新的 `https://` 地址。 +如果你已经有域名,并且 DNS `A` 记录已经指向当前服务器,可以让安装脚本自动配置 Caddy HTTPS: + +```bash +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server \ + --domain monitor.example.com \ + --email admin@example.com \ + -y +``` + +脚本会先校验域名是否解析到当前服务器。未解析或解析到其他 IP 时,脚本会停止并提示应添加的 DNS 记录。`--email` 用于 Let's Encrypt 证书通知,可省略。 + ## 方式二:安装脚本 适用于 Linux 服务器,一键完成安装、配置和 systemd 服务注册。 diff --git a/apps/docs/content/docs/en/deployment.mdx b/apps/docs/content/docs/en/deployment.mdx index f33be130..c714978b 100644 --- a/apps/docs/content/docs/en/deployment.mdx +++ b/apps/docs/content/docs/en/deployment.mdx @@ -256,6 +256,14 @@ Then restart the server: sudo systemctl restart serverbee-server ``` +If ServerBee was installed with the install script, you can also let the CLI configure Caddy HTTPS automatically: + +```bash +sudo serverbee domain setup --domain monitor.example.com --email admin@example.com -y +``` + +This command verifies that the domain resolves to the current server, installs and configures Caddy, changes ServerBee to listen only on `127.0.0.1:9527`, and sets `auth.secure_cookie = true`. If DNS is not ready yet, it stops and prints the `A`/`AAAA` record you need to add. + ### Nginx ```nginx diff --git a/apps/docs/content/docs/en/quick-start.mdx b/apps/docs/content/docs/en/quick-start.mdx index 919b098b..95d60369 100644 --- a/apps/docs/content/docs/en/quick-start.mdx +++ b/apps/docs/content/docs/en/quick-start.mdx @@ -63,6 +63,17 @@ ServerBee can be accessed directly by IP address or through a domain name. The r If you started with the quick-start HTTP settings and later move to a domain with HTTPS, change `secure_cookie` back to `true`, restart ServerBee, and update existing agents to use the new `https://` server URL. +If you already have a domain and its DNS `A` record points to this server, the install script can configure Caddy HTTPS automatically: + +```bash +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server \ + --domain monitor.example.com \ + --email admin@example.com \ + -y +``` + +The script first verifies that the domain resolves to the current server. If it does not, installation stops and prints the DNS record you need to add. `--email` is used for Let's Encrypt certificate notices and can be omitted. + ## Option 2: Install Script Download and run the install script for a quick binary installation: diff --git a/deploy/install.sh b/deploy/install.sh index f45c4141..6dab72de 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -9,6 +9,8 @@ DATA_DIR="/var/lib/serverbee" DOCKER_DIR="/opt/serverbee" META_FILE="${CONFIG_DIR}/.install-meta" DOCS_URL="https://server-bee-docs.vercel.app" +CADDY_CONFIG_DIR="/etc/caddy" +CADDYFILE="${CADDY_CONFIG_DIR}/Caddyfile" # ─── Globals ────────────────────────────────────────────────────────────────── COMMAND="" @@ -17,8 +19,11 @@ METHOD="" SERVER_URL="" ENROLLMENT_CODE="" PASSWORD="" +DOMAIN="" +EMAIL="" YES=false PURGE=false +SKIP_DNS_CHECK=false CONFIG_KEY="" CONFIG_VALUE="" @@ -85,7 +90,7 @@ require_root() { } # ─── Known subcommands ─────────────────────────────────────────────────────── -KNOWN_COMMANDS="install uninstall upgrade status start stop restart config env" +KNOWN_COMMANDS="install uninstall upgrade status start stop restart config env domain" # ─── Argument parsing ───────────────────────────────────────────────────────── parse_args() { @@ -95,6 +100,9 @@ parse_args() { --server-url) SERVER_URL="$2"; shift 2 ;; --enrollment-code) ENROLLMENT_CODE="$2"; shift 2 ;; --password) PASSWORD="$2"; shift 2 ;; + --domain) DOMAIN="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --skip-dns-check) SKIP_DNS_CHECK=true; shift ;; --purge) PURGE=true; shift ;; --yes|-y) YES=true; shift ;; -*) error "Unknown option: $1" ;; @@ -154,6 +162,101 @@ get_local_ip() { || echo "localhost" } +get_public_ipv4() { + if [ -n "${SERVERBEE_TEST_PUBLIC_IPV4:-}" ]; then + echo "$SERVERBEE_TEST_PUBLIC_IPV4" + return + fi + curl -4 -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true +} + +get_public_ipv6() { + if [ -n "${SERVERBEE_TEST_PUBLIC_IPV6:-}" ]; then + echo "$SERVERBEE_TEST_PUBLIC_IPV6" + return + fi + curl -6 -fsS --max-time 5 https://api6.ipify.org 2>/dev/null || true +} + +resolve_domain_a() { + local domain="$1" + if [ -n "${SERVERBEE_TEST_DNS_A:-}" ]; then + echo "$SERVERBEE_TEST_DNS_A" | tr ',' '\n' | sed '/^$/d' + return + fi + if command -v getent &>/dev/null; then + getent ahostsv4 "$domain" 2>/dev/null | awk '{print $1}' | sort -u + elif command -v dig &>/dev/null; then + dig +short A "$domain" 2>/dev/null | sed '/^$/d' + elif command -v host &>/dev/null; then + host -t A "$domain" 2>/dev/null | awk '/has address/ {print $4}' + fi +} + +resolve_domain_aaaa() { + local domain="$1" + if [ -n "${SERVERBEE_TEST_DNS_AAAA:-}" ]; then + echo "$SERVERBEE_TEST_DNS_AAAA" | tr ',' '\n' | sed '/^$/d' + return + fi + if command -v getent &>/dev/null; then + getent ahostsv6 "$domain" 2>/dev/null | awk '{print $1}' | sort -u + elif command -v dig &>/dev/null; then + dig +short AAAA "$domain" 2>/dev/null | sed '/^$/d' + elif command -v host &>/dev/null; then + host -t AAAA "$domain" 2>/dev/null | awk '/has IPv6 address/ {print $5}' + fi +} + +validate_domain_name() { + local domain="$1" + [[ "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] \ + || error "Invalid domain: ${domain}\n Use a hostname like monitor.example.com, without http:// or a path." +} + +line_contains_value() { + local haystack="$1" needle="$2" + [ -n "$needle" ] && echo "$haystack" | grep -Fxq "$needle" +} + +check_domain_points_here() { + local domain="$1" + if [ "$SKIP_DNS_CHECK" = true ]; then + warn "Skipping DNS check for ${domain}." + return + fi + + local public_ipv4 public_ipv6 dns_a dns_aaaa + public_ipv4=$(get_public_ipv4) + public_ipv6=$(get_public_ipv6) + dns_a=$(resolve_domain_a "$domain" || true) + dns_aaaa=$(resolve_domain_aaaa "$domain" || true) + + if line_contains_value "$dns_a" "$public_ipv4" || line_contains_value "$dns_aaaa" "$public_ipv6"; then + info "DNS check passed: ${domain} resolves to this server." + return + fi + + echo "" + echo "Domain ${domain} does not resolve to this server." + echo "" + echo "Current server IP:" + echo " IPv4: ${public_ipv4:-unknown}" + echo " IPv6: ${public_ipv6:-unknown}" + echo "" + echo "Current DNS records:" + echo " A: ${dns_a:-none}" + echo " AAAA: ${dns_aaaa:-none}" + echo "" + echo "Please add/update DNS:" + [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" + [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" + echo "" + echo "Then wait for DNS propagation and re-run this command." + echo "" + error "DNS validation failed for ${domain}" +} + # ─── Install metadata (.install-meta JSON) ─────────────────────────────────── # Uses basic grep/sed for JSON manipulation to avoid jq dependency. # The JSON is simple (flat per-component objects) and always written by us. @@ -676,6 +779,205 @@ print_agent_result() { echo "" } +# ─── Domain / HTTPS setup ───────────────────────────────────────────────────── + +install_caddy() { + if command -v caddy &>/dev/null; then + info "Caddy is already installed" + return + fi + + if command -v apt-get &>/dev/null; then + info "Installing Caddy via official apt repository..." + apt-get update -qq >/dev/null 2>&1 + apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl gpg >/dev/null 2>&1 \ + || error "Failed to install Caddy apt repository dependencies" + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + > /etc/apt/sources.list.d/caddy-stable.list + chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list + apt-get update -qq >/dev/null 2>&1 + apt-get install -y -qq caddy >/dev/null 2>&1 || error "Failed to install Caddy" + elif command -v dnf &>/dev/null; then + info "Installing Caddy via COPR repository..." + dnf install -y -q dnf-plugins-core >/dev/null 2>&1 || dnf install -y -q dnf5-plugins >/dev/null 2>&1 \ + || error "Failed to install dnf COPR plugin" + dnf copr enable -y @caddy/caddy >/dev/null 2>&1 || error "Failed to enable Caddy COPR repository" + dnf install -y -q caddy >/dev/null 2>&1 || error "Failed to install Caddy" + elif command -v yum &>/dev/null; then + info "Installing Caddy via COPR repository..." + yum install -y -q yum-plugin-copr >/dev/null 2>&1 || error "Failed to install yum COPR plugin" + yum copr enable -y @caddy/caddy >/dev/null 2>&1 || error "Failed to enable Caddy COPR repository" + yum install -y -q caddy >/dev/null 2>&1 || error "Failed to install Caddy" + else + error "Cannot install Caddy automatically on this distribution.\n Install Caddy manually, then configure:\n\n ${DOMAIN} {\n reverse_proxy 127.0.0.1:9527\n }" + fi +} + +check_http_ports_available() { + local listeners="" + if command -v ss &>/dev/null; then + listeners=$(ss -ltnp 2>/dev/null | awk '$4 ~ /:80$/ || $4 ~ /:443$/ {print}' || true) + elif command -v lsof &>/dev/null; then + listeners=$(lsof -nP -iTCP:80 -iTCP:443 -sTCP:LISTEN 2>/dev/null || true) + fi + + if [ -n "$listeners" ] && ! echo "$listeners" | grep -qi caddy; then + echo "$listeners" | sed 's/^/ /' + error "Port 80 or 443 is already used by a non-Caddy service.\n Stop that service or configure your existing reverse proxy manually." + fi +} + +write_caddyfile() { + mkdir -p "$CADDY_CONFIG_DIR" + if [ -f "$CADDYFILE" ]; then + cp "$CADDYFILE" "${CADDYFILE}.serverbee.$(date +%Y%m%d%H%M%S).bak" + if [ -n "$EMAIL" ] && ! grep -q "^[[:space:]]*email[[:space:]]" "$CADDYFILE"; then + local first_nonblank + first_nonblank=$(awk 'NF {print; exit}' "$CADDYFILE") + if [ "$first_nonblank" = "{" ]; then + awk -v email="$EMAIL" ' + !inserted && $0 == "{" { print; print " email "email; inserted=1; next } + { print } + ' "$CADDYFILE" > /tmp/serverbee-caddyfile + else + { + echo "{" + echo " email ${EMAIL}" + echo "}" + echo "" + cat "$CADDYFILE" + } > /tmp/serverbee-caddyfile + fi + mv /tmp/serverbee-caddyfile "$CADDYFILE" + fi + if grep -q "^${DOMAIN}[[:space:]]*{" "$CADDYFILE"; then + awk -v domain="$DOMAIN" ' + $0 ~ "^"domain"[[:space:]]*{" { print domain" {\n reverse_proxy 127.0.0.1:9527\n}"; in_block=1; depth=1; next } + in_block { + depth += gsub(/\{/, "{") + depth -= gsub(/\}/, "}") + if (depth <= 0) in_block=0 + next + } + { print } + ' "$CADDYFILE" > /tmp/serverbee-caddyfile + mv /tmp/serverbee-caddyfile "$CADDYFILE" + else + cat >> "$CADDYFILE" << EOF + +${DOMAIN} { + reverse_proxy 127.0.0.1:9527 +} +EOF + fi + else + if [ -n "$EMAIL" ]; then + cat > "$CADDYFILE" << EOF +{ + email ${EMAIL} +} + +${DOMAIN} { + reverse_proxy 127.0.0.1:9527 +} +EOF + else + cat > "$CADDYFILE" << EOF +${DOMAIN} { + reverse_proxy 127.0.0.1:9527 +} +EOF + fi + fi + info "Configured ${CADDYFILE} for ${DOMAIN}" +} + +update_server_for_domain_binary() { + [ -f "${CONFIG_DIR}/server.toml" ] || error "Server config not found: ${CONFIG_DIR}/server.toml" + toml_set "${CONFIG_DIR}/server.toml" "server.listen" "127.0.0.1:9527" + toml_set "${CONFIG_DIR}/server.toml" "auth.secure_cookie" "true" + if has_systemd; then + systemctl restart serverbee-server + fi +} + +update_server_for_domain_docker() { + local compose_file="${DOCKER_DIR}/docker-compose.server.yml" + [ -f "$compose_file" ] || error "Compose file not found: $compose_file" + + sed -i.bak 's|- "9527:9527"|- "127.0.0.1:9527:9527"|' "$compose_file" && rm -f "${compose_file}.bak" + if grep -q "SERVERBEE_AUTH__SECURE_COOKIE=" "$compose_file"; then + sed -i.bak 's|SERVERBEE_AUTH__SECURE_COOKIE=.*|SERVERBEE_AUTH__SECURE_COOKIE=true|' "$compose_file" && rm -f "${compose_file}.bak" + else + sed -i.bak '/environment:/a\ - SERVERBEE_AUTH__SECURE_COOKIE=true' "$compose_file" && rm -f "${compose_file}.bak" + fi + docker compose -f "$compose_file" up -d +} + +setup_domain() { + validate_domain_name "$DOMAIN" + check_domain_points_here "$DOMAIN" + check_http_ports_available + install_caddy + write_caddyfile + + detect_installed + meta_has "server" || error "serverbee-server is not installed. Install the server first." + + local method + method=$(meta_read "server" "method") + case "$method" in + binary) update_server_for_domain_binary ;; + docker) update_server_for_domain_docker ;; + *) error "Unsupported server install method for domain setup: ${method}" ;; + esac + + if has_systemd; then + systemctl enable caddy >/dev/null 2>&1 || true + systemctl restart caddy + else + warn "systemd not found. Start Caddy manually with: caddy run --config ${CADDYFILE}" + fi + + info "Verifying HTTPS endpoint..." + curl -fsS --max-time 20 "https://${DOMAIN}/healthz" >/dev/null \ + || error "HTTPS verification failed for https://${DOMAIN}/healthz. Check Caddy logs and DNS propagation." + + echo "" + echo -e "${GREEN}ServerBee HTTPS domain configured successfully!${NC}" + echo "" + echo " Dashboard: https://${DOMAIN}" + echo " Agent URL: https://${DOMAIN}" + echo "" + echo "Install an agent with:" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO}/main/deploy/install.sh | sudo bash -s -- agent \\" + echo " --server-url https://${DOMAIN} \\" + echo " --enrollment-code YOUR_ONE_TIME_CODE" + echo "" +} + +cmd_domain() { + if [ -z "$COMPONENT" ]; then + COMPONENT="setup" + fi + [ "$COMPONENT" = "setup" ] || error "Usage: serverbee domain setup --domain monitor.example.com --email admin@example.com" + + if [ -z "$DOMAIN" ]; then + if [ "$YES" = true ] || ! [ -t 0 ]; then + error "--domain is required" + fi + read -rp "Domain (e.g., monitor.example.com): " DOMAIN + fi + + if [ -z "$EMAIL" ] && [ "$YES" != true ] && [ -t 0 ]; then + read -rp "Email for certificate notices (optional): " EMAIL + fi + + setup_domain +} + # ─── Install command ────────────────────────────────────────────────────────── cmd_install() { @@ -750,6 +1052,14 @@ cmd_install() { echo "" read -rp "Admin password (Enter to skip, auto-generated on first start): " PASSWORD fi + if [ -z "$DOMAIN" ] && [ "$YES" != true ] && [ -t 0 ]; then + echo "" + read -rp "Configure HTTPS domain with Caddy now? [y/N]: " confirm_domain + if [[ "$confirm_domain" =~ ^[yY] ]]; then + read -rp "Domain (e.g., monitor.example.com): " DOMAIN + read -rp "Email for certificate notices (optional): " EMAIL + fi + fi elif [ "$COMPONENT" = "agent" ]; then while [ -z "$SERVER_URL" ]; do if [ "$YES" = true ]; then error "--server-url is required for agent installation"; fi @@ -769,6 +1079,10 @@ cmd_install() { agent-binary) install_binary_agent ;; agent-docker) install_docker_agent ;; esac + + if [ "$COMPONENT" = "server" ] && [ -n "$DOMAIN" ]; then + setup_domain + fi } # ─── Uninstall command ──────────────────────────────────────────────────────── @@ -1551,9 +1865,10 @@ interactive_menu() { echo " [5] Service 服务控制 (start/stop/restart)" echo " [6] Config 配置管理" echo " [7] Env 环境变量" + echo " [8] Domain 域名 HTTPS" echo " [0] Exit 退出" echo "" - read -rp "Select [0-7]: " choice + read -rp "Select [0-8]: " choice case "$choice" in 1) COMMAND="install" ;; 2) COMMAND="uninstall" ;; @@ -1562,6 +1877,7 @@ interactive_menu() { 5) interactive_service_menu ;; 6) COMMAND="config" ;; 7) COMMAND="env" ;; + 8) COMMAND="domain"; COMPONENT="setup" ;; 0) exit 0 ;; *) error "Invalid choice: $choice" ;; esac @@ -1597,6 +1913,7 @@ run_command() { restart) cmd_service restart ;; config) cmd_config ;; env) cmd_env ;; + domain) cmd_domain ;; *) error "Unknown command: $COMMAND" ;; esac } From 25d06a4656474c428be76b15c5ce62b96f3ed01a Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 18:53:37 +0800 Subject: [PATCH 04/43] fix(deploy): reject unsupported admin password option --- apps/docs/content/docs/cn/configuration.mdx | 12 ------- apps/docs/content/docs/cn/server.mdx | 11 ------ apps/docs/content/docs/en/configuration.mdx | 17 ++-------- apps/docs/content/docs/en/server.mdx | 4 --- deploy/install.sh | 37 +++------------------ 5 files changed, 8 insertions(+), 73 deletions(-) diff --git a/apps/docs/content/docs/cn/configuration.mdx b/apps/docs/content/docs/cn/configuration.mdx index 33dccfed..c92654b6 100644 --- a/apps/docs/content/docs/cn/configuration.mdx +++ b/apps/docs/content/docs/cn/configuration.mdx @@ -211,18 +211,6 @@ session_ttl = 86400 # 默认: 0 max_servers = 0 -# --- 初始管理员 --- -[admin] -# 初始管理员用户名 -# 仅在 users 表为空时生效 -# 默认: "admin" -username = "admin" - -# 初始管理员密码 -# 留空则自动生成随机密码并打印到日志 -# 默认: "" (自动生成) -password = "" - # --- 速率限制 --- [rate_limit] # 登录接口速率限制:每 IP 每分钟最大请求数 diff --git a/apps/docs/content/docs/cn/server.mdx b/apps/docs/content/docs/cn/server.mdx index 4293534b..9418cdea 100644 --- a/apps/docs/content/docs/cn/server.mdx +++ b/apps/docs/content/docs/cn/server.mdx @@ -60,10 +60,6 @@ max_connections = 10 session_ttl = 86400 max_servers = 0 -[admin] -username = "admin" -password = "" - [rate_limit] login_max = 5 register_max = 3 @@ -106,13 +102,6 @@ file = "" | `session_ttl` | int | `86400` | Session 过期时间,单位秒(默认 24 小时) | | `max_servers` | int | `0` | 通过注册码接入允许创建的新服务器软上限(0 表示不限制) | -#### `[admin]` 初始管理员 - -| 配置项 | 类型 | 默认值 | 说明 | -|--------|------|--------|------| -| `username` | string | `"admin"` | 初始管理员用户名 | -| `password` | string | `""` | 初始管理员密码,留空则自动生成随机密码 | - #### `[rate_limit]` 速率限制 | 配置项 | 类型 | 默认值 | 说明 | diff --git a/apps/docs/content/docs/en/configuration.mdx b/apps/docs/content/docs/en/configuration.mdx index 4348d270..454d33f5 100644 --- a/apps/docs/content/docs/en/configuration.mdx +++ b/apps/docs/content/docs/en/configuration.mdx @@ -184,13 +184,6 @@ Agent top-level keys use single underscore. Nested keys use `__` (double undersc | `max_servers` | u32 | `0` | Maximum servers allowed via enrollment (0 = no limit). Best-effort soft cap | | `secure_cookie` | bool | `true` | Set the `Secure` flag on session cookies. Use `false` only when the browser accesses ServerBee over plain HTTP | -### `[admin]` -- Initial Admin Account - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `username` | string | `"admin"` | Admin username (used only on first startup when no users exist) | -| `password` | string | `""` | Admin password. If empty, a random password is generated and logged | - ### `[retention]` -- Data Retention | Key | Type | Default | Description | @@ -357,11 +350,11 @@ The log level can also be set via the `RUST_LOG` environment variable, which tak ## Example: Minimal Server Configuration ```toml -[admin] -password = "my-secure-password" +[server] +data_dir = "./data" ``` -Everything else uses sensible defaults. This is sufficient to start a working server that listens on port 9527 with a SQLite database in `./data/`. +Everything else uses sensible defaults. On first startup, ServerBee creates the `admin` user with a random password and prints it once in the server logs. ## Example: Production Server Configuration @@ -370,10 +363,6 @@ Everything else uses sensible defaults. This is sufficient to start a working se listen = "127.0.0.1:9527" data_dir = "/var/lib/serverbee" -[admin] -username = "admin" -password = "a-very-strong-password" - [auth] secure_cookie = true diff --git a/apps/docs/content/docs/en/server.mdx b/apps/docs/content/docs/en/server.mdx index cc70382c..528a28b6 100644 --- a/apps/docs/content/docs/en/server.mdx +++ b/apps/docs/content/docs/en/server.mdx @@ -62,10 +62,6 @@ session_ttl = 86400 # Session lifetime in seconds (default: 24 hours) max_servers = 0 # Soft limit for newly enrolled servers secure_cookie = true # Set Secure flag on session cookies (disable for HTTP-only dev) -[admin] -username = "admin" # Initial admin username -password = "" # Initial admin password (auto-generated if empty) - [retention] records_days = 7 # Raw metric records retention (days) records_hourly_days = 90 # Hourly aggregated records retention (days) diff --git a/deploy/install.sh b/deploy/install.sh index 6dab72de..e5b18b99 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -18,7 +18,6 @@ COMPONENT="" METHOD="" SERVER_URL="" ENROLLMENT_CODE="" -PASSWORD="" DOMAIN="" EMAIL="" YES=false @@ -99,7 +98,7 @@ parse_args() { --method) METHOD="$2"; shift 2 ;; --server-url) SERVER_URL="$2"; shift 2 ;; --enrollment-code) ENROLLMENT_CODE="$2"; shift 2 ;; - --password) PASSWORD="$2"; shift 2 ;; + --password) error "--password is no longer supported. ServerBee always generates a one-time first-run admin password; check the server logs after installation." ;; --domain) DOMAIN="$2"; shift 2 ;; --email) EMAIL="$2"; shift 2 ;; --skip-dns-check) SKIP_DNS_CHECK=true; shift ;; @@ -507,13 +506,6 @@ data_dir = "${DATA_DIR}" [auth] secure_cookie = false TOML - if [ -n "$PASSWORD" ]; then - cat >> "${CONFIG_DIR}/server.toml" << TOML - -[admin] -password = "${PASSWORD}" -TOML - fi info "Created ${CONFIG_DIR}/server.toml" else warn "${CONFIG_DIR}/server.toml already exists, not overwriting" @@ -635,23 +627,11 @@ install_docker_server() { [server] data_dir = "/data" TOML - if [ -n "$PASSWORD" ]; then - cat >> "${CONFIG_DIR}/server.toml" << TOML - -[admin] -password = "${PASSWORD}" -TOML - fi info "Created ${CONFIG_DIR}/server.toml" else warn "${CONFIG_DIR}/server.toml already exists, not overwriting" fi - local password_env="" - if [ -n "$PASSWORD" ]; then - password_env=" - SERVERBEE_ADMIN__PASSWORD=${PASSWORD}" - fi - cat > "${DOCKER_DIR}/docker-compose.server.yml" << YAML services: serverbee-server: @@ -664,8 +644,7 @@ services: environment: - SERVERBEE_ADMIN__USERNAME=admin - SERVERBEE_AUTH__SECURE_COOKIE=false -${password_env:+${password_env} -} restart: unless-stopped + restart: unless-stopped healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:9527/healthz"] interval: 30s @@ -746,9 +725,7 @@ print_server_result() { echo "" echo " Dashboard: http://${ip}:9527" echo " Username: admin" - if [ -n "$PASSWORD" ]; then - echo " Password: ${PASSWORD}" - elif [ "$METHOD" = "docker" ]; then + if [ "$METHOD" = "docker" ]; then echo " Password: (auto-generated, check: docker compose -f ${DOCKER_DIR}/docker-compose.server.yml logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" elif has_systemd; then echo " Password: (auto-generated, check: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" @@ -1048,10 +1025,6 @@ cmd_install() { # Prompt for component-specific params if [ "$COMPONENT" = "server" ]; then - if [ -z "$PASSWORD" ] && [ "$YES" != true ] && [ -t 0 ]; then - echo "" - read -rp "Admin password (Enter to skip, auto-generated on first start): " PASSWORD - fi if [ -z "$DOMAIN" ] && [ "$YES" != true ] && [ -t 0 ]; then echo "" read -rp "Configure HTTPS domain with Caddy now? [y/N]: " confirm_domain @@ -1603,8 +1576,8 @@ cmd_config() { # 1. Check rejected keys if echo "$REJECTED_KEYS" | grep -qw "$key"; then case "$key" in - admin.password) error "Admin password can only be set during initial installation. To change password, use the Dashboard UI." ;; - admin.username) error "Admin username can only be set during initial installation." ;; + admin.password) error "Admin password is not a runtime config. ServerBee generates a one-time first-run password; change it in the Dashboard UI after login." ;; + admin.username) error "Admin username is not a runtime config. Change it during first-login onboarding or in the Dashboard UI." ;; esac fi From e375fa953c83989bad46ac86cbda3c83e54d4b8f Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 19:06:28 +0800 Subject: [PATCH 05/43] fix(deploy): make domain setup verification idempotent --- deploy/install.sh | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index e5b18b99..27162280 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -770,7 +770,7 @@ install_caddy() { apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl gpg >/dev/null 2>&1 \ || error "Failed to install Caddy apt repository dependencies" curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ - | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + | gpg --dearmor --batch --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ > /etc/apt/sources.list.d/caddy-stable.list chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list @@ -893,6 +893,26 @@ update_server_for_domain_docker() { docker compose -f "$compose_file" up -d } +wait_for_https_endpoint() { + local url="https://${DOMAIN}/healthz" + local attempts=30 + local delay=2 + local attempt + + for ((attempt = 1; attempt <= attempts; attempt++)); do + if curl -fsS --max-time 20 "$url" >/dev/null; then + return 0 + fi + + if [ "$attempt" -lt "$attempts" ]; then + info "HTTPS endpoint is not ready yet (attempt ${attempt}/${attempts}); retrying in ${delay}s..." + sleep "$delay" + fi + done + + return 1 +} + setup_domain() { validate_domain_name "$DOMAIN" check_domain_points_here "$DOMAIN" @@ -919,7 +939,7 @@ setup_domain() { fi info "Verifying HTTPS endpoint..." - curl -fsS --max-time 20 "https://${DOMAIN}/healthz" >/dev/null \ + wait_for_https_endpoint \ || error "HTTPS verification failed for https://${DOMAIN}/healthz. Check Caddy logs and DNS propagation." echo "" From 11a06e25b8d225e8d88ab24d50f5aec65e597ae0 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 19:10:46 +0800 Subject: [PATCH 06/43] feat(deploy): preview install actions before execution --- deploy/install.sh | 175 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 171 insertions(+), 4 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 27162280..2c4a5738 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -25,6 +25,7 @@ PURGE=false SKIP_DNS_CHECK=false CONFIG_KEY="" CONFIG_VALUE="" +MISSING_DEPS=() # ─── Colors ─────────────────────────────────────────────────────────────────── RED='\033[0;31m' @@ -38,6 +39,10 @@ info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +should_prompt() { + [ "$YES" != true ] && [ -t 0 ] +} + # ─── Dependency check ───────────────────────────────────────────────────────── install_deps() { # Auto-install missing packages using the available package manager @@ -81,6 +86,16 @@ check_deps() { fi } +collect_missing_deps() { + MISSING_DEPS=() + local cmd + for cmd in curl grep sed awk mktemp; do + if ! command -v "$cmd" &>/dev/null; then + MISSING_DEPS+=("$cmd") + fi + done +} + # ─── Root check ─────────────────────────────────────────────────────────────── require_root() { if [ "$(id -u)" -ne 0 ]; then @@ -91,6 +106,13 @@ require_root() { # ─── Known subcommands ─────────────────────────────────────────────────────── KNOWN_COMMANDS="install uninstall upgrade status start stop restart config env domain" +is_known_command() { + case "$1" in + install|uninstall|upgrade|status|start|stop|restart|config|env|domain) return 0 ;; + *) return 1 ;; + esac +} + # ─── Argument parsing ───────────────────────────────────────────────────────── parse_args() { while [[ $# -gt 0 ]]; do @@ -972,11 +994,150 @@ cmd_domain() { read -rp "Email for certificate notices (optional): " EMAIL fi - setup_domain + run_domain_setup_with_plan } # ─── Install command ────────────────────────────────────────────────────────── +print_missing_deps_plan() { + collect_missing_deps + if [ ${#MISSING_DEPS[@]} -gt 0 ]; then + echo " - System packages: ${MISSING_DEPS[*]} (required script tools)" + fi +} + +print_common_binary_plan() { + local component="$1" + local os arch filename + os=$(detect_os) + arch=$(detect_arch) + filename="serverbee-${component}-${os}-${arch}" + + echo " - GitHub API: latest ServerBee release metadata" + if [ -f "${INSTALL_DIR}/serverbee-${component}" ]; then + echo " - Binary: existing ${INSTALL_DIR}/serverbee-${component} will be adopted (no binary download)" + else + echo " - Binary: https://github.com/${REPO}/releases/download//${filename}" + fi + echo " - CLI script: https://raw.githubusercontent.com/${REPO}//deploy/install.sh" +} + +print_common_docker_plan() { + local component="$1" + echo " - Prerequisite: Docker and Docker Compose V2 must already be installed" + echo " - GitHub API: latest ServerBee release metadata" + echo " - Docker image: ghcr.io/zingerlittlebee/serverbee-${component}:" + echo " - CLI script: https://raw.githubusercontent.com/${REPO}//deploy/install.sh" +} + +print_domain_plan() { + [ -z "$DOMAIN" ] && return + + echo "" + echo "HTTPS domain setup:" + echo " - DNS validation: ${DOMAIN} must resolve to this server" + echo " - Caddy repository: Cloudsmith apt repo on Debian/Ubuntu, or COPR on Fedora/CentOS" + echo " - Caddy apt key: https://dl.cloudsmith.io/public/caddy/stable/gpg.key" + echo " - Caddy apt source: https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" + echo " - System packages: Caddy and its repository dependencies when missing" + echo " - Caddyfile: ${CADDYFILE}" + echo " - Server bind address: 127.0.0.1:9527" + echo " - secure_cookie: true" + echo " - Public URL: https://${DOMAIN}" +} + +confirm_domain_setup_plan() { + echo "" + echo -e "${BOLD}Domain setup plan${NC}" + echo "" + echo "Domain: ${DOMAIN}" + [ -n "$EMAIL" ] && echo "Email: ${EMAIL}" + echo "" + echo "Will add or download:" + print_missing_deps_plan + print_domain_plan + echo "" + + if ! should_prompt; then + info "Proceeding without prompt." + return + fi + + read -rp "Start domain setup now? [y/N]: " confirm + case "$confirm" in + [yY]|[yY][eE][sS]) ;; + *) error "Domain setup cancelled." ;; + esac +} + +run_domain_setup_with_plan() { + confirm_domain_setup_plan + check_deps + setup_domain +} + +print_install_plan() { + echo "" + echo -e "${BOLD}Installation plan${NC}" + echo "" + echo "Component: serverbee-${COMPONENT}" + echo "Method: ${METHOD}" + + if [ "$COMPONENT" = "server" ]; then + if [ -n "$DOMAIN" ]; then + echo "Access: domain (${DOMAIN})" + else + echo "Access: IP / direct port (:9527)" + fi + else + echo "Server URL: ${SERVER_URL}" + fi + + echo "" + echo "Will add or download:" + print_missing_deps_plan + case "${COMPONENT}-${METHOD}" in + server-binary) + print_common_binary_plan "server" + echo " - Config file: ${CONFIG_DIR}/server.toml" + echo " - Data directory: ${DATA_DIR}" + if has_systemd; then echo " - systemd service: serverbee-server"; fi + ;; + agent-binary) + print_common_binary_plan "agent" + echo " - Config file: ${CONFIG_DIR}/agent.toml" + if has_systemd; then echo " - systemd service: serverbee-agent"; fi + ;; + server-docker) + print_common_docker_plan "server" + echo " - Config file: ${CONFIG_DIR}/server.toml" + echo " - Compose file: ${DOCKER_DIR}/docker-compose.server.yml" + echo " - Docker volume: serverbee-data" + ;; + agent-docker) + print_common_docker_plan "agent" + echo " - Config file: ${CONFIG_DIR}/agent.toml" + echo " - Compose file: ${DOCKER_DIR}/docker-compose.agent.yml" + ;; + esac + print_domain_plan + echo "" +} + +confirm_install_plan() { + print_install_plan + if ! should_prompt; then + info "Proceeding without prompt." + return + fi + + read -rp "Start installation now? [y/N]: " confirm + case "$confirm" in + [yY]|[yY][eE][sS]) ;; + *) error "Installation cancelled." ;; + esac +} + cmd_install() { # Interactive: prompt for component if not provided if [ -z "$COMPONENT" ]; then @@ -1064,6 +1225,9 @@ cmd_install() { done fi + confirm_install_plan + check_deps + info "Installing ${COMPONENT} via ${METHOD}..." case "${COMPONENT}-${METHOD}" in @@ -1918,19 +2082,22 @@ main() { case "$arg" in --yes|-y) YES=true ;; esac done - check_deps - # Shorthand: first arg not a known command → prepend "install" - if [[ $# -gt 0 ]] && ! echo "$KNOWN_COMMANDS" | grep -qw "$1"; then + if [[ $# -gt 0 ]] && ! is_known_command "$1"; then set -- install "$@" fi if [[ $# -eq 0 ]]; then + check_deps interactive_menu else COMMAND="$1"; shift parse_args "$@" require_root + case "$COMMAND" in + install|domain) ;; + *) check_deps ;; + esac run_command fi } From 80c6bc328ebbfa9ea6fc06f513234f65a8fca0d1 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 19:15:12 +0800 Subject: [PATCH 07/43] fix(deploy): defer interactive dependency checks --- deploy/install.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deploy/install.sh b/deploy/install.sh index 2c4a5738..4348e606 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -2039,6 +2039,10 @@ interactive_menu() { *) error "Invalid choice: $choice" ;; esac require_root + case "$COMMAND" in + install|domain) ;; + *) check_deps ;; + esac run_command } @@ -2088,7 +2092,6 @@ main() { fi if [[ $# -eq 0 ]]; then - check_deps interactive_menu else COMMAND="$1"; shift From 5dea99827f1318d75f14874a42e4182a445a6d85 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 19:25:11 +0800 Subject: [PATCH 08/43] fix(deploy): recommend docker for server installs --- deploy/install.sh | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 4348e606..0b6e60df 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1138,6 +1138,33 @@ confirm_install_plan() { esac } +prompt_install_method() { + local choice + + echo "" + if [ "$COMPONENT" = "server" ]; then + echo " [1] Docker (recommended for Server)" + echo " [2] Binary" + echo "" + read -rp "Select installation method [1/2]: " choice + case "$choice" in + 1|docker) METHOD="docker" ;; + 2|binary) METHOD="binary" ;; + *) error "Invalid choice: $choice" ;; + esac + else + echo " [1] Binary (recommended for Agent)" + echo " [2] Docker" + echo "" + read -rp "Select installation method [1/2]: " choice + case "$choice" in + 1|binary) METHOD="binary" ;; + 2|docker) METHOD="docker" ;; + *) error "Invalid choice: $choice" ;; + esac + fi +} + cmd_install() { # Interactive: prompt for component if not provided if [ -z "$COMPONENT" ]; then @@ -1167,21 +1194,14 @@ cmd_install() { # Interactive: prompt for method if not provided if [ -z "$METHOD" ]; then if [ -t 0 ]; then - # Interactive terminal — show menu - echo "" - echo " [1] Binary (recommended)" - echo " [2] Docker" - echo "" - read -rp "Select installation method [1/2]: " choice - case "$choice" in - 1|binary) METHOD="binary" ;; - 2|docker) METHOD="docker" ;; - *) error "Invalid choice: $choice" ;; - esac + prompt_install_method else # Non-interactive (piped) — default to binary METHOD="binary" info "Non-interactive mode detected, defaulting to binary installation." + if [ "$COMPONENT" = "server" ]; then + info "Docker is recommended for Server when Docker is available; pass --method docker to use it." + fi fi fi : "${METHOD:=binary}" From 34e22a54ad996876f0187b6962744c849bfe4c31 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 19:29:30 +0800 Subject: [PATCH 09/43] fix(deploy): run domain dns check before install confirmation --- deploy/install.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/deploy/install.sh b/deploy/install.sh index 0b6e60df..758c3ea1 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1046,6 +1046,15 @@ print_domain_plan() { echo " - Public URL: https://${DOMAIN}" } +run_domain_preflight_checks() { + [ -z "$DOMAIN" ] && return + + echo "" + echo "Preflight checks:" + validate_domain_name "$DOMAIN" + check_domain_points_here "$DOMAIN" +} + confirm_domain_setup_plan() { echo "" echo -e "${BOLD}Domain setup plan${NC}" @@ -1057,6 +1066,7 @@ confirm_domain_setup_plan() { print_missing_deps_plan print_domain_plan echo "" + run_domain_preflight_checks if ! should_prompt; then info "Proceeding without prompt." @@ -1126,6 +1136,8 @@ print_install_plan() { confirm_install_plan() { print_install_plan + run_domain_preflight_checks + if ! should_prompt; then info "Proceeding without prompt." return From 6e9276181c8ec952c8bebc48f3f560ad4a322286 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 19:56:22 +0800 Subject: [PATCH 10/43] fix(deploy): wait for domain dns before confirmation --- deploy/install.sh | 55 +++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 758c3ea1..f83c40fb 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -240,26 +240,16 @@ line_contains_value() { [ -n "$needle" ] && echo "$haystack" | grep -Fxq "$needle" } -check_domain_points_here() { - local domain="$1" - if [ "$SKIP_DNS_CHECK" = true ]; then - warn "Skipping DNS check for ${domain}." - return - fi - - local public_ipv4 public_ipv6 dns_a dns_aaaa - public_ipv4=$(get_public_ipv4) - public_ipv6=$(get_public_ipv6) - dns_a=$(resolve_domain_a "$domain" || true) - dns_aaaa=$(resolve_domain_aaaa "$domain" || true) +domain_points_to_server() { + local dns_a="$1" dns_aaaa="$2" public_ipv4="$3" public_ipv6="$4" + line_contains_value "$dns_a" "$public_ipv4" || line_contains_value "$dns_aaaa" "$public_ipv6" +} - if line_contains_value "$dns_a" "$public_ipv4" || line_contains_value "$dns_aaaa" "$public_ipv6"; then - info "DNS check passed: ${domain} resolves to this server." - return - fi +print_dns_mismatch_help() { + local domain="$1" public_ipv4="$2" public_ipv6="$3" dns_a="$4" dns_aaaa="$5" echo "" - echo "Domain ${domain} does not resolve to this server." + echo "Domain ${domain} does not resolve to this server yet." echo "" echo "Current server IP:" echo " IPv4: ${public_ipv4:-unknown}" @@ -273,9 +263,36 @@ check_domain_points_here() { [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" echo "" - echo "Then wait for DNS propagation and re-run this command." + echo "Waiting for DNS to match before continuing." + echo "If this does not match, Caddy/Let's Encrypt certificate issuance will fail." + echo "Press Ctrl+C to stop waiting." echo "" - error "DNS validation failed for ${domain}" +} + +check_domain_points_here() { + local domain="$1" + if [ "$SKIP_DNS_CHECK" = true ]; then + warn "Skipping DNS check for ${domain}." + return + fi + + local public_ipv4 public_ipv6 dns_a dns_aaaa + public_ipv4=$(get_public_ipv4) + public_ipv6=$(get_public_ipv6) + + local interval="${SERVERBEE_DNS_CHECK_INTERVAL:-10}" + while true; do + dns_a=$(resolve_domain_a "$domain" || true) + dns_aaaa=$(resolve_domain_aaaa "$domain" || true) + + if domain_points_to_server "$dns_a" "$dns_aaaa" "$public_ipv4" "$public_ipv6"; then + info "DNS check passed: ${domain} resolves to this server." + return + fi + + print_dns_mismatch_help "$domain" "$public_ipv4" "$public_ipv6" "$dns_a" "$dns_aaaa" + sleep "$interval" + done } # ─── Install metadata (.install-meta JSON) ─────────────────────────────────── From 0970150b476af2dcd021b7f6e20339328aa3b797 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 20:00:38 +0800 Subject: [PATCH 11/43] fix(deploy): require manual dns recheck before plan --- deploy/install.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index f83c40fb..9418e77c 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -263,9 +263,9 @@ print_dns_mismatch_help() { [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" echo "" - echo "Waiting for DNS to match before continuing." + echo "DNS must match before continuing." echo "If this does not match, Caddy/Let's Encrypt certificate issuance will fail." - echo "Press Ctrl+C to stop waiting." + echo "Update DNS, then press Enter to check again. Press Ctrl+C to stop." echo "" } @@ -280,7 +280,6 @@ check_domain_points_here() { public_ipv4=$(get_public_ipv4) public_ipv6=$(get_public_ipv6) - local interval="${SERVERBEE_DNS_CHECK_INTERVAL:-10}" while true; do dns_a=$(resolve_domain_a "$domain" || true) dns_aaaa=$(resolve_domain_aaaa "$domain" || true) @@ -291,7 +290,10 @@ check_domain_points_here() { fi print_dns_mismatch_help "$domain" "$public_ipv4" "$public_ipv6" "$dns_a" "$dns_aaaa" - sleep "$interval" + if ! should_prompt; then + error "DNS validation failed for ${domain}. Fix DNS and re-run, or pass --skip-dns-check if you have configured TLS another way." + fi + read -rp "Press Enter to re-check DNS..." _ done } @@ -1073,6 +1075,8 @@ run_domain_preflight_checks() { } confirm_domain_setup_plan() { + run_domain_preflight_checks + echo "" echo -e "${BOLD}Domain setup plan${NC}" echo "" @@ -1083,7 +1087,6 @@ confirm_domain_setup_plan() { print_missing_deps_plan print_domain_plan echo "" - run_domain_preflight_checks if ! should_prompt; then info "Proceeding without prompt." @@ -1152,8 +1155,8 @@ print_install_plan() { } confirm_install_plan() { - print_install_plan run_domain_preflight_checks + print_install_plan if ! should_prompt; then info "Proceeding without prompt." From 84e64cec47b3624b084be3fcf26824b096716384 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 20:08:13 +0800 Subject: [PATCH 12/43] fix(deploy): warn about mismatched ipv6 dns --- deploy/install.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/deploy/install.sh b/deploy/install.sh index 9418e77c..148cf575 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -245,6 +245,26 @@ domain_points_to_server() { line_contains_value "$dns_a" "$public_ipv4" || line_contains_value "$dns_aaaa" "$public_ipv6" } +warn_mismatched_aaaa_if_present() { + local domain="$1" dns_aaaa="$2" public_ipv6="$3" + + [ -n "$dns_aaaa" ] || return 0 + if [ -n "$public_ipv6" ] && line_contains_value "$dns_aaaa" "$public_ipv6"; then + return 0 + fi + + warn "AAAA record for ${domain} does not point to this server." + if [ -n "$public_ipv6" ]; then + echo " Current server IPv6: ${public_ipv6}" + echo " DNS AAAA: ${dns_aaaa}" + echo " Fix the AAAA record or remove it if you only want IPv4." + else + echo " This server has no detected public IPv6, but DNS has AAAA: ${dns_aaaa}" + echo " Remove the AAAA record unless you have verified IPv6 reaches this server." + fi + echo " Caddy/Let's Encrypt may try IPv6 and certificate issuance may fail." +} + print_dns_mismatch_help() { local domain="$1" public_ipv4="$2" public_ipv6="$3" dns_a="$4" dns_aaaa="$5" @@ -286,6 +306,7 @@ check_domain_points_here() { if domain_points_to_server "$dns_a" "$dns_aaaa" "$public_ipv4" "$public_ipv6"; then info "DNS check passed: ${domain} resolves to this server." + warn_mismatched_aaaa_if_present "$domain" "$dns_aaaa" "$public_ipv6" return fi From d22cdbcdf8bc1c0181c32810aa9db62a209ac432 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 20:15:32 +0800 Subject: [PATCH 13/43] feat(deploy): add installer language selection --- deploy/install.sh | 304 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 238 insertions(+), 66 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 148cf575..a88ed1b2 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -20,6 +20,7 @@ SERVER_URL="" ENROLLMENT_CODE="" DOMAIN="" EMAIL="" +LANG_CODE="${SERVERBEE_LANG:-}" YES=false PURGE=false SKIP_DNS_CHECK=false @@ -43,6 +44,119 @@ should_prompt() { [ "$YES" != true ] && [ -t 0 ] } +normalize_lang() { + case "${LANG_CODE:-}" in + zh|zh_*|zh-*|cn|CN) LANG_CODE="zh" ;; + en|en_*|en-*) LANG_CODE="en" ;; + "") ;; + *) error "Unsupported language: ${LANG_CODE} (use 'en' or 'zh')" ;; + esac +} + +detect_lang() { + if [ -n "${LANG_CODE:-}" ]; then + normalize_lang + return + fi + + case "${LC_ALL:-${LANG:-en}}" in + zh*|ZH*) LANG_CODE="zh" ;; + *) LANG_CODE="en" ;; + esac +} + +select_language() { + [ -z "${LANG_CODE:-}" ] || { normalize_lang; return; } + + if ! should_prompt; then + detect_lang + return + fi + + local choice + echo "" + echo -e "${BOLD}Select language / 选择语言${NC}" + echo "" + echo " [1] English" + echo " [2] 简体中文" + echo "" + read -rp "Select language [1/2]: " choice + case "$choice" in + 1|en|EN|English|english) LANG_CODE="en" ;; + 2|zh|ZH|cn|CN|中文) LANG_CODE="zh" ;; + *) error "Invalid language choice: ${choice}" ;; + esac +} + +tr_text() { + local key="$1" + case "${LANG_CODE:-en}:${key}" in + zh:manager_title) echo "ServerBee 管理器" ;; + en:manager_title) echo "ServerBee Manager" ;; + zh:install_menu) echo " [1] 安装 Install" ;; + en:install_menu) echo " [1] Install 安装" ;; + zh:uninstall_menu) echo " [2] 卸载 Uninstall" ;; + en:uninstall_menu) echo " [2] Uninstall 卸载" ;; + zh:upgrade_menu) echo " [3] 升级 Upgrade" ;; + en:upgrade_menu) echo " [3] Upgrade 升级" ;; + zh:status_menu) echo " [4] 状态 Status" ;; + en:status_menu) echo " [4] Status 查看状态" ;; + zh:service_menu) echo " [5] 服务控制 Service (start/stop/restart)" ;; + en:service_menu) echo " [5] Service 服务控制 (start/stop/restart)" ;; + zh:config_menu) echo " [6] 配置管理 Config" ;; + en:config_menu) echo " [6] Config 配置管理" ;; + zh:env_menu) echo " [7] 环境变量 Env" ;; + en:env_menu) echo " [7] Env 环境变量" ;; + zh:domain_menu) echo " [8] 域名 HTTPS Domain" ;; + en:domain_menu) echo " [8] Domain 域名 HTTPS" ;; + zh:exit_menu) echo " [0] 退出 Exit" ;; + en:exit_menu) echo " [0] Exit 退出" ;; + zh:select_menu) echo "选择 [0-8]: " ;; + en:select_menu) echo "Select [0-8]: " ;; + zh:install_title) echo "安装" ;; + en:install_title) echo "Install" ;; + zh:server_option) echo " [1] Server — 控制台和 API" ;; + en:server_option) echo " [1] Server — Dashboard & API" ;; + zh:agent_option) echo " [2] Agent — 系统指标采集器" ;; + en:agent_option) echo " [2] Agent — System metrics collector" ;; + zh:select_component) echo "选择组件 [1/2]: " ;; + en:select_component) echo "Select component [1/2]: " ;; + zh:server_docker_recommended) echo " [1] Docker (Server 推荐)" ;; + en:server_docker_recommended) echo " [1] Docker (recommended for Server)" ;; + zh:agent_binary_recommended) echo " [1] Binary (Agent 推荐)" ;; + en:agent_binary_recommended) echo " [1] Binary (recommended for Agent)" ;; + zh:binary_option) echo " [2] Binary" ;; + en:binary_option) echo " [2] Binary" ;; + zh:docker_option) echo " [2] Docker" ;; + en:docker_option) echo " [2] Docker" ;; + zh:select_method) echo "选择安装方式 [1/2]: " ;; + en:select_method) echo "Select installation method [1/2]: " ;; + zh:configure_domain) echo "现在配置 HTTPS 域名(Caddy)吗?[y/N]: " ;; + en:configure_domain) echo "Configure HTTPS domain with Caddy now? [y/N]: " ;; + zh:domain_prompt) echo "域名(例如 monitor.example.com): " ;; + en:domain_prompt) echo "Domain (e.g., monitor.example.com): " ;; + zh:email_prompt) echo "证书通知邮箱(可选): " ;; + en:email_prompt) echo "Email for certificate notices (optional): " ;; + zh:server_url_prompt) echo "Server URL(例如 http://10.0.0.1:9527): " ;; + en:server_url_prompt) echo "Server URL (e.g., http://10.0.0.1:9527): " ;; + zh:enrollment_prompt) echo "Enrollment code(注册码): " ;; + en:enrollment_prompt) echo "Enrollment code: " ;; + zh:install_plan_title) echo "安装计划" ;; + en:install_plan_title) echo "Installation plan" ;; + zh:domain_plan_title) echo "域名配置计划" ;; + en:domain_plan_title) echo "Domain setup plan" ;; + zh:will_add_download) echo "将添加或下载:" ;; + en:will_add_download) echo "Will add or download:" ;; + zh:start_install) echo "现在开始安装?[y/N]: " ;; + en:start_install) echo "Start installation now? [y/N]: " ;; + zh:start_domain) echo "现在开始域名配置?[y/N]: " ;; + en:start_domain) echo "Start domain setup now? [y/N]: " ;; + zh:preflight) echo "安装前检查:" ;; + en:preflight) echo "Preflight checks:" ;; + *) echo "$key" ;; + esac +} + # ─── Dependency check ───────────────────────────────────────────────────────── install_deps() { # Auto-install missing packages using the available package manager @@ -123,6 +237,7 @@ parse_args() { --password) error "--password is no longer supported. ServerBee always generates a one-time first-run admin password; check the server logs after installation." ;; --domain) DOMAIN="$2"; shift 2 ;; --email) EMAIL="$2"; shift 2 ;; + --lang) LANG_CODE="$2"; normalize_lang; shift 2 ;; --skip-dns-check) SKIP_DNS_CHECK=true; shift ;; --purge) PURGE=true; shift ;; --yes|-y) YES=true; shift ;; @@ -253,40 +368,75 @@ warn_mismatched_aaaa_if_present() { return 0 fi - warn "AAAA record for ${domain} does not point to this server." - if [ -n "$public_ipv6" ]; then - echo " Current server IPv6: ${public_ipv6}" - echo " DNS AAAA: ${dns_aaaa}" - echo " Fix the AAAA record or remove it if you only want IPv4." + if [ "${LANG_CODE:-en}" = "zh" ]; then + warn "${domain} 的 AAAA 记录没有指向当前服务器。" + if [ -n "$public_ipv6" ]; then + echo " 当前服务器 IPv6: ${public_ipv6}" + echo " DNS AAAA: ${dns_aaaa}" + echo " 请修正 AAAA 记录;如果只使用 IPv4,请删除 AAAA 记录。" + else + echo " 当前服务器未检测到公网 IPv6,但 DNS 存在 AAAA: ${dns_aaaa}" + echo " 除非你确认 IPv6 可以访问这台服务器,否则请删除 AAAA 记录。" + fi + echo " Caddy/Let's Encrypt 可能会尝试 IPv6,导致证书申请失败。" else - echo " This server has no detected public IPv6, but DNS has AAAA: ${dns_aaaa}" - echo " Remove the AAAA record unless you have verified IPv6 reaches this server." + warn "AAAA record for ${domain} does not point to this server." + if [ -n "$public_ipv6" ]; then + echo " Current server IPv6: ${public_ipv6}" + echo " DNS AAAA: ${dns_aaaa}" + echo " Fix the AAAA record or remove it if you only want IPv4." + else + echo " This server has no detected public IPv6, but DNS has AAAA: ${dns_aaaa}" + echo " Remove the AAAA record unless you have verified IPv6 reaches this server." + fi + echo " Caddy/Let's Encrypt may try IPv6 and certificate issuance may fail." fi - echo " Caddy/Let's Encrypt may try IPv6 and certificate issuance may fail." } print_dns_mismatch_help() { local domain="$1" public_ipv4="$2" public_ipv6="$3" dns_a="$4" dns_aaaa="$5" - echo "" - echo "Domain ${domain} does not resolve to this server yet." - echo "" - echo "Current server IP:" - echo " IPv4: ${public_ipv4:-unknown}" - echo " IPv6: ${public_ipv6:-unknown}" - echo "" - echo "Current DNS records:" - echo " A: ${dns_a:-none}" - echo " AAAA: ${dns_aaaa:-none}" - echo "" - echo "Please add/update DNS:" - [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" - [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" - echo "" - echo "DNS must match before continuing." - echo "If this does not match, Caddy/Let's Encrypt certificate issuance will fail." - echo "Update DNS, then press Enter to check again. Press Ctrl+C to stop." - echo "" + if [ "${LANG_CODE:-en}" = "zh" ]; then + echo "" + echo "域名 ${domain} 还没有解析到当前服务器。" + echo "" + echo "当前服务器 IP:" + echo " IPv4: ${public_ipv4:-未知}" + echo " IPv6: ${public_ipv6:-未知}" + echo "" + echo "当前 DNS 记录:" + echo " A: ${dns_a:-无}" + echo " AAAA: ${dns_aaaa:-无}" + echo "" + echo "请添加或更新 DNS:" + [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" + [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" + echo "" + echo "继续之前 DNS 必须匹配。" + echo "如果不匹配,Caddy/Let's Encrypt 证书申请会失败。" + echo "更新 DNS 后按 Enter 重新校验,按 Ctrl+C 停止。" + echo "" + else + echo "" + echo "Domain ${domain} does not resolve to this server yet." + echo "" + echo "Current server IP:" + echo " IPv4: ${public_ipv4:-unknown}" + echo " IPv6: ${public_ipv6:-unknown}" + echo "" + echo "Current DNS records:" + echo " A: ${dns_a:-none}" + echo " AAAA: ${dns_aaaa:-none}" + echo "" + echo "Please add/update DNS:" + [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" + [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" + echo "" + echo "DNS must match before continuing." + echo "If this does not match, Caddy/Let's Encrypt certificate issuance will fail." + echo "Update DNS, then press Enter to check again. Press Ctrl+C to stop." + echo "" + fi } check_domain_points_here() { @@ -314,7 +464,11 @@ check_domain_points_here() { if ! should_prompt; then error "DNS validation failed for ${domain}. Fix DNS and re-run, or pass --skip-dns-check if you have configured TLS another way." fi - read -rp "Press Enter to re-check DNS..." _ + if [ "${LANG_CODE:-en}" = "zh" ]; then + read -rp "按 Enter 重新校验 DNS..." _ + else + read -rp "Press Enter to re-check DNS..." _ + fi done } @@ -1027,11 +1181,11 @@ cmd_domain() { if [ "$YES" = true ] || ! [ -t 0 ]; then error "--domain is required" fi - read -rp "Domain (e.g., monitor.example.com): " DOMAIN + read -rp "$(tr_text domain_prompt)" DOMAIN fi if [ -z "$EMAIL" ] && [ "$YES" != true ] && [ -t 0 ]; then - read -rp "Email for certificate notices (optional): " EMAIL + read -rp "$(tr_text email_prompt)" EMAIL fi run_domain_setup_with_plan @@ -1090,7 +1244,7 @@ run_domain_preflight_checks() { [ -z "$DOMAIN" ] && return echo "" - echo "Preflight checks:" + echo "$(tr_text preflight)" validate_domain_name "$DOMAIN" check_domain_points_here "$DOMAIN" } @@ -1099,12 +1253,12 @@ confirm_domain_setup_plan() { run_domain_preflight_checks echo "" - echo -e "${BOLD}Domain setup plan${NC}" + echo -e "${BOLD}$(tr_text domain_plan_title)${NC}" echo "" echo "Domain: ${DOMAIN}" [ -n "$EMAIL" ] && echo "Email: ${EMAIL}" echo "" - echo "Will add or download:" + echo "$(tr_text will_add_download)" print_missing_deps_plan print_domain_plan echo "" @@ -1114,7 +1268,7 @@ confirm_domain_setup_plan() { return fi - read -rp "Start domain setup now? [y/N]: " confirm + read -rp "$(tr_text start_domain)" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) error "Domain setup cancelled." ;; @@ -1129,7 +1283,7 @@ run_domain_setup_with_plan() { print_install_plan() { echo "" - echo -e "${BOLD}Installation plan${NC}" + echo -e "${BOLD}$(tr_text install_plan_title)${NC}" echo "" echo "Component: serverbee-${COMPONENT}" echo "Method: ${METHOD}" @@ -1145,7 +1299,7 @@ print_install_plan() { fi echo "" - echo "Will add or download:" + echo "$(tr_text will_add_download)" print_missing_deps_plan case "${COMPONENT}-${METHOD}" in server-binary) @@ -1184,7 +1338,7 @@ confirm_install_plan() { return fi - read -rp "Start installation now? [y/N]: " confirm + read -rp "$(tr_text start_install)" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) error "Installation cancelled." ;; @@ -1196,20 +1350,20 @@ prompt_install_method() { echo "" if [ "$COMPONENT" = "server" ]; then - echo " [1] Docker (recommended for Server)" - echo " [2] Binary" + echo "$(tr_text server_docker_recommended)" + echo "$(tr_text binary_option)" echo "" - read -rp "Select installation method [1/2]: " choice + read -rp "$(tr_text select_method)" choice case "$choice" in 1|docker) METHOD="docker" ;; 2|binary) METHOD="binary" ;; *) error "Invalid choice: $choice" ;; esac else - echo " [1] Binary (recommended for Agent)" - echo " [2] Docker" + echo "$(tr_text agent_binary_recommended)" + echo "$(tr_text docker_option)" echo "" - read -rp "Select installation method [1/2]: " choice + read -rp "$(tr_text select_method)" choice case "$choice" in 1|binary) METHOD="binary" ;; 2|docker) METHOD="docker" ;; @@ -1222,12 +1376,12 @@ cmd_install() { # Interactive: prompt for component if not provided if [ -z "$COMPONENT" ]; then echo "" - echo -e "${BOLD}Install${NC}" + echo -e "${BOLD}$(tr_text install_title)${NC}" echo "" - echo " [1] Server — Dashboard & API" - echo " [2] Agent — System metrics collector" + echo "$(tr_text server_option)" + echo "$(tr_text agent_option)" echo "" - read -rp "Select component [1/2]: " choice + read -rp "$(tr_text select_component)" choice case "$choice" in 1|server) COMPONENT="server" ;; 2|agent) COMPONENT="agent" ;; @@ -1281,20 +1435,20 @@ cmd_install() { if [ "$COMPONENT" = "server" ]; then if [ -z "$DOMAIN" ] && [ "$YES" != true ] && [ -t 0 ]; then echo "" - read -rp "Configure HTTPS domain with Caddy now? [y/N]: " confirm_domain + read -rp "$(tr_text configure_domain)" confirm_domain if [[ "$confirm_domain" =~ ^[yY] ]]; then - read -rp "Domain (e.g., monitor.example.com): " DOMAIN - read -rp "Email for certificate notices (optional): " EMAIL + read -rp "$(tr_text domain_prompt)" DOMAIN + read -rp "$(tr_text email_prompt)" EMAIL fi fi elif [ "$COMPONENT" = "agent" ]; then while [ -z "$SERVER_URL" ]; do if [ "$YES" = true ]; then error "--server-url is required for agent installation"; fi - read -rp "Server URL (e.g., http://10.0.0.1:9527): " SERVER_URL + read -rp "$(tr_text server_url_prompt)" SERVER_URL done while [ -z "$ENROLLMENT_CODE" ]; do if [ "$YES" = true ]; then error "--enrollment-code is required for agent installation (generate a one-time code in the server UI Settings)"; fi - read -rp "Enrollment code: " ENROLLMENT_CODE + read -rp "$(tr_text enrollment_prompt)" ENROLLMENT_CODE done fi @@ -2085,20 +2239,20 @@ EOF interactive_menu() { echo "" - echo -e "${BOLD}ServerBee Manager${NC}" + echo -e "${BOLD}$(tr_text manager_title)${NC}" echo "=================" echo "" - echo " [1] Install 安装" - echo " [2] Uninstall 卸载" - echo " [3] Upgrade 升级" - echo " [4] Status 查看状态" - echo " [5] Service 服务控制 (start/stop/restart)" - echo " [6] Config 配置管理" - echo " [7] Env 环境变量" - echo " [8] Domain 域名 HTTPS" - echo " [0] Exit 退出" + echo "$(tr_text install_menu)" + echo "$(tr_text uninstall_menu)" + echo "$(tr_text upgrade_menu)" + echo "$(tr_text status_menu)" + echo "$(tr_text service_menu)" + echo "$(tr_text config_menu)" + echo "$(tr_text env_menu)" + echo "$(tr_text domain_menu)" + echo "$(tr_text exit_menu)" echo "" - read -rp "Select [0-8]: " choice + read -rp "$(tr_text select_menu)" choice case "$choice" in 1) COMMAND="install" ;; 2) COMMAND="uninstall" ;; @@ -2154,9 +2308,22 @@ run_command() { # ─── Main ───────────────────────────────────────────────────────────────────── main() { - # Pre-scan for -y flag so check_deps knows whether to auto-install - for arg in "$@"; do - case "$arg" in --yes|-y) YES=true ;; esac + # Pre-scan for -y and --lang before any prompt or dependency handling. + local args=("$@") + local i + for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[$i]}" in + --yes|-y) + YES=true + ;; + --lang) + if [ $((i + 1)) -ge ${#args[@]} ]; then + error "--lang requires a value" + fi + LANG_CODE="${args[$((i + 1))]}" + normalize_lang + ;; + esac done # Shorthand: first arg not a known command → prepend "install" @@ -2165,10 +2332,15 @@ main() { fi if [[ $# -eq 0 ]]; then + select_language interactive_menu else COMMAND="$1"; shift parse_args "$@" + case "$COMMAND" in + install|domain) select_language ;; + *) detect_lang ;; + esac require_root case "$COMMAND" in install|domain) ;; From 9e7238b47f8d22a788d66829918d80702831c80a Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 20:21:54 +0800 Subject: [PATCH 14/43] fix(deploy): use snap-accessible compose directory --- deploy/install.sh | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/deploy/install.sh b/deploy/install.sh index a88ed1b2..26dea89d 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -7,6 +7,8 @@ INSTALL_DIR="/usr/local/bin" CONFIG_DIR="/etc/serverbee" DATA_DIR="/var/lib/serverbee" DOCKER_DIR="/opt/serverbee" +DEFAULT_DOCKER_DIR="/opt/serverbee" +SNAP_DOCKER_DIR="/var/snap/docker/common/serverbee" META_FILE="${CONFIG_DIR}/.install-meta" DOCS_URL="https://server-bee-docs.vercel.app" CADDY_CONFIG_DIR="/etc/caddy" @@ -210,6 +212,24 @@ collect_missing_deps() { done } +docker_is_snap() { + command -v docker &>/dev/null || return 1 + local docker_path + docker_path=$(command -v docker) + case "$(readlink -f "$docker_path" 2>/dev/null || echo "$docker_path")" in + /snap/*|/usr/bin/snap) return 0 ;; + *) return 1 ;; + esac +} + +configure_docker_dir() { + if docker_is_snap; then + DOCKER_DIR="$SNAP_DOCKER_DIR" + else + DOCKER_DIR="$DEFAULT_DOCKER_DIR" + fi +} + # ─── Root check ─────────────────────────────────────────────────────────────── require_root() { if [ "$(id -u)" -ne 0 ]; then @@ -645,6 +665,7 @@ has_systemd() { check_docker() { command -v docker &>/dev/null || error "Docker is not installed. Install it first: https://docs.docker.com/get-docker/" docker compose version &>/dev/null || error "Docker Compose V2 is not available. Install it first: https://docs.docker.com/compose/install/" + configure_docker_dir } check_unmanaged_container() { @@ -1218,6 +1239,7 @@ print_common_binary_plan() { print_common_docker_plan() { local component="$1" + configure_docker_dir echo " - Prerequisite: Docker and Docker Compose V2 must already be installed" echo " - GitHub API: latest ServerBee release metadata" echo " - Docker image: ghcr.io/zingerlittlebee/serverbee-${component}:" @@ -2291,6 +2313,8 @@ interactive_service_menu() { # ─── Command dispatch ───────────────────────────────────────────────────────── run_command() { + configure_docker_dir + case "$COMMAND" in install) cmd_install ;; uninstall) cmd_uninstall ;; From bbfb14ad57ba70bfa06c174d48cf88510485f1ef Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 20:41:03 +0800 Subject: [PATCH 15/43] refactor(deploy): consolidate install layout under /opt/serverbee Unify the binary install mode into a single base directory (/opt/serverbee/{bin,etc,data}) to align with docker mode and simplify management. Adds automatic in-place migration of existing legacy FHS-split installs (/usr/local/bin, /etc/serverbee, /var/lib/serverbee), rewriting server.toml and systemd units and restarting running services. --- deploy/install.sh | 122 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 15 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 26dea89d..217ac75c 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -3,13 +3,22 @@ set -euo pipefail # ─── Constants ──────────────────────────────────────────────────────────────── REPO="ZingerLittleBee/ServerBee" -INSTALL_DIR="/usr/local/bin" -CONFIG_DIR="/etc/serverbee" -DATA_DIR="/var/lib/serverbee" -DOCKER_DIR="/opt/serverbee" -DEFAULT_DOCKER_DIR="/opt/serverbee" +# Everything ServerBee installs lives under a single base directory for +# unified management. The PATH-visible management CLI is the only exception. +BASE_DIR="/opt/serverbee" +INSTALL_DIR="${BASE_DIR}/bin" +CONFIG_DIR="${BASE_DIR}/etc" +DATA_DIR="${BASE_DIR}/data" +DOCKER_DIR="${BASE_DIR}" +DEFAULT_DOCKER_DIR="${BASE_DIR}" SNAP_DOCKER_DIR="/var/snap/docker/common/serverbee" META_FILE="${CONFIG_DIR}/.install-meta" +CLI_PATH="/usr/local/bin/serverbee" +# Legacy FHS-split layout (pre-/opt). Kept only for one-time auto-migration +# of installs created by older versions of this script. +LEGACY_BIN_DIR="/usr/local/bin" +LEGACY_CONFIG_DIR="/etc/serverbee" +LEGACY_DATA_DIR="/var/lib/serverbee" DOCS_URL="https://server-bee-docs.vercel.app" CADDY_CONFIG_DIR="/etc/caddy" CADDYFILE="${CADDY_CONFIG_DIR}/Caddyfile" @@ -237,6 +246,81 @@ require_root() { fi } +# ─── Legacy layout migration ────────────────────────────────────────────────── +# Older versions of this script used an FHS-split layout: +# /usr/local/bin/serverbee-* /etc/serverbee /var/lib/serverbee +# Newer installs are consolidated under ${BASE_DIR}. This migrates an existing +# legacy install in place so that upgrade/uninstall/config keep working. +migrate_legacy_layout() { + # Already on the new layout — nothing to do. + [ -f "$META_FILE" ] && return 0 + + local legacy_meta="${LEGACY_CONFIG_DIR}/.install-meta" + local has_legacy=false + [ -f "$legacy_meta" ] && has_legacy=true + [ -f "${LEGACY_BIN_DIR}/serverbee-server" ] && has_legacy=true + [ -f "${LEGACY_BIN_DIR}/serverbee-agent" ] && has_legacy=true + [ "$has_legacy" = true ] || return 0 + + info "Detected legacy install layout — migrating to ${BASE_DIR}" + mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_DIR" + + local comp + for comp in server agent; do + if [ -f "${LEGACY_BIN_DIR}/serverbee-${comp}" ]; then + mv -f "${LEGACY_BIN_DIR}/serverbee-${comp}" "${INSTALL_DIR}/serverbee-${comp}" + fi + done + + if [ -d "$LEGACY_CONFIG_DIR" ]; then + local f + for f in "$LEGACY_CONFIG_DIR"/* "$LEGACY_CONFIG_DIR"/.install-meta; do + [ -e "$f" ] || continue + mv -f "$f" "$CONFIG_DIR"/ 2>/dev/null || true + done + rmdir "$LEGACY_CONFIG_DIR" 2>/dev/null || true + fi + + if [ -d "$LEGACY_DATA_DIR" ] && [ "$LEGACY_DATA_DIR" != "$DATA_DIR" ]; then + local d + for d in "$LEGACY_DATA_DIR"/* "$LEGACY_DATA_DIR"/.[!.]*; do + [ -e "$d" ] || continue + mv -f "$d" "$DATA_DIR"/ 2>/dev/null || true + done + rmdir "$LEGACY_DATA_DIR" 2>/dev/null || true + fi + + # Point server.toml at the new data directory if it referenced the old one. + if [ -f "${CONFIG_DIR}/server.toml" ]; then + sed -i "s#${LEGACY_DATA_DIR}#${DATA_DIR}#g" "${CONFIG_DIR}/server.toml" 2>/dev/null || true + fi + + # Rewrite systemd units to the new paths and restart anything running. + if has_systemd; then + local unit svc + for comp in server agent; do + svc="serverbee-${comp}" + unit="/etc/systemd/system/${svc}.service" + [ -f "$unit" ] || continue + sed -i \ + -e "s#${LEGACY_BIN_DIR}/serverbee-${comp}#${INSTALL_DIR}/serverbee-${comp}#g" \ + -e "s#WorkingDirectory=${LEGACY_DATA_DIR}#WorkingDirectory=${DATA_DIR}#g" \ + -e "s#WorkingDirectory=${LEGACY_CONFIG_DIR}#WorkingDirectory=${CONFIG_DIR}#g" \ + -e "s#SERVERBEE_SERVER__DATA_DIR=${LEGACY_DATA_DIR}#SERVERBEE_SERVER__DATA_DIR=${DATA_DIR}#g" \ + "$unit" 2>/dev/null || true + done + systemctl daemon-reload 2>/dev/null || true + for comp in server agent; do + svc="serverbee-${comp}" + if systemctl is-active --quiet "$svc" 2>/dev/null; then + systemctl restart "$svc" 2>/dev/null || true + fi + done + fi + + info "Migration complete — ServerBee now lives under ${BASE_DIR}" +} + # ─── Known subcommands ─────────────────────────────────────────────────────── KNOWN_COMMANDS="install uninstall upgrade status start stop restart config env domain" @@ -682,7 +766,7 @@ check_unmanaged_container() { # ─── CLI self-install ──────────────────────────────────────────────────────── install_cli() { - local target="/usr/local/bin/serverbee" + local target="$CLI_PATH" local version="${1:-main}" # Entire body runs in a subshell so any failure (cp, curl, chmod, mv) @@ -718,6 +802,8 @@ install_binary_server() { arch=$(detect_arch) version=$(get_latest_version) + mkdir -p "$INSTALL_DIR" + # Download (skip if binary already exists — adopt mode) if [ -f "${INSTALL_DIR}/serverbee-server" ]; then warn "Binary already exists at ${INSTALL_DIR}/serverbee-server — skipping download (adopting existing)" @@ -750,16 +836,16 @@ TOML # systemd service if has_systemd; then - cat > /etc/systemd/system/serverbee-server.service << 'UNIT' + cat > /etc/systemd/system/serverbee-server.service << UNIT [Unit] Description=ServerBee Dashboard After=network.target [Service] Type=simple -ExecStart=/usr/local/bin/serverbee-server -WorkingDirectory=/var/lib/serverbee -Environment=SERVERBEE_SERVER__DATA_DIR=/var/lib/serverbee +ExecStart=${INSTALL_DIR}/serverbee-server +WorkingDirectory=${DATA_DIR} +Environment=SERVERBEE_SERVER__DATA_DIR=${DATA_DIR} Restart=always RestartSec=5 LimitNOFILE=65536 @@ -786,6 +872,8 @@ install_binary_agent() { arch=$(detect_arch) version=$(get_latest_version) + mkdir -p "$INSTALL_DIR" + # Download (skip if binary already exists — adopt mode) if [ -f "${INSTALL_DIR}/serverbee-agent" ]; then warn "Binary already exists at ${INSTALL_DIR}/serverbee-agent — skipping download (adopting existing)" @@ -819,15 +907,15 @@ TOML # systemd service if has_systemd; then - cat > /etc/systemd/system/serverbee-agent.service << 'UNIT' + cat > /etc/systemd/system/serverbee-agent.service << UNIT [Unit] Description=ServerBee Agent After=network.target [Service] Type=simple -ExecStart=/usr/local/bin/serverbee-agent -WorkingDirectory=/etc/serverbee +ExecStart=${INSTALL_DIR}/serverbee-agent +WorkingDirectory=${CONFIG_DIR} Restart=always RestartSec=5 AmbientCapabilities=CAP_NET_RAW @@ -941,7 +1029,7 @@ services: - /proc:/host/proc:ro - /sys:/host/sys:ro - /etc/machine-id:/etc/machine-id:ro - - /etc/serverbee:/etc/serverbee + - ${CONFIG_DIR}:/etc/serverbee restart: unless-stopped YAML @@ -1602,8 +1690,10 @@ cmd_uninstall() { remaining=$(grep -c '"method"' "$META_FILE" 2>/dev/null || true) : "${remaining:=0}" if [ "$remaining" -eq 0 ]; then - rm -f "/usr/local/bin/serverbee" + rm -f "$CLI_PATH" rm -f "$META_FILE" + # Drop now-empty layout directories (ignored if anything remains). + rmdir "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_DIR" "$DOCKER_DIR" "$BASE_DIR" 2>/dev/null || true info "All components removed. CLI uninstalled." fi fi @@ -2288,6 +2378,7 @@ interactive_menu() { *) error "Invalid choice: $choice" ;; esac require_root + migrate_legacy_layout case "$COMMAND" in install|domain) ;; *) check_deps ;; @@ -2366,6 +2457,7 @@ main() { *) detect_lang ;; esac require_root + migrate_legacy_layout case "$COMMAND" in install|domain) ;; *) check_deps ;; From dd50144f6325591eb07ba7f2342fc78f1450e5d9 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 20:53:20 +0800 Subject: [PATCH 16/43] feat(deploy): localize interactive UI, plan, result and status output Replace the case-based tr_text with maintainable per-language associative arrays (EN/ZH, 115 keys) with English fallback and a visible marker for missing keys. Localize the service and uninstall menus, all interactive confirmation prompts, the install/domain plan body, install result screens, and the status page; switch the docs link to the matching language. Diagnostic info/warn/error stay in English. Add a bash 4.0+ guard since i18n now uses associative arrays. --- deploy/install.sh | 517 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 360 insertions(+), 157 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 217ac75c..a610ddbf 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +# Requires bash 4.0+ (associative arrays for i18n string tables). +if [ -z "${BASH_VERSINFO:-}" ] || [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + echo "ServerBee installer requires bash 4.0+ (found ${BASH_VERSION:-unknown})." >&2 + echo "On macOS: 'brew install bash' then run it with that bash." >&2 + exit 1 +fi + # ─── Constants ──────────────────────────────────────────────────────────────── REPO="ZingerLittleBee/ServerBee" # Everything ServerBee installs lives under a single base directory for @@ -99,73 +106,267 @@ select_language() { esac } +# i18n string tables. Add new user-facing strings here in both languages. +# Parametrized strings use printf format specifiers (%s) — render via trp(). +declare -A I18N_EN=( + [manager_title]="ServerBee Manager" + [install_menu]=" [1] Install 安装" + [uninstall_menu]=" [2] Uninstall 卸载" + [upgrade_menu]=" [3] Upgrade 升级" + [status_menu]=" [4] Status 查看状态" + [service_menu]=" [5] Service 服务控制 (start/stop/restart)" + [config_menu]=" [6] Config 配置管理" + [env_menu]=" [7] Env 环境变量" + [domain_menu]=" [8] Domain 域名 HTTPS" + [exit_menu]=" [0] Exit 退出" + [select_menu]="Select [0-8]: " + [install_title]="Install" + [server_option]=" [1] Server — Dashboard & API" + [agent_option]=" [2] Agent — System metrics collector" + [select_component]="Select component [1/2]: " + [server_docker_recommended]=" [1] Docker (recommended for Server)" + [agent_binary_recommended]=" [1] Binary (recommended for Agent)" + [binary_option]=" [2] Binary" + [docker_option]=" [2] Docker" + [select_method]="Select installation method [1/2]: " + [configure_domain]="Configure HTTPS domain with Caddy now? [y/N]: " + [domain_prompt]="Domain (e.g., monitor.example.com): " + [email_prompt]="Email for certificate notices (optional): " + [server_url_prompt]="Server URL (e.g., http://10.0.0.1:9527): " + [enrollment_prompt]="Enrollment code: " + [install_plan_title]="Installation plan" + [domain_plan_title]="Domain setup plan" + [will_add_download]="Will add or download:" + [start_install]="Start installation now? [y/N]: " + [start_domain]="Start domain setup now? [y/N]: " + [preflight]="Preflight checks:" + [svc_title]="Service control" + [svc_start]=" [1] Start" + [svc_stop]=" [2] Stop" + [svc_restart]=" [3] Restart" + [svc_select]="Select [1-3]: " + [uninstall_title]="Uninstall" + [opt_server]=" [1] Server" + [opt_agent]=" [2] Agent" + [uninstall_confirm]="Uninstall serverbee-%s (%s)%s? [y/N]: " + [uninstall_purge_note]=" (including config and data)" + [uninstall_preserved]=" Config preserved at:" + [uninstall_purge_hint]=" To remove all data: re-run with --purge" + [deps_install_confirm]=" Install them now? [y/N]: " + [docker_continue_confirm]=" Continue with Docker? [y/N]: " + [docker_agent_note]=" ServerBee Agent is portable software:" + [docker_agent_note1]=" - Single binary, no residual files" + [docker_agent_note2]=" - Docker requires --privileged for full metrics" + [docker_agent_note3]=" - Web terminal accesses container, not host" + [upgrade_confirm]="Proceed with upgrade? [y/N]: " + [restart_apply_q]=" Restart service to apply changes?" + [restart_apply_confirm]=" [y/N]: " + [plan_component]="Component:" + [plan_method]="Method:" + [plan_access]="Access:" + [plan_access_ip_val]="IP / direct port (:9527)" + [plan_access_domain_val]="domain" + [plan_server_url]="Server URL:" + [plan_cfg_file]=" - Config file:" + [plan_data_dir]=" - Data directory:" + [plan_compose_file]=" - Compose file:" + [plan_docker_volume]=" - Docker volume: serverbee-data" + [plan_systemd]=" - systemd service:" + [plan_pkgs]=" - System packages:" + [plan_pkgs_suffix]="(required script tools)" + [plan_gh_meta]=" - GitHub API: latest ServerBee release metadata" + [plan_binary_adopt_pre]=" - Binary: existing" + [plan_binary_adopt_suf]="will be adopted (no binary download)" + [plan_binary_dl]=" - Binary:" + [plan_cli_script]=" - CLI script:" + [plan_docker_prereq]=" - Prerequisite: Docker and Docker Compose V2 must already be installed" + [plan_docker_image]=" - Docker image:" + [domain_plan_header]="HTTPS domain setup:" + [dp_dns_pre]=" - DNS validation:" + [dp_dns_suf]="must resolve to this server" + [dp_repo]=" - Caddy repository: Cloudsmith apt repo on Debian/Ubuntu, or COPR on Fedora/CentOS" + [dp_key]=" - Caddy apt key:" + [dp_src]=" - Caddy apt source:" + [dp_pkgs]=" - System packages: Caddy and its repository dependencies when missing" + [dp_caddyfile]=" - Caddyfile:" + [dp_bind]=" - Server bind address: 127.0.0.1:9527" + [dp_cookie]=" - secure_cookie: true" + [dp_url]=" - Public URL:" + [domain_label]="Domain:" + [email_label]="Email: " + [result_server_ok]="ServerBee Server installed successfully!" + [result_agent_ok]="ServerBee Agent installed successfully!" + [lbl_dashboard]=" Dashboard:" + [lbl_username]=" Username:" + [lbl_password]=" Password:" + [pw_docker]="(auto-generated, check: docker compose -f %s logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_systemd]="(auto-generated, check: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_proc]="(auto-generated, check process output for 'FIRST-RUN ADMIN CREDENTIALS')" + [lbl_docs]=" Docs:" + [lbl_server_url]=" Server URL:" + [lbl_logs]=" Logs:" + [lbl_start]=" Start:" + [lbl_config]=" Config:" + [status_none]="No ServerBee components found. Run 'serverbee install' to get started." + [status_title]="ServerBee Status" + [st_version]=" Version:" + [st_binary]=" Binary:" + [st_config]=" Config:" + [st_service]=" Service:" + [st_active]="active (running)" + [st_since]="since" + [st_recent_logs]=" Recent logs:" + [st_no_logs]=" (no logs)" + [st_server]=" Server:" + [st_dashboard]=" Dashboard:" + [st_container]=" Container:" + [st_stopped]="stopped" + [st_image]=" Image:" + [st_port]=" Port:" + [st_unknown]="unknown" +) +declare -A I18N_ZH=( + [manager_title]="ServerBee 管理器" + [install_menu]=" [1] 安装 Install" + [uninstall_menu]=" [2] 卸载 Uninstall" + [upgrade_menu]=" [3] 升级 Upgrade" + [status_menu]=" [4] 状态 Status" + [service_menu]=" [5] 服务控制 Service (start/stop/restart)" + [config_menu]=" [6] 配置管理 Config" + [env_menu]=" [7] 环境变量 Env" + [domain_menu]=" [8] 域名 HTTPS Domain" + [exit_menu]=" [0] 退出 Exit" + [select_menu]="选择 [0-8]: " + [install_title]="安装" + [server_option]=" [1] Server — 控制台和 API" + [agent_option]=" [2] Agent — 系统指标采集器" + [select_component]="选择组件 [1/2]: " + [server_docker_recommended]=" [1] Docker (Server 推荐)" + [agent_binary_recommended]=" [1] Binary (Agent 推荐)" + [binary_option]=" [2] Binary" + [docker_option]=" [2] Docker" + [select_method]="选择安装方式 [1/2]: " + [configure_domain]="现在配置 HTTPS 域名(Caddy)吗?[y/N]: " + [domain_prompt]="域名(例如 monitor.example.com): " + [email_prompt]="证书通知邮箱(可选): " + [server_url_prompt]="Server URL(例如 http://10.0.0.1:9527): " + [enrollment_prompt]="Enrollment code(注册码): " + [install_plan_title]="安装计划" + [domain_plan_title]="域名配置计划" + [will_add_download]="将添加或下载:" + [start_install]="现在开始安装?[y/N]: " + [start_domain]="现在开始域名配置?[y/N]: " + [preflight]="安装前检查:" + [svc_title]="服务控制" + [svc_start]=" [1] 启动" + [svc_stop]=" [2] 停止" + [svc_restart]=" [3] 重启" + [svc_select]="选择 [1-3]: " + [uninstall_title]="卸载" + [opt_server]=" [1] Server" + [opt_agent]=" [2] Agent" + [uninstall_confirm]="卸载 serverbee-%s(%s)%s ? [y/N]: " + [uninstall_purge_note]="(含配置与数据)" + [uninstall_preserved]=" 配置已保留:" + [uninstall_purge_hint]=" 如需移除全部数据: 重新运行并加 --purge" + [deps_install_confirm]=" 现在安装它们?[y/N]: " + [docker_continue_confirm]=" 仍然继续使用 Docker?[y/N]: " + [docker_agent_note]=" ServerBee Agent 是便携软件:" + [docker_agent_note1]=" - 单一二进制,无残留文件" + [docker_agent_note2]=" - Docker 需 --privileged 才能采集完整指标" + [docker_agent_note3]=" - Web 终端访问的是容器而非宿主机" + [upgrade_confirm]="确认升级?[y/N]: " + [restart_apply_q]=" 重启服务以应用更改?" + [restart_apply_confirm]=" [y/N]: " + [plan_component]="组件:" + [plan_method]="方式:" + [plan_access]="访问:" + [plan_access_ip_val]="IP / 直连端口 (:9527)" + [plan_access_domain_val]="域名" + [plan_server_url]="Server URL:" + [plan_cfg_file]=" - 配置文件:" + [plan_data_dir]=" - 数据目录:" + [plan_compose_file]=" - Compose 文件:" + [plan_docker_volume]=" - Docker 卷: serverbee-data" + [plan_systemd]=" - systemd 服务:" + [plan_pkgs]=" - 系统软件包:" + [plan_pkgs_suffix]="(脚本所需工具)" + [plan_gh_meta]=" - GitHub API: 最新 ServerBee 发布元数据" + [plan_binary_adopt_pre]=" - 二进制: 已存在" + [plan_binary_adopt_suf]="将被沿用(不下载二进制)" + [plan_binary_dl]=" - 二进制:" + [plan_cli_script]=" - CLI 脚本:" + [plan_docker_prereq]=" - 前置条件: 需已安装 Docker 与 Docker Compose V2" + [plan_docker_image]=" - Docker 镜像:" + [domain_plan_header]="HTTPS 域名配置:" + [dp_dns_pre]=" - DNS 校验:" + [dp_dns_suf]="必须解析到本机" + [dp_repo]=" - Caddy 仓库: Debian/Ubuntu 用 Cloudsmith apt 源,Fedora/CentOS 用 COPR" + [dp_key]=" - Caddy apt key:" + [dp_src]=" - Caddy apt source:" + [dp_pkgs]=" - 系统软件包: 缺失时安装 Caddy 及其仓库依赖" + [dp_caddyfile]=" - Caddyfile:" + [dp_bind]=" - 服务监听地址: 127.0.0.1:9527" + [dp_cookie]=" - secure_cookie: true" + [dp_url]=" - 公网地址:" + [domain_label]="域名:" + [email_label]="邮箱: " + [result_server_ok]="ServerBee Server 安装成功!" + [result_agent_ok]="ServerBee Agent 安装成功!" + [lbl_dashboard]=" 控制台:" + [lbl_username]=" 用户名:" + [lbl_password]=" 密码:" + [pw_docker]="(自动生成,查看: docker compose -f %s logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_systemd]="(自动生成,查看: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_proc]="(自动生成,在进程输出中查找 'FIRST-RUN ADMIN CREDENTIALS')" + [lbl_docs]=" 文档:" + [lbl_server_url]=" Server URL:" + [lbl_logs]=" 日志:" + [lbl_start]=" 启动:" + [lbl_config]=" 配置:" + [status_none]="未找到任何 ServerBee 组件。运行 'serverbee install' 开始安装。" + [status_title]="ServerBee 状态" + [st_version]=" 版本:" + [st_binary]=" 二进制:" + [st_config]=" 配置:" + [st_service]=" 服务:" + [st_active]="运行中" + [st_since]="自" + [st_recent_logs]=" 最近日志:" + [st_no_logs]=" (无日志)" + [st_server]=" Server:" + [st_dashboard]=" 控制台:" + [st_container]=" 容器:" + [st_stopped]="已停止" + [st_image]=" 镜像:" + [st_port]=" 端口:" + [st_unknown]="未知" +) + tr_text() { - local key="$1" - case "${LANG_CODE:-en}:${key}" in - zh:manager_title) echo "ServerBee 管理器" ;; - en:manager_title) echo "ServerBee Manager" ;; - zh:install_menu) echo " [1] 安装 Install" ;; - en:install_menu) echo " [1] Install 安装" ;; - zh:uninstall_menu) echo " [2] 卸载 Uninstall" ;; - en:uninstall_menu) echo " [2] Uninstall 卸载" ;; - zh:upgrade_menu) echo " [3] 升级 Upgrade" ;; - en:upgrade_menu) echo " [3] Upgrade 升级" ;; - zh:status_menu) echo " [4] 状态 Status" ;; - en:status_menu) echo " [4] Status 查看状态" ;; - zh:service_menu) echo " [5] 服务控制 Service (start/stop/restart)" ;; - en:service_menu) echo " [5] Service 服务控制 (start/stop/restart)" ;; - zh:config_menu) echo " [6] 配置管理 Config" ;; - en:config_menu) echo " [6] Config 配置管理" ;; - zh:env_menu) echo " [7] 环境变量 Env" ;; - en:env_menu) echo " [7] Env 环境变量" ;; - zh:domain_menu) echo " [8] 域名 HTTPS Domain" ;; - en:domain_menu) echo " [8] Domain 域名 HTTPS" ;; - zh:exit_menu) echo " [0] 退出 Exit" ;; - en:exit_menu) echo " [0] Exit 退出" ;; - zh:select_menu) echo "选择 [0-8]: " ;; - en:select_menu) echo "Select [0-8]: " ;; - zh:install_title) echo "安装" ;; - en:install_title) echo "Install" ;; - zh:server_option) echo " [1] Server — 控制台和 API" ;; - en:server_option) echo " [1] Server — Dashboard & API" ;; - zh:agent_option) echo " [2] Agent — 系统指标采集器" ;; - en:agent_option) echo " [2] Agent — System metrics collector" ;; - zh:select_component) echo "选择组件 [1/2]: " ;; - en:select_component) echo "Select component [1/2]: " ;; - zh:server_docker_recommended) echo " [1] Docker (Server 推荐)" ;; - en:server_docker_recommended) echo " [1] Docker (recommended for Server)" ;; - zh:agent_binary_recommended) echo " [1] Binary (Agent 推荐)" ;; - en:agent_binary_recommended) echo " [1] Binary (recommended for Agent)" ;; - zh:binary_option) echo " [2] Binary" ;; - en:binary_option) echo " [2] Binary" ;; - zh:docker_option) echo " [2] Docker" ;; - en:docker_option) echo " [2] Docker" ;; - zh:select_method) echo "选择安装方式 [1/2]: " ;; - en:select_method) echo "Select installation method [1/2]: " ;; - zh:configure_domain) echo "现在配置 HTTPS 域名(Caddy)吗?[y/N]: " ;; - en:configure_domain) echo "Configure HTTPS domain with Caddy now? [y/N]: " ;; - zh:domain_prompt) echo "域名(例如 monitor.example.com): " ;; - en:domain_prompt) echo "Domain (e.g., monitor.example.com): " ;; - zh:email_prompt) echo "证书通知邮箱(可选): " ;; - en:email_prompt) echo "Email for certificate notices (optional): " ;; - zh:server_url_prompt) echo "Server URL(例如 http://10.0.0.1:9527): " ;; - en:server_url_prompt) echo "Server URL (e.g., http://10.0.0.1:9527): " ;; - zh:enrollment_prompt) echo "Enrollment code(注册码): " ;; - en:enrollment_prompt) echo "Enrollment code: " ;; - zh:install_plan_title) echo "安装计划" ;; - en:install_plan_title) echo "Installation plan" ;; - zh:domain_plan_title) echo "域名配置计划" ;; - en:domain_plan_title) echo "Domain setup plan" ;; - zh:will_add_download) echo "将添加或下载:" ;; - en:will_add_download) echo "Will add or download:" ;; - zh:start_install) echo "现在开始安装?[y/N]: " ;; - en:start_install) echo "Start installation now? [y/N]: " ;; - zh:start_domain) echo "现在开始域名配置?[y/N]: " ;; - en:start_domain) echo "Start domain setup now? [y/N]: " ;; - zh:preflight) echo "安装前检查:" ;; - en:preflight) echo "Preflight checks:" ;; - *) echo "$key" ;; - esac + local key="$1" val + if [ "${LANG_CODE:-en}" = "zh" ]; then + val="${I18N_ZH[$key]-}" + else + val="${I18N_EN[$key]-}" + fi + # Fall back to English, then to a visible marker so gaps are obvious. + [ -n "$val" ] || val="${I18N_EN[$key]-??${key}??}" + echo "$val" +} + +# Docs site language segment (apps/docs is bilingual: cn / en). +docs_lang() { + [ "${LANG_CODE:-en}" = "zh" ] && echo "cn" || echo "en" +} + +# Translate + printf for parametrized strings (no trailing newline added). +trp() { + local key="$1"; shift + local fmt + fmt="$(tr_text "$key")" + # shellcheck disable=SC2059 + printf "$fmt" "$@" } # ─── Dependency check ───────────────────────────────────────────────────────── @@ -203,7 +404,7 @@ check_deps() { install_deps "${missing[@]}" else warn "Missing required tools: ${missing[*]}" - read -rp " Install them now? [y/N]: " confirm + read -rp "$(tr_text deps_install_confirm)" confirm case "$confirm" in [yY]|[yY][eE][sS]) install_deps "${missing[@]}" ;; *) error "Cannot continue without: ${missing[*]}" ;; @@ -1046,38 +1247,38 @@ print_server_result() { local ip ip=$(get_local_ip) echo "" - echo -e "${GREEN}ServerBee Server installed successfully!${NC}" + echo -e "${GREEN}$(tr_text result_server_ok)${NC}" echo "" - echo " Dashboard: http://${ip}:9527" - echo " Username: admin" + echo "$(tr_text lbl_dashboard) http://${ip}:9527" + echo "$(tr_text lbl_username) admin" if [ "$METHOD" = "docker" ]; then - echo " Password: (auto-generated, check: docker compose -f ${DOCKER_DIR}/docker-compose.server.yml logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + echo "$(tr_text lbl_password) $(trp pw_docker "${DOCKER_DIR}/docker-compose.server.yml")" elif has_systemd; then - echo " Password: (auto-generated, check: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + echo "$(tr_text lbl_password) $(tr_text pw_systemd)" else - echo " Password: (auto-generated, check process output for 'FIRST-RUN ADMIN CREDENTIALS')" + echo "$(tr_text lbl_password) $(tr_text pw_proc)" fi echo "" - echo " Docs: ${DOCS_URL}/en/docs/configuration" + echo "$(tr_text lbl_docs) ${DOCS_URL}/$(docs_lang)/docs/configuration" echo "" } print_agent_result() { echo "" - echo -e "${GREEN}ServerBee Agent installed successfully!${NC}" + echo -e "${GREEN}$(tr_text result_agent_ok)${NC}" echo "" - echo " Server URL: ${SERVER_URL}" + echo "$(tr_text lbl_server_url) ${SERVER_URL}" if [ "$METHOD" = "docker" ]; then - echo " Logs: docker compose -f ${DOCKER_DIR}/docker-compose.agent.yml logs -f" + echo "$(tr_text lbl_logs) docker compose -f ${DOCKER_DIR}/docker-compose.agent.yml logs -f" elif has_systemd; then - echo " Start: sudo systemctl start serverbee-agent" - echo " Logs: sudo journalctl -u serverbee-agent -f" + echo "$(tr_text lbl_start) sudo systemctl start serverbee-agent" + echo "$(tr_text lbl_logs) sudo journalctl -u serverbee-agent -f" else - echo " Start: ${INSTALL_DIR}/serverbee-agent &" + echo "$(tr_text lbl_start) ${INSTALL_DIR}/serverbee-agent &" fi echo "" - echo " Config: ${CONFIG_DIR}/agent.toml" - echo " Docs: ${DOCS_URL}/en/docs/configuration" + echo "$(tr_text lbl_config) ${CONFIG_DIR}/agent.toml" + echo "$(tr_text lbl_docs) ${DOCS_URL}/$(docs_lang)/docs/configuration" echo "" } @@ -1305,7 +1506,7 @@ cmd_domain() { print_missing_deps_plan() { collect_missing_deps if [ ${#MISSING_DEPS[@]} -gt 0 ]; then - echo " - System packages: ${MISSING_DEPS[*]} (required script tools)" + echo "$(tr_text plan_pkgs) ${MISSING_DEPS[*]} $(tr_text plan_pkgs_suffix)" fi } @@ -1316,38 +1517,38 @@ print_common_binary_plan() { arch=$(detect_arch) filename="serverbee-${component}-${os}-${arch}" - echo " - GitHub API: latest ServerBee release metadata" + echo "$(tr_text plan_gh_meta)" if [ -f "${INSTALL_DIR}/serverbee-${component}" ]; then - echo " - Binary: existing ${INSTALL_DIR}/serverbee-${component} will be adopted (no binary download)" + echo "$(tr_text plan_binary_adopt_pre) ${INSTALL_DIR}/serverbee-${component} $(tr_text plan_binary_adopt_suf)" else - echo " - Binary: https://github.com/${REPO}/releases/download//${filename}" + echo "$(tr_text plan_binary_dl) https://github.com/${REPO}/releases/download//${filename}" fi - echo " - CLI script: https://raw.githubusercontent.com/${REPO}//deploy/install.sh" + echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}//deploy/install.sh" } print_common_docker_plan() { local component="$1" configure_docker_dir - echo " - Prerequisite: Docker and Docker Compose V2 must already be installed" - echo " - GitHub API: latest ServerBee release metadata" - echo " - Docker image: ghcr.io/zingerlittlebee/serverbee-${component}:" - echo " - CLI script: https://raw.githubusercontent.com/${REPO}//deploy/install.sh" + echo "$(tr_text plan_docker_prereq)" + echo "$(tr_text plan_gh_meta)" + echo "$(tr_text plan_docker_image) ghcr.io/zingerlittlebee/serverbee-${component}:" + echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}//deploy/install.sh" } print_domain_plan() { [ -z "$DOMAIN" ] && return echo "" - echo "HTTPS domain setup:" - echo " - DNS validation: ${DOMAIN} must resolve to this server" - echo " - Caddy repository: Cloudsmith apt repo on Debian/Ubuntu, or COPR on Fedora/CentOS" - echo " - Caddy apt key: https://dl.cloudsmith.io/public/caddy/stable/gpg.key" - echo " - Caddy apt source: https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" - echo " - System packages: Caddy and its repository dependencies when missing" - echo " - Caddyfile: ${CADDYFILE}" - echo " - Server bind address: 127.0.0.1:9527" - echo " - secure_cookie: true" - echo " - Public URL: https://${DOMAIN}" + echo "$(tr_text domain_plan_header)" + echo "$(tr_text dp_dns_pre) ${DOMAIN} $(tr_text dp_dns_suf)" + echo "$(tr_text dp_repo)" + echo "$(tr_text dp_key) https://dl.cloudsmith.io/public/caddy/stable/gpg.key" + echo "$(tr_text dp_src) https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" + echo "$(tr_text dp_pkgs)" + echo "$(tr_text dp_caddyfile) ${CADDYFILE}" + echo "$(tr_text dp_bind)" + echo "$(tr_text dp_cookie)" + echo "$(tr_text dp_url) https://${DOMAIN}" } run_domain_preflight_checks() { @@ -1365,8 +1566,8 @@ confirm_domain_setup_plan() { echo "" echo -e "${BOLD}$(tr_text domain_plan_title)${NC}" echo "" - echo "Domain: ${DOMAIN}" - [ -n "$EMAIL" ] && echo "Email: ${EMAIL}" + echo "$(tr_text domain_label) ${DOMAIN}" + [ -n "$EMAIL" ] && echo "$(tr_text email_label) ${EMAIL}" echo "" echo "$(tr_text will_add_download)" print_missing_deps_plan @@ -1395,17 +1596,17 @@ print_install_plan() { echo "" echo -e "${BOLD}$(tr_text install_plan_title)${NC}" echo "" - echo "Component: serverbee-${COMPONENT}" - echo "Method: ${METHOD}" + echo "$(tr_text plan_component) serverbee-${COMPONENT}" + echo "$(tr_text plan_method) ${METHOD}" if [ "$COMPONENT" = "server" ]; then if [ -n "$DOMAIN" ]; then - echo "Access: domain (${DOMAIN})" + echo "$(tr_text plan_access) $(tr_text plan_access_domain_val) (${DOMAIN})" else - echo "Access: IP / direct port (:9527)" + echo "$(tr_text plan_access) $(tr_text plan_access_ip_val)" fi else - echo "Server URL: ${SERVER_URL}" + echo "$(tr_text plan_server_url) ${SERVER_URL}" fi echo "" @@ -1414,25 +1615,25 @@ print_install_plan() { case "${COMPONENT}-${METHOD}" in server-binary) print_common_binary_plan "server" - echo " - Config file: ${CONFIG_DIR}/server.toml" - echo " - Data directory: ${DATA_DIR}" - if has_systemd; then echo " - systemd service: serverbee-server"; fi + echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/server.toml" + echo "$(tr_text plan_data_dir) ${DATA_DIR}" + if has_systemd; then echo "$(tr_text plan_systemd) serverbee-server"; fi ;; agent-binary) print_common_binary_plan "agent" - echo " - Config file: ${CONFIG_DIR}/agent.toml" - if has_systemd; then echo " - systemd service: serverbee-agent"; fi + echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/agent.toml" + if has_systemd; then echo "$(tr_text plan_systemd) serverbee-agent"; fi ;; server-docker) print_common_docker_plan "server" - echo " - Config file: ${CONFIG_DIR}/server.toml" - echo " - Compose file: ${DOCKER_DIR}/docker-compose.server.yml" - echo " - Docker volume: serverbee-data" + echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/server.toml" + echo "$(tr_text plan_compose_file) ${DOCKER_DIR}/docker-compose.server.yml" + echo "$(tr_text plan_docker_volume)" ;; agent-docker) print_common_docker_plan "agent" - echo " - Config file: ${CONFIG_DIR}/agent.toml" - echo " - Compose file: ${DOCKER_DIR}/docker-compose.agent.yml" + echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/agent.toml" + echo "$(tr_text plan_compose_file) ${DOCKER_DIR}/docker-compose.agent.yml" ;; esac print_domain_plan @@ -1529,12 +1730,12 @@ cmd_install() { echo "" warn "Docker is NOT recommended for Agent" echo "" - echo " ServerBee Agent is portable software:" - echo " - Single binary, no residual files" - echo " - Docker requires --privileged for full metrics" - echo " - Web terminal accesses container, not host" + echo "$(tr_text docker_agent_note)" + echo "$(tr_text docker_agent_note1)" + echo "$(tr_text docker_agent_note2)" + echo "$(tr_text docker_agent_note3)" echo "" - read -rp " Continue with Docker? [y/N]: " confirm + read -rp "$(tr_text docker_continue_confirm)" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) METHOD="binary"; info "Switched to binary installation." ;; @@ -1638,12 +1839,12 @@ cmd_uninstall() { # Component is required for uninstall if [ -z "$COMPONENT" ]; then echo "" - echo -e "${BOLD}Uninstall${NC}" + echo -e "${BOLD}$(tr_text uninstall_title)${NC}" echo "" - echo " [1] Server" - echo " [2] Agent" + echo "$(tr_text opt_server)" + echo "$(tr_text opt_agent)" echo "" - read -rp "Select component [1/2]: " choice + read -rp "$(tr_text select_component)" choice case "$choice" in 1|server) COMPONENT="server" ;; 2|agent) COMPONENT="agent" ;; @@ -1664,9 +1865,9 @@ cmd_uninstall() { if [ "$YES" != true ]; then local purge_note="" if [ "$PURGE" = true ]; then - purge_note=" (including config and data)" + purge_note="$(tr_text uninstall_purge_note)" fi - read -rp "Uninstall serverbee-${COMPONENT} (${method})${purge_note}? [y/N]: " confirm + read -rp "$(trp uninstall_confirm "$COMPONENT" "$method" "$purge_note")" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) info "Cancelled."; exit 0 ;; @@ -1700,8 +1901,8 @@ cmd_uninstall() { if [ "$PURGE" != true ]; then echo "" - echo " Config preserved at: ${CONFIG_DIR}/${COMPONENT}.toml" - echo " To remove all data: re-run with --purge" + echo "$(tr_text uninstall_preserved) ${CONFIG_DIR}/${COMPONENT}.toml" + echo "$(tr_text uninstall_purge_hint)" echo "" fi } @@ -1729,7 +1930,7 @@ upgrade_component() { # Confirmation if [ "$YES" != true ]; then - read -rp "Proceed with upgrade? [y/N]: " confirm + read -rp "$(tr_text upgrade_confirm)" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) info "Skipped."; return ;; @@ -1825,9 +2026,9 @@ status_component() { echo -e "${BOLD}${component^} (${method})${NC}" if [ "$method" = "binary" ]; then - echo " Version: ${version:-unknown}" - echo " Binary: ${INSTALL_DIR}/${service}" - echo " Config: ${CONFIG_DIR}/${component}.toml" + echo "$(tr_text st_version) ${version:-$(tr_text st_unknown)}" + echo "$(tr_text st_binary) ${INSTALL_DIR}/${service}" + echo "$(tr_text st_config) ${CONFIG_DIR}/${component}.toml" if has_systemd; then local status_line @@ -1835,55 +2036,55 @@ status_component() { if [ "$status_line" = "active" ]; then local since since=$(systemctl show "$service" --property=ActiveEnterTimestamp --value 2>/dev/null || echo "") - echo -e " Service: ${GREEN}active (running)${NC} since ${since}" + echo -e "$(tr_text st_service) ${GREEN}$(tr_text st_active)${NC} $(tr_text st_since) ${since}" else - echo -e " Service: ${RED}${status_line}${NC}" + echo -e "$(tr_text st_service) ${RED}${status_line}${NC}" fi - echo " Recent logs:" - journalctl -u "$service" -n 5 --no-pager 2>/dev/null | sed 's/^/ /' || echo " (no logs)" + echo "$(tr_text st_recent_logs)" + journalctl -u "$service" -n 5 --no-pager 2>/dev/null | sed 's/^/ /' || echo "$(tr_text st_no_logs)" fi # Show server_url for agent if [ "$component" = "agent" ] && [ -f "${CONFIG_DIR}/agent.toml" ]; then local srv srv=$(grep "^server_url" "${CONFIG_DIR}/agent.toml" 2>/dev/null | sed 's/.*= *"//;s/".*//' || echo "") - [ -n "$srv" ] && echo " Server: ${srv}" + [ -n "$srv" ] && echo "$(tr_text st_server) ${srv}" fi # Show dashboard URL for server if [ "$component" = "server" ]; then local ip ip=$(get_local_ip) - echo " Dashboard: http://${ip}:9527" + echo "$(tr_text st_dashboard) http://${ip}:9527" fi elif [ "$method" = "docker" ]; then local compose_file="${DOCKER_DIR}/docker-compose.${component}.yml" - echo " Version: ${version:-unknown}" + echo "$(tr_text st_version) ${version:-$(tr_text st_unknown)}" if docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -q "^${service} "; then local container_status container_status=$(docker ps --format '{{.Status}}' --filter "name=^${service}$" 2>/dev/null) - echo -e " Container: ${service} (${GREEN}${container_status}${NC})" + echo -e "$(tr_text st_container) ${service} (${GREEN}${container_status}${NC})" else - echo -e " Container: ${service} (${RED}stopped${NC})" + echo -e "$(tr_text st_container) ${service} (${RED}$(tr_text st_stopped)${NC})" fi local image_tag image_tag=$(docker inspect "${service}" --format '{{.Config.Image}}' 2>/dev/null || echo "unknown") - echo " Image: ${image_tag}" + echo "$(tr_text st_image) ${image_tag}" if [ "$component" = "server" ]; then local ports ports=$(docker port "${service}" 2>/dev/null | head -1 || echo "") - [ -n "$ports" ] && echo " Port: ${ports}" + [ -n "$ports" ] && echo "$(tr_text st_port) ${ports}" local ip ip=$(get_local_ip) - echo " Dashboard: http://${ip}:9527" + echo "$(tr_text st_dashboard) http://${ip}:9527" fi - echo " Recent logs:" - docker logs "${service}" --tail 5 2>/dev/null | sed 's/^/ /' || echo " (no logs)" + echo "$(tr_text st_recent_logs)" + docker logs "${service}" --tail 5 2>/dev/null | sed 's/^/ /' || echo "$(tr_text st_no_logs)" fi } @@ -1893,13 +2094,13 @@ cmd_status() { if [ ${#MANAGED_COMPONENTS[@]} -eq 0 ] && [ ${#UNMANAGED_COMPONENTS[@]} -eq 0 ]; then echo "" - echo "No ServerBee components found. Run 'serverbee install' to get started." + echo "$(tr_text status_none)" echo "" return fi echo "" - echo -e "${BOLD}ServerBee Status${NC}" + echo -e "${BOLD}$(tr_text status_title)${NC}" echo "================" for entry in "${MANAGED_COMPONENTS[@]}"; do @@ -2151,8 +2352,8 @@ cmd_config() { else if [ -t 0 ]; then echo "" - echo " Restart service to apply changes?" - read -rp " [y/N]: " confirm + echo "$(tr_text restart_apply_q)" + read -rp "$(tr_text restart_apply_confirm)" confirm if [[ "$confirm" =~ ^[yY] ]]; then for entry in "${MANAGED_COMPONENTS[@]}"; do local comp="${entry%%:*}" @@ -2388,11 +2589,13 @@ interactive_menu() { interactive_service_menu() { echo "" - echo " [1] Start 启动" - echo " [2] Stop 停止" - echo " [3] Restart 重启" + echo -e "${BOLD}$(tr_text svc_title)${NC}" + echo "" + echo "$(tr_text svc_start)" + echo "$(tr_text svc_stop)" + echo "$(tr_text svc_restart)" echo "" - read -rp "Select [1-3]: " choice + read -rp "$(tr_text svc_select)" choice case "$choice" in 1) COMMAND="start" ;; 2) COMMAND="stop" ;; From ad9dca09887a558f3fd9f1e5d7439652708e0c01 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 20:57:30 +0800 Subject: [PATCH 17/43] fix(deploy): show resolved version in install plan instead of Resolve the latest release tag and display it in the binary URL, CLI script URL and docker image tag of the install/domain plan. Memoize get_latest_version so the plan and the subsequent install share a single GitHub API lookup. --- deploy/install.sh | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index a610ddbf..ff4b24a0 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -582,13 +582,19 @@ detect_arch() { esac } +RESOLVED_VERSION="" get_latest_version() { + if [ -n "$RESOLVED_VERSION" ]; then + echo "$RESOLVED_VERSION" + return + fi local tag tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ | grep '"tag_name"' \ | head -1 \ | sed 's/.*"tag_name": *"//;s/".*//') [ -z "$tag" ] && error "Failed to get latest version from GitHub" + RESOLVED_VERSION="$tag" echo "$tag" } @@ -1512,27 +1518,29 @@ print_missing_deps_plan() { print_common_binary_plan() { local component="$1" - local os arch filename + local os arch filename version os=$(detect_os) arch=$(detect_arch) filename="serverbee-${component}-${os}-${arch}" + version=$(get_latest_version) echo "$(tr_text plan_gh_meta)" if [ -f "${INSTALL_DIR}/serverbee-${component}" ]; then echo "$(tr_text plan_binary_adopt_pre) ${INSTALL_DIR}/serverbee-${component} $(tr_text plan_binary_adopt_suf)" else - echo "$(tr_text plan_binary_dl) https://github.com/${REPO}/releases/download//${filename}" + echo "$(tr_text plan_binary_dl) https://github.com/${REPO}/releases/download/${version}/${filename}" fi - echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}//deploy/install.sh" + echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" } print_common_docker_plan() { - local component="$1" + local component="$1" version configure_docker_dir + version=$(get_latest_version) echo "$(tr_text plan_docker_prereq)" echo "$(tr_text plan_gh_meta)" - echo "$(tr_text plan_docker_image) ghcr.io/zingerlittlebee/serverbee-${component}:" - echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}//deploy/install.sh" + echo "$(tr_text plan_docker_image) ghcr.io/zingerlittlebee/serverbee-${component}:$(docker_image_tag "$version")" + echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" } print_domain_plan() { From 72c1a220bfdc9b60cfb4a708c475f804a3a4b206 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 21:11:54 +0800 Subject: [PATCH 18/43] fix(deploy): point server workdir at config dir so server.toml loads The consolidated /opt layout moved server.toml to /opt/serverbee/etc, but the server binary only reads /etc/serverbee/server.toml, ./server.toml or SERVERBEE_* env. With WorkingDirectory=/opt/serverbee/data the config file was never read, so secure_cookie fell back to its default (true). Over plain HTTP the browser then dropped the Secure session cookie, making the forced onboarding request unauthenticated (401). Set the server unit (and legacy-migrated units) WorkingDirectory to the config dir so the relative server.toml resolves; data dir still comes from the absolute SERVERBEE_SERVER__DATA_DIR env. --- deploy/install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index ff4b24a0..a5b907c8 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -505,7 +505,7 @@ migrate_legacy_layout() { [ -f "$unit" ] || continue sed -i \ -e "s#${LEGACY_BIN_DIR}/serverbee-${comp}#${INSTALL_DIR}/serverbee-${comp}#g" \ - -e "s#WorkingDirectory=${LEGACY_DATA_DIR}#WorkingDirectory=${DATA_DIR}#g" \ + -e "s#WorkingDirectory=${LEGACY_DATA_DIR}#WorkingDirectory=${CONFIG_DIR}#g" \ -e "s#WorkingDirectory=${LEGACY_CONFIG_DIR}#WorkingDirectory=${CONFIG_DIR}#g" \ -e "s#SERVERBEE_SERVER__DATA_DIR=${LEGACY_DATA_DIR}#SERVERBEE_SERVER__DATA_DIR=${DATA_DIR}#g" \ "$unit" 2>/dev/null || true @@ -1051,7 +1051,7 @@ After=network.target [Service] Type=simple ExecStart=${INSTALL_DIR}/serverbee-server -WorkingDirectory=${DATA_DIR} +WorkingDirectory=${CONFIG_DIR} Environment=SERVERBEE_SERVER__DATA_DIR=${DATA_DIR} Restart=always RestartSec=5 From 9039fba2786fd1207c2119697d26aec3076ba782 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 21:11:54 +0800 Subject: [PATCH 19/43] fix(server): also load config from /opt/serverbee/etc/server.toml Add the consolidated install layout's config path to the Figment chain so future binaries find the config without depending on the process working directory. --- crates/server/src/config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 8191666d..e647977a 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -429,6 +429,7 @@ impl AppConfig { pub fn load() -> anyhow::Result { let config: AppConfig = Figment::new() .merge(Toml::file("/etc/serverbee/server.toml")) + .merge(Toml::file("/opt/serverbee/etc/server.toml")) .merge(Toml::file("server.toml")) .merge(Env::prefixed("SERVERBEE_").split("__")) .extract()?; From 1441270d9fef9483400fd882ffb167728619a2d3 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 21:27:17 +0800 Subject: [PATCH 20/43] fix(deploy): place docker-mode config in a snap-accessible directory The snap-confined Docker daemon cannot bind-mount paths under /opt (its rootfs is read-only), so after the /opt layout consolidation the docker agent's config bind mount (/opt/serverbee/etc) failed and the container never started. Add a snap-aware docker config dir (/var/snap/docker/common/serverbee/etc under snap, CONFIG_DIR otherwise) and use it for docker server.toml/agent.toml, the agent bind mount, config/env/uninstall and plan/result output. Binary mode and non-snap Docker are unchanged. --- deploy/install.sh | 74 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index a5b907c8..b14e7e5c 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -440,6 +440,31 @@ configure_docker_dir() { fi } +# Config directory for docker-mode components. The snap-confined Docker +# daemon cannot bind-mount paths under /opt or /etc (its rootfs is +# read-only), so config that must be visible inside a container has to +# live under the snap-accessible tree. For non-snap Docker this is the +# normal CONFIG_DIR, so binary mode and non-snap Docker are unchanged. +docker_conf_dir() { + if docker_is_snap; then + echo "${SNAP_DOCKER_DIR}/etc" + else + echo "${CONFIG_DIR}" + fi +} + +# Resolve a component's config file based on how it was installed. +# Docker-managed components may live under the snap-accessible tree. +conf_file_for() { + local comp="$1" method + method=$(meta_read "$comp" "method" 2>/dev/null || echo "") + if [ "$method" = "docker" ]; then + echo "$(docker_conf_dir)/${comp}.toml" + else + echo "${CONFIG_DIR}/${comp}.toml" + fi +} + # ─── Root check ─────────────────────────────────────────────────────────────── require_root() { if [ "$(id -u)" -ne 0 ]; then @@ -1151,17 +1176,19 @@ install_docker_server() { version=$(get_latest_version) image_tag=$(docker_image_tag "$version") - mkdir -p "$DOCKER_DIR" "$CONFIG_DIR" + local conf_dir + conf_dir="$(docker_conf_dir)" + mkdir -p "$DOCKER_DIR" "$conf_dir" # Generate server.toml (skip if exists) - if [ ! -f "${CONFIG_DIR}/server.toml" ]; then - cat > "${CONFIG_DIR}/server.toml" << TOML + if [ ! -f "${conf_dir}/server.toml" ]; then + cat > "${conf_dir}/server.toml" << TOML [server] data_dir = "/data" TOML - info "Created ${CONFIG_DIR}/server.toml" + info "Created ${conf_dir}/server.toml" else - warn "${CONFIG_DIR}/server.toml already exists, not overwriting" + warn "${conf_dir}/server.toml already exists, not overwriting" fi cat > "${DOCKER_DIR}/docker-compose.server.yml" << YAML @@ -1205,11 +1232,13 @@ install_docker_agent() { version=$(get_latest_version) image_tag=$(docker_image_tag "$version") - mkdir -p "$CONFIG_DIR" + local conf_dir + conf_dir="$(docker_conf_dir)" + mkdir -p "$conf_dir" # Generate agent.toml (skip if exists) - if [ ! -f "${CONFIG_DIR}/agent.toml" ]; then - cat > "${CONFIG_DIR}/agent.toml" << TOML + if [ ! -f "${conf_dir}/agent.toml" ]; then + cat > "${conf_dir}/agent.toml" << TOML server_url = "${SERVER_URL}" enrollment_code = "${ENROLLMENT_CODE}" @@ -1217,9 +1246,9 @@ enrollment_code = "${ENROLLMENT_CODE}" interval = 3 enable_temperature = true TOML - info "Created ${CONFIG_DIR}/agent.toml" + info "Created ${conf_dir}/agent.toml" else - warn "${CONFIG_DIR}/agent.toml already exists, not overwriting" + warn "${conf_dir}/agent.toml already exists, not overwriting" fi mkdir -p "$DOCKER_DIR" @@ -1236,7 +1265,7 @@ services: - /proc:/host/proc:ro - /sys:/host/sys:ro - /etc/machine-id:/etc/machine-id:ro - - ${CONFIG_DIR}:/etc/serverbee + - ${conf_dir}:/etc/serverbee restart: unless-stopped YAML @@ -1283,7 +1312,11 @@ print_agent_result() { echo "$(tr_text lbl_start) ${INSTALL_DIR}/serverbee-agent &" fi echo "" - echo "$(tr_text lbl_config) ${CONFIG_DIR}/agent.toml" + if [ "$METHOD" = "docker" ]; then + echo "$(tr_text lbl_config) $(docker_conf_dir)/agent.toml" + else + echo "$(tr_text lbl_config) ${CONFIG_DIR}/agent.toml" + fi echo "$(tr_text lbl_docs) ${DOCS_URL}/$(docs_lang)/docs/configuration" echo "" } @@ -1634,13 +1667,13 @@ print_install_plan() { ;; server-docker) print_common_docker_plan "server" - echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/server.toml" + echo "$(tr_text plan_cfg_file) $(docker_conf_dir)/server.toml" echo "$(tr_text plan_compose_file) ${DOCKER_DIR}/docker-compose.server.yml" echo "$(tr_text plan_docker_volume)" ;; agent-docker) print_common_docker_plan "agent" - echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/agent.toml" + echo "$(tr_text plan_cfg_file) $(docker_conf_dir)/agent.toml" echo "$(tr_text plan_compose_file) ${DOCKER_DIR}/docker-compose.agent.yml" ;; esac @@ -1838,7 +1871,7 @@ uninstall_docker() { done fi rm -f "$compose_file" - rm -f "${CONFIG_DIR}/${component}.toml" + rm -f "$(docker_conf_dir)/${component}.toml" info "Config, data, images, and volumes purged" fi } @@ -2325,13 +2358,13 @@ cmd_config() { local files_to_update=() if [ "$target" = "both" ]; then - meta_has "agent" && files_to_update+=("${CONFIG_DIR}/agent.toml") - meta_has "server" && files_to_update+=("${CONFIG_DIR}/server.toml") + meta_has "agent" && files_to_update+=("$(conf_file_for agent)") + meta_has "server" && files_to_update+=("$(conf_file_for server)") [ ${#files_to_update[@]} -eq 0 ] && error "No managed components found to update log config" elif [ "$target" = "agent" ]; then - files_to_update=("${CONFIG_DIR}/agent.toml") + files_to_update=("$(conf_file_for agent)") elif [ "$target" = "server" ]; then - files_to_update=("${CONFIG_DIR}/server.toml") + files_to_update=("$(conf_file_for server)") fi for file in "${files_to_update[@]}"; do @@ -2392,7 +2425,8 @@ cmd_config() { [ ${#targets[@]} -eq 0 ] && error "No managed components found." for comp in "${targets[@]}"; do - local file="${CONFIG_DIR}/${comp}.toml" + local file + file="$(conf_file_for "$comp")" echo "" echo -e "${BOLD}${comp^} config (${file})${NC}" echo "─────────────────────────────────" From 7fd8997cdc0d45ac2a4bb87c0c51419d0577266e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 21:40:29 +0800 Subject: [PATCH 21/43] fix(deploy): install the running script as the management CLI install_cli always downloaded the release-tagged install.sh, so an installer carrying unreleased layout changes installed a mismatched CLI: serverbee status/config/env/uninstall then reported "no managed components" because the old-layout CLI looked under the legacy paths. Copy the running script when invoked as a file; fall back to the release download only when piped via stdin (curl | bash). --- deploy/install.sh | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index b14e7e5c..e7d6b145 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -8,6 +8,20 @@ if [ -z "${BASH_VERSINFO:-}" ] || [ "${BASH_VERSINFO[0]}" -lt 4 ]; then exit 1 fi +# Absolute path to this script when run as a regular file; empty when the +# script was piped via stdin (curl | bash). Used so the installed +# management CLI matches the installer that created the layout, instead +# of an out-of-sync released copy. +SELF_SCRIPT="" +case "${BASH_SOURCE[0]:-}" in + "" | bash | sh | -bash | -sh) ;; + *) + if [ -r "${BASH_SOURCE[0]}" ]; then + SELF_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/$(basename "${BASH_SOURCE[0]}")" + fi + ;; +esac + # ─── Constants ──────────────────────────────────────────────────────────────── REPO="ZingerLittleBee/ServerBee" # Everything ServerBee installs lives under a single base directory for @@ -1011,10 +1025,15 @@ install_cli() { tmp=$(mktemp "${target_dir}/.serverbee-cli.XXXXXX") trap 'rm -f "$tmp"' EXIT - # Always download from the release tag to avoid version skew - # between binaries (latest release) and CLI (possibly stale checkout) - local url="https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" - curl -fsSL -o "$tmp" "$url" + # Prefer the running script so the CLI always matches the layout + # it just created. Fall back to the released copy only when piped + # via stdin (curl | bash), where no script file is available. + if [ -n "$SELF_SCRIPT" ] && [ -r "$SELF_SCRIPT" ]; then + cp "$SELF_SCRIPT" "$tmp" + else + local url="https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" + curl -fsSL -o "$tmp" "$url" + fi chmod +x "$tmp" mv "$tmp" "$target" From f0471d45a20f923031a1b35acf1ae2b360ed0652 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 21:50:09 +0800 Subject: [PATCH 22/43] feat(deploy): show first-run admin password in install result Instead of telling the user to run a command to dig the one-time password out of the logs, poll the server's startup logs (systemd journal or docker compose logs) after start and print the password directly, with a note that it must be changed on first login. Falls back to the previous "check the logs" hint when no password is found (re-install/adopt, or no captured log source). --- deploy/install.sh | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index e7d6b145..b7d1eabc 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -216,6 +216,7 @@ declare -A I18N_EN=( [pw_docker]="(auto-generated, check: docker compose -f %s logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" [pw_systemd]="(auto-generated, check: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" [pw_proc]="(auto-generated, check process output for 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_must_change]=" (one-time password — you must change it on first login)" [lbl_docs]=" Docs:" [lbl_server_url]=" Server URL:" [lbl_logs]=" Logs:" @@ -333,6 +334,7 @@ declare -A I18N_ZH=( [pw_docker]="(自动生成,查看: docker compose -f %s logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" [pw_systemd]="(自动生成,查看: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" [pw_proc]="(自动生成,在进程输出中查找 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_must_change]=" (一次性密码 —— 首次登录后必须修改)" [lbl_docs]=" 文档:" [lbl_server_url]=" Server URL:" [lbl_logs]=" 日志:" @@ -1297,15 +1299,47 @@ YAML print_agent_result } +# Poll the server's startup logs for the one-time first-run admin password. +# Echoes the password if found within the timeout, otherwise nothing (e.g. +# re-install/adopt where the admin already exists, or no captured logs). +fetch_first_run_password() { + local i out pw + for ((i = 0; i < 15; i++)); do + if [ "$METHOD" = "docker" ]; then + out=$(docker compose -f "${DOCKER_DIR}/docker-compose.server.yml" logs --no-color serverbee-server 2>/dev/null) + elif has_systemd; then + out=$(journalctl -u serverbee-server --no-pager 2>/dev/null) + else + return 0 + fi + pw=$(printf '%s\n' "$out" \ + | sed 's/\x1b\[[0-9;]*m//g' \ + | grep -A8 'FIRST-RUN ADMIN CREDENTIALS' \ + | grep -m1 'Password:' \ + | sed -E 's/.*Password:[[:space:]]*//' \ + | awk '{print $1}') + if [ -n "$pw" ]; then + printf '%s' "$pw" + return 0 + fi + sleep 1 + done + return 0 +} + print_server_result() { - local ip + local ip pw ip=$(get_local_ip) + pw="$(fetch_first_run_password)" echo "" echo -e "${GREEN}$(tr_text result_server_ok)${NC}" echo "" echo "$(tr_text lbl_dashboard) http://${ip}:9527" echo "$(tr_text lbl_username) admin" - if [ "$METHOD" = "docker" ]; then + if [ -n "$pw" ]; then + echo -e "$(tr_text lbl_password) ${BOLD}${pw}${NC}" + echo "$(tr_text pw_must_change)" + elif [ "$METHOD" = "docker" ]; then echo "$(tr_text lbl_password) $(trp pw_docker "${DOCKER_DIR}/docker-compose.server.yml")" elif has_systemd; then echo "$(tr_text lbl_password) $(tr_text pw_systemd)" From c120cb2e39ddae77d24b804371db6421cee1dc02 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 21:53:30 +0800 Subject: [PATCH 23/43] fix(deploy): print explicit rm commands instead of --purge hint on uninstall The CLI is removed during uninstall and the script is often piped via stdin, so "re-run with --purge" was unactionable. Print the concrete rm/docker volume commands for the preserved config and data instead. --- deploy/install.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index b7d1eabc..d9d99dde 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -164,8 +164,7 @@ declare -A I18N_EN=( [opt_agent]=" [2] Agent" [uninstall_confirm]="Uninstall serverbee-%s (%s)%s? [y/N]: " [uninstall_purge_note]=" (including config and data)" - [uninstall_preserved]=" Config preserved at:" - [uninstall_purge_hint]=" To remove all data: re-run with --purge" + [uninstall_preserved]=" Config and data preserved. To remove them, run:" [deps_install_confirm]=" Install them now? [y/N]: " [docker_continue_confirm]=" Continue with Docker? [y/N]: " [docker_agent_note]=" ServerBee Agent is portable software:" @@ -282,8 +281,7 @@ declare -A I18N_ZH=( [opt_agent]=" [2] Agent" [uninstall_confirm]="卸载 serverbee-%s(%s)%s ? [y/N]: " [uninstall_purge_note]="(含配置与数据)" - [uninstall_preserved]=" 配置已保留:" - [uninstall_purge_hint]=" 如需移除全部数据: 重新运行并加 --purge" + [uninstall_preserved]=" 配置与数据已保留,如需移除请执行:" [deps_install_confirm]=" 现在安装它们?[y/N]: " [docker_continue_confirm]=" 仍然继续使用 Docker?[y/N]: " [docker_agent_note]=" ServerBee Agent 是便携软件:" @@ -1995,8 +1993,22 @@ cmd_uninstall() { if [ "$PURGE" != true ]; then echo "" - echo "$(tr_text uninstall_preserved) ${CONFIG_DIR}/${COMPONENT}.toml" - echo "$(tr_text uninstall_purge_hint)" + echo "$(tr_text uninstall_preserved)" + echo "" + if [ "$method" = "docker" ]; then + local conf_dir + conf_dir="$(docker_conf_dir)" + echo " rm -f ${DOCKER_DIR}/docker-compose.${COMPONENT}.yml" + echo " rm -f ${conf_dir}/${COMPONENT}.toml" + if [ "$COMPONENT" = "server" ]; then + echo " docker volume rm serverbee_serverbee-data" + fi + else + echo " rm -f ${CONFIG_DIR}/${COMPONENT}.toml" + if [ "$COMPONENT" = "server" ]; then + echo " rm -rf ${DATA_DIR}" + fi + fi echo "" fi } From 8730a4f738249930548d6f14fb85a0549153f1d9 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 21:56:38 +0800 Subject: [PATCH 24/43] feat(deploy): default agent server url to detected local ip The Server URL prompt was empty with only a static example. Pre-fill it with http://:9527 so single-machine and same-network setups can accept the default by pressing Enter. --- deploy/install.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index d9d99dde..09dc3c1b 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -146,7 +146,7 @@ declare -A I18N_EN=( [configure_domain]="Configure HTTPS domain with Caddy now? [y/N]: " [domain_prompt]="Domain (e.g., monitor.example.com): " [email_prompt]="Email for certificate notices (optional): " - [server_url_prompt]="Server URL (e.g., http://10.0.0.1:9527): " + [server_url_prompt]="Server URL [%s]: " [enrollment_prompt]="Enrollment code: " [install_plan_title]="Installation plan" [domain_plan_title]="Domain setup plan" @@ -263,7 +263,7 @@ declare -A I18N_ZH=( [configure_domain]="现在配置 HTTPS 域名(Caddy)吗?[y/N]: " [domain_prompt]="域名(例如 monitor.example.com): " [email_prompt]="证书通知邮箱(可选): " - [server_url_prompt]="Server URL(例如 http://10.0.0.1:9527): " + [server_url_prompt]="Server URL [%s]: " [enrollment_prompt]="Enrollment code(注册码): " [install_plan_title]="安装计划" [domain_plan_title]="域名配置计划" @@ -1845,9 +1845,15 @@ cmd_install() { fi fi elif [ "$COMPONENT" = "agent" ]; then + if [ -z "$SERVER_URL" ] && [ "$YES" != true ]; then + local default_server_url + default_server_url="http://$(get_local_ip):9527" + read -rp "$(trp server_url_prompt "$default_server_url")" SERVER_URL + SERVER_URL="${SERVER_URL:-$default_server_url}" + fi while [ -z "$SERVER_URL" ]; do if [ "$YES" = true ]; then error "--server-url is required for agent installation"; fi - read -rp "$(tr_text server_url_prompt)" SERVER_URL + read -rp "$(trp server_url_prompt "http://$(get_local_ip):9527")" SERVER_URL done while [ -z "$ENROLLMENT_CODE" ]; do if [ "$YES" = true ]; then error "--enrollment-code is required for agent installation (generate a one-time code in the server UI Settings)"; fi From ef881d1c028d586b9b166e6d736579ce764e3cf0 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 22:18:05 +0800 Subject: [PATCH 25/43] feat(deploy): cache interactive language selection Persist the interactively chosen language to a sidecar file in the config dir so subsequent runs (status, upgrade, uninstall) reuse it without re-prompting. A sidecar file is used instead of editing the hand-rolled meta JSON to avoid corrupting its fragile comma handling; it shares the meta-file's directory and uninstall lifecycle. Priority: --lang > SERVERBEE_LANG > cached > system locale > en. The cache is removed on full uninstall alongside the meta file. --- deploy/install.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/deploy/install.sh b/deploy/install.sh index 09dc3c1b..29ba165f 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -34,6 +34,7 @@ DOCKER_DIR="${BASE_DIR}" DEFAULT_DOCKER_DIR="${BASE_DIR}" SNAP_DOCKER_DIR="/var/snap/docker/common/serverbee" META_FILE="${CONFIG_DIR}/.install-meta" +LANG_CACHE_FILE="${CONFIG_DIR}/.install-lang" CLI_PATH="/usr/local/bin/serverbee" # Legacy FHS-split layout (pre-/opt). Kept only for one-time auto-migration # of installs created by older versions of this script. @@ -85,12 +86,35 @@ normalize_lang() { esac } +lang_cache_read() { + [ -f "$LANG_CACHE_FILE" ] || return 1 + local cached + cached=$(head -n1 "$LANG_CACHE_FILE" 2>/dev/null | tr -d '[:space:]') + case "$cached" in + en|zh) printf '%s' "$cached"; return 0 ;; + *) return 1 ;; + esac +} + +lang_cache_write() { + [ -n "${LANG_CODE:-}" ] || return 0 + mkdir -p "$CONFIG_DIR" 2>/dev/null || return 0 + printf '%s\n' "$LANG_CODE" > "$LANG_CACHE_FILE" 2>/dev/null || return 0 + chmod 600 "$LANG_CACHE_FILE" 2>/dev/null || true +} + detect_lang() { if [ -n "${LANG_CODE:-}" ]; then normalize_lang return fi + local cached + if cached=$(lang_cache_read); then + LANG_CODE="$cached" + return + fi + case "${LC_ALL:-${LANG:-en}}" in zh*|ZH*) LANG_CODE="zh" ;; *) LANG_CODE="en" ;; @@ -100,6 +124,12 @@ detect_lang() { select_language() { [ -z "${LANG_CODE:-}" ] || { normalize_lang; return; } + local cached + if cached=$(lang_cache_read); then + LANG_CODE="$cached" + return + fi + if ! should_prompt; then detect_lang return @@ -118,6 +148,7 @@ select_language() { 2|zh|ZH|cn|CN|中文) LANG_CODE="zh" ;; *) error "Invalid language choice: ${choice}" ;; esac + lang_cache_write } # i18n string tables. Add new user-facing strings here in both languages. @@ -1991,6 +2022,7 @@ cmd_uninstall() { if [ "$remaining" -eq 0 ]; then rm -f "$CLI_PATH" rm -f "$META_FILE" + rm -f "$LANG_CACHE_FILE" # Drop now-empty layout directories (ignored if anything remains). rmdir "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_DIR" "$DOCKER_DIR" "$BASE_DIR" 2>/dev/null || true info "All components removed. CLI uninstalled." From 78906b8d198cb266715f4b733b89869c57897541 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 22:26:54 +0800 Subject: [PATCH 26/43] feat(deploy): refresh management CLI script during upgrade serverbee upgrade previously only self-copied the running CLI, so it never picked up new installer logic. Add refresh_cli_from_release: it pulls the target release's deploy/install.sh, validates it (non-empty, bash -n, repo marker) and atomically replaces the CLI. Failures are non-fatal and the refresh runs at most once per upgrade run. The new CLI takes effect on the next serverbee invocation. --- deploy/install.sh | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 29ba165f..1e25fb10 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1076,6 +1076,48 @@ install_cli() { fi } +# Refresh the installed management CLI from the release script itself. +# Unlike install_cli (which self-copies the running script to match the +# layout it just created), this pulls the target release's deploy/install.sh +# so `serverbee upgrade` also updates the installer logic, not just the +# monitored component. Validates before atomically replacing; never aborts +# the caller — a stale CLI is non-fatal and the component upgrade already +# succeeded. The running process keeps old code; the new CLI applies on the +# next invocation. +CLI_REFRESHED="" +refresh_cli_from_release() { + local version="${1:-main}" + [ -z "$CLI_REFRESHED" ] || return 0 + + local target="$CLI_PATH" + if ( + local target_dir + target_dir=$(dirname "$target") + local tmp + tmp=$(mktemp "${target_dir}/.serverbee-cli.XXXXXX") + trap 'rm -f "$tmp"' EXIT + + local url="https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" + curl -fsSL -o "$tmp" "$url" || exit 1 + + # Sanity-check the download before trusting it as our own CLI: + # non-empty, syntactically valid bash, and carrying the expected + # repo marker (guards against HTML error pages / truncated bodies). + [ -s "$tmp" ] || exit 1 + bash -n "$tmp" 2>/dev/null || exit 1 + grep -q 'REPO="ZingerLittleBee/ServerBee"' "$tmp" || exit 1 + + chmod +x "$tmp" + mv "$tmp" "$target" + trap - EXIT + ); then + CLI_REFRESHED=1 + info "Management CLI refreshed to ${version} (applies on next 'serverbee' run)" + else + warn "Could not refresh management CLI from ${version} — keeping existing CLI" + fi +} + # ─── Install helpers ───────────────────────────────────────────────────────── install_binary_server() { @@ -2061,7 +2103,7 @@ upgrade_component() { if [ -n "$current_version" ] && [ "$current_version" = "$latest_version" ]; then # Always ensure CLI matches the current release (repairs missing or stale) - install_cli "$latest_version" + refresh_cli_from_release "$latest_version" info "serverbee-${component} is already up to date (${current_version})" return fi @@ -2088,7 +2130,7 @@ upgrade_component() { esac meta_write "$component" "$method" "$latest_version" - install_cli "$latest_version" + refresh_cli_from_release "$latest_version" info "serverbee-${component} upgraded to ${latest_version}" } From 2a1a9b9623a7e6b45fbec83f08f510cefacd35f0 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 22:35:46 +0800 Subject: [PATCH 27/43] fix(deploy): widen docker first-run password poll budget On a cold image pull the container starts and logs the credentials banner later than the previous 15s poll window, causing the installer to fall back to the manual "check logs" hint. Use a 45s budget for docker (15s unchanged for systemd); the loop still exits as soon as the password is found, so the warm-cache path stays fast. --- deploy/install.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 1e25fb10..2c93d37e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1374,8 +1374,12 @@ YAML # Echoes the password if found within the timeout, otherwise nothing (e.g. # re-install/adopt where the admin already exists, or no captured logs). fetch_first_run_password() { - local i out pw - for ((i = 0; i < 15; i++)); do + local i out pw max + # Docker's first run may pull the image before the container starts and + # logs the banner, so allow a longer budget; the loop exits as soon as + # the password is found, keeping the warm-cache path fast. + if [ "$METHOD" = "docker" ]; then max=45; else max=15; fi + for ((i = 0; i < max; i++)); do if [ "$METHOD" = "docker" ]; then out=$(docker compose -f "${DOCKER_DIR}/docker-compose.server.yml" logs --no-color serverbee-server 2>/dev/null) elif has_systemd; then From 0493dad1f1b7ce5b9c263583fe4c8b9a30a469ac Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 22:45:59 +0800 Subject: [PATCH 28/43] feat(deploy): list Agent before Server in component menus Agents are installed far more often than the single server, so make Agent option [1] and Server option [2] in both the install and uninstall interactive menus (kept consistent to avoid the number meaning different things between the two menus). --- deploy/install.sh | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 2c93d37e..bc9fb6a6 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -166,8 +166,8 @@ declare -A I18N_EN=( [exit_menu]=" [0] Exit 退出" [select_menu]="Select [0-8]: " [install_title]="Install" - [server_option]=" [1] Server — Dashboard & API" - [agent_option]=" [2] Agent — System metrics collector" + [agent_option]=" [1] Agent — System metrics collector" + [server_option]=" [2] Server — Dashboard & API" [select_component]="Select component [1/2]: " [server_docker_recommended]=" [1] Docker (recommended for Server)" [agent_binary_recommended]=" [1] Binary (recommended for Agent)" @@ -191,8 +191,8 @@ declare -A I18N_EN=( [svc_restart]=" [3] Restart" [svc_select]="Select [1-3]: " [uninstall_title]="Uninstall" - [opt_server]=" [1] Server" - [opt_agent]=" [2] Agent" + [opt_agent]=" [1] Agent" + [opt_server]=" [2] Server" [uninstall_confirm]="Uninstall serverbee-%s (%s)%s? [y/N]: " [uninstall_purge_note]=" (including config and data)" [uninstall_preserved]=" Config and data preserved. To remove them, run:" @@ -283,8 +283,8 @@ declare -A I18N_ZH=( [exit_menu]=" [0] 退出 Exit" [select_menu]="选择 [0-8]: " [install_title]="安装" - [server_option]=" [1] Server — 控制台和 API" - [agent_option]=" [2] Agent — 系统指标采集器" + [agent_option]=" [1] Agent — 系统指标采集器" + [server_option]=" [2] Server — 控制台和 API" [select_component]="选择组件 [1/2]: " [server_docker_recommended]=" [1] Docker (Server 推荐)" [agent_binary_recommended]=" [1] Binary (Agent 推荐)" @@ -308,8 +308,8 @@ declare -A I18N_ZH=( [svc_restart]=" [3] 重启" [svc_select]="选择 [1-3]: " [uninstall_title]="卸载" - [opt_server]=" [1] Server" - [opt_agent]=" [2] Agent" + [opt_agent]=" [1] Agent" + [opt_server]=" [2] Server" [uninstall_confirm]="卸载 serverbee-%s(%s)%s ? [y/N]: " [uninstall_purge_note]="(含配置与数据)" [uninstall_preserved]=" 配置与数据已保留,如需移除请执行:" @@ -1858,13 +1858,13 @@ cmd_install() { echo "" echo -e "${BOLD}$(tr_text install_title)${NC}" echo "" - echo "$(tr_text server_option)" echo "$(tr_text agent_option)" + echo "$(tr_text server_option)" echo "" read -rp "$(tr_text select_component)" choice case "$choice" in - 1|server) COMPONENT="server" ;; - 2|agent) COMPONENT="agent" ;; + 1|agent) COMPONENT="agent" ;; + 2|server) COMPONENT="server" ;; *) error "Invalid choice: $choice" ;; esac fi @@ -2016,13 +2016,13 @@ cmd_uninstall() { echo "" echo -e "${BOLD}$(tr_text uninstall_title)${NC}" echo "" - echo "$(tr_text opt_server)" echo "$(tr_text opt_agent)" + echo "$(tr_text opt_server)" echo "" read -rp "$(tr_text select_component)" choice case "$choice" in - 1|server) COMPONENT="server" ;; - 2|agent) COMPONENT="agent" ;; + 1|agent) COMPONENT="agent" ;; + 2|server) COMPONENT="server" ;; *) error "Invalid choice: $choice" ;; esac fi From 3ba55a56506a2cff1be8a620d00dc06acbb07619 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 22:50:16 +0800 Subject: [PATCH 29/43] docs(agent): document how to correct a wrong enrollment code Add a "Correcting a Wrong Enrollment Code" subsection (EN + CN) under the registration flow: how to fix a mistyped enrollment code or server_url via serverbee config set / reinstall, and clarify that the code is irrelevant once a token exists. --- apps/docs/content/docs/cn/agent.mdx | 22 ++++++++++++++++++++++ apps/docs/content/docs/en/agent.mdx | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/docs/content/docs/cn/agent.mdx b/apps/docs/content/docs/cn/agent.mdx index d4eca7d2..0e2b3ed6 100644 --- a/apps/docs/content/docs/cn/agent.mdx +++ b/apps/docs/content/docs/cn/agent.mdx @@ -157,6 +157,28 @@ SERVERBEE_ENROLLMENT_CODE=YOUR_ONE_TIME_CODE serverbee-agent 当 Agent 能读取稳定的机器标识时,还会在注册请求中携带指纹。相同机器重复注册时会复用原有服务器记录并轮换 token,而不是继续创建新的占位条目。 +### 更正错误的注册码 + +如果安装时把注册码(或 `server_url`)填错了,且 Agent **尚未注册成功**(配置中还没有 `token`),无需重装即可更正。注册码为单次使用,填错或已被消费的码无法重复使用——必要时先到管理面板「设置」页重新生成一个新码,再执行: + +```bash +serverbee config set enrollment_code <新注册码> -y +# 如果 server_url 也填错了: +serverbee config set server_url http://your-server-ip:9527 -y +``` + +`-y` 会顺带重启 Agent,使其立即用新码重新尝试注册。不加 `-y` 时只写入配置但不重启服务,需再执行 `serverbee restart agent`。 + +若不确定 Agent 当前状态,直接重跑 Agent 安装即可,它会重写 `agent.toml`: + +```bash +serverbee install agent --method \ + --server-url http://your-server-ip:9527 \ + --enrollment-code <新注册码> -y +``` + +如果 Agent **已注册成功**(`agent.toml` 中已有 `token`),注册码便不再使用,无需更正;要把该 Agent 接到另一台 Server,只能用新 Server 的新码重新注册。 + ## 配置文件 Agent 默认读取 `/etc/serverbee/agent.toml` 配置文件。 diff --git a/apps/docs/content/docs/en/agent.mdx b/apps/docs/content/docs/en/agent.mdx index eb730dda..850a1cf7 100644 --- a/apps/docs/content/docs/en/agent.mdx +++ b/apps/docs/content/docs/en/agent.mdx @@ -118,6 +118,28 @@ enrollment_code = "" When the agent can read a stable machine identifier, it also sends a fingerprint during registration. Repeated registration from the same machine reuses the existing server row instead of creating duplicate placeholders. If a code is lost, expired, or already used, the server responds with HTTP 401 and the agent logs `Registration failed: HTTP 401 ... enrollment code ... expired or already used`; mint a fresh code in Settings to retry. +### Correcting a Wrong Enrollment Code + +If the agent was installed with a mistyped enrollment code (or wrong `server_url`) and has **not yet registered** (no `token` saved), fix it without reinstalling. Enrollment codes are single-use, so a typo'd or already-consumed code cannot be reused — generate a fresh one in **Settings** first if needed, then: + +```bash +serverbee config set enrollment_code -y +# if the server URL was also wrong: +serverbee config set server_url http://your-server-ip:9527 -y +``` + +The `-y` flag restarts the agent so it retries registration immediately. Without `-y` the value is written but the service is not restarted; run `serverbee restart agent` afterwards. + +If you are unsure of the agent's state, simply re-run the agent installer — it rewrites `agent.toml`: + +```bash +serverbee install agent --method \ + --server-url http://your-server-ip:9527 \ + --enrollment-code -y +``` + +Once the agent has **already registered** (a `token` is present in `agent.toml`), the enrollment code is no longer used and does not need correcting; point the agent at a different server only by re-registering with a fresh code from that server. + ### Manual Token If you prefer not to use enrollment codes, you can manually create a server entry in the dashboard and provide the token directly: From d4703fc007a607f59ce9dbdf7d18fdd832b64cba Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 23:02:19 +0800 Subject: [PATCH 30/43] fix(web): stop DataTable column widths exploding to ~1e6px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared DataTable applies table-fixed, but ui/table's base class sets min-w-max. Inside the nested ScrollArea the fixed table with min-width:max-content had no defined total width and resolved to a runaway ~1,000,000px, which table-fixed then distributed across columns proportionally — pushing all cell content off-screen so the servers table appeared empty despite data being present. Add min-w-full to the DataTable's Table className so tailwind-merge overrides the base min-w-max for these fixed-layout tables. --- apps/web/src/components/data-table/data-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/data-table/data-table.tsx b/apps/web/src/components/data-table/data-table.tsx index cc906db8..639ee177 100644 --- a/apps/web/src/components/data-table/data-table.tsx +++ b/apps/web/src/components/data-table/data-table.tsx @@ -15,7 +15,7 @@ export function DataTable({ table, actionBar, children, className, ...pro
{children}
- +
{table.getHeaderGroups().map((headerGroup) => ( From d4f5f63437510901dd4d290c44ab9ffa1e8b27be Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 23:13:29 +0800 Subject: [PATCH 31/43] fix(web): let wide DataTables scroll horizontally instead of clipping The shared scroll wrapper used overflow-hidden, so a fixed-layout table wider than its container had its rightmost columns clipped and unreachable, and the overflowing width pushed sibling page chrome (e.g. header actions) off-screen. Switch to overflow-x-auto so wide tables scroll within their bordered container. --- apps/web/src/components/data-table/data-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/data-table/data-table.tsx b/apps/web/src/components/data-table/data-table.tsx index 639ee177..36e8da4c 100644 --- a/apps/web/src/components/data-table/data-table.tsx +++ b/apps/web/src/components/data-table/data-table.tsx @@ -14,7 +14,7 @@ export function DataTable({ table, actionBar, children, className, ...pro return (
{children} -
+
{table.getHeaderGroups().map((headerGroup) => ( From 84a8b9dc9be5df0379532b3b7813bf7b4844cf1c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 23:13:29 +0800 Subject: [PATCH 32/43] feat(web): add server via enrollment dialog on the servers page Move agent enrollment out of Settings into an admin-only "Add Server" action on the Servers page. The dialog collects an optional name and code validity, generates a one-time enrollment code, then shows the code, ready-to-run install command and next steps, plus management of existing codes. Settings now only hosts the GeoIP card. --- .../components/server/add-server-dialog.tsx | 316 ++++++++++++++++++ apps/web/src/locales/en/servers.json | 35 ++ apps/web/src/locales/zh/servers.json | 35 ++ apps/web/src/routes/_authed/servers/index.tsx | 16 +- .../web/src/routes/_authed/settings/index.tsx | 201 ----------- 5 files changed, 400 insertions(+), 203 deletions(-) create mode 100644 apps/web/src/components/server/add-server-dialog.tsx diff --git a/apps/web/src/components/server/add-server-dialog.tsx b/apps/web/src/components/server/add-server-dialog.tsx new file mode 100644 index 00000000..112407da --- /dev/null +++ b/apps/web/src/components/server/add-server-dialog.tsx @@ -0,0 +1,316 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Copy, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' +import { api } from '@/lib/api-client' +import type { CreateEnrollmentResponse, EnrollmentSummary } from '@/lib/api-schema' +import { cn } from '@/lib/utils' + +const TTL_OPTIONS = [ + { secs: 600, key: 'validity_10m' }, + { secs: 3600, key: 'validity_1h' }, + { secs: 86_400, key: 'validity_1d' } +] as const + +type EnrollmentStatus = 'active' | 'consumed' | 'expired' + +function enrollmentStatus(item: EnrollmentSummary): EnrollmentStatus { + if (item.consumed_at) { + return 'consumed' + } + if (new Date(item.expires_at).getTime() < Date.now()) { + return 'expired' + } + return 'active' +} + +function statusVariant(status: EnrollmentStatus): 'default' | 'secondary' | 'destructive' { + if (status === 'active') { + return 'default' + } + if (status === 'consumed') { + return 'secondary' + } + return 'destructive' +} + +export function AddServerDialog({ open, onClose }: { onClose: () => void; open: boolean }) { + const { t } = useTranslation(['servers', 'common']) + const queryClient = useQueryClient() + + const [label, setLabel] = useState('') + const [ttl, setTtl] = useState(600) + const [issued, setIssued] = useState(null) + + const { data: enrollments, isLoading } = useQuery({ + queryKey: ['agent', 'enrollments'], + queryFn: () => api.get('/api/agent/enrollments') + }) + + const generateMutation = useMutation({ + mutationFn: () => + api.post('/api/agent/enrollments', { + label: label.trim() || null, + ttl_secs: ttl + }), + onSuccess: (data) => { + setIssued(data) + queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) + }, + onError: () => toast.error(t('add_server.generate_failed')) + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/api/agent/enrollments/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) + toast.success(t('add_server.deleted')) + }, + onError: () => toast.error(t('add_server.delete_failed')) + }) + + const origin = window.location.origin + const installCommand = issued + ? `curl -fsSL ${origin}/install.sh | sudo bash -s -- --server-url '${origin}' --enrollment-code '${issued.code}'` + : '' + + const copy = async (value: string) => { + try { + await navigator.clipboard.writeText(value) + toast.success(t('add_server.copied')) + } catch { + // Clipboard access denied + } + } + + const reset = () => { + setIssued(null) + setLabel('') + setTtl(600) + } + + const handleClose = () => { + reset() + onClose() + } + + return ( + { + if (!isOpen) { + handleClose() + } + }} + open={open} + > + + + {t('add_server.title')} + + + +

{t('add_server.description')}

+ + {issued ? ( +
+

{t('add_server.code_once_warning')}

+ +
+

{t('add_server.code_label')}

+
+ + {issued.code} + + +
+
+ +
+

{t('add_server.install_command')}

+
+ + {installCommand} + + +
+
+ +
+

{t('add_server.steps_title')}

+
    +
  1. {t('add_server.step1')}
  2. +
  3. {t('add_server.step2')}
  4. +
  5. {t('add_server.step3')}
  6. +
+
+
+ ) : ( +
+
+ + setLabel(e.target.value)} + placeholder={t('add_server.name_placeholder')} + type="text" + value={label} + /> +

{t('add_server.name_hint')}

+
+ +
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: label describes the segmented button group below */} + +
+ {TTL_OPTIONS.map((opt) => ( + + ))} +
+
+
+ )} + +
+

{t('add_server.existing_title')}

+ {(() => { + if (isLoading) { + return + } + if (!enrollments || enrollments.length === 0) { + return

{t('add_server.empty')}

+ } + return ( + +
    + {enrollments.map((item) => { + const status = enrollmentStatus(item) + return ( +
  • +
    +
    + {item.code_prefix}… + {item.label ? ( + {item.label} + ) : null} +
    +

    + {t('add_server.expires_at', { + date: new Date(item.expires_at).toLocaleString() + })} +

    +
    + {t(`add_server.status_${status}`)} + + + + + } + /> + + + {t('add_server.delete_confirm_title')} + + {t('add_server.delete_confirm_description')} + + + + {t('common:cancel')} + deleteMutation.mutate(item.id)} variant="destructive"> + {t('add_server.delete')} + + + + +
  • + ) + })} +
+
+ ) + })()} +
+
+ + + {issued ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index 4a721183..dc749e4f 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -1,5 +1,40 @@ { "title": "Servers", + "add_server.button": "Add Server", + "add_server.title": "Add a Server", + "add_server.description": "Set the basics, generate a one-time enrollment code, then run the install command on your VPS.", + "add_server.name_label": "Name (optional)", + "add_server.name_placeholder": "e.g. tokyo-vps-01", + "add_server.name_hint": "A label to recognize this enrollment code in the list below.", + "add_server.validity_label": "Code validity", + "add_server.validity_10m": "10 minutes", + "add_server.validity_1h": "1 hour", + "add_server.validity_1d": "1 day", + "add_server.generate": "Generate code", + "add_server.generating": "Generating…", + "add_server.generate_failed": "Failed to generate enrollment code", + "add_server.code_once_warning": "Copy this now — the code is shown only once and cannot be retrieved again.", + "add_server.code_label": "Enrollment code", + "add_server.install_command": "Install command (run on your VPS as root)", + "add_server.steps_title": "Next steps", + "add_server.step1": "Copy the install command above.", + "add_server.step2": "SSH into your VPS and run it as root.", + "add_server.step3": "The agent registers automatically and appears in the server list.", + "add_server.copy": "Copy", + "add_server.copied": "Copied", + "add_server.done": "Done", + "add_server.another": "Generate another", + "add_server.existing_title": "Existing codes", + "add_server.empty": "No enrollment codes yet", + "add_server.expires_at": "Expires {{date}}", + "add_server.status_active": "Active", + "add_server.status_consumed": "Used", + "add_server.status_expired": "Expired", + "add_server.delete": "Delete", + "add_server.delete_confirm_title": "Delete enrollment code?", + "add_server.delete_confirm_description": "This permanently revokes the code. An agent that has not yet enrolled with it will fail to connect.", + "add_server.deleted": "Enrollment code deleted", + "add_server.delete_failed": "Failed to delete enrollment code", "card_latency": "Latency", "card_load": "Load", "card_net_total": "Total", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index 92733640..a595d0a9 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -1,5 +1,40 @@ { "title": "服务器", + "add_server.button": "添加服务器", + "add_server.title": "添加服务器", + "add_server.description": "填写基础信息,生成一次性注册码,然后在 VPS 上执行安装命令。", + "add_server.name_label": "名称(可选)", + "add_server.name_placeholder": "例如 tokyo-vps-01", + "add_server.name_hint": "用于在下方列表中识别此注册码的备注。", + "add_server.validity_label": "注册码有效期", + "add_server.validity_10m": "10 分钟", + "add_server.validity_1h": "1 小时", + "add_server.validity_1d": "1 天", + "add_server.generate": "生成注册码", + "add_server.generating": "生成中…", + "add_server.generate_failed": "生成注册码失败", + "add_server.code_once_warning": "请立即复制 —— 注册码只显示一次,无法再次获取。", + "add_server.code_label": "注册码", + "add_server.install_command": "安装命令(在 VPS 上以 root 执行)", + "add_server.steps_title": "后续步骤", + "add_server.step1": "复制上面的安装命令。", + "add_server.step2": "SSH 登录 VPS 并以 root 执行。", + "add_server.step3": "Agent 会自动注册并出现在服务器列表中。", + "add_server.copy": "复制", + "add_server.copied": "已复制", + "add_server.done": "完成", + "add_server.another": "再生成一个", + "add_server.existing_title": "已有注册码", + "add_server.empty": "暂无注册码", + "add_server.expires_at": "过期时间 {{date}}", + "add_server.status_active": "有效", + "add_server.status_consumed": "已使用", + "add_server.status_expired": "已过期", + "add_server.delete": "删除", + "add_server.delete_confirm_title": "删除注册码?", + "add_server.delete_confirm_description": "将永久吊销此码。尚未用它注册的 Agent 将无法连接。", + "add_server.deleted": "注册码已删除", + "add_server.delete_failed": "删除注册码失败", "card_latency": "延迟", "card_load": "负载", "card_net_total": "总流量", diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index fe1e3740..a05afc9f 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -1,13 +1,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' import type { ColumnDef } from '@tanstack/react-table' -import { CircleDot, ExternalLink, LayoutGrid, Search, Table2, Tag, Trash2 } from 'lucide-react' +import { CircleDot, ExternalLink, LayoutGrid, Plus, Search, Table2, Tag, Trash2 } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { DataTable } from '@/components/data-table/data-table' import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header' import { DataTableToolbar } from '@/components/data-table/data-table-toolbar' +import { AddServerDialog } from '@/components/server/add-server-dialog' import { CostCell } from '@/components/server/cost-cell' import { ServerCard } from '@/components/server/server-card' import { ServerEditDialog } from '@/components/server/server-edit-dialog' @@ -30,6 +31,7 @@ import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useServer } from '@/hooks/use-api' +import { useAuth } from '@/hooks/use-auth' import { useCostOverview } from '@/hooks/use-cost' import { useDataTable } from '@/hooks/use-data-table' import type { ServerMetrics } from '@/hooks/use-servers-ws' @@ -65,6 +67,9 @@ const arrayIncludesFilter = (row: { getValue: (id: string) => unknown }, id: str function ServersListPage() { const { t } = useTranslation(['servers', 'common']) const queryClient = useQueryClient() + const { user } = useAuth() + const isAdmin = user?.role === 'admin' + const [addOpen, setAddOpen] = useState(false) const navigate = Route.useNavigate() const { q: search, view: viewParam } = Route.useSearch() @@ -334,13 +339,19 @@ function ServersListPage() { return (
-
+

{t('title')}

{t('servers_online', { online: servers.filter((s) => s.online).length, total: servers.length })}

+ {isAdmin && ( + + )}
@@ -448,6 +459,7 @@ function ServersListPage() { )} {editingId !== null && setEditingId(null)} serverId={editingId} />} + {isAdmin && setAddOpen(false)} open={addOpen} />}
) } diff --git a/apps/web/src/routes/_authed/settings/index.tsx b/apps/web/src/routes/_authed/settings/index.tsx index bdb69bbe..907111dd 100644 --- a/apps/web/src/routes/_authed/settings/index.tsx +++ b/apps/web/src/routes/_authed/settings/index.tsx @@ -1,220 +1,19 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' -import { Copy, Plus, Trash2 } from 'lucide-react' -import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' import { GeoIpCard } from '@/components/settings/geoip-card' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger -} from '@/components/ui/alert-dialog' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Skeleton } from '@/components/ui/skeleton' -import { api } from '@/lib/api-client' -import type { CreateEnrollmentResponse, EnrollmentSummary } from '@/lib/api-schema' export const Route = createFileRoute('/_authed/settings/')({ component: SettingsPage }) -type EnrollmentStatus = 'active' | 'consumed' | 'expired' - -function enrollmentStatus(item: EnrollmentSummary): EnrollmentStatus { - if (item.consumed_at) { - return 'consumed' - } - if (new Date(item.expires_at).getTime() < Date.now()) { - return 'expired' - } - return 'active' -} - -function statusVariant(status: EnrollmentStatus): 'default' | 'secondary' | 'destructive' { - if (status === 'active') { - return 'default' - } - if (status === 'consumed') { - return 'secondary' - } - return 'destructive' -} - function SettingsPage() { const { t } = useTranslation('settings') - const queryClient = useQueryClient() - const [issuedCode, setIssuedCode] = useState(null) - - const { data: enrollments, isLoading } = useQuery({ - queryKey: ['agent', 'enrollments'], - queryFn: () => api.get('/api/agent/enrollments') - }) - - const generateMutation = useMutation({ - mutationFn: () => api.post('/api/agent/enrollments', {}), - onSuccess: (data) => { - setIssuedCode(data.code) - queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) - }, - onError: () => toast.error(t('enrollment.generate_failed')) - }) - - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete(`/api/agent/enrollments/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) - toast.success(t('enrollment.deleted')) - }, - onError: () => toast.error(t('enrollment.delete_failed')) - }) - - const installCommand = issuedCode - ? `curl -fsSL ${window.location.origin}/install.sh | sudo bash -s -- --server-url '${window.location.origin}' --enrollment-code '${issuedCode}'` - : '' - - const copy = async (value: string) => { - try { - await navigator.clipboard.writeText(value) - toast.success(t('copied')) - } catch { - // Clipboard access denied - } - } return (

{t('title')}

-
-

{t('enrollment.title')}

-

{t('enrollment.description')}

- - - - {issuedCode ? ( -
-

{t('enrollment.code_once_warning')}

- -
-

{t('enrollment.code_label')}

-
- - {issuedCode} - - -
-
- -
-

{t('enrollment.install_command')}

-
- - {installCommand} - - -
-
-
- ) : null} - -
-

{t('enrollment.list_title')}

- {(() => { - if (isLoading) { - return - } - if (!enrollments || enrollments.length === 0) { - return

{t('enrollment.empty')}

- } - return ( - -
    - {enrollments.map((item) => { - const status = enrollmentStatus(item) - return ( -
  • -
    -
    - {item.code_prefix}… - {item.label ? ( - {item.label} - ) : null} -
    -

    - {t('enrollment.expires_at', { - date: new Date(item.expires_at).toLocaleString() - })} -

    -
    - {t(`enrollment.status_${status}`)} - - - - - } - /> - - - {t('enrollment.delete_confirm_title')} - - {t('enrollment.delete_confirm_description')} - - - - {t('common:cancel')} - deleteMutation.mutate(item.id)} variant="destructive"> - {t('enrollment.delete')} - - - - -
  • - ) - })} -
-
- ) - })()} -
-
-
From 3475ed708327e35a04c338753e9f17bf1668bb9c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 23:31:01 +0800 Subject: [PATCH 33/43] fix(web): right-align the Add Server button in the servers header Restore the header's space-between layout so the admin Add Server action sits at the top-right of the page, matching the placement of primary actions elsewhere. --- apps/web/src/routes/_authed/servers/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index a05afc9f..adc5be3e 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -339,7 +339,7 @@ function ServersListPage() { return (
-
+

{t('title')}

@@ -347,7 +347,7 @@ function ServersListPage() {

{isAdmin && ( - From 7f36a9166bba6fed686e670715247fb1f68f39fe Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Sun, 17 May 2026 23:56:50 +0800 Subject: [PATCH 34/43] fix(web): correct network square grid tooltip styling The tooltip overrode only the popup background (bg-background) while the base tooltip's text (text-background) and arrow (bg-foreground) were left intact. That made the body text the same color as its background and left a mismatched light arrow on a dark bubble in dark mode. Drop the partial override and use the shared tooltip surface, matching every other tooltip in the app and staying theme-correct in both light and dark. --- apps/web/src/components/server/network-square-grid.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/components/server/network-square-grid.tsx b/apps/web/src/components/server/network-square-grid.tsx index dffbee69..4abc5a7b 100644 --- a/apps/web/src/components/server/network-square-grid.tsx +++ b/apps/web/src/components/server/network-square-grid.tsx @@ -138,10 +138,7 @@ export function NetworkSquareGrid({ points, kind }: NetworkSquareGridProps) { /> } /> - + From d601289885a8adeab68d4c7fc7cab4f30767c613 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:00:39 +0800 Subject: [PATCH 35/43] fix(web): make tooltips follow the theme instead of inverting Tooltips used bg-foreground/text-background, so in dark mode they rendered as a bright pill instead of a dark surface. Switch the tooltip popup and arrow to the popover tokens (bg-popover/ text-popover-foreground with a border), so tooltips are dark in dark mode and light in light mode, with the arrow matching the bubble. --- apps/web/src/components/ui/tooltip.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index cfdb78bb..98a0f0ee 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -35,14 +35,14 @@ function TooltipContent({ > {children} - + From e7a7cf0ee5bc676dd0e8b80dc13b8d35731719b4 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:19:36 +0800 Subject: [PATCH 36/43] refactor(web): use two-column grid for server card footer stats Convert the bottom footer stats row from a centered wrapping flex to a two-column label/value grid so it matches the card's other 2-column sections. Traffic days-remaining now renders in the second column and the cost footnote spans both columns centered below. --- .../web/src/components/server/server-card.tsx | 54 ++++++++++--------- apps/web/src/locales/en/servers.json | 3 +- apps/web/src/locales/zh/servers.json | 3 +- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index 0b65316f..6a492220 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -289,34 +289,38 @@ const ServerCardInner = ({ server }: ServerCardProps) => { )} -
- - {t('col_uptime')}{' '} +
+
+ {t('col_uptime')} {formatUptime(server.uptime)} - - - - {t('card_swap')} {`${swapPct.toFixed(0)}%`} - - - - {t('card_processes')} {server.process_count} - - - - {t('card_tcp')} {server.tcp_conn} - - - - {t('card_udp')} {server.udp_conn} - +
+
+ {t('card_swap')} + {`${swapPct.toFixed(0)}%`} +
+
+ {t('card_processes')} + {server.process_count} +
+
+ {t('card_tcp')} + {server.tcp_conn} +
+
+ {t('card_udp')} + {server.udp_conn} +
{trafficDaysRemaining != null && ( - <> - - {t('card_traffic_days_left', { count: trafficDaysRemaining })} - +
+ {t('card_traffic_days_left_label')} + + {t('card_traffic_days_value', { count: trafficDaysRemaining })} + +
)} - +
+ +
diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index dc749e4f..3d28479f 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -50,7 +50,8 @@ "card_disk_write": "↻ Write", "card_load_trend": "Load 5m·15m", "card_traffic_quota": "Traffic", - "card_traffic_days_left": "{{count}}d left", + "card_traffic_days_left_label": "Days left", + "card_traffic_days_value": "{{count}}d", "servers_online": "{{online}} of {{total}} servers online", "search_placeholder": "Search servers\u2026", "delete_selected": "Delete {{count}}", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index a595d0a9..97db1912 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -50,7 +50,8 @@ "card_disk_write": "↻ 写", "card_load_trend": "负载 5m·15m", "card_traffic_quota": "流量", - "card_traffic_days_left": "剩 {{count}} 天", + "card_traffic_days_left_label": "剩余天数", + "card_traffic_days_value": "{{count}} 天", "servers_online": "{{total}} 台服务器中 {{online}} 台在线", "search_placeholder": "搜索服务器\u2026", "delete_selected": "删除 {{count}} 项", From 3be75cc95d18728a96e775aefd9b0e8fb06f55d0 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:24:56 +0800 Subject: [PATCH 37/43] refactor(web): merge proc/tcp/udp and align cost in card footer Combine processes, TCP and UDP into a single footer row and render the cost as a regular label/value cell so it conforms to the two-column grid instead of a centered footnote. --- .../src/components/server/cost-footnote.tsx | 5 ++-- .../components/server/server-card.test.tsx | 4 +--- .../web/src/components/server/server-card.tsx | 23 ++++++++----------- apps/web/src/locales/en/servers.json | 5 ++-- apps/web/src/locales/zh/servers.json | 5 ++-- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/server/cost-footnote.tsx b/apps/web/src/components/server/cost-footnote.tsx index f175f8a1..6ac579d4 100644 --- a/apps/web/src/components/server/cost-footnote.tsx +++ b/apps/web/src/components/server/cost-footnote.tsx @@ -5,9 +5,10 @@ import { cn } from '@/lib/utils' interface CostFootnoteProps { entry?: ServerCostOverview + inline?: boolean } -export function CostFootnote({ entry }: CostFootnoteProps) { +export function CostFootnote({ entry, inline = false }: CostFootnoteProps) { const { t } = useTranslation('servers') if (!entry) { @@ -16,7 +17,7 @@ export function CostFootnote({ entry }: CostFootnoteProps) { return ( - + {!inline && } {entry.configured ? ( ) : ( diff --git a/apps/web/src/components/server/server-card.test.tsx b/apps/web/src/components/server/server-card.test.tsx index 4d87740d..17ddd8a9 100644 --- a/apps/web/src/components/server/server-card.test.tsx +++ b/apps/web/src/components/server/server-card.test.tsx @@ -122,9 +122,7 @@ describe('ServerCard', () => { renderCard(makeServer()) expect(screen.getByText('col_uptime')).toBeDefined() expect(screen.getByText('card_swap')).toBeDefined() - expect(screen.getByText('card_processes')).toBeDefined() - expect(screen.getByText('card_tcp')).toBeDefined() - expect(screen.getByText('card_udp')).toBeDefined() + expect(screen.getByText('card_proc_conn_label')).toBeDefined() }) it('renders compact cost footnote when cost overview is available', () => { diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index 6a492220..16cee4f3 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -299,16 +299,10 @@ const ServerCardInner = ({ server }: ServerCardProps) => { {`${swapPct.toFixed(0)}%`}
- {t('card_processes')} - {server.process_count} -
-
- {t('card_tcp')} - {server.tcp_conn} -
-
- {t('card_udp')} - {server.udp_conn} + {t('card_proc_conn_label')} + + {`${server.process_count} / ${server.tcp_conn} / ${server.udp_conn}`} +
{trafficDaysRemaining != null && (
@@ -318,9 +312,12 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
)} -
- -
+ {costEntry && ( +
+ {t('card_cost_label')} + +
+ )}
diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index 3d28479f..b67e6033 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -40,10 +40,7 @@ "card_net_total": "Total", "card_network_quality": "Network Quality", "card_packet_loss": "Loss", - "card_processes": "Processes", "card_swap": "Swap", - "card_tcp": "TCP", - "card_udp": "UDP", "card_net_in_speed": "↓ In", "card_net_out_speed": "↑ Out", "card_disk_read": "↺ Read", @@ -52,6 +49,8 @@ "card_traffic_quota": "Traffic", "card_traffic_days_left_label": "Days left", "card_traffic_days_value": "{{count}}d", + "card_proc_conn_label": "Proc / TCP / UDP", + "card_cost_label": "Cost", "servers_online": "{{online}} of {{total}} servers online", "search_placeholder": "Search servers\u2026", "delete_selected": "Delete {{count}}", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index 97db1912..d3cb147d 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -40,10 +40,7 @@ "card_net_total": "总流量", "card_network_quality": "网络质量", "card_packet_loss": "丢包", - "card_processes": "进程", "card_swap": "Swap", - "card_tcp": "TCP", - "card_udp": "UDP", "card_net_in_speed": "↓ 入站", "card_net_out_speed": "↑ 出站", "card_disk_read": "↺ 读", @@ -52,6 +49,8 @@ "card_traffic_quota": "流量", "card_traffic_days_left_label": "剩余天数", "card_traffic_days_value": "{{count}} 天", + "card_proc_conn_label": "进程 / TCP / UDP", + "card_cost_label": "费用", "servers_online": "{{total}} 台服务器中 {{online}} 台在线", "search_placeholder": "搜索服务器\u2026", "delete_selected": "删除 {{count}} 项", From f41b53a3f804f58e39cb226e4ad48b013faba307 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:29:42 +0800 Subject: [PATCH 38/43] refactor(web): circular R/W badges and move load to card footer Show full read/write labels prefixed with circular R/W badges, and relocate the load trend from the compact metrics grid into the bottom two-column footer stats so the compact grid is a clean 2x2. --- .../web/src/components/server/server-card.tsx | 38 +++++++++++-------- apps/web/src/locales/en/servers.json | 4 +- apps/web/src/locales/zh/servers.json | 4 +- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index 16cee4f3..230424d9 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -237,32 +237,32 @@ const ServerCardInner = ({ server }: ServerCardProps) => { - R + + + {t('card_disk_read')} } value={renderSpeedValue(server.disk_read_bytes_per_sec)} /> - W + + + {t('card_disk_write')} } value={renderSpeedValue(server.disk_write_bytes_per_sec)} /> -
{hasNetworkData && ( @@ -298,6 +298,12 @@ const ServerCardInner = ({ server }: ServerCardProps) => { {t('card_swap')} {`${swapPct.toFixed(0)}%`}
+
+ {t('card_load_trend')} + + {`${formatLoad(server.load5)}·${formatLoad(server.load15)}`} + +
{t('card_proc_conn_label')} diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index b67e6033..efc6de6b 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -43,8 +43,8 @@ "card_swap": "Swap", "card_net_in_speed": "↓ In", "card_net_out_speed": "↑ Out", - "card_disk_read": "↺ Read", - "card_disk_write": "↻ Write", + "card_disk_read": "Read", + "card_disk_write": "Write", "card_load_trend": "Load 5m·15m", "card_traffic_quota": "Traffic", "card_traffic_days_left_label": "Days left", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index d3cb147d..fb51f2de 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -43,8 +43,8 @@ "card_swap": "Swap", "card_net_in_speed": "↓ 入站", "card_net_out_speed": "↑ 出站", - "card_disk_read": "↺ 读", - "card_disk_write": "↻ 写", + "card_disk_read": "读", + "card_disk_write": "写", "card_load_trend": "负载 5m·15m", "card_traffic_quota": "流量", "card_traffic_days_left_label": "剩余天数", From 40764d2b6bec721cf289326ec3b38bd04cb2b936 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:33:25 +0800 Subject: [PATCH 39/43] refactor(web): show monthly cost instead of value grade in footnote Replace the value-score grade label in the configured cost footnote with the monthly-equivalent total cost so the card surfaces an absolute spend figure alongside the hourly rate. --- apps/web/src/components/server/cost-footnote.tsx | 9 ++++----- .../web/src/components/server/server-card.test.tsx | 14 +++++--------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/server/cost-footnote.tsx b/apps/web/src/components/server/cost-footnote.tsx index 6ac579d4..2f2dc929 100644 --- a/apps/web/src/components/server/cost-footnote.tsx +++ b/apps/web/src/components/server/cost-footnote.tsx @@ -1,7 +1,6 @@ import { useTranslation } from 'react-i18next' import type { ServerCostOverview } from '@/lib/api-schema' -import { formatCostRate, getCostGradeClassName } from '@/lib/cost' -import { cn } from '@/lib/utils' +import { formatCostRate } from '@/lib/cost' interface CostFootnoteProps { entry?: ServerCostOverview @@ -39,11 +38,11 @@ function ConfiguredFootnote({ entry }: { entry: ServerCostOverview }) { {formatCostRate(entry.cost_per_hour, entry.currency, 'h', { maximumFractionDigits: 4 })} - {entry.value_score && ( + {entry.cost_per_month_equivalent != null && ( <> - - {t(`cost_grade_${entry.value_score.grade}`)} + + {formatCostRate(entry.cost_per_month_equivalent, entry.currency, 'mo', { maximumFractionDigits: 2 })} )} diff --git a/apps/web/src/components/server/server-card.test.tsx b/apps/web/src/components/server/server-card.test.tsx index 17ddd8a9..1501889b 100644 --- a/apps/web/src/components/server/server-card.test.tsx +++ b/apps/web/src/components/server/server-card.test.tsx @@ -7,6 +7,7 @@ import { CostFootnote } from './cost-footnote' import { ServerCard } from './server-card' const REGEX_COST_PER_HOUR = /0\.01\/h/ +const REGEX_COST_PER_MONTH = /7\.30\/mo/ vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -133,15 +134,10 @@ describe('ServerCard', () => { { configured: true, cost_per_hour: 0.01, + cost_per_month_equivalent: 7.3, currency: 'USD', name: 'test-server', - server_id: 'srv-1', - value_score: { - confidence: 'high', - grade: 'good', - reasons: [], - score: 82 - } + server_id: 'srv-1' } ] } satisfies CostOverviewResponse @@ -150,8 +146,8 @@ describe('ServerCard', () => { renderCard(makeServer()) expect(screen.getByText(REGEX_COST_PER_HOUR)).toBeDefined() - expect(screen.getByText('cost_grade_good')).toBeDefined() - expect(screen.queryByText('82')).toBeNull() + expect(screen.getByText(REGEX_COST_PER_MONTH)).toBeDefined() + expect(screen.queryByText('cost_grade_good')).toBeNull() }) it('renders compact unconfigured cost footnote labels', () => { From ba7592c2fc396cb7f6fc19cca6425e99b3329646 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:38:13 +0800 Subject: [PATCH 40/43] refactor(web): reserve footer slots and align load separator Keep the days-left and cost grid slots occupied with invisible placeholders when unset so the card footer height stays stable, and render the load trend with the same dot separator spacing as the cost row. --- apps/web/src/components/server/server-card.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index 230424d9..ab415d8e 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -300,8 +300,10 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
{t('card_load_trend')} - - {`${formatLoad(server.load5)}·${formatLoad(server.load15)}`} + + {formatLoad(server.load5)} + + {formatLoad(server.load15)}
@@ -310,7 +312,11 @@ const ServerCardInner = ({ server }: ServerCardProps) => { {`${server.process_count} / ${server.tcp_conn} / ${server.udp_conn}`}
- {trafficDaysRemaining != null && ( + {trafficDaysRemaining == null ? ( + + ) : (
{t('card_traffic_days_left_label')} @@ -318,11 +324,15 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
)} - {costEntry && ( + {costEntry?.configured ? (
{t('card_cost_label')}
+ ) : ( + )}
From e09f4c6b9cb92462d3ca43856bf33b7684bb8e62 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:40:29 +0800 Subject: [PATCH 41/43] fix(web): match dashboard grid layout for servers card view Use the same auto-fill minmax column template as the dashboard server cards widget so wide viewports no longer stretch cards across only three columns with an oversized horizontal gap. --- apps/web/src/routes/_authed/servers/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index adc5be3e..15128631 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -449,7 +449,7 @@ function ServersListPage() { )} {servers.length > 0 && viewMode === 'grid' && ( -
+
{filtered.map((server) => (
From 5116c1f291ee9a49e03390d38e8bc630015d63d6 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:46:20 +0800 Subject: [PATCH 42/43] fix(web): color persisted network history in card square grid The backend overview already returns up to 30 buckets of historical latency/loss per server, but the card painted every synthetic point (which includes backend sparkline buckets) with the unknown color, so history rendered gray and only live points gained color after a refresh. Color a square by whether it has a value instead of the synthetic flag; genuinely empty buckets and padding still render as unknown. --- .../server/network-square-grid.test.tsx | 42 +++++++++++++++++++ .../components/server/network-square-grid.tsx | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/server/network-square-grid.test.tsx b/apps/web/src/components/server/network-square-grid.test.tsx index 9f2f8822..0d8115fb 100644 --- a/apps/web/src/components/server/network-square-grid.test.tsx +++ b/apps/web/src/components/server/network-square-grid.test.tsx @@ -71,6 +71,48 @@ describe('NetworkSquareGrid', () => { expect(squares.length).toBe(10) }) + it('colors synthetic backend-history points that carry a real value', () => { + // @ts-expect-error inject mock + globalThis.ResizeObserver = TestResizeObserver + + const historyPoint: ServerCardMetricPoint = { + synthetic: true, + targets: [], + timestamp: 'synthetic-0', + value: 42 + } + + const { container } = render( + + + + ) + + const square = container.querySelector('[data-testid="square"]') + expect(square?.style.backgroundColor).not.toBe('var(--color-muted)') + }) + + it('renders the unknown color for points without a value', () => { + // @ts-expect-error inject mock + globalThis.ResizeObserver = TestResizeObserver + + const emptyPoint: ServerCardMetricPoint = { + synthetic: true, + targets: [], + timestamp: 'padding-0', + value: null + } + + const { container } = render( + + + + ) + + const square = container.querySelector('[data-testid="square"]') + expect(square?.style.backgroundColor).toBe('var(--color-muted)') + }) + it('renders at least one square even at zero width', () => { // @ts-expect-error inject mock globalThis.ResizeObserver = TestResizeObserver diff --git a/apps/web/src/components/server/network-square-grid.tsx b/apps/web/src/components/server/network-square-grid.tsx index 4abc5a7b..4327a8f6 100644 --- a/apps/web/src/components/server/network-square-grid.tsx +++ b/apps/web/src/components/server/network-square-grid.tsx @@ -27,7 +27,7 @@ function averageLossRatio(point: ServerCardMetricPoint): number | null { } function getSquareColor(point: ServerCardMetricPoint, kind: 'latency' | 'loss'): string { - if (point.synthetic) { + if (point.value == null) { return LATENCY_UNKNOWN_BAR_COLOR } if (kind === 'latency') { From a046e3783d0ae1140448df34a0f71b1ed21fcf7e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Mon, 18 May 2026 00:52:06 +0800 Subject: [PATCH 43/43] fix(web): show per-bucket history values in network square tooltip Backend overview only ships server-level aggregate sparklines plus a single current per-target snapshot. The card reused that constant snapshot as the tooltip for every historical point, so all tooltips showed identical values. Carry each bucket's own aggregate latency/loss on its point and label it as the average. --- .../components/server/network-square-grid.tsx | 6 +++-- .../server/server-card-network-data.test.ts | 10 ++++++-- .../server/server-card-network-data.ts | 23 +++++++++++++++---- apps/web/src/locales/en/servers.json | 1 + apps/web/src/locales/zh/servers.json | 1 + 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/server/network-square-grid.tsx b/apps/web/src/components/server/network-square-grid.tsx index 4327a8f6..110d89d5 100644 --- a/apps/web/src/components/server/network-square-grid.tsx +++ b/apps/web/src/components/server/network-square-grid.tsx @@ -8,7 +8,7 @@ import { LATENCY_UNKNOWN_BAR_COLOR } from '@/lib/network-latency-constants' import { latencyColorClass } from '@/lib/network-types' -import type { ServerCardMetricPoint } from './server-card-network-data' +import { AGGREGATE_TARGET_ID, type ServerCardMetricPoint } from './server-card-network-data' const SQUARE_SIZE = 6 const SQUARE_GAP = 2 @@ -87,7 +87,9 @@ function PointTooltip({ point, t }: { point: ServerCardMetricPoint; t: (key: str const failed = isLatencyFailure(target.lossRatio) return (
- {target.targetName} + + {target.targetId === AGGREGATE_TARGET_ID ? t('card_network_avg') : target.targetName} +
{formatLatency(target.latency)} {formatPacketLoss(target.lossRatio)} diff --git a/apps/web/src/components/server/server-card-network-data.test.ts b/apps/web/src/components/server/server-card-network-data.test.ts index fbab419c..8fe4221a 100644 --- a/apps/web/src/components/server/server-card-network-data.test.ts +++ b/apps/web/src/components/server/server-card-network-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { NetworkServerSummary } from '@/lib/network-types' -import { buildServerCardNetworkState } from './server-card-network-data' +import { AGGREGATE_TARGET_ID, buildServerCardNetworkState } from './server-card-network-data' function makeSummary(overrides: Partial = {}): NetworkServerSummary { return { @@ -126,9 +126,15 @@ describe('buildServerCardNetworkState', () => { expect(state.currentAvgLossRatio).toBe(0.03) expect(state.latencyPoints.at(-1)?.value).toBe(40) expect(state.lossPoints.at(-1)?.value).toBe(3) + // Each historical point carries its own bucket aggregate, not a constant + // current-snapshot, so tooltips differ per point. expect(state.latencyPoints.at(-1)?.targets).toEqual([ - { latency: 40, lossRatio: 0.03, targetId: 'target-1', targetName: 'Shanghai' } + { latency: 40, lossRatio: 0.03, targetId: AGGREGATE_TARGET_ID, targetName: AGGREGATE_TARGET_ID } ]) + expect(state.latencyPoints.at(-3)?.targets).toEqual([ + { latency: 10, lossRatio: 0.01, targetId: AGGREGATE_TARGET_ID, targetName: AGGREGATE_TARGET_ID } + ]) + expect(state.latencyPoints.at(-2)?.targets).toEqual([]) }) it('keeps backend seed data and appends realtime points when live samples arrive', () => { diff --git a/apps/web/src/components/server/server-card-network-data.ts b/apps/web/src/components/server/server-card-network-data.ts index ee521259..44e6b86a 100644 --- a/apps/web/src/components/server/server-card-network-data.ts +++ b/apps/web/src/components/server/server-card-network-data.ts @@ -2,6 +2,8 @@ import type { NetworkProbeResultData, NetworkServerSummary, NetworkTargetSummary const MAX_TREND_POINTS = 30 +export const AGGREGATE_TARGET_ID = '__aggregate__' + export interface ServerCardTooltipTarget { latency: number | null lossRatio: number @@ -170,17 +172,30 @@ function buildFallbackState( for (let index = startIndex; index < pointCount; index += 1) { const timestamp = buildSyntheticTimestamp(index) + const latencyValue = latencySparkline[index] ?? null + const lossValue = lossSparkline[index] ?? null + const bucketTargets: ServerCardTooltipTarget[] = + latencyValue == null && lossValue == null + ? [] + : [ + { + latency: latencyValue, + lossRatio: lossValue ?? 0, + targetId: AGGREGATE_TARGET_ID, + targetName: AGGREGATE_TARGET_ID + } + ] latencyPoints.push({ synthetic: true, - targets: fallbackTargets, + targets: bucketTargets, timestamp, - value: latencySparkline[index] ?? null + value: latencyValue }) lossPoints.push({ synthetic: true, - targets: fallbackTargets, + targets: bucketTargets, timestamp, - value: lossSparkline[index] == null ? null : (lossSparkline[index] as number) * 100 + value: lossValue == null ? null : lossValue * 100 }) } diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index efc6de6b..d2b2c7f0 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -39,6 +39,7 @@ "card_load": "Load", "card_net_total": "Total", "card_network_quality": "Network Quality", + "card_network_avg": "Average", "card_packet_loss": "Loss", "card_swap": "Swap", "card_net_in_speed": "↓ In", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index fb51f2de..27d2027f 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -39,6 +39,7 @@ "card_load": "负载", "card_net_total": "总流量", "card_network_quality": "网络质量", + "card_network_avg": "平均", "card_packet_loss": "丢包", "card_swap": "Swap", "card_net_in_speed": "↓ 入站",