feat: P0 通知(邮件 + MeoW)与 TLS 证书监控,事件时间线与维护窗口
通知(P0-1): - 邮件:自写 SMTP 客户端,零依赖。465 隐式 TLS / 587·25 STARTTLS、 AUTH LOGIN+PLAIN、RFC2047 中文主题、base64 正文、多收件人; 服务器不支持 STARTTLS 时默认拒绝明文(需显式 allowInsecure) - MeoW:按官方文档实现,昵称寻址、msgType/htmlHeight 走查询参数、 以响应体 status 判定成功(HTTP 恒 200)、429/404/403 可读化、 本地限流器遵守 3s/15min/60h - 告警:连续 N 次失败去抖 + 恢复通知 + 同轮合并 + 去抖状态持久化 (data/notify-state.json,gitignore)+ 静默时段(跨零点) - --test-notify 自检命令、--data-dir / SHIROKO_DATA_DIR 证书(P0-2): - 独立 TLS 握手取 notAfter(不动既有 fetch 探测路径),默认 6 小时一次 - 面板徽标 ≤14 天黄 / ≤7 天红,阈值 14/7/3 各告一次、已过期只报最严重一档 - 单个目标可用 tls: false 关闭 事件与维护(P1-1 的后端部分): - data/events.json 追加式事件流(最近 500 条)+ GET /api/events + 快照内附最近 20 条;redactDetails 时脱敏 - 维护窗口:窗口内不计入可用率、不发告警、显示「维护中」徽标 测试抓到并修复的三个真 bug: 1. SMTP 空闲超时无人处理 → 服务器不响应会把探针循环挂死 2. MeoW 限流器把空历史当 0 → 第一条通知永远被自己拦掉 3. 去抖状态在无事轮次不落盘 → 连续失败计数丢失、永远达不到阈值 验证:新增 verify-notify.cjs 39 项(假 SMTP / 假 MeoW / 三轮集成 / 维护窗口 / 真实证书握手),六套合计 154 项全通过。
@@ -9,3 +9,5 @@ local.monitors.json
|
|||||||
backups/
|
backups/
|
||||||
# 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行)
|
# 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行)
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
|
# 运行期私有数据:通知去抖状态与事件时间线
|
||||||
|
data/
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ node prober/node/index.js --once --demo
|
|||||||
|
|
||||||
# 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处)
|
# 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处)
|
||||||
node prober/node/index.js --once --demo --out=dist/status.json
|
node prober/node/index.js --once --demo --out=dist/status.json
|
||||||
|
|
||||||
|
# 通知渠道自检:给每个启用的渠道发一条测试消息(配好 SMTP / MeoW 后先跑这个)
|
||||||
|
node prober/node/index.js --test-notify
|
||||||
|
|
||||||
|
# 通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR)
|
||||||
|
node prober/node/index.js --data-dir=/var/lib/shiroko-monitor
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后打开 <http://localhost:8787/> 即可看到面板。无需安装任何依赖。
|
启动后打开 <http://localhost:8787/> 即可看到面板。无需安装任何依赖。
|
||||||
@@ -71,7 +77,7 @@ shiroko-monitor/
|
|||||||
│ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测 / 主题切换
|
│ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测 / 主题切换
|
||||||
│ └── favicon.svg # 默认主题图标
|
│ └── favicon.svg # 默认主题图标
|
||||||
├── prober/node/ # Node 探针(本地 / CI 用)
|
├── prober/node/ # Node 探针(本地 / CI 用)
|
||||||
│ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config
|
│ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config/--test-notify
|
||||||
│ └── lib/
|
│ └── lib/
|
||||||
│ ├── http-probe.js # HTTP 探测(fetch,redirect: manual)
|
│ ├── http-probe.js # HTTP 探测(fetch,redirect: manual)
|
||||||
│ ├── tcp-probe.js # TCP 探测(net.connect)
|
│ ├── tcp-probe.js # TCP 探测(net.connect)
|
||||||
@@ -119,6 +125,14 @@ shiroko-monitor/
|
|||||||
"refreshCooldownSec": 5, // 每个来源的触发冷却(秒)
|
"refreshCooldownSec": 5, // 每个来源的触发冷却(秒)
|
||||||
"hideTargets": false, // true = 面板不渲染任何地址(对外发布用)
|
"hideTargets": false, // true = 面板不渲染任何地址(对外发布用)
|
||||||
"redactDetails": false, // true = 快照 detail 只保留「正常/异常」
|
"redactDetails": false, // true = 快照 detail 只保留「正常/异常」
|
||||||
|
"notify": { // 通知:故障/恢复/证书到期(SMTP 密码与 MeoW 昵称请放 local.monitors.json)
|
||||||
|
"enabled": false,
|
||||||
|
"failureThreshold": 2,
|
||||||
|
"quietHours": "",
|
||||||
|
"channels": { "email": { "enabled": false }, "meow": { "enabled": false } }
|
||||||
|
},
|
||||||
|
"tls": { "enabled": true, "checkIntervalSec": 21600, "alertDays": [14, 7, 3] },
|
||||||
|
"maintenance": [], // 维护窗口:[{ from, to, id?, note? }]
|
||||||
"uptimeRobot": { // UptimeRobot 只读数据源(Key 建议放 local.monitors.json)
|
"uptimeRobot": { // UptimeRobot 只读数据源(Key 建议放 local.monitors.json)
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"apiKey": "",
|
"apiKey": "",
|
||||||
@@ -352,6 +366,76 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
|
|||||||
- **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker
|
- **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker
|
||||||
- **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=<path>` 写到别处
|
- **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=<path>` 写到别处
|
||||||
|
|
||||||
|
## 通知、证书与事件
|
||||||
|
|
||||||
|
### 通知(邮件 + MeoW)
|
||||||
|
|
||||||
|
面板只在你打开时展示状态,**通知**让故障主动找你。支持两个渠道:**邮件**(自写 SMTP 客户端,零依赖)与 **MeoW 推送**(按 [官方文档](https://www.chuckfang.com/MeoW/api_doc.html) 实现)。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"notify": {
|
||||||
|
"enabled": true,
|
||||||
|
"failureThreshold": 2, // 连续失败 N 次才告警(去抖,默认 2;1 = 立即)
|
||||||
|
"recoveryNotify": true, // 恢复时也发一条
|
||||||
|
"quietHours": "23:00-07:00", // 静默时段(本地时间,支持跨零点),期间只记录不发送;留空全天
|
||||||
|
"channels": {
|
||||||
|
"email": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "smtp.example.com", "port": 465, // 465 隐式 TLS;587/25 自动 STARTTLS
|
||||||
|
"user": "[email protected]", "pass": "密码",
|
||||||
|
"from": "[email protected]", "to": ["[email protected]"],
|
||||||
|
"allowInsecure": false // 服务器不支持 STARTTLS 时是否允许明文(默认拒绝)
|
||||||
|
},
|
||||||
|
"meow": {
|
||||||
|
"enabled": true,
|
||||||
|
"nickname": "你的昵称", // MeoW 以昵称寻址、无需 token,因此昵称等同密钥
|
||||||
|
"baseUrl": "https://api.chuckfang.com",
|
||||||
|
"msgType": "text" // text | html(html 可另配 htmlHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
行为要点:
|
||||||
|
|
||||||
|
- **去抖**:连续失败达到阈值才发「故障」;恢复时发「恢复」;同一轮多个目标变化会合并成一条,不刷屏
|
||||||
|
- **重启不重复**:去抖状态持久化在 `data/notify-state.json`(私有目录,已 gitignore),重启/单轮模式都不重发
|
||||||
|
- **静默时段**:支持 `23:00-07:00` 这类跨零点写法;静默期内照常记录事件,只是不发送
|
||||||
|
- **MeoW 限流**:客户端本地也做限流(3 秒 1 条 / 15 每分钟 / 60 每小时),替你挡掉一部分官方 429;429/404/403 都会转成可读的错误原因
|
||||||
|
- **CI 注意**:MeoW 官方明确不建议用多人共享的出口 IP(GitHub Actions 即属此类),CI 场景建议只开邮件渠道
|
||||||
|
- **自检**:`node prober/node/index.js --test-notify` 给每个启用渠道发一条测试消息,确认配置可用
|
||||||
|
|
||||||
|
> SMTP 密码与 MeoW 昵称属于私密信息,请放在 `local.monitors.json`(已 gitignore),不要写进公开的 `monitors.json`。
|
||||||
|
|
||||||
|
### TLS 证书监控
|
||||||
|
|
||||||
|
对 `https://` 目标,探针额外做一次 TLS 握手读取证书有效期(默认 6 小时一次):
|
||||||
|
|
||||||
|
- 面板在名称旁显示徽标:剩余 **≤14 天**黄色、**≤7 天**红色,悬停可见到期日与签发者
|
||||||
|
- 剩余 ≤14 / 7 / 3 天时按阈值告警;同一张证书每个阈值只发一次,换证书后重新计算;**已过期的证书只报最严重的一档**
|
||||||
|
- 单个目标不需要可设 `"tls": false`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"tls": { "enabled": true, "checkIntervalSec": 21600, "alertDays": [14, 7, 3] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 维护窗口
|
||||||
|
|
||||||
|
计划重启/迁移期间,不想污染可用率统计、也不想被告警轰炸:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"maintenance": [
|
||||||
|
{ "from": "2026-10-14T02:00", "to": "2026-10-14T04:00", "id": "box", "note": "升级迁移" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` 可省略(对所有目标生效),也支持写成数组
|
||||||
|
- 窗口内:探测照常进行,但**不计入当日可用率**、**不发告警**,面板显示「维护中」徽标;事件时间线照常记录并带 `maintenance: true`
|
||||||
|
|
||||||
|
### 事件时间线
|
||||||
|
|
||||||
|
每次状态翻转(故障 / 恢复 / 证书阈值)都会追加到 `data/events.json`(私有目录,保留最近 500 条),并通过 `GET /api/events` 读取。快照里也会带上最近 20 条供面板使用;`redactDetails` 开启时,快照内事件只保留类型与时间。
|
||||||
|
|
||||||
## 部署方案
|
## 部署方案
|
||||||
|
|
||||||
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
|
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
|
||||||
@@ -445,6 +529,19 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
|
|||||||
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
|
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
|
||||||
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
|
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
|
||||||
|
|
||||||
|
### v0.10.0 已实现(P0:通知 + 证书)
|
||||||
|
|
||||||
|
- [x] **通知渠道①:邮件** —— 自写极简 SMTP 客户端(**零依赖**,不引 nodemailer):465 隐式 TLS / 587·25 自动 STARTTLS、AUTH LOGIN 与 PLAIN、UTF-8 主题按 RFC2047 编码 + base64 正文、多收件人、5xx 与连接错误可读;服务器不支持 STARTTLS 时**默认拒绝明文发信**(需显式 `allowInsecure`)
|
||||||
|
- [x] **通知渠道②:MeoW 推送** —— 按[官方文档](https://www.chuckfang.com/MeoW/api_doc.html)实现:昵称寻址无 token、`msgType`/`htmlHeight` 走查询参数、**HTTP 恒 200 改为按响应体 status 判定**、429/404/403 转成可读原因、本地限流器遵守 3s/15min/60h
|
||||||
|
- [x] **告警逻辑**:连续 N 次失败才告警(去抖)+ 恢复通知 + 同轮多点变化合并为一条 + **去抖状态持久化**(重启不重复)+ 静默时段(跨零点,只记录不发送)
|
||||||
|
- [x] **事件时间线**:`data/events.json`(保留最近 500 条)+ `GET /api/events` + 快照内附最近 20 条;`redactDetails` 时脱敏
|
||||||
|
- [x] **TLS 证书监控**:对 https 目标单独握手取有效期(默认 6 小时一次),面板徽标(≤14 天黄 / ≤7 天红),阈值 14/7/3 各告一次、已过期只报最严重一档、换证书自动重置
|
||||||
|
- [x] **维护窗口**:窗口内不计入可用率、不发告警、显示「维护中」徽标,事件仍记录
|
||||||
|
- [x] **`--test-notify` 自检命令** 与 `--data-dir` 私有数据目录(默认 `./data`,已 gitignore)
|
||||||
|
- [x] 新增 `scripts/verify-notify.cjs`:**39 项断言**(假 SMTP 服务器 / 假 MeoW 服务器 / 静默时段 / 限流器 / 三轮集成验证去抖与恢复 / 维护窗口抑制 / 真实证书握手)
|
||||||
|
|
||||||
|
> 测试过程中抓到并修掉两个真 bug:① SMTP 空闲超时没有处理,服务器不响应会把探针循环**挂死**;② MeoW 限流器把空历史当成 0,**第一条通知永远被自己拦掉**;③ 去抖状态在「无事发生」的轮次不落盘,导致连续失败计数丢失、永远达不到告警阈值。
|
||||||
|
|
||||||
### v0.9.1 已实现
|
### v0.9.1 已实现
|
||||||
|
|
||||||
- [x] **「任何情况下都不需要横向滚动」定为硬要求并落地**:表格改为**流式列宽**(`table-layout: fixed` + 百分比列宽),表头允许换行、单元格裁剪,心率图与可用率条随宽度伸缩;从结构上不可能撑出横向滚动
|
- [x] **「任何情况下都不需要横向滚动」定为硬要求并落地**:表格改为**流式列宽**(`table-layout: fixed` + 百分比列宽),表头允许换行、单元格裁剪,心率图与可用率条随宽度伸缩;从结构上不可能撑出横向滚动
|
||||||
@@ -530,10 +627,11 @@ node prober/node/index.js --once
|
|||||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项)
|
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项)
|
||||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项)
|
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项)
|
||||||
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
|
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
|
||||||
|
node scripts/verify-notify.cjs # 通知与证书(39 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
|
||||||
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项)
|
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项)
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。
|
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。
|
||||||
|
|
||||||
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
|
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器
|
|||||||
| 泄露面 | 风险 | 措施 |
|
| 泄露面 | 风险 | 措施 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `public/monitors.json` | 面板必需文件,静态部署必然公开,含真实 URL / 内网主机端口 | **私有配置分层**:公开文件只写 `id/name/group`,真实地址放仓库根的 `local.monitors.json`(已 gitignore),探针按 `id` 合并覆盖 |
|
| `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` 开启时不下发 |
|
| 通知凭据 | SMTP 密码、MeoW 昵称(等同密钥)泄露 → 被冒名发信/推送 | 与地址一样只放 `local.monitors.json` 或环境变量;`public/monitors.json` 中 `notify.channels.*` 保持 `enabled: false` |
|
||||||
|
| `status.json` / `/api/status` | 探针输出,Pages 部署会公开,`detail` 可能含地址、版本、错误码 | `settings.redactDetails: true` → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 `hideTargets` 开启时不下发;事件时间线在 `redactDetails` 下只保留类型与时间 |
|
||||||
| 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 |
|
| 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 |
|
||||||
| UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 |
|
| UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 |
|
||||||
| `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 |
|
| `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { connect } from 'cloudflare:sockets';
|
import { connect } from 'cloudflare:sockets';
|
||||||
|
|
||||||
const VERSION = '0.9.1';
|
const VERSION = '0.10.0';
|
||||||
const HISTORY_LIMIT = 48;
|
const HISTORY_LIMIT = 48;
|
||||||
const UPTIME_DAYS = 90;
|
const UPTIME_DAYS = 90;
|
||||||
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
|
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { probeHttp } from './lib/http-probe.js';
|
|||||||
import { probeTcp } from './lib/tcp-probe.js';
|
import { probeTcp } from './lib/tcp-probe.js';
|
||||||
import { probeBanner } from './lib/banner-probe.js';
|
import { probeBanner } from './lib/banner-probe.js';
|
||||||
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
|
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
|
||||||
|
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
|
||||||
|
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = path.resolve(__dirname, '../..');
|
const ROOT = path.resolve(__dirname, '../..');
|
||||||
@@ -35,13 +37,15 @@ const CONCURRENCY = 8;
|
|||||||
const WRITE_DEBOUNCE_MS = 300;
|
const WRITE_DEBOUNCE_MS = 300;
|
||||||
const DEFAULT_INTERVAL_SEC = 60;
|
const DEFAULT_INTERVAL_SEC = 60;
|
||||||
const PORT = Number(process.env.PORT || 8787);
|
const PORT = Number(process.env.PORT || 8787);
|
||||||
const VERSION = '0.9.1';
|
const VERSION = '0.10.0';
|
||||||
const UR_DEFAULT_POLL_SEC = 300;
|
const UR_DEFAULT_POLL_SEC = 300;
|
||||||
const UR_MIN_REFRESH_GAP_MS = 60000;
|
const UR_MIN_REFRESH_GAP_MS = 60000;
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const ONCE = argv.includes('--once');
|
const ONCE = argv.includes('--once');
|
||||||
const DEMO = argv.includes('--demo');
|
const DEMO = argv.includes('--demo');
|
||||||
|
// --test-notify:只给各通知渠道发一条自检消息后退出(不探测、不写快照)
|
||||||
|
const TEST_NOTIFY = argv.includes('--test-notify');
|
||||||
|
|
||||||
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
|
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
|
||||||
function argValue(name) {
|
function argValue(name) {
|
||||||
@@ -54,6 +58,10 @@ const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH);
|
|||||||
// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖)
|
// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖)
|
||||||
const CONFIG_OVERRIDE = argValue('--config');
|
const CONFIG_OVERRIDE = argValue('--config');
|
||||||
const LOCAL_CONFIG_OVERRIDE = argValue('--local-config');
|
const LOCAL_CONFIG_OVERRIDE = argValue('--local-config');
|
||||||
|
// --data-dir=<path>:通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR)
|
||||||
|
if (argValue('--data-dir')) process.env.SHIROKO_DATA_DIR = argValue('--data-dir');
|
||||||
|
// 私有数据目录:通知状态(告警去抖)与事件时间线,含本机运行痕迹,默认不入库
|
||||||
|
const DATA_DIR = process.env.SHIROKO_DATA_DIR ? path.resolve(process.env.SHIROKO_DATA_DIR) : path.join(ROOT, 'data');
|
||||||
const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH;
|
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 ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH;
|
||||||
|
|
||||||
@@ -308,7 +316,208 @@ function memoryStats() {
|
|||||||
return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) };
|
return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 数据源过滤 ---------- */
|
/* ---------- 通知(邮件 / MeoW)、事件时间线、维护窗口与证书监控 ---------- */
|
||||||
|
|
||||||
|
const DEFAULT_TLS_CHECK_SEC = 21600; // 证书默认 6 小时查一次
|
||||||
|
const TLS_ALERT_DAYS = [14, 7, 3];
|
||||||
|
const EVENT_LIMIT = 500;
|
||||||
|
const EVENT_IN_SNAPSHOT = 20;
|
||||||
|
|
||||||
|
function notifyConfig(config) {
|
||||||
|
const n = config.settings?.notify ?? {};
|
||||||
|
const channels = n.channels ?? {};
|
||||||
|
return {
|
||||||
|
enabled: n.enabled === true && Object.values(channels).some((c) => c?.enabled === true),
|
||||||
|
failureThreshold: Math.max(1, Number(n.failureThreshold) || 2),
|
||||||
|
recoveryNotify: n.recoveryNotify !== false,
|
||||||
|
quietHours: n.quietHours ?? null,
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tlsConfig(config) {
|
||||||
|
const t = config.settings?.tls ?? {};
|
||||||
|
const alertDays = Array.isArray(t.alertDays) ? t.alertDays.map(Number).filter(Number.isFinite) : null;
|
||||||
|
return {
|
||||||
|
enabled: t.enabled !== false, // 默认对 https 目标开启
|
||||||
|
checkIntervalSec: Math.max(600, Number(t.checkIntervalSec) || DEFAULT_TLS_CHECK_SEC),
|
||||||
|
alertDays: alertDays?.length ? alertDays.sort((a, b) => b - a) : TLS_ALERT_DAYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 维护窗口:{ from, to, id?: string|string[], note? };不写 id 表示对所有目标生效
|
||||||
|
function activeMaintenanceFor(config, id, at = new Date()) {
|
||||||
|
const list = config.settings?.maintenance;
|
||||||
|
if (!Array.isArray(list) || !list.length) return null;
|
||||||
|
const t = at.getTime();
|
||||||
|
for (const w of list) {
|
||||||
|
const from = Date.parse(w.from);
|
||||||
|
const to = Date.parse(w.to);
|
||||||
|
if (!Number.isFinite(from) || !Number.isFinite(to) || t < from || t > to) continue;
|
||||||
|
if (!w.id) return w;
|
||||||
|
const ids = Array.isArray(w.id) ? w.id : [w.id];
|
||||||
|
if (ids.includes(id)) return w;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(file, data) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json');
|
||||||
|
const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
|
||||||
|
|
||||||
|
function monitorNotifyState(runtime, id) {
|
||||||
|
const list = runtime.notifyState.monitors;
|
||||||
|
if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, notifiedTls: [], tlsValidTo: null };
|
||||||
|
return list[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEvents(runtime, entries) {
|
||||||
|
if (!entries.length) return;
|
||||||
|
runtime.events = [...runtime.events, ...entries].slice(-EVENT_LIMIT);
|
||||||
|
writeJson(EVENTS_PATH(), runtime.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知 + 事件:每轮探测结束后评估状态翻转(带去抖)、恢复、维护窗口抑制
|
||||||
|
async function evaluateRound(runtime) {
|
||||||
|
const notify = notifyConfig(runtime.config);
|
||||||
|
const events = [];
|
||||||
|
const pendingNotifications = [];
|
||||||
|
|
||||||
|
for (const m of runtime.config.monitors) {
|
||||||
|
const r = runtime.state.get(m.id)?.lastResult;
|
||||||
|
if (!r || r.status === 'pending') continue;
|
||||||
|
const st = monitorNotifyState(runtime, m.id);
|
||||||
|
st.failStreak = r.status === 'down' ? (st.failStreak ?? 0) + 1 : 0;
|
||||||
|
const maintenance = activeMaintenanceFor(runtime.config, m.id);
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
|
||||||
|
if (r.status === 'down') {
|
||||||
|
// 去抖:连续失败达到阈值才算确认故障(阈值 1 表示立即告警)
|
||||||
|
if (st.lastStatus === 'down' || st.failStreak < notify.failureThreshold) continue;
|
||||||
|
st.lastStatus = 'down';
|
||||||
|
st.lastNotifiedAt = at;
|
||||||
|
events.push({ t: at, id: m.id, name: m.name, type: 'down', detail: r.detail ?? null, maintenance: Boolean(maintenance) });
|
||||||
|
if (!maintenance) {
|
||||||
|
pendingNotifications.push({
|
||||||
|
subject: `【故障】${m.name} 连接失败`,
|
||||||
|
text: `目标:${m.name}\n地址:${m.url ?? `${m.host}:${m.port}`}\n时间:${at}\n详情:${r.detail ?? '无'}\n连续失败:${st.failStreak} 次\n\n—— shiroko-monitor`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (r.status === 'up') {
|
||||||
|
if (st.lastStatus === null) {
|
||||||
|
st.lastStatus = 'up'; // 首次观测不产生「恢复」噪音
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.lastStatus === 'down') {
|
||||||
|
st.lastStatus = 'up';
|
||||||
|
st.lastNotifiedAt = at;
|
||||||
|
events.push({ t: at, id: m.id, name: m.name, type: 'up', detail: '已恢复', maintenance: Boolean(maintenance) });
|
||||||
|
if (notify.recoveryNotify && !maintenance) {
|
||||||
|
pendingNotifications.push({
|
||||||
|
subject: `【恢复】${m.name} 已恢复正常`,
|
||||||
|
text: `目标:${m.name}\n地址:${m.url ?? `${m.host}:${m.port}`}\n恢复时间:${at}\n延迟:${r.latency ?? '—'} ms\n\n—— shiroko-monitor`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length) {
|
||||||
|
appendEvents(runtime, events);
|
||||||
|
console.log(`[events] 新增 ${events.length} 条:${events.map((e) => `${e.name} ${e.type}`).join('、')}`);
|
||||||
|
}
|
||||||
|
// 汇总成一条通知,避免同一轮多点故障刷屏
|
||||||
|
if (pendingNotifications.length) {
|
||||||
|
const message = pendingNotifications.length === 1
|
||||||
|
? pendingNotifications[0]
|
||||||
|
: {
|
||||||
|
subject: `【${pendingNotifications.length} 个目标状态变化】shiroko-monitor`,
|
||||||
|
text: pendingNotifications.map((p) => p.text).join('\n\n──────────\n\n'),
|
||||||
|
};
|
||||||
|
const res = await notifyAll(notify, message, { logger: console });
|
||||||
|
runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
|
||||||
|
}
|
||||||
|
// 无论是否发通知都要落盘:否则「连续失败计数」会丢,重启或下一轮就达不到阈值
|
||||||
|
saveNotifyState(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 证书检查:按 checkIntervalSec 逐个握手,跨阈值时告警(同一张证书每个阈值只告一次)
|
||||||
|
async function checkCertificates(runtime) {
|
||||||
|
const tlsCfg = tlsConfig(runtime.config);
|
||||||
|
if (!tlsCfg.enabled) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const events = [];
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
for (const m of runtime.config.monitors) {
|
||||||
|
const target = tlsTargetOf(m);
|
||||||
|
if (!target) continue;
|
||||||
|
const prev = runtime.tls.get(m.id);
|
||||||
|
if (prev && now - (prev.checkedAtMs ?? 0) < tlsCfg.checkIntervalSec * 1000) continue;
|
||||||
|
const result = await probeCertificate({ ...target, timeoutMs: Math.max(5, Number(m.timeout) || 8) * 1000 });
|
||||||
|
runtime.tls.set(m.id, { ...result, checkedAt: new Date().toISOString(), checkedAtMs: now });
|
||||||
|
|
||||||
|
const st = monitorNotifyState(runtime, m.id);
|
||||||
|
if (result.error) {
|
||||||
|
console.warn(`[tls] ${m.id} 证书检查失败:${result.error}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 换了证书(validTo 变化)→ 重置已通知阈值,新证书重新提醒
|
||||||
|
if (st.tlsValidTo !== result.validTo) {
|
||||||
|
st.tlsValidTo = result.validTo;
|
||||||
|
st.notifiedTls = [];
|
||||||
|
}
|
||||||
|
const maintenance = activeMaintenanceFor(runtime.config, m.id);
|
||||||
|
// 已过期的证书只报最严重的一档(避免一次刷三条)
|
||||||
|
const thresholds = result.daysLeft <= 0 ? tlsCfg.alertDays.slice(-1) : tlsCfg.alertDays;
|
||||||
|
for (const threshold of thresholds) {
|
||||||
|
if (result.daysLeft > threshold || (st.notifiedTls ?? []).includes(threshold)) continue;
|
||||||
|
st.notifiedTls = [...(st.notifiedTls ?? []), threshold];
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
events.push({ t: at, id: m.id, name: m.name, type: 'tls', detail: `证书剩余 ${result.daysLeft} 天(阈值 ${threshold} 天)`, maintenance: Boolean(maintenance) });
|
||||||
|
if (!maintenance) {
|
||||||
|
messages.push(`- ${m.name}:证书剩余 ${result.daysLeft} 天(到期 ${result.validTo.slice(0, 10)},签发者 ${result.issuer ?? '未知'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length) {
|
||||||
|
appendEvents(runtime, events);
|
||||||
|
if (messages.length) {
|
||||||
|
const res = await notifyAll(notifyConfig(runtime.config), {
|
||||||
|
subject: `【证书提醒】${messages.length} 个证书即将到期`,
|
||||||
|
text: `${messages.join('\n')}\n\n签发者与到期时间取自实际握手,请及时续期。\n\n—— shiroko-monitor`,
|
||||||
|
}, { logger: console });
|
||||||
|
runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
|
||||||
|
}
|
||||||
|
saveNotifyState(runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNotifyState(runtime) {
|
||||||
|
writeJson(NOTIFY_STATE_PATH(), runtime.notifyState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知与证书检查串行执行:避免并发轮次重复发同一条告警
|
||||||
|
function queueEvaluation(runtime, task) {
|
||||||
|
runtime.notifyChain = (runtime.notifyChain ?? Promise.resolve())
|
||||||
|
.then(() => task(runtime))
|
||||||
|
.catch((err) => console.error(`[notify] 评估异常:${err.stack || err.message}`));
|
||||||
|
return runtime.notifyChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
|
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
|
||||||
function dataFilter(config) {
|
function dataFilter(config) {
|
||||||
@@ -415,6 +624,14 @@ function snapshot(runtime, demo = false) {
|
|||||||
};
|
};
|
||||||
entry.uptimeRatios = s.uptimeRatios ?? null;
|
entry.uptimeRatios = s.uptimeRatios ?? null;
|
||||||
entry.downSeconds = s.downSeconds ?? null;
|
entry.downSeconds = s.downSeconds ?? null;
|
||||||
|
} else {
|
||||||
|
// 证书与维护窗口信息(来自独立的握手检查与配置),供面板打标
|
||||||
|
const tlsInfo = runtime.tls?.get(id);
|
||||||
|
if (tlsInfo) entry.tls = redact
|
||||||
|
? { daysLeft: tlsInfo.daysLeft ?? null, error: tlsInfo.error ? '不可用' : null }
|
||||||
|
: { daysLeft: tlsInfo.daysLeft ?? null, validTo: tlsInfo.validTo ?? null, issuer: tlsInfo.issuer ?? null, checkedAt: tlsInfo.checkedAt ?? null, error: tlsInfo.error ?? null };
|
||||||
|
const maintenance = activeMaintenanceFor(config, id);
|
||||||
|
if (maintenance) entry.maintenance = { note: maintenance.note ?? null, to: maintenance.to ?? null };
|
||||||
}
|
}
|
||||||
monitors[id] = entry;
|
monitors[id] = entry;
|
||||||
}
|
}
|
||||||
@@ -438,6 +655,20 @@ function snapshot(runtime, demo = false) {
|
|||||||
memory: memoryStats(),
|
memory: memoryStats(),
|
||||||
},
|
},
|
||||||
sources: { uptimerobot: { ...(runtime.ur ?? {}) } },
|
sources: { uptimerobot: { ...(runtime.ur ?? {}) } },
|
||||||
|
// 通知能力与最近事件(事件含目标名与原因,redactDetails 时只保留类型与时间)
|
||||||
|
notify: (() => {
|
||||||
|
const n = notifyConfig(config);
|
||||||
|
return {
|
||||||
|
enabled: n.enabled,
|
||||||
|
channels: Object.entries(n.channels ?? {}).filter(([, c]) => c?.enabled).map(([k]) => k),
|
||||||
|
quietHours: n.quietHours,
|
||||||
|
failureThreshold: n.failureThreshold,
|
||||||
|
lastResult: runtime.notifyState?.lastResult ?? null,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
events: (runtime.events ?? []).slice(-EVENT_IN_SNAPSHOT).reverse().map((e) => (redact
|
||||||
|
? { t: e.t, id: e.id, type: e.type, name: e.name, detail: e.type === 'down' ? '异常' : e.type === 'up' ? '恢复' : e.detail, maintenance: e.maintenance }
|
||||||
|
: e)),
|
||||||
monitors,
|
monitors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -486,6 +717,7 @@ function forceRound(runtime) {
|
|||||||
await settleInflight(runtime.state);
|
await settleInflight(runtime.state);
|
||||||
runtime.forcedAt = Date.now();
|
runtime.forcedAt = Date.now();
|
||||||
scheduleWrite(runtime);
|
scheduleWrite(runtime);
|
||||||
|
queueEvaluation(runtime, evaluateRound);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[refresh] 强制探测异常:${err.message}`);
|
console.error(`[refresh] 强制探测异常:${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -654,6 +886,12 @@ function createServer(runtime) {
|
|||||||
return sendJson(res, toUptimeRobotResponse(runtime));
|
return sendJson(res, toUptimeRobotResponse(runtime));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录)
|
||||||
|
if (url.pathname === '/api/events') {
|
||||||
|
const list = [...(runtime.events ?? [])].reverse();
|
||||||
|
return sendJson(res, { total: list.length, events: list.slice(0, 200) });
|
||||||
|
}
|
||||||
|
|
||||||
let rel = decodeURIComponent(url.pathname);
|
let rel = decodeURIComponent(url.pathname);
|
||||||
if (rel === '/') rel = '/index.html';
|
if (rel === '/') rel = '/index.html';
|
||||||
const file = path.join(PUBLIC_DIR, path.normalize(rel));
|
const file = path.join(PUBLIC_DIR, path.normalize(rel));
|
||||||
@@ -688,8 +926,26 @@ async function main() {
|
|||||||
config: loadConfig(),
|
config: loadConfig(),
|
||||||
state: null,
|
state: null,
|
||||||
dataUpdatedAt: Date.now(),
|
dataUpdatedAt: Date.now(),
|
||||||
|
// 通知去抖状态与事件时间线从私有数据目录恢复,重启不会重复告警
|
||||||
|
notifyState: readJson(NOTIFY_STATE_PATH(), { monitors: {} }),
|
||||||
|
events: readJson(EVENTS_PATH(), []),
|
||||||
|
tls: new Map(),
|
||||||
|
tlsLastCheckMs: 0,
|
||||||
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
|
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
|
||||||
};
|
};
|
||||||
|
if (TEST_NOTIFY) {
|
||||||
|
// 通道自检:只发一条测试消息,不探测、不写快照
|
||||||
|
const cfg = notifyConfig(runtime.config);
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
console.error('未启用任何通知渠道(settings.notify.enabled 或 channels.*.enabled 未开启)');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('通知通道自检:');
|
||||||
|
const out = await sendTestNotifications(cfg, { logger: console });
|
||||||
|
if (out.some((r) => !r.ok)) process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (DEMO) {
|
if (DEMO) {
|
||||||
writeDemo(runtime.config);
|
writeDemo(runtime.config);
|
||||||
return;
|
return;
|
||||||
@@ -700,6 +956,8 @@ async function main() {
|
|||||||
|
|
||||||
if (ONCE) {
|
if (ONCE) {
|
||||||
await probeDue(runtime, true);
|
await probeDue(runtime, true);
|
||||||
|
await queueEvaluation(runtime, evaluateRound);
|
||||||
|
await queueEvaluation(runtime, checkCertificates);
|
||||||
writeStatus(snapshot(runtime));
|
writeStatus(snapshot(runtime));
|
||||||
const s = snapshot(runtime).stats;
|
const s = snapshot(runtime).stats;
|
||||||
console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`);
|
console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`);
|
||||||
@@ -725,7 +983,10 @@ async function main() {
|
|||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
probeDue(runtime)
|
probeDue(runtime)
|
||||||
.then(() => scheduleWrite(runtime))
|
.then(() => {
|
||||||
|
scheduleWrite(runtime);
|
||||||
|
queueEvaluation(runtime, evaluateRound);
|
||||||
|
})
|
||||||
.catch((err) => console.error(`[tick] ${err.stack || err}`));
|
.catch((err) => console.error(`[tick] ${err.stack || err}`));
|
||||||
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
|
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
|
||||||
if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
|
if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
|
||||||
@@ -733,6 +994,11 @@ async function main() {
|
|||||||
.then(() => scheduleWrite(runtime))
|
.then(() => scheduleWrite(runtime))
|
||||||
.catch(() => { /* syncUptimeRobot 内部已记录错误 */ });
|
.catch(() => { /* syncUptimeRobot 内部已记录错误 */ });
|
||||||
}
|
}
|
||||||
|
// 证书到期检查:按 checkIntervalSec 周期执行(默认 6 小时),握手失败只记录不影响巡检
|
||||||
|
if (Date.now() - runtime.tlsLastCheckMs >= tlsConfig(runtime.config).checkIntervalSec * 1000) {
|
||||||
|
runtime.tlsLastCheckMs = Date.now();
|
||||||
|
queueEvaluation(runtime, checkCertificates);
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
createServer(runtime).listen(PORT, () => {
|
createServer(runtime).listen(PORT, () => {
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* 通知发送:邮件(手写 SMTP,零依赖)+ MeoW 推送
|
||||||
|
*
|
||||||
|
* 为什么手写 SMTP:本项目刻意保持零运行时依赖(Node 内置模块够用),
|
||||||
|
* 引入 nodemailer 会带来一串传递依赖,对一个自用面板不划算。
|
||||||
|
* 只实现发信必需的流程:EHLO → (STARTTLS) → AUTH → MAIL/RCPT/DATA → QUIT。
|
||||||
|
*
|
||||||
|
* MeoW 接口要点(据 https://www.chuckfang.com/MeoW/api_doc.html ):
|
||||||
|
* - 无需 token,昵称即寻址:POST https://api.chuckfang.com/{昵称}
|
||||||
|
* - HTTP 状态恒为 200,成功与否看响应体的 status 字段
|
||||||
|
* - msgType / htmlHeight 是查询参数,不是 body 参数
|
||||||
|
* - 非会员限流:3 秒 1 条、每分钟 15 条、每小时 60 条
|
||||||
|
* - 官方提醒:不要用多人共享的出口 IP(CI 环境建议只用邮件)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import net from 'node:net';
|
||||||
|
import tls from 'node:tls';
|
||||||
|
|
||||||
|
const CRLF = '\r\n';
|
||||||
|
|
||||||
|
/* ---------- 邮件 ---------- */
|
||||||
|
|
||||||
|
function encodeHeader(value) {
|
||||||
|
// 非 ASCII 主题按 RFC 2047 base64 编码,避免中文主题乱码
|
||||||
|
return /^[\x20-\x7E]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Body(text) {
|
||||||
|
// 正文用 base64:既免去 quoted-printable 的编码复杂度,也不会出现以 "." 开头的行(无需 dot-stuffing)
|
||||||
|
return (Buffer.from(text, 'utf8').toString('base64').match(/.{1,76}/g) ?? []).join(CRLF);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessage({ from, to, subject, text }) {
|
||||||
|
return [
|
||||||
|
`From: ${from}`,
|
||||||
|
`To: ${to.join(', ')}`,
|
||||||
|
`Subject: ${encodeHeader(subject)}`,
|
||||||
|
`Date: ${new Date().toUTCString()}`,
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
'Content-Transfer-Encoding: base64',
|
||||||
|
'',
|
||||||
|
base64Body(text),
|
||||||
|
'',
|
||||||
|
].join(CRLF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 极简 SMTP 会话:读 3 位状态码响应(支持多行 `250-` 连续响应) */
|
||||||
|
function createSession(socket, timeoutMs) {
|
||||||
|
let buffer = '';
|
||||||
|
let waiter = null;
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer += chunk;
|
||||||
|
if (!waiter) return;
|
||||||
|
// 一条完整响应以 `\d{3} `(空格结尾)结束
|
||||||
|
const lines = buffer.split(CRLF);
|
||||||
|
const complete = lines.some((l) => /^\d{3} /.test(l));
|
||||||
|
if (complete) {
|
||||||
|
const reply = buffer;
|
||||||
|
buffer = '';
|
||||||
|
const w = waiter;
|
||||||
|
waiter = null;
|
||||||
|
w.resolve(reply);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const fail = (err) => {
|
||||||
|
if (waiter) {
|
||||||
|
const w = waiter;
|
||||||
|
waiter = null;
|
||||||
|
w.reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.on('error', fail);
|
||||||
|
socket.on('close', () => fail(new Error('SMTP 连接被关闭')));
|
||||||
|
// 空闲超时必须主动断开:否则服务器不响应时读响应会一直挂着,把探针循环卡死
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
fail(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
read: () => new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
waiter = null;
|
||||||
|
reject(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
|
||||||
|
}, timeoutMs);
|
||||||
|
waiter = {
|
||||||
|
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
||||||
|
reject: (e) => { clearTimeout(timer); reject(e); },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
write: (line) => socket.write(line + CRLF),
|
||||||
|
code: (reply) => Number(reply.slice(0, 3)),
|
||||||
|
raw: () => socket,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(socket) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.once('connect', resolve);
|
||||||
|
socket.once('secureConnect', resolve);
|
||||||
|
socket.once('error', reject);
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail(cfg, { subject, text }, { timeoutMs = 15000 } = {}) {
|
||||||
|
const host = cfg.host;
|
||||||
|
const port = Number(cfg.port) || 465;
|
||||||
|
if (!host) throw new Error('邮件通道缺少 host');
|
||||||
|
if (!cfg.from) throw new Error('邮件通道缺少发件人 from');
|
||||||
|
const to = Array.isArray(cfg.to) ? cfg.to.filter(Boolean) : [cfg.to].filter(Boolean);
|
||||||
|
if (!to.length) throw new Error('邮件通道缺少收件人 to');
|
||||||
|
|
||||||
|
// 465 走隐式 TLS;587/25 先明文再 STARTTLS
|
||||||
|
const implicitTls = port === 465 || cfg.secure === true;
|
||||||
|
let socket = implicitTls
|
||||||
|
? tls.connect({ host, port, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false })
|
||||||
|
: net.connect({ host, port });
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
await connect(socket);
|
||||||
|
|
||||||
|
let session = createSession(socket, timeoutMs);
|
||||||
|
const expect = async (wanted) => {
|
||||||
|
const reply = await session.read();
|
||||||
|
const code = session.code(reply);
|
||||||
|
if (code !== wanted) throw new Error(`SMTP 期望 ${wanted} 但收到:${reply.trim().split(CRLF).pop()}`);
|
||||||
|
return reply;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(220);
|
||||||
|
session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
|
||||||
|
let caps = await expect(250);
|
||||||
|
|
||||||
|
if (!implicitTls) {
|
||||||
|
if (/STARTTLS/i.test(caps)) {
|
||||||
|
session.write('STARTTLS');
|
||||||
|
await expect(220);
|
||||||
|
socket = await new Promise((resolve, reject) => {
|
||||||
|
const upgraded = tls.connect({ socket, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false }, () => resolve(upgraded));
|
||||||
|
upgraded.once('error', reject);
|
||||||
|
});
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
session = createSession(socket, timeoutMs);
|
||||||
|
session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
|
||||||
|
caps = await expect(250);
|
||||||
|
} else if (cfg.allowInsecure !== true) {
|
||||||
|
// 默认拒绝明文发信(凭据会裸奔);仅测试或本机可信中继场景下显式开启
|
||||||
|
throw new Error('SMTP 服务器不支持 STARTTLS,已中止(如需明文传输请显式设置 allowInsecure: true)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.user) {
|
||||||
|
if (/AUTH[ =-].*PLAIN/i.test(caps) && !/AUTH[ =-].*LOGIN/i.test(caps)) {
|
||||||
|
session.write(`AUTH PLAIN ${Buffer.from(`\0${cfg.user}\0${cfg.pass ?? ''}`, 'utf8').toString('base64')}`);
|
||||||
|
} else {
|
||||||
|
session.write('AUTH LOGIN');
|
||||||
|
await expect(334);
|
||||||
|
session.write(Buffer.from(cfg.user, 'utf8').toString('base64'));
|
||||||
|
await expect(334);
|
||||||
|
session.write(Buffer.from(cfg.pass ?? '', 'utf8').toString('base64'));
|
||||||
|
}
|
||||||
|
await expect(235);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.write(`MAIL FROM:<${cfg.from}>`);
|
||||||
|
await expect(250);
|
||||||
|
for (const rcpt of to) {
|
||||||
|
session.write(`RCPT TO:<${rcpt}>`);
|
||||||
|
const reply = await session.read();
|
||||||
|
if (![250, 251].includes(session.code(reply))) throw new Error(`收件人被拒(${rcpt}):${reply.trim()}`);
|
||||||
|
}
|
||||||
|
session.write('DATA');
|
||||||
|
await expect(354);
|
||||||
|
socket.write(buildMessage({ from: cfg.from, to, subject, text }) + CRLF + '.' + CRLF);
|
||||||
|
await expect(250);
|
||||||
|
session.write('QUIT');
|
||||||
|
socket.end();
|
||||||
|
return { channel: 'email', ok: true, detail: `已投递至 ${to.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- MeoW ---------- */
|
||||||
|
|
||||||
|
export async function sendMeoW(cfg, { title, text }, { timeoutMs = 12000, fetchImpl = fetch } = {}) {
|
||||||
|
const nickname = cfg.nickname;
|
||||||
|
if (!nickname) throw new Error('MeoW 通道缺少 nickname');
|
||||||
|
if (nickname.includes('/')) throw new Error('MeoW 昵称不允许包含斜杠');
|
||||||
|
const base = (cfg.baseUrl || 'https://api.chuckfang.com').replace(/\/+$/, '');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (cfg.msgType && cfg.msgType !== 'text') {
|
||||||
|
params.set('msgType', cfg.msgType);
|
||||||
|
if (cfg.htmlHeight) params.set('htmlHeight', String(cfg.htmlHeight));
|
||||||
|
}
|
||||||
|
if (cfg.url) params.set('url', cfg.url);
|
||||||
|
const query = params.toString() ? `?${params}` : '';
|
||||||
|
const res = await fetchImpl(`${base}/${encodeURIComponent(nickname)}${query}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, msg: text }),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = await res.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`MeoW 响应不是 JSON(HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
// 注意:HTTP 状态恒为 200,必须以响应体的 status 为准
|
||||||
|
const status = Number(payload?.status);
|
||||||
|
if (status !== 200) {
|
||||||
|
const reason = payload?.message || payload?.msg || '未知错误';
|
||||||
|
const hint = status === 429 ? '(触发频率限制:3 秒 1 条 / 15 每分钟 / 60 每小时)'
|
||||||
|
: status === 404 ? '(昵称未注册)'
|
||||||
|
: status === 403 ? '(内容被策略拦截)' : '';
|
||||||
|
throw new Error(`MeoW 推送失败 status=${status} ${reason}${hint}`);
|
||||||
|
}
|
||||||
|
return { channel: 'meow', ok: true, detail: payload.message || payload.msg || '推送成功' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 调度 ---------- */
|
||||||
|
|
||||||
|
export function parseQuietHours(spec) {
|
||||||
|
if (!spec) return null;
|
||||||
|
const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(String(spec).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const [from, to] = [Number(m[1]) * 60 + Number(m[2]), Number(m[3]) * 60 + Number(m[4])];
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inQuietHours(spec, date = new Date()) {
|
||||||
|
const range = parseQuietHours(spec);
|
||||||
|
if (!range) return false;
|
||||||
|
const now = date.getHours() * 60 + date.getMinutes();
|
||||||
|
// 跨零点(如 23:00-07:00)
|
||||||
|
return range.from <= range.to ? now >= range.from && now < range.to : now >= range.from || now < range.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MeoW 客户端限流:保守遵守 3 秒 1 条 / 每分钟 15 条 / 每小时 60 条 */
|
||||||
|
export function createMeoWLimiter(now = () => Date.now()) {
|
||||||
|
let history = [];
|
||||||
|
return {
|
||||||
|
allow() {
|
||||||
|
const t = now();
|
||||||
|
history = history.filter((x) => t - x < 3600_000);
|
||||||
|
const last = history.length ? history.at(-1) : Number.NEGATIVE_INFINITY; // 空历史不能当成 0,否则首条必被拦
|
||||||
|
const inMinute = history.filter((x) => t - x < 60_000).length;
|
||||||
|
if (t - last < 3000 || inMinute >= 15 || history.length >= 60) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
mark() {
|
||||||
|
history.push(now());
|
||||||
|
},
|
||||||
|
state: () => history.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const meowLimiter = createMeoWLimiter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按配置把一条通知发到所有启用的渠道。
|
||||||
|
* 静默时段内不发送,但返回 suppressed 标记(调用方仍需记录事件)。
|
||||||
|
*/
|
||||||
|
export async function notifyAll(cfg, message, { logger = console, fetchImpl = fetch, now = () => Date.now() } = {}) {
|
||||||
|
if (!cfg?.enabled) return { suppressed: false, skipped: 'notify-disabled', results: [] };
|
||||||
|
if (inQuietHours(cfg.quietHours)) {
|
||||||
|
logger.log?.(`[notify] 静默时段(${cfg.quietHours}),本条不发送:${message.subject}`);
|
||||||
|
return { suppressed: true, results: [] };
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
const channels = cfg.channels ?? {};
|
||||||
|
|
||||||
|
if (channels.email?.enabled) {
|
||||||
|
try {
|
||||||
|
results.push(await sendEmail(channels.email, message));
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ channel: 'email', ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (channels.meow?.enabled) {
|
||||||
|
if (!meowLimiter.allow()) {
|
||||||
|
results.push({ channel: 'meow', ok: false, error: '本地限流:为避免触发 MeoW 频率限制,本条已跳过', rateLimited: true });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
meowLimiter.mark();
|
||||||
|
results.push(await sendMeoW(channels.meow, message, { fetchImpl }));
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ channel: 'meow', ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
if (failed.length) logger.error?.(`[notify] 部分渠道发送失败:${failed.map((f) => `${f.channel}: ${f.error}`).join(' | ')}`);
|
||||||
|
if (!failed.length && results.length) logger.log?.(`[notify] 已发送:${message.subject}`);
|
||||||
|
return { suppressed: false, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自检:给每个启用的渠道发一条测试消息(不经过静默时段判定) */
|
||||||
|
export async function sendTestNotifications(cfg, { logger = console, fetchImpl = fetch } = {}) {
|
||||||
|
const channels = cfg?.channels ?? {};
|
||||||
|
const subject = 'shiroko-monitor 通知自检';
|
||||||
|
const text = `这是一条通道自检消息,收到即表示该渠道配置可用。\n时间:${new Date().toLocaleString('zh-CN')}`;
|
||||||
|
const out = [];
|
||||||
|
if (channels.email?.enabled) {
|
||||||
|
try {
|
||||||
|
out.push(await sendEmail(channels.email, { subject, text }));
|
||||||
|
} catch (err) {
|
||||||
|
out.push({ channel: 'email', ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push({ channel: 'email', ok: false, error: '未启用(settings.notify.channels.email.enabled = false)' });
|
||||||
|
}
|
||||||
|
if (channels.meow?.enabled) {
|
||||||
|
try {
|
||||||
|
out.push(await sendMeoW(channels.meow, { title: subject, text }, { fetchImpl }));
|
||||||
|
} catch (err) {
|
||||||
|
out.push({ channel: 'meow', ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push({ channel: 'meow', ok: false, error: '未启用(settings.notify.channels.meow.enabled = false)' });
|
||||||
|
}
|
||||||
|
for (const r of out) logger.log?.(` ${r.ok ? '✓' : '✗'} ${r.channel}:${r.ok ? r.detail : r.error}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* TLS 证书探测:只做一次握手取 notAfter,用于「证书还有多久过期」提醒。
|
||||||
|
*
|
||||||
|
* 为什么不复用 HTTP 探测:现有探测走 fetch(undici),拿不到对端证书;
|
||||||
|
* 单独握手更轻,也不影响既有探测路径与判定逻辑。
|
||||||
|
* rejectUnauthorized 设为 false:证书已过期/不受信时也要能读到有效期,
|
||||||
|
* 是否通过校验由 socket.authorized 反映。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import tls from 'node:tls';
|
||||||
|
|
||||||
|
export async function probeCertificate({ host, port = 443, servername, timeoutMs = 8000 }) {
|
||||||
|
if (!host) return { error: '缺少主机名' };
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (result) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const socket = tls.connect(
|
||||||
|
{ host, port, servername: servername || host, rejectUnauthorized: false, timeout: timeoutMs },
|
||||||
|
() => {
|
||||||
|
let cert;
|
||||||
|
try {
|
||||||
|
cert = socket.getPeerCertificate();
|
||||||
|
} catch (err) {
|
||||||
|
return finish({ error: `读取证书失败:${err.message}` });
|
||||||
|
}
|
||||||
|
if (!cert || !cert.valid_to) return finish({ error: '未取到证书有效期' });
|
||||||
|
const validTo = new Date(cert.valid_to);
|
||||||
|
if (Number.isNaN(validTo.getTime())) return finish({ error: `证书有效期无法解析:${cert.valid_to}` });
|
||||||
|
finish({
|
||||||
|
error: null,
|
||||||
|
validTo: validTo.toISOString(),
|
||||||
|
daysLeft: Math.floor((validTo.getTime() - Date.now()) / 86_400_000),
|
||||||
|
issuer: cert.issuer?.O || cert.issuer?.CN || null,
|
||||||
|
subject: cert.subject?.CN || null,
|
||||||
|
authorized: socket.authorized === true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
socket.on('error', (err) => finish({ error: err.message }));
|
||||||
|
socket.on('timeout', () => finish({ error: `TLS 握手超时(${timeoutMs}ms)` }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从监控目标推断是否需要/如何进行证书检查 */
|
||||||
|
export function tlsTargetOf(monitor) {
|
||||||
|
if (!monitor || monitor.tls === false) return null;
|
||||||
|
const url = monitor.url;
|
||||||
|
if (!url || !/^https:\/\//i.test(url)) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return { host: parsed.hostname, port: Number(parsed.port) || 443, servername: parsed.hostname };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const VERSION = '0.9.1';
|
const VERSION = '0.10.0';
|
||||||
const HB_LIMIT = 48;
|
const HB_LIMIT = 48;
|
||||||
const UPTIME_DAYS = 90;
|
const UPTIME_DAYS = 90;
|
||||||
const BROWSER_CONCURRENCY = 6;
|
const BROWSER_CONCURRENCY = 6;
|
||||||
@@ -286,7 +286,7 @@ function renderCard(m) {
|
|||||||
const head = document.createElement('div');
|
const head = document.createElement('div');
|
||||||
head.className = 'card-head';
|
head.className = 'card-head';
|
||||||
// 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称
|
// 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称
|
||||||
head.innerHTML = `<span class="dot"></span><h3 title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>`;
|
head.innerHTML = `<span class="dot"></span><h3 title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}`;
|
||||||
|
|
||||||
const target = document.createElement('div');
|
const target = document.createElement('div');
|
||||||
target.className = 'card-target';
|
target.className = 'card-target';
|
||||||
@@ -356,6 +356,25 @@ function isExternal(m) {
|
|||||||
return m.source === 'uptimerobot';
|
return m.source === 'uptimerobot';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 证书徽标:仅在临近到期时出现(≤14 天黄色、≤7 天红色),避免常驻打扰
|
||||||
|
function certBadge(st) {
|
||||||
|
const t = st?.tls;
|
||||||
|
if (!t || t.daysLeft == null) return '';
|
||||||
|
if (t.error) return `<span class="tls-badge danger" title="证书检查失败:${escapeHtml(t.error)}">证书异常</span>`;
|
||||||
|
if (t.daysLeft > 14) return '';
|
||||||
|
const cls = t.daysLeft <= 7 ? 'danger' : 'warn';
|
||||||
|
const tip = `证书到期:${escapeHtml(String(t.validTo ?? '').slice(0, 10))}${t.issuer ? `(${escapeHtml(t.issuer)})` : ''}`;
|
||||||
|
return `<span class="tls-badge ${cls}" title="${tip}">证书 ${t.daysLeft} 天</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 维护窗口徽标:该时段内不计入可用率统计、不发告警
|
||||||
|
function maintBadge(st) {
|
||||||
|
const m = st?.maintenance;
|
||||||
|
if (!m) return '';
|
||||||
|
const tip = `维护窗口${m.to ? `至 ${escapeHtml(String(m.to).replace('T', ' ').slice(0, 16))}` : ''}${m.note ? `:${escapeHtml(m.note)}` : ''}\n期间不计入可用率、不发告警`;
|
||||||
|
return `<span class="maint-badge" title="${tip}">维护中</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTabs() {
|
function renderTabs() {
|
||||||
const nav = $('group-tabs');
|
const nav = $('group-tabs');
|
||||||
const monitors = allMonitors();
|
const monitors = allMonitors();
|
||||||
@@ -469,7 +488,7 @@ function tableRow(m, hide) {
|
|||||||
tr.dataset.id = m.id;
|
tr.dataset.id = m.id;
|
||||||
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML =
|
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-state"><span class="dot"></span>${s.label}</td>` +
|
||||||
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span></td>` +
|
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}</td>` +
|
||||||
targetCell +
|
targetCell +
|
||||||
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
|
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
|
||||||
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
|
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
|
||||||
@@ -613,6 +632,22 @@ function renderSummary() {
|
|||||||
? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒`
|
? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒`
|
||||||
: '尚无服务端数据';
|
: '尚无服务端数据';
|
||||||
$('demo-chip').hidden = !st?.demo;
|
$('demo-chip').hidden = !st?.demo;
|
||||||
|
// 通知渠道状态:有失败渠道时给出具体原因(点开 title 看)
|
||||||
|
const notify = st?.notify;
|
||||||
|
const nb = $('notify-badge');
|
||||||
|
if (nb) {
|
||||||
|
const failed = (notify?.lastResult?.results ?? []).filter((r) => !r.ok);
|
||||||
|
if (!notify?.enabled) {
|
||||||
|
nb.hidden = true;
|
||||||
|
} else {
|
||||||
|
nb.hidden = false;
|
||||||
|
nb.classList.toggle('warn-chip', failed.length > 0);
|
||||||
|
nb.textContent = `通知:${(notify.channels ?? []).join(' + ') || '无'}${notify.lastResult ? ` · 最近 ${fmtRelative(notify.lastResult.at)}` : ''}${failed.length ? ` · ${failed.length} 个渠道失败` : ''}`;
|
||||||
|
nb.title = failed.length
|
||||||
|
? `发送失败:${failed.map((r) => `${r.channel}: ${r.error}`).join('\n')}`
|
||||||
|
: (notify.lastResult ? `最近一次:${notify.lastResult.at},${(notify.lastResult.results ?? []).map((r) => `${r.channel} ${r.ok ? '成功' : '失败'}`).join('、')}` : '尚未发送过通知');
|
||||||
|
}
|
||||||
|
}
|
||||||
const ur = st?.sources?.uptimerobot;
|
const ur = st?.sources?.uptimerobot;
|
||||||
const urChip = $('ur-chip');
|
const urChip = $('ur-chip');
|
||||||
if (urChip) {
|
if (urChip) {
|
||||||
|
|||||||
@@ -412,6 +412,22 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
|
||||||
|
.tls-badge, .maint-badge {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.tls-badge.warn { background: var(--pending-bg); color: var(--pending-text); }
|
||||||
|
.tls-badge.danger { background: var(--down-bg); color: var(--down-text); }
|
||||||
|
.maint-badge { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
|
||||||
|
.card-head .tls-badge, .card-head .maint-badge { flex: none; }
|
||||||
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
|
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
|
||||||
.t-uptime { white-space: nowrap; }
|
.t-uptime { white-space: nowrap; }
|
||||||
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
|
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
|
||||||
|
|||||||
@@ -54,9 +54,10 @@
|
|||||||
<span id="source-badge" class="footer-item">数据源:–</span>
|
<span id="source-badge" class="footer-item">数据源:–</span>
|
||||||
<span id="demo-chip" class="footer-item demo-chip" hidden>演示数据</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="ur-chip" class="footer-item warn-chip" hidden>UptimeRobot 同步异常</span>
|
||||||
|
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
|
||||||
<span id="updated-at" class="footer-item">–</span>
|
<span id="updated-at" class="footer-item">–</span>
|
||||||
<span id="countdown" class="footer-item">–</span>
|
<span id="countdown" class="footer-item">–</span>
|
||||||
<span class="footer-item version">shiroko-monitor v0.9.1</span>
|
<span class="footer-item version">shiroko-monitor v0.10.0</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script type="module" src="assets/app.js"></script>
|
<script type="module" src="assets/app.js"></script>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"prober": "shiroko-monitor-node/0.7.0",
|
"prober": "shiroko-monitor-node/0.10.0",
|
||||||
"demo": false,
|
"demo": false,
|
||||||
"generatedAt": "2026-10-13T14:36:59.637Z",
|
"generatedAt": "2026-10-13T15:54:42.793Z",
|
||||||
"dataUpdatedAt": "2026-10-13T14:36:43.139Z",
|
"dataUpdatedAt": "2026-10-13T15:53:59.134Z",
|
||||||
"updateIntervalSec": 300,
|
"updateIntervalSec": 300,
|
||||||
"dataFilter": "uptimerobot",
|
"dataFilter": "uptimerobot",
|
||||||
"stats": {
|
"stats": {
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
"up": 5,
|
"up": 5,
|
||||||
"down": 1,
|
"down": 1,
|
||||||
"pending": 0,
|
"pending": 0,
|
||||||
"avgLatency": 874,
|
"avgLatency": 763,
|
||||||
"avgUptime90": 83.3,
|
"avgUptime90": 83.3,
|
||||||
"memory": {
|
"memory": {
|
||||||
"rssMB": 81.3,
|
"rssMB": 67.4,
|
||||||
"heapUsedMB": 13.8,
|
"heapUsedMB": 12.7,
|
||||||
"heapTotalMB": 15.7
|
"heapTotalMB": 14.2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sources": {
|
"sources": {
|
||||||
@@ -23,30 +23,98 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"error": null,
|
"error": null,
|
||||||
"lastSyncAt": "2026-10-13T14:36:16.338Z",
|
"lastSyncAt": "2026-10-13T15:51:29.877Z",
|
||||||
"count": 6,
|
"count": 6,
|
||||||
"syncing": false,
|
"syncing": false,
|
||||||
"lastSyncMs": 1791902174992
|
"lastSyncMs": 1791906687760
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notify": {
|
||||||
|
"enabled": false,
|
||||||
|
"channels": [],
|
||||||
|
"quietHours": null,
|
||||||
|
"failureThreshold": 2,
|
||||||
|
"lastResult": {
|
||||||
|
"at": "2026-10-13T15:53:51.149Z",
|
||||||
|
"suppressed": false,
|
||||||
|
"skipped": "notify-disabled",
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:53:51.142Z",
|
||||||
|
"id": "cloudflare-site",
|
||||||
|
"name": "Cloudflare 官网",
|
||||||
|
"type": "up",
|
||||||
|
"detail": "已恢复",
|
||||||
|
"maintenance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:52:51.476Z",
|
||||||
|
"id": "cloudflare-site",
|
||||||
|
"name": "Cloudflare 官网",
|
||||||
|
"type": "down",
|
||||||
|
"detail": "ETIMEDOUT",
|
||||||
|
"maintenance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:32:38.735Z",
|
||||||
|
"id": "google-dns-tcp",
|
||||||
|
"name": "Google DNS 53 端口",
|
||||||
|
"type": "down",
|
||||||
|
"detail": "TCP 连接超时",
|
||||||
|
"maintenance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:32:38.735Z",
|
||||||
|
"id": "cloudflare-dns-tcp",
|
||||||
|
"name": "Cloudflare 443 端口",
|
||||||
|
"type": "down",
|
||||||
|
"detail": "TCP 连接超时",
|
||||||
|
"maintenance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:32:34.170Z",
|
||||||
|
"id": "badssl-expired",
|
||||||
|
"name": "BadSSL 过期证书(演示离线)",
|
||||||
|
"type": "down",
|
||||||
|
"detail": "CERT_HAS_EXPIRED",
|
||||||
|
"maintenance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:32:34.129Z",
|
||||||
|
"id": "badssl-expired",
|
||||||
|
"name": "BadSSL 过期证书(演示离线)",
|
||||||
|
"type": "tls",
|
||||||
|
"detail": "证书剩余 -4202 天(阈值 3 天)",
|
||||||
|
"maintenance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:32:34.129Z",
|
||||||
|
"id": "badssl-expired",
|
||||||
|
"name": "BadSSL 过期证书(演示离线)",
|
||||||
|
"type": "tls",
|
||||||
|
"detail": "证书剩余 -4202 天(阈值 7 天)",
|
||||||
|
"maintenance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:32:34.129Z",
|
||||||
|
"id": "badssl-expired",
|
||||||
|
"name": "BadSSL 过期证书(演示离线)",
|
||||||
|
"type": "tls",
|
||||||
|
"detail": "证书剩余 -4202 天(阈值 14 天)",
|
||||||
|
"maintenance": false
|
||||||
|
}
|
||||||
|
],
|
||||||
"monitors": {
|
"monitors": {
|
||||||
"ur-792059439": {
|
"ur-792059439": {
|
||||||
"status": "up",
|
"status": "up",
|
||||||
"latency": 464,
|
"latency": 233,
|
||||||
"code": null,
|
"code": null,
|
||||||
"detail": "UptimeRobot:运行正常",
|
"detail": "UptimeRobot:运行正常",
|
||||||
"lastCheck": "2026-10-13T14:34:00.000Z",
|
"lastCheck": "2026-10-13T15:50:00.000Z",
|
||||||
"history": [
|
"history": [
|
||||||
{
|
|
||||||
"t": "2026-10-12T15:45:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 392
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T16:25:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 288
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T17:05:00.000Z",
|
"t": "2026-10-12T17:05:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -203,20 +271,30 @@
|
|||||||
"ms": 271
|
"ms": 271
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T13:53:00.000Z",
|
"t": "2026-10-13T13:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 294
|
"ms": 307
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T14:34:00.000Z",
|
"t": "2026-10-13T14:34:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 464
|
"ms": 463
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:10:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 260
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:50:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 233
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"daily": {
|
"daily": {
|
||||||
"2026-10-13": {
|
"2026-10-13": {
|
||||||
"total": 46,
|
"total": 6,
|
||||||
"up": 46
|
"up": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -243,186 +321,191 @@
|
|||||||
},
|
},
|
||||||
"ur-795497022": {
|
"ur-795497022": {
|
||||||
"status": "up",
|
"status": "up",
|
||||||
"latency": 704,
|
"latency": 772,
|
||||||
"code": null,
|
"code": null,
|
||||||
"detail": "UptimeRobot:运行正常",
|
"detail": "UptimeRobot:运行正常",
|
||||||
"lastCheck": "2026-10-13T14:33:00.000Z",
|
"lastCheck": "2026-10-13T15:48:00.000Z",
|
||||||
"history": [
|
"history": [
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T15:40:00.000Z",
|
"t": "2026-10-12T16:35:00.000Z",
|
||||||
"ok": true,
|
|
||||||
"ms": 798
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T16:20:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 685
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T17:00:00.000Z",
|
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 700
|
"ms": 700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T17:40:00.000Z",
|
"t": "2026-10-12T17:15:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 694
|
"ms": 689
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T18:20:00.000Z",
|
"t": "2026-10-12T17:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 703
|
"ms": 725
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T19:00:00.000Z",
|
"t": "2026-10-12T18:35:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 729
|
"ms": 744
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T19:40:00.000Z",
|
"t": "2026-10-12T19:15:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 713
|
"ms": 723
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T20:20:00.000Z",
|
"t": "2026-10-12T19:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 784
|
"ms": 698
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T21:00:00.000Z",
|
"t": "2026-10-12T20:35:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 628
|
"ms": 768
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T21:40:00.000Z",
|
"t": "2026-10-12T21:15:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 629
|
"ms": 616
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T22:20:00.000Z",
|
"t": "2026-10-12T21:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 751
|
"ms": 626
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T23:00:00.000Z",
|
"t": "2026-10-12T22:35:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 674
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-12T23:15:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 624
|
"ms": 624
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T23:40:00.000Z",
|
"t": "2026-10-12T23:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 715
|
"ms": 640
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T00:20:00.000Z",
|
"t": "2026-10-13T00:35:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 683
|
"ms": 673
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T01:00:00.000Z",
|
"t": "2026-10-13T01:15:00.000Z",
|
||||||
"ok": true,
|
|
||||||
"ms": 643
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T01:40:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 641
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T02:20:00.000Z",
|
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 609
|
"ms": 609
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T03:00:00.000Z",
|
"t": "2026-10-13T01:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 606
|
"ms": 759
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T03:40:00.000Z",
|
"t": "2026-10-13T02:35:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 720
|
"ms": 581
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T04:20:00.000Z",
|
"t": "2026-10-13T03:15:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 711
|
"ms": 666
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T05:00:00.000Z",
|
"t": "2026-10-13T03:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 598
|
"ms": 662
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T05:40:00.000Z",
|
"t": "2026-10-13T04:35:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 729
|
"ms": 685
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T06:20:00.000Z",
|
"t": "2026-10-13T05:15:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 676
|
"ms": 647
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T07:00:00.000Z",
|
"t": "2026-10-13T05:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 691
|
"ms": 588
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T07:40:00.000Z",
|
"t": "2026-10-13T06:40:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 696
|
"ms": 625
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T08:20:00.000Z",
|
"t": "2026-10-13T07:20:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 677
|
"ms": 673
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T09:00:00.000Z",
|
"t": "2026-10-13T08:00:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 969
|
"ms": 758
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T09:40:00.000Z",
|
"t": "2026-10-13T08:40:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 669
|
"ms": 826
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T10:20:00.000Z",
|
"t": "2026-10-13T09:20:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 840
|
"ms": 749
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T11:00:00.000Z",
|
"t": "2026-10-13T10:00:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 735
|
"ms": 791
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T11:40:00.000Z",
|
"t": "2026-10-13T10:40:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 693
|
"ms": 678
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T12:20:00.000Z",
|
"t": "2026-10-13T11:20:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 793
|
"ms": 740
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T13:56:00.000Z",
|
"t": "2026-10-13T12:00:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 1056
|
"ms": 828
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T14:33:00.000Z",
|
"t": "2026-10-13T12:40:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 704
|
"ms": 722
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T13:20:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 849
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T14:43:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 845
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:15:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 857
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:48:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 772
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"daily": {
|
"daily": {
|
||||||
"2026-10-13": {
|
"2026-10-13": {
|
||||||
"total": 46,
|
"total": 6,
|
||||||
"up": 46
|
"up": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -449,21 +532,11 @@
|
|||||||
},
|
},
|
||||||
"ur-795556828": {
|
"ur-795556828": {
|
||||||
"status": "up",
|
"status": "up",
|
||||||
"latency": 338,
|
"latency": 230,
|
||||||
"code": null,
|
"code": null,
|
||||||
"detail": "UptimeRobot:运行正常",
|
"detail": "UptimeRobot:运行正常",
|
||||||
"lastCheck": "2026-10-13T14:34:00.000Z",
|
"lastCheck": "2026-10-13T15:50:00.000Z",
|
||||||
"history": [
|
"history": [
|
||||||
{
|
|
||||||
"t": "2026-10-12T15:35:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 275
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T16:15:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 450
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T16:55:00.000Z",
|
"t": "2026-10-12T16:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -620,20 +693,30 @@
|
|||||||
"ms": 413
|
"ms": 413
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T13:54:00.000Z",
|
"t": "2026-10-13T13:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 187
|
"ms": 283
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T14:34:00.000Z",
|
"t": "2026-10-13T14:34:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 338
|
"ms": 333
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:10:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 510
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:50:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 230
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"daily": {
|
"daily": {
|
||||||
"2026-10-13": {
|
"2026-10-13": {
|
||||||
"total": 46,
|
"total": 6,
|
||||||
"up": 46
|
"up": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -660,21 +743,11 @@
|
|||||||
},
|
},
|
||||||
"ur-792059414": {
|
"ur-792059414": {
|
||||||
"status": "up",
|
"status": "up",
|
||||||
"latency": 2424,
|
"latency": 2238,
|
||||||
"code": null,
|
"code": null,
|
||||||
"detail": "UptimeRobot:运行正常",
|
"detail": "UptimeRobot:运行正常",
|
||||||
"lastCheck": "2026-10-13T14:34:00.000Z",
|
"lastCheck": "2026-10-13T15:48:00.000Z",
|
||||||
"history": [
|
"history": [
|
||||||
{
|
|
||||||
"t": "2026-10-12T15:40:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 2901
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T16:25:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 2402
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T17:10:00.000Z",
|
"t": "2026-10-12T17:10:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -796,45 +869,50 @@
|
|||||||
"ms": 1597
|
"ms": 1597
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T10:35:00.000Z",
|
"t": "2026-10-13T10:40:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 3015
|
"ms": 2816
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T11:15:00.000Z",
|
"t": "2026-10-13T11:25:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 1965
|
"ms": 1912
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T11:55:00.000Z",
|
"t": "2026-10-13T12:10:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 2118
|
"ms": 2326
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T12:35:00.000Z",
|
"t": "2026-10-13T12:55:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 2557
|
"ms": 2938
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T13:15:00.000Z",
|
"t": "2026-10-13T13:40:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 1671
|
"ms": 3146
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T13:55:00.000Z",
|
"t": "2026-10-13T14:25:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 1908
|
"ms": 2125
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T14:34:00.000Z",
|
"t": "2026-10-13T15:08:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 2424
|
"ms": 2892
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:48:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 2238
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"daily": {
|
"daily": {
|
||||||
"2026-10-13": {
|
"2026-10-13": {
|
||||||
"total": 46,
|
"total": 6,
|
||||||
"up": 46
|
"up": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -861,191 +939,191 @@
|
|||||||
},
|
},
|
||||||
"ur-798788113": {
|
"ur-798788113": {
|
||||||
"status": "up",
|
"status": "up",
|
||||||
"latency": 438,
|
"latency": 340,
|
||||||
"code": null,
|
"code": null,
|
||||||
"detail": "UptimeRobot:运行正常",
|
"detail": "UptimeRobot:运行正常",
|
||||||
"lastCheck": "2026-10-13T14:35:00.000Z",
|
"lastCheck": "2026-10-13T15:46:00.000Z",
|
||||||
"history": [
|
"history": [
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T15:45:00.000Z",
|
"t": "2026-10-12T16:50:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 462
|
"ms": 463
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T16:25:00.000Z",
|
"t": "2026-10-12T17:30:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 288
|
"ms": 320
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T17:05:00.000Z",
|
"t": "2026-10-12T18:10:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 409
|
"ms": 404
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-12T17:50:00.000Z",
|
"t": "2026-10-12T18:50:00.000Z",
|
||||||
"ok": true,
|
|
||||||
"ms": 312
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T18:30:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 472
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T19:10:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 421
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T19:50:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 332
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T20:30:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 310
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T21:10:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 436
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T21:50:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 338
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T22:30:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 423
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T23:10:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 247
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-12T23:50:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 296
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T00:30:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 239
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T01:10:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 393
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T01:50:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 346
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T02:30:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 441
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T03:10:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 443
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T03:50:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 382
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T04:30:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 355
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T05:10:00.000Z",
|
|
||||||
"ok": true,
|
|
||||||
"ms": 414
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"t": "2026-10-13T05:50:00.000Z",
|
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 343
|
"ms": 343
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T06:30:00.000Z",
|
"t": "2026-10-12T19:30:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 434
|
"ms": 476
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T07:10:00.000Z",
|
"t": "2026-10-12T20:10:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 444
|
"ms": 285
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T07:50:00.000Z",
|
"t": "2026-10-12T20:50:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 281
|
"ms": 449
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T08:30:00.000Z",
|
"t": "2026-10-12T21:30:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 453
|
"ms": 332
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T09:15:00.000Z",
|
"t": "2026-10-12T22:10:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 333
|
"ms": 450
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T09:55:00.000Z",
|
"t": "2026-10-12T22:50:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 602
|
"ms": 231
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T10:35:00.000Z",
|
"t": "2026-10-12T23:30:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 519
|
"ms": 249
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T11:15:00.000Z",
|
"t": "2026-10-13T00:10:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 325
|
"ms": 286
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T11:55:00.000Z",
|
"t": "2026-10-13T00:50:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 933
|
"ms": 223
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T12:35:00.000Z",
|
"t": "2026-10-13T01:30:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 424
|
"ms": 386
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T13:15:00.000Z",
|
"t": "2026-10-13T02:10:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 460
|
"ms": 511
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T13:55:00.000Z",
|
"t": "2026-10-13T02:50:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 328
|
"ms": 292
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"t": "2026-10-13T14:35:00.000Z",
|
"t": "2026-10-13T03:30:00.000Z",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ms": 438
|
"ms": 435
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T04:15:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T04:55:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 462
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T05:35:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 377
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T06:15:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 396
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T06:55:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 310
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T07:35:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 336
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T08:15:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 379
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T08:55:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 476
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T09:35:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 605
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T10:15:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 456
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T10:55:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 420
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T11:35:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 878
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T12:15:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 530
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T12:55:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 517
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T13:35:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 491
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T14:20:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 429
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:06:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 443
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "2026-10-13T15:46:00.000Z",
|
||||||
|
"ok": true,
|
||||||
|
"ms": 340
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"daily": {
|
"daily": {
|
||||||
"2026-10-13": {
|
"2026-10-13": {
|
||||||
"total": 46,
|
"total": 6,
|
||||||
"up": 46
|
"up": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -1085,7 +1163,7 @@
|
|||||||
],
|
],
|
||||||
"daily": {
|
"daily": {
|
||||||
"2026-10-13": {
|
"2026-10-13": {
|
||||||
"total": 46,
|
"total": 6,
|
||||||
"up": 0
|
"up": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* shiroko-monitor 通知与证书验证
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* A. 邮件通道(假 SMTP 服务器):完整握手、AUTH LOGIN、多收件人、UTF-8 主题与正文、
|
||||||
|
* 5xx 报错路径、不支持 STARTTLS 时默认拒绝明文
|
||||||
|
* B. MeoW 通道(假 HTTP 服务器):成功(status=200)、404 昵称未注册、429 限流、
|
||||||
|
* 「HTTP 200 但业务 status≠200」、非 JSON 响应
|
||||||
|
* C. 静默时段(含跨零点)与 MeoW 本地限流器
|
||||||
|
* D. 集成:真跑探针 --once,验证去抖(连续失败 N 次才通知)、恢复通知、
|
||||||
|
* 事件时间线落盘、维护窗口抑制告警但仍记录事件
|
||||||
|
* E. 证书:tlsTargetOf 解析 + 真实握手(无网络则跳过,不算失败)
|
||||||
|
*
|
||||||
|
* 用法:node scripts/verify-notify.cjs
|
||||||
|
*/
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const net = require('node:net');
|
||||||
|
const http = require('node:http');
|
||||||
|
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', 'notify.js');
|
||||||
|
const TLS_LIB = path.join(ROOT, 'prober', 'node', 'lib', 'tls-probe.js');
|
||||||
|
const SMTP_PORT = 8895;
|
||||||
|
const MEOW_PORT = 8896;
|
||||||
|
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}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 假 SMTP 服务器 ---------- */
|
||||||
|
|
||||||
|
function startFakeSmtp() {
|
||||||
|
const received = [];
|
||||||
|
let failAt = null; // 'auth' | 'mail' 注入 5xx
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
let buf = '';
|
||||||
|
let stage = 'greeting'; // greeting → ehlo → auth-user → auth-pass → ready → data
|
||||||
|
const send = (line) => socket.write(`${line}\r\n`);
|
||||||
|
send('220 fake-smtp ready');
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buf += chunk.toString('utf8');
|
||||||
|
if (!buf.endsWith('\r\n')) return;
|
||||||
|
const line = buf.trim();
|
||||||
|
buf = '';
|
||||||
|
const cmd = line.split(/\s+/)[0].toUpperCase();
|
||||||
|
// 不实现 TLS:EHLO 只通告 AUTH LOGIN(客户端会按 allowInsecure 决定是否明文继续)
|
||||||
|
if (stage === 'greeting' && cmd === 'EHLO') {
|
||||||
|
// SMTP 多行响应:最后一行必须是「250 + 空格」才算终止(客户端按此判定)
|
||||||
|
send('250-fake-smtp');
|
||||||
|
send('250-AUTH LOGIN PLAIN');
|
||||||
|
send('250 OK');
|
||||||
|
stage = 'ehlo';
|
||||||
|
} else if (cmd === 'AUTH' && line.toUpperCase().startsWith('AUTH LOGIN')) {
|
||||||
|
if (failAt === 'auth') return send('535 认证失败');
|
||||||
|
send('334 VXNlcm5hbWU6');
|
||||||
|
stage = 'auth-user';
|
||||||
|
} else if (stage === 'auth-user') {
|
||||||
|
send('334 UGFzc3dvcmQ6');
|
||||||
|
stage = 'auth-pass';
|
||||||
|
} else if (stage === 'auth-pass') {
|
||||||
|
send('235 认证成功');
|
||||||
|
stage = 'ready';
|
||||||
|
} else if (cmd === 'MAIL') {
|
||||||
|
if (failAt === 'mail') return send('550 发件人被拒');
|
||||||
|
received.push({ mailFrom: /<([^>]*)>/.exec(line)?.[1] ?? '' });
|
||||||
|
send('250 ok');
|
||||||
|
} else if (cmd === 'RCPT') {
|
||||||
|
const addr = /<([^>]*)>/.exec(line)?.[1] ?? '';
|
||||||
|
if (!received.length) received.push({});
|
||||||
|
received.at(-1).rcpt = [...(received.at(-1).rcpt ?? []), addr];
|
||||||
|
send('250 ok');
|
||||||
|
} else if (cmd === 'DATA') {
|
||||||
|
send('354 end with <CRLF>.<CRLF>');
|
||||||
|
stage = 'data';
|
||||||
|
} else if (stage === 'data') {
|
||||||
|
received.at(-1).raw = line;
|
||||||
|
stage = 'done';
|
||||||
|
send('250 queued');
|
||||||
|
} else if (cmd === 'QUIT') {
|
||||||
|
send('221 bye');
|
||||||
|
socket.end();
|
||||||
|
} else {
|
||||||
|
send('250 ok');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', () => {});
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => server.listen(SMTP_PORT, '127.0.0.1', () => resolve({
|
||||||
|
received,
|
||||||
|
failAt: (v) => { failAt = v; },
|
||||||
|
close: () => new Promise((r) => server.close(r)),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 假 MeoW 服务器 ---------- */
|
||||||
|
|
||||||
|
function startFakeMeoW() {
|
||||||
|
const received = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const nickname = decodeURIComponent(req.url.split('?')[0].replace(/^\//, ''));
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => (body += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
received.push({ nickname, url: req.url, body });
|
||||||
|
const json = (status, message) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ status, message }));
|
||||||
|
};
|
||||||
|
// 注意:MeoW 的 HTTP 状态恒为 200,业务结果只在 body 里
|
||||||
|
if (nickname === 'ok') return json(200, '推送成功');
|
||||||
|
if (nickname === 'limited') return json(429, '请求过于频繁');
|
||||||
|
if (nickname === 'missing') return json(404, '昵称未注册');
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html' });
|
||||||
|
res.end('<html>not json</html>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => server.listen(MEOW_PORT, '127.0.0.1', () => resolve({
|
||||||
|
received,
|
||||||
|
close: () => new Promise((r) => server.close(r)),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 探针进程 ---------- */
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(dir, overrides = {}) {
|
||||||
|
const cfg = {
|
||||||
|
settings: {
|
||||||
|
title: 'notify-test',
|
||||||
|
dataFilter: 'config',
|
||||||
|
refreshInterval: 60,
|
||||||
|
notify: {
|
||||||
|
enabled: true,
|
||||||
|
failureThreshold: 2,
|
||||||
|
recoveryNotify: true,
|
||||||
|
channels: {
|
||||||
|
email: {
|
||||||
|
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
|
||||||
|
user: '[email protected]', pass: 'secret', from: '[email protected]',
|
||||||
|
to: ['[email protected]', '[email protected]'],
|
||||||
|
},
|
||||||
|
meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
monitors: [
|
||||||
|
// 拒绝连接 → 离线(127.0.0.1:1 一定连不上)
|
||||||
|
{ id: 'down-tcp', name: '必然离线的 TCP', group: '测试', type: 'tcp', host: '127.0.0.1', port: 1, interval: 60, timeout: 3 },
|
||||||
|
// data: URL 一定成功 → 在线
|
||||||
|
{ id: 'up-http', name: '必然在线的 HTTP', group: '测试', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const file = path.join(dir, 'monitors.json');
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||||
|
return { file, cfg };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { sendEmail, sendMeoW, sendTestNotifications, notifyAll, parseQuietHours, inQuietHours, createMeoWLimiter } = await import(`file://${LIB.replace(/\\/g, '/')}`);
|
||||||
|
const { probeCertificate, tlsTargetOf } = await import(`file://${TLS_LIB.replace(/\\/g, '/')}`);
|
||||||
|
const smtp = await startFakeSmtp();
|
||||||
|
const meow = await startFakeMeoW();
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* ---------- A. 邮件通道 ---------- */
|
||||||
|
console.log('\n[A] 邮件通道(假 SMTP)');
|
||||||
|
const emailCfg = {
|
||||||
|
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
|
||||||
|
user: '[email protected]', pass: 's3cret!', from: '[email protected]', to: ['[email protected]', '[email protected]'],
|
||||||
|
};
|
||||||
|
await sendEmail(emailCfg, { subject: '【故障】测试目标 连接失败', text: '第一行\n第二行:中文内容' });
|
||||||
|
const mail = smtp.received.at(-1);
|
||||||
|
check('邮件已发出', Boolean(mail?.raw), '假服务器收到 DATA');
|
||||||
|
check('发件人与收件人正确', mail?.mailFrom === '[email protected]' && mail?.rcpt?.join(',') === '[email protected],[email protected]', `${mail?.mailFrom} → ${mail?.rcpt?.join(',')}`);
|
||||||
|
const headers = (mail?.raw ?? '').split('\r\n\r\n')[0] ?? '';
|
||||||
|
const bodyB64 = (mail?.raw ?? '').split('\r\n\r\n')[1] ?? '';
|
||||||
|
check('中文主题按 RFC2047 编码', /^Subject: =\?UTF-8\?B\?/m.test(headers), headers.split('\r\n').find((l) => l.startsWith('Subject:'))?.slice(0, 46) + '…');
|
||||||
|
check('正文 base64 且可解码出中文', Buffer.from(bodyB64.replace(/\r\n/g, ''), 'base64').toString('utf8').includes('第二行:中文内容'), '');
|
||||||
|
check('声明 UTF-8 字符集', /Content-Type: text\/plain; charset=UTF-8/.test(headers), '');
|
||||||
|
|
||||||
|
smtp.failAt('mail');
|
||||||
|
let err = null;
|
||||||
|
try { await sendEmail(emailCfg, { subject: 'x', text: 'y' }); } catch (e) { err = e; }
|
||||||
|
check('SMTP 5xx 被识别为失败', /发件人被拒/.test(err?.message ?? ''), err?.message ?? '未抛错');
|
||||||
|
smtp.failAt(null); // 复位注入的故障,避免污染后续用例
|
||||||
|
|
||||||
|
let err2 = null;
|
||||||
|
try { await sendEmail({ ...emailCfg, host: '127.0.0.1', port: 8999 }, { subject: 'x', text: 'y' }); } catch (e) { err2 = e; }
|
||||||
|
check('连不上 SMTP 时报错可读', /ECONNREFUSED|超时|连接/.test(err2?.message ?? ''), err2?.message?.slice(0, 60) ?? '未抛错');
|
||||||
|
|
||||||
|
let err3 = null;
|
||||||
|
try { await sendEmail({ ...emailCfg, allowInsecure: false }, { subject: 'x', text: 'y' }); } catch (e) { err3 = e; }
|
||||||
|
check('服务器无 STARTTLS 时默认拒绝明文发信', /STARTTLS/.test(err3?.message ?? ''), err3?.message?.slice(0, 70) ?? '未抛错');
|
||||||
|
|
||||||
|
/* ---------- B. MeoW 通道 ---------- */
|
||||||
|
console.log('\n[B] MeoW 通道(假 HTTP)');
|
||||||
|
const meowCfg = { nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` };
|
||||||
|
const r1 = await sendMeoW(meowCfg, { title: '标题', text: '正文' });
|
||||||
|
check('status=200 判定为成功', r1.ok === true, r1.detail ?? '');
|
||||||
|
const req1 = meow.received.at(-1);
|
||||||
|
check('昵称走路径、msgType 不作为 body', req1.nickname === 'ok' && !req1.body.includes('msgType'), `POST /${req1.nickname}${req1.url.includes('?') ? req1.url.slice(req1.url.indexOf('?')) : ''}`);
|
||||||
|
check('JSON body 含 title 与 msg', (() => { const b = JSON.parse(req1.body); return b.title === '标题' && b.msg === '正文'; })(), '');
|
||||||
|
|
||||||
|
for (const [nick, label] of [['limited', '429 限流'], ['missing', '404 昵称未注册']]) {
|
||||||
|
let e = null;
|
||||||
|
try { await sendMeoW({ ...meowCfg, nickname: nick }, { title: 't', text: 'm' }); } catch (err3) { e = err3; }
|
||||||
|
check(`${label} 被识别为失败`, Boolean(e), e?.message?.slice(0, 70) ?? '未抛错');
|
||||||
|
}
|
||||||
|
|
||||||
|
let e4 = null;
|
||||||
|
try { await sendMeoW({ ...meowCfg, nickname: 'broken' }, { title: 't', text: 'm' }); } catch (err4) { e4 = err4; }
|
||||||
|
check('非 JSON 响应被识别为失败', /不是 JSON/.test(e4?.message ?? ''), e4?.message?.slice(0, 50) ?? '');
|
||||||
|
|
||||||
|
let e5 = null;
|
||||||
|
try { await sendMeoW({ ...meowCfg, nickname: 'a/b' }, { title: 't', text: 'm' }); } catch (err5) { e5 = err5; }
|
||||||
|
check('昵称含斜杠被拒绝', /斜杠/.test(e5?.message ?? ''), e5?.message ?? '');
|
||||||
|
|
||||||
|
/* ---------- C. 静默时段与限流器 ---------- */
|
||||||
|
console.log('\n[C] 静默时段与限流器');
|
||||||
|
check('静默时段解析', parseQuietHours('23:00-07:00').from === 23 * 60, '');
|
||||||
|
check('跨零点判定(23:30 在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T23:30:00')) === true, '');
|
||||||
|
check('跨零点判定(07:30 不在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T07:30:00')) === false, '');
|
||||||
|
check('同时段判定(09:00-17:00 内)', inQuietHours('09:00-17:00', new Date('2026-10-13T12:00:00')) === true, '');
|
||||||
|
|
||||||
|
const limiter = createMeoWLimiter(() => 0);
|
||||||
|
check('限流器首次放行', limiter.allow() === true, '');
|
||||||
|
limiter.mark();
|
||||||
|
check('限流器 3 秒内拦截', limiter.allow() === false, '');
|
||||||
|
|
||||||
|
const quiet = await notifyAll(
|
||||||
|
{ enabled: true, quietHours: '23:00-07:00', channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } },
|
||||||
|
{ subject: '静默测试', text: '不应发送' },
|
||||||
|
{ logger: console, fetchImpl: fetch },
|
||||||
|
);
|
||||||
|
const before = meow.received.length;
|
||||||
|
const now = new Date();
|
||||||
|
const quietCfg = { enabled: true, quietHours: `${String(now.getHours()).padStart(2, '0')}:00-${String(now.getHours()).padStart(2, '0')}:59`, channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } };
|
||||||
|
const q = await notifyAll(quietCfg, { subject: '静默测试', text: '不应发送' }, { logger: console });
|
||||||
|
check('静默时段内不发送且标记 suppressed', q.suppressed === true && meow.received.length === before, `received ${before} → ${meow.received.length}`);
|
||||||
|
|
||||||
|
/* ---------- D. 集成:真探针 ---------- */
|
||||||
|
console.log('\n[D] 集成(探针 --once × 多轮)');
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-notify-'));
|
||||||
|
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
|
||||||
|
const { file } = writeConfig(tmp);
|
||||||
|
const dataDir = path.join(tmp, 'data');
|
||||||
|
const outFile = path.join(tmp, 'status.json');
|
||||||
|
const common = [`--config=${file}`, `--local-config=${file}`, `--out=${outFile}`, `--data-dir=${dataDir}`];
|
||||||
|
|
||||||
|
const beforeMail = smtp.received.length;
|
||||||
|
const beforeMeow = meow.received.length;
|
||||||
|
|
||||||
|
const run1 = await runOnce(['--once', ...common]);
|
||||||
|
check('第一轮探测成功', run1.code === 0, run1.out.split('\n').find((l) => l.includes('[once]')) ?? '');
|
||||||
|
const mailAfter1 = smtp.received.length - beforeMail;
|
||||||
|
const meowAfter1 = meow.received.length - beforeMeow;
|
||||||
|
check('离线目标连续 2 次失败才告警:第一轮不通知', mailAfter1 === 0 && meowAfter1 === 0, `邮件 ${mailAfter1} / MeoW ${meowAfter1}`);
|
||||||
|
|
||||||
|
// 事件文件只在有事件时才落盘,因此要容忍不存在
|
||||||
|
const readEvents = (dir) => {
|
||||||
|
const f = path.join(dir, 'events.json');
|
||||||
|
return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : [];
|
||||||
|
};
|
||||||
|
const events1 = readEvents(dataDir);
|
||||||
|
check('第一轮未记录事件(去抖中)', events1.length === 0, `events=${events1.length}`);
|
||||||
|
|
||||||
|
await runOnce(['--once', ...common]);
|
||||||
|
const mailAfter2 = smtp.received.length - beforeMail;
|
||||||
|
const meowAfter2 = meow.received.length - beforeMeow;
|
||||||
|
check('第二轮达到阈值:邮件与 MeoW 各 1 条', mailAfter2 === 1 && meowAfter2 === 1, `邮件 ${mailAfter2} / MeoW ${meowAfter2}`);
|
||||||
|
const decodeMail = (raw) => {
|
||||||
|
const [head, body = ''] = raw.split('\r\n\r\n');
|
||||||
|
const subject = /^Subject: =\?UTF-8\?B\?(.*)\?=/m.exec(head);
|
||||||
|
return {
|
||||||
|
subject: subject ? Buffer.from(subject[1], 'base64').toString('utf8') : (/^Subject: (.*)$/m.exec(head)?.[1] ?? ''),
|
||||||
|
body: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString('utf8'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const downMail = smtp.received.at(-1);
|
||||||
|
const downDecoded = decodeMail(downMail.raw);
|
||||||
|
check('告警邮件含目标名称与详情', /必然离线的 TCP/.test(downDecoded.body) && /【故障】/.test(downDecoded.subject), `主题「${downDecoded.subject}」正文 ${downDecoded.body.length} 字符`);
|
||||||
|
const events2 = readEvents(dataDir);
|
||||||
|
check('事件时间线记录 down', events2.some((e) => e.type === 'down' && e.id === 'down-tcp'), JSON.stringify(events2.map((e) => e.type)));
|
||||||
|
|
||||||
|
await runOnce(['--once', ...common]);
|
||||||
|
const mailAfter3 = smtp.received.length - beforeMail;
|
||||||
|
check('第三轮不重复告警(去抖状态已持久化)', mailAfter3 === 1, `邮件累计 ${mailAfter3}`);
|
||||||
|
|
||||||
|
// 恢复:把离线目标换成 data: URL,下一轮应发「恢复」
|
||||||
|
const upCfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
upCfg.monitors = upCfg.monitors.map((m) => (m.id === 'down-tcp' ? { ...m, type: 'http', url: 'data:text/plain,ok', expectStatus: [200] } : m));
|
||||||
|
delete upCfg.monitors.find((m) => m.id === 'down-tcp').host;
|
||||||
|
delete upCfg.monitors.find((m) => m.id === 'down-tcp').port;
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(upCfg, null, 2)}\n`);
|
||||||
|
await runOnce(['--once', ...common]);
|
||||||
|
const recovery = smtp.received.at(-1);
|
||||||
|
const recoveryDecoded = decodeMail(recovery.raw);
|
||||||
|
check('恢复时发送「恢复」通知', /【恢复】/.test(recoveryDecoded.subject) && /恢复时间/.test(recoveryDecoded.body), `主题「${recoveryDecoded.subject}」`);
|
||||||
|
const events3 = readEvents(dataDir);
|
||||||
|
check('事件时间线记录 up(恢复)', events3.some((e) => e.type === 'up' && e.id === 'down-tcp'), JSON.stringify(events3.map((e) => e.type)));
|
||||||
|
|
||||||
|
/* ---------- D2. 维护窗口 ---------- */
|
||||||
|
console.log('\n[D2] 维护窗口抑制告警');
|
||||||
|
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-maint-'));
|
||||||
|
fs.cpSync(path.join(ROOT, 'public'), tmp2, { recursive: true });
|
||||||
|
const { file: file2 } = writeConfig(tmp2);
|
||||||
|
const cfg2 = JSON.parse(fs.readFileSync(file2, 'utf8'));
|
||||||
|
cfg2.settings.maintenance = [{ from: new Date(Date.now() - 3600_000).toISOString(), to: new Date(Date.now() + 3600_000).toISOString(), note: '计划维护' }];
|
||||||
|
fs.writeFileSync(file2, `${JSON.stringify(cfg2, null, 2)}\n`);
|
||||||
|
const dataDir2 = path.join(tmp2, 'data');
|
||||||
|
const outFile2 = path.join(tmp2, 'status.json');
|
||||||
|
const beforeMail2 = smtp.received.length;
|
||||||
|
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
|
||||||
|
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
|
||||||
|
check('维护窗口内不发告警', smtp.received.length === beforeMail2, `新增 ${smtp.received.length - beforeMail2} 封`);
|
||||||
|
const events4 = readEvents(dataDir2);
|
||||||
|
check('维护窗口内仍记录事件(标记 maintenance)', events4.some((e) => e.type === 'down' && e.maintenance === true), JSON.stringify(events4.map((e) => `${e.type}:${e.maintenance}`)));
|
||||||
|
const snap2 = JSON.parse(fs.readFileSync(outFile2, 'utf8'));
|
||||||
|
check('快照中目标带维护标记', snap2.monitors['down-tcp'].maintenance?.note === '计划维护', '');
|
||||||
|
|
||||||
|
/* ---------- E. 证书 ---------- */
|
||||||
|
console.log('\n[E] 证书');
|
||||||
|
check('tlsTargetOf 识别 https 目标', tlsTargetOf({ type: 'http', url: 'https://www.example.com:8443/x' }).port === 8443, '');
|
||||||
|
check('tlsTargetOf 忽略 http 目标', tlsTargetOf({ type: 'http', url: 'http://www.example.com' }) === null, '');
|
||||||
|
check('tlsTargetOf 尊重 tls:false', tlsTargetOf({ type: 'http', url: 'https://www.example.com', tls: false }) === null, '');
|
||||||
|
try {
|
||||||
|
const cert = await probeCertificate({ host: 'www.example.com', timeoutMs: 8000 });
|
||||||
|
if (cert.error) {
|
||||||
|
console.log(` - 真实握手失败(可能无外网):${cert.error} → 跳过`);
|
||||||
|
} else {
|
||||||
|
check('真实证书可取到剩余天数', Number.isFinite(cert.daysLeft) && cert.daysLeft > -1, `剩余 ${cert.daysLeft} 天,签发者 ${cert.issuer}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` - 真实握手异常:${err.message} → 跳过`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- F. 通道自检 CLI ---------- */
|
||||||
|
console.log('\n[F] --test-notify 自检命令');
|
||||||
|
const test = await runOnce(['--test-notify', ...common]);
|
||||||
|
check('--test-notify 退出码为 0 且报告两渠道', test.code === 0 && /email/.test(test.out) && /meow/i.test(test.out), test.out.split('\n').slice(-4).join(' | ').slice(0, 120));
|
||||||
|
} finally {
|
||||||
|
await smtp.close();
|
||||||
|
await meow.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);
|
||||||
|
});
|
||||||