monitor

开发指南

主题开发

主题是一个纯静态的单页应用,决定公开状态页的样子。hub 只把它当静态文件提供:不编译、不注入、不提供构建期变量。

后台和登录页始终由 hub 内置提供,不属于主题,主题不必实现节点管理、OAuth 和密码设置。

主题包

一个可安装主题是一个目录,名字必须和 theme.json 里的 short 相同:

<themes-dir>/<short>/
├── theme.json
├── preview.png        # 可选,面板上的预览图,文件名固定
└── dist/
    └── index.html

发布时打成 theme.tar.gz,解开就是这个目录。放进 GitHub release 时文件名保持 theme.tar.gz:面板的「从 GitHub 安装」和一键更新只认仓库最新正式 release(不含预发布和草稿)里这个名字的文件。

发布前用 gzip -t 检查一遍,没有输出就是完整的:

# 检查压缩流是否写完整,打包脚本没收尾时这里会报错
gzip -t theme.tar.gz
bash

hub 在安装和一键更新时会校验 gzip 尾部的校验和,不完整的包会被拒绝,面板提示「主题包损坏或不完整」。hub 1.3.0 及更早的版本不做这项检查,所以以前装得上的包,升级 hub 后可能装不上。

theme.json

name 到 url 六个字段都要写,都是字符串,后四个可以是空字符串;config 可以不写:

字段可留空含义
name否显示名称
short否唯一短名,限字母、数字、-、_。取 default 则顶替 hub 内置的那份
description是简介
version是主题版本。用一键更新时要和 release 的 tag 对上(v1.2.0 对 1.2.0),否则每次都会重装一遍
author是作者
url是源码地址。指向 GitHub 仓库时,面板的一键更新从它的 release 取 theme.tar.gz
config可不写数组,站长可调的设置,面板按它画表单。见下文主题设置

少写一个字段时,在面板安装会被拒绝,并指出缺的是哪个;直接放进 hub 的 --themes 目录的,主题不会出现在列表里,hub 日志里有一条警告。

上限

限制值
整包32 MiB
解压后总量64 MiB
单个文件8 MiB
文件和目录数2000
tar 结尾之后的填充1 MiB

只接受普通文件和目录,包里有符号链接等其他类型时拒绝安装。

可以用的接口

读取接口只有这五个,都是同源请求。主题自带设置界面时还会用到 PUT,见下文主题自带的设置界面。

接口用途
GET /api/me站点名、登录状态、公开页开关、历史保留天数
GET /api/nodes节点列表、实时指标、累计流量
GET /api/nodes/{id}/metrics历史指标和延迟记录
GET /api/ws每 2 秒推送一次节点快照的 WebSocket
GET /api/themes/{short}/config站长在面板里保存的主题设置,见下文主题设置

metrics 的三个查询参数都可以省:

  • hours=N:窗口宽度,默认 6,上限是 /api/me 的 history_days × 24,登录与匿名相同,超出时静默收窄
  • points=W:图表能画下的点数,60–1440。只会让 hub 抽得更稀,不会更密
  • series=metrics|ping:只取要画的那一类,另一类占响应的三分之一到三分之二

响应顶层的 step 是每个点覆盖的秒数:7 天以内是 60 的倍数,更宽的窗口是 3600 的倍数。

metrics 的每一行描述一段时间,minutes 是其中有数据的分钟数,满额是 step ÷ 60:

  • cpu、mem_used、disk_used、net_rx / net_tx 是这些分钟的平均值。网速乘以 minutes × 60 秒就是这段时间的流量
  • cpu_max、net_rx_max / net_tx_max 是其中的最高值,按 agent 的一个上报间隔(默认 1 秒)测得,不小于平均值。一次 15 秒的测速或 CPU 占满在一分钟的平均值里只剩四分之一,在 7 天窗口里更低,要画它到过多高就读这几个 key
  • minutes 不满额,说明节点这段时间有一部分不在线,可以用来画在线率。agent 每次连上的第一分钟不单独记一行,所以算出的在线率会比实际略低
  • 最后一个点是还没过完的时段,minutes 只算到当前,不满额不代表离线
  • 以上按 agent 每分钟至少上报一次而言(默认每秒一次)。--interval 超过 60 秒时,hub 只在收到上报的那一分钟记一行,minutes 按比例偏小,300 秒时只有满额的五分之一,积分出的流量和在线率都会偏低。累计流量以 /api/nodes 返回的为准

