chore: v0.6.0 baseline — 主题美化前存档

已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
This commit is contained in:
tamakyi
2026-10-13 18:31:58 +08:00
commit 3db3ce570a
31 changed files with 5136 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
name: probe
on:
schedule:
# GitHub Actions 计划任务最小粒度为 5 分钟
- cron: "*/5 * * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
probe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: 执行一轮探测
run: node prober/node/index.js --once
- name: 提交 status.json
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add public/status.json
git diff --staged --quiet || git commit -m "chore: update status snapshot"
git push
+29
View File
@@ -0,0 +1,29 @@
name: deploy-pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: public
- id: deployment
uses: actions/deploy-pages@v4
+11
View File
@@ -0,0 +1,11 @@
node_modules/
*.log
.DS_Store
.wrangler/
.dev.vars
# 私有配置:真实地址/端口/密钥,不入库
local.monitors.json
# 本地备份包
backups/
# 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行)
.workbuddy/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 shiroko-monitor contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+436
View File
@@ -0,0 +1,436 @@
# shiroko-monitor
轻量级服务监控面板。借鉴 Uptime Kuma 的交互思路,代码完全自研,**零运行时依赖**(Node ≥ 18 直接跑,无 npm install),纯静态前端 + 可插拔探针,支持本地 / GitHub Pages / Cloudflare Workers / EdgeOne Pages 等多种低成本部署。
## 功能特性
- **HTTP 可用性探测**:状态码(支持自定义期望值,如把 500 判为正常)、响应延迟、超时控制、不自动跟随重定向(保留 3xx 原始码)
- **TCP 端口探测**:连通性判定、连接耗时,区分「连接被拒绝」与「超时」
- **Banner 探测(SSH / SMTP / FTP / POP3 / IMAP 等)**:连接后**被动读取**服务端主动发送的标识串,支持 `expectBanner` 规则校验(可做版本合规检查);不做认证、不发任何数据,不会触发目标侧的 fail2ban 封禁。默认只输出协议族摘要,不回显版本号
- **90 天可用率**:按天聚合每次探测结果(保留 90 天,自动淘汰旧数据),卡片底部以 90 格色条展示每日可用率(≥99% 绿 / ≥95% 橙 / <95% 红 / 无数据灰),悬停显示当日「可用率(在线次数/总次数)」;顶部汇总整体可用率
- **两种面板形态**(顶部一键切换,也可用 `settings.view` / `?view=` 指定):
- **表格**:一行一服务的高密度列表(状态/名称/目标/延迟/结果/最近检测/心率/可用率),默认视图
- **卡片**:一服务一卡,含心率图与可用率条
- **数据源过滤**:`settings.dataFilter` 可选 `both`(默认,两边都展示)/ `config`(只看配置文件里的自建目标)/ `uptimerobot`(只看 UptimeRobot 数据);也可用 `?filter=` 临时切换,顶部统计随展示集合同步
- **可配置的自动刷新**:`settings.refreshInterval`(秒)决定面板自动刷新节奏;未显式配置时,「只看 UptimeRobot」模式默认 300 秒(UR 自身 5 分钟才更新一次,刷新更快没有意义)
- **监控面板**:
- 每个服务的在线 / 离线 / 等待检测状态(绿点脉冲动画)
- 响应延迟、结果(状态码 / 端口 / 标识串)、最近检测时间(相对时间 + 悬停显示精确时间)
- 最近 48 次检测的心率图(绿色 = 在线,红色 = 离线,柱高 = 延迟)
- 分组页签筛选,顶部统计摘要(监控数 / 在线 / 离线 / 平均延迟 / 90 天可用率)
- 自动刷新倒计时(基于截止时间,不受浏览器后台标签页定时器节流影响)、手动刷新按钮、暗色模式自适应(`prefers-color-scheme`)
- **配置即文件**:`public/monitors.json` 单一配置源,改完即生效(本地探针支持热重载)
- **刷新节奏由服务端驱动**:探测由服务端按各目标 `interval` 持续进行,快照里带上「数据最后更新时刻 + 服务端更新节奏」;面板的倒计时对齐到**服务端下次更新时间**,不因访客打开页面而重新计时(旧行为下每次进页面都要重等一整个周期,最坏会看到近两倍周期的旧数据)。自动刷新只读取数据,只有手动点「刷新」才触发一次重新检测
- **UptimeRobot 双向兼容**:
- *接入*:把 UptimeRobot 已有的监控结果当数据源拉进来,与自建探测并列展示(异地视角),状态、延迟、90 天可用率、心率图全部映射自 UR 官方数据;UR 读取失败时保留上次数据,绝不把服务误判为离线
- *输出*:提供 `/api/v2/getMonitors`,字段与状态码编码对齐 UptimeRobot v2,既有脚本/看板可直接消费
- **滥用防护与信息泄露控制**:刷新接口仅内网/回环可触发、按来源冷却(429 + Retry-After)、可整体关闭;支持 `hideTargets` / `redactDetails` 与 `local.monitors.json` 私有配置分层,公开配置里可以只有服务名 —— 详见 [docs/SECURITY.md](docs/SECURITY.md)
- **三种数据源自动解析**:边缘 API(实时)→ 静态快照 → 浏览器直连,也可用 `?source=` 强制指定
## 快速开始(本地)
```bash
# 常驻模式:周期探测 + 伺服面板(默认端口 8787)
node prober/node/index.js
# 或 npm start
# 单轮探测后退出(供 CI 使用,历史心率与 90 天分桶从已有 status.json 恢复)
node prober/node/index.js --once
# 或 npm run probe
# 生成演示数据(含 90 天可用率样例,用于静态部署首版占位)
node prober/node/index.js --once --demo
# 或 npm run demo
# 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处)
node prober/node/index.js --once --demo --out=dist/status.json
```
启动后打开 <http://localhost:8787/> 即可看到面板。无需安装任何依赖。
## 项目结构
```
shiroko-monitor/
├── monitors.json ─────────────── 已移至 public/monitors.json(单一配置源,见下)
├── package.json # 零依赖,仅封装常用命令
├── LICENSE # MIT
├── public/ # 纯静态面板(可整体部署到任意静态托管)
│ ├── index.html # 面板页面骨架
│ ├── monitors.json # ★ 监控目标配置(改这里)
│ ├── status.json # 探针输出的状态快照(面板读取)
│ └── assets/
│ ├── style.css # 浅色主题 + 暗色自适应
│ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测
│ └── favicon.svg
├── prober/node/ # Node 探针(本地 / CI 用)
│ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config
│ └── lib/
│ ├── http-probe.js # HTTP 探测(fetch,redirect: manual)
│ ├── tcp-probe.js # TCP 探测(net.connect)
│ ├── banner-probe.js # Banner 探测(被动读取标识串,零认证零交互)
│ └── uptimerobot.js # UptimeRobot 只读数据源(API v2 映射)
├── scripts/ # 开发验证脚本(需 playwright)
│ ├── verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板渲染
│ ├── verify-views.cjs # 视图渲染、切换持久化与数据源过滤
│ ├── verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流
│ └── verify-uptimerobot.cjs # UR 映射单测 / 兼容接口 / 失败路径 / 真实 Key 冒烟
├── local.monitors.json # 私有配置(不入库):真实地址、API Key
├── edge/cloudflare/ # Cloudflare Workers 边缘探针
│ ├── worker.js # cron 探针 + KV 存储 + /api/* + 静态伺服
│ └── wrangler.toml
├── .github/workflows/
│ ├── monitor.yml # 每 5 分钟跑一轮探针并提交 status.json
│ └── pages.yml # 推送 main 时部署 GitHub Pages
└── docs/
├── DEPLOYMENT.md # 四种部署方案详细步骤
├── SECURITY.md # 刷新滥用防护 / SSH 探测边界 / 信息泄露控制
└── screenshot-*.png # 各视图实测截图
```
> 配置源为什么放在 `public/` 下:面板用相对路径 `fetch('monitors.json')`,探针(Node/Worker)、静态托管(Pages)读取的都是同一份文件,零拷贝、零漂移。配置文件不含任何密钥,可安全公开。
## 配置说明(public/monitors.json)
```jsonc
{
"settings": {
"title": "Shiroko Monitor", // 面板标题
"description": "轻量级服务监控面板",
"view": "table", // 默认视图:table | cards
"dataFilter": "both", // both | config | uptimerobot(详见「面板形态与数据源」)
"refreshInterval": 30, // 面板检查数据的最大间隔(秒,最小 5);实际对齐服务端更新节奏
"dataSource": "auto", // auto | api | static | browser
"allowRefresh": true, // 是否允许触发强制探测(公网部署建议 false;详见 docs/SECURITY.md)
"refreshLocalOnly": true, // 仅内网/回环来源可触发强制探测
"refreshCooldownSec": 5, // 每个来源的触发冷却(秒)
"hideTargets": false, // true = 面板不渲染任何地址(对外发布用)
"redactDetails": false, // true = 快照 detail 只保留「正常/异常」
"uptimeRobot": { // UptimeRobot 只读数据源(Key 建议放 local.monitors.json)
"enabled": false,
"apiKey": "",
"pollIntervalSec": 300,
"group": "UptimeRobot"
}
},
"monitors": [
{
"id": "example-com", // 唯一 ID,状态数据按此关联
"name": "Example 站点", // 显示名称
"group": "公共服务", // 分组名(相同名字归为一组)
"type": "http", // http | tcp | banner
"url": "https://www.example.com",
"method": "GET", // 可选,默认 GET
"expectStatus": [200], // 判定为「在线」的状态码列表
"interval": 60, // 探测间隔(秒);无人打开面板时的后台节奏
"timeout": 10, // 超时(秒)
"probeOnRefresh": false // 可选:false = 该目标不参与「刷新即重新检测」,只按自己的 interval 探测
},
{
"id": "cloudflare-dns-tcp",
"name": "Cloudflare 443 端口",
"group": "基础设施",
"type": "tcp", // 只测端口连通性
"host": "1.1.1.1",
"port": 443,
"interval": 60,
"timeout": 10
},
{
"id": "ssh-gateway",
"name": "跳板机 SSH",
"group": "基础设施",
"type": "banner", // 被动读取标识串(SSH/SMTP/FTP/POP3/IMAP/HTTP…)
"host": "10.0.0.5",
"port": 22,
"expectBanner": "^SSH-2\\.0-", // 可选:正则策略,不匹配即判离线(可做版本合规检查)
"bannerFlags": "i", // 可选:正则 flags,默认 i
"revealBanner": false, // 默认 false:只输出「SSH 响应正常」,不回显版本号
"interval": 300,
"timeout": 5
}
]
}
```
添加监控目标 = 在 `monitors` 数组里加一项,保存即可(本地探针自动热重载;静态部署等下一次探针写入)。
`interval` 与 `refreshInterval` 的关系:
- `interval`(每个目标):**服务端**的探测节奏,与是否有访客无关;也决定 90 天可用率的样本量
- `refreshInterval`(面板):面板去服务端取数据的最长间隔。由于倒计时对齐服务端更新节奏,页面打开期间**不会**额外触发探测;服务端数据比我配置的节奏更新更快时,面板会跟着服务端走
- 探测只由服务端驱动 → 打开再多标签页也不会增加对目标的压力(旧版本下每个访客都会触发探测轮次)
- 需要立刻看一次实时结果时,手动点面板上的「刷新」(服务端限流/关闭策略见 [docs/SECURITY.md](docs/SECURITY.md))
> ⚠️ **注意目标限流**:探测本身会消耗目标的配额。例如 GitHub 未认证 API 每小时仅 60 次,按 30s 节奏探测会很快耗尽并返回 403,面板会如实记为离线(那不是面板故障,而是服务真的在拒绝请求)。对这类目标请调大 `interval`,并可设目标级 `probeOnRefresh: false` 让它退出强制检测。仓库默认配置里的 `github-api` 就是这样一个示例目标。
## 数据源模式
| 模式 | 触发条件 | 数据新鲜度 | 说明 |
| --- | --- | --- | --- |
| `api` | 同源存在 `/api/status`(Node 服务器 / Cloudflare Worker) | 实时 | 首选 |
| `static` | 存在 `status.json`(GitHub Pages / EdgeOne 静态部署) | 取决于探针写入频率 | 次选 |
| `browser` | 以上都不可用 | 实时(浏览器直连) | 兜底,限制见下 |
`auto` 模式按上表顺序自动降级。可用 `?source=api|static|browser` 强制指定,便于调试。
## UptimeRobot 集成
### 一、把 UptimeRobot 当数据源(推荐)
UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自建探测并列。配置(**API Key 请放 `local.monitors.json`,不要写进公开配置**):
```jsonc
// local.monitors.json(已在 .gitignore 中)
{
"settings": {
"uptimeRobot": { "enabled": true, "apiKey": "ur123456-xxxxxxxxxxxxxxxxxxxxxxxx" }
}
}
```
```jsonc
// public/monitors.json 的 settings 里保留开关与默认值
"uptimeRobot": {
"enabled": false, // 公开配置默认关闭;由私有配置开启
"apiKey": "", // 留空;也可用环境变量 UPTIMEROBOT_API_KEY(适合 CI/Secrets)
"pollIntervalSec": 300, // 拉取间隔,别低于 UR 的检测间隔(免费版 5 分钟)
"group": "UptimeRobot", // 镜像目标在面板中的分组名
"includeTypes": [] // 可选,按 UR 类型过滤:1=HTTP 2=Keyword 3=Ping 4=Port
}
```
映射关系(全部来自 UR 官方 API,非我们的二次探测):
| 面板字段 | UptimeRobot 来源 |
| --- | --- |
| 在线/离线/等待 | `status`:2→在线,8/9→离线,0/1→等待检测 |
| 响应延迟 | 最新一条 `response_times`(缺失时用 `average_response_time`) |
| 心率图 | `response_times`(最多 48 个采样点,随 `response_times_average=30` 聚合) |
| 90 天可用率 | `custom_uptime_ratio`(1/7/30/90 天),逐日色条由本探针同步后自行累积 |
| 结果/原因 | 故障时取最近一次故障日志的 `reason.code/detail`,如 `403 Forbidden` |
| 类型徽标 | `UR·HTTP` / `UR·PORT` / `UR·KEYWORD`(与自建探测区分) |
行为约定:
- **只读**:仅调用 `getMonitors`,不做任何写操作,因此 `ur*` 开头的只读 Key 即可
- **不参与本地强制探测**:UR 镜像由 UR 自己探测,本探针只拉结果;面板「刷新」不会去跑它们
- **失败不误判**:Key 无效、限流、网络异常时只记录错误(页脚显示「UptimeRobot 同步异常」并给出原因),目标状态保持上一次的值
- 速率:一次请求取回全部监控,默认 5 分钟一次(12 次/小时),远低于 UR 的接口限制
### 二、对外提供 UptimeRobot 兼容接口
`GET|POST /api/v2/getMonitors` 返回与 UptimeRobot v2 同构的响应,便于已有脚本/看板迁移:
```jsonc
{
"stat": "ok",
"pagination": { "offset": 0, "limit": 50, "total": 14 },
"monitors": [{
"id": 536986782, // 由字符串 id 派生的稳定数字 id
"friendly_name": "Example 站点",
"url": "https://www.example.com",
"type": 1, // 1=HTTP/Keyword 4=Port(UR 编码)
"status": 2, // 2=在线 9=离线 1=未检测
"interval": 60,
"custom_uptime_ratio": "97.436-97.436-97.436-97.436", // 1-7-30-90 天,三位小数
"average_response_time": 1291,
"response_times": [{ "datetime": 1791880740, "value": 429 }]
}]
}
```
该端点不做鉴权(其内容与公开面板一致);如需鉴权请在其前方加代理。`api_key` 参数会被忽略。
## 面板形态与数据源
### 视图
| 视图 | 形态 | 适合 |
| --- | --- | --- |
| `table` 表格 | 一行一服务,列含状态/名称/目标/延迟/结果/最近检测/心率/可用率 | **默认**,目标多时可一屏扫完 |
| `cards` 卡片 | 一服务一卡 + 心率图 + 90 格可用率条 | 需要更大点击区域或看图细节 |
指定方式(优先级从高到低):URL 参数 `?view=table|cards` → 浏览器上次选择(localStorage)→ `settings.view` → 表格。
### 数据源过滤
| 值 | 含义 |
| --- | --- |
| `both` | 配置文件里的自建目标 + UptimeRobot 镜像目标都展示(默认) |
| `config` | 只看配置文件里的自建探测目标 |
| `uptimerobot` | 只看 UptimeRobot 的数据 |
配置在 `settings.dataFilter`;也可用 URL 临时覆盖(适合做固定链接,如挂墙看板):`?filter=uptimerobot`。
过滤只影响**展示与快照输出**,不影响探测——配置文件里的目标始终按各自 `interval` 探测。
顶部统计(监控数/在线/离线/平均延迟/可用率)按当前展示的集合实时汇总,不会出现「列表 8 行、统计 14 个」的不一致。
### 自动刷新节奏(服务端驱动)
```jsonc
"refreshInterval": 300 // 秒,最小 5;不配则默认 30,「只看 UptimeRobot」时默认 300
```
面板不再从「你打开页面的那一刻」重新计时,而是**对齐服务端的更新节奏**:
| 环节 | 由谁驱动 | 说明 |
| --- | --- | --- |
| 数据产出 | **服务端** | 自建目标按各自 `interval` 探测;UptimeRobot 按 `pollIntervalSec` 拉取。与是否有访客无关 |
| 面板检查时刻 | 对齐服务端 | 快照带 `dataUpdatedAt`(数据最后变更时刻)与 `updateIntervalSec`(服务端更新节奏),面板在「上次更新 + 一个周期」时去取一次 |
| `refreshInterval` | 上限 | 面板检查间隔不会超过它;服务端节奏更快时按服务端节奏走 |
| 手动「刷新」 | 你 | 唯一会触发**立即重新检测**的动作(服务端可拒绝/限流,见 docs/SECURITY.md)|
| 自愈 | 面板 | 若服务端数据超过 1.5 个周期没更新(如探针刚重启或被休眠),面板会自动触发一次重新检测 |
效果:无论你什么时候打开页面,倒计时都显示「距服务端下一次更新还有多久」——刚更新完打开是 300s,过了 4 分钟打开就只剩 60s。静态快照与浏览器直连模式没有可对齐的服务端节奏,仍按 `refreshInterval` 轮询。
## 90 天可用率
探针每完成一次探测,就把它计入「当日分桶」:`monitors.<id>.daily["YYYY-MM-DD"] = { total, up }`,只保留最近 90 天(超出自动淘汰)。面板据此渲染 90 格色条与百分比。
- **数据积累**:从探针首次运行当天开始累积,跑满 90 天后色条填满;没有数据的日期显示灰色(悬停提示「无数据」)
- **判定口径**:可用率 = 当日在线探测次数 / 当日总探测次数。**提高探测频率会提高统计精度**(60s 间隔约 1440 次/天,GitHub Actions 5 分钟粒度约 288 次/天)
- **本地时区**:Node 探针按运行机器的本地日期分桶;Cloudflare Worker 无本地时区概念,按 UTC 日期分桶
- **跨重启保留**:分桶随 `status.json` 持久化,重启/CI 单轮模式都会先恢复再追加,不会断档
- **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker
- **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=<path>` 写到别处
## 部署方案
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
| --- | --- | --- | --- | --- |
| 本地 Node | 探针 + 面板同机伺服 | 实时(按 interval) | 免费 | 内网自用、开发调试 |
| GitHub Pages + Actions | 纯静态 + Actions 定时提交 status.json | 每 5 分钟(Actions 下限) | 免费 | 公网演示、个人项目 |
| Cloudflare Workers | 边缘函数 cron 探针 + KV | 每 1 分钟 | 免费版可用(TCP 需付费版) | 公网生产、多目标 |
| EdgeOne Pages | 纯静态 + 数据更新同 Pages 路径 | 每 5 分钟 | 免费额度 | 国内访问加速 |
四种方案的详细步骤见 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)。
## 浏览器直连模式的限制(重要)
浏览器没有原始 TCP 能力,且受同源策略约束,`browser` 模式下:
- HTTP:优先以 `cors` 模式请求,目标响应带 `Access-Control-Allow-Origin` 时可读到真实状态码;否则降级 `no-cors`,**只能判断可达性,读不到状态码**(显示「跨域可达」)
- TCP:以 HTTP 连接行为近似——快速拒绝判为端口关闭;连接后无响应直到超时判为端口开放(启发式,无法区分「开放但静默」与「被防火墙丢弃」)
- HTTPS 页面无法探测 HTTP 目标(浏览器混合内容拦截,会判为离线)
- 结论:**浏览器直连只作兜底**。要准确的状态码与 TCP 探测,请使用 Node 探针或边缘 Worker 提供 `/api/status`
## 已实现 vs 路线图
### v0.1.0 已实现
- [x] HTTP 探测:状态码 / 延迟 / 自定义期望值 / 超时 / 保留 3xx
- [x] TCP 探测:连通性 / 连接耗时 / 拒绝与超时区分
- [x] Node 探针:常驻巡检、周期调度、配置热重载、历史心率持久化(重启不断档)、原子写入 status.json
- [x] Node 探针附带静态服务器与 `/api/status`、`/api/monitors`、`/api/refresh`
- [x] `--once`(CI 单轮)与 `--demo`(演示数据)模式
- [x] 面板:状态卡片、心率图、分组页签、统计摘要、相对/绝对时间、自动刷新倒计时、暗色模式
- [x] 数据源三级自动降级 + `?source=` 强制指定
- [x] Cloudflare Worker 边缘探针(cron + KV + 静态伺服)
- [x] GitHub Actions:定时探测 + Pages 部署
- [x] 四种部署路径文档
### v0.2.0 已实现
- [x] **90 天可用率**:按天分桶聚合、90 天滚动保留、卡片 90 格分级色条 + 悬停明细、顶部整体可用率(Node 与 Worker 双端一致实现)
- [x] `--out=<path>` 自定义快照输出路径;`--demo` 覆盖真实快照前给出告警
- [x] 修复:配置热重载后 `/api/status` 永久冻结在旧数据(闭包捕获了被替换的旧 state 引用,改为持有器对象)
- [x] 修复:慢目标(如 10s 超时)会被每秒的调度 tick 重复探测,形成探测风暴(改为探测前同步占位 + inFlight 标记)
- [x] 修复:非法/缺失 `interval` 会算出 `NaN` 导致该目标永久不再被调度(改为回落默认 60s)
- [x] 修复:刷新倒计时改按截止时间计算,不再受浏览器后台标签页定时器节流影响
- [x] `scripts/verify.cjs` 端到端回归验证(15 项断言,覆盖上述修复)
### v0.2.1 已实现
- [x] **刷新即重新检测**:`/api/refresh` 等待本轮探测完成后返回最新快照,面板自动刷新与手动「刷新」按钮都会真正重新探测目标(此前只是重读旧快照,导致「部分卡片停留在几十秒前、手动刷新也没变化」)
- [x] 服务端强制轮次去重:多标签页/多客户端同时刷新共享同一轮探测,2s 内的重复请求直接复用快照(避免对目标重复施压);强制轮次先等在途探测收尾,保证不跳过任何目标
- [x] `settings.probeOnRefresh`(默认 `true`):关闭后自动刷新退回只读轮询,手动刷新仍然强制检测
- [x] 目标级 `probeOnRefresh: false`:单个目标可退出「刷新即检测」(适合有限流的 API),只按自身 `interval` 探测;默认配置新增示例目标说明该用法
- [x] Cloudflare Worker 的 `/api/refresh` 同步改为「等待完成再返回」+ 同隔离体去重
### v0.3.0 已实现
- [x] **Banner 探测**(`type: banner`):被动读取 SSH/SMTP/FTP/POP3/IMAP 等标识串,`expectBanner` 正则策略校验(可做版本合规)、`revealBanner` 显式开启才回显;不做认证、不发数据,不触发目标封禁
- [x] **四种面板形态**:卡片 / 表格 / 大屏(深色 NOC)/ 极简(对外状态页),顶部一键切换 + `?view=` + `settings.view` + localStorage 记忆
- [x] **刷新接口滥用防护**:内网/回环来源限制(不信任 X-Forwarded-For)、每来源冷却 429 + Retry-After、轮次去重、总开关 `allowRefresh`;Worker 需 `REFRESH_TOKEN` 才开放
- [x] **信息泄露控制**:`local.monitors.json` 私有配置分层(公开配置可只留 id/name/group)、`hideTargets`、`redactDetails`、TCP 详情不再回显地址、banner 默认不回显版本
- [x] 私有配置分层 + `--config` / `--local-config`(多实例与测试用)
- [x] 修复:客户端中断请求(切页/关标签)后向已断开 socket 写响应会抛出未捕获异常,可能终止探针进程
- [x] 修复:非卡片视图的状态点沿用胶囊色(着色规则作用域写死在 `.card` 上)
- [x] 修复:首次打开面板时需等强制检测完成才出数据,期间显示空白「等待检测」——改为先用已有快照立即渲染
- [x] 新增 `docs/SECURITY.md`(滥用防护 / SSH 探测边界 / 信息泄露控制)与 `scripts/verify-security.cjs`、`scripts/verify-views.cjs`
### v0.4.0 已实现
- [x] **UptimeRobot 数据源(只读)**:把 UR 已有监控作为「异地视角」接入面板,状态/延迟/心率/90 天可用率/故障原因全部映射自 UR 官方字段;镜像目标不参与本地强制探测;同步失败只记录错误并保留上次数据
- [x] **UptimeRobot 兼容接口**:`/api/v2/getMonitors` 输出与 UR v2 同构(含 `custom_uptime_ratio`、`response_times`、数字 id 与状态码编码)
- [x] `stats.memory`:探针自报内存占用(RSS/heapUsed),方便观察「监控器自身」的开销
- [x] 配置分层增强:`--config` / `--local-config` 指定配置文件路径;UR API Key 支持私有配置或 `UPTIMEROBOT_API_KEY` 环境变量
- [x] 修复:非 http/tcp 类型的目标行渲染为 `undefined:undefined`(改为有 url 用 url、有 host 用 host:port)
- [x] 修复:热重载会丢弃 UptimeRobot 镜像目标已累积的逐日分桶
- [x] 修复(实测发现):`custom_down_durations` 不能作为请求参数传给 UR v2(返回 `invalid_parameter`),改为只从响应中读取
- [x] 新增 `scripts/verify-uptimerobot.cjs`(22 项:映射单测 / 兼容接口 / 失败路径 / 真实 Key 冒烟)
### v0.5.0 已实现
- [x] **表格设为默认视图**,移除大屏与极简两种形态(面板只保留表格/卡片,切换器更精简)
- [x] **数据源过滤** `settings.dataFilter`:`both` / `config`(仅配置文件)/ `uptimerobot`(仅 UR),支持 `?filter=` 临时覆盖;服务端快照与面板共用同一口径(私有配置覆盖后也不会错位)
- [x] **顶部统计按展示集合汇总**:切换过滤后不再出现「列表 8 行、统计 14 个」
- [x] **自动刷新间隔可配置**并按数据源自适应:`refreshInterval`(秒);只看 UptimeRobot 时默认 300 秒(UR 自身 5 分钟才更新)
- [x] `/api/refresh?scope=external`:只看 UR 的刷新只同步 UR 数据,不跑本地探测轮次;普通刷新也会顺带同步 UR(最小间隔 60 秒,尊重 UR 接口限流)
### v0.6.0 已实现
- [x] **刷新节奏改为服务端驱动**:快照新增 `dataUpdatedAt`(数据最后变更时刻)与 `updateIntervalSec`(服务端更新节奏),面板倒计时对齐到服务端下次更新,**不再每次打开页面从整周期重新计时**(旧行为最坏会看到近两倍周期的旧数据;实测:两次打开间隔 25s,倒计时差值 28s)
- [x] 自动刷新改为**只读**:探测完全由服务端按 `interval` / `pollIntervalSec` 驱动,访客数量不再影响对目标的压力;只有手动点「刷新」才触发立即重新检测
- [x] 新增自愈:服务端数据超过 1.5 个周期未更新时(探针刚重启、机器休眠过)面板自动触发一次重新检测
- [x] 移除已无意义的全局 `probeOnRefresh` 开关(目标级 `probeOnRefresh: false` 保留:该目标不参与强制检测);`refreshInterval` 语义澄清为「面板检查数据的最大间隔」
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
### 路线图(后续迭代)
- [ ] 通知渠道:Webhook / Server 酱 / Telegram / Bark / 邮件
- [ ] 故障事件记录:连续失败次数、事件时间线、状态变更历史
- [ ] 状态徽章:SVG badge(shields.io 风格)
- [ ] 更多探测类型:证书到期天数、响应体关键字、DNS 解析、ICMP(边缘端)、HTTP 重定向链
- [ ] 主动协议探测(Redis `PING`、MySQL 握手等):需先设计「发送探测字节」的安全边界与白名单,避免退化成通用交互工具
- [ ] 只读分享令牌:给 `hideTargets` 之外的场景做「带令牌才能看到真实名称」的分级
- [ ] 多区域探针:多 Worker 部署 + 结果合并,取容错视图
- [ ] 配置 Web UI(可选加密编辑),替代手改 JSON
- [ ] 面板:更多形态(如时间线视图、地图视图)、明暗主题手动切换、可用率区间可配置(7/30/90 天)
- [ ] 状态页 JSON API 的 CORS 开放与缓存策略细化
## 开发
```bash
# 本地起服务(含探针)
node prober/node/index.js
# 只验证探针逻辑
node prober/node/index.js --once
# UI 自动化实测(需 playwright,仅开发验证用,驱动系统 Chrome)
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项)
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项)
```
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
## 界面截图
| 表格(默认视图) | 卡片视图 |
| --- | --- |
| ![table](docs/screenshot-desktop.png) | ![cards](docs/screenshot-view-cards.png) |
UptimeRobot 数据源(`?filter=uptimerobot`,截图中含真实监控名与域名,公开发布前可自行删除):`docs/screenshot-uptimerobot.png`
演示数据(90 天满档)与移动端:`docs/screenshot-demo.png`、`docs/screenshot-mobile.png`
## 致谢与定位
交互设计参考 Uptime Kuma(心跳图、90 天可用率条、分组卡片、汇总统计),实现为自研轻量版:不追求 Kuma 的通知/多用户/数据库体系,只保留「配置文件 → 探针 → 状态面板」的最小闭环,并对静态/边缘部署友好。
+132
View File
@@ -0,0 +1,132 @@
# 部署方案详解
四种方案按「成本从低到要求、数据从准到轻」排列。所有方案共用同一份 `public/` 目录与 `public/monitors.json` 配置。
---
## 方案一:本地运行(Node)
最适合内网自用与开发调试,数据最实时。
```bash
node prober/node/index.js # 默认端口 8787
PORT=9000 node prober/node/index.js # 自定义端口
```
- 面板:`http://localhost:8787/`
- 状态 API:`http://localhost:8787/api/status`
- 改 `public/monitors.json` 后自动热重载,无需重启
- 历史心率存于 `public/status.json`,重启后自动恢复
- 90 天可用率按天累积在 `public/status.json`,重启不丢(每次探测先恢复分桶再追加)
- 面板刷新即重新检测(`/api/refresh` 等待本轮完成再返回);多标签页同时刷新只触发一轮探测
- 后台常驻可用 pm2 / systemd / Windows 服务,按需自行封装
**适用**:监控内网服务(面板浏览器与目标同内网时,浏览器直连模式也能用)。
---
## 方案二:GitHub Pages + Actions(零成本公网)
架构:仓库里的 `public/` 整体作为 Pages 站点;Actions 每 5 分钟跑一轮 Node 探针,把 `public/status.json` 提交回仓库,面板以 `static` 模式读取。
步骤:
1. 推送代码到 GitHub 仓库(`main` 分支)
2. 仓库 **Settings → Pages → Build and deployment → Source** 选 **GitHub Actions**
3. 首次提交时建议带一份占位数据,避免页面空白:
```bash
node prober/node/index.js --once --demo # 生成演示 status.json
git add public/status.json && git commit -m "chore: demo status" && git push
```
4. 之后 `pages.yml` 负责每次推送自动部署;`monitor.yml` 每 5 分钟更新一次 `status.json`(提交信息为 `chore: update status snapshot`)
5. 访问 `https://<用户名>.github.io/<仓库名>/`
注意事项:
- Actions 计划任务最小粒度 5 分钟,数据最多 5 分钟延迟;心跳图按「检测次数」记录,5 分钟粒度下 48 格 ≈ 4 小时
- 90 天可用率同样按「检测次数」统计,5 分钟粒度约 288 次/天;每轮 Actions 会把当日分桶续写到 `status.json` 并提交,因此可用率数据跨运行累积(若某天 Actions 未运行,该日色条会显示「无数据」而不是 0%)
- **静态快照模式没有服务端,面板无法触发重新检测**:此方案下刷新只是重新读取仓库里的 `status.json`,数据新鲜度完全取决于 Actions 的运行频率(面板页脚会显示「数据源:静态快照」以作区分)
- 若仓库开启了分支保护,需允许 `github-actions[bot]` 推送(workflow 已声明 `contents: write` 权限)
- 免费额度对公开仓库无限量,私有仓库每月 2000 分钟,足够
---
## 方案三:Cloudflare Workers(边缘实时)
架构:Worker 每分钟由 cron 触发,用 `fetch` 探测 HTTP、用 `cloudflare:sockets` 探测 TCP,状态写入 KV;同 Worker 伺服 `public/` 静态资源与 `/api/*`。面板以 `api` 模式实时读取。
```bash
# 1. 安装 wrangler(任选其一,不影响本项目运行时)
npm install -g wrangler
# 2. 登录并创建 KV 命名空间
wrangler login
wrangler kv namespace create MONITOR_KV
# 把输出的 id 填到 edge/cloudflare/wrangler.toml 的 [[kv_namespaces]].id
# 3. 部署(wrangler.toml 已指向 ../../public 作为静态资产,monitors.json 随之部署,无需手动导入 KV)
cd edge/cloudflare
wrangler deploy
```
- 面板与 API:`https://<worker 域名>/` 与 `/api/status`
- 手动触发一轮:`curl https://<worker 域名>/api/refresh`(等待本轮完成后返回最新快照,与本地探针同语义)
- cron 表达式在 `wrangler.toml` 的 `[triggers].crons` 中调整(`* * * * *` = 每分钟)
注意事项:
- **TCP 出站连接(`cloudflare:sockets`)需要 Workers 付费计划($5/月)**;免费计划下 `http` 类型探测与面板完全正常,`tcp` 类型会报错并记为离线
- KV 为最终一致存储,刚写入后立即读取可能有秒级延迟,对分钟级巡检无影响
- 90 天可用率分桶随 state 一起存于 KV,按 **UTC 日期** 分桶(边缘环境无本地时区);每分钟 cron 约 1440 次/天
- 免费版 Worker 每日 10 万次请求,面板静态资源走 Assets 不计入 Worker 请求数
- 中国大陆访问 Worker 默认域名不稳定,可绑定自有域名(需 Cloudflare 账号管理 DNS)
---
## 方案四:EdgeOne Pages(国内加速)
EdgeOne Pages 当前以静态托管为主,采用与方案二相同的「静态 + 定时数据」路径,国内访问体验更好。
步骤:
1. 登录腾讯云 EdgeOne 控制台,进入 **Pages → 新建项目 → 导入 Git 仓库**(或直接上传 `public/` 目录)
2. 构建配置:无构建步骤,输出目录填 `public`
3. 数据更新二选一:
- **A. GitHub Actions 驱动**:保持方案二的 `monitor.yml` 不变,EdgeOne 侧开启「构建时拉取最新提交」或配置 Webhook 触发重新部署
- **B. CI 推送触发**:在 `monitor.yml` 末尾追加一步,调用 EdgeOne 的部署 API / Webhook(按控制台提示填写)
4. 绑定自定义域名(需要备案的域名走国内加速线路)
说明:EdgeOne Pages 若上线边缘函数(类 Pages Functions),可把 `edge/cloudflare/worker.js` 的 `/api/*` 部分平移过去,实现分钟级实时数据;当前初稿以静态路径交付,接口契约完全一致,迁移时面板无需改动。
---
## 90 天可用率在各方案下的表现
可用率按「探测次数」加权统计,因此**探测频率直接决定精度**,也决定色条填充速度:
| 方案 | 每次探测间隔 | 每天样本数 | 90 天色条填满所需时间 |
| --- | --- | --- | --- |
| 本地 Node | 按 `monitors.json` 的 `interval`(示例 60s) | ≈1440 | 90 天 |
| Cloudflare Workers | cron 每分钟 | ≈1440(UTC 日期分桶) | 90 天 |
| GitHub Pages + Actions | ≥5 分钟(Actions 下限) | ≈288 | 90 天 |
| EdgeOne Pages | 同 GitHub Pages | ≈288 | 90 天 |
注意:分桶数据随 `status.json`(或 KV state)持久化并滚动保留 90 天,**必须由探针持续写入才会累积**——静态快照方案下依赖 Actions 按计划运行;Actions 计划任务在仓库长期无活动时可能被 GitHub 自动暂停(后台重新启用即可)。
需要临时提升精度时,可把 `interval` 调到 30s 或更低(本地/Worker 均支持,注意目标站点的压力与自身限流)。
---
## 方案对比与选型建议
| 维度 | 本地 Node | GH Pages | CF Workers | EdgeOne Pages |
| --- | --- | --- | --- | --- |
| 数据延迟 | 秒级(interval) | ≤5 分钟 | ≤1 分钟 | ≤5 分钟 |
| 90 天可用率精度 | 高(≈1440 次/天) | 中(≈288 次/天) | 高(≈1440 次/天) | 中(≈288 次/天) |
| TCP 探测 | ✅ | ✅(Actions 内) | 付费版 ✅ / 免费版 ❌ | 同 GH Pages |
| 真实状态码 | ✅ | ✅ | ✅ | ✅ |
| 内网目标 | ✅(浏览器同网即可) | ❌ | ❌ | ❌ |
| 国内访问速度 | 本机最快 | 一般 | 一般 | 快 |
| 运维成本 | 需保活进程 | 零 | 零 | 零 |
建议:个人/演示用 **GitHub Pages**;生产公网监控用 **Cloudflare Workers**;服务对象在国内且以 HTTP 监控为主用 **EdgeOne Pages**;内网资产用 **本地 Node**。
+154
View File
@@ -0,0 +1,154 @@
# 安全说明:刷新滥用防护、SSH 探测与信息泄露控制
面向「公网可访问的状态页」这个前提编写。核心结论先给:
| 场景 | 建议 |
| --- | --- |
| 本地 / 内网自用 | 默认配置即可,无需额外改动 |
| 公网发布 | 静态快照(GitHub Pages / EdgeOne)或 Cloudflare Worker + `REFRESH_TOKEN`;**不要把 Node 探针直接暴露到公网** |
| 目标含内网资产 | 用 `local.monitors.json` 分层,公开配置只留 `id/name/group`,并开 `hideTargets` + `redactDetails` |
---
## 一、`/api/refresh` 的滥用风险与分层防护
「刷新即重新检测」意味着**一个未鉴权的 GET 请求会触发一次对所有目标的出站连接**。滥用的三种形态:
1. **探测放大器**:任何人反复调用,把本服务当作对目标站点施压的跳板(借刀杀人)
2. **资源耗尽**:每次强制刷新都会对所有目标发起连接,慢目标(如超时 10s)会长时间占用并发
3. **目标配额消耗**:被诱导高频探测的第三方目标可能限流甚至封禁我们的出口 IP(真实案例:GitHub 未认证 API 上限 60 次/小时,被高频探测打满后返回 403,面板如实记为离线)
### 防护措施(已实现)
| 位置 | 措施 | 默认 |
| --- | --- | --- |
| Node 探针 | 仅允许内网/回环来源触发;**只信任 socket 对端地址,不解析 `X-Forwarded-For`**(该头部可被伪造) | 开启 |
| Node 探针 | 每来源冷却 `settings.refreshCooldownSec`,超限返回 `429` + `Retry-After` | 5s |
| Node 探针 | 轮次去重:多标签页/多客户端共享同一轮探测;2s 内的重复请求直接复用快照 | 开启 |
| Node 探针 | 单轮上限 22s 兜底(`FORCED_MAX_WAIT_MS`),防止卡死连接长期占用请求 | 开启 |
| Node 探针 | 总开关 `settings.allowRefresh: false`,完全关闭强制探测(只读模式) | 可配 |
| Node 探针 | 拒绝响应只回错误码(`{"error":"refresh-throttled"}`),不回显目标与内部细节 | 固定 |
| Worker | **必须配置 `REFRESH_TOKEN` 才开放** `/api/refresh`,否则一律 403;token 比较用等长逐字节异或,避免按字符提前返回的时序差异 | 默认关闭 |
| 面板 | 收到 401/403 自动退回只读轮询(页脚标注「只读」);429 仅本轮降级并在按钮提示 | 自动 |
### 配置参考
```jsonc
{
"settings": {
"refreshInterval": 30, // 面板刷新节奏
"probeOnRefresh": true, // 刷新是否重新探测
"allowRefresh": true, // false = 完全关闭强制探测(公网部署建议)
"refreshLocalOnly": true, // 仅内网/回环来源可触发
"refreshCooldownSec": 5 // 每来源冷却
}
}
```
单个目标也可退出「刷新即检测」,避免被高频探测压垮配额:
```jsonc
{ "id": "github-api", "interval": 300, "probeOnRefresh": false }
```
### 已知限制(请按需叠加外部措施)
- **限流在进程内**:多个探针实例不共享计数;需要强约束时请在网关(Nginx / Cloudflare Rate Limiting)再加一层
- **反向代理场景**:因为不信任 `X-Forwarded-For`,前置代理时所有请求都来自代理的内网地址,`refreshLocalOnly` 会失效——此时应设 `allowRefresh: false`,或自行在代理层做认证与限流
- Worker 的同隔离体去重是 best-effort(不同边缘节点各自计数)
---
## 二、SSH 这类服务怎么探测
支持三种探测类型,按「侵入性从低到高」:
| 类型 | 做什么 | 适合 |
| --- | --- | --- |
| `tcp` | 仅建立 TCP 连接,测连通性与连接耗时 | 任意端口存活监控 |
| `banner` | 连接后**被动读取服务端主动发送的标识串**,可与规则比对 | SSH / SMTP / FTP / POP3 / IMAP / HTTP 等主动问候的协议 |
| `http` | 完整 HTTP 请求,读状态码 | Web 服务 |
### SSH 配置示例
```jsonc
{
"id": "ssh-gateway",
"name": "跳板机 SSH",
"group": "基础设施",
"type": "banner",
"host": "10.0.0.5", // 真实地址建议放 local.monitors.json
"port": 22,
"timeout": 5,
"interval": 300,
"expectBanner": "^SSH-2\\.0-", // 策略:必须是 SSH 2.0
"revealBanner": false // 默认不回显标识串(含软件版本)
}
```
`expectBanner` 让探测同时具备**合规检查**能力:例如要求 `^SSH-2\.0-OpenSSH_[89]`,若目标降级到旧版本,探测即判为离线并触发面板告警。
### 明确不做的三件事
1. **不做 SSH 登录/认证尝试**:会触发目标侧 fail2ban/sshguard 封禁,需要凭据,还会在目标留下审计噪音
2. **不发送任何应用层数据**:因此需要客户端先行发包的协议(Redis 的 `PING`、MySQL 握手)暂不适用——这是刻意保留的安全边界,不是能力缺失
3. **不在标识串不匹配时回显实际收到的内容**:避免把「目标是什么版本」写进公开的 `status.json`
banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器不同:不产生登录失败记录,也不改动目标任何状态。
---
## 三、信息泄露控制
状态页天生是公开的,泄露面有三处,逐个封堵:
| 泄露面 | 风险 | 措施 |
| --- | --- | --- |
| `public/monitors.json` | 面板必需文件,静态部署必然公开,含真实 URL / 内网主机端口 | **私有配置分层**:公开文件只写 `id/name/group`,真实地址放仓库根的 `local.monitors.json`(已 gitignore),探针按 `id` 合并覆盖 |
| `status.json` / `/api/status` | 探针输出,Pages 部署会公开,`detail` 可能含地址、版本、错误码 | `settings.redactDetails: true` → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 `hideTargets` 开启时不下发 |
| 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 |
| UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 |
| `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 |
### 对外发布状态页的推荐组合
```jsonc
// local.monitors.json(不入库,仅本机/CI 环境存在)
{
"monitors": [
{ "id": "blog", "url": "https://blog.example.com" },
{ "id": "nas", "host": "192.168.1.10", "port": 5000 }
]
}
```
```jsonc
// public/monitors.json(公开)
{
"settings": { "hideTargets": true, "redactDetails": true },
"monitors": [
{ "id": "blog", "name": "博客站点", "group": "网站", "type": "http", "interval": 60, "timeout": 10 },
{ "id": "nas", "name": "家庭存储", "group": "基础设施", "type": "tcp", "interval": 300, "timeout": 5 }
]
}
```
这样即使 `status.json` 与面板完全公开,访客也只能看到「有哪些服务、是否在线、延迟、可用率」。
补充:`dataFilter`(`both` / `config` / `uptimerobot`)只决定**展示与快照输出**哪些目标,不影响探测行为;如果希望某个来源的数据完全不进入公开快照,应使用 `config` 过滤或把对应目标从配置中移除。
### 仍然会被公开的信息(请自行评估)
- **服务数量与名称**:命名不要包含敏感语义(不要叫「内网财务系统」)
- **延迟特征**:多次采样可反推大致网络位置
- **可用率与故障时间**:这本身就是状态页要公开的信息
- **GitHub Actions 场景**:CI 读不到未提交的 `local.monitors.json`,需用 repository secrets 在 workflow 中生成,或只监控公开目标
---
## 四、其他基线
- `.gitignore` 已包含 `local.monitors.json` 与 `.dev.vars`(Cloudflare 本地密钥)
- Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml`
- 面板为只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身
- Node 探针同时充当静态服务器,**不要直接暴露公网**;确实需要时在其前方加带认证的反向代理,并设 `allowRefresh: false`
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+221
View File
@@ -0,0 +1,221 @@
/**
* shiroko-monitor 边缘探针(Cloudflare Workers)
*
* - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets)
* - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB)
* - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源
*
* 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划;
* 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。
*/
import { connect } from 'cloudflare:sockets';
const VERSION = '0.6.0';
const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90;
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
// 边缘端按 UTC 日期分桶(Worker 无本地时区概念)
function dayKey(d = new Date()) {
return d.toISOString().slice(0, 10);
}
// 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序
function pruneDaily(daily) {
const keys = Object.keys(daily).sort();
while (keys.length > UPTIME_DAYS) delete daily[keys.shift()];
}
export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(runChecksDedup(env));
},
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === '/api/status') return json(await getStatus(env, ctx));
if (url.pathname === '/api/monitors') return json(await getMonitors(env, request.url));
if (url.pathname === '/api/refresh') {
// 公网上的强制探测必须显式开启,避免被任意访客当作「探测放大器」使用
const gate = refreshGate(env, request);
if (!gate.ok) return json({ error: gate.error }, { status: gate.status });
return json(await runChecksDedup(env));
}
return env.ASSETS.fetch(request);
},
};
// 未配置 REFRESH_TOKEN 时一律拒绝(wrangler secret put REFRESH_TOKEN 开启);
// 比较采用等长逐字节异或,避免按字符提前返回带来的时序差异
function refreshGate(env, request) {
const token = env.REFRESH_TOKEN;
if (!token) return { ok: false, status: 403, error: 'refresh-disabled' };
const url = new URL(request.url);
const provided = request.headers.get('x-refresh-token') || url.searchParams.get('token') || '';
if (provided.length !== token.length) return { ok: false, status: 403, error: 'refresh-forbidden' };
let diff = 0;
for (let i = 0; i < token.length; i++) diff |= provided.charCodeAt(i) ^ token.charCodeAt(i);
return diff === 0 ? { ok: true } : { ok: false, status: 403, error: 'refresh-forbidden' };
}
async function probeMonitor(m) {
if (m.type === 'tcp') return probeTcp(m);
// 边缘端无原始 socket 读取能力(sockets API 需付费版且未覆盖测试),banner 类型请使用本地探针
if (m.type === 'banner') return { status: 'down', latency: 0, code: null, detail: '边缘端暂不支持 banner 探测(请用本地探针)' };
return probeHttp(m);
}
// 同隔离体内共享同一轮探测,避免并发刷新对目标重复施压(跨隔离体为 best-effort)
let inFlightRound = null;
function runChecksDedup(env) {
if (!inFlightRound) {
inFlightRound = runChecks(env).finally(() => {
inFlightRound = null;
});
}
return inFlightRound;
}
function json(data, init = {}) {
return new Response(JSON.stringify(data), {
...init,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...(init.headers ?? {}) },
});
}
async function getMonitors(env, baseUrl) {
const raw = await env.MONITOR_KV.get('monitors');
if (raw) return JSON.parse(raw);
// 未手动写入 KV 时,直接从静态资产读取仓库根目录的 monitors.json,实现零配置部署
const res = await env.ASSETS.fetch(new URL('/monitors.json', baseUrl));
if (res.ok) return res.json();
return { settings: {}, monitors: [] };
}
async function getStatus(env, ctx) {
const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {}, generatedAt: null };
if (!state.generatedAt) ctx.waitUntil(runChecks(env));
return state;
}
async function probeHttp(m) {
const started = Date.now();
const timeoutMs = (m.timeout ?? 10) * 1000;
try {
// redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定
const res = await fetch(m.url, {
method: m.method || 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
headers: { 'user-agent': USER_AGENT, accept: '*/*' },
});
const latency = Date.now() - started;
const expect = m.expectStatus ?? [200];
const ok = expect.includes(res.status);
return {
status: ok ? 'up' : 'down',
latency,
code: res.status,
detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`,
};
} catch (err) {
const latency = Date.now() - started;
return {
status: 'down',
latency,
code: 0,
detail: err.name === 'TimeoutError' ? `请求超时(>${timeoutMs / 1000}s)` : String(err.cause?.code || err.message),
};
}
}
async function probeTcp(m) {
const started = Date.now();
const timeoutMs = (m.timeout ?? 10) * 1000;
try {
const socket = connect({ hostname: m.host, port: m.port });
const timer = setTimeout(() => socket.close(), timeoutMs);
await Promise.race([
socket.opened,
new Promise((_, reject) => setTimeout(() => reject(new Error('TCP 连接超时')), timeoutMs)),
]);
clearTimeout(timer);
socket.close();
return { status: 'up', latency: Date.now() - started, code: null, detail: `TCP ${m.host}:${m.port} 可连接` };
} catch (err) {
return { status: 'down', latency: Date.now() - started, code: null, detail: String(err?.message || err) };
}
}
async function runChecks(env) {
const config = await getMonitors(env, new URL('http://placeholder/'));
const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {} };
const monitors = state.monitors ?? {};
const redact = config.settings?.redactDetails === true;
let up = 0;
let down = 0;
let pending = 0;
let latencySum = 0;
let latencyCount = 0;
await Promise.all(
(config.monitors ?? []).map(async (m) => {
const prev = monitors[m.id];
const r = await probeMonitor(m);
r.checkedAt = new Date().toISOString();
const history = [...(prev?.history ?? []), { t: r.checkedAt, ok: r.status === 'up', ms: r.latency ?? 0 }].slice(-HISTORY_LIMIT);
const daily = { ...(prev?.daily ?? {}) };
const key = dayKey();
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
bucket.total++;
if (r.status === 'up') bucket.up++;
pruneDaily(daily);
monitors[m.id] = {
status: r.status,
latency: r.latency,
code: r.code,
detail: redact ? (r.status === 'up' ? '正常' : '异常') : r.detail,
lastCheck: r.checkedAt,
history,
daily,
};
if (r.status === 'up') {
up++;
latencySum += r.latency ?? 0;
latencyCount++;
} else {
down++;
}
})
);
let uptimeUp = 0;
let uptimeTotal = 0;
for (const m of Object.values(monitors)) {
for (const bucket of Object.values(m.daily ?? {})) {
uptimeTotal += bucket.total ?? 0;
uptimeUp += bucket.up ?? 0;
}
}
const next = {
prober: `shiroko-monitor-worker/${VERSION}`,
demo: false,
generatedAt: new Date().toISOString(),
stats: {
total: (config.monitors ?? []).length,
up,
down,
pending,
avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null,
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
},
monitors,
};
await env.MONITOR_KV.put('state', JSON.stringify(next));
return next;
}
+14
View File
@@ -0,0 +1,14 @@
name = "shiroko-monitor"
main = "worker.js"
compatibility_date = "2025-09-01"
[triggers]
crons = ["* * * * *"]
[[kv_namespaces]]
binding = "MONITOR_KV"
id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID"
[assets]
directory = "../../public"
binding = "ASSETS"
+15
View File
@@ -0,0 +1,15 @@
{
"name": "shiroko-monitor",
"version": "0.1.0",
"description": "轻量级服务监控面板:纯静态前端 + Node/边缘探针,支持 HTTP 与 TCP 探测",
"type": "module",
"scripts": {
"start": "node prober/node/index.js",
"probe": "node prober/node/index.js --once",
"demo": "node prober/node/index.js --once --demo"
},
"engines": {
"node": ">=18"
},
"license": "MIT"
}
+746
View File
@@ -0,0 +1,746 @@
#!/usr/bin/env node
/**
* shiroko-monitor 本地探针 + 面板服务器(零依赖,Node >= 18)
*
* 模式:
* 默认 常驻巡检:按各 monitor 的 interval 周期探测,结果写入 public/status.json,同时伺服面板
* --once 执行一轮探测后退出(供 GitHub Actions 等 CI 使用,历史心率从已有 status.json 恢复)
* --demo 生成一份演示状态数据后退出(用于静态部署的首版占位)
* --out=<path> 指定快照输出路径,默认 public/status.json(可避免演示数据覆盖真实快照)
* --config=<path> / --local-config=<path> 指定公开配置与私有覆盖的路径(多实例、测试用)
*
* 私有配置:若存在 local.monitors.json(已在 .gitignore 中),其 monitors 按 id 覆盖公开配置,
* 用于把真实地址/端口从会公开的 public/monitors.json 中拆出去。
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { probeHttp } from './lib/http-probe.js';
import { probeTcp } from './lib/tcp-probe.js';
import { probeBanner } from './lib/banner-probe.js';
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
const PUBLIC_DIR = path.join(ROOT, 'public');
const CONFIG_PATH = path.join(PUBLIC_DIR, 'monitors.json');
// 私有配置(不入库):用于把真实地址/端口从公开的 monitors.json 中拆出去
const LOCAL_CONFIG_PATH = path.join(ROOT, 'local.monitors.json');
const STATUS_PATH = path.join(PUBLIC_DIR, 'status.json');
const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90;
const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.6.0';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
const argv = process.argv.slice(2);
const ONCE = argv.includes('--once');
const DEMO = argv.includes('--demo');
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
function argValue(name) {
const inline = argv.find((a) => a.startsWith(`${name}=`));
if (inline) return inline.slice(name.length + 1);
const idx = argv.indexOf(name);
return idx >= 0 && argv[idx + 1] && !argv[idx + 1].startsWith('--') ? argv[idx + 1] : null;
}
const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH);
// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖)
const CONFIG_OVERRIDE = argValue('--config');
const LOCAL_CONFIG_OVERRIDE = argValue('--local-config');
const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH;
const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
function loadConfig() {
const pub = JSON.parse(fs.readFileSync(ACTIVE_CONFIG_PATH, 'utf8'));
const local = loadLocalOverlay();
if (!local) return pub;
// 私有配置按 id 覆盖公开条目(前者可只写 url/host/port 等敏感字段),settings 同样按需覆盖
const byId = new Map((local.monitors ?? []).map((m) => [m.id, m]));
return {
...pub,
settings: { ...pub.settings, ...(local.settings ?? {}) },
monitors: (pub.monitors ?? []).map((m) => ({ ...m, ...(byId.get(m.id) ?? {}) })),
};
}
function loadLocalOverlay() {
try {
return JSON.parse(fs.readFileSync(ACTIVE_LOCAL_CONFIG_PATH, 'utf8'));
} catch {
return null;
}
}
function loadPersistedStatus() {
try {
return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
} catch {
return null;
}
}
function dayKey(d = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
// 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序
function pruneDaily(daily) {
const keys = Object.keys(daily).sort();
while (keys.length > UPTIME_DAYS) delete daily[keys.shift()];
}
function recordDaily(daily, ok) {
const key = dayKey();
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
bucket.total++;
if (ok) bucket.up++;
pruneDaily(daily);
}
function intervalMs(monitor) {
const sec = Number(monitor.interval);
// 非法或缺失的 interval 一律回落到默认值,避免算出 NaN 让该目标永久不再被调度
return Math.max(5, Number.isFinite(sec) && sec > 0 ? sec : DEFAULT_INTERVAL_SEC) * 1000;
}
/* ---------- UptimeRobot 只读数据源(异地探测视角) ---------- */
// API Key 建议放 local.monitors.json(不入库)或环境变量 UPTIMEROBOT_API_KEY;两者都无则视为未启用
function uptimeRobotConfig(config) {
const ur = config.settings?.uptimeRobot ?? {};
const apiKey = String(ur.apiKey || process.env.UPTIMEROBOT_API_KEY || '').trim();
const ratios = Array.isArray(ur.ratios) && ur.ratios.length ? ur.ratios.map(Number).filter(Number.isFinite) : [1, 7, 30, 90];
return {
enabled: ur.enabled === true && Boolean(apiKey),
apiKey,
pollIntervalSec: Math.max(60, Number(ur.pollIntervalSec) || UR_DEFAULT_POLL_SEC),
group: ur.group || 'UptimeRobot',
includeTypes: Array.isArray(ur.includeTypes) && ur.includeTypes.length ? ur.includeTypes.map(Number) : null,
ratios: ratios.length ? ratios : [1, 7, 30, 90],
};
}
// UR 侧不探测,只做「拉取 + 映射」;失败时保留上一次数据,绝不因为读不到 UR 就把服务判为离线
async function syncUptimeRobot(runtime, { minGapMs = 0 } = {}) {
const ur = uptimeRobotConfig(runtime.config);
runtime.ur.enabled = ur.enabled;
if (!ur.enabled || runtime.ur.syncing) return;
// minGapMs 用于「面板手动刷新时顺带同步」的场景,避免被反复点击打爆 UR 接口
if (minGapMs && Date.now() - runtime.ur.lastSyncMs < minGapMs) return;
runtime.ur.lastSyncMs = Date.now();
runtime.ur.syncing = true;
try {
let list = await fetchUptimeRobotMonitors({ apiKey: ur.apiKey, ratios: ur.ratios });
if (ur.includeTypes) list = list.filter((m) => ur.includeTypes.includes(m.sourceType));
const seen = new Set();
for (const item of list) {
seen.add(item.id);
let s = runtime.state.get(item.id);
if (!s) {
s = { monitor: item, nextDue: 0, inFlight: false, lastResult: null, history: [], daily: {} };
runtime.state.set(item.id, s);
}
s.monitor = item;
s.lastResult = {
status: item.status,
latency: item.latency,
code: item.code,
detail: item.detail,
checkedAt: item.lastCheckIso,
};
s.history = item.history;
s.uptimeRatios = item.uptimeRatios;
s.downSeconds = item.downSeconds;
if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up');
}
// 清理 UR 侧已删除的监控
for (const [id, s] of runtime.state) {
if (s.monitor?.source === 'uptimerobot' && !seen.has(id)) runtime.state.delete(id);
}
runtime.ur.ok = true;
runtime.ur.error = null;
runtime.ur.count = list.length;
runtime.ur.lastSyncAt = new Date().toISOString();
markDataUpdated(runtime);
console.log(`[uptimerobot] 同步完成:${list.length} 个监控(在线 ${list.filter((m) => m.status === 'up').length})`);
} catch (err) {
runtime.ur.ok = false;
runtime.ur.error = err.message;
console.error(`[uptimerobot] 同步失败(保留上次数据):${err.message}`);
} finally {
runtime.ur.syncing = false;
}
}
// 恢复 lastResult / history / daily:优先复用内存态(配置热重载),否则读持久化的 status.json(重启)
function hydrate(config, prevState = null) {
const persisted = prevState ? null : loadPersistedStatus();
const state = new Map();
for (const m of config.monitors) {
const own = prevState?.get(m.id);
const saved = own
? {
status: own.lastResult?.status,
latency: own.lastResult?.latency,
code: own.lastResult?.code,
detail: own.lastResult?.detail,
lastCheck: own.lastResult?.checkedAt,
history: own.history,
daily: own.daily,
}
: persisted?.monitors?.[m.id];
state.set(m.id, {
monitor: m,
nextDue: 0,
inFlight: false,
lastResult: saved?.status && saved.status !== 'pending'
? { status: saved.status, latency: saved.latency, code: saved.code, detail: saved.detail, checkedAt: saved.lastCheck }
: null,
history: Array.isArray(saved?.history) ? saved.history.slice(-HISTORY_LIMIT) : [],
daily: saved?.daily && typeof saved.daily === 'object' ? { ...saved.daily } : {},
// UR 侧的聚合可用率(用于在逐日分桶填满前也能显示官方数字)
uptimeRatios: own?.uptimeRatios ?? saved?.uptimeRatios ?? null,
downSeconds: own?.downSeconds ?? saved?.downSeconds ?? null,
});
}
// UptimeRobot 镜像目标不在 config 里,热重载时需整体保留,否则会丢掉已累积的逐日分桶
if (prevState) {
for (const [id, s] of prevState) {
if (s.monitor?.source === 'uptimerobot') state.set(id, s);
}
}
return state;
}
async function probeOne(monitor) {
let result;
if (monitor.type === 'tcp') result = await probeTcp(monitor);
else if (monitor.type === 'banner') result = await probeBanner(monitor);
else if (monitor.type === 'http') result = await probeHttp(monitor);
else return { status: 'down', latency: 0, code: null, detail: `不支持的探测类型:${monitor.type}`, checkedAt: new Date().toISOString() };
result.checkedAt = new Date().toISOString();
return result;
}
async function mapLimit(items, limit, fn) {
let idx = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (idx < items.length) {
const i = idx++;
await fn(items[i], i);
}
});
await Promise.all(workers);
}
async function probeDue(runtime, force = false) {
const { config, state } = runtime;
const now = Date.now();
const due = [];
for (const m of config.monitors) {
const s = state.get(m.id);
if (!s || s.inFlight) continue;
// UptimeRobot 镜像目标由其自身节点探测,本探针只拉取结果
if (m.type === 'ur' || m.source === 'uptimerobot') continue;
// 目标可单独退出「刷新即检测」(如有限流的 API),只按自身 interval 探测
if (force && m.probeOnRefresh === false) continue;
if (!force && s.nextDue > now) continue;
// 先同步占位再探测:否则慢目标(如 10s 超时)尚未返回时会被下一秒的 tick 重复调度,形成探测风暴
s.inFlight = true;
s.nextDue = Date.now() + intervalMs(m);
due.push(m);
}
await mapLimit(due, CONCURRENCY, async (m) => {
const s = state.get(m.id);
const task = (async () => {
try {
const result = await probeOne(m);
s.lastResult = result;
s.history.push({ t: result.checkedAt, ok: result.status === 'up', ms: result.latency ?? 0 });
if (s.history.length > HISTORY_LIMIT) s.history.shift();
recordDaily(s.daily, result.status === 'up');
const tag = result.status === 'up' ? '[up] ' : '[down]';
console.log(`${tag} ${m.id.padEnd(20)} ${String(result.latency ?? '-').padStart(5)}ms ${result.detail}`);
} catch (err) {
console.error(`[probe] ${m.id} 探测异常:${err.message}`);
} finally {
s.inFlight = false;
s.inFlightPromise = null;
}
})();
s.inFlightPromise = task;
await task;
});
if (due.length) markDataUpdated(runtime);
}
// 等待在途探测收尾(强制刷新前调用,避免"正在探测中"的目标被跳过而显示旧数据)
async function settleInflight(state) {
const pending = [...state.values()].map((s) => s.inFlightPromise).filter(Boolean);
if (pending.length) await Promise.allSettled(pending);
}
// 探针自报内存占用(MB):便于观察这个「监控器」自身的资源开销
function memoryStats() {
const m = process.memoryUsage();
const mb = (v) => Math.round((v / 1048576) * 10) / 10;
return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) };
}
/* ---------- 数据源过滤 ---------- */
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
function dataFilter(config) {
const f = config.settings?.dataFilter;
return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both';
}
/* ---------- 服务端更新节奏 ---------- */
// 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端,
// 而不是从用户打开页面那一刻重新计时(否则每次进来都要重等一整个周期,最坏会看到近两倍周期的旧数据)
function markDataUpdated(runtime) {
runtime.dataUpdatedAt = Date.now();
}
// 当前展示集合下,服务端多久产出一次新数据:取各目标 interval 与 UR 拉取间隔的最小值
function updateIntervalSec(config, filter) {
const intervals = [];
if (filter !== 'uptimerobot') {
for (const m of config.monitors) {
const n = Number(m.interval);
if (Number.isFinite(n) && n > 0) intervals.push(n);
}
}
if (filter !== 'config') {
const ur = uptimeRobotConfig(config);
if (ur.enabled) intervals.push(ur.pollIntervalSec);
}
return Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : DEFAULT_INTERVAL_SEC));
}
function snapshot(runtime, demo = false) {
const { config, state } = runtime;
// redactDetails:公开部署时可只输出「正常/异常」,避免 detail 中的版本、地址等细节被公开读取
const redact = config.settings?.redactDetails === true;
const filter = dataFilter(config);
const hideUrls = config.settings?.hideTargets === true;
const includeConfig = filter !== 'uptimerobot';
const includeExternal = filter !== 'config';
const monitors = {};
let up = 0;
let down = 0;
let pending = 0;
let latencySum = 0;
let latencyCount = 0;
let uptimeUp = 0;
let uptimeTotal = 0;
// 配置里的探测目标 + UptimeRobot 镜像目标(后者只存在于 state 中,由同步产生)
const entries = [];
if (includeConfig) for (const m of config.monitors) entries.push([m.id, m, state.get(m.id)]);
if (includeExternal) {
for (const [id, s] of state) {
if (s.monitor?.source === 'uptimerobot') entries.push([id, s.monitor, s]);
}
}
for (const [id, m, s] of entries) {
const r = s?.lastResult;
const isUr = m.source === 'uptimerobot';
if (!r) {
pending++;
monitors[id] = { status: 'pending', latency: null, code: null, detail: '等待检测', lastCheck: null, history: [], daily: s?.daily ?? {} };
continue;
}
if (r.status === 'up') {
up++;
// 平均延迟只统计自建探针:UR 的延迟来自其探测节点,与本机视角不可比
if (!isUr && r.latency != null) {
latencySum += r.latency;
latencyCount++;
}
} else if (r.status === 'down') {
down++;
} else {
pending++;
}
for (const bucket of Object.values(s.daily ?? {})) {
uptimeTotal += bucket.total ?? 0;
uptimeUp += bucket.up ?? 0;
}
const entry = {
status: r.status,
latency: r.latency,
code: r.code,
detail: redact ? (r.status === 'up' ? '正常' : r.status === 'down' ? '异常' : '等待检测') : r.detail,
lastCheck: r.checkedAt,
history: s.history,
daily: s.daily,
};
if (isUr) {
// UR 镜像目标不在 config 中,需要把展示元信息随快照下发,面板据此合并渲染;
// hideTargets 开启时不下发地址:面板反正不渲染,公开的 status.json 里也就不该有
entry.meta = {
name: m.name,
group: m.group,
type: 'ur',
url: hideUrls ? '' : m.url,
source: 'uptimerobot',
sourceLabel: m.sourceLabel,
interval: m.interval,
};
entry.uptimeRatios = s.uptimeRatios ?? null;
entry.downSeconds = s.downSeconds ?? null;
}
monitors[id] = entry;
}
return {
prober: `shiroko-monitor-node/${VERSION}`,
demo,
generatedAt: new Date().toISOString(),
// 数据最后变更时刻 + 服务端更新节奏:面板据此把刷新对齐到服务端,而不是每访客重新计时
dataUpdatedAt: new Date(runtime.dataUpdatedAt ?? Date.now()).toISOString(),
updateIntervalSec: updateIntervalSec(config, filter),
// 让面板与服务端共用同一份过滤口径,避免私有配置覆盖后两边不一致
dataFilter: filter,
stats: {
total: entries.length,
up,
down,
pending,
avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null,
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
memory: memoryStats(),
},
sources: { uptimerobot: { ...(runtime.ur ?? {}) } },
monitors,
};
}
function writeStatus(data) {
const payload = `${JSON.stringify(data, null, 2)}\n`;
const tmp = `${OUT_PATH}.tmp`;
try {
fs.writeFileSync(tmp, payload);
// 原子替换:避免面板读到写了一半的 JSON
fs.renameSync(tmp, OUT_PATH);
} catch {
// Windows 下 rename 可能因目标文件被短暂占用而失败(EPERM,常见于杀软扫描),
// 退化为直接写入;监控探针绝不能因为一次写文件抖动而整个进程退出
try {
fs.writeFileSync(OUT_PATH, payload);
fs.rmSync(tmp, { force: true });
} catch (err) {
console.error(`[write] status.json 写入失败:${err.message}`);
}
}
}
function scheduleWrite(runtime) {
if (scheduleWrite.timer) return;
scheduleWrite.timer = setTimeout(() => {
scheduleWrite.timer = null;
writeStatus(snapshot(runtime));
}, WRITE_DEBOUNCE_MS);
}
// 刷新即重新检测:多客户端/多标签页同时刷新时共享同一轮探测,避免对目标重复施压;
// 间隔过近的重复请求直接复用当前快照,只有真正需要时才新起一轮
const FORCED_MIN_GAP_MS = 2000;
// 兜底上限:正常情况下轮次在目标超时内完成;若有 socket 卡死,也不让请求无限挂起
const FORCED_MAX_WAIT_MS = 22000;
function forceRound(runtime) {
if (runtime.forced) return runtime.forced;
if (runtime.forcedAt && Date.now() - runtime.forcedAt < FORCED_MIN_GAP_MS) return Promise.resolve();
runtime.forced = (async () => {
try {
// 先等在途探测收尾再统一探测,保证返回时每个目标都是本轮的新结果
await settleInflight(runtime.state);
await probeDue(runtime, true);
await settleInflight(runtime.state);
runtime.forcedAt = Date.now();
scheduleWrite(runtime);
} catch (err) {
console.error(`[refresh] 强制探测异常:${err.message}`);
} finally {
runtime.forced = null;
}
})();
return runtime.forced;
}
function writeDemo(config) {
if (OUT_PATH === STATUS_PATH && fs.existsSync(STATUS_PATH) && !loadPersistedStatus()?.demo) {
console.warn('[demo] 目标已是真实快照,将被演示数据覆盖(如不希望覆盖,请用 --out=<path> 指定其他输出路径)');
}
const state = hydrate(config);
for (const m of config.monitors) {
const s = state.get(m.id);
// 过期证书目标在演示数据中固定为离线,其余按 id 哈希产生稳定的伪随机结果
const seed = [...m.id].reduce((a, c) => a + c.charCodeAt(0), 0);
const alwaysDown = m.id.includes('badssl');
const history = Array.from({ length: HISTORY_LIMIT }, (_, i) => {
const ok = alwaysDown ? false : (seed + i * 7) % 23 !== 0;
return { t: new Date(Date.now() - (HISTORY_LIMIT - i) * (m.interval ?? 60) * 1000).toISOString(), ok, ms: ok ? 30 + ((seed * (i + 3)) % 240) : 0 };
});
const lastOk = history[history.length - 1].ok;
s.lastResult = {
status: lastOk ? 'up' : 'down',
latency: lastOk ? history[history.length - 1].ms : 0,
code: m.type === 'tcp' ? null : lastOk ? 200 : 0,
detail: m.type === 'tcp'
? (lastOk ? '端口可连接' : '连接被拒绝(端口关闭)')
: m.type === 'banner'
? (lastOk ? `${m.protocol ?? 'BANNER'} 响应正常` : '标识串与期望规则不符')
: lastOk ? 'HTTP 200' : 'CERT_HAS_EXPIRED',
checkedAt: history[history.length - 1].t,
};
s.history = history;
// 90 天可用率演示数据:多数日期满分,少量日期按档位抖落,便于观察分级配色
const perDay = Math.max(1, Math.round(86400 / (Number(m.interval) > 0 ? Number(m.interval) : DEFAULT_INTERVAL_SEC)));
const daily = {};
for (let d = UPTIME_DAYS - 1; d >= 0; d--) {
const key = dayKey(new Date(Date.now() - d * 86400000));
const missRatio = alwaysDown ? 1 : (seed + d * 13) % 23 === 0 ? 0.05 : (seed + d * 7) % 11 === 0 ? 0.012 : 0;
const miss = Math.round(perDay * missRatio);
daily[key] = { total: perDay, up: perDay - miss };
}
s.daily = daily;
}
writeStatus(snapshot({ config, state }, true));
console.log(`[demo] 演示状态已写入 ${path.relative(ROOT, OUT_PATH) || OUT_PATH}`);
}
function sendJson(res, data, { status = 200, headers = {} } = {}) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers });
res.end(JSON.stringify(data));
}
/* ---------- /api/refresh 滥用防护 ---------- */
// 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false
export function isPrivateAddress(addr) {
const ip = String(addr || '').replace(/^::ffff:/i, '');
if (ip === '::1' || ip.startsWith('127.')) return true;
if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
if (/^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip)) return true;
return false;
}
const refreshHits = new Map();
const DEFAULT_COOLDOWN_SEC = 5;
function refreshDecision(config, ip) {
const s = config.settings ?? {};
if (s.allowRefresh === false) return { ok: false, status: 403, error: 'refresh-disabled' };
if (s.refreshLocalOnly !== false && !isPrivateAddress(ip)) return { ok: false, status: 403, error: 'refresh-local-only' };
const cooldown = Math.max(1, Number(s.refreshCooldownSec) || DEFAULT_COOLDOWN_SEC) * 1000;
const wait = (refreshHits.get(ip) ?? 0) + cooldown - Date.now();
if (wait > 0) return { ok: false, status: 429, error: 'refresh-throttled', retryAfter: Math.ceil(wait / 1000) };
refreshHits.set(ip, Date.now());
// 简单清理,避免长期运行下 Map 无界增长
const expire = Date.now() - 10 * 60 * 1000;
for (const [k, ts] of refreshHits) if (ts < expire) refreshHits.delete(k);
return { ok: true };
}
/* ---------- UptimeRobot 兼容输出(只读) ---------- */
// 把内部快照映射成 getMonitors 的响应形状,便于既有 UptimeRobot 生态的脚本/看板直接复用本服务数据
function toUptimeRobotResponse(runtime) {
const snap = snapshot(runtime);
const cfgById = new Map(runtime.config.monitors.map((m) => [m.id, m]));
// 字符串 id → 稳定数字 id(UptimeRobot 侧 id 为数字)
const numericId = (id) => {
let h = 0;
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 1e9;
return h;
};
const ratio = (st, days) => {
const buckets = Object.entries(st.daily ?? {}).sort().slice(-days);
const total = buckets.reduce((a, [, b]) => a + (b.total ?? 0), 0);
const upCount = buckets.reduce((a, [, b]) => a + (b.up ?? 0), 0);
if (total) return (upCount / total) * 100;
// 无逐日数据时回退到数据源给出的聚合比率(UptimeRobot 镜像目标自带)
const provided = st.uptimeRatios?.[days];
return Number.isFinite(provided) ? provided : 0;
};
const monitors = Object.entries(snap.monitors).map(([id, st]) => {
const cfg = cfgById.get(id);
const isUr = (st.meta?.source ?? cfg?.source) === 'uptimerobot';
const name = st.meta?.name ?? cfg?.name ?? id;
const url = cfg ? (cfg.type === 'http' ? cfg.url : `${cfg.host}:${cfg.port}`) : st.meta?.url ?? '';
const portLike = isUr ? st.meta?.sourceLabel === 'PORT' : cfg?.type !== 'http';
return {
id: numericId(id),
friendly_name: name,
url,
type: portLike ? 4 : 1,
status: st.status === 'up' ? 2 : st.status === 'down' ? 9 : 1,
interval: cfg?.interval ?? st.meta?.interval ?? DEFAULT_INTERVAL_SEC,
custom_uptime_ratio: [1, 7, 30, 90].map((d) => ratio(st, d).toFixed(3)).join('-'),
average_response_time: st.latency ?? 0,
response_times: (st.history ?? []).slice(-HISTORY_LIMIT).map((h) => ({ datetime: Math.floor(new Date(h.t).getTime() / 1000), value: h.ms })),
};
});
return { stat: 'ok', pagination: { offset: 0, limit: 50, total: monitors.length }, monitors };
}
// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用
function createServer(runtime) {
return http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const { config, state } = runtime;
try {
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime));
if (url.pathname === '/api/monitors') return sendJson(res, config);
if (url.pathname === '/api/refresh') {
const decide = refreshDecision(config, req.socket.remoteAddress);
if (!decide.ok) {
// 只回错误码,不回显目标地址与内部细节
return sendJson(res, { error: decide.error }, {
status: decide.status,
headers: decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {},
});
}
// scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用)
const scope = url.searchParams.get('scope');
if (scope !== 'external') {
// 等本轮探测完成再返回快照,避免面板刷新后读到的仍是旧数据;上限兜底防止个别目标卡死请求
await Promise.race([forceRound(runtime), new Promise((resolve) => setTimeout(resolve, FORCED_MAX_WAIT_MS))]);
}
// 顺带同步 UptimeRobot(至少间隔 60s,尊重其接口限流),让「只看 UR」模式下的刷新同样有意义;
// 再等 6s 上限,避免 UR 接口慢把整体刷新拖过面板超时
await Promise.race([
syncUptimeRobot(runtime, { minGapMs: UR_MIN_REFRESH_GAP_MS }),
new Promise((resolve) => setTimeout(resolve, 6000)),
]);
// 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应
if (res.writableEnded || res.destroyed) return;
return sendJson(res, snapshot(runtime));
}
// UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。
// 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。
if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') {
return sendJson(res, toUptimeRobotResponse(runtime));
}
let rel = decodeURIComponent(url.pathname);
if (rel === '/') rel = '/index.html';
const file = path.join(PUBLIC_DIR, path.normalize(rel));
if (!file.startsWith(PUBLIC_DIR)) {
res.writeHead(403);
return res.end('Forbidden');
}
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
return res.end('Not Found');
}
const ext = path.extname(file).toLowerCase();
const live = rel === '/status.json' || rel === '/monitors.json';
res.writeHead(200, {
'content-type': MIME[ext] || 'application/octet-stream',
'cache-control': live ? 'no-store' : 'public, max-age=300',
});
fs.createReadStream(file).pipe(res);
} catch (err) {
try {
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Internal Error');
} catch {
// 连接已被对端关闭,忽略写入失败(避免未捕获异常终止探针进程)
}
}
});
}
async function main() {
const runtime = {
config: loadConfig(),
state: null,
dataUpdatedAt: Date.now(),
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
};
if (DEMO) {
writeDemo(runtime.config);
return;
}
runtime.state = hydrate(runtime.config);
// 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据
await syncUptimeRobot(runtime);
if (ONCE) {
await probeDue(runtime, true);
writeStatus(snapshot(runtime));
const s = snapshot(runtime).stats;
console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`);
return;
}
let reloadTimer = null;
fs.watch(ACTIVE_CONFIG_PATH, () => {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(() => {
try {
const next = loadConfig();
// 复用当前内存态恢复 history/daily;仅替换持有器属性,不再产生游离的旧引用
runtime.state = hydrate(next, runtime.state);
runtime.config = next;
console.log('[config] monitors.json 已热重载');
scheduleWrite(runtime);
} catch (err) {
console.error(`[config] 重载失败:${err.message}`);
}
}, 200);
});
setInterval(() => {
probeDue(runtime)
.then(() => scheduleWrite(runtime))
.catch((err) => console.error(`[tick] ${err.stack || err}`));
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
syncUptimeRobot(runtime)
.then(() => scheduleWrite(runtime))
.catch(() => { /* syncUptimeRobot 内部已记录错误 */ });
}
}, 1000);
createServer(runtime).listen(PORT, () => {
console.log(`shiroko-monitor v${VERSION}`);
console.log(`面板: http://localhost:${PORT}/`);
console.log(`状态 API: http://localhost:${PORT}/api/status`);
console.log(`兼容接口: http://localhost:${PORT}/api/v2/getMonitors (UptimeRobot getMonitors 只读兼容)`);
console.log(`监控目标: ${runtime.config.monitors.length} 个自建探测${runtime.ur.enabled ? ` + ${runtime.ur.count} 个 UptimeRobot 监控` : ''}(配置热重载已启用)`);
});
}
// 直接执行(node index.js)时启动;被测试脚本 import 时不自动启动
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
+64
View File
@@ -0,0 +1,64 @@
import net from 'node:net';
// 协议族识别只用于生成「SSH 响应正常」这类摘要;版本号等细节默认不回显,避免写入公开的 status.json
const FAMILIES = [
{ re: /^SSH-\d/i, name: 'SSH' },
{ re: /^220[\s\S]*ESMTP/i, name: 'SMTP' },
{ re: /^220[\s\S]*FTP/i, name: 'FTP' },
{ re: /^\+OK/i, name: 'POP3' },
{ re: /^\* OK/i, name: 'IMAP' },
{ re: /^HTTP\/\d/i, name: 'HTTP' },
{ re: /^220\b/i, name: 'SMTP/TCP' },
];
/**
* 被动 banner 探测:连接后仅读取服务端主动发送的标识串(RFC 4253 的 SSH banner 即此类),
* 不做认证、不发送任何数据、不执行任何命令——因此不会触发目标侧的登录失败计数或封禁。
* 需要客户端先发字节的协议(Redis/MySQL)不适用,属于路线图内容。
*/
export function probeBanner(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
const maxBytes = Math.max(32, Number(monitor.maxBannerBytes) || 512);
return new Promise((resolve) => {
const socket = net.createConnection({ host: monitor.host, port: monitor.port });
let raw = '';
let settled = false;
const done = (status, detail) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({ status, latency: Date.now() - started, code: null, detail });
};
const evaluate = () => {
const line = (raw.split(/\r?\n/)[0] ?? '').trim();
const family = FAMILIES.find((f) => f.re.test(line))?.name ?? null;
if (monitor.expectBanner) {
let ok = false;
try {
ok = new RegExp(monitor.expectBanner, monitor.bannerFlags || 'i').test(line);
} catch {
return done('down', 'banner 规则无法编译(expectBanner 不是合法正则)');
}
// 不匹配时只报告策略结果,不回显实际收到的标识串
if (!ok) return done('down', '标识串与期望规则不符');
}
const label = family ? `${family} 响应正常` : '收到服务标识串';
return done('up', monitor.revealBanner ? `${label}:${line.slice(0, 80)}` : label);
};
socket.setTimeout(timeoutMs, () => done('down', '等待标识串超时'));
socket.once('error', (err) =>
done('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message))
);
// 对端发完就断开(无换行结尾)也算有效响应
socket.once('end', () => (raw ? evaluate() : done('down', '连接被对端关闭且未发送标识串')));
// 只在收到换行或达到字节上限时判定,避免把分包到达的标识串截断
socket.on('data', (chunk) => {
raw += chunk.toString('latin1');
if (/[\r\n]/.test(raw) || raw.length >= maxBytes) evaluate();
});
});
}
+31
View File
@@ -0,0 +1,31 @@
const USER_AGENT = 'shiroko-monitor/0.2 (lightweight uptime panel)';
export async function probeHttp(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
try {
// redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定,避免跟随重定向掩盖异常
const res = await fetch(monitor.url, {
method: monitor.method || 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
headers: { 'user-agent': USER_AGENT, accept: '*/*' },
});
const latency = Date.now() - started;
const expect = monitor.expectStatus ?? [200];
const ok = expect.includes(res.status);
return {
status: ok ? 'up' : 'down',
latency,
code: res.status,
detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`,
};
} catch (err) {
const latency = Date.now() - started;
let detail;
if (err.name === 'TimeoutError') detail = `请求超时(>${timeoutMs / 1000}s)`;
else if (err.cause?.code) detail = err.cause.code;
else detail = err.message;
return { status: 'down', latency, code: 0, detail };
}
}
+18
View File
@@ -0,0 +1,18 @@
import net from 'node:net';
export function probeTcp(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
return new Promise((resolve) => {
const socket = net.createConnection({ host: monitor.host, port: monitor.port });
const finish = (status, detail) => {
socket.destroy();
resolve({ status, latency: Date.now() - started, code: null, detail });
};
socket.setTimeout(timeoutMs, () => finish('down', 'TCP 连接超时'));
socket.once('error', (err) =>
finish('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message))
);
socket.once('connect', () => finish('up', '端口可连接'));
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* UptimeRobot 只读数据源(API v2)
*
* 作用:把 UptimeRobot 已有的监控结果接入本面板,作为「异地探测视角」与自建探针并列展示。
* 只读:仅调用 getMonitors,不做任何写操作(暂停/编辑/删除),因此用 ur* 开头的只读 Key 即可。
*
* 术语对齐(UptimeRobot v2):
* monitor.type 1=HTTP(s) 2=Keyword 3=Ping 4=Port
* monitor.status 0=暂停 1=未检测 2=正常 8/9=异常
* logs[].type 1=故障 2=恢复 98=开始监控 99=暂停
* logs[].reason { code: "403", detail: "Forbidden" }
* custom_uptime_ratio / custom_down_durations 按请求顺序以 '-' 分隔
*/
const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';
const HISTORY_LIMIT = 48;
const TYPE_LABEL = { 1: 'HTTP', 2: 'KEYWORD', 3: 'PING', 4: 'PORT' };
// 暂停/未检测都归入 pending:否则会被误报成「离线」
const STATUS_MAP = { 0: 'pending', 1: 'pending', 2: 'up', 8: 'down', 9: 'down' };
function splitNums(value) {
return String(value ?? '')
.split('-')
.map((v) => Number(v))
.filter((v) => Number.isFinite(v));
}
function ratiosToObject(value, ratios) {
const nums = splitNums(value);
const out = {};
ratios.forEach((r, i) => {
if (Number.isFinite(nums[i])) out[r] = nums[i];
});
return out;
}
function targetOf(m) {
if (m.type === 4 && m.port) return `${m.url ? `${m.url}:` : ''}${m.port}`;
return m.url || '';
}
function latestDownReason(logs) {
const down = (logs ?? []).find((l) => l.type === 1);
const reason = down?.reason;
if (!reason) return null;
const code = String(reason.code ?? '').trim();
const detail = String(reason.detail ?? '').trim();
return { code: code || null, text: [code, detail].filter(Boolean).join(' ') || null };
}
/**
* 纯函数:把 UptimeRobot 的 getMonitors 响应映射成本探针的内部结构(可离线单测)
* @param {object} payload getMonitors 原始响应
* @param {{group?: string, ratios?: number[], now?: number}} [options]
*/
export function mapUptimeRobotMonitors(payload, options = {}) {
const group = options.group || 'UptimeRobot';
const ratios = options.ratios ?? [1, 7, 30, 90];
const now = options.now ?? Date.now();
const monitors = [];
for (const m of payload?.monitors ?? []) {
const status = STATUS_MAP[m.status] ?? 'pending';
const samples = Array.isArray(m.response_times) ? m.response_times : [];
const logs = Array.isArray(m.logs) ? m.logs : [];
// UR 侧的真实检测时刻:正常取最新响应样本,故障取最近一次故障日志
const lastCheckSec = status === 'down' ? logs.find((l) => l.type === 1)?.datetime : samples[0]?.datetime;
const lastCheckIso = new Date((Number(lastCheckSec) || Math.floor(now / 1000)) * 1000).toISOString();
const history = samples
.slice(0, HISTORY_LIMIT)
.map((s) => ({ t: new Date(s.datetime * 1000).toISOString(), ok: Number(s.value) > 0, ms: Math.max(0, Number(s.value) || 0) }))
.reverse();
if (status === 'down') history.push({ t: lastCheckIso, ok: false, ms: 0 });
const last = samples[0]?.value;
const avg = m.average_response_time;
const latency = Number.isFinite(Number(last)) && Number(last) > 0
? Math.round(Number(last))
: Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null;
const reason = latestDownReason(logs);
let detail;
if (status === 'up') detail = 'UptimeRobot:运行正常';
else if (status === 'down') detail = `UptimeRobot:${reason?.text ?? '连接失败'}`;
else detail = m.status === 0 ? 'UptimeRobot:监控已暂停' : 'UptimeRobot:尚未检测';
monitors.push({
id: `ur-${m.id}`,
name: m.friendly_name || `UptimeRobot #${m.id}`,
group,
type: 'ur',
url: targetOf(m),
// 与 UR 侧一致:编号、协议、检测间隔
source: 'uptimerobot',
sourceId: m.id,
sourceType: m.type,
sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`,
interval: Number(m.interval) || 300,
status,
latency,
// UR 不直接给「当前 HTTP 状态码」,故障时取最近一次故障日志里的 code
code: status === 'down' && reason?.code ? Number(reason.code) || null : null,
detail,
lastCheckIso,
history,
uptimeRatios: ratiosToObject(m.custom_uptime_ratio, ratios),
downSeconds: ratiosToObject(m.custom_down_durations, ratios),
createDatetime: Number(m.create_datetime) || null,
});
}
return monitors;
}
export async function fetchUptimeRobotMonitors({ apiKey, timeoutMs = 15000, ratios = [1, 7, 30, 90] }) {
if (!apiKey) throw new Error('缺少 UptimeRobot API Key');
const body = new URLSearchParams({
api_key: apiKey,
format: 'json',
response_times: '1',
response_times_average: '30',
custom_uptime_ratios: ratios.join('-'),
logs: '1',
log_types: '1-2',
// 注意:custom_down_durations 不能作为请求参数传入(v2 会返回 invalid_parameter),
// 但响应中默认会带该字段,故 downSeconds 仍可解析
});
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body,
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`UptimeRobot HTTP ${res.status}`);
const payload = await res.json();
if (payload.stat !== 'ok') throw new Error(`UptimeRobot 返回错误:${payload.error?.message || payload.error?.type || 'unknown'}`);
return mapUptimeRobotMonitors(payload, { ratios });
}
+778
View File
@@ -0,0 +1,778 @@
const VERSION = '0.6.0';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
// 表格为默认视图(目标多时可一屏扫完);卡片视图保留给需要心率图细节的场景
const VIEWS = { table: '表格', cards: '卡片' };
const DEFAULT_VIEW = 'table';
const DEFAULT_REFRESH_SEC = 30;
// 只看 UptimeRobot 时,其自身检测间隔就是 5 分钟,刷新再快也没有新数据
const EXTERNAL_REFRESH_SEC = 300;
const state = {
config: null,
status: null,
source: null,
filter: 'all',
view: 'cards',
refreshTimer: null,
nextRefreshAt: 0,
loading: false,
// 服务端未开放/拒绝强制探测时置位,后续刷新退回只读轮询,避免每轮都撞 403
refreshDenied: false,
};
const $ = (id) => document.getElementById(id);
const SOURCE_LABEL = {
api: '边缘 API(实时)',
static: '静态快照',
browser: '浏览器直连',
};
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
async function fetchJson(url, timeoutMs = 8000) {
const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
function fmtRelative(iso) {
if (!iso) return '–';
const diff = Date.now() - new Date(iso).getTime();
if (Number.isNaN(diff)) return '–';
const s = Math.max(0, Math.round(diff / 1000));
if (s < 10) return '刚刚';
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}
function fmtTime(iso) {
if (!iso) return '–';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '–' : d.toLocaleString('zh-CN', { hour12: false });
}
async function mapLimit(items, limit, fn) {
let idx = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (idx < items.length) {
const i = idx++;
await fn(items[i], i);
}
});
await Promise.all(workers);
}
/* ---------- 数据源 ---------- */
function setSource(s) {
state.source = s;
$('source-badge').textContent = `数据源:${SOURCE_LABEL[s] ?? s}`;
}
async function resolveSource() {
const forced = new URLSearchParams(location.search).get('source');
const mode = forced || state.config.settings?.dataSource || 'auto';
if (mode === 'browser') return setSource('browser');
if (mode === 'static') {
try {
await fetchJson('status.json', 5000);
return setSource('static');
} catch {
return setSource('browser');
}
}
if (mode === 'api') {
try {
await fetchJson('api/status', 5000);
return setSource('api');
} catch {
return setSource('browser');
}
}
// auto:优先边缘 API,其次静态快照,最后浏览器直连
try {
await fetchJson('api/status', 3000);
return setSource('api');
} catch { /* 无边缘 API */ }
try {
await fetchJson('status.json', 3000);
return setSource('static');
} catch { /* 无静态快照 */ }
return setSource('browser');
}
/* ---------- 浏览器直连探测 ---------- */
async function probeBrowserOne(m) {
const started = Date.now();
const timeoutMs = (m.timeout ?? 10) * 1000;
if (m.type === 'tcp') {
// 浏览器无原始 TCP 能力,以 HTTP 连接行为近似:快速拒绝=端口关闭;超时无响应=端口开放但非 HTTP 服务(启发式)
const t = Math.min(timeoutMs, 4000);
try {
await fetch(`http://${m.host}:${m.port}/`, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(t) });
return { status: 'up', latency: Date.now() - started, code: null, detail: `端口 ${m.port} 可连接(启发式)` };
} catch (err) {
if (err.name === 'TimeoutError') {
return { status: 'up', latency: Date.now() - started, code: null, detail: `端口 ${m.port} 开放(无 HTTP 响应,启发式)` };
}
return { status: 'down', latency: Date.now() - started, code: null, detail: '端口不可连接(或被防火墙拒绝)' };
}
}
// 先尝试 cors 模式读取真实状态码;目标未开放跨域时降级为 no-cors 仅判断可达性
try {
const res = await fetch(m.url, {
method: m.method || 'GET',
mode: 'cors',
redirect: 'manual',
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs),
});
const expect = m.expectStatus ?? [200];
const ok = expect.includes(res.status);
return {
status: ok ? 'up' : 'down',
latency: Date.now() - started,
code: res.status,
detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`,
};
} catch { /* 跨域被拒或网络不可达,降级 */ }
try {
await fetch(m.url, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
return { status: 'up', latency: Date.now() - started, code: null, detail: '跨域可达(浏览器模式无法读取状态码)' };
} catch (err) {
return { status: 'down', latency: Date.now() - started, code: null, detail: err.name === 'TimeoutError' ? '请求超时' : '不可达' };
}
}
async function probeFromBrowser(config) {
const monitors = {};
let up = 0;
let down = 0;
let latencySum = 0;
await mapLimit(config.monitors, BROWSER_CONCURRENCY, async (m) => {
const r = await probeBrowserOne(m);
if (r.status === 'up') { up++; latencySum += r.latency; } else { down++; }
monitors[m.id] = { ...r, lastCheck: new Date().toISOString(), history: [] };
});
return {
prober: 'shiroko-monitor-browser',
demo: false,
generatedAt: new Date().toISOString(),
stats: { total: config.monitors.length, up, down, pending: 0, avgLatency: up ? Math.round(latencySum / up) : null },
monitors,
};
}
/* ---------- 渲染 ---------- */
function heartbeatSvg(history) {
const pts = (history ?? []).slice(-HB_LIMIT);
if (!pts.length) return '<div class="hb-empty">暂无历史数据</div>';
const w = 100;
const h = 30;
const max = Math.max(...pts.map((p) => p.ms || 0), 1);
const bw = w / pts.length;
const bars = pts.map((p, i) => {
const bh = p.ok ? Math.max(2, (p.ms / max) * (h - 2)) : 2;
const color = p.ok ? 'var(--up)' : 'var(--down)';
const tip = `${fmtTime(p.t)} · ${p.ok ? `在线 ${p.ms}ms` : '离线'}`;
return `<rect x="${(i * bw).toFixed(2)}" y="${h - bh}" width="${Math.max(bw - 0.6, 0.4).toFixed(2)}" height="${bh}" rx="0.8" fill="${color}"><title>${escapeHtml(tip)}</title></rect>`;
}).join('');
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" role="img" aria-label="最近检测心率">${bars}</svg>`;
}
function statusText(st) {
if (!st || st.status === 'pending') return { label: '等待检测', cls: 'pending' };
return st.status === 'up' ? { label: '在线', cls: 'up' } : { label: '离线', cls: 'down' };
}
function lastNDays(n) {
const out = [];
const now = new Date();
const p = (x) => String(x).padStart(2, '0');
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
out.push(`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`);
}
return out;
}
function uptimeSummary(st) {
const daily = st?.daily ?? {};
let total = 0;
let up = 0;
for (const k of Object.keys(daily)) {
total += daily[k].total ?? 0;
up += daily[k].up ?? 0;
}
if (total) return { pct: (up / total) * 100, total, up, days: Object.keys(daily).length, source: 'local' };
// 没有逐日数据时回退到数据源提供的官方聚合比率(UptimeRobot 镜像目标自带 90 天可用率)
const provided = Number(st?.uptimeRatios?.[90]);
if (Number.isFinite(provided)) return { pct: provided, total: 0, up: 0, days: 90, source: 'external' };
return null;
}
function uptimeBar(st) {
const daily = st?.daily;
const cells = lastNDays(UPTIME_DAYS).map((key) => {
const b = daily?.[key];
let cls = 'lv-none';
let tip = `${key} · 无数据`;
if (b && b.total > 0) {
const pct = (b.up / b.total) * 100;
cls = pct >= 99 ? 'lv-ok' : pct >= 95 ? 'lv-warn' : 'lv-bad';
tip = `${key} · 可用率 ${pct.toFixed(1)}%(${b.up}/${b.total} 次)`;
}
return `<span class="uptime-cell ${cls}" title="${escapeHtml(tip)}"></span>`;
}).join('');
const s = uptimeSummary(st);
const label = s ? `90天 ${s.pct.toFixed(1)}%` : '90天 —';
let labelTip;
if (!s) labelTip = '可用率数据积累中(探针需持续运行)';
else if (s.source === 'external') labelTip = `由数据源提供(UptimeRobot 官方 90 天可用率);逐日色条由本探针同步后累积`;
else labelTip = `按已积累的 ${s.days} 天统计:${s.up}/${s.total} 次在线`;
return `<div class="uptime"><div class="uptime-bar">${cells}</div><span class="uptime-label" title="${escapeHtml(labelTip)}">${label}</span></div>`;
}
function hideTargets() {
return state.config?.settings?.hideTargets === true;
}
function targetText(m) {
if (hideTargets()) return '已隐藏';
if (m.url) return m.url;
if (m.host) return m.port ? `${m.host}:${m.port}` : m.host;
return '—';
}
function typeLabel(m) {
// 外部数据源(UptimeRobot)标注来源与协议,便于与自建探测区分
if (isExternal(m)) return `UR·${m.sourceLabel ?? ''}`.replace(/·$/, '');
if (m.type === 'tcp') return 'TCP';
if (m.type === 'banner') return String(m.protocol || 'BANNER').toUpperCase();
return m.method || 'GET';
}
function resultText(st, m) {
if (st?.code != null) return String(st.code);
if (isExternal(m)) return '–';
if (m.type === 'tcp') return '端口';
if (m.type === 'banner') return '标识串';
return '–';
}
function renderCard(m) {
const st = state.status?.monitors?.[m.id];
const s = statusText(st);
const card = document.createElement('article');
card.className = 'card';
card.dataset.status = s.cls;
card.dataset.id = m.id;
const head = document.createElement('div');
head.className = 'card-head';
head.innerHTML = `<span class="dot"></span><h3>${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>`;
const target = document.createElement('div');
target.className = 'card-target';
target.title = targetText(m);
target.textContent = targetText(m);
const metrics = document.createElement('div');
metrics.className = 'metrics';
// 外部数据源只有「最近一次状态变更」时间,语义与自建探测的「最近检测」不同,tooltip 里说明
const checkTitle = isExternal(m) && st?.lastCheck
? `UptimeRobot 最近一次状态变更:${fmtTime(st.lastCheck)}`
: fmtTime(st?.lastCheck);
metrics.innerHTML =
`<div class="metric"><b>${st?.latency != null ? `${st.latency} ms` : '–'}</b><label>响应延迟</label></div>` +
`<div class="metric"><b>${escapeHtml(resultText(st, m))}</b><label>结果</label></div>` +
`<div class="metric"><b title="${escapeHtml(checkTitle)}">${fmtRelative(st?.lastCheck)}</b><label>最近检测</label></div>`;
const hb = document.createElement('div');
hb.className = 'heartbeat';
hb.innerHTML = heartbeatSvg(st?.history);
const up = document.createElement('div');
up.innerHTML = uptimeBar(st);
const detail = document.createElement('div');
detail.className = 'card-detail';
detail.title = st?.detail ?? '';
detail.textContent = st?.detail ?? '等待检测';
card.append(head, target, metrics, hb, up.firstElementChild ?? document.createElement('div'), detail);
return card;
}
function groupMonitors(monitors) {
const groups = new Map();
for (const m of monitors) {
const g = m.group || '未分组';
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(m);
}
return groups;
}
// 面板展示的目标 = 配置文件里的自建目标 + 快照中带 meta 的外部数据源目标(如 UptimeRobot),
// 过滤优先级:URL ?filter= > 快照 dataFilter(服务端口径,私有配置覆盖后仍一致)> 配置 settings.dataFilter
function dataFilter() {
const q = new URLSearchParams(location.search).get('filter');
if (['both', 'config', 'uptimerobot'].includes(q)) return q;
const f = state.status?.dataFilter ?? state.config?.settings?.dataFilter;
return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both';
}
function allMonitors() {
const filter = dataFilter();
const cfg = state.config?.monitors ?? [];
const out = filter === 'uptimerobot' ? [] : [...cfg];
if (filter === 'config') return out;
const known = new Set(cfg.map((m) => m.id));
for (const [id, st] of Object.entries(state.status?.monitors ?? {})) {
if (known.has(id) || !st?.meta) continue;
out.push({ id, ...st.meta });
}
return out;
}
function isExternal(m) {
return m.source === 'uptimerobot';
}
function renderTabs() {
const nav = $('group-tabs');
const monitors = allMonitors();
const groups = groupMonitors(monitors);
nav.innerHTML = '';
const all = document.createElement('button');
all.type = 'button';
all.className = `tab${state.filter === 'all' ? ' active' : ''}`;
all.textContent = `全部 ${monitors.length}`;
all.addEventListener('click', () => setFilter('all'));
nav.appendChild(all);
for (const [g, list] of groups) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `tab${state.filter === g ? ' active' : ''}`;
btn.textContent = `${g} ${list.length}`;
btn.addEventListener('click', () => setFilter(g));
nav.appendChild(btn);
}
}
function setFilter(g) {
state.filter = g;
renderTabs();
renderView();
}
function visibleGroups() {
const all = [...groupMonitors(allMonitors())];
return state.filter === 'all' ? all : all.filter(([g]) => g === state.filter);
}
/* ---------- 视图:卡片 / 表格 / 大屏 / 极简 ---------- */
// 展示过滤(URL ?filter=)与服务端快照的 dataFilter 不一致时,被过滤出来的目标不在快照里,
// 会整页显示「等待检测」——这里给出明确解释,避免看起来像故障
function renderFilterNotice(container) {
const snapMonitors = state.status?.monitors ?? {};
const shown = allMonitors();
const missing = shown.filter((m) => !snapMonitors[m.id]).length;
const serverFilter = state.status?.dataFilter;
if (!missing || !shown.length || !serverFilter || serverFilter === dataFilter()) return;
const el = document.createElement('div');
el.className = 'notice';
el.innerHTML =
`当前展示过滤为 <b>${escapeHtml(dataFilter())}</b>,但服务端快照的 dataFilter 为 <b>${escapeHtml(serverFilter)}</b>:其中 ${missing} 个目标不在快照中,因此显示为「等待检测」。` +
`如需一致,请把 <code>settings.dataFilter</code> 设为相同值,或去掉地址栏里的 <code>?filter=</code> 参数。`;
container.appendChild(el);
}
function renderView() {
const grid = $('monitor-grid');
grid.innerHTML = '';
document.body.dataset.view = state.view;
if (!allMonitors().length) {
grid.innerHTML = '<div class="empty">当前数据源下没有可展示的监控目标。</div>';
return;
}
renderFilterNotice(grid);
({ table: renderTableView, cards: renderCardsView }[state.view] ?? renderTableView)(grid);
}
function renderCardsView(container) {
const groups = visibleGroups();
for (const [g, list] of groups) {
if (groups.length > 1) container.appendChild(groupTitle(g));
for (const m of list) container.appendChild(renderCard(m));
}
}
function groupTitle(text) {
const h = document.createElement('h2');
h.className = 'group-title';
h.textContent = text;
return h;
}
function renderTableView(container) {
const hide = hideTargets();
const wrap = document.createElement('div');
wrap.className = 'table-wrap';
const table = document.createElement('table');
table.className = 'monitor-table';
const thead = document.createElement('thead');
thead.innerHTML = `<tr><th>状态</th><th>服务</th>${hide ? '' : '<th>目标</th>'}<th>延迟</th><th>结果</th><th>最近检测</th><th>心率(48 次)</th><th>90 天可用率</th></tr>`;
const tbody = document.createElement('tbody');
const groups = visibleGroups();
for (const [g, list] of groups) {
if (groups.length > 1) {
const tr = document.createElement('tr');
tr.className = 'group-row';
const td = document.createElement('td');
td.colSpan = hide ? 7 : 8;
td.textContent = g;
tr.appendChild(td);
tbody.appendChild(tr);
}
for (const m of list) tbody.appendChild(tableRow(m, hide));
}
table.append(thead, tbody);
wrap.appendChild(table);
container.appendChild(wrap);
}
function tableRow(m, hide) {
const st = state.status?.monitors?.[m.id];
const s = statusText(st);
const up = uptimeSummary(st);
const tr = document.createElement('tr');
tr.dataset.status = s.cls;
tr.dataset.id = m.id;
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`;
tr.innerHTML =
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
`<td class="t-name">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span></td>` +
targetCell +
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
`<td class="t-num" title="${escapeHtml(fmtTime(st?.lastCheck))}">${fmtRelative(st?.lastCheck)}</td>` +
`<td class="t-hb">${heartbeatSvg(st?.history)}</td>` +
`<td class="t-uptime">${up ? `<span class="u-mini">${miniUptimeBar(st?.daily)}</span><span class="t-pct">${up.pct.toFixed(1)}%</span>` : '<span class="t-pct">–</span>'}</td>`;
return tr;
}
function miniUptimeBar(daily) {
const cells = lastNDays(UPTIME_DAYS).map((key) => {
const b = daily?.[key];
let cls = 'lv-none';
if (b && b.total > 0) {
const pct = (b.up / b.total) * 100;
cls = pct >= 99 ? 'lv-ok' : pct >= 95 ? 'lv-warn' : 'lv-bad';
}
return `<span class="uptime-cell ${cls}"></span>`;
}).join('');
return `<span class="uptime-bar uptime-bar-mini">${cells}</span>`;
}
function renderViewSwitch() {
const box = $('view-switch');
if (!box) return;
box.innerHTML = '';
for (const [key, label] of Object.entries(VIEWS)) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `view-btn${state.view === key ? ' active' : ''}`;
btn.textContent = label;
btn.title = `${label}视图`;
btn.addEventListener('click', () => setView(key));
box.appendChild(btn);
}
}
function resolveView() {
const q = new URLSearchParams(location.search).get('view');
let saved = null;
try {
saved = localStorage.getItem('shiroko.view');
} catch { /* 隐私模式下 localStorage 不可用 */ }
const cfg = state.config?.settings?.view;
return [q, saved, cfg, DEFAULT_VIEW].find((v) => v && VIEWS[v]) ?? DEFAULT_VIEW;
}
function setView(view, { persist = true } = {}) {
state.view = VIEWS[view] ? view : 'cards';
if (persist) {
try {
localStorage.setItem('shiroko.view', state.view);
} catch { /* 忽略存储失败 */ }
}
renderViewSwitch();
renderView();
}
// 顶部汇总按「当前实际展示的目标」计算:URL ?filter= 覆盖服务端口径时,统计也不会与列表不一致
function localStats() {
const monitors = allMonitors();
let up = 0;
let down = 0;
let pending = 0;
let latSum = 0;
let latCount = 0;
let uptimeUp = 0;
let uptimeTotal = 0;
for (const m of monitors) {
const st = state.status?.monitors?.[m.id];
if (!st || st.status === 'pending') {
pending++;
continue;
}
if (st.status === 'up') up++;
else down++;
// 与探针口径一致:平均延迟只统计自建探测目标
if (!isExternal(m) && st.latency != null) {
latSum += st.latency;
latCount++;
}
const s = uptimeSummary(st);
if (s) {
uptimeTotal += s.total;
uptimeUp += s.up;
}
}
return {
total: monitors.length,
up,
down,
pending,
avgLatency: latCount ? Math.round(latSum / latCount) : null,
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
};
}
function renderSummary() {
const st = state.status;
const stats = localStats();
const down = stats.down;
const pending = stats.pending;
const pill = $('overall-pill');
pill.className = 'pill';
if (!st) pill.textContent = '检测中…';
else if (!stats.total) {
pill.classList.add('pending');
pill.textContent = '无展示目标';
} else if (down > 0) {
pill.classList.add('bad');
pill.textContent = `${down} 个服务异常`;
} else if (pending > 0) {
pill.classList.add('pending');
pill.textContent = '检测中…';
} else {
pill.classList.add('ok');
pill.textContent = '全部服务正常';
}
$('stat-total').textContent = stats.total;
$('stat-up').textContent = stats.up;
$('stat-down').textContent = stats.down;
$('stat-latency').textContent = stats.avgLatency != null ? `${stats.avgLatency} ms` : '–';
$('stat-uptime').textContent = stats.avgUptime90 != null ? `${stats.avgUptime90}%` : '–';
// 展示「数据最后更新时刻」(服务端产出新数据的时间)而非快照生成时间:后者每次请求都变,无法反映新鲜度
const updatedMs = dataUpdatedMs();
$('updated-at').textContent = updatedMs ? `数据更新于 ${fmtTime(state.status?.dataUpdatedAt)}` : '数据更新于 –';
$('updated-at').title = updatedMs
? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒`
: '尚无服务端数据';
$('demo-chip').hidden = !st?.demo;
const ur = st?.sources?.uptimerobot;
const urChip = $('ur-chip');
if (urChip) {
urChip.hidden = !(ur?.enabled && ur.ok === false);
if (!urChip.hidden) urChip.title = `UptimeRobot 同步失败:${ur.error ?? '未知错误'}(保留上次数据,目标状态未被误判)`;
}
}
function renderAll() {
renderSummary();
renderTabs();
renderViewSwitch();
renderView();
}
function renderError(err) {
const grid = $('monitor-grid');
grid.innerHTML = `<div class="empty">数据加载失败:${escapeHtml(err.message)}<br>请检查 monitors.json 与数据源配置。</div>`;
}
/* ---------- 刷新循环 ---------- */
function refreshIntervalSec() {
const configured = Number(state.config?.settings?.refreshInterval);
if (Number.isFinite(configured) && configured > 0) return Math.max(5, configured);
// 未显式配置时:只看 UptimeRobot 的话,其自身 5 分钟才更新一次,刷新更快没有意义
return dataFilter() === 'uptimerobot' ? EXTERNAL_REFRESH_SEC : DEFAULT_REFRESH_SEC;
}
// 服务端产出新数据的节奏(秒),由快照提供;浏览器直连模式没有服务端节奏
function serverCadenceSec() {
const n = Number(state.status?.updateIntervalSec);
return Number.isFinite(n) && n > 0 ? n : null;
}
function dataUpdatedMs() {
const t = state.status?.dataUpdatedAt ? new Date(state.status.dataUpdatedAt).getTime() : 0;
return Number.isFinite(t) ? t : 0;
}
// 服务端数据是否已明显过期(超过 1.5 个周期没更新,例如探针刚启动或被休眠过)
function isDataStale() {
const cadence = serverCadenceSec();
const updated = dataUpdatedMs();
if (!cadence || !updated) return false;
return Date.now() - updated > cadence * 1500;
}
// 下一次检查的间隔:api 模式下对齐「服务端上次更新 + 一个周期」,refreshInterval 只作为上限;
// 静态快照与浏览器直连没有可对齐的服务端节奏,直接按 refreshInterval 轮询
function nextCheckInSec() {
const cap = refreshIntervalSec();
const cadence = serverCadenceSec();
const updated = dataUpdatedMs();
if (state.source !== 'api' || !cadence || !updated) return cap;
const left = Math.round((updated + cadence * 1000 - Date.now()) / 1000);
if (left <= 0) return Math.min(cap, Math.max(5, cadence)); // 已过预期更新时刻:稍后重试
return Math.max(5, Math.min(left, cap));
}
// 强制检测需等待各目标探测返回(含超时目标,服务端另有 22s 兜底),故用更宽松的超时
const REFRESH_TIMEOUT_MS = 30000;
function setLoading(on, text = '刷新中…') {
state.loading = on;
const btn = $('refresh-btn');
btn.disabled = on;
btn.textContent = on ? text : '刷新';
}
async function refresh({ force = false } = {}) {
if (state.loading) return;
// 自动刷新只读取服务端已产出的数据(探测由服务端按各自 interval 持续驱动,不因访客而变);
// 仅两种情况触发重新检测:① 手动点击刷新 ② 服务端数据明显过期时自愈一次
const recheck = state.source === 'api' && !state.refreshDenied && (force || isDataStale());
setLoading(true, recheck ? '检测中…' : '刷新中…');
try {
if (state.source === 'browser') {
state.status = await probeFromBrowser(state.config);
} else if (state.source === 'api') {
if (recheck) {
// 首次加载:先用服务端已有快照立即出图,避免强制检测(含超时目标,可能十几秒)期间显示空白面板
if (!state.status) {
try {
state.status = await fetchJson('api/status');
renderAll();
} catch { /* 拿不到旧快照就继续等强制检测结果 */ }
}
try {
// 只看 UptimeRobot 时不需要跑本地探测轮次,只同步 UR 数据
const scope = dataFilter() === 'uptimerobot' ? '?scope=external' : '';
state.status = await fetchJson(`api/refresh${scope}`, REFRESH_TIMEOUT_MS);
} catch (err) {
// 服务端未开放强制探测(403/401)→ 本次会话退回只读轮询;限流(429)→ 仅是这一轮降级
if (err.status === 403 || err.status === 401) markRefreshDenied();
else if (err.status === 429) $('refresh-btn').title = '刷新过于频繁,已显示最近一次数据(服务端限流保护)';
else throw err;
state.status = await fetchJson('api/status');
}
} else {
state.status = await fetchJson('api/status');
}
} else {
state.status = await fetchJson('status.json');
}
renderAll();
} catch (err) {
if (state.source !== 'browser') {
// 数据源失效(如静态部署缺少 status.json)时降级为浏览器直连
setSource('browser');
setLoading(false);
return refresh({ force });
}
renderError(err);
} finally {
setLoading(false);
resetCountdown();
}
}
function markRefreshDenied() {
if (state.refreshDenied) return;
state.refreshDenied = true;
const btn = $('refresh-btn');
btn.title = '服务端未开放「重新检测」,刷新仅重新读取最新数据';
$('source-badge').textContent = `${SOURCE_LABEL[state.source]}(只读)`;
}
function resetCountdown() {
state.nextRefreshAt = Date.now() + nextCheckInSec() * 1000;
updateCountdownText();
}
function updateCountdownText() {
const left = Math.max(0, Math.round((state.nextRefreshAt - Date.now()) / 1000));
$('countdown').textContent = `下次检查 ${left}s`;
$('countdown').title = '自动刷新只读取服务端已产出的数据,节奏对齐服务端的更新时间;点击「刷新」可立即重新检测';
}
function startAutoRefresh() {
clearInterval(state.refreshTimer);
resetCountdown();
state.refreshTimer = setInterval(() => {
// 以截止时间计算剩余秒数,而非累加 tick:浏览器对后台标签页会节流定时器,计数法会导致刷新严重滞后
if (Date.now() >= state.nextRefreshAt) refresh();
else updateCountdownText();
}, 1000);
}
function bindEvents() {
// 只有手动点「刷新」才会触发一次重新检测;自动刷新是只读的
$('refresh-btn').addEventListener('click', () => refresh({ force: true }));
}
/* ---------- 启动 ---------- */
async function init() {
try {
state.config = await fetchJson('monitors.json');
} catch (err) {
renderError(err);
return;
}
document.title = state.config.settings?.title || 'Shiroko Monitor';
$('site-title').textContent = state.config.settings?.title || 'Shiroko Monitor';
$('site-desc').textContent = state.config.settings?.description || '';
const versionEl = document.querySelector('.version');
if (versionEl) versionEl.textContent = `shiroko-monitor v${VERSION}`;
await resolveSource();
state.view = resolveView();
bindEvents();
renderAll();
await refresh();
startAutoRefresh();
}
init();
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="#16a34a"/>
<circle cx="16" cy="16" r="12" fill="none" stroke="#16a34a" stroke-opacity="0.3" stroke-width="5"/>
<path d="M11 16.5l3.5 3.5L21 13" stroke="#fff" stroke-width="2.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

