Spot Monitor

开发指南

主题开发

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

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

主题包

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

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

发布时打成 theme.tar.gz,解开就是这个目录;同一个 release 里再放一个 theme.tar.gz.sha256,内容是一行 sha256(sha256sum theme.tar.gz > theme.tar.gz.sha256 就是这个格式,多出来的文件名会被忽略)。

hub 从 GitHub 安装或更新时先读这个校验和、再核对下载到的字节:它证明的是完整性(校验和与包来自同一个 release),不是作者身份。没有这个文件就装不上——hub 会提示让作者补上,或者手动上传那个包(上传这条路不做校验和核对)。直接放进 --themes 目录也不受此约束。

theme.json

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

字段可留空含义
name否显示名称
short否唯一短名,限字母、数字、-、_。取 default 则顶替 hub 内置的那份
description是简介
version是主题版本。用一键更新时要和 release 的 tag 对上(v1.2.0 对 1.2.0),否则每次都会重装一遍
author是作者
url是源码地址。指向 GitHub 仓库时,面板的一键更新与「从 GitHub 安装」都从它的 release 取 theme.tar.gz,并核对配套的 theme.tar.gz.sha256

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

主题设置(config)

站长能在面板里改的东西,由主题自己声明,不必改主题包再重新上传。config 是一个数组,按顺序就是表单的顺序:

"config": [
  { "type": "title", "label": "布局" },
  { "key": "show_summary", "type": "boolean", "label": "显示汇总", "default": true,
    "help": "节点列表上方的四格汇总" },
  { "key": "default_view", "type": "select", "label": "默认视图", "default": "grid",
    "options": [{ "value": "grid", "label": "网格" }, { "value": "list", "label": "列表" }] }
]
json
类型值表单画成
string字符串单行输入框
text字符串多行输入框
number数字数字输入框,可给 min / max
booleantrue / false开关
select字符串下拉框,options 给出可选项
title——分组标题,不取值,只要 label

字段自身除 key、type、default 外都可以省:label 缺省用 key,help 是说明文字。面板只存与默认值不同的项,其余用主题声明的默认值补上——所以日后改了某个默认值,没动过这一项的站点会跟着变。主题作者改表单不认识的 key 也会被保留。

在 theme.json 里写下 config 之后,站长在面板的「主题」页会看到设置按钮:

  • GET /api/themes/{short}/config 返回站长改过的项,没存过是 {};匿名可读(公开页关着时 401)
  • PUT /api/themes/{short}/config 需要管理员登录,且主题须已安装

值由 hub 按主题保存,更新、重装、删除主题都不会丢,数据库备份也带着它。

读回来时自己校验:hub 不按声明检查写入的值,而且「在某个版本下存的值」会遇到「下一个版本」。推荐的写法是逐个字段比对类型,不符就用默认值;任何失败都退回默认值——包括装在没有这个接口的老 hub 上时的 404。本项目的默认主题就是这么做的(src/lib/config.ts),装到老 hub 上照常渲染。

上限

限制值
整包32 MiB
解压后总量64 MiB
单个文件8 MiB
文件和目录数2000

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

可以用的接口

读取接口只有这四个,都是同源请求,匿名可读(公开页关着时未登录的请求得到 401)。

接口用途
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。匿名上限 168,登录后 2160,超出时静默收窄
  • points=W:图表能画下的点数,60–1440。只会让 hub 抽得更稀,不会更密
  • series=:逗号分隔,取 metrics、ping、availability 里的一个或几个;省略表示全要。只取要画的那些,省掉的每一类原本占响应的一部分

延迟监控的名称在响应的 probes 里随数据一起返回,匿名可读,画延迟图不需要第二个请求,也不需要登录;同一条 ping 数据里还有一个 loss,按探针给出整个窗口的丢包百分比。

到期天数

/api/nodes 和 /api/ws 推送的快照里,每个节点带一个 expires_at(YYYY-MM-DD,没有到期日为 null)。「N 天后到期」「已过期」由主题按访客浏览器的日期自己算:

// 到期日当天的本地零点;0 是今天到期,负数是已过期的天数,null 表示没有到期日
function daysUntil(date: string | null): number | null {
  if (!date) return null
  return Math.ceil((new Date(`${date}T00:00:00`).getTime() - Date.now()) / 86_400_000)
}
ts

hub 按自己所在时区的日期给在线节点顺延到期日,和访客的日期可能差一天。

节点分组

/api/nodes 和 /api/ws 推送的快照里,每个节点带一个 group:站长在面板上设的分组名,公开可读,空字符串表示未分组。