hub 1.3.0 及更早没有 net_rx_max / net_tx_max,1.3.1 及更早没有 cpu_max、minutes 和 step。

延迟监控的名称在响应的 probes 里随数据一起返回,匿名可读,画延迟图不需要第二个请求,也不需要登录。

时间范围

站长在面板里设的保留天数就是能画多远,/api/me 里的 history_days(1–365)给出这个数。时间范围的按钮按它生成,不要写死:写死的「30 天」遇上只保留 7 天的 hub,会被静默收窄成 7 天,按钮上却还写着 30 天。

// 默认主题的做法:小于保留期的整档位,最后加上保留期本身。
// 保留期比某个整档位多不到四分之一时去掉那一档,
// 免得「90 天」「92 天」两个按钮并列
const WINDOWS = [1, 6, 24, 168, 720, 2160]
const whole = history_days * 24
const hours = [...WINDOWS.filter((h) => h * 1.25 <= whole), whole]
ts

hub 1.3.1 及更早没有 history_days,这时按 7 天处理:那些版本匿名最多只给 168 小时。

7 天以内的窗口来自每分钟一行的明细,更宽的来自小时汇总,每个点至少一小时。一行的含义不变:资源仍是这段时间的平均值和峰值,延迟的 loss 和 band 是精确的,latency 在一个点跨多个小时时是各小时中位数按答上来的次数加权后的中位数。

ping 里的记录按站长在面板上排的监控顺序逐个排列,同一个监控的记录按时间先后。按 task_id 第一次出现的顺序生成曲线,图例和配色就和面板一致。hub 1.3.0 及更早按时间先后排,监控之间谁先谁后不固定。

// 每个监控一条线,先后就是面板上的顺序
const ids = [...new Set(ping.map((p) => p.task_id))]
ts

到期天数

/api/nodes 和 /api/ws 推送的快照里,每个节点带一个 expires_in:按 hub 的日期算的剩余天数,0 是今天到期,负数是已过期的天数,没有到期日为 null。

显示「N 天后到期」「已过期」用它,不要拿 expires_at 按浏览器的时钟自己算。hub 按自己所在时区的日期给在线节点顺延到期日;访客在别的时区时,自己算会让在线节点在顺延之前显示几个小时的「已过期」。

hub 1.2.0 及更早没有这个 key,只有这时才按 expires_at 自己算。null 表示没有到期日,要和「没有这个 key」分开判断:

// 旧 hub 没有 expires_in,才按浏览器时钟自己算;daysUntil 由主题自己实现
const days = node.expires_in !== undefined
  ? node.expires_in
  : daysUntil(node.expires_at)
ts

价格与付款周期

currency 是货币代码,交给 Intl.NumberFormat 显示。hub 1.3.1 起写入的都是三个大写字母:只检查格式,不核对代码是否真的存在,Intl 遇到不认识的代码会把代码本身当作符号显示。更早的 hub 不检查,那时存下的值可能不是三个字母,Intl 会抛错,要兜住。

// 地区写死 zh-CN:显示不随访客的浏览器语言变,
// 美元、港币写作 US$、HK$,日元写作 JP¥,不和人民币的 ¥ 混
function money(amount: number, currency: string): string {
  try {
    return new Intl.NumberFormat("zh-CN", {
      style: "currency",
      currency,
      // 最多两位小数,与价格的填写精度一致,日元的小数不会被取整
      maximumFractionDigits: 2,
    }).format(amount)
  } catch {
    // hub 1.3.1 之前存下的值,可能不是三个字母
    return `${currency} ${amount.toFixed(2)}`
  }
}
ts