+356
View File
@@ -0,0 +1,356 @@
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--border: #e5e7eb;
--text: #111827;
--text-2: #6b7280;
--up: #16a34a;
--up-bg: #f0fdf4;
--down: #dc2626;
--down-bg: #fef2f2;
--pending: #d97706;
--pending-bg: #fffbeb;
--accent: #2563eb;
--uptime-ok: #16a34a;
--uptime-warn: #f59e0b;
--uptime-bad: #dc2626;
--uptime-none: #e5e7eb;
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--shadow: 0 1px 2px rgb(0 0 0 / 0.04);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--surface: #171a21;
--border: #2a2f3a;
--text: #e5e7eb;
--text-2: #9ca3af;
--up: #4ade80;
--up-bg: #10281c;
--down: #f87171;
--down-bg: #2a1517;
--pending: #fbbf24;
--pending-bg: #2a2113;
--accent: #60a5fa;
--uptime-none: #2a2f3a;
--shadow: none;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
}
/* ---------- 顶栏 ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.logo {
width: 26px;
height: 26px;
flex: none;
border-radius: 50%;
background: var(--up);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--up) 18%, transparent);
animation: pulse 2.4s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 30%, transparent); }
70% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--up) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); }
}
.brand-text { min-width: 0; }
.brand-text h1 { margin: 0; font-size: 15px; font-weight: 650; white-space: nowrap; }
.brand-text p { margin: 0; font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.pill {
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.pill.ok { background: var(--up-bg); color: var(--up); }
.pill.bad { background: var(--down-bg); color: var(--down); }
.pill.pending { background: var(--pending-bg); color: var(--pending); }
.stat { text-align: center; min-width: 44px; }
.stat b { display: block; font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat label { font-size: 11px; color: var(--text-2); }
.stat-up b { color: var(--up); }
.stat-down b { color: var(--down); }
#refresh-btn {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s;
}
#refresh-btn:hover { background: var(--bg); }
#refresh-btn:disabled { opacity: 0.6; cursor: default; }
/* ---------- 视图切换 ---------- */
.view-switch { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.view-btn {
padding: 5px 10px;
border: 0;
background: var(--surface);
color: var(--text-2);
font-size: 12px;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-btn + .view-btn { border-left: 1px solid var(--border); }
.view-btn:hover { color: var(--text); }
.view-btn.active { background: var(--accent); color: #fff; }
/* ---------- 分组页签 ---------- */
.group-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 1200px;
margin: 0 auto;
padding: 16px 20px 0;
}
.tab {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-2);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ---------- 卡片网格 ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 16px 20px 8px;
}
.group-title {
grid-column: 1 / -1;
margin: 8px 0 0;
font-size: 13px;
font-weight: 650;
color: var(--text-2);
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--pending);
border-radius: 12px;
box-shadow: var(--shadow);
}
.card[data-status="up"] { border-left-color: var(--up); }
.card[data-status="down"] { border-left-color: var(--down); }
.card-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.card-head h3 { flex: 1; margin: 0; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dot { width: 10px; height: 10px; flex: none; border-radius: 50%; background: var(--pending); }
/* 通用着色:卡片 / 表格行 / 极简列表 都以 data-status 标记状态 */
[data-status="up"] .dot { background: var(--up); }
[data-status="down"] .dot { background: var(--down); }
[data-status="pending"] .dot { background: var(--pending); }
.card[data-status="up"] .dot { animation: pulse-dot 2.4s ease-out infinite; }
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 35%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--up) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); }
}
.type-badge {
flex: none;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-2);
}
.card-target {
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metrics { display: flex; gap: 8px; }
.metric { flex: 1; min-width: 0; }
.metric b { display: block; font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.metric label { font-size: 11px; color: var(--text-2); }
.heartbeat { height: 32px; }
.heartbeat svg { display: block; width: 100%; height: 100%; }
.hb-empty { display: flex; align-items: center; height: 100%; font-size: 12px; color: var(--text-2); }
/* 90 天可用率 */
.uptime { display: flex; align-items: center; gap: 8px; }
.uptime-bar { display: flex; gap: 1px; flex: 1; min-width: 0; height: 10px; }
.uptime-cell { flex: 1; min-width: 0; border-radius: 1px; background: var(--uptime-none); }
.uptime-cell.lv-ok { background: var(--uptime-ok); }
.uptime-cell.lv-warn { background: var(--uptime-warn); }
.uptime-cell.lv-bad { background: var(--uptime-bad); }
.uptime-label { font-size: 11px; color: var(--text-2); white-space: nowrap; font-variant-numeric: tabular-nums; }
.card-detail {
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card[data-status="down"] .card-detail { color: var(--down); }
.empty {
grid-column: 1 / -1;
padding: 60px 0;
text-align: center;
color: var(--text-2);
}
/* 过滤口径不一致时的提示条 */
.notice {
grid-column: 1 / -1;
margin-bottom: 10px;
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--pending) 45%, var(--border));
border-radius: 10px;
background: var(--pending-bg);
color: var(--pending);
font-size: 12px;
line-height: 1.7;
}
.notice code { font-family: var(--mono); background: color-mix(in srgb, var(--pending) 14%, transparent); padding: 0 4px; border-radius: 4px; }
/* ---------- 底栏 ---------- */
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 16px;
max-width: 1200px;
margin: 0 auto;
padding: 8px 20px 28px;
font-size: 12px;
color: var(--text-2);
}
.footer .version { margin-left: auto; }
.demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending); font-weight: 600; }
.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down); font-weight: 600; }
@media (max-width: 640px) {
.topbar { padding: 10px 14px; }
.summary { gap: 6px 12px; }
.grid { grid-template-columns: 1fr; padding: 12px 14px 8px; }
.group-tabs { padding: 12px 14px 0; }
.footer { padding: 8px 14px 24px; }
}
/* ================= 视图二:表格(高密度列表) ================= */
body[data-view="table"] .grid { display: block; }
.table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.monitor-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.monitor-table th {
padding: 10px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
white-space: nowrap;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.monitor-table tbody tr:last-child td { border-bottom: 0; }
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); }
.t-state { white-space: nowrap; }
.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; }
tr[data-status="up"] .t-state { color: var(--up); }
tr[data-status="down"] .t-state { color: var(--down); }
tr[data-status="pending"] .t-state { color: var(--pending); }
.t-name { white-space: nowrap; }
.t-name .type-badge { margin-left: 8px; }
.t-target {
max-width: 280px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.t-hb { width: 160px; }
.t-hb svg { display: block; width: 150px; height: 24px; }
.t-uptime { white-space: nowrap; }
.uptime-bar-mini { display: inline-flex; width: 110px; height: 8px; margin-right: 8px; vertical-align: middle; }
.t-pct { font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="轻量级服务监控面板:HTTP 可用性与 TCP 端口探测">
<title>Shiroko Monitor</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo" aria-hidden="true"></span>
<div class="brand-text">
<h1 id="site-title">Shiroko Monitor</h1>
<p id="site-desc">轻量级服务监控面板</p>
</div>
</div>
<div class="summary">
<span id="overall-pill" class="pill pending">检测中…</span>
<div class="stat"><b id="stat-total">–</b><label>监控数</label></div>
<div class="stat stat-up"><b id="stat-up">–</b><label>在线</label></div>
<div class="stat stat-down"><b id="stat-down">–</b><label>离线</label></div>
<div class="stat"><b id="stat-latency">–</b><label>平均延迟</label></div>
<div class="stat"><b id="stat-uptime">–</b><label>90天可用率</label></div>
<div class="view-switch" id="view-switch" role="group" aria-label="视图切换"></div>
<button id="refresh-btn" type="button">刷新</button>
</div>
</header>
<nav class="group-tabs" id="group-tabs" aria-label="分组筛选"></nav>
<main class="grid" id="monitor-grid">
<div class="empty">正在加载监控配置…</div>
</main>
<footer class="footer">
<span id="source-badge" class="footer-item">数据源:–</span>
<span id="demo-chip" class="footer-item demo-chip" hidden>演示数据</span>
<span id="ur-chip" class="footer-item warn-chip" hidden>UptimeRobot 同步异常</span>
<span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.6.0</span>
</footer>
<script type="module" src="assets/app.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
{
"settings": {
"title": "Shiroko Monitor",
"description": "轻量级服务监控面板",
"view": "table",
"dataFilter": "uptimerobot",
"refreshInterval": 300,
"dataSource": "auto",
"uptimeRobot": {
"enabled": false,
"apiKey": "",
"pollIntervalSec": 300,
"group": "UptimeRobot"
}
},
"monitors": [
{
"id": "example-com",
"name": "Example 站点",
"group": "公共服务",
"type": "http",
"url": "https://www.example.com",
"method": "GET",
"expectStatus": [200],
"interval": 60,
"timeout": 10
},
{
"id": "baidu",
"name": "百度",
"group": "公共服务",
"type": "http",
"url": "https://www.baidu.com",
"expectStatus": [200],
"interval": 60,
"timeout": 10
},
{
"id": "cloudflare-site",
"name": "Cloudflare 官网",
"group": "公共服务",
"type": "http",
"url": "https://www.cloudflare.com",
"expectStatus": [200],
"interval": 60,
"timeout": 10
},
{
"id": "github-api",
"name": "GitHub API(限流目标示例)",
"group": "公共服务",
"type": "http",
"url": "https://api.github.com",
"expectStatus": [200],
"interval": 300,
"timeout": 10,
"probeOnRefresh": false
},
{
"id": "httpbin-500",
"name": "HTTPBin 500(演示 expectStatus)",
"group": "公共服务",
"type": "http",
"url": "https://httpbin.org/status/500",
"expectStatus": [500],
"interval": 120,
"timeout": 10
},
{
"id": "badssl-expired",
"name": "BadSSL 过期证书(演示离线)",
"group": "公共服务",
"type": "http",
"url": "https://expired.badssl.com/",
"expectStatus": [200],
"interval": 120,
"timeout": 10
},
{
"id": "cloudflare-dns-tcp",
"name": "Cloudflare 443 端口",
"group": "基础设施",
"type": "tcp",
"host": "1.1.1.1",
"port": 443,
"interval": 60,
"timeout": 10
},
{
"id": "google-dns-tcp",
"name": "Google DNS 53 端口",
"group": "基础设施",
"type": "tcp",
"host": "8.8.8.8",
"port": 53,
"interval": 60,
"timeout": 10
}
]
}
+1100
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
/**
* shiroko-monitor 安全与探测能力验证(开发用)
*
* 覆盖:
* A. banner 探测:SSH 标识串识别、expectBanner 策略匹配/不符、静默端口超时、默认不回显版本
* B. 信息泄露控制:detail 不含地址/版本、redactDetails 脱敏、公开配置不含真实地址
* C. 私有配置分层:local.monitors.json 覆盖 host/port 后探测才可连通
* D. /api/refresh 滥用防护:冷却限流 429 + Retry-After、allowRefresh=false 时 403
* E. 来源判定 isPrivateAddress 单元校验
*
* 用法:node scripts/verify-security.cjs
*/
const fs = require('node:fs');
const os = require('node:os');
const net = require('node:net');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-sec-'));
const SSH_PORT = 2222;
const SILENT_PORT = 2223;
const results = [];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function waitForHttp(url, timeoutMs = 8000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(url, { cache: 'no-store' });
return true;
} catch {
await sleep(200);
}
}
return false;
}
function startProber(args, env = {}) {
return spawn(process.execPath, [PROBER, ...args], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function runOnce(args) {
return new Promise((resolve) => {
const child = startProber(args);
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('exit', (code) => resolve({ code, out }));
});
}
async function main() {
// ---------- 准备:本地假 SSH 服务 + 静默端口 ----------
const sshServer = net.createServer((sock) => {
// 忠实模拟 OpenSSH:连接建立后立即发送标识串(RFC 4253)
sock.write('SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n');
});
const silentServer = net.createServer(() => { /* 接受连接但从不发送数据 */ });
await new Promise((r) => sshServer.listen(SSH_PORT, '127.0.0.1', r));
await new Promise((r) => silentServer.listen(SILENT_PORT, '127.0.0.1', r));
console.log(`[准备] 假 SSH 服务 127.0.0.1:${SSH_PORT},静默端口 127.0.0.1:${SILENT_PORT},临时目录 ${TMP}`);
// ---------- A/B/C:banner 探测 + 脱敏 + 私有配置分层 ----------
const pubConfig = path.join(TMP, 'monitors.json');
const localConfig = path.join(TMP, 'local.monitors.json');
const outJson = path.join(TMP, 'status.json');
fs.writeFileSync(pubConfig, JSON.stringify({
settings: { title: 'sec-test', redactDetails: false },
monitors: [
{ id: 'ssh-banner', name: 'SSH 服务', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-' },
{ id: 'ssh-reveal', name: 'SSH(回显)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-', revealBanner: true },
{ id: 'ssh-mismatch', name: 'SSH(策略不符)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-1\\.99-' },
{ id: 'silent-banner', name: '静默端口 banner', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 },
{ id: 'silent-tcp', name: '静默端口 TCP', group: '基础设施', type: 'tcp', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 },
],
}, null, 2));
// 真实地址只存在于私有覆盖中(模拟「公开仓库不含内网地址」)
fs.writeFileSync(localConfig, JSON.stringify({
monitors: [
{ id: 'ssh-banner', host: '127.0.0.1', port: SSH_PORT },
{ id: 'ssh-reveal', host: '127.0.0.1', port: SSH_PORT },
{ id: 'ssh-mismatch', host: '127.0.0.1', port: SSH_PORT },
{ id: 'silent-banner', host: '127.0.0.1', port: SILENT_PORT },
{ id: 'silent-tcp', host: '127.0.0.1', port: SILENT_PORT },
],
}, null, 2));
console.log('\n[A] banner 探测(私有配置覆盖地址后执行一轮探测)');
const once = await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outJson}`]);
const snap = JSON.parse(fs.readFileSync(outJson, 'utf8'));
const m = snap.monitors;
check('私有配置分层生效(占位地址被覆盖为本地地址后探测成功)', m['ssh-banner'].status === 'up', `ssh-banner=${m['ssh-banner'].status} · ${m['ssh-banner'].detail}`);
check('SSH 标识串识别为在线且只给协议族摘要', m['ssh-banner'].detail === 'SSH 响应正常', `detail="${m['ssh-banner'].detail}"`);
check('默认不回显版本号(detail 不含 OpenSSH)', !/OpenSSH/.test(m['ssh-banner'].detail), m['ssh-banner'].detail);
check('expectBanner 策略不符时判为离线且不回显实际标识串', m['ssh-mismatch'].status === 'down' && !/OpenSSH/.test(m['ssh-mismatch'].detail), `detail="${m['ssh-mismatch'].detail}"`);
check('显式开启 revealBanner 后才回显标识串', /OpenSSH/.test(m['ssh-reveal'].detail), `detail="${m['ssh-reveal'].detail}"`);
check('静默端口 banner 探测超时判离线', m['silent-banner'].status === 'down', `detail="${m['silent-banner'].detail}"`);
check('同一静默端口 TCP 探测可达(证明两者判定逻辑不同)', m['silent-tcp'].status === 'up', `detail="${m['silent-tcp'].detail}"`);
check('TCP detail 不再包含地址', !m['silent-tcp'].detail.includes('127.0.0.1') && !m['silent-tcp'].detail.includes(':'), m['silent-tcp'].detail);
const rawSnapshot = fs.readFileSync(outJson, 'utf8');
check('快照中不出现公开配置的占位地址', !rawSnapshot.includes('placeholder.invalid'), '未泄露 host');
check('快照中不出现真实内网地址', !rawSnapshot.includes('127.0.0.1'), '未泄露 host');
console.log('\n[B] redactDetails 脱敏');
const redacted = JSON.parse(fs.readFileSync(pubConfig, 'utf8'));
redacted.settings.redactDetails = true;
fs.writeFileSync(pubConfig, JSON.stringify(redacted, null, 2));
const outRedacted = path.join(TMP, 'status-redacted.json');
await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outRedacted}`]);
const rSnap = JSON.parse(fs.readFileSync(outRedacted, 'utf8'));
check('redactDetails 开启后 detail 只保留正常/异常', rSnap.monitors['ssh-reveal'].detail === '正常' && rSnap.monitors['silent-tcp'].detail === '正常', JSON.stringify(Object.fromEntries(Object.entries(rSnap.monitors).map(([k, v]) => [k, v.detail]))));
// ---------- D:刷新接口滥用防护 ----------
console.log('\n[D] /api/refresh 滥用防护');
const cooldownCfg = path.join(TMP, 'cooldown.json');
fs.writeFileSync(cooldownCfg, JSON.stringify({ settings: { title: 't', refreshCooldownSec: 3 }, monitors: [] }, null, 2));
const srvA = startProber([`--config=${cooldownCfg}`, `--local-config=${cooldownCfg}`, `--out=${path.join(TMP, 'a.json')}`], { PORT: '8897' });
await waitForHttp('http://127.0.0.1:8897/api/status');
const r1 = await fetch('http://127.0.0.1:8897/api/refresh');
const r2 = await fetch('http://127.0.0.1:8897/api/refresh');
check('首次刷新放行', r1.status === 200, `status=${r1.status}`);
check('冷却期内再次刷新被限流', r2.status === 429, `status=${r2.status}, retry-after=${r2.headers.get('retry-after')}s`);
check('限流响应带 Retry-After 头', Number(r2.headers.get('retry-after')) >= 1, `retry-after=${r2.headers.get('retry-after')}`);
await sleep(3300);
const r3 = await fetch('http://127.0.0.1:8897/api/refresh');
check('冷却结束后可再次刷新', r3.status === 200, `status=${r3.status}`);
srvA.kill();
const disabledCfg = path.join(TMP, 'disabled.json');
fs.writeFileSync(disabledCfg, JSON.stringify({ settings: { title: 't', allowRefresh: false }, monitors: [] }, null, 2));
const srvB = startProber([`--config=${disabledCfg}`, `--local-config=${disabledCfg}`, `--out=${path.join(TMP, 'b.json')}`], { PORT: '8898' });
await waitForHttp('http://127.0.0.1:8898/api/status');
const r4 = await fetch('http://127.0.0.1:8898/api/refresh');
const body4 = await r4.json().catch(() => ({}));
check('allowRefresh:false 时刷新被拒绝', r4.status === 403, `status=${r4.status}, error=${body4.error}`);
check('拒绝响应不泄露目标信息', !JSON.stringify(body4).includes('host') && !JSON.stringify(body4).includes('/'), JSON.stringify(body4));
const r5 = await fetch('http://127.0.0.1:8898/api/status');
check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`);
srvB.kill();
// ---------- E:来源判定 ----------
console.log('\n[E] 来源判定');
const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`);
const priv = ['127.0.0.1', '::1', '10.1.2.3', '192.168.1.10', '172.16.0.1', '172.31.255.254', 'fc00::1', 'fe80::1', '::ffff:127.0.0.1'];
const pub = ['8.8.8.8', '1.1.1.1', '172.32.0.1', '11.0.0.1', '2001:4860:4860::8888'];
check('内网/回环地址被允许', priv.every(isPrivateAddress), priv.filter((i) => !isPrivateAddress(i)).join(',') || '全部通过');
check('公网地址被拒绝', pub.every((i) => !isPrivateAddress(i)), pub.filter(isPrivateAddress).join(',') || '全部通过');
// ---------- 收尾 ----------
sshServer.close();
silentServer.close();
fs.rmSync(TMP, { recursive: true, force: true });
const failed = results.filter((r) => !r.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) process.exit(1);
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});
+193
View File
@@ -0,0 +1,193 @@
/**
* shiroko-monitor UptimeRobot 集成验证(开发用)
*
* 覆盖:
* A. 映射单测(无网络):用合成 payload 校验状态/延迟/状态码/心率/聚合比率/元信息,且不泄露 Key
* B. 真实 Key 冒烟(需 UPTIMEROBOT_API_KEY,未设置则跳过)
* C. UptimeRobot 兼容只读接口 /api/v2/getMonitors
* D. 失败路径:Key 无效时只记录错误、不把任何目标误判为离线
*
* 用法:UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs
*/
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const LIB = path.join(ROOT, 'prober', 'node', 'lib', 'uptimerobot.js');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-ur-'));
const LIVE_KEY = process.env.UPTIMEROBOT_API_KEY || '';
const results = [];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function waitForHttp(url, timeoutMs = 8000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(url, { cache: 'no-store' });
return true;
} catch {
await sleep(200);
}
}
return false;
}
function runOnce(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('exit', (code) => resolve({ code, out }));
});
}
// 合成的 getMonitors 响应:结构照搬真实字段,但内容全部虚构
const FIXTURE = {
stat: 'ok',
pagination: { offset: 0, limit: 50, total: 3 },
monitors: [
{
id: 111111111,
friendly_name: '演示站点 A',
url: 'https://a.example.test',
type: 1,
interval: 300,
status: 2,
create_datetime: 1700000000,
custom_uptime_ratio: '100.000-99.500-99.900-99.950',
custom_down_durations: '0-100-200-300',
average_response_time: 250.5,
response_times: [
{ datetime: 1791880000, value: 300 },
{ datetime: 1791879700, value: 200 },
{ datetime: 1791879400, value: 0 },
],
logs: [
{ type: 2, datetime: 1791879700, duration: 300, reason: { code: '200', detail: 'OK' } },
{ type: 1, datetime: 1791879400, duration: 300, reason: { code: '500', detail: 'Internal Server Error' } },
],
},
{
id: 222222222,
friendly_name: '演示端口 B',
url: 'b.example.test',
type: 4,
port: 22,
interval: 300,
status: 9,
custom_uptime_ratio: '0.000-0.000-0.000-0.000',
custom_down_durations: '86400-86400-86400-86400',
average_response_time: 0,
response_times: [],
logs: [{ type: 1, datetime: 1791000000, duration: 0, reason: { code: '403', detail: 'Forbidden' } }],
},
{
id: 333333333,
friendly_name: '演示暂停项 C',
url: 'https://c.example.test',
type: 1,
interval: 300,
status: 0,
custom_uptime_ratio: '0.000-0.000-0.000-0.000',
response_times: [],
logs: [],
},
],
};
async function main() {
const { mapUptimeRobotMonitors } = await import(`file://${LIB.replace(/\\/g, '/')}`);
// ---------- A. 映射单测 ----------
console.log('\n[A] 映射单测(合成 payload,无网络)');
const mapped = mapUptimeRobotMonitors(FIXTURE, { group: 'UR 测试', now: 1791880300000 });
const [a, b, c] = mapped;
check('监控数量与 id 前缀', mapped.length === 3 && a.id === 'ur-111111111', `${mapped.length} 个,首个 id=${a.id}`);
check('status 映射:2→up / 9→down / 0→pending', a.status === 'up' && b.status === 'down' && c.status === 'pending', `${a.status}/${b.status}/${c.status}`);
check('延迟取最新一次样本', a.latency === 300 && b.latency === 0, `A=${a.latency}ms B=${b.latency}ms`);
check('故障时取故障日志里的 HTTP 状态码', b.code === 403 && a.code === null, `B.code=${b.code}`);
check('detail 含 UR 侧原因', b.detail === 'UptimeRobot:403 Forbidden', b.detail);
check('心率来自 response_times 且按时间升序', a.history.length === 3 && a.history[0].ms === 0 && a.history[2].ms === 300, JSON.stringify(a.history.map((h) => h.ms)));
check('离线目标的心率末尾补一条离线记录', b.history.length === 1 && b.history[0].ok === false, `${b.history.length} 条`);
check('聚合比率解析(1/7/30/90)', a.uptimeRatios['90'] === 99.95 && a.downSeconds['30'] === 200, JSON.stringify(a.uptimeRatios));
check('端口型目标拼出 host:port 形式', b.url === 'b.example.test:22', b.url);
check('来源标识与协议标签', a.source === 'uptimerobot' && a.sourceLabel === 'HTTP' && b.sourceLabel === 'PORT', `${a.sourceLabel}/${b.sourceLabel}`);
check('映射结果不含 API Key 字段', !JSON.stringify(mapped).toLowerCase().includes('apikey'), '无泄露');
// ---------- C. 兼容接口 ----------
console.log('\n[C] UptimeRobot 兼容只读接口');
const cfg = path.join(TMP, 'monitors.json');
const out = path.join(TMP, 'status.json');
fs.writeFileSync(cfg, JSON.stringify({
settings: { title: 't' },
monitors: [
{ id: 'ok-site', name: '正常站点', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
],
}, null, 2));
const child = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${cfg}`, `--out=${out}`], { env: { ...process.env, PORT: '8891' }, stdio: ['ignore', 'pipe', 'pipe'] });
await waitForHttp('http://127.0.0.1:8891/api/status');
const compat = await fetch('http://127.0.0.1:8891/api/v2/getMonitors').then((r) => r.json());
const first = compat.monitors?.[0] ?? {};
check('返回 stat=ok 与 pagination', compat.stat === 'ok' && compat.pagination?.total === 1, JSON.stringify(compat.pagination));
check('字段与 UptimeRobot 对齐', ['id', 'friendly_name', 'url', 'type', 'status', 'interval', 'custom_uptime_ratio', 'average_response_time', 'response_times'].every((k) => k in first), Object.keys(first).join(','));
check('id 为数字、type/status 编码一致', typeof first.id === 'number' && first.type === 1 && [1, 2, 9].includes(first.status), `id=${first.id} type=${first.type} status=${first.status}`);
check('custom_uptime_ratio 为 1-7-30-90 四位三位小数', /^\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}$/.test(first.custom_uptime_ratio), first.custom_uptime_ratio);
child.kill();
// ---------- D. 失败路径:Key 无效不得误判目标状态 ----------
console.log('\n[D] 失败路径(无效 Key)');
const badCfg = path.join(TMP, 'bad.json');
const badOut = path.join(TMP, 'bad-status.json');
fs.writeFileSync(badCfg, JSON.stringify({
settings: { title: 't', uptimeRobot: { enabled: true, apiKey: 'ur000000-this-key-is-not-valid' } },
monitors: [
{ id: 'only-site', name: '唯一目标', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
],
}, null, 2));
const r = await runOnce([`--once`, `--config=${badCfg}`, `--local-config=${badCfg}`, `--out=${badOut}`]);
const badSnap = JSON.parse(fs.readFileSync(badOut, 'utf8'));
check('同步失败被记录为 ok:false', badSnap.sources.uptimerobot.ok === false, String(badSnap.sources.uptimerobot.error).slice(0, 60));
check('同步失败不影响本地目标(无目标被误判为离线)', badSnap.stats.total === 1 && badSnap.stats.up === 1, `total=${badSnap.stats.total} up=${badSnap.stats.up}`);
check('未创建任何 UptimeRobot 镜像目标', Object.values(badSnap.monitors).every((m) => m.meta?.source !== 'uptimerobot'), '无镜像目标');
// ---------- B. 真实 Key 冒烟 ----------
console.log('\n[B] 真实 Key 冒烟');
if (!LIVE_KEY) {
console.log(' - 跳过(未设置 UPTIMEROBOT_API_KEY)');
} else {
const liveCfg = path.join(TMP, 'live.json');
const liveOut = path.join(TMP, 'live-status.json');
fs.writeFileSync(liveCfg, JSON.stringify({
settings: { title: 't', uptimeRobot: { enabled: true, apiKey: LIVE_KEY, pollIntervalSec: 300, group: 'UptimeRobot' } },
monitors: [],
}, null, 2));
await runOnce([`--once`, `--config=${liveCfg}`, `--local-config=${liveCfg}`, `--out=${liveOut}`]);
const live = JSON.parse(fs.readFileSync(liveOut, 'utf8'));
const mirrors = Object.entries(live.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot');
check('真实账号同步成功', live.sources.uptimerobot.ok === true, `count=${live.sources.uptimerobot.count}`);
check('拉到监控并全部映射', mirrors.length > 0 && mirrors.every(([, m]) => ['up', 'down', 'pending'].includes(m.status)), `${mirrors.length} 个`);
check('每个镜像目标都带展示元信息', mirrors.every(([, m]) => m.meta.name && Array.isArray(m.history) && m.uptimeRatios), 'name/history/uptimeRatios 齐备');
check('快照中不含 API Key', !JSON.stringify(live).includes(LIVE_KEY.slice(-12)), '无泄露');
for (const [id, m] of mirrors) console.log(` · ${id.padEnd(14)} ${m.status.padEnd(7)} ${String(m.latency ?? '-').padStart(5)}ms UR90天=${m.uptimeRatios?.['90']} ${m.meta.name}`);
}
fs.rmSync(TMP, { recursive: true, force: true });
const failed = results.filter((x) => !x.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) process.exit(1);
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
fs.rmSync(TMP, { recursive: true, force: true });
process.exit(1);
});
+130
View File
@@ -0,0 +1,130 @@
/**
* shiroko-monitor 四种视图验证(开发用,需 playwright)
*
* 覆盖:卡片 / 表格 / 大屏 / 极简 四视图的渲染数量、切换器、URL 指定、hideTargets 脱敏、无 JS 错误
*
* 用法:NODE_PATH=<playwright node_modules> node scripts/verify-views.cjs [baseUrl]
*/
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const BASE = process.argv[2] || 'http://localhost:8787';
const CONFIG = path.join(__dirname, '..', 'public', 'monitors.json');
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const results = [];
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
const SELECTOR = { cards: '.card', table: 'table.monitor-table tbody tr[data-status]' };
async function main() {
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
const snap = await fetch(`${BASE}/api/status`, { cache: 'no-store' }).then((r) => r.json());
const shown = Object.keys(snap.monitors).length;
// 默认视图:不带 ?view= 时应为表格
await page.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR.table, { timeout: 15000 });
check('默认视图为表格', (await page.evaluate(() => document.body.dataset.view)) === 'table', `data-view=${await page.evaluate(() => document.body.dataset.view)}`);
check('切换器只有两个选项(表格/卡片)', (await page.evaluate(() => document.querySelectorAll('.view-btn').length)) === 2, '2 个');
for (const view of ['table', 'cards']) {
await page.goto(`${BASE}/?source=api&view=${view}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR[view], { timeout: 15000 });
// 等首次快照渲染完成(不再有 pending 条目)再截图,避免拍到「等待检测」中间态
await page.waitForFunction(() => !document.querySelector('[data-status="pending"]'), null, { timeout: 40000 });
await page.waitForTimeout(400);
const info = await page.evaluate((sel) => ({
bodyView: document.body.dataset.view,
count: document.querySelectorAll(sel).length,
activeBtn: document.querySelector('.view-btn.active')?.textContent ?? '',
}), SELECTOR[view]);
check(`${view} 视图渲染 ${shown} 个条目`, info.count === shown, `count=${info.count}, 切换器=${info.activeBtn}`);
await page.screenshot({ path: path.join(__dirname, '..', 'docs', `screenshot-view-${view}.png`), fullPage: true });
}
// 切换器持久化:点击后写入 localStorage,刷新后仍生效(URL 不带 ?view= 时不覆盖用户选择)
await page.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR.table, { timeout: 15000 });
await page.click('.view-btn:nth-child(2)'); // 卡片
await page.waitForSelector(SELECTOR.cards, { timeout: 10000 });
const saved = await page.evaluate(() => localStorage.getItem('shiroko.view'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR.cards, { timeout: 15000 });
check('视图切换被记住(localStorage),刷新后仍为卡片', saved === 'cards' && (await page.evaluate(() => document.body.dataset.view)) === 'cards', `saved=${saved}`);
await page.evaluate(() => localStorage.removeItem('shiroko.view'));
// dataFilter:仅配置 / 仅 UptimeRobot / 两者
const cfgAll = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
const configCount = cfgAll.monitors.length;
const urCount = Object.values(snap.monitors).filter((m) => m.meta?.source === 'uptimerobot').length;
console.log(` 配置目标 ${configCount} 个,UptimeRobot 镜像 ${urCount} 个`);
const rawCfg = fs.readFileSync(CONFIG, 'utf8');
try {
for (const [mode, expect] of [['config', configCount], ['uptimerobot', urCount], ['both', configCount + urCount]]) {
const c = JSON.parse(rawCfg);
c.settings.dataFilter = mode;
fs.writeFileSync(CONFIG, `${JSON.stringify(c, null, 2)}\n`);
await new Promise((r) => setTimeout(r, 900)); // 等探针热重载
await page.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
if (expect > 0) await page.waitForSelector(SELECTOR.table, { timeout: 15000 });
else await page.waitForTimeout(1500);
const n = await page.evaluate((sel) => document.querySelectorAll(sel).length, SELECTOR.table);
const snapNow = await fetch(`${BASE}/api/status`, { cache: 'no-store' }).then((r) => r.json());
check(`dataFilter=${mode} 时面板与快照口径一致(${expect} 个)`, n === expect && snapNow.dataFilter === mode, `面板 ${n} 个,快照 dataFilter=${snapNow.dataFilter}`);
}
} finally {
// 还原并校验:测试改配置必须原样恢复,否则会静默改坏仓库里的配置
fs.writeFileSync(CONFIG, rawCfg);
if (fs.readFileSync(CONFIG, 'utf8') !== rawCfg) {
fs.writeFileSync(CONFIG, rawCfg);
check('测试后配置原样还原', false, '首次还原后内容不一致,已重写');
} else {
check('测试后配置原样还原', true, 'monitors.json 未被测试改动');
}
}
// hideTargets:开启后面板不渲染任何目标地址
const raw = fs.readFileSync(CONFIG, 'utf8');
const cfg = JSON.parse(raw);
cfg.settings.hideTargets = true;
fs.writeFileSync(CONFIG, `${JSON.stringify(cfg, null, 2)}\n`);
try {
await page.goto(`${BASE}/?source=api&view=cards`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.card', { timeout: 15000 });
await page.waitForTimeout(800);
const leak = await page.evaluate(() => {
const html = document.getElementById('monitor-grid').innerHTML;
return /https?:\/\//.test(html) || /\d+\.\d+\.\d+\.\d+:\d+/.test(html);
});
const target = await page.evaluate(() => document.querySelector('.card-target')?.textContent ?? '');
check('hideTargets 开启后面板不渲染目标地址', !leak && target === '已隐藏', `target="${target}"`);
} finally {
fs.writeFileSync(CONFIG, raw); // 还原
}
// 429 是刷新限流保护在起作用(快速连续导航各触发一次强制探测),面板已优雅降级为只读数据;
// 这里只把 429 以外的错误视为失败
const hardErrors = errors.filter((e) => !/429/.test(e));
check('两种视图无 JS 错误(除限流保护外)', hardErrors.length === 0, hardErrors.length ? hardErrors.join(' | ') : `无;另有 ${errors.length} 次 429 —— 快速连续刷新触发服务端限流,面板已回退到只读数据`);
await browser.close();
const failed = results.filter((r) => !r.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) process.exit(1);
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});
+191
View File
@@ -0,0 +1,191 @@
/**
* shiroko-monitor 端到端验证脚本(开发用,需 playwright)
*
* 覆盖:
* A. 热重载回归:改写 monitors.json 触发配置热重载后,/api/status 必须继续更新(历史 bug:接口冻结)
* B. 接口与文件一致性:/api/status 与 status.json 的 lastCheck 必须一致
* C. 面板重渲染:跨越一个刷新周期后,页脚「更新于」必须前进
* D. UI 断言:卡片数、心率图、90 格可用率条、统计栏
*
* 用法:NODE_PATH=<playwright node_modules> node scripts/verify.cjs [baseUrl]
*/
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const BASE = process.argv[2] || 'http://localhost:8787';
const CONFIG = path.join(__dirname, '..', 'public', 'monitors.json');
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
function check(name, pass, detail) {
results.push({ name, pass, detail });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function getJson(url) {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`);
return res.json();
}
async function main() {
// ---------- A/B. 服务端数据新鲜度与一致性 ----------
const cfgA = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
const cfgIdsA = new Set(cfgA.monitors.map((m) => m.id));
const first = (await getJson(`${BASE}/api/status`)).monitors;
// 优先选配置里的自建探测目标;若当前 dataFilter 只展示外部数据源,则退化为一致性/新鲜度断言
const probeId = Object.keys(first).find((id) => cfgIdsA.has(id)) ?? Object.keys(first)[0];
const isProbeTarget = cfgIdsA.has(probeId);
const before = first[probeId].lastCheck;
console.log(`\n[A] 触发配置热重载(改写 monitors.json),监控目标 ${probeId}${isProbeTarget ? '(自建探测)' : '(外部数据源镜像)'}`);
console.log(` 热重载前 lastCheck: ${before}`);
// 原样写回(不改格式)即可触发 fs.watch 的 change 事件
fs.writeFileSync(CONFIG, fs.readFileSync(CONFIG, 'utf8'));
await sleep(12000);
const mid = await getJson(`${BASE}/api/status`);
if (isProbeTarget) {
check('/api/status 热重载后 12s 内已产生新检测', mid.monitors[probeId].lastCheck > before, `lastCheck=${mid.monitors[probeId].lastCheck}`);
} else {
// 外部镜像目标由数据源按自身节奏更新,热重载不会让它产生新检测;改为校验服务端仍在产出数据
const ageSec = Math.round((Date.now() - new Date(mid.dataUpdatedAt).getTime()) / 1000);
check('热重载后接口仍持续服务且数据在节奏内', mid.monitors[probeId] != null && ageSec <= Number(mid.updateIntervalSec) + 30, `数据距今 ${ageSec}s / 节奏 ${mid.updateIntervalSec}s`);
}
await sleep(60000);
const api = await getJson(`${BASE}/api/status`);
const file = await getJson(`${BASE}/status.json`);
if (isProbeTarget) {
check('/api/status 跨越一个探测周期后仍在更新', api.monitors[probeId].lastCheck > mid.monitors[probeId].lastCheck, `lastCheck=${api.monitors[probeId].lastCheck}`);
}
check('接口与 status.json 数据一致', api.monitors[probeId].lastCheck === file.monitors[probeId].lastCheck, `api=${api.monitors[probeId].lastCheck} file=${file.monitors[probeId].lastCheck}`);
const dailyKeys = Object.keys(api.monitors[probeId].daily ?? {});
check('90 天可用率数据已聚合且不超过 90 天', dailyKeys.length > 0 && dailyKeys.length <= 90, `days=${dailyKeys.length}, today=${JSON.stringify(api.monitors[probeId].daily[dailyKeys[dailyKeys.length - 1]])}`);
check('统计包含 avgUptime90', api.stats.avgUptime90 != null, `avgUptime90=${api.stats.avgUptime90}`);
// ---------- E. 刷新即重新检测 ----------
console.log('\n[E] 刷新即重新检测');
const cfg = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
const snapMeta = await getJson(`${BASE}/api/status`);
const expectedCards = Object.keys(snapMeta.monitors).length;
// 外部数据源(UptimeRobot)镜像目标不参与本地强制探测,其「最近检测」由数据源给出
const externalIds = Object.entries(snapMeta.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot').map(([id]) => id);
// dataFilter 只展示外部数据源时,配置目标不在快照里,探测类断言不适用
const cfgIds = new Set(cfg.monitors.map((m) => m.id));
const probeIds = Object.keys(snapMeta.monitors).filter((id) => cfgIds.has(id));
const probeMode = probeIds.length > 0;
const forceIds = cfg.monitors.filter((m) => m.probeOnRefresh !== false && probeIds.includes(m.id)).map((m) => m.id);
// 显式 opt-out 的配置目标:不参与任何强制探测
const optOutIds = cfg.monitors.filter((m) => m.probeOnRefresh === false && probeIds.includes(m.id)).map((m) => m.id);
const pre = await getJson(`${BASE}/api/status`);
const t0 = Date.now();
const roundStartIso = new Date(t0).toISOString();
const fresh = await fetch(`${BASE}/api/refresh`).then((r) => r.json());
const roundMs = Date.now() - t0;
check('/api/refresh 等待本轮探测完成后才返回', roundMs > 100, `round=${roundMs}ms`);
if (probeMode) {
const ages = forceIds.map((id) => [id, Math.round((Date.now() - new Date(fresh.monitors[id].lastCheck)) / 1000)]);
const worst = ages.length ? Math.max(...ages.map(([, a]) => a)) : 0;
check('刷新后参与强制检测的目标全部产生新检测', forceIds.every((id) => fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck), `${forceIds.filter((id) => !(fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck)).join(',') || '全部前进'}`);
check('刷新后最旧数据不超过本轮耗时', worst <= Math.ceil(roundMs / 1000) + 3, `最旧 ${worst}s(轮次 ${Math.round(roundMs / 1000)}s)`);
// 外部镜像目标会被顺带同步(受 60s 最小间隔约束),故不在此断言其时间不变
check('probeOnRefresh:false 的目标不参与强制检测', optOutIds.length === 0 || optOutIds.every((id) => fresh.monitors[id].lastCheck <= roundStartIso), optOutIds.length ? `${optOutIds.join(',')} 未参与(另有 ${externalIds.length} 个外部镜像目标由数据源同步)` : '无此类目标');
} else {
console.log(` - 跳过强制探测断言:当前 dataFilter=${snapMeta.dataFilter},快照中无配置目标(${externalIds.length} 个外部镜像目标由数据源同步)`);
}
// ---------- G. 刷新节奏对齐服务端 ----------
console.log('\n[G] 刷新节奏对齐服务端(不再每访客重新计时)');
const align = await getJson(`${BASE}/api/status`);
const cadence = Number(align.updateIntervalSec);
const updatedMs = new Date(align.dataUpdatedAt).getTime();
check('快照带 dataUpdatedAt 与 updateIntervalSec', Number.isFinite(updatedMs) && updatedMs > 0 && cadence >= 5, `更新于 ${align.dataUpdatedAt},节奏 ${cadence}s`);
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
const pageG = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await pageG.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
await pageG.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await pageG.waitForTimeout(1200);
const shownText = await pageG.evaluate(() => document.getElementById('countdown').textContent);
const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]);
// 期望值需在当前时刻重算:面板显示的是「服务端上次更新 + 一个周期 - 现在」
const expectSec = Math.round((updatedMs + cadence * 1000 - Date.now()) / 1000);
check('倒计时对齐服务端下次更新时刻', Math.abs(shownSec - expectSec) <= 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`);
check('倒计时不会退化为从整周期重新计时', shownSec <= Math.max(5, Math.min(cadence, Number(cfg.settings.refreshInterval) || cadence)), `显示 ${shownSec}s`);
await pageG.close();
// ---------- F. 面板渲染与自动刷新 ----------
console.log('\n[F] 面板渲染与刷新');
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
await page.goto(`${BASE}/?source=api&view=cards`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.card', { timeout: 15000 });
await page.waitForTimeout(1200);
const read = () => page.evaluate(() => ({
cards: document.querySelectorAll('.card').length,
bars: document.querySelectorAll('.heartbeat svg rect').length,
uptimeCells: document.querySelectorAll('.uptime-cell').length,
uptimeCellsPerCard: document.querySelector('.card .uptime-bar')?.children.length ?? 0,
uptimeLabel: document.querySelector('.uptime-label')?.textContent ?? '',
uptimeLabelTip: document.querySelector('.uptime-label')?.getAttribute('title') ?? '',
statUptime: document.getElementById('stat-uptime').textContent,
stats: document.querySelectorAll('.summary .stat').length,
updated: document.getElementById('updated-at').textContent,
lastCheckTitle: document.querySelector('.card .metric b[title]')?.getAttribute('title') ?? '',
countdown: document.getElementById('countdown').textContent,
version: document.querySelector('.version')?.textContent ?? '',
}));
const a = await read();
console.log(` 首次读取: 卡片 ${a.cards},可用率条 ${a.uptimeCellsPerCard} 格/卡,标签「${a.uptimeLabel}」`);
check('卡片全部渲染', a.cards === expectedCards, `cards=${a.cards}/${expectedCards}`);
check('每卡 90 格可用率条', a.uptimeCellsPerCard === 90, `perCard=${a.uptimeCellsPerCard}`);
check('可用率标签含百分比', /90天\s*[\d.]+%/.test(a.uptimeLabel), `label=${a.uptimeLabel}`);
check('可用率悬浮提示含统计明细', /次在线/.test(a.uptimeLabelTip), `title=${a.uptimeLabelTip}`);
check('顶部 90天可用率统计已渲染', a.statUptime !== '–', `stat=${a.statUptime}`);
check('最近检测带绝对时间提示', a.lastCheckTitle !== '–' && a.lastCheckTitle.length > 0, `title=${a.lastCheckTitle}`);
check('版本号已渲染', /^shiroko-monitor v\d+\.\d+\.\d+$/.test(a.version), a.version);
console.log(' 等待一个检查周期(70s)…');
await sleep(70000);
const b = await read();
// 自动刷新是只读的:页脚「数据更新于」只在服务端产出新数据时变化,故用倒计时重新对齐来判断面板仍在按节奏轮询
check('面板持续按服务端节奏轮询(倒计时重新对齐)', a.countdown !== b.countdown, `${a.countdown} -> ${b.countdown}`);
if (probeMode) {
check('探测目标数据已流入面板(最近检测前进)', b.lastCheckTitle > a.lastCheckTitle, `${a.lastCheckTitle} -> ${b.lastCheckTitle}`);
}
// 手动刷新按钮:参与的卡片都必须重新检测,opt-out 的卡片不应被带动(历史 bug:只有部分目标更新)
const readCards = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.card')].map((c) => [c.dataset.id, c.querySelector('.metric b[title]')?.getAttribute('title') ?? ''])));
const beforeCards = await readCards();
await page.click('#refresh-btn');
await page.waitForFunction(() => document.getElementById('refresh-btn').textContent === '刷新' && !document.getElementById('refresh-btn').disabled, null, { timeout: 45000 });
await page.waitForTimeout(400);
const afterCards = await readCards();
const notAdvanced = forceIds.filter((id) => !(afterCards[id] > beforeCards[id]));
const optOutAdvanced = optOutIds.filter((id) => afterCards[id] > beforeCards[id]);
if (probeMode) {
check('参与检测的卡片全部重新检测(手动刷新)', notAdvanced.length === 0, notAdvanced.length ? `${notAdvanced.join(',')} 未前进` : `${forceIds.length} 张全部前进`);
check('probeOnRefresh:false 的卡片不被手动刷新带动', optOutAdvanced.length === 0, optOutIds.length ? `${optOutIds.length} 张保持原时间${externalIds.length ? `(另有 ${externalIds.length} 张外部镜像由数据源同步,允许更新)` : ''}` : '无此类目标');
}
check('刷新过程中无 PageError', errors.length === 0, errors.length ? errors.join(' | ') : '无');
await page.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-desktop.png'), fullPage: true });
await browser.close();
const failed = results.filter((r) => !r.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) process.exit(1);
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});