commit 3db3ce570ae39cd4c27db97d769a81c5d7f8d842 Author: tamakyi Date: Tue Oct 13 18:31:58 2026 +0800 chore: v0.6.0 baseline — 主题美化前存档 已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。 diff --git a/.github/workflows/monitor.yml b/.github/workflows/monitor.yml new file mode 100644 index 0000000..0061bc3 --- /dev/null +++ b/.github/workflows/monitor.yml @@ -0,0 +1,31 @@ +name: probe + +on: + schedule: + # GitHub Actions 计划任务最小粒度为 5 分钟 + - cron: "*/5 * * * *" + workflow_dispatch: + +permissions: + contents: write + +jobs: + probe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: 执行一轮探测 + run: node prober/node/index.js --once + + - name: 提交 status.json + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add public/status.json + git diff --staged --quiet || git commit -m "chore: update status snapshot" + git push diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..41ab0b3 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,29 @@ +name: deploy-pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: public + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..996c745 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +*.log +.DS_Store +.wrangler/ +.dev.vars +# 私有配置:真实地址/端口/密钥,不入库 +local.monitors.json +# 本地备份包 +backups/ +# 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行) +.workbuddy/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4538136 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 shiroko-monitor contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0add600 --- /dev/null +++ b/README.md @@ -0,0 +1,436 @@ +# shiroko-monitor + +轻量级服务监控面板。借鉴 Uptime Kuma 的交互思路,代码完全自研,**零运行时依赖**(Node ≥ 18 直接跑,无 npm install),纯静态前端 + 可插拔探针,支持本地 / GitHub Pages / Cloudflare Workers / EdgeOne Pages 等多种低成本部署。 + +## 功能特性 + +- **HTTP 可用性探测**:状态码(支持自定义期望值,如把 500 判为正常)、响应延迟、超时控制、不自动跟随重定向(保留 3xx 原始码) +- **TCP 端口探测**:连通性判定、连接耗时,区分「连接被拒绝」与「超时」 +- **Banner 探测(SSH / SMTP / FTP / POP3 / IMAP 等)**:连接后**被动读取**服务端主动发送的标识串,支持 `expectBanner` 规则校验(可做版本合规检查);不做认证、不发任何数据,不会触发目标侧的 fail2ban 封禁。默认只输出协议族摘要,不回显版本号 +- **90 天可用率**:按天聚合每次探测结果(保留 90 天,自动淘汰旧数据),卡片底部以 90 格色条展示每日可用率(≥99% 绿 / ≥95% 橙 / <95% 红 / 无数据灰),悬停显示当日「可用率(在线次数/总次数)」;顶部汇总整体可用率 +- **两种面板形态**(顶部一键切换,也可用 `settings.view` / `?view=` 指定): + - **表格**:一行一服务的高密度列表(状态/名称/目标/延迟/结果/最近检测/心率/可用率),默认视图 + - **卡片**:一服务一卡,含心率图与可用率条 +- **数据源过滤**:`settings.dataFilter` 可选 `both`(默认,两边都展示)/ `config`(只看配置文件里的自建目标)/ `uptimerobot`(只看 UptimeRobot 数据);也可用 `?filter=` 临时切换,顶部统计随展示集合同步 +- **可配置的自动刷新**:`settings.refreshInterval`(秒)决定面板自动刷新节奏;未显式配置时,「只看 UptimeRobot」模式默认 300 秒(UR 自身 5 分钟才更新一次,刷新更快没有意义) +- **监控面板**: + - 每个服务的在线 / 离线 / 等待检测状态(绿点脉冲动画) + - 响应延迟、结果(状态码 / 端口 / 标识串)、最近检测时间(相对时间 + 悬停显示精确时间) + - 最近 48 次检测的心率图(绿色 = 在线,红色 = 离线,柱高 = 延迟) + - 分组页签筛选,顶部统计摘要(监控数 / 在线 / 离线 / 平均延迟 / 90 天可用率) + - 自动刷新倒计时(基于截止时间,不受浏览器后台标签页定时器节流影响)、手动刷新按钮、暗色模式自适应(`prefers-color-scheme`) +- **配置即文件**:`public/monitors.json` 单一配置源,改完即生效(本地探针支持热重载) +- **刷新节奏由服务端驱动**:探测由服务端按各目标 `interval` 持续进行,快照里带上「数据最后更新时刻 + 服务端更新节奏」;面板的倒计时对齐到**服务端下次更新时间**,不因访客打开页面而重新计时(旧行为下每次进页面都要重等一整个周期,最坏会看到近两倍周期的旧数据)。自动刷新只读取数据,只有手动点「刷新」才触发一次重新检测 +- **UptimeRobot 双向兼容**: + - *接入*:把 UptimeRobot 已有的监控结果当数据源拉进来,与自建探测并列展示(异地视角),状态、延迟、90 天可用率、心率图全部映射自 UR 官方数据;UR 读取失败时保留上次数据,绝不把服务误判为离线 + - *输出*:提供 `/api/v2/getMonitors`,字段与状态码编码对齐 UptimeRobot v2,既有脚本/看板可直接消费 +- **滥用防护与信息泄露控制**:刷新接口仅内网/回环可触发、按来源冷却(429 + Retry-After)、可整体关闭;支持 `hideTargets` / `redactDetails` 与 `local.monitors.json` 私有配置分层,公开配置里可以只有服务名 —— 详见 [docs/SECURITY.md](docs/SECURITY.md) +- **三种数据源自动解析**:边缘 API(实时)→ 静态快照 → 浏览器直连,也可用 `?source=` 强制指定 + +## 快速开始(本地) + +```bash +# 常驻模式:周期探测 + 伺服面板(默认端口 8787) +node prober/node/index.js +# 或 npm start + +# 单轮探测后退出(供 CI 使用,历史心率与 90 天分桶从已有 status.json 恢复) +node prober/node/index.js --once +# 或 npm run probe + +# 生成演示数据(含 90 天可用率样例,用于静态部署首版占位) +node prober/node/index.js --once --demo +# 或 npm run demo + +# 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处) +node prober/node/index.js --once --demo --out=dist/status.json +``` + +启动后打开 即可看到面板。无需安装任何依赖。 + +## 项目结构 + +``` +shiroko-monitor/ +├── monitors.json ─────────────── 已移至 public/monitors.json(单一配置源,见下) +├── package.json # 零依赖,仅封装常用命令 +├── LICENSE # MIT +├── public/ # 纯静态面板(可整体部署到任意静态托管) +│ ├── index.html # 面板页面骨架 +│ ├── monitors.json # ★ 监控目标配置(改这里) +│ ├── status.json # 探针输出的状态快照(面板读取) +│ └── assets/ +│ ├── style.css # 浅色主题 + 暗色自适应 +│ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测 +│ └── favicon.svg +├── prober/node/ # Node 探针(本地 / CI 用) +│ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config +│ └── lib/ +│ ├── http-probe.js # HTTP 探测(fetch,redirect: manual) +│ ├── tcp-probe.js # TCP 探测(net.connect) +│ ├── banner-probe.js # Banner 探测(被动读取标识串,零认证零交互) +│ └── uptimerobot.js # UptimeRobot 只读数据源(API v2 映射) +├── scripts/ # 开发验证脚本(需 playwright) +│ ├── verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板渲染 +│ ├── verify-views.cjs # 视图渲染、切换持久化与数据源过滤 +│ ├── verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流 +│ └── verify-uptimerobot.cjs # UR 映射单测 / 兼容接口 / 失败路径 / 真实 Key 冒烟 +├── local.monitors.json # 私有配置(不入库):真实地址、API Key +├── edge/cloudflare/ # Cloudflare Workers 边缘探针 +│ ├── worker.js # cron 探针 + KV 存储 + /api/* + 静态伺服 +│ └── wrangler.toml +├── .github/workflows/ +│ ├── monitor.yml # 每 5 分钟跑一轮探针并提交 status.json +│ └── pages.yml # 推送 main 时部署 GitHub Pages +└── docs/ + ├── DEPLOYMENT.md # 四种部署方案详细步骤 + ├── SECURITY.md # 刷新滥用防护 / SSH 探测边界 / 信息泄露控制 + └── screenshot-*.png # 各视图实测截图 +``` + +> 配置源为什么放在 `public/` 下:面板用相对路径 `fetch('monitors.json')`,探针(Node/Worker)、静态托管(Pages)读取的都是同一份文件,零拷贝、零漂移。配置文件不含任何密钥,可安全公开。 + +## 配置说明(public/monitors.json) + +```jsonc +{ + "settings": { + "title": "Shiroko Monitor", // 面板标题 + "description": "轻量级服务监控面板", + "view": "table", // 默认视图:table | cards + "dataFilter": "both", // both | config | uptimerobot(详见「面板形态与数据源」) + "refreshInterval": 30, // 面板检查数据的最大间隔(秒,最小 5);实际对齐服务端更新节奏 + "dataSource": "auto", // auto | api | static | browser + "allowRefresh": true, // 是否允许触发强制探测(公网部署建议 false;详见 docs/SECURITY.md) + "refreshLocalOnly": true, // 仅内网/回环来源可触发强制探测 + "refreshCooldownSec": 5, // 每个来源的触发冷却(秒) + "hideTargets": false, // true = 面板不渲染任何地址(对外发布用) + "redactDetails": false, // true = 快照 detail 只保留「正常/异常」 + "uptimeRobot": { // UptimeRobot 只读数据源(Key 建议放 local.monitors.json) + "enabled": false, + "apiKey": "", + "pollIntervalSec": 300, + "group": "UptimeRobot" + } + }, + "monitors": [ + { + "id": "example-com", // 唯一 ID,状态数据按此关联 + "name": "Example 站点", // 显示名称 + "group": "公共服务", // 分组名(相同名字归为一组) + "type": "http", // http | tcp | banner + "url": "https://www.example.com", + "method": "GET", // 可选,默认 GET + "expectStatus": [200], // 判定为「在线」的状态码列表 + "interval": 60, // 探测间隔(秒);无人打开面板时的后台节奏 + "timeout": 10, // 超时(秒) + "probeOnRefresh": false // 可选:false = 该目标不参与「刷新即重新检测」,只按自己的 interval 探测 + }, + { + "id": "cloudflare-dns-tcp", + "name": "Cloudflare 443 端口", + "group": "基础设施", + "type": "tcp", // 只测端口连通性 + "host": "1.1.1.1", + "port": 443, + "interval": 60, + "timeout": 10 + }, + { + "id": "ssh-gateway", + "name": "跳板机 SSH", + "group": "基础设施", + "type": "banner", // 被动读取标识串(SSH/SMTP/FTP/POP3/IMAP/HTTP…) + "host": "10.0.0.5", + "port": 22, + "expectBanner": "^SSH-2\\.0-", // 可选:正则策略,不匹配即判离线(可做版本合规检查) + "bannerFlags": "i", // 可选:正则 flags,默认 i + "revealBanner": false, // 默认 false:只输出「SSH 响应正常」,不回显版本号 + "interval": 300, + "timeout": 5 + } + ] +} +``` + +添加监控目标 = 在 `monitors` 数组里加一项,保存即可(本地探针自动热重载;静态部署等下一次探针写入)。 + +`interval` 与 `refreshInterval` 的关系: + +- `interval`(每个目标):**服务端**的探测节奏,与是否有访客无关;也决定 90 天可用率的样本量 +- `refreshInterval`(面板):面板去服务端取数据的最长间隔。由于倒计时对齐服务端更新节奏,页面打开期间**不会**额外触发探测;服务端数据比我配置的节奏更新更快时,面板会跟着服务端走 +- 探测只由服务端驱动 → 打开再多标签页也不会增加对目标的压力(旧版本下每个访客都会触发探测轮次) +- 需要立刻看一次实时结果时,手动点面板上的「刷新」(服务端限流/关闭策略见 [docs/SECURITY.md](docs/SECURITY.md)) + +> ⚠️ **注意目标限流**:探测本身会消耗目标的配额。例如 GitHub 未认证 API 每小时仅 60 次,按 30s 节奏探测会很快耗尽并返回 403,面板会如实记为离线(那不是面板故障,而是服务真的在拒绝请求)。对这类目标请调大 `interval`,并可设目标级 `probeOnRefresh: false` 让它退出强制检测。仓库默认配置里的 `github-api` 就是这样一个示例目标。 + +## 数据源模式 + +| 模式 | 触发条件 | 数据新鲜度 | 说明 | +| --- | --- | --- | --- | +| `api` | 同源存在 `/api/status`(Node 服务器 / Cloudflare Worker) | 实时 | 首选 | +| `static` | 存在 `status.json`(GitHub Pages / EdgeOne 静态部署) | 取决于探针写入频率 | 次选 | +| `browser` | 以上都不可用 | 实时(浏览器直连) | 兜底,限制见下 | + +`auto` 模式按上表顺序自动降级。可用 `?source=api|static|browser` 强制指定,便于调试。 + +## UptimeRobot 集成 + +### 一、把 UptimeRobot 当数据源(推荐) + +UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自建探测并列。配置(**API Key 请放 `local.monitors.json`,不要写进公开配置**): + +```jsonc +// local.monitors.json(已在 .gitignore 中) +{ + "settings": { + "uptimeRobot": { "enabled": true, "apiKey": "ur123456-xxxxxxxxxxxxxxxxxxxxxxxx" } + } +} +``` + +```jsonc +// public/monitors.json 的 settings 里保留开关与默认值 +"uptimeRobot": { + "enabled": false, // 公开配置默认关闭;由私有配置开启 + "apiKey": "", // 留空;也可用环境变量 UPTIMEROBOT_API_KEY(适合 CI/Secrets) + "pollIntervalSec": 300, // 拉取间隔,别低于 UR 的检测间隔(免费版 5 分钟) + "group": "UptimeRobot", // 镜像目标在面板中的分组名 + "includeTypes": [] // 可选,按 UR 类型过滤:1=HTTP 2=Keyword 3=Ping 4=Port +} +``` + +映射关系(全部来自 UR 官方 API,非我们的二次探测): + +| 面板字段 | UptimeRobot 来源 | +| --- | --- | +| 在线/离线/等待 | `status`:2→在线,8/9→离线,0/1→等待检测 | +| 响应延迟 | 最新一条 `response_times`(缺失时用 `average_response_time`) | +| 心率图 | `response_times`(最多 48 个采样点,随 `response_times_average=30` 聚合) | +| 90 天可用率 | `custom_uptime_ratio`(1/7/30/90 天),逐日色条由本探针同步后自行累积 | +| 结果/原因 | 故障时取最近一次故障日志的 `reason.code/detail`,如 `403 Forbidden` | +| 类型徽标 | `UR·HTTP` / `UR·PORT` / `UR·KEYWORD`(与自建探测区分) | + +行为约定: + +- **只读**:仅调用 `getMonitors`,不做任何写操作,因此 `ur*` 开头的只读 Key 即可 +- **不参与本地强制探测**:UR 镜像由 UR 自己探测,本探针只拉结果;面板「刷新」不会去跑它们 +- **失败不误判**:Key 无效、限流、网络异常时只记录错误(页脚显示「UptimeRobot 同步异常」并给出原因),目标状态保持上一次的值 +- 速率:一次请求取回全部监控,默认 5 分钟一次(12 次/小时),远低于 UR 的接口限制 + +### 二、对外提供 UptimeRobot 兼容接口 + +`GET|POST /api/v2/getMonitors` 返回与 UptimeRobot v2 同构的响应,便于已有脚本/看板迁移: + +```jsonc +{ + "stat": "ok", + "pagination": { "offset": 0, "limit": 50, "total": 14 }, + "monitors": [{ + "id": 536986782, // 由字符串 id 派生的稳定数字 id + "friendly_name": "Example 站点", + "url": "https://www.example.com", + "type": 1, // 1=HTTP/Keyword 4=Port(UR 编码) + "status": 2, // 2=在线 9=离线 1=未检测 + "interval": 60, + "custom_uptime_ratio": "97.436-97.436-97.436-97.436", // 1-7-30-90 天,三位小数 + "average_response_time": 1291, + "response_times": [{ "datetime": 1791880740, "value": 429 }] + }] +} +``` + +该端点不做鉴权(其内容与公开面板一致);如需鉴权请在其前方加代理。`api_key` 参数会被忽略。 + +## 面板形态与数据源 + +### 视图 + +| 视图 | 形态 | 适合 | +| --- | --- | --- | +| `table` 表格 | 一行一服务,列含状态/名称/目标/延迟/结果/最近检测/心率/可用率 | **默认**,目标多时可一屏扫完 | +| `cards` 卡片 | 一服务一卡 + 心率图 + 90 格可用率条 | 需要更大点击区域或看图细节 | + +指定方式(优先级从高到低):URL 参数 `?view=table|cards` → 浏览器上次选择(localStorage)→ `settings.view` → 表格。 + +### 数据源过滤 + +| 值 | 含义 | +| --- | --- | +| `both` | 配置文件里的自建目标 + UptimeRobot 镜像目标都展示(默认) | +| `config` | 只看配置文件里的自建探测目标 | +| `uptimerobot` | 只看 UptimeRobot 的数据 | + +配置在 `settings.dataFilter`;也可用 URL 临时覆盖(适合做固定链接,如挂墙看板):`?filter=uptimerobot`。 +过滤只影响**展示与快照输出**,不影响探测——配置文件里的目标始终按各自 `interval` 探测。 + +顶部统计(监控数/在线/离线/平均延迟/可用率)按当前展示的集合实时汇总,不会出现「列表 8 行、统计 14 个」的不一致。 + +### 自动刷新节奏(服务端驱动) + +```jsonc +"refreshInterval": 300 // 秒,最小 5;不配则默认 30,「只看 UptimeRobot」时默认 300 +``` + +面板不再从「你打开页面的那一刻」重新计时,而是**对齐服务端的更新节奏**: + +| 环节 | 由谁驱动 | 说明 | +| --- | --- | --- | +| 数据产出 | **服务端** | 自建目标按各自 `interval` 探测;UptimeRobot 按 `pollIntervalSec` 拉取。与是否有访客无关 | +| 面板检查时刻 | 对齐服务端 | 快照带 `dataUpdatedAt`(数据最后变更时刻)与 `updateIntervalSec`(服务端更新节奏),面板在「上次更新 + 一个周期」时去取一次 | +| `refreshInterval` | 上限 | 面板检查间隔不会超过它;服务端节奏更快时按服务端节奏走 | +| 手动「刷新」 | 你 | 唯一会触发**立即重新检测**的动作(服务端可拒绝/限流,见 docs/SECURITY.md)| +| 自愈 | 面板 | 若服务端数据超过 1.5 个周期没更新(如探针刚重启或被休眠),面板会自动触发一次重新检测 | + +效果:无论你什么时候打开页面,倒计时都显示「距服务端下一次更新还有多久」——刚更新完打开是 300s,过了 4 分钟打开就只剩 60s。静态快照与浏览器直连模式没有可对齐的服务端节奏,仍按 `refreshInterval` 轮询。 + +## 90 天可用率 + +探针每完成一次探测,就把它计入「当日分桶」:`monitors..daily["YYYY-MM-DD"] = { total, up }`,只保留最近 90 天(超出自动淘汰)。面板据此渲染 90 格色条与百分比。 + +- **数据积累**:从探针首次运行当天开始累积,跑满 90 天后色条填满;没有数据的日期显示灰色(悬停提示「无数据」) +- **判定口径**:可用率 = 当日在线探测次数 / 当日总探测次数。**提高探测频率会提高统计精度**(60s 间隔约 1440 次/天,GitHub Actions 5 分钟粒度约 288 次/天) +- **本地时区**:Node 探针按运行机器的本地日期分桶;Cloudflare Worker 无本地时区概念,按 UTC 日期分桶 +- **跨重启保留**:分桶随 `status.json` 持久化,重启/CI 单轮模式都会先恢复再追加,不会断档 +- **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker +- **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=` 写到别处 + +## 部署方案 + +| 方案 | 架构 | 数据更新 | 成本 | 适合场景 | +| --- | --- | --- | --- | --- | +| 本地 Node | 探针 + 面板同机伺服 | 实时(按 interval) | 免费 | 内网自用、开发调试 | +| GitHub Pages + Actions | 纯静态 + Actions 定时提交 status.json | 每 5 分钟(Actions 下限) | 免费 | 公网演示、个人项目 | +| Cloudflare Workers | 边缘函数 cron 探针 + KV | 每 1 分钟 | 免费版可用(TCP 需付费版) | 公网生产、多目标 | +| EdgeOne Pages | 纯静态 + 数据更新同 Pages 路径 | 每 5 分钟 | 免费额度 | 国内访问加速 | + +四种方案的详细步骤见 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)。 + +## 浏览器直连模式的限制(重要) + +浏览器没有原始 TCP 能力,且受同源策略约束,`browser` 模式下: + +- HTTP:优先以 `cors` 模式请求,目标响应带 `Access-Control-Allow-Origin` 时可读到真实状态码;否则降级 `no-cors`,**只能判断可达性,读不到状态码**(显示「跨域可达」) +- TCP:以 HTTP 连接行为近似——快速拒绝判为端口关闭;连接后无响应直到超时判为端口开放(启发式,无法区分「开放但静默」与「被防火墙丢弃」) +- HTTPS 页面无法探测 HTTP 目标(浏览器混合内容拦截,会判为离线) +- 结论:**浏览器直连只作兜底**。要准确的状态码与 TCP 探测,请使用 Node 探针或边缘 Worker 提供 `/api/status` + +## 已实现 vs 路线图 + +### v0.1.0 已实现 + +- [x] HTTP 探测:状态码 / 延迟 / 自定义期望值 / 超时 / 保留 3xx +- [x] TCP 探测:连通性 / 连接耗时 / 拒绝与超时区分 +- [x] Node 探针:常驻巡检、周期调度、配置热重载、历史心率持久化(重启不断档)、原子写入 status.json +- [x] Node 探针附带静态服务器与 `/api/status`、`/api/monitors`、`/api/refresh` +- [x] `--once`(CI 单轮)与 `--demo`(演示数据)模式 +- [x] 面板:状态卡片、心率图、分组页签、统计摘要、相对/绝对时间、自动刷新倒计时、暗色模式 +- [x] 数据源三级自动降级 + `?source=` 强制指定 +- [x] Cloudflare Worker 边缘探针(cron + KV + 静态伺服) +- [x] GitHub Actions:定时探测 + Pages 部署 +- [x] 四种部署路径文档 + +### v0.2.0 已实现 + +- [x] **90 天可用率**:按天分桶聚合、90 天滚动保留、卡片 90 格分级色条 + 悬停明细、顶部整体可用率(Node 与 Worker 双端一致实现) +- [x] `--out=` 自定义快照输出路径;`--demo` 覆盖真实快照前给出告警 +- [x] 修复:配置热重载后 `/api/status` 永久冻结在旧数据(闭包捕获了被替换的旧 state 引用,改为持有器对象) +- [x] 修复:慢目标(如 10s 超时)会被每秒的调度 tick 重复探测,形成探测风暴(改为探测前同步占位 + inFlight 标记) +- [x] 修复:非法/缺失 `interval` 会算出 `NaN` 导致该目标永久不再被调度(改为回落默认 60s) +- [x] 修复:刷新倒计时改按截止时间计算,不再受浏览器后台标签页定时器节流影响 +- [x] `scripts/verify.cjs` 端到端回归验证(15 项断言,覆盖上述修复) + +### v0.2.1 已实现 + +- [x] **刷新即重新检测**:`/api/refresh` 等待本轮探测完成后返回最新快照,面板自动刷新与手动「刷新」按钮都会真正重新探测目标(此前只是重读旧快照,导致「部分卡片停留在几十秒前、手动刷新也没变化」) +- [x] 服务端强制轮次去重:多标签页/多客户端同时刷新共享同一轮探测,2s 内的重复请求直接复用快照(避免对目标重复施压);强制轮次先等在途探测收尾,保证不跳过任何目标 +- [x] `settings.probeOnRefresh`(默认 `true`):关闭后自动刷新退回只读轮询,手动刷新仍然强制检测 +- [x] 目标级 `probeOnRefresh: false`:单个目标可退出「刷新即检测」(适合有限流的 API),只按自身 `interval` 探测;默认配置新增示例目标说明该用法 +- [x] Cloudflare Worker 的 `/api/refresh` 同步改为「等待完成再返回」+ 同隔离体去重 + +### v0.3.0 已实现 + +- [x] **Banner 探测**(`type: banner`):被动读取 SSH/SMTP/FTP/POP3/IMAP 等标识串,`expectBanner` 正则策略校验(可做版本合规)、`revealBanner` 显式开启才回显;不做认证、不发数据,不触发目标封禁 +- [x] **四种面板形态**:卡片 / 表格 / 大屏(深色 NOC)/ 极简(对外状态页),顶部一键切换 + `?view=` + `settings.view` + localStorage 记忆 +- [x] **刷新接口滥用防护**:内网/回环来源限制(不信任 X-Forwarded-For)、每来源冷却 429 + Retry-After、轮次去重、总开关 `allowRefresh`;Worker 需 `REFRESH_TOKEN` 才开放 +- [x] **信息泄露控制**:`local.monitors.json` 私有配置分层(公开配置可只留 id/name/group)、`hideTargets`、`redactDetails`、TCP 详情不再回显地址、banner 默认不回显版本 +- [x] 私有配置分层 + `--config` / `--local-config`(多实例与测试用) +- [x] 修复:客户端中断请求(切页/关标签)后向已断开 socket 写响应会抛出未捕获异常,可能终止探针进程 +- [x] 修复:非卡片视图的状态点沿用胶囊色(着色规则作用域写死在 `.card` 上) +- [x] 修复:首次打开面板时需等强制检测完成才出数据,期间显示空白「等待检测」——改为先用已有快照立即渲染 +- [x] 新增 `docs/SECURITY.md`(滥用防护 / SSH 探测边界 / 信息泄露控制)与 `scripts/verify-security.cjs`、`scripts/verify-views.cjs` + +### v0.4.0 已实现 + +- [x] **UptimeRobot 数据源(只读)**:把 UR 已有监控作为「异地视角」接入面板,状态/延迟/心率/90 天可用率/故障原因全部映射自 UR 官方字段;镜像目标不参与本地强制探测;同步失败只记录错误并保留上次数据 +- [x] **UptimeRobot 兼容接口**:`/api/v2/getMonitors` 输出与 UR v2 同构(含 `custom_uptime_ratio`、`response_times`、数字 id 与状态码编码) +- [x] `stats.memory`:探针自报内存占用(RSS/heapUsed),方便观察「监控器自身」的开销 +- [x] 配置分层增强:`--config` / `--local-config` 指定配置文件路径;UR API Key 支持私有配置或 `UPTIMEROBOT_API_KEY` 环境变量 +- [x] 修复:非 http/tcp 类型的目标行渲染为 `undefined:undefined`(改为有 url 用 url、有 host 用 host:port) +- [x] 修复:热重载会丢弃 UptimeRobot 镜像目标已累积的逐日分桶 +- [x] 修复(实测发现):`custom_down_durations` 不能作为请求参数传给 UR v2(返回 `invalid_parameter`),改为只从响应中读取 +- [x] 新增 `scripts/verify-uptimerobot.cjs`(22 项:映射单测 / 兼容接口 / 失败路径 / 真实 Key 冒烟) + +### v0.5.0 已实现 + +- [x] **表格设为默认视图**,移除大屏与极简两种形态(面板只保留表格/卡片,切换器更精简) +- [x] **数据源过滤** `settings.dataFilter`:`both` / `config`(仅配置文件)/ `uptimerobot`(仅 UR),支持 `?filter=` 临时覆盖;服务端快照与面板共用同一口径(私有配置覆盖后也不会错位) +- [x] **顶部统计按展示集合汇总**:切换过滤后不再出现「列表 8 行、统计 14 个」 +- [x] **自动刷新间隔可配置**并按数据源自适应:`refreshInterval`(秒);只看 UptimeRobot 时默认 300 秒(UR 自身 5 分钟才更新) +- [x] `/api/refresh?scope=external`:只看 UR 的刷新只同步 UR 数据,不跑本地探测轮次;普通刷新也会顺带同步 UR(最小间隔 60 秒,尊重 UR 接口限流) + +### v0.6.0 已实现 + +- [x] **刷新节奏改为服务端驱动**:快照新增 `dataUpdatedAt`(数据最后变更时刻)与 `updateIntervalSec`(服务端更新节奏),面板倒计时对齐到服务端下次更新,**不再每次打开页面从整周期重新计时**(旧行为最坏会看到近两倍周期的旧数据;实测:两次打开间隔 25s,倒计时差值 28s) +- [x] 自动刷新改为**只读**:探测完全由服务端按 `interval` / `pollIntervalSec` 驱动,访客数量不再影响对目标的压力;只有手动点「刷新」才触发立即重新检测 +- [x] 新增自愈:服务端数据超过 1.5 个周期未更新时(探针刚重启、机器休眠过)面板自动触发一次重新检测 +- [x] 移除已无意义的全局 `probeOnRefresh` 开关(目标级 `probeOnRefresh: false` 保留:该目标不参与强制检测);`refreshInterval` 语义澄清为「面板检查数据的最大间隔」 +- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间 +- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言 + +### 路线图(后续迭代) + +- [ ] 通知渠道:Webhook / Server 酱 / Telegram / Bark / 邮件 +- [ ] 故障事件记录:连续失败次数、事件时间线、状态变更历史 +- [ ] 状态徽章:SVG badge(shields.io 风格) +- [ ] 更多探测类型:证书到期天数、响应体关键字、DNS 解析、ICMP(边缘端)、HTTP 重定向链 +- [ ] 主动协议探测(Redis `PING`、MySQL 握手等):需先设计「发送探测字节」的安全边界与白名单,避免退化成通用交互工具 +- [ ] 只读分享令牌:给 `hideTargets` 之外的场景做「带令牌才能看到真实名称」的分级 +- [ ] 多区域探针:多 Worker 部署 + 结果合并,取容错视图 +- [ ] 配置 Web UI(可选加密编辑),替代手改 JSON +- [ ] 面板:更多形态(如时间线视图、地图视图)、明暗主题手动切换、可用率区间可配置(7/30/90 天) +- [ ] 状态页 JSON API 的 CORS 开放与缓存策略细化 + +## 开发 + +```bash +# 本地起服务(含探针) +node prober/node/index.js + +# 只验证探针逻辑 +node prober/node/index.js --once + +# UI 自动化实测(需 playwright,仅开发验证用,驱动系统 Chrome) +NODE_PATH= node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项) +NODE_PATH= node scripts/verify-views.cjs # 视图与数据源过滤(10 项) +node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项) +UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项) +``` + +`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。 + +代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。 + +## 界面截图 + +| 表格(默认视图) | 卡片视图 | +| --- | --- | +| ![table](docs/screenshot-desktop.png) | ![cards](docs/screenshot-view-cards.png) | + +UptimeRobot 数据源(`?filter=uptimerobot`,截图中含真实监控名与域名,公开发布前可自行删除):`docs/screenshot-uptimerobot.png` + +演示数据(90 天满档)与移动端:`docs/screenshot-demo.png`、`docs/screenshot-mobile.png` + +## 致谢与定位 + +交互设计参考 Uptime Kuma(心跳图、90 天可用率条、分组卡片、汇总统计),实现为自研轻量版:不追求 Kuma 的通知/多用户/数据库体系,只保留「配置文件 → 探针 → 状态面板」的最小闭环,并对静态/边缘部署友好。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..e0e2081 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,132 @@ +# 部署方案详解 + +四种方案按「成本从低到要求、数据从准到轻」排列。所有方案共用同一份 `public/` 目录与 `public/monitors.json` 配置。 + +--- + +## 方案一:本地运行(Node) + +最适合内网自用与开发调试,数据最实时。 + +```bash +node prober/node/index.js # 默认端口 8787 +PORT=9000 node prober/node/index.js # 自定义端口 +``` + +- 面板:`http://localhost:8787/` +- 状态 API:`http://localhost:8787/api/status` +- 改 `public/monitors.json` 后自动热重载,无需重启 +- 历史心率存于 `public/status.json`,重启后自动恢复 +- 90 天可用率按天累积在 `public/status.json`,重启不丢(每次探测先恢复分桶再追加) +- 面板刷新即重新检测(`/api/refresh` 等待本轮完成再返回);多标签页同时刷新只触发一轮探测 +- 后台常驻可用 pm2 / systemd / Windows 服务,按需自行封装 + +**适用**:监控内网服务(面板浏览器与目标同内网时,浏览器直连模式也能用)。 + +--- + +## 方案二:GitHub Pages + Actions(零成本公网) + +架构:仓库里的 `public/` 整体作为 Pages 站点;Actions 每 5 分钟跑一轮 Node 探针,把 `public/status.json` 提交回仓库,面板以 `static` 模式读取。 + +步骤: + +1. 推送代码到 GitHub 仓库(`main` 分支) +2. 仓库 **Settings → Pages → Build and deployment → Source** 选 **GitHub Actions** +3. 首次提交时建议带一份占位数据,避免页面空白: + ```bash + node prober/node/index.js --once --demo # 生成演示 status.json + git add public/status.json && git commit -m "chore: demo status" && git push + ``` +4. 之后 `pages.yml` 负责每次推送自动部署;`monitor.yml` 每 5 分钟更新一次 `status.json`(提交信息为 `chore: update status snapshot`) +5. 访问 `https://<用户名>.github.io/<仓库名>/` + +注意事项: + +- Actions 计划任务最小粒度 5 分钟,数据最多 5 分钟延迟;心跳图按「检测次数」记录,5 分钟粒度下 48 格 ≈ 4 小时 +- 90 天可用率同样按「检测次数」统计,5 分钟粒度约 288 次/天;每轮 Actions 会把当日分桶续写到 `status.json` 并提交,因此可用率数据跨运行累积(若某天 Actions 未运行,该日色条会显示「无数据」而不是 0%) +- **静态快照模式没有服务端,面板无法触发重新检测**:此方案下刷新只是重新读取仓库里的 `status.json`,数据新鲜度完全取决于 Actions 的运行频率(面板页脚会显示「数据源:静态快照」以作区分) +- 若仓库开启了分支保护,需允许 `github-actions[bot]` 推送(workflow 已声明 `contents: write` 权限) +- 免费额度对公开仓库无限量,私有仓库每月 2000 分钟,足够 + +--- + +## 方案三:Cloudflare Workers(边缘实时) + +架构:Worker 每分钟由 cron 触发,用 `fetch` 探测 HTTP、用 `cloudflare:sockets` 探测 TCP,状态写入 KV;同 Worker 伺服 `public/` 静态资源与 `/api/*`。面板以 `api` 模式实时读取。 + +```bash +# 1. 安装 wrangler(任选其一,不影响本项目运行时) +npm install -g wrangler + +# 2. 登录并创建 KV 命名空间 +wrangler login +wrangler kv namespace create MONITOR_KV +# 把输出的 id 填到 edge/cloudflare/wrangler.toml 的 [[kv_namespaces]].id + +# 3. 部署(wrangler.toml 已指向 ../../public 作为静态资产,monitors.json 随之部署,无需手动导入 KV) +cd edge/cloudflare +wrangler deploy +``` + +- 面板与 API:`https:///` 与 `/api/status` +- 手动触发一轮:`curl https:///api/refresh`(等待本轮完成后返回最新快照,与本地探针同语义) +- cron 表达式在 `wrangler.toml` 的 `[triggers].crons` 中调整(`* * * * *` = 每分钟) + +注意事项: + +- **TCP 出站连接(`cloudflare:sockets`)需要 Workers 付费计划($5/月)**;免费计划下 `http` 类型探测与面板完全正常,`tcp` 类型会报错并记为离线 +- KV 为最终一致存储,刚写入后立即读取可能有秒级延迟,对分钟级巡检无影响 +- 90 天可用率分桶随 state 一起存于 KV,按 **UTC 日期** 分桶(边缘环境无本地时区);每分钟 cron 约 1440 次/天 +- 免费版 Worker 每日 10 万次请求,面板静态资源走 Assets 不计入 Worker 请求数 +- 中国大陆访问 Worker 默认域名不稳定,可绑定自有域名(需 Cloudflare 账号管理 DNS) + +--- + +## 方案四:EdgeOne Pages(国内加速) + +EdgeOne Pages 当前以静态托管为主,采用与方案二相同的「静态 + 定时数据」路径,国内访问体验更好。 + +步骤: + +1. 登录腾讯云 EdgeOne 控制台,进入 **Pages → 新建项目 → 导入 Git 仓库**(或直接上传 `public/` 目录) +2. 构建配置:无构建步骤,输出目录填 `public` +3. 数据更新二选一: + - **A. GitHub Actions 驱动**:保持方案二的 `monitor.yml` 不变,EdgeOne 侧开启「构建时拉取最新提交」或配置 Webhook 触发重新部署 + - **B. CI 推送触发**:在 `monitor.yml` 末尾追加一步,调用 EdgeOne 的部署 API / Webhook(按控制台提示填写) +4. 绑定自定义域名(需要备案的域名走国内加速线路) + +说明:EdgeOne Pages 若上线边缘函数(类 Pages Functions),可把 `edge/cloudflare/worker.js` 的 `/api/*` 部分平移过去,实现分钟级实时数据;当前初稿以静态路径交付,接口契约完全一致,迁移时面板无需改动。 + +--- + +## 90 天可用率在各方案下的表现 + +可用率按「探测次数」加权统计,因此**探测频率直接决定精度**,也决定色条填充速度: + +| 方案 | 每次探测间隔 | 每天样本数 | 90 天色条填满所需时间 | +| --- | --- | --- | --- | +| 本地 Node | 按 `monitors.json` 的 `interval`(示例 60s) | ≈1440 | 90 天 | +| Cloudflare Workers | cron 每分钟 | ≈1440(UTC 日期分桶) | 90 天 | +| GitHub Pages + Actions | ≥5 分钟(Actions 下限) | ≈288 | 90 天 | +| EdgeOne Pages | 同 GitHub Pages | ≈288 | 90 天 | + +注意:分桶数据随 `status.json`(或 KV state)持久化并滚动保留 90 天,**必须由探针持续写入才会累积**——静态快照方案下依赖 Actions 按计划运行;Actions 计划任务在仓库长期无活动时可能被 GitHub 自动暂停(后台重新启用即可)。 + +需要临时提升精度时,可把 `interval` 调到 30s 或更低(本地/Worker 均支持,注意目标站点的压力与自身限流)。 + +--- + +## 方案对比与选型建议 + +| 维度 | 本地 Node | GH Pages | CF Workers | EdgeOne Pages | +| --- | --- | --- | --- | --- | +| 数据延迟 | 秒级(interval) | ≤5 分钟 | ≤1 分钟 | ≤5 分钟 | +| 90 天可用率精度 | 高(≈1440 次/天) | 中(≈288 次/天) | 高(≈1440 次/天) | 中(≈288 次/天) | +| TCP 探测 | ✅ | ✅(Actions 内) | 付费版 ✅ / 免费版 ❌ | 同 GH Pages | +| 真实状态码 | ✅ | ✅ | ✅ | ✅ | +| 内网目标 | ✅(浏览器同网即可) | ❌ | ❌ | ❌ | +| 国内访问速度 | 本机最快 | 一般 | 一般 | 快 | +| 运维成本 | 需保活进程 | 零 | 零 | 零 | + +建议:个人/演示用 **GitHub Pages**;生产公网监控用 **Cloudflare Workers**;服务对象在国内且以 HTTP 监控为主用 **EdgeOne Pages**;内网资产用 **本地 Node**。 diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..e79832c --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,154 @@ +# 安全说明:刷新滥用防护、SSH 探测与信息泄露控制 + +面向「公网可访问的状态页」这个前提编写。核心结论先给: + +| 场景 | 建议 | +| --- | --- | +| 本地 / 内网自用 | 默认配置即可,无需额外改动 | +| 公网发布 | 静态快照(GitHub Pages / EdgeOne)或 Cloudflare Worker + `REFRESH_TOKEN`;**不要把 Node 探针直接暴露到公网** | +| 目标含内网资产 | 用 `local.monitors.json` 分层,公开配置只留 `id/name/group`,并开 `hideTargets` + `redactDetails` | + +--- + +## 一、`/api/refresh` 的滥用风险与分层防护 + +「刷新即重新检测」意味着**一个未鉴权的 GET 请求会触发一次对所有目标的出站连接**。滥用的三种形态: + +1. **探测放大器**:任何人反复调用,把本服务当作对目标站点施压的跳板(借刀杀人) +2. **资源耗尽**:每次强制刷新都会对所有目标发起连接,慢目标(如超时 10s)会长时间占用并发 +3. **目标配额消耗**:被诱导高频探测的第三方目标可能限流甚至封禁我们的出口 IP(真实案例:GitHub 未认证 API 上限 60 次/小时,被高频探测打满后返回 403,面板如实记为离线) + +### 防护措施(已实现) + +| 位置 | 措施 | 默认 | +| --- | --- | --- | +| Node 探针 | 仅允许内网/回环来源触发;**只信任 socket 对端地址,不解析 `X-Forwarded-For`**(该头部可被伪造) | 开启 | +| Node 探针 | 每来源冷却 `settings.refreshCooldownSec`,超限返回 `429` + `Retry-After` | 5s | +| Node 探针 | 轮次去重:多标签页/多客户端共享同一轮探测;2s 内的重复请求直接复用快照 | 开启 | +| Node 探针 | 单轮上限 22s 兜底(`FORCED_MAX_WAIT_MS`),防止卡死连接长期占用请求 | 开启 | +| Node 探针 | 总开关 `settings.allowRefresh: false`,完全关闭强制探测(只读模式) | 可配 | +| Node 探针 | 拒绝响应只回错误码(`{"error":"refresh-throttled"}`),不回显目标与内部细节 | 固定 | +| Worker | **必须配置 `REFRESH_TOKEN` 才开放** `/api/refresh`,否则一律 403;token 比较用等长逐字节异或,避免按字符提前返回的时序差异 | 默认关闭 | +| 面板 | 收到 401/403 自动退回只读轮询(页脚标注「只读」);429 仅本轮降级并在按钮提示 | 自动 | + +### 配置参考 + +```jsonc +{ + "settings": { + "refreshInterval": 30, // 面板刷新节奏 + "probeOnRefresh": true, // 刷新是否重新探测 + "allowRefresh": true, // false = 完全关闭强制探测(公网部署建议) + "refreshLocalOnly": true, // 仅内网/回环来源可触发 + "refreshCooldownSec": 5 // 每来源冷却 + } +} +``` + +单个目标也可退出「刷新即检测」,避免被高频探测压垮配额: + +```jsonc +{ "id": "github-api", "interval": 300, "probeOnRefresh": false } +``` + +### 已知限制(请按需叠加外部措施) + +- **限流在进程内**:多个探针实例不共享计数;需要强约束时请在网关(Nginx / Cloudflare Rate Limiting)再加一层 +- **反向代理场景**:因为不信任 `X-Forwarded-For`,前置代理时所有请求都来自代理的内网地址,`refreshLocalOnly` 会失效——此时应设 `allowRefresh: false`,或自行在代理层做认证与限流 +- Worker 的同隔离体去重是 best-effort(不同边缘节点各自计数) + +--- + +## 二、SSH 这类服务怎么探测 + +支持三种探测类型,按「侵入性从低到高」: + +| 类型 | 做什么 | 适合 | +| --- | --- | --- | +| `tcp` | 仅建立 TCP 连接,测连通性与连接耗时 | 任意端口存活监控 | +| `banner` | 连接后**被动读取服务端主动发送的标识串**,可与规则比对 | SSH / SMTP / FTP / POP3 / IMAP / HTTP 等主动问候的协议 | +| `http` | 完整 HTTP 请求,读状态码 | Web 服务 | + +### SSH 配置示例 + +```jsonc +{ + "id": "ssh-gateway", + "name": "跳板机 SSH", + "group": "基础设施", + "type": "banner", + "host": "10.0.0.5", // 真实地址建议放 local.monitors.json + "port": 22, + "timeout": 5, + "interval": 300, + "expectBanner": "^SSH-2\\.0-", // 策略:必须是 SSH 2.0 + "revealBanner": false // 默认不回显标识串(含软件版本) +} +``` + +`expectBanner` 让探测同时具备**合规检查**能力:例如要求 `^SSH-2\.0-OpenSSH_[89]`,若目标降级到旧版本,探测即判为离线并触发面板告警。 + +### 明确不做的三件事 + +1. **不做 SSH 登录/认证尝试**:会触发目标侧 fail2ban/sshguard 封禁,需要凭据,还会在目标留下审计噪音 +2. **不发送任何应用层数据**:因此需要客户端先行发包的协议(Redis 的 `PING`、MySQL 握手)暂不适用——这是刻意保留的安全边界,不是能力缺失 +3. **不在标识串不匹配时回显实际收到的内容**:避免把「目标是什么版本」写进公开的 `status.json` + +banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器不同:不产生登录失败记录,也不改动目标任何状态。 + +--- + +## 三、信息泄露控制 + +状态页天生是公开的,泄露面有三处,逐个封堵: + +| 泄露面 | 风险 | 措施 | +| --- | --- | --- | +| `public/monitors.json` | 面板必需文件,静态部署必然公开,含真实 URL / 内网主机端口 | **私有配置分层**:公开文件只写 `id/name/group`,真实地址放仓库根的 `local.monitors.json`(已 gitignore),探针按 `id` 合并覆盖 | +| `status.json` / `/api/status` | 探针输出,Pages 部署会公开,`detail` 可能含地址、版本、错误码 | `settings.redactDetails: true` → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 `hideTargets` 开启时不下发 | +| 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 | +| UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 | +| `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 | + +### 对外发布状态页的推荐组合 + +```jsonc +// local.monitors.json(不入库,仅本机/CI 环境存在) +{ + "monitors": [ + { "id": "blog", "url": "https://blog.example.com" }, + { "id": "nas", "host": "192.168.1.10", "port": 5000 } + ] +} +``` + +```jsonc +// public/monitors.json(公开) +{ + "settings": { "hideTargets": true, "redactDetails": true }, + "monitors": [ + { "id": "blog", "name": "博客站点", "group": "网站", "type": "http", "interval": 60, "timeout": 10 }, + { "id": "nas", "name": "家庭存储", "group": "基础设施", "type": "tcp", "interval": 300, "timeout": 5 } + ] +} +``` + +这样即使 `status.json` 与面板完全公开,访客也只能看到「有哪些服务、是否在线、延迟、可用率」。 + +补充:`dataFilter`(`both` / `config` / `uptimerobot`)只决定**展示与快照输出**哪些目标,不影响探测行为;如果希望某个来源的数据完全不进入公开快照,应使用 `config` 过滤或把对应目标从配置中移除。 + +### 仍然会被公开的信息(请自行评估) + +- **服务数量与名称**:命名不要包含敏感语义(不要叫「内网财务系统」) +- **延迟特征**:多次采样可反推大致网络位置 +- **可用率与故障时间**:这本身就是状态页要公开的信息 +- **GitHub Actions 场景**:CI 读不到未提交的 `local.monitors.json`,需用 repository secrets 在 workflow 中生成,或只监控公开目标 + +--- + +## 四、其他基线 + +- `.gitignore` 已包含 `local.monitors.json` 与 `.dev.vars`(Cloudflare 本地密钥) +- Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml` +- 面板为只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身 +- Node 探针同时充当静态服务器,**不要直接暴露公网**;确实需要时在其前方加带认证的反向代理,并设 `allowRefresh: false` diff --git a/docs/screenshot-demo.png b/docs/screenshot-demo.png new file mode 100644 index 0000000..f7b2f35 Binary files /dev/null and b/docs/screenshot-demo.png differ diff --git a/docs/screenshot-desktop.png b/docs/screenshot-desktop.png new file mode 100644 index 0000000..f81bf09 Binary files /dev/null and b/docs/screenshot-desktop.png differ diff --git a/docs/screenshot-mobile.png b/docs/screenshot-mobile.png new file mode 100644 index 0000000..2a09e12 Binary files /dev/null and b/docs/screenshot-mobile.png differ diff --git a/docs/screenshot-uptimerobot.png b/docs/screenshot-uptimerobot.png new file mode 100644 index 0000000..68db2df Binary files /dev/null and b/docs/screenshot-uptimerobot.png differ diff --git a/docs/screenshot-view-cards.png b/docs/screenshot-view-cards.png new file mode 100644 index 0000000..f2eeb1a Binary files /dev/null and b/docs/screenshot-view-cards.png differ diff --git a/docs/screenshot-view-table.png b/docs/screenshot-view-table.png new file mode 100644 index 0000000..f81bf09 Binary files /dev/null and b/docs/screenshot-view-table.png differ diff --git a/edge/cloudflare/worker.js b/edge/cloudflare/worker.js new file mode 100644 index 0000000..561ff91 --- /dev/null +++ b/edge/cloudflare/worker.js @@ -0,0 +1,221 @@ +/** + * shiroko-monitor 边缘探针(Cloudflare Workers) + * + * - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets) + * - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB) + * - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源 + * + * 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划; + * 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。 + */ + +import { connect } from 'cloudflare:sockets'; + +const VERSION = '0.6.0'; +const HISTORY_LIMIT = 48; +const UPTIME_DAYS = 90; +const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)'; + +// 边缘端按 UTC 日期分桶(Worker 无本地时区概念) +function dayKey(d = new Date()) { + return d.toISOString().slice(0, 10); +} + +// 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序 +function pruneDaily(daily) { + const keys = Object.keys(daily).sort(); + while (keys.length > UPTIME_DAYS) delete daily[keys.shift()]; +} + +export default { + async scheduled(event, env, ctx) { + ctx.waitUntil(runChecksDedup(env)); + }, + + async fetch(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname === '/api/status') return json(await getStatus(env, ctx)); + if (url.pathname === '/api/monitors') return json(await getMonitors(env, request.url)); + if (url.pathname === '/api/refresh') { + // 公网上的强制探测必须显式开启,避免被任意访客当作「探测放大器」使用 + const gate = refreshGate(env, request); + if (!gate.ok) return json({ error: gate.error }, { status: gate.status }); + return json(await runChecksDedup(env)); + } + return env.ASSETS.fetch(request); + }, +}; + +// 未配置 REFRESH_TOKEN 时一律拒绝(wrangler secret put REFRESH_TOKEN 开启); +// 比较采用等长逐字节异或,避免按字符提前返回带来的时序差异 +function refreshGate(env, request) { + const token = env.REFRESH_TOKEN; + if (!token) return { ok: false, status: 403, error: 'refresh-disabled' }; + const url = new URL(request.url); + const provided = request.headers.get('x-refresh-token') || url.searchParams.get('token') || ''; + if (provided.length !== token.length) return { ok: false, status: 403, error: 'refresh-forbidden' }; + let diff = 0; + for (let i = 0; i < token.length; i++) diff |= provided.charCodeAt(i) ^ token.charCodeAt(i); + return diff === 0 ? { ok: true } : { ok: false, status: 403, error: 'refresh-forbidden' }; +} + +async function probeMonitor(m) { + if (m.type === 'tcp') return probeTcp(m); + // 边缘端无原始 socket 读取能力(sockets API 需付费版且未覆盖测试),banner 类型请使用本地探针 + if (m.type === 'banner') return { status: 'down', latency: 0, code: null, detail: '边缘端暂不支持 banner 探测(请用本地探针)' }; + return probeHttp(m); +} + +// 同隔离体内共享同一轮探测,避免并发刷新对目标重复施压(跨隔离体为 best-effort) +let inFlightRound = null; + +function runChecksDedup(env) { + if (!inFlightRound) { + inFlightRound = runChecks(env).finally(() => { + inFlightRound = null; + }); + } + return inFlightRound; +} + +function json(data, init = {}) { + return new Response(JSON.stringify(data), { + ...init, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...(init.headers ?? {}) }, + }); +} + +async function getMonitors(env, baseUrl) { + const raw = await env.MONITOR_KV.get('monitors'); + if (raw) return JSON.parse(raw); + // 未手动写入 KV 时,直接从静态资产读取仓库根目录的 monitors.json,实现零配置部署 + const res = await env.ASSETS.fetch(new URL('/monitors.json', baseUrl)); + if (res.ok) return res.json(); + return { settings: {}, monitors: [] }; +} + +async function getStatus(env, ctx) { + const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {}, generatedAt: null }; + if (!state.generatedAt) ctx.waitUntil(runChecks(env)); + return state; +} + +async function probeHttp(m) { + const started = Date.now(); + const timeoutMs = (m.timeout ?? 10) * 1000; + try { + // redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定 + const res = await fetch(m.url, { + method: m.method || 'GET', + redirect: 'manual', + signal: AbortSignal.timeout(timeoutMs), + headers: { 'user-agent': USER_AGENT, accept: '*/*' }, + }); + const latency = Date.now() - started; + const expect = m.expectStatus ?? [200]; + const ok = expect.includes(res.status); + return { + status: ok ? 'up' : 'down', + latency, + code: res.status, + detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`, + }; + } catch (err) { + const latency = Date.now() - started; + return { + status: 'down', + latency, + code: 0, + detail: err.name === 'TimeoutError' ? `请求超时(>${timeoutMs / 1000}s)` : String(err.cause?.code || err.message), + }; + } +} + +async function probeTcp(m) { + const started = Date.now(); + const timeoutMs = (m.timeout ?? 10) * 1000; + try { + const socket = connect({ hostname: m.host, port: m.port }); + const timer = setTimeout(() => socket.close(), timeoutMs); + await Promise.race([ + socket.opened, + new Promise((_, reject) => setTimeout(() => reject(new Error('TCP 连接超时')), timeoutMs)), + ]); + clearTimeout(timer); + socket.close(); + return { status: 'up', latency: Date.now() - started, code: null, detail: `TCP ${m.host}:${m.port} 可连接` }; + } catch (err) { + return { status: 'down', latency: Date.now() - started, code: null, detail: String(err?.message || err) }; + } +} + +async function runChecks(env) { + const config = await getMonitors(env, new URL('http://placeholder/')); + const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {} }; + const monitors = state.monitors ?? {}; + const redact = config.settings?.redactDetails === true; + let up = 0; + let down = 0; + let pending = 0; + let latencySum = 0; + let latencyCount = 0; + + await Promise.all( + (config.monitors ?? []).map(async (m) => { + const prev = monitors[m.id]; + const r = await probeMonitor(m); + r.checkedAt = new Date().toISOString(); + const history = [...(prev?.history ?? []), { t: r.checkedAt, ok: r.status === 'up', ms: r.latency ?? 0 }].slice(-HISTORY_LIMIT); + + const daily = { ...(prev?.daily ?? {}) }; + const key = dayKey(); + const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 }); + bucket.total++; + if (r.status === 'up') bucket.up++; + pruneDaily(daily); + + monitors[m.id] = { + status: r.status, + latency: r.latency, + code: r.code, + detail: redact ? (r.status === 'up' ? '正常' : '异常') : r.detail, + lastCheck: r.checkedAt, + history, + daily, + }; + if (r.status === 'up') { + up++; + latencySum += r.latency ?? 0; + latencyCount++; + } else { + down++; + } + }) + ); + + let uptimeUp = 0; + let uptimeTotal = 0; + for (const m of Object.values(monitors)) { + for (const bucket of Object.values(m.daily ?? {})) { + uptimeTotal += bucket.total ?? 0; + uptimeUp += bucket.up ?? 0; + } + } + + const next = { + prober: `shiroko-monitor-worker/${VERSION}`, + demo: false, + generatedAt: new Date().toISOString(), + stats: { + total: (config.monitors ?? []).length, + up, + down, + pending, + avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null, + avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null, + }, + monitors, + }; + await env.MONITOR_KV.put('state', JSON.stringify(next)); + return next; +} diff --git a/edge/cloudflare/wrangler.toml b/edge/cloudflare/wrangler.toml new file mode 100644 index 0000000..596c33c --- /dev/null +++ b/edge/cloudflare/wrangler.toml @@ -0,0 +1,14 @@ +name = "shiroko-monitor" +main = "worker.js" +compatibility_date = "2025-09-01" + +[triggers] +crons = ["* * * * *"] + +[[kv_namespaces]] +binding = "MONITOR_KV" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" + +[assets] +directory = "../../public" +binding = "ASSETS" diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad02f99 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "shiroko-monitor", + "version": "0.1.0", + "description": "轻量级服务监控面板:纯静态前端 + Node/边缘探针,支持 HTTP 与 TCP 探测", + "type": "module", + "scripts": { + "start": "node prober/node/index.js", + "probe": "node prober/node/index.js --once", + "demo": "node prober/node/index.js --once --demo" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/prober/node/index.js b/prober/node/index.js new file mode 100644 index 0000000..389f50d --- /dev/null +++ b/prober/node/index.js @@ -0,0 +1,746 @@ +#!/usr/bin/env node +/** + * shiroko-monitor 本地探针 + 面板服务器(零依赖,Node >= 18) + * + * 模式: + * 默认 常驻巡检:按各 monitor 的 interval 周期探测,结果写入 public/status.json,同时伺服面板 + * --once 执行一轮探测后退出(供 GitHub Actions 等 CI 使用,历史心率从已有 status.json 恢复) + * --demo 生成一份演示状态数据后退出(用于静态部署的首版占位) + * --out= 指定快照输出路径,默认 public/status.json(可避免演示数据覆盖真实快照) + * --config= / --local-config= 指定公开配置与私有覆盖的路径(多实例、测试用) + * + * 私有配置:若存在 local.monitors.json(已在 .gitignore 中),其 monitors 按 id 覆盖公开配置, + * 用于把真实地址/端口从会公开的 public/monitors.json 中拆出去。 + */ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { probeHttp } from './lib/http-probe.js'; +import { probeTcp } from './lib/tcp-probe.js'; +import { probeBanner } from './lib/banner-probe.js'; +import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const PUBLIC_DIR = path.join(ROOT, 'public'); +const CONFIG_PATH = path.join(PUBLIC_DIR, 'monitors.json'); +// 私有配置(不入库):用于把真实地址/端口从公开的 monitors.json 中拆出去 +const LOCAL_CONFIG_PATH = path.join(ROOT, 'local.monitors.json'); +const STATUS_PATH = path.join(PUBLIC_DIR, 'status.json'); + +const HISTORY_LIMIT = 48; +const UPTIME_DAYS = 90; +const CONCURRENCY = 8; +const WRITE_DEBOUNCE_MS = 300; +const DEFAULT_INTERVAL_SEC = 60; +const PORT = Number(process.env.PORT || 8787); +const VERSION = '0.6.0'; +const UR_DEFAULT_POLL_SEC = 300; +const UR_MIN_REFRESH_GAP_MS = 60000; + +const argv = process.argv.slice(2); +const ONCE = argv.includes('--once'); +const DEMO = argv.includes('--demo'); + +// --out= 或 --out :指定快照输出路径(默认 public/status.json) +function argValue(name) { + const inline = argv.find((a) => a.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const idx = argv.indexOf(name); + return idx >= 0 && argv[idx + 1] && !argv[idx + 1].startsWith('--') ? argv[idx + 1] : null; +} +const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH); +// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖) +const CONFIG_OVERRIDE = argValue('--config'); +const LOCAL_CONFIG_OVERRIDE = argValue('--local-config'); +const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH; +const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', +}; + +function loadConfig() { + const pub = JSON.parse(fs.readFileSync(ACTIVE_CONFIG_PATH, 'utf8')); + const local = loadLocalOverlay(); + if (!local) return pub; + // 私有配置按 id 覆盖公开条目(前者可只写 url/host/port 等敏感字段),settings 同样按需覆盖 + const byId = new Map((local.monitors ?? []).map((m) => [m.id, m])); + return { + ...pub, + settings: { ...pub.settings, ...(local.settings ?? {}) }, + monitors: (pub.monitors ?? []).map((m) => ({ ...m, ...(byId.get(m.id) ?? {}) })), + }; +} + +function loadLocalOverlay() { + try { + return JSON.parse(fs.readFileSync(ACTIVE_LOCAL_CONFIG_PATH, 'utf8')); + } catch { + return null; + } +} + +function loadPersistedStatus() { + try { + return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8')); + } catch { + return null; + } +} + +function dayKey(d = new Date()) { + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +// 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序 +function pruneDaily(daily) { + const keys = Object.keys(daily).sort(); + while (keys.length > UPTIME_DAYS) delete daily[keys.shift()]; +} + +function recordDaily(daily, ok) { + const key = dayKey(); + const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 }); + bucket.total++; + if (ok) bucket.up++; + pruneDaily(daily); +} + +function intervalMs(monitor) { + const sec = Number(monitor.interval); + // 非法或缺失的 interval 一律回落到默认值,避免算出 NaN 让该目标永久不再被调度 + return Math.max(5, Number.isFinite(sec) && sec > 0 ? sec : DEFAULT_INTERVAL_SEC) * 1000; +} + +/* ---------- UptimeRobot 只读数据源(异地探测视角) ---------- */ + +// API Key 建议放 local.monitors.json(不入库)或环境变量 UPTIMEROBOT_API_KEY;两者都无则视为未启用 +function uptimeRobotConfig(config) { + const ur = config.settings?.uptimeRobot ?? {}; + const apiKey = String(ur.apiKey || process.env.UPTIMEROBOT_API_KEY || '').trim(); + const ratios = Array.isArray(ur.ratios) && ur.ratios.length ? ur.ratios.map(Number).filter(Number.isFinite) : [1, 7, 30, 90]; + return { + enabled: ur.enabled === true && Boolean(apiKey), + apiKey, + pollIntervalSec: Math.max(60, Number(ur.pollIntervalSec) || UR_DEFAULT_POLL_SEC), + group: ur.group || 'UptimeRobot', + includeTypes: Array.isArray(ur.includeTypes) && ur.includeTypes.length ? ur.includeTypes.map(Number) : null, + ratios: ratios.length ? ratios : [1, 7, 30, 90], + }; +} + +// UR 侧不探测,只做「拉取 + 映射」;失败时保留上一次数据,绝不因为读不到 UR 就把服务判为离线 +async function syncUptimeRobot(runtime, { minGapMs = 0 } = {}) { + const ur = uptimeRobotConfig(runtime.config); + runtime.ur.enabled = ur.enabled; + if (!ur.enabled || runtime.ur.syncing) return; + // minGapMs 用于「面板手动刷新时顺带同步」的场景,避免被反复点击打爆 UR 接口 + if (minGapMs && Date.now() - runtime.ur.lastSyncMs < minGapMs) return; + runtime.ur.lastSyncMs = Date.now(); + runtime.ur.syncing = true; + try { + let list = await fetchUptimeRobotMonitors({ apiKey: ur.apiKey, ratios: ur.ratios }); + if (ur.includeTypes) list = list.filter((m) => ur.includeTypes.includes(m.sourceType)); + const seen = new Set(); + for (const item of list) { + seen.add(item.id); + let s = runtime.state.get(item.id); + if (!s) { + s = { monitor: item, nextDue: 0, inFlight: false, lastResult: null, history: [], daily: {} }; + runtime.state.set(item.id, s); + } + s.monitor = item; + s.lastResult = { + status: item.status, + latency: item.latency, + code: item.code, + detail: item.detail, + checkedAt: item.lastCheckIso, + }; + s.history = item.history; + s.uptimeRatios = item.uptimeRatios; + s.downSeconds = item.downSeconds; + if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up'); + } + // 清理 UR 侧已删除的监控 + for (const [id, s] of runtime.state) { + if (s.monitor?.source === 'uptimerobot' && !seen.has(id)) runtime.state.delete(id); + } + runtime.ur.ok = true; + runtime.ur.error = null; + runtime.ur.count = list.length; + runtime.ur.lastSyncAt = new Date().toISOString(); + markDataUpdated(runtime); + console.log(`[uptimerobot] 同步完成:${list.length} 个监控(在线 ${list.filter((m) => m.status === 'up').length})`); + } catch (err) { + runtime.ur.ok = false; + runtime.ur.error = err.message; + console.error(`[uptimerobot] 同步失败(保留上次数据):${err.message}`); + } finally { + runtime.ur.syncing = false; + } +} + +// 恢复 lastResult / history / daily:优先复用内存态(配置热重载),否则读持久化的 status.json(重启) +function hydrate(config, prevState = null) { + const persisted = prevState ? null : loadPersistedStatus(); + const state = new Map(); + for (const m of config.monitors) { + const own = prevState?.get(m.id); + const saved = own + ? { + status: own.lastResult?.status, + latency: own.lastResult?.latency, + code: own.lastResult?.code, + detail: own.lastResult?.detail, + lastCheck: own.lastResult?.checkedAt, + history: own.history, + daily: own.daily, + } + : persisted?.monitors?.[m.id]; + state.set(m.id, { + monitor: m, + nextDue: 0, + inFlight: false, + lastResult: saved?.status && saved.status !== 'pending' + ? { status: saved.status, latency: saved.latency, code: saved.code, detail: saved.detail, checkedAt: saved.lastCheck } + : null, + history: Array.isArray(saved?.history) ? saved.history.slice(-HISTORY_LIMIT) : [], + daily: saved?.daily && typeof saved.daily === 'object' ? { ...saved.daily } : {}, + // UR 侧的聚合可用率(用于在逐日分桶填满前也能显示官方数字) + uptimeRatios: own?.uptimeRatios ?? saved?.uptimeRatios ?? null, + downSeconds: own?.downSeconds ?? saved?.downSeconds ?? null, + }); + } + // UptimeRobot 镜像目标不在 config 里,热重载时需整体保留,否则会丢掉已累积的逐日分桶 + if (prevState) { + for (const [id, s] of prevState) { + if (s.monitor?.source === 'uptimerobot') state.set(id, s); + } + } + return state; +} + +async function probeOne(monitor) { + let result; + if (monitor.type === 'tcp') result = await probeTcp(monitor); + else if (monitor.type === 'banner') result = await probeBanner(monitor); + else if (monitor.type === 'http') result = await probeHttp(monitor); + else return { status: 'down', latency: 0, code: null, detail: `不支持的探测类型:${monitor.type}`, checkedAt: new Date().toISOString() }; + result.checkedAt = new Date().toISOString(); + return result; +} + +async function mapLimit(items, limit, fn) { + let idx = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (idx < items.length) { + const i = idx++; + await fn(items[i], i); + } + }); + await Promise.all(workers); +} + +async function probeDue(runtime, force = false) { + const { config, state } = runtime; + const now = Date.now(); + const due = []; + for (const m of config.monitors) { + const s = state.get(m.id); + if (!s || s.inFlight) continue; + // UptimeRobot 镜像目标由其自身节点探测,本探针只拉取结果 + if (m.type === 'ur' || m.source === 'uptimerobot') continue; + // 目标可单独退出「刷新即检测」(如有限流的 API),只按自身 interval 探测 + if (force && m.probeOnRefresh === false) continue; + if (!force && s.nextDue > now) continue; + // 先同步占位再探测:否则慢目标(如 10s 超时)尚未返回时会被下一秒的 tick 重复调度,形成探测风暴 + s.inFlight = true; + s.nextDue = Date.now() + intervalMs(m); + due.push(m); + } + await mapLimit(due, CONCURRENCY, async (m) => { + const s = state.get(m.id); + const task = (async () => { + try { + const result = await probeOne(m); + s.lastResult = result; + s.history.push({ t: result.checkedAt, ok: result.status === 'up', ms: result.latency ?? 0 }); + if (s.history.length > HISTORY_LIMIT) s.history.shift(); + recordDaily(s.daily, result.status === 'up'); + const tag = result.status === 'up' ? '[up] ' : '[down]'; + console.log(`${tag} ${m.id.padEnd(20)} ${String(result.latency ?? '-').padStart(5)}ms ${result.detail}`); + } catch (err) { + console.error(`[probe] ${m.id} 探测异常:${err.message}`); + } finally { + s.inFlight = false; + s.inFlightPromise = null; + } + })(); + s.inFlightPromise = task; + await task; + }); + if (due.length) markDataUpdated(runtime); +} + +// 等待在途探测收尾(强制刷新前调用,避免"正在探测中"的目标被跳过而显示旧数据) +async function settleInflight(state) { + const pending = [...state.values()].map((s) => s.inFlightPromise).filter(Boolean); + if (pending.length) await Promise.allSettled(pending); +} + +// 探针自报内存占用(MB):便于观察这个「监控器」自身的资源开销 +function memoryStats() { + const m = process.memoryUsage(); + const mb = (v) => Math.round((v / 1048576) * 10) / 10; + return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) }; +} + +/* ---------- 数据源过滤 ---------- */ + +// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测 +function dataFilter(config) { + const f = config.settings?.dataFilter; + return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both'; +} + +/* ---------- 服务端更新节奏 ---------- */ + +// 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端, +// 而不是从用户打开页面那一刻重新计时(否则每次进来都要重等一整个周期,最坏会看到近两倍周期的旧数据) +function markDataUpdated(runtime) { + runtime.dataUpdatedAt = Date.now(); +} + +// 当前展示集合下,服务端多久产出一次新数据:取各目标 interval 与 UR 拉取间隔的最小值 +function updateIntervalSec(config, filter) { + const intervals = []; + if (filter !== 'uptimerobot') { + for (const m of config.monitors) { + const n = Number(m.interval); + if (Number.isFinite(n) && n > 0) intervals.push(n); + } + } + if (filter !== 'config') { + const ur = uptimeRobotConfig(config); + if (ur.enabled) intervals.push(ur.pollIntervalSec); + } + return Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : DEFAULT_INTERVAL_SEC)); +} + +function snapshot(runtime, demo = false) { + const { config, state } = runtime; + // redactDetails:公开部署时可只输出「正常/异常」,避免 detail 中的版本、地址等细节被公开读取 + const redact = config.settings?.redactDetails === true; + const filter = dataFilter(config); + const hideUrls = config.settings?.hideTargets === true; + const includeConfig = filter !== 'uptimerobot'; + const includeExternal = filter !== 'config'; + const monitors = {}; + let up = 0; + let down = 0; + let pending = 0; + let latencySum = 0; + let latencyCount = 0; + let uptimeUp = 0; + let uptimeTotal = 0; + + // 配置里的探测目标 + UptimeRobot 镜像目标(后者只存在于 state 中,由同步产生) + const entries = []; + if (includeConfig) for (const m of config.monitors) entries.push([m.id, m, state.get(m.id)]); + if (includeExternal) { + for (const [id, s] of state) { + if (s.monitor?.source === 'uptimerobot') entries.push([id, s.monitor, s]); + } + } + + for (const [id, m, s] of entries) { + const r = s?.lastResult; + const isUr = m.source === 'uptimerobot'; + if (!r) { + pending++; + monitors[id] = { status: 'pending', latency: null, code: null, detail: '等待检测', lastCheck: null, history: [], daily: s?.daily ?? {} }; + continue; + } + if (r.status === 'up') { + up++; + // 平均延迟只统计自建探针:UR 的延迟来自其探测节点,与本机视角不可比 + if (!isUr && r.latency != null) { + latencySum += r.latency; + latencyCount++; + } + } else if (r.status === 'down') { + down++; + } else { + pending++; + } + for (const bucket of Object.values(s.daily ?? {})) { + uptimeTotal += bucket.total ?? 0; + uptimeUp += bucket.up ?? 0; + } + const entry = { + status: r.status, + latency: r.latency, + code: r.code, + detail: redact ? (r.status === 'up' ? '正常' : r.status === 'down' ? '异常' : '等待检测') : r.detail, + lastCheck: r.checkedAt, + history: s.history, + daily: s.daily, + }; + if (isUr) { + // UR 镜像目标不在 config 中,需要把展示元信息随快照下发,面板据此合并渲染; + // hideTargets 开启时不下发地址:面板反正不渲染,公开的 status.json 里也就不该有 + entry.meta = { + name: m.name, + group: m.group, + type: 'ur', + url: hideUrls ? '' : m.url, + source: 'uptimerobot', + sourceLabel: m.sourceLabel, + interval: m.interval, + }; + entry.uptimeRatios = s.uptimeRatios ?? null; + entry.downSeconds = s.downSeconds ?? null; + } + monitors[id] = entry; + } + + return { + prober: `shiroko-monitor-node/${VERSION}`, + demo, + generatedAt: new Date().toISOString(), + // 数据最后变更时刻 + 服务端更新节奏:面板据此把刷新对齐到服务端,而不是每访客重新计时 + dataUpdatedAt: new Date(runtime.dataUpdatedAt ?? Date.now()).toISOString(), + updateIntervalSec: updateIntervalSec(config, filter), + // 让面板与服务端共用同一份过滤口径,避免私有配置覆盖后两边不一致 + dataFilter: filter, + stats: { + total: entries.length, + up, + down, + pending, + avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null, + avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null, + memory: memoryStats(), + }, + sources: { uptimerobot: { ...(runtime.ur ?? {}) } }, + monitors, + }; +} + +function writeStatus(data) { + const payload = `${JSON.stringify(data, null, 2)}\n`; + const tmp = `${OUT_PATH}.tmp`; + try { + fs.writeFileSync(tmp, payload); + // 原子替换:避免面板读到写了一半的 JSON + fs.renameSync(tmp, OUT_PATH); + } catch { + // Windows 下 rename 可能因目标文件被短暂占用而失败(EPERM,常见于杀软扫描), + // 退化为直接写入;监控探针绝不能因为一次写文件抖动而整个进程退出 + try { + fs.writeFileSync(OUT_PATH, payload); + fs.rmSync(tmp, { force: true }); + } catch (err) { + console.error(`[write] status.json 写入失败:${err.message}`); + } + } +} + +function scheduleWrite(runtime) { + if (scheduleWrite.timer) return; + scheduleWrite.timer = setTimeout(() => { + scheduleWrite.timer = null; + writeStatus(snapshot(runtime)); + }, WRITE_DEBOUNCE_MS); +} + +// 刷新即重新检测:多客户端/多标签页同时刷新时共享同一轮探测,避免对目标重复施压; +// 间隔过近的重复请求直接复用当前快照,只有真正需要时才新起一轮 +const FORCED_MIN_GAP_MS = 2000; +// 兜底上限:正常情况下轮次在目标超时内完成;若有 socket 卡死,也不让请求无限挂起 +const FORCED_MAX_WAIT_MS = 22000; + +function forceRound(runtime) { + if (runtime.forced) return runtime.forced; + if (runtime.forcedAt && Date.now() - runtime.forcedAt < FORCED_MIN_GAP_MS) return Promise.resolve(); + runtime.forced = (async () => { + try { + // 先等在途探测收尾再统一探测,保证返回时每个目标都是本轮的新结果 + await settleInflight(runtime.state); + await probeDue(runtime, true); + await settleInflight(runtime.state); + runtime.forcedAt = Date.now(); + scheduleWrite(runtime); + } catch (err) { + console.error(`[refresh] 强制探测异常:${err.message}`); + } finally { + runtime.forced = null; + } + })(); + return runtime.forced; +} + +function writeDemo(config) { + if (OUT_PATH === STATUS_PATH && fs.existsSync(STATUS_PATH) && !loadPersistedStatus()?.demo) { + console.warn('[demo] 目标已是真实快照,将被演示数据覆盖(如不希望覆盖,请用 --out= 指定其他输出路径)'); + } + const state = hydrate(config); + for (const m of config.monitors) { + const s = state.get(m.id); + // 过期证书目标在演示数据中固定为离线,其余按 id 哈希产生稳定的伪随机结果 + const seed = [...m.id].reduce((a, c) => a + c.charCodeAt(0), 0); + const alwaysDown = m.id.includes('badssl'); + const history = Array.from({ length: HISTORY_LIMIT }, (_, i) => { + const ok = alwaysDown ? false : (seed + i * 7) % 23 !== 0; + return { t: new Date(Date.now() - (HISTORY_LIMIT - i) * (m.interval ?? 60) * 1000).toISOString(), ok, ms: ok ? 30 + ((seed * (i + 3)) % 240) : 0 }; + }); + const lastOk = history[history.length - 1].ok; + s.lastResult = { + status: lastOk ? 'up' : 'down', + latency: lastOk ? history[history.length - 1].ms : 0, + code: m.type === 'tcp' ? null : lastOk ? 200 : 0, + detail: m.type === 'tcp' + ? (lastOk ? '端口可连接' : '连接被拒绝(端口关闭)') + : m.type === 'banner' + ? (lastOk ? `${m.protocol ?? 'BANNER'} 响应正常` : '标识串与期望规则不符') + : lastOk ? 'HTTP 200' : 'CERT_HAS_EXPIRED', + checkedAt: history[history.length - 1].t, + }; + s.history = history; + + // 90 天可用率演示数据:多数日期满分,少量日期按档位抖落,便于观察分级配色 + const perDay = Math.max(1, Math.round(86400 / (Number(m.interval) > 0 ? Number(m.interval) : DEFAULT_INTERVAL_SEC))); + const daily = {}; + for (let d = UPTIME_DAYS - 1; d >= 0; d--) { + const key = dayKey(new Date(Date.now() - d * 86400000)); + const missRatio = alwaysDown ? 1 : (seed + d * 13) % 23 === 0 ? 0.05 : (seed + d * 7) % 11 === 0 ? 0.012 : 0; + const miss = Math.round(perDay * missRatio); + daily[key] = { total: perDay, up: perDay - miss }; + } + s.daily = daily; + } + writeStatus(snapshot({ config, state }, true)); + console.log(`[demo] 演示状态已写入 ${path.relative(ROOT, OUT_PATH) || OUT_PATH}`); +} + +function sendJson(res, data, { status = 200, headers = {} } = {}) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers }); + res.end(JSON.stringify(data)); +} + +/* ---------- /api/refresh 滥用防护 ---------- */ + +// 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false +export function isPrivateAddress(addr) { + const ip = String(addr || '').replace(/^::ffff:/i, ''); + if (ip === '::1' || ip.startsWith('127.')) return true; + if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; + if (/^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip)) return true; + return false; +} + +const refreshHits = new Map(); +const DEFAULT_COOLDOWN_SEC = 5; + +function refreshDecision(config, ip) { + const s = config.settings ?? {}; + if (s.allowRefresh === false) return { ok: false, status: 403, error: 'refresh-disabled' }; + if (s.refreshLocalOnly !== false && !isPrivateAddress(ip)) return { ok: false, status: 403, error: 'refresh-local-only' }; + const cooldown = Math.max(1, Number(s.refreshCooldownSec) || DEFAULT_COOLDOWN_SEC) * 1000; + const wait = (refreshHits.get(ip) ?? 0) + cooldown - Date.now(); + if (wait > 0) return { ok: false, status: 429, error: 'refresh-throttled', retryAfter: Math.ceil(wait / 1000) }; + refreshHits.set(ip, Date.now()); + // 简单清理,避免长期运行下 Map 无界增长 + const expire = Date.now() - 10 * 60 * 1000; + for (const [k, ts] of refreshHits) if (ts < expire) refreshHits.delete(k); + return { ok: true }; +} + +/* ---------- UptimeRobot 兼容输出(只读) ---------- */ + +// 把内部快照映射成 getMonitors 的响应形状,便于既有 UptimeRobot 生态的脚本/看板直接复用本服务数据 +function toUptimeRobotResponse(runtime) { + const snap = snapshot(runtime); + const cfgById = new Map(runtime.config.monitors.map((m) => [m.id, m])); + // 字符串 id → 稳定数字 id(UptimeRobot 侧 id 为数字) + const numericId = (id) => { + let h = 0; + for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 1e9; + return h; + }; + const ratio = (st, days) => { + const buckets = Object.entries(st.daily ?? {}).sort().slice(-days); + const total = buckets.reduce((a, [, b]) => a + (b.total ?? 0), 0); + const upCount = buckets.reduce((a, [, b]) => a + (b.up ?? 0), 0); + if (total) return (upCount / total) * 100; + // 无逐日数据时回退到数据源给出的聚合比率(UptimeRobot 镜像目标自带) + const provided = st.uptimeRatios?.[days]; + return Number.isFinite(provided) ? provided : 0; + }; + const monitors = Object.entries(snap.monitors).map(([id, st]) => { + const cfg = cfgById.get(id); + const isUr = (st.meta?.source ?? cfg?.source) === 'uptimerobot'; + const name = st.meta?.name ?? cfg?.name ?? id; + const url = cfg ? (cfg.type === 'http' ? cfg.url : `${cfg.host}:${cfg.port}`) : st.meta?.url ?? ''; + const portLike = isUr ? st.meta?.sourceLabel === 'PORT' : cfg?.type !== 'http'; + return { + id: numericId(id), + friendly_name: name, + url, + type: portLike ? 4 : 1, + status: st.status === 'up' ? 2 : st.status === 'down' ? 9 : 1, + interval: cfg?.interval ?? st.meta?.interval ?? DEFAULT_INTERVAL_SEC, + custom_uptime_ratio: [1, 7, 30, 90].map((d) => ratio(st, d).toFixed(3)).join('-'), + average_response_time: st.latency ?? 0, + response_times: (st.history ?? []).slice(-HISTORY_LIMIT).map((h) => ({ datetime: Math.floor(new Date(h.t).getTime() / 1000), value: h.ms })), + }; + }); + return { stat: 'ok', pagination: { offset: 0, limit: 50, total: monitors.length }, monitors }; +} + +// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用 +function createServer(runtime) { + return http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const { config, state } = runtime; + try { + if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime)); + if (url.pathname === '/api/monitors') return sendJson(res, config); + if (url.pathname === '/api/refresh') { + const decide = refreshDecision(config, req.socket.remoteAddress); + if (!decide.ok) { + // 只回错误码,不回显目标地址与内部细节 + return sendJson(res, { error: decide.error }, { + status: decide.status, + headers: decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {}, + }); + } + // scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用) + const scope = url.searchParams.get('scope'); + if (scope !== 'external') { + // 等本轮探测完成再返回快照,避免面板刷新后读到的仍是旧数据;上限兜底防止个别目标卡死请求 + await Promise.race([forceRound(runtime), new Promise((resolve) => setTimeout(resolve, FORCED_MAX_WAIT_MS))]); + } + // 顺带同步 UptimeRobot(至少间隔 60s,尊重其接口限流),让「只看 UR」模式下的刷新同样有意义; + // 再等 6s 上限,避免 UR 接口慢把整体刷新拖过面板超时 + await Promise.race([ + syncUptimeRobot(runtime, { minGapMs: UR_MIN_REFRESH_GAP_MS }), + new Promise((resolve) => setTimeout(resolve, 6000)), + ]); + // 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应 + if (res.writableEnded || res.destroyed) return; + return sendJson(res, snapshot(runtime)); + } + + // UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。 + // 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。 + if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') { + return sendJson(res, toUptimeRobotResponse(runtime)); + } + + let rel = decodeURIComponent(url.pathname); + if (rel === '/') rel = '/index.html'; + const file = path.join(PUBLIC_DIR, path.normalize(rel)); + if (!file.startsWith(PUBLIC_DIR)) { + res.writeHead(403); + return res.end('Forbidden'); + } + if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + return res.end('Not Found'); + } + const ext = path.extname(file).toLowerCase(); + const live = rel === '/status.json' || rel === '/monitors.json'; + res.writeHead(200, { + 'content-type': MIME[ext] || 'application/octet-stream', + 'cache-control': live ? 'no-store' : 'public, max-age=300', + }); + fs.createReadStream(file).pipe(res); + } catch (err) { + try { + res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Internal Error'); + } catch { + // 连接已被对端关闭,忽略写入失败(避免未捕获异常终止探针进程) + } + } + }); +} + +async function main() { + const runtime = { + config: loadConfig(), + state: null, + dataUpdatedAt: Date.now(), + ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 }, + }; + if (DEMO) { + writeDemo(runtime.config); + return; + } + runtime.state = hydrate(runtime.config); + // 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据 + await syncUptimeRobot(runtime); + + if (ONCE) { + await probeDue(runtime, true); + writeStatus(snapshot(runtime)); + const s = snapshot(runtime).stats; + console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`); + return; + } + + let reloadTimer = null; + fs.watch(ACTIVE_CONFIG_PATH, () => { + clearTimeout(reloadTimer); + reloadTimer = setTimeout(() => { + try { + const next = loadConfig(); + // 复用当前内存态恢复 history/daily;仅替换持有器属性,不再产生游离的旧引用 + runtime.state = hydrate(next, runtime.state); + runtime.config = next; + console.log('[config] monitors.json 已热重载'); + scheduleWrite(runtime); + } catch (err) { + console.error(`[config] 重载失败:${err.message}`); + } + }, 200); + }); + + setInterval(() => { + probeDue(runtime) + .then(() => scheduleWrite(runtime)) + .catch((err) => console.error(`[tick] ${err.stack || err}`)); + // UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检 + if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) { + syncUptimeRobot(runtime) + .then(() => scheduleWrite(runtime)) + .catch(() => { /* syncUptimeRobot 内部已记录错误 */ }); + } + }, 1000); + + createServer(runtime).listen(PORT, () => { + console.log(`shiroko-monitor v${VERSION}`); + console.log(`面板: http://localhost:${PORT}/`); + console.log(`状态 API: http://localhost:${PORT}/api/status`); + console.log(`兼容接口: http://localhost:${PORT}/api/v2/getMonitors (UptimeRobot getMonitors 只读兼容)`); + console.log(`监控目标: ${runtime.config.monitors.length} 个自建探测${runtime.ur.enabled ? ` + ${runtime.ur.count} 个 UptimeRobot 监控` : ''}(配置热重载已启用)`); + }); +} + +// 直接执行(node index.js)时启动;被测试脚本 import 时不自动启动 +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/prober/node/lib/banner-probe.js b/prober/node/lib/banner-probe.js new file mode 100644 index 0000000..d2ddc08 --- /dev/null +++ b/prober/node/lib/banner-probe.js @@ -0,0 +1,64 @@ +import net from 'node:net'; + +// 协议族识别只用于生成「SSH 响应正常」这类摘要;版本号等细节默认不回显,避免写入公开的 status.json +const FAMILIES = [ + { re: /^SSH-\d/i, name: 'SSH' }, + { re: /^220[\s\S]*ESMTP/i, name: 'SMTP' }, + { re: /^220[\s\S]*FTP/i, name: 'FTP' }, + { re: /^\+OK/i, name: 'POP3' }, + { re: /^\* OK/i, name: 'IMAP' }, + { re: /^HTTP\/\d/i, name: 'HTTP' }, + { re: /^220\b/i, name: 'SMTP/TCP' }, +]; + +/** + * 被动 banner 探测:连接后仅读取服务端主动发送的标识串(RFC 4253 的 SSH banner 即此类), + * 不做认证、不发送任何数据、不执行任何命令——因此不会触发目标侧的登录失败计数或封禁。 + * 需要客户端先发字节的协议(Redis/MySQL)不适用,属于路线图内容。 + */ +export function probeBanner(monitor) { + const started = Date.now(); + const timeoutMs = (monitor.timeout ?? 10) * 1000; + const maxBytes = Math.max(32, Number(monitor.maxBannerBytes) || 512); + return new Promise((resolve) => { + const socket = net.createConnection({ host: monitor.host, port: monitor.port }); + let raw = ''; + let settled = false; + + const done = (status, detail) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve({ status, latency: Date.now() - started, code: null, detail }); + }; + + const evaluate = () => { + const line = (raw.split(/\r?\n/)[0] ?? '').trim(); + const family = FAMILIES.find((f) => f.re.test(line))?.name ?? null; + if (monitor.expectBanner) { + let ok = false; + try { + ok = new RegExp(monitor.expectBanner, monitor.bannerFlags || 'i').test(line); + } catch { + return done('down', 'banner 规则无法编译(expectBanner 不是合法正则)'); + } + // 不匹配时只报告策略结果,不回显实际收到的标识串 + if (!ok) return done('down', '标识串与期望规则不符'); + } + const label = family ? `${family} 响应正常` : '收到服务标识串'; + return done('up', monitor.revealBanner ? `${label}:${line.slice(0, 80)}` : label); + }; + + socket.setTimeout(timeoutMs, () => done('down', '等待标识串超时')); + socket.once('error', (err) => + done('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message)) + ); + // 对端发完就断开(无换行结尾)也算有效响应 + socket.once('end', () => (raw ? evaluate() : done('down', '连接被对端关闭且未发送标识串'))); + // 只在收到换行或达到字节上限时判定,避免把分包到达的标识串截断 + socket.on('data', (chunk) => { + raw += chunk.toString('latin1'); + if (/[\r\n]/.test(raw) || raw.length >= maxBytes) evaluate(); + }); + }); +} diff --git a/prober/node/lib/http-probe.js b/prober/node/lib/http-probe.js new file mode 100644 index 0000000..68db0e6 --- /dev/null +++ b/prober/node/lib/http-probe.js @@ -0,0 +1,31 @@ +const USER_AGENT = 'shiroko-monitor/0.2 (lightweight uptime panel)'; + +export async function probeHttp(monitor) { + const started = Date.now(); + const timeoutMs = (monitor.timeout ?? 10) * 1000; + try { + // redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定,避免跟随重定向掩盖异常 + const res = await fetch(monitor.url, { + method: monitor.method || 'GET', + redirect: 'manual', + signal: AbortSignal.timeout(timeoutMs), + headers: { 'user-agent': USER_AGENT, accept: '*/*' }, + }); + const latency = Date.now() - started; + const expect = monitor.expectStatus ?? [200]; + const ok = expect.includes(res.status); + return { + status: ok ? 'up' : 'down', + latency, + code: res.status, + detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`, + }; + } catch (err) { + const latency = Date.now() - started; + let detail; + if (err.name === 'TimeoutError') detail = `请求超时(>${timeoutMs / 1000}s)`; + else if (err.cause?.code) detail = err.cause.code; + else detail = err.message; + return { status: 'down', latency, code: 0, detail }; + } +} diff --git a/prober/node/lib/tcp-probe.js b/prober/node/lib/tcp-probe.js new file mode 100644 index 0000000..d85f992 --- /dev/null +++ b/prober/node/lib/tcp-probe.js @@ -0,0 +1,18 @@ +import net from 'node:net'; + +export function probeTcp(monitor) { + const started = Date.now(); + const timeoutMs = (monitor.timeout ?? 10) * 1000; + return new Promise((resolve) => { + const socket = net.createConnection({ host: monitor.host, port: monitor.port }); + const finish = (status, detail) => { + socket.destroy(); + resolve({ status, latency: Date.now() - started, code: null, detail }); + }; + socket.setTimeout(timeoutMs, () => finish('down', 'TCP 连接超时')); + socket.once('error', (err) => + finish('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message)) + ); + socket.once('connect', () => finish('up', '端口可连接')); + }); +} diff --git a/prober/node/lib/uptimerobot.js b/prober/node/lib/uptimerobot.js new file mode 100644 index 0000000..b3283b8 --- /dev/null +++ b/prober/node/lib/uptimerobot.js @@ -0,0 +1,140 @@ +/** + * UptimeRobot 只读数据源(API v2) + * + * 作用:把 UptimeRobot 已有的监控结果接入本面板,作为「异地探测视角」与自建探针并列展示。 + * 只读:仅调用 getMonitors,不做任何写操作(暂停/编辑/删除),因此用 ur* 开头的只读 Key 即可。 + * + * 术语对齐(UptimeRobot v2): + * monitor.type 1=HTTP(s) 2=Keyword 3=Ping 4=Port + * monitor.status 0=暂停 1=未检测 2=正常 8/9=异常 + * logs[].type 1=故障 2=恢复 98=开始监控 99=暂停 + * logs[].reason { code: "403", detail: "Forbidden" } + * custom_uptime_ratio / custom_down_durations 按请求顺序以 '-' 分隔 + */ + +const API_URL = 'https://api.uptimerobot.com/v2/getMonitors'; +const HISTORY_LIMIT = 48; + +const TYPE_LABEL = { 1: 'HTTP', 2: 'KEYWORD', 3: 'PING', 4: 'PORT' }; + +// 暂停/未检测都归入 pending:否则会被误报成「离线」 +const STATUS_MAP = { 0: 'pending', 1: 'pending', 2: 'up', 8: 'down', 9: 'down' }; + +function splitNums(value) { + return String(value ?? '') + .split('-') + .map((v) => Number(v)) + .filter((v) => Number.isFinite(v)); +} + +function ratiosToObject(value, ratios) { + const nums = splitNums(value); + const out = {}; + ratios.forEach((r, i) => { + if (Number.isFinite(nums[i])) out[r] = nums[i]; + }); + return out; +} + +function targetOf(m) { + if (m.type === 4 && m.port) return `${m.url ? `${m.url}:` : ''}${m.port}`; + return m.url || ''; +} + +function latestDownReason(logs) { + const down = (logs ?? []).find((l) => l.type === 1); + const reason = down?.reason; + if (!reason) return null; + const code = String(reason.code ?? '').trim(); + const detail = String(reason.detail ?? '').trim(); + return { code: code || null, text: [code, detail].filter(Boolean).join(' ') || null }; +} + +/** + * 纯函数:把 UptimeRobot 的 getMonitors 响应映射成本探针的内部结构(可离线单测) + * @param {object} payload getMonitors 原始响应 + * @param {{group?: string, ratios?: number[], now?: number}} [options] + */ +export function mapUptimeRobotMonitors(payload, options = {}) { + const group = options.group || 'UptimeRobot'; + const ratios = options.ratios ?? [1, 7, 30, 90]; + const now = options.now ?? Date.now(); + const monitors = []; + + for (const m of payload?.monitors ?? []) { + const status = STATUS_MAP[m.status] ?? 'pending'; + const samples = Array.isArray(m.response_times) ? m.response_times : []; + const logs = Array.isArray(m.logs) ? m.logs : []; + // UR 侧的真实检测时刻:正常取最新响应样本,故障取最近一次故障日志 + const lastCheckSec = status === 'down' ? logs.find((l) => l.type === 1)?.datetime : samples[0]?.datetime; + const lastCheckIso = new Date((Number(lastCheckSec) || Math.floor(now / 1000)) * 1000).toISOString(); + + const history = samples + .slice(0, HISTORY_LIMIT) + .map((s) => ({ t: new Date(s.datetime * 1000).toISOString(), ok: Number(s.value) > 0, ms: Math.max(0, Number(s.value) || 0) })) + .reverse(); + if (status === 'down') history.push({ t: lastCheckIso, ok: false, ms: 0 }); + + const last = samples[0]?.value; + const avg = m.average_response_time; + const latency = Number.isFinite(Number(last)) && Number(last) > 0 + ? Math.round(Number(last)) + : Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null; + + const reason = latestDownReason(logs); + let detail; + if (status === 'up') detail = 'UptimeRobot:运行正常'; + else if (status === 'down') detail = `UptimeRobot:${reason?.text ?? '连接失败'}`; + else detail = m.status === 0 ? 'UptimeRobot:监控已暂停' : 'UptimeRobot:尚未检测'; + + monitors.push({ + id: `ur-${m.id}`, + name: m.friendly_name || `UptimeRobot #${m.id}`, + group, + type: 'ur', + url: targetOf(m), + // 与 UR 侧一致:编号、协议、检测间隔 + source: 'uptimerobot', + sourceId: m.id, + sourceType: m.type, + sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`, + interval: Number(m.interval) || 300, + status, + latency, + // UR 不直接给「当前 HTTP 状态码」,故障时取最近一次故障日志里的 code + code: status === 'down' && reason?.code ? Number(reason.code) || null : null, + detail, + lastCheckIso, + history, + uptimeRatios: ratiosToObject(m.custom_uptime_ratio, ratios), + downSeconds: ratiosToObject(m.custom_down_durations, ratios), + createDatetime: Number(m.create_datetime) || null, + }); + } + return monitors; +} + +export async function fetchUptimeRobotMonitors({ apiKey, timeoutMs = 15000, ratios = [1, 7, 30, 90] }) { + if (!apiKey) throw new Error('缺少 UptimeRobot API Key'); + const body = new URLSearchParams({ + api_key: apiKey, + format: 'json', + response_times: '1', + response_times_average: '30', + custom_uptime_ratios: ratios.join('-'), + logs: '1', + log_types: '1-2', + // 注意:custom_down_durations 不能作为请求参数传入(v2 会返回 invalid_parameter), + // 但响应中默认会带该字段,故 downSeconds 仍可解析 + }); + const res = await fetch(API_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) throw new Error(`UptimeRobot HTTP ${res.status}`); + const payload = await res.json(); + if (payload.stat !== 'ok') throw new Error(`UptimeRobot 返回错误:${payload.error?.message || payload.error?.type || 'unknown'}`); + return mapUptimeRobotMonitors(payload, { ratios }); +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..ab7f698 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,778 @@ +const VERSION = '0.6.0'; +const HB_LIMIT = 48; +const UPTIME_DAYS = 90; +const BROWSER_CONCURRENCY = 6; +// 表格为默认视图(目标多时可一屏扫完);卡片视图保留给需要心率图细节的场景 +const VIEWS = { table: '表格', cards: '卡片' }; +const DEFAULT_VIEW = 'table'; +const DEFAULT_REFRESH_SEC = 30; +// 只看 UptimeRobot 时,其自身检测间隔就是 5 分钟,刷新再快也没有新数据 +const EXTERNAL_REFRESH_SEC = 300; + +const state = { + config: null, + status: null, + source: null, + filter: 'all', + view: 'cards', + refreshTimer: null, + nextRefreshAt: 0, + loading: false, + // 服务端未开放/拒绝强制探测时置位,后续刷新退回只读轮询,避免每轮都撞 403 + refreshDenied: false, +}; + +const $ = (id) => document.getElementById(id); + +const SOURCE_LABEL = { + api: '边缘 API(实时)', + static: '静态快照', + browser: '浏览器直连', +}; + +const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]); + +async function fetchJson(url, timeoutMs = 8000) { + const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) { + const err = new Error(`HTTP ${res.status}`); + err.status = res.status; + throw err; + } + return res.json(); +} + +function fmtRelative(iso) { + if (!iso) return '–'; + const diff = Date.now() - new Date(iso).getTime(); + if (Number.isNaN(diff)) return '–'; + const s = Math.max(0, Math.round(diff / 1000)); + if (s < 10) return '刚刚'; + if (s < 60) return `${s} 秒前`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} 分钟前`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} 小时前`; + return `${Math.floor(h / 24)} 天前`; +} + +function fmtTime(iso) { + if (!iso) return '–'; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? '–' : d.toLocaleString('zh-CN', { hour12: false }); +} + +async function mapLimit(items, limit, fn) { + let idx = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (idx < items.length) { + const i = idx++; + await fn(items[i], i); + } + }); + await Promise.all(workers); +} + +/* ---------- 数据源 ---------- */ + +function setSource(s) { + state.source = s; + $('source-badge').textContent = `数据源:${SOURCE_LABEL[s] ?? s}`; +} + +async function resolveSource() { + const forced = new URLSearchParams(location.search).get('source'); + const mode = forced || state.config.settings?.dataSource || 'auto'; + if (mode === 'browser') return setSource('browser'); + if (mode === 'static') { + try { + await fetchJson('status.json', 5000); + return setSource('static'); + } catch { + return setSource('browser'); + } + } + if (mode === 'api') { + try { + await fetchJson('api/status', 5000); + return setSource('api'); + } catch { + return setSource('browser'); + } + } + // auto:优先边缘 API,其次静态快照,最后浏览器直连 + try { + await fetchJson('api/status', 3000); + return setSource('api'); + } catch { /* 无边缘 API */ } + try { + await fetchJson('status.json', 3000); + return setSource('static'); + } catch { /* 无静态快照 */ } + return setSource('browser'); +} + +/* ---------- 浏览器直连探测 ---------- */ + +async function probeBrowserOne(m) { + const started = Date.now(); + const timeoutMs = (m.timeout ?? 10) * 1000; + if (m.type === 'tcp') { + // 浏览器无原始 TCP 能力,以 HTTP 连接行为近似:快速拒绝=端口关闭;超时无响应=端口开放但非 HTTP 服务(启发式) + const t = Math.min(timeoutMs, 4000); + try { + await fetch(`http://${m.host}:${m.port}/`, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(t) }); + return { status: 'up', latency: Date.now() - started, code: null, detail: `端口 ${m.port} 可连接(启发式)` }; + } catch (err) { + if (err.name === 'TimeoutError') { + return { status: 'up', latency: Date.now() - started, code: null, detail: `端口 ${m.port} 开放(无 HTTP 响应,启发式)` }; + } + return { status: 'down', latency: Date.now() - started, code: null, detail: '端口不可连接(或被防火墙拒绝)' }; + } + } + // 先尝试 cors 模式读取真实状态码;目标未开放跨域时降级为 no-cors 仅判断可达性 + try { + const res = await fetch(m.url, { + method: m.method || 'GET', + mode: 'cors', + redirect: 'manual', + cache: 'no-store', + signal: AbortSignal.timeout(timeoutMs), + }); + const expect = m.expectStatus ?? [200]; + const ok = expect.includes(res.status); + return { + status: ok ? 'up' : 'down', + latency: Date.now() - started, + code: res.status, + detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`, + }; + } catch { /* 跨域被拒或网络不可达,降级 */ } + try { + await fetch(m.url, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) }); + return { status: 'up', latency: Date.now() - started, code: null, detail: '跨域可达(浏览器模式无法读取状态码)' }; + } catch (err) { + return { status: 'down', latency: Date.now() - started, code: null, detail: err.name === 'TimeoutError' ? '请求超时' : '不可达' }; + } +} + +async function probeFromBrowser(config) { + const monitors = {}; + let up = 0; + let down = 0; + let latencySum = 0; + await mapLimit(config.monitors, BROWSER_CONCURRENCY, async (m) => { + const r = await probeBrowserOne(m); + if (r.status === 'up') { up++; latencySum += r.latency; } else { down++; } + monitors[m.id] = { ...r, lastCheck: new Date().toISOString(), history: [] }; + }); + return { + prober: 'shiroko-monitor-browser', + demo: false, + generatedAt: new Date().toISOString(), + stats: { total: config.monitors.length, up, down, pending: 0, avgLatency: up ? Math.round(latencySum / up) : null }, + monitors, + }; +} + +/* ---------- 渲染 ---------- */ + +function heartbeatSvg(history) { + const pts = (history ?? []).slice(-HB_LIMIT); + if (!pts.length) return '
暂无历史数据
'; + const w = 100; + const h = 30; + const max = Math.max(...pts.map((p) => p.ms || 0), 1); + const bw = w / pts.length; + const bars = pts.map((p, i) => { + const bh = p.ok ? Math.max(2, (p.ms / max) * (h - 2)) : 2; + const color = p.ok ? 'var(--up)' : 'var(--down)'; + const tip = `${fmtTime(p.t)} · ${p.ok ? `在线 ${p.ms}ms` : '离线'}`; + return `${escapeHtml(tip)}`; + }).join(''); + return `${bars}`; +} + +function statusText(st) { + if (!st || st.status === 'pending') return { label: '等待检测', cls: 'pending' }; + return st.status === 'up' ? { label: '在线', cls: 'up' } : { label: '离线', cls: 'down' }; +} + +function lastNDays(n) { + const out = []; + const now = new Date(); + const p = (x) => String(x).padStart(2, '0'); + for (let i = n - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i); + out.push(`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`); + } + return out; +} + +function uptimeSummary(st) { + const daily = st?.daily ?? {}; + let total = 0; + let up = 0; + for (const k of Object.keys(daily)) { + total += daily[k].total ?? 0; + up += daily[k].up ?? 0; + } + if (total) return { pct: (up / total) * 100, total, up, days: Object.keys(daily).length, source: 'local' }; + // 没有逐日数据时回退到数据源提供的官方聚合比率(UptimeRobot 镜像目标自带 90 天可用率) + const provided = Number(st?.uptimeRatios?.[90]); + if (Number.isFinite(provided)) return { pct: provided, total: 0, up: 0, days: 90, source: 'external' }; + return null; +} + +function uptimeBar(st) { + const daily = st?.daily; + const cells = lastNDays(UPTIME_DAYS).map((key) => { + const b = daily?.[key]; + let cls = 'lv-none'; + let tip = `${key} · 无数据`; + if (b && b.total > 0) { + const pct = (b.up / b.total) * 100; + cls = pct >= 99 ? 'lv-ok' : pct >= 95 ? 'lv-warn' : 'lv-bad'; + tip = `${key} · 可用率 ${pct.toFixed(1)}%(${b.up}/${b.total} 次)`; + } + return ``; + }).join(''); + const s = uptimeSummary(st); + const label = s ? `90天 ${s.pct.toFixed(1)}%` : '90天 —'; + let labelTip; + if (!s) labelTip = '可用率数据积累中(探针需持续运行)'; + else if (s.source === 'external') labelTip = `由数据源提供(UptimeRobot 官方 90 天可用率);逐日色条由本探针同步后累积`; + else labelTip = `按已积累的 ${s.days} 天统计:${s.up}/${s.total} 次在线`; + return `
${cells}
${label}
`; +} + +function hideTargets() { + return state.config?.settings?.hideTargets === true; +} + +function targetText(m) { + if (hideTargets()) return '已隐藏'; + if (m.url) return m.url; + if (m.host) return m.port ? `${m.host}:${m.port}` : m.host; + return '—'; +} + +function typeLabel(m) { + // 外部数据源(UptimeRobot)标注来源与协议,便于与自建探测区分 + if (isExternal(m)) return `UR·${m.sourceLabel ?? ''}`.replace(/·$/, ''); + if (m.type === 'tcp') return 'TCP'; + if (m.type === 'banner') return String(m.protocol || 'BANNER').toUpperCase(); + return m.method || 'GET'; +} + +function resultText(st, m) { + if (st?.code != null) return String(st.code); + if (isExternal(m)) return '–'; + if (m.type === 'tcp') return '端口'; + if (m.type === 'banner') return '标识串'; + return '–'; +} + +function renderCard(m) { + const st = state.status?.monitors?.[m.id]; + const s = statusText(st); + const card = document.createElement('article'); + card.className = 'card'; + card.dataset.status = s.cls; + card.dataset.id = m.id; + + const head = document.createElement('div'); + head.className = 'card-head'; + head.innerHTML = `