billing_cycle 是付款周期:

值含义
monthly quarterly semiannual yearly biennial triennial1、3、6、12、24、36 个月
<n>m其余长度,n 是月数,1–1200,比如 60m 是五年
once一次性,不续期

有名字的长度只会以名字出现,不会出现 12m。hub 1.3.0 及更早只有带名字的七个值。

// 月数;once 是 0,不认识的是 NaN
function months(cycle: string): number {
  const named: Record<string, number> = {
    monthly: 1, quarterly: 3, semiannual: 6,
    yearly: 12, biennial: 24, triennial: 36,
  }
  if (cycle === "once") return 0
  return named[cycle] ?? Number(/^(\d+)m$/.exec(cycle)?.[1])
}
ts

节点分组

/api/nodes 和 /api/ws 推送的快照里,每个节点带一个 group:站长在面板上设的分组名,公开可读,空字符串表示未分组。hub 1.2.0 及更早没有这个 key,按空字符串处理。

// 按节点顺序收集用到的分组;旧 hub 没有 group 字段
const groups = [...new Set(nodes.map((n) => n.group ?? "").filter(Boolean))]
ts
  • 分组按节点顺序排,不按名字排。站长在面板上拖动节点来排分组,每个分组第一次出现的位置就是它的位置
  • 一个分组都没有时,页面和没有分组功能时一样。只有部分节点有分组时,其余节点要有「未分组」这样的去处,不能藏起来
  • 分组名是任意文本,最多 13 个字,可能叫 *、all、none,也可能就叫「全部」「未分组」。「全部」「未分组」这类标签的取值不要和分组名混在同一个字符串空间里,比如用 null 表示全部
  • 分组可能有几十个。横排的标签要能滚动或换行
  • 按分组切换时,汇总数字(在线数、流量、网速)跟着这一组算,不要一半跟着分组、一半是全站

公开备注

每个节点带一个 public_remark:站长在面板上写给访客看的一行说明,最多 100 字,不含换行,空字符串表示没有。hub 1.3.1 及更早没有这个 key,按空字符串处理。按纯文本渲染。

站长的私有备注是另一个 key remark,访客拿不到,见下文匿名拿不到什么。

主题设置

公告、布局、显示哪些区块这类整个站点共用的选项,在 theme.json 的 config 里声明。面板主题页的卡片上随之出现设置按钮,按声明画出表单;站长保存后,值存在 hub 的数据库里,主题运行时读取。

这是站点级设置唯一的存放处,下面两种做法都不要用:

  • 存进 localStorage:只在站长自己的浏览器里生效,访客和站长的其它设备都看不到
  • 让站长改 dist/ 里的文件:更新、重装主题都是整个目录替换,改过的文件随之消失

某位访客自己的偏好(深浅色、排序方式)不属于站点设置,仍然放 localStorage。

声明

下面只列出 config,其余六个字段照常要写:

{
  "config": [
    { "type": "title", "label": "首页" },
    { "key": "notice", "type": "text", "label": "公告",
      "default": "", "help": "显示在页面顶部,留空不显示" },
    { "key": "layout", "type": "select", "label": "布局",
      "default": "grid",
      "options": [
        { "value": "grid", "label": "卡片" },
        { "value": "table", "label": "表格" }
      ] },
    { "key": "show_map", "type": "boolean", "label": "显示地图",
      "default": true },
    { "key": "columns", "type": "number", "label": "每行卡片数",
      "default": 3, "min": 1, "max": 6 }
  ]
}
json
字段必需含义
key是读取时用的名字,同一个主题里不能重复
type是string 单行文本、text 多行文本、number 数字、boolean 开关、select 下拉
default是默认值,类型要和 type 对得上;select 的默认值必须是某个选项的 value
label否表单上显示的名称,字符串,不写就显示 key
help否表单上的一行说明,字符串
optionsselect 必需[{ "value": "...", "label": "..." }],value 是非空字符串,label 是字符串、不写就显示 value
min、max否number 的取值范围,面板按它拦下越界的输入

