chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
# 私有配置:真实地址/端口/密钥,不入库
|
||||
local.monitors.json
|
||||
# 本地备份包
|
||||
backups/
|
||||
# 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行)
|
||||
.workbuddy/
|
||||
@@ -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.
|
||||
@@ -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 }` 即可被面板直接渲染。
|
||||
|
||||
## 界面截图
|
||||
|
||||
| 表格(默认视图) | 卡片视图 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
UptimeRobot 数据源(`?filter=uptimerobot`,截图中含真实监控名与域名,公开发布前可自行删除):`docs/screenshot-uptimerobot.png`
|
||||
|
||||
演示数据(90 天满档)与移动端:`docs/screenshot-demo.png`、`docs/screenshot-mobile.png`
|
||||
|
||||
## 致谢与定位
|
||||
|
||||
交互设计参考 Uptime Kuma(心跳图、90 天可用率条、分组卡片、汇总统计),实现为自研轻量版:不追求 Kuma 的通知/多用户/数据库体系,只保留「配置文件 → 探针 → 状态面板」的最小闭环,并对静态/边缘部署友好。
|
||||
@@ -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**。
|
||||
@@ -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`
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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', '端口可连接'));
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
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();
|
||||
@@ -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 |
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||