${escapeHtml(m.name)}

${escapeHtml(typeLabel(m))}`; + + const target = document.createElement('div'); + target.className = 'card-target'; + target.title = targetText(m); + target.textContent = targetText(m); + + const metrics = document.createElement('div'); + metrics.className = 'metrics'; + // 外部数据源只有「最近一次状态变更」时间,语义与自建探测的「最近检测」不同,tooltip 里说明 + const checkTitle = isExternal(m) && st?.lastCheck + ? `UptimeRobot 最近一次状态变更:${fmtTime(st.lastCheck)}` + : fmtTime(st?.lastCheck); + metrics.innerHTML = + `
${st?.latency != null ? `${st.latency} ms` : '–'}
` + + `
${escapeHtml(resultText(st, m))}
` + + `
${fmtRelative(st?.lastCheck)}
`; + + const hb = document.createElement('div'); + hb.className = 'heartbeat'; + hb.innerHTML = heartbeatSvg(st?.history); + + const up = document.createElement('div'); + up.innerHTML = uptimeBar(st); + + const detail = document.createElement('div'); + detail.className = 'card-detail'; + detail.title = st?.detail ?? ''; + detail.textContent = st?.detail ?? '等待检测'; + + card.append(head, target, metrics, hb, up.firstElementChild ?? document.createElement('div'), detail); + return card; +} + +function groupMonitors(monitors) { + const groups = new Map(); + for (const m of monitors) { + const g = m.group || '未分组'; + if (!groups.has(g)) groups.set(g, []); + groups.get(g).push(m); + } + return groups; +} + +// 面板展示的目标 = 配置文件里的自建目标 + 快照中带 meta 的外部数据源目标(如 UptimeRobot), +// 过滤优先级:URL ?filter= > 快照 dataFilter(服务端口径,私有配置覆盖后仍一致)> 配置 settings.dataFilter +function dataFilter() { + const q = new URLSearchParams(location.search).get('filter'); + if (['both', 'config', 'uptimerobot'].includes(q)) return q; + const f = state.status?.dataFilter ?? state.config?.settings?.dataFilter; + return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both'; +} + +function allMonitors() { + const filter = dataFilter(); + const cfg = state.config?.monitors ?? []; + const out = filter === 'uptimerobot' ? [] : [...cfg]; + if (filter === 'config') return out; + const known = new Set(cfg.map((m) => m.id)); + for (const [id, st] of Object.entries(state.status?.monitors ?? {})) { + if (known.has(id) || !st?.meta) continue; + out.push({ id, ...st.meta }); + } + return out; +} + +function isExternal(m) { + return m.source === 'uptimerobot'; +} + +function renderTabs() { + const nav = $('group-tabs'); + const monitors = allMonitors(); + const groups = groupMonitors(monitors); + nav.innerHTML = ''; + const all = document.createElement('button'); + all.type = 'button'; + all.className = `tab${state.filter === 'all' ? ' active' : ''}`; + all.textContent = `全部 ${monitors.length}`; + all.addEventListener('click', () => setFilter('all')); + nav.appendChild(all); + for (const [g, list] of groups) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `tab${state.filter === g ? ' active' : ''}`; + btn.textContent = `${g} ${list.length}`; + btn.addEventListener('click', () => setFilter(g)); + nav.appendChild(btn); + } +} + +function setFilter(g) { + state.filter = g; + renderTabs(); + renderView(); +} + +function visibleGroups() { + const all = [...groupMonitors(allMonitors())]; + return state.filter === 'all' ? all : all.filter(([g]) => g === state.filter); +} + +/* ---------- 视图:卡片 / 表格 / 大屏 / 极简 ---------- */ + +// 展示过滤(URL ?filter=)与服务端快照的 dataFilter 不一致时,被过滤出来的目标不在快照里, +// 会整页显示「等待检测」——这里给出明确解释,避免看起来像故障 +function renderFilterNotice(container) { + const snapMonitors = state.status?.monitors ?? {}; + const shown = allMonitors(); + const missing = shown.filter((m) => !snapMonitors[m.id]).length; + const serverFilter = state.status?.dataFilter; + if (!missing || !shown.length || !serverFilter || serverFilter === dataFilter()) return; + const el = document.createElement('div'); + el.className = 'notice'; + el.innerHTML = + `当前展示过滤为 ${escapeHtml(dataFilter())},但服务端快照的 dataFilter 为 ${escapeHtml(serverFilter)}:其中 ${missing} 个目标不在快照中,因此显示为「等待检测」。` + + `如需一致,请把 settings.dataFilter 设为相同值,或去掉地址栏里的 ?filter= 参数。`; + container.appendChild(el); +} + +function renderView() { + const grid = $('monitor-grid'); + grid.innerHTML = ''; + document.body.dataset.view = state.view; + if (!allMonitors().length) { + grid.innerHTML = '
当前数据源下没有可展示的监控目标。
'; + return; + } + renderFilterNotice(grid); + ({ table: renderTableView, cards: renderCardsView }[state.view] ?? renderTableView)(grid); +} + +function renderCardsView(container) { + const groups = visibleGroups(); + for (const [g, list] of groups) { + if (groups.length > 1) container.appendChild(groupTitle(g)); + for (const m of list) container.appendChild(renderCard(m)); + } +} + +function groupTitle(text) { + const h = document.createElement('h2'); + h.className = 'group-title'; + h.textContent = text; + return h; +} + +function renderTableView(container) { + const hide = hideTargets(); + const wrap = document.createElement('div'); + wrap.className = 'table-wrap'; + const table = document.createElement('table'); + table.className = 'monitor-table'; + const thead = document.createElement('thead'); + thead.innerHTML = `状态服务${hide ? '' : '目标'}延迟结果最近检测心率(48 次)90 天可用率`; + const tbody = document.createElement('tbody'); + const groups = visibleGroups(); + for (const [g, list] of groups) { + if (groups.length > 1) { + const tr = document.createElement('tr'); + tr.className = 'group-row'; + const td = document.createElement('td'); + td.colSpan = hide ? 7 : 8; + td.textContent = g; + tr.appendChild(td); + tbody.appendChild(tr); + } + for (const m of list) tbody.appendChild(tableRow(m, hide)); + } + table.append(thead, tbody); + wrap.appendChild(table); + container.appendChild(wrap); +} + +function tableRow(m, hide) { + const st = state.status?.monitors?.[m.id]; + const s = statusText(st); + const up = uptimeSummary(st); + const tr = document.createElement('tr'); + tr.dataset.status = s.cls; + tr.dataset.id = m.id; + const targetCell = hide ? '' : `${escapeHtml(targetText(m))}`; + tr.innerHTML = + `${s.label}` + + `${escapeHtml(m.name)}${escapeHtml(typeLabel(m))}` + + targetCell + + `${st?.latency != null ? `${st.latency} ms` : '–'}` + + `${escapeHtml(resultText(st, m))}` + + `${fmtRelative(st?.lastCheck)}` + + `${heartbeatSvg(st?.history)}` + + `${up ? `${miniUptimeBar(st?.daily)}${up.pct.toFixed(1)}%` : '–'}`; + return tr; +} + +function miniUptimeBar(daily) { + const cells = lastNDays(UPTIME_DAYS).map((key) => { + const b = daily?.[key]; + let cls = 'lv-none'; + if (b && b.total > 0) { + const pct = (b.up / b.total) * 100; + cls = pct >= 99 ? 'lv-ok' : pct >= 95 ? 'lv-warn' : 'lv-bad'; + } + return ``; + }).join(''); + return `${cells}`; +} + +function renderViewSwitch() { + const box = $('view-switch'); + if (!box) return; + box.innerHTML = ''; + for (const [key, label] of Object.entries(VIEWS)) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `view-btn${state.view === key ? ' active' : ''}`; + btn.textContent = label; + btn.title = `${label}视图`; + btn.addEventListener('click', () => setView(key)); + box.appendChild(btn); + } +} + +function resolveView() { + const q = new URLSearchParams(location.search).get('view'); + let saved = null; + try { + saved = localStorage.getItem('shiroko.view'); + } catch { /* 隐私模式下 localStorage 不可用 */ } + const cfg = state.config?.settings?.view; + return [q, saved, cfg, DEFAULT_VIEW].find((v) => v && VIEWS[v]) ?? DEFAULT_VIEW; +} + +function setView(view, { persist = true } = {}) { + state.view = VIEWS[view] ? view : 'cards'; + if (persist) { + try { + localStorage.setItem('shiroko.view', state.view); + } catch { /* 忽略存储失败 */ } + } + renderViewSwitch(); + renderView(); +} + +// 顶部汇总按「当前实际展示的目标」计算:URL ?filter= 覆盖服务端口径时,统计也不会与列表不一致 +function localStats() { + const monitors = allMonitors(); + let up = 0; + let down = 0; + let pending = 0; + let latSum = 0; + let latCount = 0; + let uptimeUp = 0; + let uptimeTotal = 0; + for (const m of monitors) { + const st = state.status?.monitors?.[m.id]; + if (!st || st.status === 'pending') { + pending++; + continue; + } + if (st.status === 'up') up++; + else down++; + // 与探针口径一致:平均延迟只统计自建探测目标 + if (!isExternal(m) && st.latency != null) { + latSum += st.latency; + latCount++; + } + const s = uptimeSummary(st); + if (s) { + uptimeTotal += s.total; + uptimeUp += s.up; + } + } + return { + total: monitors.length, + up, + down, + pending, + avgLatency: latCount ? Math.round(latSum / latCount) : null, + avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null, + }; +} + +function renderSummary() { + const st = state.status; + const stats = localStats(); + const down = stats.down; + const pending = stats.pending; + const pill = $('overall-pill'); + pill.className = 'pill'; + if (!st) pill.textContent = '检测中…'; + else if (!stats.total) { + pill.classList.add('pending'); + pill.textContent = '无展示目标'; + } else if (down > 0) { + pill.classList.add('bad'); + pill.textContent = `${down} 个服务异常`; + } else if (pending > 0) { + pill.classList.add('pending'); + pill.textContent = '检测中…'; + } else { + pill.classList.add('ok'); + pill.textContent = '全部服务正常'; + } + $('stat-total').textContent = stats.total; + $('stat-up').textContent = stats.up; + $('stat-down').textContent = stats.down; + $('stat-latency').textContent = stats.avgLatency != null ? `${stats.avgLatency} ms` : '–'; + $('stat-uptime').textContent = stats.avgUptime90 != null ? `${stats.avgUptime90}%` : '–'; + // 展示「数据最后更新时刻」(服务端产出新数据的时间)而非快照生成时间:后者每次请求都变,无法反映新鲜度 + const updatedMs = dataUpdatedMs(); + $('updated-at').textContent = updatedMs ? `数据更新于 ${fmtTime(state.status?.dataUpdatedAt)}` : '数据更新于 –'; + $('updated-at').title = updatedMs + ? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒` + : '尚无服务端数据'; + $('demo-chip').hidden = !st?.demo; + const ur = st?.sources?.uptimerobot; + const urChip = $('ur-chip'); + if (urChip) { + urChip.hidden = !(ur?.enabled && ur.ok === false); + if (!urChip.hidden) urChip.title = `UptimeRobot 同步失败:${ur.error ?? '未知错误'}(保留上次数据,目标状态未被误判)`; + } +} + +function renderAll() { + renderSummary(); + renderTabs(); + renderViewSwitch(); + renderView(); +} + +function renderError(err) { + const grid = $('monitor-grid'); + grid.innerHTML = `
数据加载失败:${escapeHtml(err.message)}
请检查 monitors.json 与数据源配置。
`; +} + +/* ---------- 刷新循环 ---------- */ + +function refreshIntervalSec() { + const configured = Number(state.config?.settings?.refreshInterval); + if (Number.isFinite(configured) && configured > 0) return Math.max(5, configured); + // 未显式配置时:只看 UptimeRobot 的话,其自身 5 分钟才更新一次,刷新更快没有意义 + return dataFilter() === 'uptimerobot' ? EXTERNAL_REFRESH_SEC : DEFAULT_REFRESH_SEC; +} + +// 服务端产出新数据的节奏(秒),由快照提供;浏览器直连模式没有服务端节奏 +function serverCadenceSec() { + const n = Number(state.status?.updateIntervalSec); + return Number.isFinite(n) && n > 0 ? n : null; +} + +function dataUpdatedMs() { + const t = state.status?.dataUpdatedAt ? new Date(state.status.dataUpdatedAt).getTime() : 0; + return Number.isFinite(t) ? t : 0; +} + +// 服务端数据是否已明显过期(超过 1.5 个周期没更新,例如探针刚启动或被休眠过) +function isDataStale() { + const cadence = serverCadenceSec(); + const updated = dataUpdatedMs(); + if (!cadence || !updated) return false; + return Date.now() - updated > cadence * 1500; +} + +// 下一次检查的间隔:api 模式下对齐「服务端上次更新 + 一个周期」,refreshInterval 只作为上限; +// 静态快照与浏览器直连没有可对齐的服务端节奏,直接按 refreshInterval 轮询 +function nextCheckInSec() { + const cap = refreshIntervalSec(); + const cadence = serverCadenceSec(); + const updated = dataUpdatedMs(); + if (state.source !== 'api' || !cadence || !updated) return cap; + const left = Math.round((updated + cadence * 1000 - Date.now()) / 1000); + if (left <= 0) return Math.min(cap, Math.max(5, cadence)); // 已过预期更新时刻:稍后重试 + return Math.max(5, Math.min(left, cap)); +} + +// 强制检测需等待各目标探测返回(含超时目标,服务端另有 22s 兜底),故用更宽松的超时 +const REFRESH_TIMEOUT_MS = 30000; + +function setLoading(on, text = '刷新中…') { + state.loading = on; + const btn = $('refresh-btn'); + btn.disabled = on; + btn.textContent = on ? text : '刷新'; +} + +async function refresh({ force = false } = {}) { + if (state.loading) return; + // 自动刷新只读取服务端已产出的数据(探测由服务端按各自 interval 持续驱动,不因访客而变); + // 仅两种情况触发重新检测:① 手动点击刷新 ② 服务端数据明显过期时自愈一次 + const recheck = state.source === 'api' && !state.refreshDenied && (force || isDataStale()); + setLoading(true, recheck ? '检测中…' : '刷新中…'); + try { + if (state.source === 'browser') { + state.status = await probeFromBrowser(state.config); + } else if (state.source === 'api') { + if (recheck) { + // 首次加载:先用服务端已有快照立即出图,避免强制检测(含超时目标,可能十几秒)期间显示空白面板 + if (!state.status) { + try { + state.status = await fetchJson('api/status'); + renderAll(); + } catch { /* 拿不到旧快照就继续等强制检测结果 */ } + } + try { + // 只看 UptimeRobot 时不需要跑本地探测轮次,只同步 UR 数据 + const scope = dataFilter() === 'uptimerobot' ? '?scope=external' : ''; + state.status = await fetchJson(`api/refresh${scope}`, REFRESH_TIMEOUT_MS); + } catch (err) { + // 服务端未开放强制探测(403/401)→ 本次会话退回只读轮询;限流(429)→ 仅是这一轮降级 + if (err.status === 403 || err.status === 401) markRefreshDenied(); + else if (err.status === 429) $('refresh-btn').title = '刷新过于频繁,已显示最近一次数据(服务端限流保护)'; + else throw err; + state.status = await fetchJson('api/status'); + } + } else { + state.status = await fetchJson('api/status'); + } + } else { + state.status = await fetchJson('status.json'); + } + renderAll(); + } catch (err) { + if (state.source !== 'browser') { + // 数据源失效(如静态部署缺少 status.json)时降级为浏览器直连 + setSource('browser'); + setLoading(false); + return refresh({ force }); + } + renderError(err); + } finally { + setLoading(false); + resetCountdown(); + } +} + +function markRefreshDenied() { + if (state.refreshDenied) return; + state.refreshDenied = true; + const btn = $('refresh-btn'); + btn.title = '服务端未开放「重新检测」,刷新仅重新读取最新数据'; + $('source-badge').textContent = `${SOURCE_LABEL[state.source]}(只读)`; +} + +function resetCountdown() { + state.nextRefreshAt = Date.now() + nextCheckInSec() * 1000; + updateCountdownText(); +} + +function updateCountdownText() { + const left = Math.max(0, Math.round((state.nextRefreshAt - Date.now()) / 1000)); + $('countdown').textContent = `下次检查 ${left}s`; + $('countdown').title = '自动刷新只读取服务端已产出的数据,节奏对齐服务端的更新时间;点击「刷新」可立即重新检测'; +} + +function startAutoRefresh() { + clearInterval(state.refreshTimer); + resetCountdown(); + state.refreshTimer = setInterval(() => { + // 以截止时间计算剩余秒数,而非累加 tick:浏览器对后台标签页会节流定时器,计数法会导致刷新严重滞后 + if (Date.now() >= state.nextRefreshAt) refresh(); + else updateCountdownText(); + }, 1000); +} + +function bindEvents() { + // 只有手动点「刷新」才会触发一次重新检测;自动刷新是只读的 + $('refresh-btn').addEventListener('click', () => refresh({ force: true })); +} + +/* ---------- 启动 ---------- */ + +async function init() { + try { + state.config = await fetchJson('monitors.json'); + } catch (err) { + renderError(err); + return; + } + document.title = state.config.settings?.title || 'Shiroko Monitor'; + $('site-title').textContent = state.config.settings?.title || 'Shiroko Monitor'; + $('site-desc').textContent = state.config.settings?.description || ''; + const versionEl = document.querySelector('.version'); + if (versionEl) versionEl.textContent = `shiroko-monitor v${VERSION}`; + await resolveSource(); + state.view = resolveView(); + bindEvents(); + renderAll(); + await refresh(); + startAutoRefresh(); +} + +init(); diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg new file mode 100644 index 0000000..07fdb56 --- /dev/null +++ b/public/assets/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..1d51685 --- /dev/null +++ b/public/assets/style.css @@ -0,0 +1,356 @@ +:root { + --bg: #f5f6f8; + --surface: #ffffff; + --border: #e5e7eb; + --text: #111827; + --text-2: #6b7280; + --up: #16a34a; + --up-bg: #f0fdf4; + --down: #dc2626; + --down-bg: #fef2f2; + --pending: #d97706; + --pending-bg: #fffbeb; + --accent: #2563eb; + --uptime-ok: #16a34a; + --uptime-warn: #f59e0b; + --uptime-bad: #dc2626; + --uptime-none: #e5e7eb; + --mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --shadow: 0 1px 2px rgb(0 0 0 / 0.04); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1115; + --surface: #171a21; + --border: #2a2f3a; + --text: #e5e7eb; + --text-2: #9ca3af; + --up: #4ade80; + --up-bg: #10281c; + --down: #f87171; + --down-bg: #2a1517; + --pending: #fbbf24; + --pending-bg: #2a2113; + --accent: #60a5fa; + --uptime-none: #2a2f3a; + --shadow: none; + } +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; +} + +/* ---------- 顶栏 ---------- */ +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 20px; + padding: 12px 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.brand { display: flex; align-items: center; gap: 10px; min-width: 0; } + +.logo { + width: 26px; + height: 26px; + flex: none; + border-radius: 50%; + background: var(--up); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--up) 18%, transparent); + animation: pulse 2.4s ease-out infinite; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 30%, transparent); } + 70% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--up) 0%, transparent); } + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); } +} + +.brand-text { min-width: 0; } +.brand-text h1 { margin: 0; font-size: 15px; font-weight: 650; white-space: nowrap; } +.brand-text p { margin: 0; font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; } + +.pill { + padding: 4px 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 650; + white-space: nowrap; +} +.pill.ok { background: var(--up-bg); color: var(--up); } +.pill.bad { background: var(--down-bg); color: var(--down); } +.pill.pending { background: var(--pending-bg); color: var(--pending); } + +.stat { text-align: center; min-width: 44px; } +.stat b { display: block; font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; } +.stat label { font-size: 11px; color: var(--text-2); } +.stat-up b { color: var(--up); } +.stat-down b { color: var(--down); } + +#refresh-btn { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-size: 13px; + font-family: var(--sans); + cursor: pointer; + transition: background 0.15s; +} +#refresh-btn:hover { background: var(--bg); } +#refresh-btn:disabled { opacity: 0.6; cursor: default; } + +/* ---------- 视图切换 ---------- */ +.view-switch { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.view-btn { + padding: 5px 10px; + border: 0; + background: var(--surface); + color: var(--text-2); + font-size: 12px; + font-family: var(--sans); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.view-btn + .view-btn { border-left: 1px solid var(--border); } +.view-btn:hover { color: var(--text); } +.view-btn.active { background: var(--accent); color: #fff; } + +/* ---------- 分组页签 ---------- */ +.group-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 1200px; + margin: 0 auto; + padding: 16px 20px 0; +} + +.tab { + padding: 5px 14px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text-2); + font-size: 13px; + font-family: var(--sans); + cursor: pointer; + transition: all 0.15s; +} +.tab:hover { color: var(--text); } +.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* ---------- 卡片网格 ---------- */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 14px; + max-width: 1200px; + margin: 0 auto; + padding: 16px 20px 8px; +} + +.group-title { + grid-column: 1 / -1; + margin: 8px 0 0; + font-size: 13px; + font-weight: 650; + color: var(--text-2); +} + +.card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--pending); + border-radius: 12px; + box-shadow: var(--shadow); +} +.card[data-status="up"] { border-left-color: var(--up); } +.card[data-status="down"] { border-left-color: var(--down); } + +.card-head { display: flex; align-items: center; gap: 8px; min-width: 0; } +.card-head h3 { flex: 1; margin: 0; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.dot { width: 10px; height: 10px; flex: none; border-radius: 50%; background: var(--pending); } +/* 通用着色:卡片 / 表格行 / 极简列表 都以 data-status 标记状态 */ +[data-status="up"] .dot { background: var(--up); } +[data-status="down"] .dot { background: var(--down); } +[data-status="pending"] .dot { background: var(--pending); } +.card[data-status="up"] .dot { animation: pulse-dot 2.4s ease-out infinite; } + +@keyframes pulse-dot { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 35%, transparent); } + 70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--up) 0%, transparent); } + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); } +} + +.type-badge { + flex: none; + padding: 1px 7px; + border: 1px solid var(--border); + border-radius: 6px; + font-family: var(--mono); + font-size: 11px; + color: var(--text-2); +} + +.card-target { + font-family: var(--mono); + font-size: 12px; + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.metrics { display: flex; gap: 8px; } +.metric { flex: 1; min-width: 0; } +.metric b { display: block; font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; } +.metric label { font-size: 11px; color: var(--text-2); } + +.heartbeat { height: 32px; } +.heartbeat svg { display: block; width: 100%; height: 100%; } +.hb-empty { display: flex; align-items: center; height: 100%; font-size: 12px; color: var(--text-2); } + +/* 90 天可用率 */ +.uptime { display: flex; align-items: center; gap: 8px; } +.uptime-bar { display: flex; gap: 1px; flex: 1; min-width: 0; height: 10px; } +.uptime-cell { flex: 1; min-width: 0; border-radius: 1px; background: var(--uptime-none); } +.uptime-cell.lv-ok { background: var(--uptime-ok); } +.uptime-cell.lv-warn { background: var(--uptime-warn); } +.uptime-cell.lv-bad { background: var(--uptime-bad); } +.uptime-label { font-size: 11px; color: var(--text-2); white-space: nowrap; font-variant-numeric: tabular-nums; } + +.card-detail { + font-family: var(--mono); + font-size: 12px; + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.card[data-status="down"] .card-detail { color: var(--down); } + +.empty { + grid-column: 1 / -1; + padding: 60px 0; + text-align: center; + color: var(--text-2); +} + +/* 过滤口径不一致时的提示条 */ +.notice { + grid-column: 1 / -1; + margin-bottom: 10px; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--pending) 45%, var(--border)); + border-radius: 10px; + background: var(--pending-bg); + color: var(--pending); + font-size: 12px; + line-height: 1.7; +} +.notice code { font-family: var(--mono); background: color-mix(in srgb, var(--pending) 14%, transparent); padding: 0 4px; border-radius: 4px; } + +/* ---------- 底栏 ---------- */ +.footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 16px; + max-width: 1200px; + margin: 0 auto; + padding: 8px 20px 28px; + font-size: 12px; + color: var(--text-2); +} +.footer .version { margin-left: auto; } +.demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending); font-weight: 600; } +.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down); font-weight: 600; } + +@media (max-width: 640px) { + .topbar { padding: 10px 14px; } + .summary { gap: 6px 12px; } + .grid { grid-template-columns: 1fr; padding: 12px 14px 8px; } + .group-tabs { padding: 12px 14px 0; } + .footer { padding: 8px 14px 24px; } +} + +/* ================= 视图二:表格(高密度列表) ================= */ +body[data-view="table"] .grid { display: block; } + +.table-wrap { + overflow-x: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow); +} + +.monitor-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.monitor-table th { + padding: 10px 12px; + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--text-2); + white-space: nowrap; + background: var(--bg); + border-bottom: 1px solid var(--border); +} +.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; } +.monitor-table tbody tr:last-child td { border-bottom: 0; } +.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } +.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); } +.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); } + +.t-state { white-space: nowrap; } +.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; } +tr[data-status="up"] .t-state { color: var(--up); } +tr[data-status="down"] .t-state { color: var(--down); } +tr[data-status="pending"] .t-state { color: var(--pending); } +.t-name { white-space: nowrap; } +.t-name .type-badge { margin-left: 8px; } +.t-target { + max-width: 280px; + font-family: var(--mono); + font-size: 12px; + color: var(--text-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; } +.t-hb { width: 160px; } +.t-hb svg { display: block; width: 150px; height: 24px; } +.t-uptime { white-space: nowrap; } +.uptime-bar-mini { display: inline-flex; width: 110px; height: 8px; margin-right: 8px; vertical-align: middle; } +.t-pct { font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; } + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e7977dd --- /dev/null +++ b/public/index.html @@ -0,0 +1,49 @@ + + + + + + + Shiroko Monitor + + + + +
+
+ +
+

Shiroko Monitor

+

轻量级服务监控面板

+
+
+
+ 检测中… +
–
+
–
+
–
+
–
+
–
+
+ +
+
+ + + +
+
正在加载监控配置…
+
+ +
+ 数据源:– + + + – + – + shiroko-monitor v0.6.0 +
+ + + + diff --git a/public/monitors.json b/public/monitors.json new file mode 100644 index 0000000..eb1fcff --- /dev/null +++ b/public/monitors.json @@ -0,0 +1,100 @@ +{ + "settings": { + "title": "Shiroko Monitor", + "description": "轻量级服务监控面板", + "view": "table", + "dataFilter": "uptimerobot", + "refreshInterval": 300, + "dataSource": "auto", + "uptimeRobot": { + "enabled": false, + "apiKey": "", + "pollIntervalSec": 300, + "group": "UptimeRobot" + } + }, + "monitors": [ + { + "id": "example-com", + "name": "Example 站点", + "group": "公共服务", + "type": "http", + "url": "https://www.example.com", + "method": "GET", + "expectStatus": [200], + "interval": 60, + "timeout": 10 + }, + { + "id": "baidu", + "name": "百度", + "group": "公共服务", + "type": "http", + "url": "https://www.baidu.com", + "expectStatus": [200], + "interval": 60, + "timeout": 10 + }, + { + "id": "cloudflare-site", + "name": "Cloudflare 官网", + "group": "公共服务", + "type": "http", + "url": "https://www.cloudflare.com", + "expectStatus": [200], + "interval": 60, + "timeout": 10 + }, + { + "id": "github-api", + "name": "GitHub API(限流目标示例)", + "group": "公共服务", + "type": "http", + "url": "https://api.github.com", + "expectStatus": [200], + "interval": 300, + "timeout": 10, + "probeOnRefresh": false + }, + { + "id": "httpbin-500", + "name": "HTTPBin 500(演示 expectStatus)", + "group": "公共服务", + "type": "http", + "url": "https://httpbin.org/status/500", + "expectStatus": [500], + "interval": 120, + "timeout": 10 + }, + { + "id": "badssl-expired", + "name": "BadSSL 过期证书(演示离线)", + "group": "公共服务", + "type": "http", + "url": "https://expired.badssl.com/", + "expectStatus": [200], + "interval": 120, + "timeout": 10 + }, + { + "id": "cloudflare-dns-tcp", + "name": "Cloudflare 443 端口", + "group": "基础设施", + "type": "tcp", + "host": "1.1.1.1", + "port": 443, + "interval": 60, + "timeout": 10 + }, + { + "id": "google-dns-tcp", + "name": "Google DNS 53 端口", + "group": "基础设施", + "type": "tcp", + "host": "8.8.8.8", + "port": 53, + "interval": 60, + "timeout": 10 + } + ] +} diff --git a/public/status.json b/public/status.json new file mode 100644 index 0000000..a0f9d32 --- /dev/null +++ b/public/status.json @@ -0,0 +1,1100 @@ +{ + "prober": "shiroko-monitor-node/0.6.0", + "demo": false, + "generatedAt": "2026-10-13T10:31:41.131Z", + "dataUpdatedAt": "2026-10-13T10:31:32.261Z", + "updateIntervalSec": 300, + "dataFilter": "uptimerobot", + "stats": { + "total": 6, + "up": 5, + "down": 1, + "pending": 0, + "avgLatency": null, + "avgUptime90": 83.3, + "memory": { + "rssMB": 62, + "heapUsedMB": 11.7, + "heapTotalMB": 15 + } + }, + "sources": { + "uptimerobot": { + "enabled": true, + "ok": true, + "error": null, + "lastSyncAt": "2026-10-13T10:30:41.627Z", + "count": 6, + "syncing": false, + "lastSyncMs": 1791887440289 + } + }, + "monitors": { + "ur-792059439": { + "status": "up", + "latency": 171, + "code": null, + "detail": "UptimeRobot:运行正常", + "lastCheck": "2026-10-13T10:25:00.000Z", + "history": [ + { + "t": "2026-10-12T11:40:00.000Z", + "ok": true, + "ms": 258 + }, + { + "t": "2026-10-12T12:20:00.000Z", + "ok": true, + "ms": 144 + }, + { + "t": "2026-10-12T13:00:00.000Z", + "ok": true, + "ms": 277 + }, + { + "t": "2026-10-12T13:40:00.000Z", + "ok": true, + "ms": 482 + }, + { + "t": "2026-10-12T14:20:00.000Z", + "ok": true, + "ms": 698 + }, + { + "t": "2026-10-12T15:00:00.000Z", + "ok": true, + "ms": 312 + }, + { + "t": "2026-10-12T15:40:00.000Z", + "ok": true, + "ms": 336 + }, + { + "t": "2026-10-12T16:20:00.000Z", + "ok": true, + "ms": 275 + }, + { + "t": "2026-10-12T17:00:00.000Z", + "ok": true, + "ms": 255 + }, + { + "t": "2026-10-12T17:40:00.000Z", + "ok": true, + "ms": 304 + }, + { + "t": "2026-10-12T18:20:00.000Z", + "ok": true, + "ms": 323 + }, + { + "t": "2026-10-12T19:00:00.000Z", + "ok": true, + "ms": 328 + }, + { + "t": "2026-10-12T19:40:00.000Z", + "ok": true, + "ms": 304 + }, + { + "t": "2026-10-12T20:20:00.000Z", + "ok": true, + "ms": 265 + }, + { + "t": "2026-10-12T21:00:00.000Z", + "ok": true, + "ms": 294 + }, + { + "t": "2026-10-12T21:40:00.000Z", + "ok": true, + "ms": 379 + }, + { + "t": "2026-10-12T22:20:00.000Z", + "ok": true, + "ms": 176 + }, + { + "t": "2026-10-12T23:00:00.000Z", + "ok": true, + "ms": 325 + }, + { + "t": "2026-10-12T23:40:00.000Z", + "ok": true, + "ms": 192 + }, + { + "t": "2026-10-13T00:20:00.000Z", + "ok": true, + "ms": 342 + }, + { + "t": "2026-10-13T01:00:00.000Z", + "ok": true, + "ms": 252 + }, + { + "t": "2026-10-13T01:40:00.000Z", + "ok": true, + "ms": 164 + }, + { + "t": "2026-10-13T02:20:00.000Z", + "ok": true, + "ms": 199 + }, + { + "t": "2026-10-13T03:00:00.000Z", + "ok": true, + "ms": 216 + }, + { + "t": "2026-10-13T03:40:00.000Z", + "ok": true, + "ms": 160 + }, + { + "t": "2026-10-13T04:20:00.000Z", + "ok": true, + "ms": 435 + }, + { + "t": "2026-10-13T05:00:00.000Z", + "ok": true, + "ms": 281 + }, + { + "t": "2026-10-13T05:40:00.000Z", + "ok": true, + "ms": 285 + }, + { + "t": "2026-10-13T06:20:00.000Z", + "ok": true, + "ms": 270 + }, + { + "t": "2026-10-13T07:00:00.000Z", + "ok": true, + "ms": 235 + }, + { + "t": "2026-10-13T07:40:00.000Z", + "ok": true, + "ms": 271 + }, + { + "t": "2026-10-13T08:20:00.000Z", + "ok": true, + "ms": 417 + }, + { + "t": "2026-10-13T09:45:00.000Z", + "ok": true, + "ms": 612 + }, + { + "t": "2026-10-13T10:25:00.000Z", + "ok": true, + "ms": 171 + } + ], + "daily": { + "2026-10-13": { + "total": 12, + "up": 12 + } + }, + "meta": { + "name": "狼的公告板", + "group": "UptimeRobot", + "type": "ur", + "url": "https://blog.tama.guru", + "source": "uptimerobot", + "sourceLabel": "HTTP", + "interval": 300 + }, + "uptimeRatios": { + "1": 100, + "7": 100, + "30": 100, + "90": 100 + }, + "downSeconds": { + "1": 0, + "7": 0, + "30": 0, + "90": 0 + } + }, + "ur-795497022": { + "status": "up", + "latency": 744, + "code": null, + "detail": "UptimeRobot:运行正常", + "lastCheck": "2026-10-13T10:27:00.000Z", + "history": [ + { + "t": "2026-10-12T11:35:00.000Z", + "ok": true, + "ms": 713 + }, + { + "t": "2026-10-12T12:20:00.000Z", + "ok": true, + "ms": 692 + }, + { + "t": "2026-10-12T13:00:00.000Z", + "ok": true, + "ms": 733 + }, + { + "t": "2026-10-12T13:40:00.000Z", + "ok": true, + "ms": 935 + }, + { + "t": "2026-10-12T14:20:00.000Z", + "ok": true, + "ms": 802 + }, + { + "t": "2026-10-12T15:00:00.000Z", + "ok": true, + "ms": 720 + }, + { + "t": "2026-10-12T15:40: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, + "ms": 700 + }, + { + "t": "2026-10-12T17:40:00.000Z", + "ok": true, + "ms": 694 + }, + { + "t": "2026-10-12T18:20:00.000Z", + "ok": true, + "ms": 703 + }, + { + "t": "2026-10-12T19:00:00.000Z", + "ok": true, + "ms": 729 + }, + { + "t": "2026-10-12T19:40:00.000Z", + "ok": true, + "ms": 713 + }, + { + "t": "2026-10-12T20:20:00.000Z", + "ok": true, + "ms": 784 + }, + { + "t": "2026-10-12T21:00:00.000Z", + "ok": true, + "ms": 628 + }, + { + "t": "2026-10-12T21:40:00.000Z", + "ok": true, + "ms": 629 + }, + { + "t": "2026-10-12T22:20:00.000Z", + "ok": true, + "ms": 751 + }, + { + "t": "2026-10-12T23:00:00.000Z", + "ok": true, + "ms": 624 + }, + { + "t": "2026-10-12T23:40:00.000Z", + "ok": true, + "ms": 715 + }, + { + "t": "2026-10-13T00:20:00.000Z", + "ok": true, + "ms": 683 + }, + { + "t": "2026-10-13T01:00: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, + "ms": 609 + }, + { + "t": "2026-10-13T03:00:00.000Z", + "ok": true, + "ms": 606 + }, + { + "t": "2026-10-13T03:40:00.000Z", + "ok": true, + "ms": 720 + }, + { + "t": "2026-10-13T04:20:00.000Z", + "ok": true, + "ms": 711 + }, + { + "t": "2026-10-13T05:00:00.000Z", + "ok": true, + "ms": 598 + }, + { + "t": "2026-10-13T05:40:00.000Z", + "ok": true, + "ms": 729 + }, + { + "t": "2026-10-13T06:20:00.000Z", + "ok": true, + "ms": 676 + }, + { + "t": "2026-10-13T07:00:00.000Z", + "ok": true, + "ms": 691 + }, + { + "t": "2026-10-13T07:40:00.000Z", + "ok": true, + "ms": 696 + }, + { + "t": "2026-10-13T08:20:00.000Z", + "ok": true, + "ms": 682 + }, + { + "t": "2026-10-13T09:50:00.000Z", + "ok": true, + "ms": 774 + }, + { + "t": "2026-10-13T10:27:00.000Z", + "ok": true, + "ms": 744 + } + ], + "daily": { + "2026-10-13": { + "total": 12, + "up": 12 + } + }, + "meta": { + "name": "狼的共享盘", + "group": "UptimeRobot", + "type": "ur", + "url": "https://share.tama.guru", + "source": "uptimerobot", + "sourceLabel": "HTTP", + "interval": 300 + }, + "uptimeRatios": { + "1": 100, + "7": 99.789, + "30": 99.951, + "90": 99.93 + }, + "downSeconds": { + "1": 0, + "7": 1276, + "30": 1276, + "90": 5432 + } + }, + "ur-795556828": { + "status": "up", + "latency": 367, + "code": null, + "detail": "UptimeRobot:运行正常", + "lastCheck": "2026-10-13T10:26:00.000Z", + "history": [ + { + "t": "2026-10-12T11:35:00.000Z", + "ok": true, + "ms": 374 + }, + { + "t": "2026-10-12T12:15:00.000Z", + "ok": true, + "ms": 333 + }, + { + "t": "2026-10-12T12:55:00.000Z", + "ok": true, + "ms": 245 + }, + { + "t": "2026-10-12T13:35:00.000Z", + "ok": true, + "ms": 590 + }, + { + "t": "2026-10-12T14:15:00.000Z", + "ok": true, + "ms": 322 + }, + { + "t": "2026-10-12T14:55:00.000Z", + "ok": true, + "ms": 225 + }, + { + "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", + "ok": true, + "ms": 281 + }, + { + "t": "2026-10-12T17:40:00.000Z", + "ok": true, + "ms": 320 + }, + { + "t": "2026-10-12T18:20:00.000Z", + "ok": true, + "ms": 287 + }, + { + "t": "2026-10-12T19:00:00.000Z", + "ok": true, + "ms": 283 + }, + { + "t": "2026-10-12T19:40:00.000Z", + "ok": true, + "ms": 464 + }, + { + "t": "2026-10-12T20:20:00.000Z", + "ok": true, + "ms": 187 + }, + { + "t": "2026-10-12T21:00:00.000Z", + "ok": true, + "ms": 233 + }, + { + "t": "2026-10-12T21:40:00.000Z", + "ok": true, + "ms": 213 + }, + { + "t": "2026-10-12T22:20:00.000Z", + "ok": true, + "ms": 235 + }, + { + "t": "2026-10-12T23:05:00.000Z", + "ok": true, + "ms": 241 + }, + { + "t": "2026-10-12T23:45:00.000Z", + "ok": true, + "ms": 230 + }, + { + "t": "2026-10-13T00:25:00.000Z", + "ok": true, + "ms": 244 + }, + { + "t": "2026-10-13T01:05:00.000Z", + "ok": true, + "ms": 133 + }, + { + "t": "2026-10-13T01:45:00.000Z", + "ok": true, + "ms": 127 + }, + { + "t": "2026-10-13T02:25:00.000Z", + "ok": true, + "ms": 182 + }, + { + "t": "2026-10-13T03:05:00.000Z", + "ok": true, + "ms": 413 + }, + { + "t": "2026-10-13T03:45:00.000Z", + "ok": true, + "ms": 229 + }, + { + "t": "2026-10-13T04:25:00.000Z", + "ok": true, + "ms": 255 + }, + { + "t": "2026-10-13T05:05:00.000Z", + "ok": true, + "ms": 150 + }, + { + "t": "2026-10-13T05:45:00.000Z", + "ok": true, + "ms": 201 + }, + { + "t": "2026-10-13T06:25:00.000Z", + "ok": true, + "ms": 232 + }, + { + "t": "2026-10-13T07:05:00.000Z", + "ok": true, + "ms": 247 + }, + { + "t": "2026-10-13T07:45:00.000Z", + "ok": true, + "ms": 559 + }, + { + "t": "2026-10-13T08:25:00.000Z", + "ok": true, + "ms": 219 + }, + { + "t": "2026-10-13T09:50:00.000Z", + "ok": true, + "ms": 672 + }, + { + "t": "2026-10-13T10:26:00.000Z", + "ok": true, + "ms": 367 + } + ], + "daily": { + "2026-10-13": { + "total": 12, + "up": 12 + } + }, + "meta": { + "name": "狼的导航页", + "group": "UptimeRobot", + "type": "ur", + "url": "https://tama.guru", + "source": "uptimerobot", + "sourceLabel": "HTTP", + "interval": 300 + }, + "uptimeRatios": { + "1": 100, + "7": 100, + "30": 100, + "90": 100 + }, + "downSeconds": { + "1": 0, + "7": 0, + "30": 0, + "90": 0 + } + }, + "ur-792059414": { + "status": "up", + "latency": 2264, + "code": null, + "detail": "UptimeRobot:运行正常", + "lastCheck": "2026-10-13T10:27:00.000Z", + "history": [ + { + "t": "2026-10-12T11:15:00.000Z", + "ok": true, + "ms": 1867 + }, + { + "t": "2026-10-12T12:00:00.000Z", + "ok": true, + "ms": 2409 + }, + { + "t": "2026-10-12T12:45:00.000Z", + "ok": true, + "ms": 2922 + }, + { + "t": "2026-10-12T13:25:00.000Z", + "ok": true, + "ms": 3598 + }, + { + "t": "2026-10-12T14:10:00.000Z", + "ok": true, + "ms": 2478 + }, + { + "t": "2026-10-12T14:55:00.000Z", + "ok": true, + "ms": 3489 + }, + { + "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", + "ok": true, + "ms": 2816 + }, + { + "t": "2026-10-12T17:55:00.000Z", + "ok": true, + "ms": 2291 + }, + { + "t": "2026-10-12T18:40:00.000Z", + "ok": true, + "ms": 2251 + }, + { + "t": "2026-10-12T19:25:00.000Z", + "ok": true, + "ms": 2178 + }, + { + "t": "2026-10-12T20:10:00.000Z", + "ok": true, + "ms": 2123 + }, + { + "t": "2026-10-12T20:55:00.000Z", + "ok": true, + "ms": 2087 + }, + { + "t": "2026-10-12T21:40:00.000Z", + "ok": true, + "ms": 1870 + }, + { + "t": "2026-10-12T22:20:00.000Z", + "ok": true, + "ms": 1662 + }, + { + "t": "2026-10-12T23:00:00.000Z", + "ok": true, + "ms": 1859 + }, + { + "t": "2026-10-12T23:40:00.000Z", + "ok": true, + "ms": 3138 + }, + { + "t": "2026-10-13T00:20:00.000Z", + "ok": true, + "ms": 1681 + }, + { + "t": "2026-10-13T01:00:00.000Z", + "ok": true, + "ms": 2312 + }, + { + "t": "2026-10-13T01:40:00.000Z", + "ok": true, + "ms": 2200 + }, + { + "t": "2026-10-13T02:25:00.000Z", + "ok": true, + "ms": 1924 + }, + { + "t": "2026-10-13T03:10:00.000Z", + "ok": true, + "ms": 1144 + }, + { + "t": "2026-10-13T03:55:00.000Z", + "ok": true, + "ms": 2124 + }, + { + "t": "2026-10-13T04:40:00.000Z", + "ok": true, + "ms": 2623 + }, + { + "t": "2026-10-13T05:25:00.000Z", + "ok": true, + "ms": 2562 + }, + { + "t": "2026-10-13T06:10:00.000Z", + "ok": true, + "ms": 2625 + }, + { + "t": "2026-10-13T06:55:00.000Z", + "ok": true, + "ms": 2226 + }, + { + "t": "2026-10-13T07:40:00.000Z", + "ok": true, + "ms": 1715 + }, + { + "t": "2026-10-13T08:20:00.000Z", + "ok": true, + "ms": 1620 + }, + { + "t": "2026-10-13T09:48:00.000Z", + "ok": true, + "ms": 1809 + }, + { + "t": "2026-10-13T10:27:00.000Z", + "ok": true, + "ms": 2264 + } + ], + "daily": { + "2026-10-13": { + "total": 12, + "up": 12 + } + }, + "meta": { + "name": "狼的提问箱", + "group": "UptimeRobot", + "type": "ur", + "url": "https://box.tama.guru", + "source": "uptimerobot", + "sourceLabel": "HTTP", + "interval": 300 + }, + "uptimeRatios": { + "1": 100, + "7": 99.787, + "30": 99.927, + "90": 99.902 + }, + "downSeconds": { + "1": 0, + "7": 1288, + "30": 1899, + "90": 7593 + } + }, + "ur-798788113": { + "status": "up", + "latency": 513, + "code": null, + "detail": "UptimeRobot:运行正常", + "lastCheck": "2026-10-13T10:26:00.000Z", + "history": [ + { + "t": "2026-10-12T11:30:00.000Z", + "ok": true, + "ms": 437 + }, + { + "t": "2026-10-12T12:10:00.000Z", + "ok": true, + "ms": 528 + }, + { + "t": "2026-10-12T12:55:00.000Z", + "ok": true, + "ms": 738 + }, + { + "t": "2026-10-12T13:35:00.000Z", + "ok": true, + "ms": 412 + }, + { + "t": "2026-10-12T14:15:00.000Z", + "ok": true, + "ms": 401 + }, + { + "t": "2026-10-12T14:55:00.000Z", + "ok": true, + "ms": 321 + }, + { + "t": "2026-10-12T15:35:00.000Z", + "ok": true, + "ms": 517 + }, + { + "t": "2026-10-12T16:15:00.000Z", + "ok": true, + "ms": 324 + }, + { + "t": "2026-10-12T16:55:00.000Z", + "ok": true, + "ms": 452 + }, + { + "t": "2026-10-12T17:35:00.000Z", + "ok": true, + "ms": 346 + }, + { + "t": "2026-10-12T18:15:00.000Z", + "ok": true, + "ms": 386 + }, + { + "t": "2026-10-12T18:55:00.000Z", + "ok": true, + "ms": 357 + }, + { + "t": "2026-10-12T19:35:00.000Z", + "ok": true, + "ms": 423 + }, + { + "t": "2026-10-12T20:15:00.000Z", + "ok": true, + "ms": 303 + }, + { + "t": "2026-10-12T20:55:00.000Z", + "ok": true, + "ms": 524 + }, + { + "t": "2026-10-12T21:35:00.000Z", + "ok": true, + "ms": 330 + }, + { + "t": "2026-10-12T22:15:00.000Z", + "ok": true, + "ms": 441 + }, + { + "t": "2026-10-12T23:00:00.000Z", + "ok": true, + "ms": 215 + }, + { + "t": "2026-10-12T23:40:00.000Z", + "ok": true, + "ms": 239 + }, + { + "t": "2026-10-13T00:20:00.000Z", + "ok": true, + "ms": 273 + }, + { + "t": "2026-10-13T01:00:00.000Z", + "ok": true, + "ms": 273 + }, + { + "t": "2026-10-13T01:40:00.000Z", + "ok": true, + "ms": 347 + }, + { + "t": "2026-10-13T02:20:00.000Z", + "ok": true, + "ms": 505 + }, + { + "t": "2026-10-13T03:00:00.000Z", + "ok": true, + "ms": 412 + }, + { + "t": "2026-10-13T03:40:00.000Z", + "ok": true, + "ms": 332 + }, + { + "t": "2026-10-13T04:20:00.000Z", + "ok": true, + "ms": 396 + }, + { + "t": "2026-10-13T05:00:00.000Z", + "ok": true, + "ms": 537 + }, + { + "t": "2026-10-13T05:40:00.000Z", + "ok": true, + "ms": 332 + }, + { + "t": "2026-10-13T06:20:00.000Z", + "ok": true, + "ms": 426 + }, + { + "t": "2026-10-13T07:00:00.000Z", + "ok": true, + "ms": 344 + }, + { + "t": "2026-10-13T07:40:00.000Z", + "ok": true, + "ms": 334 + }, + { + "t": "2026-10-13T08:20:00.000Z", + "ok": true, + "ms": 341 + }, + { + "t": "2026-10-13T09:10:00.000Z", + "ok": true, + "ms": 249 + }, + { + "t": "2026-10-13T09:46:00.000Z", + "ok": true, + "ms": 641 + }, + { + "t": "2026-10-13T10:26:00.000Z", + "ok": true, + "ms": 513 + } + ], + "daily": { + "2026-10-13": { + "total": 12, + "up": 12 + } + }, + "meta": { + "name": "狼的聊天室", + "group": "UptimeRobot", + "type": "ur", + "url": "https://chat.tama.guru", + "source": "uptimerobot", + "sourceLabel": "HTTP", + "interval": 300 + }, + "uptimeRatios": { + "1": 100, + "7": 100, + "30": 100, + "90": 100 + }, + "downSeconds": { + "1": 0, + "7": 0, + "30": 0, + "90": 0 + } + }, + "ur-796219015": { + "status": "down", + "latency": 123, + "code": 403, + "detail": "UptimeRobot:403 Forbidden", + "lastCheck": "2026-05-07T07:54:09.000Z", + "history": [ + { + "t": "2026-05-07T07:54:09.000Z", + "ok": false, + "ms": 0 + } + ], + "daily": { + "2026-10-13": { + "total": 12, + "up": 0 + } + }, + "meta": { + "name": "狼的评论系统", + "group": "UptimeRobot", + "type": "ur", + "url": "https://artalk.tama.guru", + "source": "uptimerobot", + "sourceLabel": "HTTP", + "interval": 300 + }, + "uptimeRatios": { + "1": 0, + "7": 0, + "30": 0, + "90": 0 + }, + "downSeconds": { + "1": 86400, + "7": 604800, + "30": 2592000, + "90": 7776000 + } + } + } +} diff --git a/scripts/verify-security.cjs b/scripts/verify-security.cjs new file mode 100644 index 0000000..22c05c0 --- /dev/null +++ b/scripts/verify-security.cjs @@ -0,0 +1,171 @@ +/** + * shiroko-monitor 安全与探测能力验证(开发用) + * + * 覆盖: + * A. banner 探测:SSH 标识串识别、expectBanner 策略匹配/不符、静默端口超时、默认不回显版本 + * B. 信息泄露控制:detail 不含地址/版本、redactDetails 脱敏、公开配置不含真实地址 + * C. 私有配置分层:local.monitors.json 覆盖 host/port 后探测才可连通 + * D. /api/refresh 滥用防护:冷却限流 429 + Retry-After、allowRefresh=false 时 403 + * E. 来源判定 isPrivateAddress 单元校验 + * + * 用法:node scripts/verify-security.cjs + */ +const fs = require('node:fs'); +const os = require('node:os'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); +const PROBER = path.join(ROOT, 'prober', 'node', 'index.js'); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-sec-')); +const SSH_PORT = 2222; +const SILENT_PORT = 2223; +const results = []; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function check(name, pass, detail) { + results.push({ name, pass }); + console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); +} + +async function waitForHttp(url, timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fetch(url, { cache: 'no-store' }); + return true; + } catch { + await sleep(200); + } + } + return false; +} + +function startProber(args, env = {}) { + return spawn(process.execPath, [PROBER, ...args], { + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function runOnce(args) { + return new Promise((resolve) => { + const child = startProber(args); + let out = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (out += d)); + child.on('exit', (code) => resolve({ code, out })); + }); +} + +async function main() { + // ---------- 准备:本地假 SSH 服务 + 静默端口 ---------- + const sshServer = net.createServer((sock) => { + // 忠实模拟 OpenSSH:连接建立后立即发送标识串(RFC 4253) + sock.write('SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n'); + }); + const silentServer = net.createServer(() => { /* 接受连接但从不发送数据 */ }); + await new Promise((r) => sshServer.listen(SSH_PORT, '127.0.0.1', r)); + await new Promise((r) => silentServer.listen(SILENT_PORT, '127.0.0.1', r)); + console.log(`[准备] 假 SSH 服务 127.0.0.1:${SSH_PORT},静默端口 127.0.0.1:${SILENT_PORT},临时目录 ${TMP}`); + + // ---------- A/B/C:banner 探测 + 脱敏 + 私有配置分层 ---------- + const pubConfig = path.join(TMP, 'monitors.json'); + const localConfig = path.join(TMP, 'local.monitors.json'); + const outJson = path.join(TMP, 'status.json'); + fs.writeFileSync(pubConfig, JSON.stringify({ + settings: { title: 'sec-test', redactDetails: false }, + monitors: [ + { id: 'ssh-banner', name: 'SSH 服务', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-' }, + { id: 'ssh-reveal', name: 'SSH(回显)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-', revealBanner: true }, + { id: 'ssh-mismatch', name: 'SSH(策略不符)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-1\\.99-' }, + { id: 'silent-banner', name: '静默端口 banner', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 }, + { id: 'silent-tcp', name: '静默端口 TCP', group: '基础设施', type: 'tcp', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 }, + ], + }, null, 2)); + // 真实地址只存在于私有覆盖中(模拟「公开仓库不含内网地址」) + fs.writeFileSync(localConfig, JSON.stringify({ + monitors: [ + { id: 'ssh-banner', host: '127.0.0.1', port: SSH_PORT }, + { id: 'ssh-reveal', host: '127.0.0.1', port: SSH_PORT }, + { id: 'ssh-mismatch', host: '127.0.0.1', port: SSH_PORT }, + { id: 'silent-banner', host: '127.0.0.1', port: SILENT_PORT }, + { id: 'silent-tcp', host: '127.0.0.1', port: SILENT_PORT }, + ], + }, null, 2)); + + console.log('\n[A] banner 探测(私有配置覆盖地址后执行一轮探测)'); + const once = await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outJson}`]); + const snap = JSON.parse(fs.readFileSync(outJson, 'utf8')); + const m = snap.monitors; + check('私有配置分层生效(占位地址被覆盖为本地地址后探测成功)', m['ssh-banner'].status === 'up', `ssh-banner=${m['ssh-banner'].status} · ${m['ssh-banner'].detail}`); + check('SSH 标识串识别为在线且只给协议族摘要', m['ssh-banner'].detail === 'SSH 响应正常', `detail="${m['ssh-banner'].detail}"`); + check('默认不回显版本号(detail 不含 OpenSSH)', !/OpenSSH/.test(m['ssh-banner'].detail), m['ssh-banner'].detail); + check('expectBanner 策略不符时判为离线且不回显实际标识串', m['ssh-mismatch'].status === 'down' && !/OpenSSH/.test(m['ssh-mismatch'].detail), `detail="${m['ssh-mismatch'].detail}"`); + check('显式开启 revealBanner 后才回显标识串', /OpenSSH/.test(m['ssh-reveal'].detail), `detail="${m['ssh-reveal'].detail}"`); + check('静默端口 banner 探测超时判离线', m['silent-banner'].status === 'down', `detail="${m['silent-banner'].detail}"`); + check('同一静默端口 TCP 探测可达(证明两者判定逻辑不同)', m['silent-tcp'].status === 'up', `detail="${m['silent-tcp'].detail}"`); + check('TCP detail 不再包含地址', !m['silent-tcp'].detail.includes('127.0.0.1') && !m['silent-tcp'].detail.includes(':'), m['silent-tcp'].detail); + const rawSnapshot = fs.readFileSync(outJson, 'utf8'); + check('快照中不出现公开配置的占位地址', !rawSnapshot.includes('placeholder.invalid'), '未泄露 host'); + check('快照中不出现真实内网地址', !rawSnapshot.includes('127.0.0.1'), '未泄露 host'); + + console.log('\n[B] redactDetails 脱敏'); + const redacted = JSON.parse(fs.readFileSync(pubConfig, 'utf8')); + redacted.settings.redactDetails = true; + fs.writeFileSync(pubConfig, JSON.stringify(redacted, null, 2)); + const outRedacted = path.join(TMP, 'status-redacted.json'); + await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outRedacted}`]); + const rSnap = JSON.parse(fs.readFileSync(outRedacted, 'utf8')); + check('redactDetails 开启后 detail 只保留正常/异常', rSnap.monitors['ssh-reveal'].detail === '正常' && rSnap.monitors['silent-tcp'].detail === '正常', JSON.stringify(Object.fromEntries(Object.entries(rSnap.monitors).map(([k, v]) => [k, v.detail])))); + + // ---------- D:刷新接口滥用防护 ---------- + console.log('\n[D] /api/refresh 滥用防护'); + const cooldownCfg = path.join(TMP, 'cooldown.json'); + fs.writeFileSync(cooldownCfg, JSON.stringify({ settings: { title: 't', refreshCooldownSec: 3 }, monitors: [] }, null, 2)); + const srvA = startProber([`--config=${cooldownCfg}`, `--local-config=${cooldownCfg}`, `--out=${path.join(TMP, 'a.json')}`], { PORT: '8897' }); + await waitForHttp('http://127.0.0.1:8897/api/status'); + const r1 = await fetch('http://127.0.0.1:8897/api/refresh'); + const r2 = await fetch('http://127.0.0.1:8897/api/refresh'); + check('首次刷新放行', r1.status === 200, `status=${r1.status}`); + check('冷却期内再次刷新被限流', r2.status === 429, `status=${r2.status}, retry-after=${r2.headers.get('retry-after')}s`); + check('限流响应带 Retry-After 头', Number(r2.headers.get('retry-after')) >= 1, `retry-after=${r2.headers.get('retry-after')}`); + await sleep(3300); + const r3 = await fetch('http://127.0.0.1:8897/api/refresh'); + check('冷却结束后可再次刷新', r3.status === 200, `status=${r3.status}`); + srvA.kill(); + + const disabledCfg = path.join(TMP, 'disabled.json'); + fs.writeFileSync(disabledCfg, JSON.stringify({ settings: { title: 't', allowRefresh: false }, monitors: [] }, null, 2)); + const srvB = startProber([`--config=${disabledCfg}`, `--local-config=${disabledCfg}`, `--out=${path.join(TMP, 'b.json')}`], { PORT: '8898' }); + await waitForHttp('http://127.0.0.1:8898/api/status'); + const r4 = await fetch('http://127.0.0.1:8898/api/refresh'); + const body4 = await r4.json().catch(() => ({})); + check('allowRefresh:false 时刷新被拒绝', r4.status === 403, `status=${r4.status}, error=${body4.error}`); + check('拒绝响应不泄露目标信息', !JSON.stringify(body4).includes('host') && !JSON.stringify(body4).includes('/'), JSON.stringify(body4)); + const r5 = await fetch('http://127.0.0.1:8898/api/status'); + check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`); + srvB.kill(); + + // ---------- E:来源判定 ---------- + console.log('\n[E] 来源判定'); + const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`); + const priv = ['127.0.0.1', '::1', '10.1.2.3', '192.168.1.10', '172.16.0.1', '172.31.255.254', 'fc00::1', 'fe80::1', '::ffff:127.0.0.1']; + const pub = ['8.8.8.8', '1.1.1.1', '172.32.0.1', '11.0.0.1', '2001:4860:4860::8888']; + check('内网/回环地址被允许', priv.every(isPrivateAddress), priv.filter((i) => !isPrivateAddress(i)).join(',') || '全部通过'); + check('公网地址被拒绝', pub.every((i) => !isPrivateAddress(i)), pub.filter(isPrivateAddress).join(',') || '全部通过'); + + // ---------- 收尾 ---------- + sshServer.close(); + silentServer.close(); + fs.rmSync(TMP, { recursive: true, force: true }); + const failed = results.filter((r) => !r.pass); + console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); + if (failed.length) process.exit(1); +} + +main().catch((err) => { + console.error('FAILED:', err.stack || err.message); + process.exit(1); +}); diff --git a/scripts/verify-uptimerobot.cjs b/scripts/verify-uptimerobot.cjs new file mode 100644 index 0000000..086f842 --- /dev/null +++ b/scripts/verify-uptimerobot.cjs @@ -0,0 +1,193 @@ +/** + * shiroko-monitor UptimeRobot 集成验证(开发用) + * + * 覆盖: + * A. 映射单测(无网络):用合成 payload 校验状态/延迟/状态码/心率/聚合比率/元信息,且不泄露 Key + * B. 真实 Key 冒烟(需 UPTIMEROBOT_API_KEY,未设置则跳过) + * C. UptimeRobot 兼容只读接口 /api/v2/getMonitors + * D. 失败路径:Key 无效时只记录错误、不把任何目标误判为离线 + * + * 用法:UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs + */ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); +const PROBER = path.join(ROOT, 'prober', 'node', 'index.js'); +const LIB = path.join(ROOT, 'prober', 'node', 'lib', 'uptimerobot.js'); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-ur-')); +const LIVE_KEY = process.env.UPTIMEROBOT_API_KEY || ''; +const results = []; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function check(name, pass, detail) { + results.push({ name, pass }); + console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); +} + +async function waitForHttp(url, timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fetch(url, { cache: 'no-store' }); + return true; + } catch { + await sleep(200); + } + } + return false; +} + +function runOnce(args) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [PROBER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (out += d)); + child.on('exit', (code) => resolve({ code, out })); + }); +} + +// 合成的 getMonitors 响应:结构照搬真实字段,但内容全部虚构 +const FIXTURE = { + stat: 'ok', + pagination: { offset: 0, limit: 50, total: 3 }, + monitors: [ + { + id: 111111111, + friendly_name: '演示站点 A', + url: 'https://a.example.test', + type: 1, + interval: 300, + status: 2, + create_datetime: 1700000000, + custom_uptime_ratio: '100.000-99.500-99.900-99.950', + custom_down_durations: '0-100-200-300', + average_response_time: 250.5, + response_times: [ + { datetime: 1791880000, value: 300 }, + { datetime: 1791879700, value: 200 }, + { datetime: 1791879400, value: 0 }, + ], + logs: [ + { type: 2, datetime: 1791879700, duration: 300, reason: { code: '200', detail: 'OK' } }, + { type: 1, datetime: 1791879400, duration: 300, reason: { code: '500', detail: 'Internal Server Error' } }, + ], + }, + { + id: 222222222, + friendly_name: '演示端口 B', + url: 'b.example.test', + type: 4, + port: 22, + interval: 300, + status: 9, + custom_uptime_ratio: '0.000-0.000-0.000-0.000', + custom_down_durations: '86400-86400-86400-86400', + average_response_time: 0, + response_times: [], + logs: [{ type: 1, datetime: 1791000000, duration: 0, reason: { code: '403', detail: 'Forbidden' } }], + }, + { + id: 333333333, + friendly_name: '演示暂停项 C', + url: 'https://c.example.test', + type: 1, + interval: 300, + status: 0, + custom_uptime_ratio: '0.000-0.000-0.000-0.000', + response_times: [], + logs: [], + }, + ], +}; + +async function main() { + const { mapUptimeRobotMonitors } = await import(`file://${LIB.replace(/\\/g, '/')}`); + + // ---------- A. 映射单测 ---------- + console.log('\n[A] 映射单测(合成 payload,无网络)'); + const mapped = mapUptimeRobotMonitors(FIXTURE, { group: 'UR 测试', now: 1791880300000 }); + const [a, b, c] = mapped; + check('监控数量与 id 前缀', mapped.length === 3 && a.id === 'ur-111111111', `${mapped.length} 个,首个 id=${a.id}`); + check('status 映射:2→up / 9→down / 0→pending', a.status === 'up' && b.status === 'down' && c.status === 'pending', `${a.status}/${b.status}/${c.status}`); + check('延迟取最新一次样本', a.latency === 300 && b.latency === 0, `A=${a.latency}ms B=${b.latency}ms`); + check('故障时取故障日志里的 HTTP 状态码', b.code === 403 && a.code === null, `B.code=${b.code}`); + check('detail 含 UR 侧原因', b.detail === 'UptimeRobot:403 Forbidden', b.detail); + check('心率来自 response_times 且按时间升序', a.history.length === 3 && a.history[0].ms === 0 && a.history[2].ms === 300, JSON.stringify(a.history.map((h) => h.ms))); + check('离线目标的心率末尾补一条离线记录', b.history.length === 1 && b.history[0].ok === false, `${b.history.length} 条`); + check('聚合比率解析(1/7/30/90)', a.uptimeRatios['90'] === 99.95 && a.downSeconds['30'] === 200, JSON.stringify(a.uptimeRatios)); + check('端口型目标拼出 host:port 形式', b.url === 'b.example.test:22', b.url); + check('来源标识与协议标签', a.source === 'uptimerobot' && a.sourceLabel === 'HTTP' && b.sourceLabel === 'PORT', `${a.sourceLabel}/${b.sourceLabel}`); + check('映射结果不含 API Key 字段', !JSON.stringify(mapped).toLowerCase().includes('apikey'), '无泄露'); + + // ---------- C. 兼容接口 ---------- + console.log('\n[C] UptimeRobot 兼容只读接口'); + const cfg = path.join(TMP, 'monitors.json'); + const out = path.join(TMP, 'status.json'); + fs.writeFileSync(cfg, JSON.stringify({ + settings: { title: 't' }, + monitors: [ + { id: 'ok-site', name: '正常站点', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 }, + ], + }, null, 2)); + const child = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${cfg}`, `--out=${out}`], { env: { ...process.env, PORT: '8891' }, stdio: ['ignore', 'pipe', 'pipe'] }); + await waitForHttp('http://127.0.0.1:8891/api/status'); + const compat = await fetch('http://127.0.0.1:8891/api/v2/getMonitors').then((r) => r.json()); + const first = compat.monitors?.[0] ?? {}; + check('返回 stat=ok 与 pagination', compat.stat === 'ok' && compat.pagination?.total === 1, JSON.stringify(compat.pagination)); + check('字段与 UptimeRobot 对齐', ['id', 'friendly_name', 'url', 'type', 'status', 'interval', 'custom_uptime_ratio', 'average_response_time', 'response_times'].every((k) => k in first), Object.keys(first).join(',')); + check('id 为数字、type/status 编码一致', typeof first.id === 'number' && first.type === 1 && [1, 2, 9].includes(first.status), `id=${first.id} type=${first.type} status=${first.status}`); + check('custom_uptime_ratio 为 1-7-30-90 四位三位小数', /^\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}$/.test(first.custom_uptime_ratio), first.custom_uptime_ratio); + child.kill(); + + // ---------- D. 失败路径:Key 无效不得误判目标状态 ---------- + console.log('\n[D] 失败路径(无效 Key)'); + const badCfg = path.join(TMP, 'bad.json'); + const badOut = path.join(TMP, 'bad-status.json'); + fs.writeFileSync(badCfg, JSON.stringify({ + settings: { title: 't', uptimeRobot: { enabled: true, apiKey: 'ur000000-this-key-is-not-valid' } }, + monitors: [ + { id: 'only-site', name: '唯一目标', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 }, + ], + }, null, 2)); + const r = await runOnce([`--once`, `--config=${badCfg}`, `--local-config=${badCfg}`, `--out=${badOut}`]); + const badSnap = JSON.parse(fs.readFileSync(badOut, 'utf8')); + check('同步失败被记录为 ok:false', badSnap.sources.uptimerobot.ok === false, String(badSnap.sources.uptimerobot.error).slice(0, 60)); + check('同步失败不影响本地目标(无目标被误判为离线)', badSnap.stats.total === 1 && badSnap.stats.up === 1, `total=${badSnap.stats.total} up=${badSnap.stats.up}`); + check('未创建任何 UptimeRobot 镜像目标', Object.values(badSnap.monitors).every((m) => m.meta?.source !== 'uptimerobot'), '无镜像目标'); + + // ---------- B. 真实 Key 冒烟 ---------- + console.log('\n[B] 真实 Key 冒烟'); + if (!LIVE_KEY) { + console.log(' - 跳过(未设置 UPTIMEROBOT_API_KEY)'); + } else { + const liveCfg = path.join(TMP, 'live.json'); + const liveOut = path.join(TMP, 'live-status.json'); + fs.writeFileSync(liveCfg, JSON.stringify({ + settings: { title: 't', uptimeRobot: { enabled: true, apiKey: LIVE_KEY, pollIntervalSec: 300, group: 'UptimeRobot' } }, + monitors: [], + }, null, 2)); + await runOnce([`--once`, `--config=${liveCfg}`, `--local-config=${liveCfg}`, `--out=${liveOut}`]); + const live = JSON.parse(fs.readFileSync(liveOut, 'utf8')); + const mirrors = Object.entries(live.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot'); + check('真实账号同步成功', live.sources.uptimerobot.ok === true, `count=${live.sources.uptimerobot.count}`); + check('拉到监控并全部映射', mirrors.length > 0 && mirrors.every(([, m]) => ['up', 'down', 'pending'].includes(m.status)), `${mirrors.length} 个`); + check('每个镜像目标都带展示元信息', mirrors.every(([, m]) => m.meta.name && Array.isArray(m.history) && m.uptimeRatios), 'name/history/uptimeRatios 齐备'); + check('快照中不含 API Key', !JSON.stringify(live).includes(LIVE_KEY.slice(-12)), '无泄露'); + for (const [id, m] of mirrors) console.log(` · ${id.padEnd(14)} ${m.status.padEnd(7)} ${String(m.latency ?? '-').padStart(5)}ms UR90天=${m.uptimeRatios?.['90']} ${m.meta.name}`); + } + + fs.rmSync(TMP, { recursive: true, force: true }); + const failed = results.filter((x) => !x.pass); + console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); + if (failed.length) process.exit(1); +} + +main().catch((err) => { + console.error('FAILED:', err.stack || err.message); + fs.rmSync(TMP, { recursive: true, force: true }); + process.exit(1); +}); diff --git a/scripts/verify-views.cjs b/scripts/verify-views.cjs new file mode 100644 index 0000000..50b381e --- /dev/null +++ b/scripts/verify-views.cjs @@ -0,0 +1,130 @@ +/** + * shiroko-monitor 四种视图验证(开发用,需 playwright) + * + * 覆盖:卡片 / 表格 / 大屏 / 极简 四视图的渲染数量、切换器、URL 指定、hideTargets 脱敏、无 JS 错误 + * + * 用法:NODE_PATH= node scripts/verify-views.cjs [baseUrl] + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +const BASE = process.argv[2] || 'http://localhost:8787'; +const CONFIG = path.join(__dirname, '..', 'public', 'monitors.json'); +const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; +const results = []; + +function check(name, pass, detail) { + results.push({ name, pass }); + console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); +} + +const SELECTOR = { cards: '.card', table: 'table.monitor-table tbody tr[data-status]' }; + +async function main() { + const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const errors = []; + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); + + const snap = await fetch(`${BASE}/api/status`, { cache: 'no-store' }).then((r) => r.json()); + const shown = Object.keys(snap.monitors).length; + + // 默认视图:不带 ?view= 时应为表格 + await page.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector(SELECTOR.table, { timeout: 15000 }); + check('默认视图为表格', (await page.evaluate(() => document.body.dataset.view)) === 'table', `data-view=${await page.evaluate(() => document.body.dataset.view)}`); + check('切换器只有两个选项(表格/卡片)', (await page.evaluate(() => document.querySelectorAll('.view-btn').length)) === 2, '2 个'); + + for (const view of ['table', 'cards']) { + await page.goto(`${BASE}/?source=api&view=${view}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector(SELECTOR[view], { timeout: 15000 }); + // 等首次快照渲染完成(不再有 pending 条目)再截图,避免拍到「等待检测」中间态 + await page.waitForFunction(() => !document.querySelector('[data-status="pending"]'), null, { timeout: 40000 }); + await page.waitForTimeout(400); + const info = await page.evaluate((sel) => ({ + bodyView: document.body.dataset.view, + count: document.querySelectorAll(sel).length, + activeBtn: document.querySelector('.view-btn.active')?.textContent ?? '', + }), SELECTOR[view]); + check(`${view} 视图渲染 ${shown} 个条目`, info.count === shown, `count=${info.count}, 切换器=${info.activeBtn}`); + await page.screenshot({ path: path.join(__dirname, '..', 'docs', `screenshot-view-${view}.png`), fullPage: true }); + } + + // 切换器持久化:点击后写入 localStorage,刷新后仍生效(URL 不带 ?view= 时不覆盖用户选择) + await page.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector(SELECTOR.table, { timeout: 15000 }); + await page.click('.view-btn:nth-child(2)'); // 卡片 + await page.waitForSelector(SELECTOR.cards, { timeout: 10000 }); + const saved = await page.evaluate(() => localStorage.getItem('shiroko.view')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector(SELECTOR.cards, { timeout: 15000 }); + check('视图切换被记住(localStorage),刷新后仍为卡片', saved === 'cards' && (await page.evaluate(() => document.body.dataset.view)) === 'cards', `saved=${saved}`); + await page.evaluate(() => localStorage.removeItem('shiroko.view')); + + // dataFilter:仅配置 / 仅 UptimeRobot / 两者 + const cfgAll = JSON.parse(fs.readFileSync(CONFIG, 'utf8')); + const configCount = cfgAll.monitors.length; + const urCount = Object.values(snap.monitors).filter((m) => m.meta?.source === 'uptimerobot').length; + console.log(` 配置目标 ${configCount} 个,UptimeRobot 镜像 ${urCount} 个`); + const rawCfg = fs.readFileSync(CONFIG, 'utf8'); + try { + for (const [mode, expect] of [['config', configCount], ['uptimerobot', urCount], ['both', configCount + urCount]]) { + const c = JSON.parse(rawCfg); + c.settings.dataFilter = mode; + fs.writeFileSync(CONFIG, `${JSON.stringify(c, null, 2)}\n`); + await new Promise((r) => setTimeout(r, 900)); // 等探针热重载 + await page.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' }); + if (expect > 0) await page.waitForSelector(SELECTOR.table, { timeout: 15000 }); + else await page.waitForTimeout(1500); + const n = await page.evaluate((sel) => document.querySelectorAll(sel).length, SELECTOR.table); + const snapNow = await fetch(`${BASE}/api/status`, { cache: 'no-store' }).then((r) => r.json()); + check(`dataFilter=${mode} 时面板与快照口径一致(${expect} 个)`, n === expect && snapNow.dataFilter === mode, `面板 ${n} 个,快照 dataFilter=${snapNow.dataFilter}`); + } + } finally { + // 还原并校验:测试改配置必须原样恢复,否则会静默改坏仓库里的配置 + fs.writeFileSync(CONFIG, rawCfg); + if (fs.readFileSync(CONFIG, 'utf8') !== rawCfg) { + fs.writeFileSync(CONFIG, rawCfg); + check('测试后配置原样还原', false, '首次还原后内容不一致,已重写'); + } else { + check('测试后配置原样还原', true, 'monitors.json 未被测试改动'); + } + } + + // hideTargets:开启后面板不渲染任何目标地址 + const raw = fs.readFileSync(CONFIG, 'utf8'); + const cfg = JSON.parse(raw); + cfg.settings.hideTargets = true; + fs.writeFileSync(CONFIG, `${JSON.stringify(cfg, null, 2)}\n`); + try { + await page.goto(`${BASE}/?source=api&view=cards`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.card', { timeout: 15000 }); + await page.waitForTimeout(800); + const leak = await page.evaluate(() => { + const html = document.getElementById('monitor-grid').innerHTML; + return /https?:\/\//.test(html) || /\d+\.\d+\.\d+\.\d+:\d+/.test(html); + }); + const target = await page.evaluate(() => document.querySelector('.card-target')?.textContent ?? ''); + check('hideTargets 开启后面板不渲染目标地址', !leak && target === '已隐藏', `target="${target}"`); + } finally { + fs.writeFileSync(CONFIG, raw); // 还原 + } + + // 429 是刷新限流保护在起作用(快速连续导航各触发一次强制探测),面板已优雅降级为只读数据; + // 这里只把 429 以外的错误视为失败 + const hardErrors = errors.filter((e) => !/429/.test(e)); + check('两种视图无 JS 错误(除限流保护外)', hardErrors.length === 0, hardErrors.length ? hardErrors.join(' | ') : `无;另有 ${errors.length} 次 429 —— 快速连续刷新触发服务端限流,面板已回退到只读数据`); + await browser.close(); + + const failed = results.filter((r) => !r.pass); + console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); + if (failed.length) process.exit(1); +} + +main().catch((err) => { + console.error('FAILED:', err.stack || err.message); + process.exit(1); +}); diff --git a/scripts/verify.cjs b/scripts/verify.cjs new file mode 100644 index 0000000..1702c0c --- /dev/null +++ b/scripts/verify.cjs @@ -0,0 +1,191 @@ +/** + * shiroko-monitor 端到端验证脚本(开发用,需 playwright) + * + * 覆盖: + * A. 热重载回归:改写 monitors.json 触发配置热重载后,/api/status 必须继续更新(历史 bug:接口冻结) + * B. 接口与文件一致性:/api/status 与 status.json 的 lastCheck 必须一致 + * C. 面板重渲染:跨越一个刷新周期后,页脚「更新于」必须前进 + * D. UI 断言:卡片数、心率图、90 格可用率条、统计栏 + * + * 用法:NODE_PATH= node scripts/verify.cjs [baseUrl] + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +const BASE = process.argv[2] || 'http://localhost:8787'; +const CONFIG = path.join(__dirname, '..', 'public', 'monitors.json'); +const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const results = []; + +function check(name, pass, detail) { + results.push({ name, pass, detail }); + console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); +} + +async function getJson(url) { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`); + return res.json(); +} + +async function main() { + // ---------- A/B. 服务端数据新鲜度与一致性 ---------- + const cfgA = JSON.parse(fs.readFileSync(CONFIG, 'utf8')); + const cfgIdsA = new Set(cfgA.monitors.map((m) => m.id)); + const first = (await getJson(`${BASE}/api/status`)).monitors; + // 优先选配置里的自建探测目标;若当前 dataFilter 只展示外部数据源,则退化为一致性/新鲜度断言 + const probeId = Object.keys(first).find((id) => cfgIdsA.has(id)) ?? Object.keys(first)[0]; + const isProbeTarget = cfgIdsA.has(probeId); + const before = first[probeId].lastCheck; + console.log(`\n[A] 触发配置热重载(改写 monitors.json),监控目标 ${probeId}${isProbeTarget ? '(自建探测)' : '(外部数据源镜像)'}`); + console.log(` 热重载前 lastCheck: ${before}`); + // 原样写回(不改格式)即可触发 fs.watch 的 change 事件 + fs.writeFileSync(CONFIG, fs.readFileSync(CONFIG, 'utf8')); + + await sleep(12000); + const mid = await getJson(`${BASE}/api/status`); + if (isProbeTarget) { + check('/api/status 热重载后 12s 内已产生新检测', mid.monitors[probeId].lastCheck > before, `lastCheck=${mid.monitors[probeId].lastCheck}`); + } else { + // 外部镜像目标由数据源按自身节奏更新,热重载不会让它产生新检测;改为校验服务端仍在产出数据 + const ageSec = Math.round((Date.now() - new Date(mid.dataUpdatedAt).getTime()) / 1000); + check('热重载后接口仍持续服务且数据在节奏内', mid.monitors[probeId] != null && ageSec <= Number(mid.updateIntervalSec) + 30, `数据距今 ${ageSec}s / 节奏 ${mid.updateIntervalSec}s`); + } + + await sleep(60000); + const api = await getJson(`${BASE}/api/status`); + const file = await getJson(`${BASE}/status.json`); + if (isProbeTarget) { + check('/api/status 跨越一个探测周期后仍在更新', api.monitors[probeId].lastCheck > mid.monitors[probeId].lastCheck, `lastCheck=${api.monitors[probeId].lastCheck}`); + } + check('接口与 status.json 数据一致', api.monitors[probeId].lastCheck === file.monitors[probeId].lastCheck, `api=${api.monitors[probeId].lastCheck} file=${file.monitors[probeId].lastCheck}`); + const dailyKeys = Object.keys(api.monitors[probeId].daily ?? {}); + check('90 天可用率数据已聚合且不超过 90 天', dailyKeys.length > 0 && dailyKeys.length <= 90, `days=${dailyKeys.length}, today=${JSON.stringify(api.monitors[probeId].daily[dailyKeys[dailyKeys.length - 1]])}`); + check('统计包含 avgUptime90', api.stats.avgUptime90 != null, `avgUptime90=${api.stats.avgUptime90}`); + + // ---------- E. 刷新即重新检测 ---------- + console.log('\n[E] 刷新即重新检测'); + const cfg = JSON.parse(fs.readFileSync(CONFIG, 'utf8')); + const snapMeta = await getJson(`${BASE}/api/status`); + const expectedCards = Object.keys(snapMeta.monitors).length; + // 外部数据源(UptimeRobot)镜像目标不参与本地强制探测,其「最近检测」由数据源给出 + const externalIds = Object.entries(snapMeta.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot').map(([id]) => id); + // dataFilter 只展示外部数据源时,配置目标不在快照里,探测类断言不适用 + const cfgIds = new Set(cfg.monitors.map((m) => m.id)); + const probeIds = Object.keys(snapMeta.monitors).filter((id) => cfgIds.has(id)); + const probeMode = probeIds.length > 0; + const forceIds = cfg.monitors.filter((m) => m.probeOnRefresh !== false && probeIds.includes(m.id)).map((m) => m.id); + // 显式 opt-out 的配置目标:不参与任何强制探测 + const optOutIds = cfg.monitors.filter((m) => m.probeOnRefresh === false && probeIds.includes(m.id)).map((m) => m.id); + const pre = await getJson(`${BASE}/api/status`); + const t0 = Date.now(); + const roundStartIso = new Date(t0).toISOString(); + const fresh = await fetch(`${BASE}/api/refresh`).then((r) => r.json()); + const roundMs = Date.now() - t0; + check('/api/refresh 等待本轮探测完成后才返回', roundMs > 100, `round=${roundMs}ms`); + if (probeMode) { + const ages = forceIds.map((id) => [id, Math.round((Date.now() - new Date(fresh.monitors[id].lastCheck)) / 1000)]); + const worst = ages.length ? Math.max(...ages.map(([, a]) => a)) : 0; + check('刷新后参与强制检测的目标全部产生新检测', forceIds.every((id) => fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck), `${forceIds.filter((id) => !(fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck)).join(',') || '全部前进'}`); + check('刷新后最旧数据不超过本轮耗时', worst <= Math.ceil(roundMs / 1000) + 3, `最旧 ${worst}s(轮次 ${Math.round(roundMs / 1000)}s)`); + // 外部镜像目标会被顺带同步(受 60s 最小间隔约束),故不在此断言其时间不变 + check('probeOnRefresh:false 的目标不参与强制检测', optOutIds.length === 0 || optOutIds.every((id) => fresh.monitors[id].lastCheck <= roundStartIso), optOutIds.length ? `${optOutIds.join(',')} 未参与(另有 ${externalIds.length} 个外部镜像目标由数据源同步)` : '无此类目标'); + } else { + console.log(` - 跳过强制探测断言:当前 dataFilter=${snapMeta.dataFilter},快照中无配置目标(${externalIds.length} 个外部镜像目标由数据源同步)`); + } + + // ---------- G. 刷新节奏对齐服务端 ---------- + console.log('\n[G] 刷新节奏对齐服务端(不再每访客重新计时)'); + const align = await getJson(`${BASE}/api/status`); + const cadence = Number(align.updateIntervalSec); + const updatedMs = new Date(align.dataUpdatedAt).getTime(); + check('快照带 dataUpdatedAt 与 updateIntervalSec', Number.isFinite(updatedMs) && updatedMs > 0 && cadence >= 5, `更新于 ${align.dataUpdatedAt},节奏 ${cadence}s`); + const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] }); + const pageG = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await pageG.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' }); + await pageG.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 }); + await pageG.waitForTimeout(1200); + const shownText = await pageG.evaluate(() => document.getElementById('countdown').textContent); + const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]); + // 期望值需在当前时刻重算:面板显示的是「服务端上次更新 + 一个周期 - 现在」 + const expectSec = Math.round((updatedMs + cadence * 1000 - Date.now()) / 1000); + check('倒计时对齐服务端下次更新时刻', Math.abs(shownSec - expectSec) <= 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`); + check('倒计时不会退化为从整周期重新计时', shownSec <= Math.max(5, Math.min(cadence, Number(cfg.settings.refreshInterval) || cadence)), `显示 ${shownSec}s`); + await pageG.close(); + + // ---------- F. 面板渲染与自动刷新 ---------- + console.log('\n[F] 面板渲染与刷新'); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const errors = []; + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); + + await page.goto(`${BASE}/?source=api&view=cards`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.card', { timeout: 15000 }); + await page.waitForTimeout(1200); + + const read = () => page.evaluate(() => ({ + cards: document.querySelectorAll('.card').length, + bars: document.querySelectorAll('.heartbeat svg rect').length, + uptimeCells: document.querySelectorAll('.uptime-cell').length, + uptimeCellsPerCard: document.querySelector('.card .uptime-bar')?.children.length ?? 0, + uptimeLabel: document.querySelector('.uptime-label')?.textContent ?? '', + uptimeLabelTip: document.querySelector('.uptime-label')?.getAttribute('title') ?? '', + statUptime: document.getElementById('stat-uptime').textContent, + stats: document.querySelectorAll('.summary .stat').length, + updated: document.getElementById('updated-at').textContent, + lastCheckTitle: document.querySelector('.card .metric b[title]')?.getAttribute('title') ?? '', + countdown: document.getElementById('countdown').textContent, + version: document.querySelector('.version')?.textContent ?? '', + })); + + const a = await read(); + console.log(` 首次读取: 卡片 ${a.cards},可用率条 ${a.uptimeCellsPerCard} 格/卡,标签「${a.uptimeLabel}」`); + check('卡片全部渲染', a.cards === expectedCards, `cards=${a.cards}/${expectedCards}`); + check('每卡 90 格可用率条', a.uptimeCellsPerCard === 90, `perCard=${a.uptimeCellsPerCard}`); + check('可用率标签含百分比', /90天\s*[\d.]+%/.test(a.uptimeLabel), `label=${a.uptimeLabel}`); + check('可用率悬浮提示含统计明细', /次在线/.test(a.uptimeLabelTip), `title=${a.uptimeLabelTip}`); + check('顶部 90天可用率统计已渲染', a.statUptime !== '–', `stat=${a.statUptime}`); + check('最近检测带绝对时间提示', a.lastCheckTitle !== '–' && a.lastCheckTitle.length > 0, `title=${a.lastCheckTitle}`); + check('版本号已渲染', /^shiroko-monitor v\d+\.\d+\.\d+$/.test(a.version), a.version); + + console.log(' 等待一个检查周期(70s)…'); + await sleep(70000); + const b = await read(); + // 自动刷新是只读的:页脚「数据更新于」只在服务端产出新数据时变化,故用倒计时重新对齐来判断面板仍在按节奏轮询 + check('面板持续按服务端节奏轮询(倒计时重新对齐)', a.countdown !== b.countdown, `${a.countdown} -> ${b.countdown}`); + if (probeMode) { + check('探测目标数据已流入面板(最近检测前进)', b.lastCheckTitle > a.lastCheckTitle, `${a.lastCheckTitle} -> ${b.lastCheckTitle}`); + } + + // 手动刷新按钮:参与的卡片都必须重新检测,opt-out 的卡片不应被带动(历史 bug:只有部分目标更新) + const readCards = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.card')].map((c) => [c.dataset.id, c.querySelector('.metric b[title]')?.getAttribute('title') ?? '']))); + const beforeCards = await readCards(); + await page.click('#refresh-btn'); + await page.waitForFunction(() => document.getElementById('refresh-btn').textContent === '刷新' && !document.getElementById('refresh-btn').disabled, null, { timeout: 45000 }); + await page.waitForTimeout(400); + const afterCards = await readCards(); + const notAdvanced = forceIds.filter((id) => !(afterCards[id] > beforeCards[id])); + const optOutAdvanced = optOutIds.filter((id) => afterCards[id] > beforeCards[id]); + if (probeMode) { + check('参与检测的卡片全部重新检测(手动刷新)', notAdvanced.length === 0, notAdvanced.length ? `${notAdvanced.join(',')} 未前进` : `${forceIds.length} 张全部前进`); + check('probeOnRefresh:false 的卡片不被手动刷新带动', optOutAdvanced.length === 0, optOutIds.length ? `${optOutIds.length} 张保持原时间${externalIds.length ? `(另有 ${externalIds.length} 张外部镜像由数据源同步,允许更新)` : ''}` : '无此类目标'); + } + + check('刷新过程中无 PageError', errors.length === 0, errors.length ? errors.join(' | ') : '无'); + + await page.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-desktop.png'), fullPage: true }); + await browser.close(); + + const failed = results.filter((r) => !r.pass); + console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); + if (failed.length) process.exit(1); +} + +main().catch((err) => { + console.error('FAILED:', err.stack || err.message); + process.exit(1); +});