{ "type": "title", "label": "..." } 是分组标题:只有 label,不存值,它后面的设置项归入这一组,第一个标题之前的归入「通用」。面板按设置项的多少排版:

设置项面板里的样子
6 项以内单列,分组标题显示为小标题
超过 6 项对话框加宽,两列显示
超过 6 项,且分成两组以上左侧列出各组,右侧只显示当前这一组

没有设置项的分组标题不算一组。设置项超过十来个时写分组标题,否则站长面对的是一整页看不出归属的选项。加分组标题只改 theme.json,读取代码不用动:下面的示例代码会跳过分组标题。

面板逐项检查,不合格的那一项不显示,其余照常:key 重复、type 不认识、default 与类型不符、label 或 help 不是字符串、select 缺选项、分组标题没有 label 都算。改完 theme.json 装到 hub 上打开设置看一眼,缺了哪一项,就是哪一项写错了。

读取

GET /api/themes/{short}/config

返回站长改过的项,没改过的不在里面:

{ "notice": "今晚 23:00 维护", "show_map": false }
json
  • 从没保存过:{}
  • 公开页关闭时,未登录的请求得到 401,条件和 /api/nodes 一样
  • hub 1.2.0 及更早的版本没有这个接口,得到 404
  • 不要求主题已安装,任何 short 都能读。开发服务器代理到现成的 hub 时,读到的就是这个主题自己的设置;写入则要求主题已装在这个 hub 上

401、404 和网络错误一律按默认值渲染,不要报错,也不要为此显示提示。

hub 只存改过的项,默认值由主题自己补。这样主题作者在新版本里改了某个默认值,没动过这一项的站点会跟着变,改过的站点保持站长的选择。

示例代码

默认值只在 theme.json 里写一遍,构建时导入(Vite 直接支持;TypeScript 需要 resolveJsonModule,moduleResolution: "bundler" 下默认开启):

// src/lib/config.ts
import manifest from "../../theme.json"
 
type Field = {
  key: string
  type: string
  default: unknown
  options?: { value: string }[]
  min?: number
  max?: number
}
 
// 和面板同一套取值检查。保存的值可能来自这个主题的旧版本,
// 与当前声明对不上的,当作没有保存过
function fits(field: Field, value: unknown): boolean {
  switch (field.type) {
    case "boolean":
      return typeof value === "boolean"
    case "number":
      return typeof value === "number" && Number.isFinite(value)
        && value >= (field.min ?? -Infinity) && value <= (field.max ?? Infinity)
    case "select":
      return !!field.options?.some((option) => option.value === value)
    default:
      return typeof value === "string"
  }
}
 
// 分组标题(type 为 title)不存值
export const fields = (manifest.config as Field[]).filter((f) => f.type !== "title")
 
export async function loadConfig(): Promise<Record<string, unknown>> {
  let saved: Record<string, unknown> = {}
  try {
    const res = await fetch(`/api/themes/${manifest.short}/config`)
    if (res.ok) saved = await res.json()
  } catch {
    // 断网同样按默认值
  }
  const pick = (f: Field) => (fits(f, saved[f.key]) ? saved[f.key] : f.default)
  return Object.fromEntries(fields.map((f) => [f.key, pick(f)]))
}
ts

页面启动时和 /api/me、/api/nodes 并行请求,不要排在它们后面。

主题还想让访客在自己的浏览器里再调一层(比如深浅色)时,优先级从低到高是:theme.json 默认值、hub 里的站点设置、这位访客的 localStorage。

主题自带的设置界面

配色、背景这类要边调边看的设置,主题可以在公开页上提供自己的设置界面,站长登录后(/api/me 的 authed 为 true)直接保存到 hub:

// 接在上面的 src/lib/config.ts 里。
// values 是设置界面里当前的值,每个 key 一项
export async function saveConfig(values: Record<string, unknown>): Promise<void> {
  const url = `/api/themes/${manifest.short}/config`
  // 先读 hub 里原始的那份,不是 loadConfig() 合并过默认值的结果
  const read = await fetch(url)
  if (!read.ok) throw new Error(await read.text())
  const next: Record<string, unknown> = await read.json()
  // 只改声明过的项,其余 key 原样留下;等于默认值的删掉
  for (const f of fields) {
    if (values[f.key] === f.default) delete next[f.key]
    else next[f.key] = values[f.key]
  }
  // 站长登录时才有权限。失败要让站长看到,不能当作已保存
  const res = await fetch(url, {
    method: "PUT",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(next),
  })
  if (!res.ok) throw new Error(await res.text())
}
ts

body 是整个设置对象,会替换已保存的那份,规则和面板相同:

  • 只放与默认值不同的项,站长改回默认值的项要从对象里删掉
  • 先读后写,保留不认识的 key:面板和其他版本的主题可能存过当前代码不认识的项
  • 值要符合 theme.json 里的声明,面板也会读到它们
  • 整个对象不超过 64 KiB,超过得到 413
  • 主题要已装在这个 hub 上,否则得到 400;未登录得到 401

面板上的设置按钮始终都在,两边改的是同一份数据。主题自带的界面只是多一个入口,theme.json 里的声明仍然要写全。

约定

  • key 发布后不改名。改名等于丢掉站长已经保存的值;含义或类型变了就换一个新 key。旧值留在 hub 里,不影响显示,但访客仍能读到;站长在面板里点「恢复默认」(左侧列出各组时是「全部恢复默认」)再保存,会连同这些旧值一起清掉
  • 设置是公开的。所有访客都能读到,不要设计让站长填密钥、token 或内网地址的字段
  • 设置管显示,不管权限。主题里「不显示价格」只是不画,接口里照样有;要对访客真正隐藏的数据,得由 hub 不输出。见下文匿名拿不到什么
  • 原始 HTML 不进页面。文本按纯文本渲染:React 里直接写 {notice},原生 DOM 用 textContent。公告这类要排版的,可以按 Markdown 渲染,但渲染器必须转义其中的 HTML,链接只放行 http:、https:、mailto:。不要把设置值交给 innerHTML、dangerouslySetInnerHTML 或 v-html

匿名拿不到什么

匿名访问 GET /api/nodes 只返回公开的节点,响应里没有地址(ip、ipv4、ipv6、ipv4_pin、ipv6_pin、ipv4_auto、ipv6_auto、addresses)、hostname、remark、token 这些 key:不是空字符串,而是根本不存在。国家只给一个 country,手填的优先。

指标部分走白名单:内核原始计数器 net_rx_total、net_tx_total 和 boot_id 谁都拿不到,iface 和地址一样只在登录后返回。metrics 里的 total_rx、total_tx、month_rx、month_tx 由 hub 填入,与顶层同名字段相同。

站长登录后,/api/nodes 和 /api/ws 返回完整视图:admin 为 true,包括不公开的节点和上面这些 key。请求同源,浏览器自动带上登录凭据,主题不用额外处理,就能在站长登录时显示 remark 这类私有信息。

字段定义以 hub 的源码为准。写主题时按「这个 key 可能不存在」处理。

三种要处理的状态

状态长什么样
离线节点metrics: null
刚连上还没上报online: true + metrics: null
坏数据字段缺失或类型不对

第二种最容易漏。默认主题在渲染入口再检查一次指标是否完整,缺失或格式不对时显示「不可用」,不让一个节点的坏数据导致整个页面白屏。

接口出错时

hub 的错误回复一律是一行中文纯文本(Content-Type: text/plain),可以原样显示给访客,例如公开页关闭时的「需要登录后查看」、历史查询过多时的「查询历史的请求太多,稍后再试」。hub 1.3.0 及更早的版本这里是英文。

不是 text/plain 的错误回复来自 hub 前面的反代或 CDN,比如 nginx 的 502 页面、Cloudflare 的拦截页。它们的内容不要显示出来,按状态码给一句说明。请求没发出去(断网),或者返回 200 却不是 JSON(被代理换成了网页),也按同样的办法处理。默认主题 src/lib/api.ts 里的 api() 就是这么写的,可以直接照抄。