// 按节点顺序收集用到的分组
const groups = [...new Set(nodes.map((n) => n.group ?? "").filter(Boolean))]
ts
  • 分组按节点顺序排,不按名字排。站长在面板上拖动节点来排分组,每个分组第一次出现的位置就是它的位置
  • 一个分组都没有时,页面和没有分组功能时一样。标签整行不用画
  • 没有分组的节点只在「全部节点」里。不要塞进某个分组的标签
  • 分组名是任意文本,没有长度限制,可能叫 *、all、none,也可能就叫「全部节点」。这类标签的取值不要和分组名混在同一个字符串空间里,比如用 null 表示「全部」
  • 分组可能有几十个。横排的标签要能滚动或换行
  • 按分组筛选时,列表跟着这一组算;默认主题顶部的汇总卡片仍然是全站的,不随分组变化

可用率与故障历史

快照里每个节点还带一个 uptime,是它「有上报的分钟数占应有的分钟数」的比例,以及测的是哪个窗口:

"uptime": { "d7": 0.98, "d30": 0.95, "from7": 1789793700, "from30": 1787806500, "to": 1790398440 }
json
  • d7、d30:近 7 天、近 30 天的比例,0–1

  • from7、from30、to:两个窗口实际覆盖的区间(epoch 秒)。窗口会被节点自己的创建时间和 hub 的保留天数截短,所以别把「近 30 天」写死,按 from/to 说跨度

  • 档位也不要写死:GET /api/me 的 history_days 是这台 hub 保留的天数,按它生成档位,并且不要给出超过它的档位——那会画出比所选窗口更短的图,看起来像丢了数据,而不是像上限。这个字段是后来才有的,旧 hub 不会返回它,取不到时回退到 7 天。默认主题的 src/lib/ranges.ts 就是这么做的,可以直接照抄。

    { "history_days": 90 }
    json
  • 它衡量的是「agent 有没有在跟 hub 说话」,不是探测目标通不通;后者是 ping 的 loss

按小时的时间轴和故障列表由 metrics 的 availability 系列给出:

GET /api/nodes/{id}/metrics?hours=168&series=availability
"availability": {
  "from": 1789793700, "to": 1790398440,
  "buckets": [{ "ts": 1789797600, "n": 60, "m": 60 }],
  "incidents": [{ "start": 1789900000, "minutes": 12 }]
}
json
  • buckets:每小时一段,n 是这一小时里有上报的分钟数,m 是应有的分钟数(窗口首尾那一小时是部分的)。n 为 0 是离线,n < m 是部分异常
  • incidents:按时间正序的故障,start 是开始的分钟(epoch 秒),minutes 是持续的分钟数
  • from、to 同样是 hub 实际测到的区间,可能短于请求的 hours
  • hub 根本没有记录的时段(节点还没创建,或历史已被清理)不是故障:buckets 里不会出现,要按「无数据」画,别画成红的

列表上的三样标记

默认主题在卡片和列表上画的这三样东西,数据都在快照里:

  • 国家旗:country 是两位代码,空则什么都不画
  • 发行版图标:os 是发布版名称,按名字匹配
  • 在线时长徽章:online 为真时用 metrics.uptime(机器已开机的秒数),为假时用 last_seen 和现在的差;从未上报过的节点是第三种状态「未接入」,与「离线」不同

metrics 为 null、或 online 为真但还没上报时,这些字段取不到,徽章要能少一半而不报错。

匿名拿不到什么

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

指标部分走白名单,所以 boot_id、iface、net_rx_total、net_tx_total 也只给登录后的面板。

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

三种要处理的状态

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

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

路由

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

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

本地开发

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

默认主题的 vite.config.ts 把 /api 和 WebSocket 代理到 http://127.0.0.1:9911;指向别的 hub 就改这一行,自己写主题照抄即可。

在本机起一个 hub

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

# --site 只是为了让面板允许添加节点,任意一个 https 域名即可
monitor-hub --listen 127.0.0.1:9911 --db /tmp/monitor.db \
  --site https://hub.example.com
 
# 另开一个终端,在主题目录里:默认就代理到 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 的方式。

安装与切换

在主题页选一个即可切换,不用重启;选中的主题缺失或损坏时自动回落到内置默认主题。

安装有三种方式:

  • 填仓库地址:在主题页的「安装主题」里粘贴主题的 GitHub 仓库地址(仓库首页、Releases 页、某个版本的页面都可以),hub 读它最新 release 里的 theme.tar.gz 装上。地址只取 <owner>/<repo> 两段,下载地址由 hub 自己拼,所以它仍然只连 api.github.com 与 github.com
  • 上传主题包:下载 release 里的 theme.tar.gz 再上传——不要选 Source code,那不是主题包
  • 放进目录:把主题目录直接放进 hub 的 --themes 目录(一键脚本部署是 /opt/monitor/data/themes/)

三种都是同名主题整体替换。卡片上的「从 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-02