路由

未知路径回落到主题自己的 dist/index.html,客户端路由因此可用。/admin、/api、/install.sh、/agent/* 由 hub 处理,主题覆盖不了。

默认主题用 /node/{id} 做详情页。前面有按路径放行的反代或 WAF 时,要把主题的路由前缀加进去:从列表点进去只是前端路由切换,刷新详情页才会真正请求这个路径,症状是「点进去正常,一刷新就被拦」。写主题时在 README 里列出自己的路由。

本地开发

主题只读公开数据,开发服务器可以直接拿一个现成的 hub 当数据源,只要它开着公开状态页(设置页的「开放公开状态页」):

# 在主题目录里:Vite 把 /api 和 WebSocket 代理到这个 hub
MONITOR_HUB=https://hub.example.com npm run dev
bash

默认主题的 vite.config.ts 读的就是这个变量,自己写主题照抄即可;不设时代理到 http://127.0.0.1:9911。

在本机起一个 hub

手上没有 hub,或者想要一份能随便改的数据,就在本机起一个,自己添加节点:

# --site 只是为了让面板允许添加节点,任意一个 https 域名即可
monitor-hub --listen 127.0.0.1:9911 --db /tmp/monitor.db \
  --site https://hub.example.com
 
# 另开一个终端,在主题目录里:不设 MONITOR_HUB,代理到 127.0.0.1:9911
npm run dev
bash

打开 http://127.0.0.1:9911/admin,用 hub 启动时打印的应急密码登录,添加节点后点它的安装按钮。命令里的地址是上面的占位域名,用不了,只复制 --token 后面的值,在本机直接运行 agent:

# agent 连回环地址允许明文;二进制从 agent 仓库的 release 下载
monitor-agent --server http://127.0.0.1:9911 --token <token>
bash

agent 只有 Linux 版。在 macOS 上开发时节点会一直显示离线,要看实时数据,用上面代理到现成 hub 的方式。

安装与切换

在主题页选一个即可切换,不用重启;选中的主题缺失或损坏时自动回落到内置默认主题。声明了设置的主题,卡片上多一个设置按钮,保存后公开页刷新即生效。设置存在 hub 的数据库里,更新、重装、删掉再装回主题都不会丢,数据库备份里也有。

安装有三种方式:

  • 在主题页填主题的 GitHub 仓库地址,点「从 GitHub 安装」,hub 下载它最新 release 里的 theme.tar.gz。不用先把包下到自己电脑上,也就不会因为没下载完而装不上
  • 在主题页上传下载好的 theme.tar.gz。要下 release 里名为 theme.tar.gz 的文件,不是 Source code
  • 把主题目录直接放进 hub 的 --themes 目录(一键脚本部署是 /opt/monitor/data/themes/)

前两种都是同名主题整体替换。hub 访问不了 GitHub 时,「从 GitHub 安装」和下面的更新都需要在设置页填 GitHub 代理,见hub 连不上 GitHub;能访问时不用填。

卡片上的「从 GitHub 更新」从 theme.json 里 url 指向的 GitHub 仓库取最新 release,tag 与已装版本相同时不下载。

内置默认主题同样可以就地更新,不用升级 hub:更新会把新版装进 themes/default/,此后使用磁盘上的这份,卡片上的「内置」标记随之消失;删掉它就回到二进制里的那份。二进制里的默认主题删不掉也改不了,磁盘上的主题出问题时总能回落到它。

安装的主题是在访客浏览器里运行的第三方代码,和 hub 同源。安装时的检查只防止写到目录外和占满磁盘,不检查里面的 JS。只装信得过的来源。

参考实现

monitor-theme-default,React + Vite + shadcn/ui,黑白配色。它既是内置默认主题,也是第三方主题的范本:hub 嵌入的和装进 themes/ 的是同一个 theme.tar.gz。

在 GitHub 上修改这一页最后更新 2026-10-03