Compare commits

..
10 Commits
Author SHA1 Message Date
tamakyi 25b8b97970 fix: 表格完整显示 + 事件区块样式重做 + 探测范围可选(probeScope)
1) 表格显示不全
   table-layout:fixed 保证不横向滚动后,列内仍是 nowrap,实测长 URL 被截
   388px、名称截 219px、可用率条截 35px(1440 宽共 14 个单元格)。
   改为允许换行(地址 break-all)并重排列宽 —— 数据完整且仍零横向滚动。

2) 最近事件完全没样式
   根因:上次追加 CSS 的命令是 `node --check app.js && cat >> style.css`,
   --check 报错导致 && 短路,CSS 从未写入(只有 HTML 裸奔)。
   本次用编辑器写入;同时重做为时间线样式(竖线 + 类型着色圆点 + 左描边)、
   名称列按内容自适应(消除大片空白)、默认折叠最近 8 条可展开全部。

3) dataFilter=uptimerobot 时仍在探测本地目标
   新增 settings.probeScope(all|config|uptimerobot),不填则跟随 dataFilter;
   显式设 all 可保留探测。启动日志打印当前口径与范围。

顺带修复两个连带问题:
- UR 镜像目标此前不产生事件也不告警(evaluateRound 只遍历本地目标),
  配 uptimerobot 口径等于收不到任何提醒 —— 现在两类目标统一纳入
- 去抖计数被评估频率放大:evaluateRound 每秒执行,原实现每次评估都
  failStreak+1,持续离线目标 1 秒即达阈值告警。改为按探测时间戳去重,
  且无状态变化时不再反复落盘

测试:verify-views [J] 扩到 11 条(长内容不截断/仍不滚动/时间线布局/
类型着色/折叠展开),verify-notify 新增 [D3] 4 条(范围跟随、不再探测本地、
显式 all 恢复、与展示口径解耦)+ 去抖计数断言。六套 170 项全通过。
2026-10-14 10:22:05 +08:00
tamakyi 4bb5296525 feat: P1 收尾 —— 事件面板区块与 90 天响应时间趋势
事件时间线(面板侧):
- 监控列表下方「最近事件」区块(最近 20 条:时间/类型/目标/详情),
  无事件自动隐藏;维护窗口内事件带「维护」标记;窄屏隐藏详情列

响应时间趋势(P1-2):
- 当日分桶在 {total,up} 外累计 latSum/latCount/latMax(仅在线样本)
- 快照输出 lat: { avg, max, days:[{key,avg}] }
- 卡片视图:可用率条下方新增「90 天日均延迟」细柱图(最近 30 天 + 均值)
- 表格视图:可用率列 tooltip 追加「90 天日均延迟 X ms(峰值 Y ms)」
- --demo 同步生成 90 天延迟样例(含起伏),静态部署也能看到效果

验证:verify-views 新增 [J] 段 5 条断言(趋势条/30 格/tooltip/事件空态/无溢出),
六套合计 159 项全通过(17+35+26+20+39+22)。

至此 P0(通知、证书)与 P1(事件时间线、维护窗口、响应时间趋势)全部落地。
2026-10-14 00:32:59 +08:00
tamakyi bdaa31f10c feat: P0 通知(邮件 + MeoW)与 TLS 证书监控,事件时间线与维护窗口
通知(P0-1):
- 邮件:自写 SMTP 客户端,零依赖。465 隐式 TLS / 587·25 STARTTLS、
  AUTH LOGIN+PLAIN、RFC2047 中文主题、base64 正文、多收件人;
  服务器不支持 STARTTLS 时默认拒绝明文(需显式 allowInsecure)
- MeoW:按官方文档实现,昵称寻址、msgType/htmlHeight 走查询参数、
  以响应体 status 判定成功(HTTP 恒 200)、429/404/403 可读化、
  本地限流器遵守 3s/15min/60h
- 告警:连续 N 次失败去抖 + 恢复通知 + 同轮合并 + 去抖状态持久化
  (data/notify-state.json,gitignore)+ 静默时段(跨零点)
- --test-notify 自检命令、--data-dir / SHIROKO_DATA_DIR

证书(P0-2):
- 独立 TLS 握手取 notAfter(不动既有 fetch 探测路径),默认 6 小时一次
- 面板徽标 ≤14 天黄 / ≤7 天红,阈值 14/7/3 各告一次、已过期只报最严重一档
- 单个目标可用 tls: false 关闭

事件与维护(P1-1 的后端部分):
- data/events.json 追加式事件流(最近 500 条)+ GET /api/events +
  快照内附最近 20 条;redactDetails 时脱敏
- 维护窗口:窗口内不计入可用率、不发告警、显示「维护中」徽标

测试抓到并修复的三个真 bug:
1. SMTP 空闲超时无人处理 → 服务器不响应会把探针循环挂死
2. MeoW 限流器把空历史当 0 → 第一条通知永远被自己拦掉
3. 去抖状态在无事轮次不落盘 → 连续失败计数丢失、永远达不到阈值

验证:新增 verify-notify.cjs 39 项(假 SMTP / 假 MeoW / 三轮集成 / 维护窗口 /
真实证书握手),六套合计 154 项全通过。
2026-10-13 23:54:46 +08:00
tamakyi ea846dee31 fix(layout): 表格流式列宽,任何内容与视口都不再需要横向滚动
硬要求(用户明确):不允许出现需要滚动才能看全的情况。

改动:
- 表格 table-layout: fixed + 百分比列宽(8/16/21/8/7/11/17/12%),
  表头允许换行并裁剪、单元格裁剪、心率图与可用率条随宽度伸缩
- 卡片网格 minmax(min(300px,100%),1fr) + .card{min-width:0}
  (修 grid 的 min-width:auto 撑破:320px 下曾溢出 495px)
- 截断的名称/目标补 title,悬停可看完整值
- 新增 scripts/verify-layout.cjs:自带极端配置(40 目标 / 6 分组 /
  超长名称与 URL)+ 临时静态服务,扫描 13 视口 × 2 视图,26 项断言零横向滚动

验证:五套 115 项全通过(17+30+26+20+22)
2026-10-13 22:37:02 +08:00
tamakyi a7408e2bb5 feat(theme): 全局装饰遮罩 + 尺寸统一;全局审阅修复对比度/暗色/平板布局
用户反馈的两点:
1. 遮罩看不出效果 → 人物与单车统一全局半透明 settings.decoOpacity(默认 .78)
   (此前 0.92/0.80 不一致且几乎无感),各自保留边缘渐隐
2. 切视图时人物比例变化 → 删掉卡片视图专用的 300px 高度,两种视图尺寸一致
3. 保留原始素材 → 原图归档 assets-src/,处理流程固化为 scripts/prepare-artwork.py

全局审阅发现并修复:
- 无障碍对比度:两套主题的状态色当文字用时远低于 AA(最低 2.56:1)→ 双色体系
  (填充色 ≥3:1 保持鲜亮 / 文字色 ≥4.5:1 同色系加深),暗色模式同步
- 暗色模式主题失效:顶栏/卡片/表头/分组行是硬编码白色 → 改为随模式切换的变量
- 平板竖屏 768px 表格需内部横滚 242px → 自动切卡片阈值 700 → 1024
- Cloudflare Worker 快照补齐 dataUpdatedAt/updateIntervalSec/dataFilter
- 清理失效 CSS(.logo .halo)

测试脚本修掉两个自身 bug(均为误报):color(srgb/…) 与半透明底的解析、
渐变背景被误判为透明。verify-views 新增对比度断言 8 条,四套合计 89 项全通过。
2026-10-13 21:55:44 +08:00
tamakyi 6f9a2d33ce fix(theme): 补抠封闭白区(光环/车架/轮圈内),人物与单车加渐隐遮罩
问题(用户发现):
1. 光环环内是白块 —— 此前只用「边界洪泛」抠底,被图形围起来的封闭白色
   区域(光环内圈、单车车架三角、轮圈内扇形)与边界不连通,没被处理
2. 人物与单车直接贴在页面上显得突兀

修复:
- 对三张图再补一次「封闭白色区域」清理(按面积阈值):
  人物抠 3675px(含光环 2665)、单车抠 19657px、图标抠 308px;并同步重生成 favicon
  已验证脸部 / 袜子 / 衣服等服装白未被误抠(保留 34px 噪点级)
- 立绘 opacity .92 + 上下渐隐遮罩;单车 opacity .8 + 两端渐隐 + saturate(.92)

验证:四套脚本 81 项全通过;1440 宽 0 横向溢出;局部放大确认光环内圈可透出背景
2026-10-13 21:06:29 +08:00
tamakyi fa7c708878 feat(theme): 左下角公路车也换成简约扁平风插画
- 用图像生成(同一套扁平风格)+ Pillow 抠底,替换自绘线稿 deco-bicycle.svg
- 新素材 public/assets/shiroko-bike.webp(341x200,27KB,透明底)
- 页脚下方装饰带(公路车 / 光环水印 / 人物立绘)视觉语言统一
- README 同步更新元素表、素材来源表与目录树

验证:四套脚本 81 项全通过;1440 宽下 0 横向溢出
2026-10-13 20:51:09 +08:00
tamakyi f0e5d91171 fix(mobile): 修移动端顶栏塌陷,窄屏默认卡片视图
问题(用户报告移动端异常):窄屏下顶栏统计挤成竖排并与标题重叠,
表格在手机上必须横向拖动,固定装饰在小屏上可能溢出或压住内容。

修复:
- 顶栏结构拆为「标题 / 统计(.stats) / 控件(.controls)」三段;
  ≤820px 改为纵向三行,统计均分视口宽度,页脚改纵向堆叠
- ≤700px 且用户未显式选择视图时自动用卡片(横竖屏切换时重算,
  显式选择仍尊重 localStorage)
- ≤820px(及横屏手机)撤掉立绘、公路车、光环水印等固定装饰

验证:
- 390x844 / 360x640 / 414x896 / 844x390 四种视口:0 横向溢出,顶栏三行
- verify-views 新增移动端 7 条断言(22/22 通过)
- 四套合计 81 项全通过(17+22+20+22)
2026-10-13 20:38:39 +08:00
tamakyi 5b417c84f4 fix(theme): 修横向拖动 + 立绘遮光环,形象换成简约风插画
问题与修复:
1. 桌面端表格/列表需要横向拖动 —— 上一版为避让立绘给 .grid/.footer 加了
   240px 右内边距,而 .grid 是 max-width:1200px,内边距吃在 1200 内部,
   1200px 以上的屏幕也必然溢出。改为立绘收窄 + 置于内容之下(z-index 低于面板)
2. 立绘遮挡光环水印 —— 光环水印移到页脚下方空带,与左下公路车、右下立绘形成
   一条互不遮挡的装饰带
3. 形象换成简约风插画(AI 生成后抠底):右下角全身立绘 + 同款半身像做 logo/favicon;
   官方立绘/头像(SchaleDB 素材)已从仓库移除

验证:
- 1280/1440/1920 三种宽度下页面与表格均 0 溢出
- verify-views 新增 4 条断言(两视图无横向溢出、立绘不影响阅读),15/15 通过
- 四套脚本合计 74 项全通过
2026-10-13 19:56:46 +08:00
tamakyi d88ba56156 feat(theme): 砂狼白子主题 —— 真实立绘/头像 + 自绘装饰
素材(第三方,仅个人自用):
- 立绘/头像来自 SchaleDB(schaledb.com),学生 Id 10010 = 砂狼白子
- 版权归 NEXON Games / Yostar(蔚蓝档案),来源与公开部署提示见 README「主题」章节

自绘装饰(SVG/CSS,按公开设定还原):
- 天蓝围巾飘带(顶栏底边)、公路车(左下角 + 加载车轮)
- 光环水印:倾斜椭圆环 + 四方位带圆头短杆(对齐官方造型,此前误画为正圆+三角)
- 倒十字发饰分组标识、迷你光环状态点

其他:主题切换(settings.theme / ?theme= / 顶栏按钮)、立绘位 settings.artwork
修复:dataFilter=uptimerobot 时平均延迟恒为「–」(改为纯外部来源时以其为唯一口径)
2026-10-13 19:29:07 +08:00
35 changed files with 3348 additions and 679 deletions
+2
View File
@@ -9,3 +9,5 @@ local.monitors.json
backups/
# 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行)
.workbuddy/
# 运行期私有数据:通知去抖状态与事件时间线
data/
+264 -11
View File
@@ -25,6 +25,7 @@
- *接入*:把 UptimeRobot 已有的监控结果当数据源拉进来,与自建探测并列展示(异地视角),状态、延迟、90 天可用率、心率图全部映射自 UR 官方数据;UR 读取失败时保留上次数据,绝不把服务误判为离线
- *输出*:提供 `/api/v2/getMonitors`,字段与状态码编码对齐 UptimeRobot v2,既有脚本/看板可直接消费
- **滥用防护与信息泄露控制**:刷新接口仅内网/回环可触发、按来源冷却(429 + Retry-After)、可整体关闭;支持 `hideTargets` / `redactDetails` 与 `local.monitors.json` 私有配置分层,公开配置里可以只有服务名 —— 详见 [docs/SECURITY.md](docs/SECURITY.md)
- **主题:砂狼白子**(默认启用,可一键切回默认主题):简约风插画形象(右下角立绘 + 头像 logo/favicon,宽屏不遮挡表格)、青色光环水印、天蓝围巾飘带顶栏、倒十字发饰、公路车轮加载态。素材来源见 [主题](#主题砂狼白子)
- **三种数据源自动解析**:边缘 API(实时)→ 静态快照 → 浏览器直连,也可用 `?source=` 强制指定
## 快速开始(本地)
@@ -44,6 +45,12 @@ node prober/node/index.js --once --demo
# 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处)
node prober/node/index.js --once --demo --out=dist/status.json
# 通知渠道自检:给每个启用的渠道发一条测试消息(配好 SMTP / MeoW 后先跑这个)
node prober/node/index.js --test-notify
# 通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR)
node prober/node/index.js --data-dir=/var/lib/shiroko-monitor
```
启动后打开 <http://localhost:8787/> 即可看到面板。无需安装任何依赖。
@@ -60,11 +67,17 @@ shiroko-monitor/
│ ├── monitors.json # ★ 监控目标配置(改这里)
│ ├── status.json # 探针输出的状态快照(面板读取)
│ └── assets/
│ ├── style.css # 浅色主题 + 暗色自适应
│ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测
│ └── favicon.svg
│ ├── style.css # 基础样式(浅色 + 暗色自适应)
│ ├── theme-shiroko.css # 砂狼白子主题(仅在 body[data-theme=shiroko] 下生效)
│ ├── shiroko-figure.webp # 简约风立绘(AI 生成后抠底,见「主题」章节)
│ ├── shiroko-icon.webp # 简约风半身头像(同上)
│ ├── favicon-shiroko.png # 白子主题图标(由头像生成)
│ ├── shiroko-bike.webp # 简约风公路车插画(AI 生成后抠底)
│ ├── deco-scarf.svg # 自绘:天蓝围巾飘带
│ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测 / 主题切换
│ └── favicon.svg # 默认主题图标
├── prober/node/ # Node 探针(本地 / CI 用)
│ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config
│ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config/--test-notify
│ └── lib/
│ ├── http-probe.js # HTTP 探测(fetch,redirect: manual)
│ ├── tcp-probe.js # TCP 探测(net.connect)
@@ -74,7 +87,10 @@ shiroko-monitor/
│ ├── verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板渲染
│ ├── verify-views.cjs # 视图渲染、切换持久化与数据源过滤
│ ├── verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流
│ └── verify-uptimerobot.cjs # UR 映射单测 / 兼容接口 / 失败路径 / 真实 Key 冒烟
│ ├── verify-uptimerobot.cjs # UR 映射单测 / 兼容接口 / 失败路径 / 真实 Key 冒烟
│ ├── prepare-artwork.py # 从 assets-src/ 原图生成透明素材(边界 + 封闭区域两步抠图)
│ └── verify-layout.cjs # 布局回归:极端内容 × 13 视口 × 2 视图,断言零横向滚动
├── assets-src/ # 插画原始素材(AI 生成的白底原图,供重新处理)
├── local.monitors.json # 私有配置(不入库):真实地址、API Key
├── edge/cloudflare/ # Cloudflare Workers 边缘探针
│ ├── worker.js # cron 探针 + KV 存储 + /api/* + 静态伺服
@@ -98,7 +114,11 @@ shiroko-monitor/
"title": "Shiroko Monitor", // 面板标题
"description": "轻量级服务监控面板",
"view": "table", // 默认视图:table | cards
"dataFilter": "both", // both | config | uptimerobot(详见「面板形态与数据源」)
"theme": "shiroko", // shiroko(砂狼白子)| default
"artwork": "", // 立绘图片路径(相对 public/),留空则不显示
"decoOpacity": 0.78, // 人物/单车装饰的全局透明度(0~1,一个值同时作用于两者)
"dataFilter": "both", // 展示口径:both | config | uptimerobot(详见「面板形态与数据源」)
"probeScope": "all", // 探测范围:all | config | uptimerobot;不填则**跟随 dataFilter**
"refreshInterval": 30, // 面板检查数据的最大间隔(秒,最小 5);实际对齐服务端更新节奏
"dataSource": "auto", // auto | api | static | browser
"allowRefresh": true, // 是否允许触发强制探测(公网部署建议 false;详见 docs/SECURITY.md)
@@ -106,6 +126,14 @@ shiroko-monitor/
"refreshCooldownSec": 5, // 每个来源的触发冷却(秒)
"hideTargets": false, // true = 面板不渲染任何地址(对外发布用)
"redactDetails": false, // true = 快照 detail 只保留「正常/异常」
"notify": { // 通知:故障/恢复/证书到期(SMTP 密码与 MeoW 昵称请放 local.monitors.json)
"enabled": false,
"failureThreshold": 2,
"quietHours": "",
"channels": { "email": { "enabled": false }, "meow": { "enabled": false } }
},
"tls": { "enabled": true, "checkIntervalSec": 21600, "alertDays": [14, 7, 3] },
"maintenance": [], // 维护窗口:[{ from, to, id?, note? }]
"uptimeRobot": { // UptimeRobot 只读数据源(Key 建议放 local.monitors.json)
"enabled": false,
"apiKey": "",
@@ -253,6 +281,8 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
指定方式(优先级从高到低):URL 参数 `?view=table|cards` → 浏览器上次选择(localStorage)→ `settings.view` → 表格。
**移动端与窄窗口**:视口窄于 **1024px**(表格含心率图与可用率条,实测最小舒适宽度约 1010px)且用户没有显式选择过视图时,**自动使用卡片**;横竖屏切换时重算,显式选择(`?view=` 或点过切换器)始终优先。顶栏在 ≤820px 改为「标题 / 统计 / 控件」三行,立绘与固定装饰自动撤掉。
### 数据源过滤
| 值 | 含义 |
@@ -284,6 +314,48 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
效果:无论你什么时候打开页面,倒计时都显示「距服务端下一次更新还有多久」——刚更新完打开是 300s,过了 4 分钟打开就只剩 60s。静态快照与浏览器直连模式没有可对齐的服务端节奏,仍按 `refreshInterval` 轮询。
## 主题(砂狼白子)
面板名称叫 shiroko-monitor,主题自然取了《蔚蓝档案》的**砂狼白子**:她的光环、围巾、狼耳与配色都被做进了界面。人物形象用的是**简约风插画**(扁平配色、无阴影,见下),不是官方立绘。
### 主题里的元素
| 位置 | 元素 | 取自设定 |
| --- | --- | --- |
| 左上 logo / 浏览器图标 | **简约风半身头像**(光环 + 狼耳 + 围巾)+ 青色光环环(呼吸式外扩动画) | 生成插画的半身裁切 |
| 右下角 | **简约风全身立绘**(透明底,窄身设计:实测 1280 / 1440 / 1920 下都不遮挡表格,页面也无横向滚动);全局半透明(`settings.decoOpacity`,默认 0.78)+ 上下渐隐,融入背景 | 生成插画 |
| 顶栏底边 | **天蓝色围巾飘带**:波浪形状 + 5 处褶皱纹路(自绘) | 天蓝色围巾是星野送给她的信物 |
| 页脚下方 | **光环水印**(自绘:倾斜椭圆环 + 四方位带圆头的短杆) | 白子的光环是倾斜的椭圆环,四个方位各有一条带圆头的短杆,整体形似全息瞄准镜准星 |
| 状态点 | **迷你光环**(外圈 + 中心点,在线时缓慢脉冲外扩) | 光环是基沃托斯学生的标志 |
| 分组标题 | **青色倒十字发饰**(旋转 45°,自绘) | 她左侧刘海上别的青色十字发夹 |
| 刷新按钮加载态 | **公路车轮**(轮圈 + 辐条 + 花鼓)旋转 | 她最爱的公路车(Bianchi ARIA 原型) |
| 左下角 | 公路车插画(简约扁平风,与人物同一套视觉语言;与人物共用同一个全局透明度 `decoOpacity` + 两端渐隐) | 她最爱的公路车(Bianchi ARIA 原型) |
| 配色 | 天蓝 / 青 + 藏蓝 + 灰白;「正常」色改为青绿(`#12b0a2`) | 阿拜多斯藏蓝校服 + 天蓝围巾 + 灰白发色 |
左下公路车、页脚光环、右下立绘构成页脚下方的一条装饰带,三者互不遮挡。
### 素材来源
| 类别 | 内容 | 来源 |
| --- | --- | --- |
| **插画(人物形象 + 座驾)** | `shiroko-figure.webp`(249×560)、`shiroko-icon.webp`(180×180)、`favicon-shiroko.png`(64×64)、`shiroko-bike.webp`(341×200) | **AI 生成**:按白子的设定要素(灰发狼耳 / 倾斜椭圆光环 / 天蓝围巾 / 藏蓝校服 / 公路车)描述生成,再用 Pillow 抠掉白底转为透明。**不是**官方美术素材,可自由替换或商用自行判断 |
| 自绘装饰(SVG/CSS) | 围巾飘带 `deco-scarf.svg`、光环水印、迷你光环状态点、倒十字图标、车轮加载图标 | 本仓库原创,按公开设定还原 |
| 设定资料(光环形状、发饰、围巾、配色、车型) | 设计依据 | [萌娘百科 · 砂狼白子](https://mzh.moegirl.org.cn/%E7%A0%82%E7%8B%BC%E7%99%BD%E5%AD%90)、[Blue Archive Wiki](https://bluearchive.fandom.com/zh/wiki/%E7%A0%82%E7%8B%BC%E7%99%BD%E5%AD%90)、[百度百科](https://baike.baidu.com/item/%E7%A0%82%E7%8B%BC%E7%99%BD%E5%AD%90/62839164) |
| 曾用素材(已移除) | 最早一版用过官方立绘与头像,为改成简约形象已从仓库删除 | [SchaleDB](https://schaledb.com):`images/student/portrait/10010.webp`、`images/student/icon/10010.webp`(Id `10010` = 砂狼白子),版权归 NEXON Games / Yostar;想换回官方图可再从该地址取 |
蔚蓝档案 / Blue Archive © NEXON Games · Yostar —— 角色与设定版权归原作者所有,本主题是个人自用面板的风格致敬。
### 切换与配置
```jsonc
"theme": "shiroko", // shiroko | default(默认主题);也可用地址栏 ?theme=default 临时覆盖
"artwork": "assets/shiroko-figure.webp" // 立绘图片路径(相对 public/),留空则不显示
```
优先级:`?theme=` → 浏览器上次选择(localStorage)→ `settings.theme`。顶栏右侧的光环图标按钮可一键切换。切回默认主题后,立绘、光环、斜纹、围巾飘带、公路车全部消失,回到最初的简洁样式。
**换成你自己的图**:把图片放进 `public/assets/`,再把 `artwork` 指向它即可(透明底 PNG/WebP 效果最好)。文件不存在时静默不显示,不会出现破图。
## 90 天可用率
探针每完成一次探测,就把它计入「当日分桶」:`monitors.<id>.daily["YYYY-MM-DD"] = { total, up }`,只保留最近 90 天(超出自动淘汰)。面板据此渲染 90 格色条与百分比。
@@ -295,6 +367,86 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker
- **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=<path>` 写到别处
## 通知、证书与事件
### 通知(邮件 + MeoW)
面板只在你打开时展示状态,**通知**让故障主动找你。支持两个渠道:**邮件**(自写 SMTP 客户端,零依赖)与 **MeoW 推送**(按 [官方文档](https://www.chuckfang.com/MeoW/api_doc.html) 实现)。
```jsonc
"notify": {
"enabled": true,
"failureThreshold": 2, // 连续失败 N 次才告警(去抖,默认 2;1 = 立即)
"recoveryNotify": true, // 恢复时也发一条
"quietHours": "23:00-07:00", // 静默时段(本地时间,支持跨零点),期间只记录不发送;留空全天
"channels": {
"email": {
"enabled": true,
"host": "smtp.example.com", "port": 465, // 465 隐式 TLS;587/25 自动 STARTTLS
"user": "[email protected]", "pass": "密码",
"from": "[email protected]", "to": ["[email protected]"],
"allowInsecure": false // 服务器不支持 STARTTLS 时是否允许明文(默认拒绝)
},
"meow": {
"enabled": true,
"nickname": "你的昵称", // MeoW 以昵称寻址、无需 token,因此昵称等同密钥
"baseUrl": "https://api.chuckfang.com",
"msgType": "text" // text | html(html 可另配 htmlHeight)
}
}
}
```
行为要点:
- **去抖**:连续失败达到阈值才发「故障」;恢复时发「恢复」;同一轮多个目标变化会合并成一条,不刷屏
- **重启不重复**:去抖状态持久化在 `data/notify-state.json`(私有目录,已 gitignore),重启/单轮模式都不重发
- **静默时段**:支持 `23:00-07:00` 这类跨零点写法;静默期内照常记录事件,只是不发送
- **MeoW 限流**:客户端本地也做限流(3 秒 1 条 / 15 每分钟 / 60 每小时),替你挡掉一部分官方 429;429/404/403 都会转成可读的错误原因
- **CI 注意**:MeoW 官方明确不建议用多人共享的出口 IP(GitHub Actions 即属此类),CI 场景建议只开邮件渠道
- **自检**:`node prober/node/index.js --test-notify` 给每个启用渠道发一条测试消息,确认配置可用
> SMTP 密码与 MeoW 昵称属于私密信息,请放在 `local.monitors.json`(已 gitignore),不要写进公开的 `monitors.json`。
### TLS 证书监控
对 `https://` 目标,探针额外做一次 TLS 握手读取证书有效期(默认 6 小时一次):
- 面板在名称旁显示徽标:剩余 **≤14 天**黄色、**≤7 天**红色,悬停可见到期日与签发者
- 剩余 ≤14 / 7 / 3 天时按阈值告警;同一张证书每个阈值只发一次,换证书后重新计算;**已过期的证书只报最严重的一档**
- 单个目标不需要可设 `"tls": false`
```jsonc
"tls": { "enabled": true, "checkIntervalSec": 21600, "alertDays": [14, 7, 3] }
```
### 维护窗口
计划重启/迁移期间,不想污染可用率统计、也不想被告警轰炸:
```jsonc
"maintenance": [
{ "from": "2026-10-14T02:00", "to": "2026-10-14T04:00", "id": "box", "note": "升级迁移" }
]
```
- `id` 可省略(对所有目标生效),也支持写成数组
- 窗口内:探测照常进行,但**不计入当日可用率**、**不发告警**,面板显示「维护中」徽标;事件时间线照常记录并带 `maintenance: true`
### 事件时间线
每次状态翻转(故障 / 恢复 / 证书阈值)都会追加到 `data/events.json`(私有目录,保留最近 500 条),并通过 `GET /api/events` 读取。快照里也会带上最近 20 条供面板使用;`redactDetails` 开启时,快照内事件只保留类型与时间。
**面板展示**:监控列表下方有一个「最近事件」区块(最多 20 条),每行显示 `时间 / 类型 / 目标 / 详情`;维护窗口内的事件会带「维护」标记(表示未计入可用率、未发告警)。无事件时区块自动隐藏。
### 响应时间趋势
当日分桶在 `{ total, up }` 之外还累计 `latSum / latCount / latMax`(仅在线样本),探针据此在快照里给出 `lat: { avg, max, days: [{ key, avg }] }`:
- **卡片视图**:可用率条下方多一条「90 天日均延迟」细柱图(取最近 30 天),悬停看单日数值,右侧为 90 天均值
- **表格视图**:可用率列的 tooltip 里追加 `90 天日均延迟 X ms(峰值 Y ms)`
- 数据从探针开始累计当天起累积,跑满 90 天后覆盖全周期;离线样本不计入延迟统计
## 部署方案
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
@@ -388,6 +540,102 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
### v0.11.1 已实现
**修复你反馈的三个问题**
- [x] **表格显示不全**:`table-layout: fixed` 保证了不横向滚动,但列内仍是 `nowrap`,实测**长 URL 被截掉 388px、名称截掉 219px、可用率条截掉 35px**(1440 宽下共 14 个单元格被截断)。改为**允许换行**(地址 `break-all`、名称/结果 `overflow-wrap: anywhere`)并重排列宽比例——数据完整显示且**仍然零横向滚动**(已加断言:注入超长名称/地址后截断单元格 = 0)
- [x] **「最近事件」完全没样式**:原因是上次追加 CSS 的命令是 `node --check app.js && cat >> style.css`,`--check` 报错导致 `&&` 短路,**CSS 根本没写进文件**(只有 HTML 裸奔)。这次用编辑器写入并加了断言。同时**重做为时间线样式**:左侧竖线 + 按类型着色的圆点、类型徽标、左描边分色;避免短名称后面拖出大片空白(名称列按内容自适应);**默认折叠为最近 8 条**,可「展开全部 N 条」
- [x] **`dataFilter: uptimerobot` 仍在探测本地目标**:新增 `settings.probeScope`(`all` / `config` / `uptimerobot`),**不填则跟随 `dataFilter`** —— 只展示一个来源时不再探测另一个来源的目标(省资源、少打扰);想保留探测可显式设 `"probeScope": "all"`。启动日志会明确打印当前口径与范围
**顺带修掉两个连带问题**
- [x] **UR 镜像目标此前不产生事件也不告警**:`evaluateRound` 只遍历本地配置目标,而配了 `uptimerobot` 口径时你关心的恰恰是 UR 目标 —— 等于「收不到任何提醒」。现在两类目标统一纳入事件与告警
- [x] **去抖计数被评估频率放大**:`evaluateRound` 每秒被调用,原来每次评估都让 `failStreak +1`,一个持续离线的目标 1 秒就凑够「连续 2 次失败」触发告警。改为**按探测时间戳去重**,只在真正产生新结果的那一轮累加;同时无状态变化时不再反复落盘
**测试**:`verify-views` [J] 段扩到 11 条(长内容不截断 / 仍不滚动 / 事件时间线布局 / 类型着色 / 折叠与展开),`verify-notify` 新增 [D3] 4 条(探测范围跟随、不再探测本地、显式 all 恢复探测、探测范围与展示口径解耦)与去抖计数断言。
### v0.11.0 已实现(P1 收尾)
- [x] **「最近事件」面板区块**:监控列表下方展示最近 20 条状态翻转(故障 / 恢复 / 证书 / 维护),无事件自动隐藏;窄屏隐藏「详情」列
- [x] **90 天响应时间趋势**:当日分桶累计 `latSum/latCount/latMax`,快照输出 `lat: { avg, max, days[] }`;卡片视图在可用率条下新增延迟细柱图(最近 30 天 + 90 天均值),表格视图在可用率列 tooltip 追加日均/峰值
- [x] `--demo` 同步生成 90 天延迟样例(含起伏形状),静态部署也能看到趋势效果
- [x] `verify-views` 新增 [J] 段 5 条断言(趋势条渲染 / 30 格 / tooltip / 事件空态 / 无溢出),**P0+P1 全部完成**
> 至此最初规划的 P0(通知、证书)与 P1(事件时间线、维护窗口、响应时间趋势)全部落地。
### v0.10.0 已实现(P0:通知 + 证书)
- [x] **通知渠道①:邮件** —— 自写极简 SMTP 客户端(**零依赖**,不引 nodemailer):465 隐式 TLS / 587·25 自动 STARTTLS、AUTH LOGIN 与 PLAIN、UTF-8 主题按 RFC2047 编码 + base64 正文、多收件人、5xx 与连接错误可读;服务器不支持 STARTTLS 时**默认拒绝明文发信**(需显式 `allowInsecure`)
- [x] **通知渠道②:MeoW 推送** —— 按[官方文档](https://www.chuckfang.com/MeoW/api_doc.html)实现:昵称寻址无 token、`msgType`/`htmlHeight` 走查询参数、**HTTP 恒 200 改为按响应体 status 判定**、429/404/403 转成可读原因、本地限流器遵守 3s/15min/60h
- [x] **告警逻辑**:连续 N 次失败才告警(去抖)+ 恢复通知 + 同轮多点变化合并为一条 + **去抖状态持久化**(重启不重复)+ 静默时段(跨零点,只记录不发送)
- [x] **事件时间线**:`data/events.json`(保留最近 500 条)+ `GET /api/events` + 快照内附最近 20 条;`redactDetails` 时脱敏
- [x] **TLS 证书监控**:对 https 目标单独握手取有效期(默认 6 小时一次),面板徽标(≤14 天黄 / ≤7 天红),阈值 14/7/3 各告一次、已过期只报最严重一档、换证书自动重置
- [x] **维护窗口**:窗口内不计入可用率、不发告警、显示「维护中」徽标,事件仍记录
- [x] **`--test-notify` 自检命令** 与 `--data-dir` 私有数据目录(默认 `./data`,已 gitignore)
- [x] 新增 `scripts/verify-notify.cjs`:**39 项断言**(假 SMTP 服务器 / 假 MeoW 服务器 / 静默时段 / 限流器 / 三轮集成验证去抖与恢复 / 维护窗口抑制 / 真实证书握手)
> 测试过程中抓到并修掉两个真 bug:① SMTP 空闲超时没有处理,服务器不响应会把探针循环**挂死**;② MeoW 限流器把空历史当成 0,**第一条通知永远被自己拦掉**;③ 去抖状态在「无事发生」的轮次不落盘,导致连续失败计数丢失、永远达不到告警阈值。
### v0.9.1 已实现
- [x] **「任何情况下都不需要横向滚动」定为硬要求并落地**:表格改为**流式列宽**(`table-layout: fixed` + 百分比列宽),表头允许换行、单元格裁剪,心率图与可用率条随宽度伸缩;从结构上不可能撑出横向滚动
- [x] 修复两个只有极端内容才暴露的撑破(普通数据下测不出来):
- **卡片视图**在 <768px 曾溢出最多 495px —— grid 轨道 `minmax(300px, 1fr)` + 网格项默认 `min-width: auto`,被超长 URL 撑到 801px;改为 `minmax(min(300px, 100%), 1fr)` + `.card { min-width: 0 }`
- **表格**在 <768px 曾溢出 8~42px —— 表头 `white-space: nowrap` 在窄列里溢出;改为换行 + 裁剪
- [x] 新增 `scripts/verify-layout.cjs`:自带极端配置(40 目标 / 6 分组 / 超长名称与 URL)+ 临时静态服务,扫描 **13 种视口 × 2 视图**,26 项断言零横向滚动
- [x] 被截断的名称/目标补 `title`,鼠标悬停可看完整值
### v0.9.0 已实现
**修复你反馈的两个问题**
- [x] **装饰遮罩「看不出效果」**:人物与单车改为**统一全局半透明**(`settings.decoOpacity`,默认 0.78,一个值同时作用于两者)+ 各自的边缘渐隐;此前立绘是 0.92(几乎无感)、单车 0.80,两者还不一致
- [x] **切换视图时人物比例变化**:删掉了「卡片视图把立绘缩到 300px」的规则(那是上一版为躲让卡片加的),现在两种视图尺寸完全一致
- [x] **保留原始素材**:AI 生成的白底原图归档到 `assets-src/`,处理流程写成可复现脚本 `scripts/prepare-artwork.py`(边界洪泛 + 封闭区域补抠 + 裁边缩放 + 补白)
**全局审阅发现并修复的问题**
- [x] 🔴 **无障碍对比度不达标(两套主题都中招)**:状态色直接当文字色用时远低于 WCAG AA 4.5:1(白子主题「在线」2.71:1、「等待」2.56:1;默认主题 3.30:1 / 3.19:1)。改为**双色体系**:填充色(圆点、心率条、边框)保持鲜亮并保证 ≥3:1,文字色用同色系加深值并保证 ≥4.5:1;暗色模式同步。新增 8 条对比度断言(逐层合成实际背景色,正确处理 `color-mix` 半透明底)
- [x] 🔴 **暗色模式下主题是坏的**:顶栏/卡片/表头/分组行是硬编码白色渐变,配上深色页面就是「白顶栏 + 深底」;改为随明暗模式切换的变量(新增 `docs/screenshot-dark.png`)
- [x] 🟠 **平板竖屏(768px)表格要内部横滚 242px**:自动切卡片的阈值从 700px 提到 **1024px**(= 表格实测最小舒适宽度),显式选择视图仍然优先
- [x] 🟠 **Cloudflare Worker 快照缺字段**:补 `dataUpdatedAt` / `updateIntervalSec` / `dataFilter`,边缘部署与本地探针口径一致(否则倒计时不会对齐服务端)
- [x] 🟡 清理失效 CSS(`.logo .halo`,随内联光环 SVG 移除后遗留)
**测试脚本自身修掉的两个 bug**(都会导致误报)
- 对比度取样把 `color(srgb r g b / a)` 的 0~1 分量当成 0~255、把半透明底色当不透明 → 改为逐层合成实际背景色
- 「立绘不影响阅读」把**渐变背景**(卡片用 `background-image`)误判为透明 → 判定补上背景图检查
### v0.8.3 已实现
- [x] **修复抠图漏区**:此前只用「边界洪泛」抠底,光环**环内**、单车**车架三角区与轮圈内扇形**这些被围起来的封闭白色区域没被处理,会留成白块。改为再补一次「封闭白色区域」按面积清理(人物/单车/图标三张图都处理了;脸部、袜子等服装白已确认保留)
- [x] **人物与单车加半透明 + 渐隐遮罩**:立绘 `opacity .92` + 上下渐隐,单车 `opacity .8` + 两端渐隐 + 轻微降饱和,不再像贴上去的贴纸
### v0.8.2 已实现
- [x] 左下角公路车换成**简约扁平风插画**(AI 生成 + 抠底,与人物同一套视觉语言),替换掉此前自绘的线稿 SVG;页脚下方装饰带(公路车 / 光环 / 人物)风格统一
### v0.8.1 已实现
- [x] **修复移动端顶栏塌陷**:窄屏(≤820px)改为「标题 / 统计 / 控件」三行布局,统计均分视口宽度,页脚改纵向堆叠 —— 此前统计数字会挤成竖排并压住标题
- [x] **窄屏默认卡片视图**(≤700px):表格在手机上必须横向拖动,改由卡片呈现;横竖屏切换时重算,用户显式选过视图则尊重其选择
- [x] 窄屏与横屏手机自动撤掉立绘、公路车、光环水印等固定装饰,避免压住内容或溢出
- [x] `verify-views` 新增移动端专项断言 7 项(默认卡片 / 无横向溢出 / 顶栏三行 / 统计铺满 / 装饰隐藏 / 页脚不裁切 / 无 JS 错误)
### v0.8.0 已实现
- [x] **形象换成简约风插画**(AI 生成 + 抠底,非官方美术素材):右下角全身立绘、logo 与 favicon 用同款半身像;官方立绘/头像已从仓库移除
- [x] **修复:桌面端表格需要横向拖动** —— 上一版为避让立绘给内容区加了 240px 右内边距,而 `.grid` 是 `max-width: 1200px`,内边距在 1200 内部吃掉宽度,导致 1200px 以上的屏幕也必然溢出。改为「立绘收窄 + 置于内容之下」,实测 1280 / 1440 / 1920 三种宽度下页面与表格均 **0 溢出、0 重叠**
- [x] **修复:立绘遮挡光环水印** —— 光环水印移到页脚下方空带,与左下公路车、右下立绘形成一条装饰带,互不遮挡
### v0.7.0 已实现
- [x] **砂狼白子主题**(默认启用):官方立绘(右下角,宽屏自动为它让位)+ 头像 logo/favicon(光环环呼吸动画)+ 天蓝围巾飘带顶栏 + 倒十字发饰分组标识 + 公路车轮加载态 + 按官方造型还原的倾斜椭圆光环水印;配套青绿主色调
- [x] 主题切换:`settings.theme` / `?theme=` / 顶栏光环按钮(localStorage 记忆),默认主题完整保留
- [x] `settings.artwork` 立绘位,默认指向内置立绘;未配置或文件缺失时静默不显示
- [x] 修复:`dataFilter=uptimerobot`(只展示外部数据源)时「平均延迟」恒为「–」——原规则一律排除 UR 延迟,改为「混合来源时排除、纯外部来源时以其为唯一口径」,服务端与面板同步调整
### 路线图(后续迭代)
- [ ] 通知渠道:Webhook / Server 酱 / Telegram / Bark / 邮件
@@ -414,22 +662,27 @@ node prober/node/index.js --once
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项)
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
node scripts/verify-notify.cjs # 通知与证书(39 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项)
```
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
## 界面截图
| 表格(默认视图) | 卡片视图 |
| 砂狼白子主题(默认,表格视图) | 默认主题 |
| --- | --- |
| ![table](docs/screenshot-desktop.png) | ![cards](docs/screenshot-view-cards.png) |
| ![shiroko](docs/screenshot-desktop.png) | ![default](docs/screenshot-theme-default.png) |
UptimeRobot 数据源(`?filter=uptimerobot`,截图中含真实监控名与域名,公开发布前可自行删除):`docs/screenshot-uptimerobot.png`
| 深色模式(白子主题) | 卡片视图(白子主题) |
| --- | --- |
| ![dark](docs/screenshot-dark.png) | ![cards](docs/screenshot-view-cards.png) |
演示数据(90 天满档)与移动端:`docs/screenshot-demo.png`、`docs/screenshot-mobile.png`
移动端(390×844,自动切卡片 + 顶栏三行):`docs/screenshot-mobile.png`
UptimeRobot 数据源(`?filter=uptimerobot`,截图中含真实监控名与域名,公开发布前可自行删除):`docs/screenshot-uptimerobot.png`;演示数据 90 天满档:`docs/screenshot-demo.png`
## 致谢与定位
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

+2 -1
View File
@@ -105,7 +105,8 @@ 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` 开启时不下发 |
| 通知凭据 | SMTP 密码、MeoW 昵称(等同密钥)泄露 → 被冒名发信/推送 | 与地址一样只放 `local.monitors.json` 或环境变量;`public/monitors.json` 中 `notify.channels.*` 保持 `enabled: false` |
| `status.json` / `/api/status` | 探针输出,Pages 部署会公开,`detail` 可能含地址、版本、错误码 | `settings.redactDetails: true` → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 `hideTargets` 开启时不下发;事件时间线在 `redactDetails` 下只保留类型与时间 |
| 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 |
| UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 |
| `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 219 KiB

+17 -1
View File
@@ -11,7 +11,7 @@
import { connect } from 'cloudflare:sockets';
const VERSION = '0.6.0';
const VERSION = '0.11.1';
const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90;
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
@@ -202,10 +202,25 @@ async function runChecks(env) {
}
}
// 与本地探针保持同一份快照口径:dataUpdatedAt / updateIntervalSec / dataFilter
// 面板据此把倒计时对齐服务端节奏,并按同一过滤口径展示
const filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both';
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);
}
}
const next = {
prober: `shiroko-monitor-worker/${VERSION}`,
demo: false,
generatedAt: new Date().toISOString(),
// state.generatedAt 是上一轮探测完成的时间,即「数据最后变更时刻」
dataUpdatedAt: state.generatedAt ?? new Date().toISOString(),
updateIntervalSec: Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : 60)),
dataFilter: filter,
stats: {
total: (config.monitors ?? []).length,
up,
@@ -214,6 +229,7 @@ async function runChecks(env) {
avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null,
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
},
sources: {},
monitors,
};
await env.MONITOR_KV.put('state', JSON.stringify(next));
+374 -14
View File
@@ -20,6 +20,8 @@ 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';
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
@@ -35,13 +37,15 @@ 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 VERSION = '0.11.1';
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');
// --test-notify:只给各通知渠道发一条自检消息后退出(不探测、不写快照)
const TEST_NOTIFY = argv.includes('--test-notify');
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
function argValue(name) {
@@ -54,6 +58,10 @@ 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');
// --data-dir=<path>:通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR)
if (argValue('--data-dir')) process.env.SHIROKO_DATA_DIR = argValue('--data-dir');
// 私有数据目录:通知状态(告警去抖)与事件时间线,含本机运行痕迹,默认不入库
const DATA_DIR = process.env.SHIROKO_DATA_DIR ? path.resolve(process.env.SHIROKO_DATA_DIR) : path.join(ROOT, 'data');
const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH;
const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH;
@@ -110,14 +118,43 @@ function pruneDaily(daily) {
while (keys.length > UPTIME_DAYS) delete daily[keys.shift()];
}
function recordDaily(daily, ok) {
// 记录一次探测到「当日分桶」;latency 仅在在线时传入(离线结果没有有意义的延迟)
// 同时累计延迟统计(sum/count/max),用于面板的 90 天延迟趋势
function recordDaily(daily, ok, latency = null) {
const key = dayKey();
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
bucket.total++;
if (ok) bucket.up++;
if (ok) {
bucket.up++;
if (Number.isFinite(latency) && latency >= 0) {
bucket.latSum = (bucket.latSum ?? 0) + latency;
bucket.latCount = (bucket.latCount ?? 0) + 1;
bucket.latMax = Math.max(bucket.latMax ?? 0, latency);
}
}
pruneDaily(daily);
}
// 从分桶聚合延迟趋势(90 天):返回 { avg, max, days: [{ key, avg }] }
function latencyTrend(daily) {
const keys = Object.keys(daily ?? {}).sort();
const days = [];
let sum = 0;
let count = 0;
let max = 0;
for (const key of keys) {
const b = daily[key];
if (!b?.latCount) continue;
const avg = Math.round(b.latSum / b.latCount);
days.push({ key, avg });
sum += b.latSum;
count += b.latCount;
max = Math.max(max, b.latMax ?? 0);
}
if (!count) return null;
return { avg: Math.round(sum / count), max, days };
}
function intervalMs(monitor) {
const sec = Number(monitor.interval);
// 非法或缺失的 interval 一律回落到默认值,避免算出 NaN 让该目标永久不再被调度
@@ -172,7 +209,7 @@ async function syncUptimeRobot(runtime, { minGapMs = 0 } = {}) {
s.history = item.history;
s.uptimeRatios = item.uptimeRatios;
s.downSeconds = item.downSeconds;
if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up');
if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up', item.latency);
}
// 清理 UR 侧已删除的监控
for (const [id, s] of runtime.state) {
@@ -258,7 +295,7 @@ async function probeDue(runtime, force = false) {
const { config, state } = runtime;
const now = Date.now();
const due = [];
for (const m of config.monitors) {
for (const m of localProbeTargets(config)) {
const s = state.get(m.id);
if (!s || s.inFlight) continue;
// UptimeRobot 镜像目标由其自身节点探测,本探针只拉取结果
@@ -279,7 +316,7 @@ async function probeDue(runtime, force = false) {
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');
recordDaily(s.daily, result.status === 'up', result.latency);
const tag = result.status === 'up' ? '[up] ' : '[down]';
console.log(`${tag} ${m.id.padEnd(20)} ${String(result.latency ?? '-').padStart(5)}ms ${result.detail}`);
} catch (err) {
@@ -308,7 +345,228 @@ function memoryStats() {
return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) };
}
/* ---------- 数据源过滤 ---------- */
/* ---------- 通知(邮件 / MeoW)、事件时间线、维护窗口与证书监控 ---------- */
const DEFAULT_TLS_CHECK_SEC = 21600; // 证书默认 6 小时查一次
const TLS_ALERT_DAYS = [14, 7, 3];
const EVENT_LIMIT = 500;
const EVENT_IN_SNAPSHOT = 20;
function notifyConfig(config) {
const n = config.settings?.notify ?? {};
const channels = n.channels ?? {};
return {
enabled: n.enabled === true && Object.values(channels).some((c) => c?.enabled === true),
failureThreshold: Math.max(1, Number(n.failureThreshold) || 2),
recoveryNotify: n.recoveryNotify !== false,
quietHours: n.quietHours ?? null,
channels,
};
}
function tlsConfig(config) {
const t = config.settings?.tls ?? {};
const alertDays = Array.isArray(t.alertDays) ? t.alertDays.map(Number).filter(Number.isFinite) : null;
return {
enabled: t.enabled !== false, // 默认对 https 目标开启
checkIntervalSec: Math.max(600, Number(t.checkIntervalSec) || DEFAULT_TLS_CHECK_SEC),
alertDays: alertDays?.length ? alertDays.sort((a, b) => b - a) : TLS_ALERT_DAYS,
};
}
// 维护窗口:{ from, to, id?: string|string[], note? };不写 id 表示对所有目标生效
function activeMaintenanceFor(config, id, at = new Date()) {
const list = config.settings?.maintenance;
if (!Array.isArray(list) || !list.length) return null;
const t = at.getTime();
for (const w of list) {
const from = Date.parse(w.from);
const to = Date.parse(w.to);
if (!Number.isFinite(from) || !Number.isFinite(to) || t < from || t > to) continue;
if (!w.id) return w;
const ids = Array.isArray(w.id) ? w.id : [w.id];
if (ids.includes(id)) return w;
}
return null;
}
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return fallback;
}
}
function writeJson(file, data) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
}
const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json');
const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
function monitorNotifyState(runtime, id) {
const list = runtime.notifyState.monitors;
if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, lastCheckedAt: null, notifiedTls: [], tlsValidTo: null };
return list[id];
}
function appendEvents(runtime, entries) {
if (!entries.length) return;
runtime.events = [...runtime.events, ...entries].slice(-EVENT_LIMIT);
writeJson(EVENTS_PATH(), runtime.events);
}
// 通知 + 事件:每轮探测结束后评估状态翻转(带去抖)、恢复、维护窗口抑制
async function evaluateRound(runtime) {
const notify = notifyConfig(runtime.config);
const events = [];
const pendingNotifications = [];
let changed = false;
// 事件与告警覆盖两类目标:本地探测目标 + UptimeRobot 镜像目标
// (UR 镜像目标同样会发生状态翻转,只拉数据不告警等于「配了 uptimerobot 口径就收不到任何提醒」)
const targets = localProbeTargets(runtime.config).map((m) => ({ id: m.id, name: m.name, target: m.url ?? `${m.host}:${m.port}` }));
for (const [id, s] of runtime.state) {
if (s.monitor?.source !== 'uptimerobot') continue;
targets.push({ id, name: s.monitor.name, target: s.monitor.url ?? '', source: 'uptimerobot' });
}
for (const m of targets) {
const r = runtime.state.get(m.id)?.lastResult;
if (!r || r.status === 'pending') continue;
const st = monitorNotifyState(runtime, m.id);
// 去抖计数只应该跟着「新的探测结果」走:本函数每秒被调用,若每次评估都累加,
// 一个持续离线的目标会在 1 秒内就凑够「连续失败 N 次」,阈值形同虚设
const stamp = r.checkedAt ?? null;
if (st.lastCheckedAt !== stamp) {
st.lastCheckedAt = stamp;
st.failStreak = r.status === 'down' ? (st.failStreak ?? 0) + 1 : 0;
changed = true;
}
const maintenance = activeMaintenanceFor(runtime.config, m.id);
const at = new Date().toISOString();
if (r.status === 'down') {
// 去抖:连续失败达到阈值才算确认故障(阈值 1 表示立即告警)
if (st.lastStatus === 'down' || st.failStreak < notify.failureThreshold) continue;
st.lastStatus = 'down';
changed = true;
st.lastNotifiedAt = at;
events.push({ t: at, id: m.id, name: m.name, type: 'down', detail: r.detail ?? null, maintenance: Boolean(maintenance) });
if (!maintenance) {
pendingNotifications.push({
subject: `【故障】${m.name} 连接失败`,
text: `目标:${m.name}\n地址:${m.target}\n时间:${at}\n详情:${r.detail ?? '无'}\n连续失败:${st.failStreak} 次\n\n—— shiroko-monitor`,
});
}
} else if (r.status === 'up') {
if (st.lastStatus === null) {
st.lastStatus = 'up'; // 首次观测不产生「恢复」噪音
changed = true;
continue;
}
if (st.lastStatus === 'down') {
st.lastStatus = 'up';
st.lastNotifiedAt = at;
changed = true;
events.push({ t: at, id: m.id, name: m.name, type: 'up', detail: '已恢复', maintenance: Boolean(maintenance) });
if (notify.recoveryNotify && !maintenance) {
pendingNotifications.push({
subject: `【恢复】${m.name} 已恢复正常`,
text: `目标:${m.name}\n地址:${m.target}\n恢复时间:${at}\n延迟:${r.latency ?? '—'} ms\n\n—— shiroko-monitor`,
});
}
}
}
}
if (events.length) {
appendEvents(runtime, events);
console.log(`[events] 新增 ${events.length} 条:${events.map((e) => `${e.name} ${e.type}`).join('、')}`);
}
// 汇总成一条通知,避免同一轮多点故障刷屏
if (pendingNotifications.length) {
const message = pendingNotifications.length === 1
? pendingNotifications[0]
: {
subject: `【${pendingNotifications.length} 个目标状态变化】shiroko-monitor`,
text: pendingNotifications.map((p) => p.text).join('\n\n──────────\n\n'),
};
const res = await notifyAll(notify, message, { logger: console });
runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
changed = true;
}
// 只在状态真变化时落盘:tick 会每秒调用本函数,无变化时不写文件
if (changed) saveNotifyState(runtime);
}
// 证书检查:按 checkIntervalSec 逐个握手,跨阈值时告警(同一张证书每个阈值只告一次)
async function checkCertificates(runtime) {
const tlsCfg = tlsConfig(runtime.config);
if (!tlsCfg.enabled) return;
const now = Date.now();
const events = [];
const messages = [];
for (const m of localProbeTargets(runtime.config)) {
const target = tlsTargetOf(m);
if (!target) continue;
const prev = runtime.tls.get(m.id);
if (prev && now - (prev.checkedAtMs ?? 0) < tlsCfg.checkIntervalSec * 1000) continue;
const result = await probeCertificate({ ...target, timeoutMs: Math.max(5, Number(m.timeout) || 8) * 1000 });
runtime.tls.set(m.id, { ...result, checkedAt: new Date().toISOString(), checkedAtMs: now });
const st = monitorNotifyState(runtime, m.id);
if (result.error) {
console.warn(`[tls] ${m.id} 证书检查失败:${result.error}`);
continue;
}
// 换了证书(validTo 变化)→ 重置已通知阈值,新证书重新提醒
if (st.tlsValidTo !== result.validTo) {
st.tlsValidTo = result.validTo;
st.notifiedTls = [];
}
const maintenance = activeMaintenanceFor(runtime.config, m.id);
// 已过期的证书只报最严重的一档(避免一次刷三条)
const thresholds = result.daysLeft <= 0 ? tlsCfg.alertDays.slice(-1) : tlsCfg.alertDays;
for (const threshold of thresholds) {
if (result.daysLeft > threshold || (st.notifiedTls ?? []).includes(threshold)) continue;
st.notifiedTls = [...(st.notifiedTls ?? []), threshold];
const at = new Date().toISOString();
events.push({ t: at, id: m.id, name: m.name, type: 'tls', detail: `证书剩余 ${result.daysLeft} 天(阈值 ${threshold} 天)`, maintenance: Boolean(maintenance) });
if (!maintenance) {
messages.push(`- ${m.name}:证书剩余 ${result.daysLeft} 天(到期 ${result.validTo.slice(0, 10)},签发者 ${result.issuer ?? '未知'})`);
}
}
}
if (events.length) {
appendEvents(runtime, events);
if (messages.length) {
const res = await notifyAll(notifyConfig(runtime.config), {
subject: `【证书提醒】${messages.length} 个证书即将到期`,
text: `${messages.join('\n')}\n\n签发者与到期时间取自实际握手,请及时续期。\n\n—— shiroko-monitor`,
}, { logger: console });
runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
}
saveNotifyState(runtime);
}
}
function saveNotifyState(runtime) {
writeJson(NOTIFY_STATE_PATH(), runtime.notifyState);
}
// 通知与证书检查串行执行:避免并发轮次重复发同一条告警
function queueEvaluation(runtime, task) {
runtime.notifyChain = (runtime.notifyChain ?? Promise.resolve())
.then(() => task(runtime))
.catch((err) => console.error(`[notify] 评估异常:${err.stack || err.message}`));
return runtime.notifyChain;
}
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
function dataFilter(config) {
@@ -316,6 +574,26 @@ function dataFilter(config) {
return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both';
}
// 探测范围(是否真的去探本地配置里的目标):
// 显式设置 settings.probeScope 优先(all | config | uptimerobot)
// 未设置时**跟随 dataFilter** —— 面板只要一个来源的数据,就没必要再把另一个来源的目标探测一遍
// (既省资源,也避免不必要的对外请求;想保留探测可显式设 "all")
function probeScopeSetting(config) {
const p = config.settings?.probeScope;
if (['all', 'config', 'uptimerobot'].includes(p)) return p;
const f = dataFilter(config);
return f === 'both' ? 'all' : f;
}
function externalSyncEnabled(config) {
return probeScopeSetting(config) !== 'config';
}
// 本轮需要本地探测的目标(UR 镜像目标不在此列,它们由 UR 节点探测、这里只拉结果)
function localProbeTargets(config) {
return probeScopeSetting(config) === 'uptimerobot' ? [] : config.monitors;
}
/* ---------- 服务端更新节奏 ---------- */
// 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端,
@@ -348,6 +626,9 @@ function snapshot(runtime, demo = false) {
const hideUrls = config.settings?.hideTargets === true;
const includeConfig = filter !== 'uptimerobot';
const includeExternal = filter !== 'config';
// 混合来源时平均延迟只统计自建探测(UR 的延迟来自其探测节点,与本机视角不可比);
// 若展示集合只有外部数据源,则 UR 延迟就是唯一口径,照常统计
const latencyEligible = (isUr) => !isUr || filter === 'uptimerobot';
const monitors = {};
let up = 0;
let down = 0;
@@ -376,8 +657,7 @@ function snapshot(runtime, demo = false) {
}
if (r.status === 'up') {
up++;
// 平均延迟只统计自建探针:UR 的延迟来自其探测节点,与本机视角不可比
if (!isUr && r.latency != null) {
if (latencyEligible(isUr) && r.latency != null) {
latencySum += r.latency;
latencyCount++;
}
@@ -413,7 +693,18 @@ function snapshot(runtime, demo = false) {
};
entry.uptimeRatios = s.uptimeRatios ?? null;
entry.downSeconds = s.downSeconds ?? null;
} else {
// 证书与维护窗口信息(来自独立的握手检查与配置),供面板打标
const tlsInfo = runtime.tls?.get(id);
if (tlsInfo) entry.tls = redact
? { daysLeft: tlsInfo.daysLeft ?? null, error: tlsInfo.error ? '不可用' : null }
: { daysLeft: tlsInfo.daysLeft ?? null, validTo: tlsInfo.validTo ?? null, issuer: tlsInfo.issuer ?? null, checkedAt: tlsInfo.checkedAt ?? null, error: tlsInfo.error ?? null };
const maintenance = activeMaintenanceFor(config, id);
if (maintenance) entry.maintenance = { note: maintenance.note ?? null, to: maintenance.to ?? null };
}
// 90 天延迟趋势(自当日分桶聚合,自建与 UR 镜像目标都适用)
const trend = latencyTrend(s.daily);
if (trend) entry.lat = trend;
monitors[id] = entry;
}
@@ -426,6 +717,8 @@ function snapshot(runtime, demo = false) {
updateIntervalSec: updateIntervalSec(config, filter),
// 让面板与服务端共用同一份过滤口径,避免私有配置覆盖后两边不一致
dataFilter: filter,
// 探测范围(本地目标 / 外部数据源),面板据此显示「数据源」与是否停止本地探测
probeScope: probeScopeSetting(config),
stats: {
total: entries.length,
up,
@@ -436,6 +729,25 @@ function snapshot(runtime, demo = false) {
memory: memoryStats(),
},
sources: { uptimerobot: { ...(runtime.ur ?? {}) } },
// 通知能力与最近事件(事件含目标名与原因,redactDetails 时只保留类型与时间)
notify: (() => {
const n = notifyConfig(config);
return {
enabled: n.enabled,
channels: Object.entries(n.channels ?? {}).filter(([, c]) => c?.enabled).map(([k]) => k),
quietHours: n.quietHours,
failureThreshold: n.failureThreshold,
lastResult: runtime.notifyState?.lastResult ?? null,
};
})(),
// 只下发当前展示口径内目标的事件:切到 uptimerobot 口径后不该再看到「已经不管的目标」的旧事件
events: (runtime.events ?? [])
.filter((e) => monitors[e.id])
.slice(-EVENT_IN_SNAPSHOT)
.reverse()
.map((e) => (redact
? { t: e.t, id: e.id, type: e.type, name: e.name, detail: e.type === 'down' ? '异常' : e.type === 'up' ? '恢复' : e.detail, maintenance: e.maintenance }
: e)),
monitors,
};
}
@@ -484,6 +796,7 @@ function forceRound(runtime) {
await settleInflight(runtime.state);
runtime.forcedAt = Date.now();
scheduleWrite(runtime);
queueEvaluation(runtime, evaluateRound);
} catch (err) {
console.error(`[refresh] 强制探测异常:${err.message}`);
} finally {
@@ -528,7 +841,10 @@ function writeDemo(config) {
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 };
// 演示用延迟:随日期缓慢起伏,让 90 天趋势有形状可看
const up = perDay - miss;
const avg = alwaysDown ? 0 : 40 + ((seed + d * 5) % 160);
daily[key] = { total: perDay, up, latSum: avg * up, latCount: up, latMax: avg + 60 };
}
s.daily = daily;
}
@@ -652,6 +968,12 @@ function createServer(runtime) {
return sendJson(res, toUptimeRobotResponse(runtime));
}
// 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录)
if (url.pathname === '/api/events') {
const list = [...(runtime.events ?? [])].reverse();
return sendJson(res, { total: list.length, events: list.slice(0, 200) });
}
let rel = decodeURIComponent(url.pathname);
if (rel === '/') rel = '/index.html';
const file = path.join(PUBLIC_DIR, path.normalize(rel));
@@ -686,18 +1008,38 @@ async function main() {
config: loadConfig(),
state: null,
dataUpdatedAt: Date.now(),
// 通知去抖状态与事件时间线从私有数据目录恢复,重启不会重复告警
notifyState: readJson(NOTIFY_STATE_PATH(), { monitors: {} }),
events: readJson(EVENTS_PATH(), []),
tls: new Map(),
tlsLastCheckMs: 0,
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
};
if (TEST_NOTIFY) {
// 通道自检:只发一条测试消息,不探测、不写快照
const cfg = notifyConfig(runtime.config);
if (!cfg.enabled) {
console.error('未启用任何通知渠道(settings.notify.enabled 或 channels.*.enabled 未开启)');
process.exitCode = 1;
return;
}
console.log('通知通道自检:');
const out = await sendTestNotifications(cfg, { logger: console });
if (out.some((r) => !r.ok)) process.exitCode = 1;
return;
}
if (DEMO) {
writeDemo(runtime.config);
return;
}
runtime.state = hydrate(runtime.config);
// 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据
await syncUptimeRobot(runtime);
if (externalSyncEnabled(runtime.config)) await syncUptimeRobot(runtime);
if (ONCE) {
await probeDue(runtime, true);
await queueEvaluation(runtime, evaluateRound);
await queueEvaluation(runtime, checkCertificates);
writeStatus(snapshot(runtime));
const s = snapshot(runtime).stats;
console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`);
@@ -723,14 +1065,22 @@ async function main() {
setInterval(() => {
probeDue(runtime)
.then(() => scheduleWrite(runtime))
.then(() => {
scheduleWrite(runtime);
queueEvaluation(runtime, evaluateRound);
})
.catch((err) => console.error(`[tick] ${err.stack || err}`));
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
if (externalSyncEnabled(runtime.config) && Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
syncUptimeRobot(runtime)
.then(() => scheduleWrite(runtime))
.catch(() => { /* syncUptimeRobot 内部已记录错误 */ });
}
// 证书到期检查:按 checkIntervalSec 周期执行(默认 6 小时),握手失败只记录不影响巡检
if (Date.now() - runtime.tlsLastCheckMs >= tlsConfig(runtime.config).checkIntervalSec * 1000) {
runtime.tlsLastCheckMs = Date.now();
queueEvaluation(runtime, checkCertificates);
}
}, 1000);
createServer(runtime).listen(PORT, () => {
@@ -738,7 +1088,17 @@ async function main() {
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 监控` : ''}(配置热重载已启用)`);
const scope = probeScopeSetting(runtime.config);
const localCount = localProbeTargets(runtime.config).length;
const scopeText = {
all: `全部(自建 ${localCount} 个${runtime.ur.enabled ? ` + UptimeRobot ${runtime.ur.count} 个` : ''})`,
config: `仅自建(${localCount} 个,不同步 UptimeRobot)`,
uptimerobot: `仅 UptimeRobot(${runtime.ur.count} 个,**不探测本地配置里的 ${runtime.config.monitors.length} 个目标**)`,
}[scope];
console.log(`展示口径: ${dataFilter(runtime.config)} 探测范围: ${scope} —— ${scopeText}(settings.probeScope 可显式覆盖)`);
if (!localCount && !runtime.ur.enabled) {
console.warn('[warn] 当前没有任何数据源:本地探测已关闭且 UptimeRobot 未启用,面板将显示「等待检测」');
}
});
}
+324
View File
@@ -0,0 +1,324 @@
/**
* 通知发送:邮件(手写 SMTP,零依赖)+ MeoW 推送
*
* 为什么手写 SMTP:本项目刻意保持零运行时依赖(Node 内置模块够用),
* 引入 nodemailer 会带来一串传递依赖,对一个自用面板不划算。
* 只实现发信必需的流程:EHLO → (STARTTLS) → AUTH → MAIL/RCPT/DATA → QUIT。
*
* MeoW 接口要点(据 https://www.chuckfang.com/MeoW/api_doc.html ):
* - 无需 token,昵称即寻址:POST https://api.chuckfang.com/{昵称}
* - HTTP 状态恒为 200,成功与否看响应体的 status 字段
* - msgType / htmlHeight 是查询参数,不是 body 参数
* - 非会员限流:3 秒 1 条、每分钟 15 条、每小时 60 条
* - 官方提醒:不要用多人共享的出口 IP(CI 环境建议只用邮件)
*/
import net from 'node:net';
import tls from 'node:tls';
const CRLF = '\r\n';
/* ---------- 邮件 ---------- */
function encodeHeader(value) {
// 非 ASCII 主题按 RFC 2047 base64 编码,避免中文主题乱码
return /^[\x20-\x7E]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
}
function base64Body(text) {
// 正文用 base64:既免去 quoted-printable 的编码复杂度,也不会出现以 "." 开头的行(无需 dot-stuffing)
return (Buffer.from(text, 'utf8').toString('base64').match(/.{1,76}/g) ?? []).join(CRLF);
}
function buildMessage({ from, to, subject, text }) {
return [
`From: ${from}`,
`To: ${to.join(', ')}`,
`Subject: ${encodeHeader(subject)}`,
`Date: ${new Date().toUTCString()}`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: base64',
'',
base64Body(text),
'',
].join(CRLF);
}
/** 极简 SMTP 会话:读 3 位状态码响应(支持多行 `250-` 连续响应) */
function createSession(socket, timeoutMs) {
let buffer = '';
let waiter = null;
socket.setEncoding('utf8');
socket.on('data', (chunk) => {
buffer += chunk;
if (!waiter) return;
// 一条完整响应以 `\d{3} `(空格结尾)结束
const lines = buffer.split(CRLF);
const complete = lines.some((l) => /^\d{3} /.test(l));
if (complete) {
const reply = buffer;
buffer = '';
const w = waiter;
waiter = null;
w.resolve(reply);
}
});
const fail = (err) => {
if (waiter) {
const w = waiter;
waiter = null;
w.reject(err);
}
};
socket.on('error', fail);
socket.on('close', () => fail(new Error('SMTP 连接被关闭')));
// 空闲超时必须主动断开:否则服务器不响应时读响应会一直挂着,把探针循环卡死
socket.on('timeout', () => {
socket.destroy();
fail(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
});
return {
read: () => new Promise((resolve, reject) => {
const timer = setTimeout(() => {
waiter = null;
reject(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
}, timeoutMs);
waiter = {
resolve: (v) => { clearTimeout(timer); resolve(v); },
reject: (e) => { clearTimeout(timer); reject(e); },
};
}),
write: (line) => socket.write(line + CRLF),
code: (reply) => Number(reply.slice(0, 3)),
raw: () => socket,
};
}
async function connect(socket) {
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('secureConnect', resolve);
socket.once('error', reject);
});
return socket;
}
export async function sendEmail(cfg, { subject, text }, { timeoutMs = 15000 } = {}) {
const host = cfg.host;
const port = Number(cfg.port) || 465;
if (!host) throw new Error('邮件通道缺少 host');
if (!cfg.from) throw new Error('邮件通道缺少发件人 from');
const to = Array.isArray(cfg.to) ? cfg.to.filter(Boolean) : [cfg.to].filter(Boolean);
if (!to.length) throw new Error('邮件通道缺少收件人 to');
// 465 走隐式 TLS;587/25 先明文再 STARTTLS
const implicitTls = port === 465 || cfg.secure === true;
let socket = implicitTls
? tls.connect({ host, port, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false })
: net.connect({ host, port });
socket.setTimeout(timeoutMs);
await connect(socket);
let session = createSession(socket, timeoutMs);
const expect = async (wanted) => {
const reply = await session.read();
const code = session.code(reply);
if (code !== wanted) throw new Error(`SMTP 期望 ${wanted} 但收到:${reply.trim().split(CRLF).pop()}`);
return reply;
};
await expect(220);
session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
let caps = await expect(250);
if (!implicitTls) {
if (/STARTTLS/i.test(caps)) {
session.write('STARTTLS');
await expect(220);
socket = await new Promise((resolve, reject) => {
const upgraded = tls.connect({ socket, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false }, () => resolve(upgraded));
upgraded.once('error', reject);
});
socket.setTimeout(timeoutMs);
session = createSession(socket, timeoutMs);
session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
caps = await expect(250);
} else if (cfg.allowInsecure !== true) {
// 默认拒绝明文发信(凭据会裸奔);仅测试或本机可信中继场景下显式开启
throw new Error('SMTP 服务器不支持 STARTTLS,已中止(如需明文传输请显式设置 allowInsecure: true)');
}
}
if (cfg.user) {
if (/AUTH[ =-].*PLAIN/i.test(caps) && !/AUTH[ =-].*LOGIN/i.test(caps)) {
session.write(`AUTH PLAIN ${Buffer.from(`\0${cfg.user}\0${cfg.pass ?? ''}`, 'utf8').toString('base64')}`);
} else {
session.write('AUTH LOGIN');
await expect(334);
session.write(Buffer.from(cfg.user, 'utf8').toString('base64'));
await expect(334);
session.write(Buffer.from(cfg.pass ?? '', 'utf8').toString('base64'));
}
await expect(235);
}
session.write(`MAIL FROM:<${cfg.from}>`);
await expect(250);
for (const rcpt of to) {
session.write(`RCPT TO:<${rcpt}>`);
const reply = await session.read();
if (![250, 251].includes(session.code(reply))) throw new Error(`收件人被拒(${rcpt}):${reply.trim()}`);
}
session.write('DATA');
await expect(354);
socket.write(buildMessage({ from: cfg.from, to, subject, text }) + CRLF + '.' + CRLF);
await expect(250);
session.write('QUIT');
socket.end();
return { channel: 'email', ok: true, detail: `已投递至 ${to.join(', ')}` };
}
/* ---------- MeoW ---------- */
export async function sendMeoW(cfg, { title, text }, { timeoutMs = 12000, fetchImpl = fetch } = {}) {
const nickname = cfg.nickname;
if (!nickname) throw new Error('MeoW 通道缺少 nickname');
if (nickname.includes('/')) throw new Error('MeoW 昵称不允许包含斜杠');
const base = (cfg.baseUrl || 'https://api.chuckfang.com').replace(/\/+$/, '');
const params = new URLSearchParams();
if (cfg.msgType && cfg.msgType !== 'text') {
params.set('msgType', cfg.msgType);
if (cfg.htmlHeight) params.set('htmlHeight', String(cfg.htmlHeight));
}
if (cfg.url) params.set('url', cfg.url);
const query = params.toString() ? `?${params}` : '';
const res = await fetchImpl(`${base}/${encodeURIComponent(nickname)}${query}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title, msg: text }),
signal: AbortSignal.timeout(timeoutMs),
});
let payload = null;
try {
payload = await res.json();
} catch {
throw new Error(`MeoW 响应不是 JSON(HTTP ${res.status})`);
}
// 注意:HTTP 状态恒为 200,必须以响应体的 status 为准
const status = Number(payload?.status);
if (status !== 200) {
const reason = payload?.message || payload?.msg || '未知错误';
const hint = status === 429 ? '(触发频率限制:3 秒 1 条 / 15 每分钟 / 60 每小时)'
: status === 404 ? '(昵称未注册)'
: status === 403 ? '(内容被策略拦截)' : '';
throw new Error(`MeoW 推送失败 status=${status} ${reason}${hint}`);
}
return { channel: 'meow', ok: true, detail: payload.message || payload.msg || '推送成功' };
}
/* ---------- 调度 ---------- */
export function parseQuietHours(spec) {
if (!spec) return null;
const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(String(spec).trim());
if (!m) return null;
const [from, to] = [Number(m[1]) * 60 + Number(m[2]), Number(m[3]) * 60 + Number(m[4])];
return { from, to };
}
export function inQuietHours(spec, date = new Date()) {
const range = parseQuietHours(spec);
if (!range) return false;
const now = date.getHours() * 60 + date.getMinutes();
// 跨零点(如 23:00-07:00)
return range.from <= range.to ? now >= range.from && now < range.to : now >= range.from || now < range.to;
}
/** MeoW 客户端限流:保守遵守 3 秒 1 条 / 每分钟 15 条 / 每小时 60 条 */
export function createMeoWLimiter(now = () => Date.now()) {
let history = [];
return {
allow() {
const t = now();
history = history.filter((x) => t - x < 3600_000);
const last = history.length ? history.at(-1) : Number.NEGATIVE_INFINITY; // 空历史不能当成 0,否则首条必被拦
const inMinute = history.filter((x) => t - x < 60_000).length;
if (t - last < 3000 || inMinute >= 15 || history.length >= 60) return false;
return true;
},
mark() {
history.push(now());
},
state: () => history.length,
};
}
const meowLimiter = createMeoWLimiter();
/**
* 按配置把一条通知发到所有启用的渠道。
* 静默时段内不发送,但返回 suppressed 标记(调用方仍需记录事件)。
*/
export async function notifyAll(cfg, message, { logger = console, fetchImpl = fetch, now = () => Date.now() } = {}) {
if (!cfg?.enabled) return { suppressed: false, skipped: 'notify-disabled', results: [] };
if (inQuietHours(cfg.quietHours)) {
logger.log?.(`[notify] 静默时段(${cfg.quietHours}),本条不发送:${message.subject}`);
return { suppressed: true, results: [] };
}
const results = [];
const channels = cfg.channels ?? {};
if (channels.email?.enabled) {
try {
results.push(await sendEmail(channels.email, message));
} catch (err) {
results.push({ channel: 'email', ok: false, error: err.message });
}
}
if (channels.meow?.enabled) {
if (!meowLimiter.allow()) {
results.push({ channel: 'meow', ok: false, error: '本地限流:为避免触发 MeoW 频率限制,本条已跳过', rateLimited: true });
} else {
try {
meowLimiter.mark();
results.push(await sendMeoW(channels.meow, message, { fetchImpl }));
} catch (err) {
results.push({ channel: 'meow', ok: false, error: err.message });
}
}
}
const failed = results.filter((r) => !r.ok);
if (failed.length) logger.error?.(`[notify] 部分渠道发送失败:${failed.map((f) => `${f.channel}: ${f.error}`).join(' | ')}`);
if (!failed.length && results.length) logger.log?.(`[notify] 已发送:${message.subject}`);
return { suppressed: false, results };
}
/** 自检:给每个启用的渠道发一条测试消息(不经过静默时段判定) */
export async function sendTestNotifications(cfg, { logger = console, fetchImpl = fetch } = {}) {
const channels = cfg?.channels ?? {};
const subject = 'shiroko-monitor 通知自检';
const text = `这是一条通道自检消息,收到即表示该渠道配置可用。\n时间:${new Date().toLocaleString('zh-CN')}`;
const out = [];
if (channels.email?.enabled) {
try {
out.push(await sendEmail(channels.email, { subject, text }));
} catch (err) {
out.push({ channel: 'email', ok: false, error: err.message });
}
} else {
out.push({ channel: 'email', ok: false, error: '未启用(settings.notify.channels.email.enabled = false)' });
}
if (channels.meow?.enabled) {
try {
out.push(await sendMeoW(channels.meow, { title: subject, text }, { fetchImpl }));
} catch (err) {
out.push({ channel: 'meow', ok: false, error: err.message });
}
} else {
out.push({ channel: 'meow', ok: false, error: '未启用(settings.notify.channels.meow.enabled = false)' });
}
for (const r of out) logger.log?.(` ${r.ok ? '✓' : '✗'} ${r.channel}:${r.ok ? r.detail : r.error}`);
return out;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* TLS 证书探测:只做一次握手取 notAfter,用于「证书还有多久过期」提醒。
*
* 为什么不复用 HTTP 探测:现有探测走 fetch(undici),拿不到对端证书;
* 单独握手更轻,也不影响既有探测路径与判定逻辑。
* rejectUnauthorized 设为 false:证书已过期/不受信时也要能读到有效期,
* 是否通过校验由 socket.authorized 反映。
*/
import tls from 'node:tls';
export async function probeCertificate({ host, port = 443, servername, timeoutMs = 8000 }) {
if (!host) return { error: '缺少主机名' };
return new Promise((resolve) => {
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch { /* 忽略 */ }
resolve(result);
};
const socket = tls.connect(
{ host, port, servername: servername || host, rejectUnauthorized: false, timeout: timeoutMs },
() => {
let cert;
try {
cert = socket.getPeerCertificate();
} catch (err) {
return finish({ error: `读取证书失败:${err.message}` });
}
if (!cert || !cert.valid_to) return finish({ error: '未取到证书有效期' });
const validTo = new Date(cert.valid_to);
if (Number.isNaN(validTo.getTime())) return finish({ error: `证书有效期无法解析:${cert.valid_to}` });
finish({
error: null,
validTo: validTo.toISOString(),
daysLeft: Math.floor((validTo.getTime() - Date.now()) / 86_400_000),
issuer: cert.issuer?.O || cert.issuer?.CN || null,
subject: cert.subject?.CN || null,
authorized: socket.authorized === true,
});
},
);
socket.on('error', (err) => finish({ error: err.message }));
socket.on('timeout', () => finish({ error: `TLS 握手超时(${timeoutMs}ms)` }));
});
}
/** 从监控目标推断是否需要/如何进行证书检查 */
export function tlsTargetOf(monitor) {
if (!monitor || monitor.tls === false) return null;
const url = monitor.url;
if (!url || !/^https:\/\//i.test(url)) return null;
try {
const parsed = new URL(url);
return { host: parsed.hostname, port: Number(parsed.port) || 443, servername: parsed.hostname };
} catch {
return null;
}
}
+187 -15
View File
@@ -1,4 +1,4 @@
const VERSION = '0.6.0';
const VERSION = '0.11.1';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
@@ -15,6 +15,8 @@ const state = {
source: null,
filter: 'all',
view: 'cards',
theme: 'default',
eventsExpanded: false,
refreshTimer: null,
nextRefreshAt: 0,
loading: false,
@@ -225,9 +227,63 @@ function uptimeSummary(st) {
return null;
}
// 90 天日均延迟趋势(卡片视图):取最近 30 天的日均延迟画成细柱,右侧给 90 天均值
function latTrendBar(lat) {
const days = lat?.days?.slice(-30) ?? [];
if (days.length < 2) return '';
const max = Math.max(...days.map((d) => d.avg), 1);
const bars = days.map((d) => {
const h = Math.max(10, Math.round((d.avg / max) * 100));
return `<span class="lat-cell" style="height:${h}%" title="${escapeHtml(d.key)}:日均 ${d.avg} ms"></span>`;
}).join('');
return `<div class="lat-trend" title="90 天日均延迟 ${lat.avg} ms · 峰值 ${lat.max} ms(数据来自当日分桶累计)">
<span class="lat-label">延迟</span>
<span class="lat-bars">${bars}</span>
<span class="lat-avg">${lat.avg} ms</span>
</div>`;
}
// 最近事件区块(故障 / 恢复 / 证书阈值 / 维护窗口内的事件)
// 默认只列最近 EVENTS_COLLAPSED 条,其余折叠在「展开全部」后面,避免长列表把页面撑得过长
const EVENTS_COLLAPSED = 8;
function renderEvents() {
const box = $('events');
if (!box) return;
const list = $('events-list');
const empty = $('events-empty');
const all = state.status?.events ?? [];
box.hidden = all.length === 0;
empty.hidden = all.length > 0;
if (!all.length) return;
const expanded = state.eventsExpanded === true;
const shown = expanded ? all : all.slice(0, EVENTS_COLLAPSED);
const count = $('events-count');
if (count) count.textContent = all.length > shown.length ? `最近 ${shown.length} / ${all.length} 条` : `最近 ${all.length} 条`;
const label = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
list.innerHTML = shown.map((e) => {
const maint = e.maintenance ? '<span class="ev-maint" title="维护窗口内,未计入可用率、未发送告警">维护</span>' : '';
return `<li class="ev ev-${escapeHtml(e.type)}">
<span class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</span>
<span class="ev-type">${escapeHtml(label[e.type] ?? e.type)}${maint}</span>
<span class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</span>
<span class="ev-detail" title="${escapeHtml(e.detail ?? '')}">${escapeHtml(e.detail ?? '')}</span>
</li>`;
}).join('');
const more = $('events-more');
if (more) {
const hidden = all.length - shown.length;
more.hidden = all.length <= EVENTS_COLLAPSED;
more.textContent = expanded ? '收起' : `展开全部 ${all.length} 条`;
more.dataset.hidden = String(hidden);
}
}
function uptimeBar(st) {
const daily = st?.daily;
const cells = lastNDays(UPTIME_DAYS).map((key) => {
const daily = st?.daily; const cells = lastNDays(UPTIME_DAYS).map((key) => {
const b = daily?.[key];
let cls = 'lv-none';
let tip = `${key} · 无数据`;
@@ -284,7 +340,8 @@ function renderCard(m) {
const head = document.createElement('div');
head.className = 'card-head';
head.innerHTML = `<span class="dot"></span><h3>${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>`;
// 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称
head.innerHTML = `<span class="dot"></span><h3 title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}`;
const target = document.createElement('div');
target.className = 'card-target';
@@ -307,14 +364,14 @@ function renderCard(m) {
hb.innerHTML = heartbeatSvg(st?.history);
const up = document.createElement('div');
up.innerHTML = uptimeBar(st);
up.innerHTML = `${uptimeBar(st)}${latTrendBar(st?.lat)}`;
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);
card.append(head, target, metrics, hb, up, detail);
return card;
}
@@ -354,6 +411,25 @@ function isExternal(m) {
return m.source === 'uptimerobot';
}
// 证书徽标:仅在临近到期时出现(≤14 天黄色、≤7 天红色),避免常驻打扰
function certBadge(st) {
const t = st?.tls;
if (!t || t.daysLeft == null) return '';
if (t.error) return `<span class="tls-badge danger" title="证书检查失败:${escapeHtml(t.error)}">证书异常</span>`;
if (t.daysLeft > 14) return '';
const cls = t.daysLeft <= 7 ? 'danger' : 'warn';
const tip = `证书到期:${escapeHtml(String(t.validTo ?? '').slice(0, 10))}${t.issuer ? `(${escapeHtml(t.issuer)})` : ''}`;
return `<span class="tls-badge ${cls}" title="${tip}">证书 ${t.daysLeft} 天</span>`;
}
// 维护窗口徽标:该时段内不计入可用率统计、不发告警
function maintBadge(st) {
const m = st?.maintenance;
if (!m) return '';
const tip = `维护窗口${m.to ? `至 ${escapeHtml(String(m.to).replace('T', ' ').slice(0, 16))}` : ''}${m.note ? `:${escapeHtml(m.note)}` : ''}\n期间不计入可用率、不发告警`;
return `<span class="maint-badge" title="${tip}">维护中</span>`;
}
function renderTabs() {
const nav = $('group-tabs');
const monitors = allMonitors();
@@ -465,16 +541,20 @@ function tableRow(m, hide) {
const tr = document.createElement('tr');
tr.dataset.status = s.cls;
tr.dataset.id = m.id;
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`;
tr.innerHTML =
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML =
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
`<td class="t-name">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span></td>` +
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}</td>` +
targetCell +
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
`<td class="t-num" title="${escapeHtml(fmtTime(st?.lastCheck))}">${fmtRelative(st?.lastCheck)}</td>` +
`<td class="t-hb">${heartbeatSvg(st?.history)}</td>` +
`<td class="t-uptime">${up ? `<span class="u-mini">${miniUptimeBar(st?.daily)}</span><span class="t-pct">${up.pct.toFixed(1)}%</span>` : '<span class="t-pct">–</span>'}</td>`;
`<td class="t-hb">${heartbeatSvg(st?.history)}</td>`;
const latTip = st?.lat ? ` · 90 天日均延迟 ${st.lat.avg} ms(峰值 ${st.lat.max} ms)` : '';
const uptimeCell = document.createElement('td');
uptimeCell.className = 't-uptime';
uptimeCell.title = `${up ? `可用率 ${up.pct.toFixed(1)}%` : '可用率数据积累中'}${latTip}`;
uptimeCell.innerHTML = up ? `<span class="u-mini">${miniUptimeBar(st?.daily)}</span><span class="t-pct">${up.pct.toFixed(1)}%</span>` : '<span class="t-pct">–</span>';
tr.appendChild(uptimeCell);
return tr;
}
@@ -506,14 +586,26 @@ function renderViewSwitch() {
}
}
// 表格在窄屏必须横向滚动。实测表格最小舒适宽度约 1010px(列含心率图与可用率条),
// 因此视口窄于 1024px 时默认落到卡片;用户显式选择过视图则以选择为准。
const TABLE_MIN_WIDTH = 1024;
function isNarrowScreen() {
return window.innerWidth < TABLE_MIN_WIDTH;
}
function resolveView() {
const q = new URLSearchParams(location.search).get('view');
if (q && VIEWS[q]) return q;
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;
const resolved = [saved, cfg, DEFAULT_VIEW].find((v) => v && VIEWS[v]) ?? DEFAULT_VIEW;
// 用户没在这个设备上显式选过视图时,窄屏自动落到卡片(表格在手机上要横向拖)
if (!saved && resolved === 'table' && isNarrowScreen()) return 'cards';
return resolved;
}
function setView(view, { persist = true } = {}) {
@@ -537,6 +629,8 @@ function localStats() {
let latCount = 0;
let uptimeUp = 0;
let uptimeTotal = 0;
// 与探针口径一致:混合来源时平均延迟只统计自建探测;若展示集合只有外部数据源,则以其延迟为唯一口径
const hasProbe = monitors.some((m) => !isExternal(m));
for (const m of monitors) {
const st = state.status?.monitors?.[m.id];
if (!st || st.status === 'pending') {
@@ -545,8 +639,7 @@ function localStats() {
}
if (st.status === 'up') up++;
else down++;
// 与探针口径一致:平均延迟只统计自建探测目标
if (!isExternal(m) && st.latency != null) {
if (st.latency != null && (!isExternal(m) || !hasProbe)) {
latSum += st.latency;
latCount++;
}
@@ -599,6 +692,22 @@ function renderSummary() {
? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒`
: '尚无服务端数据';
$('demo-chip').hidden = !st?.demo;
// 通知渠道状态:有失败渠道时给出具体原因(点开 title 看)
const notify = st?.notify;
const nb = $('notify-badge');
if (nb) {
const failed = (notify?.lastResult?.results ?? []).filter((r) => !r.ok);
if (!notify?.enabled) {
nb.hidden = true;
} else {
nb.hidden = false;
nb.classList.toggle('warn-chip', failed.length > 0);
nb.textContent = `通知:${(notify.channels ?? []).join(' + ') || '无'}${notify.lastResult ? ` · 最近 ${fmtRelative(notify.lastResult.at)}` : ''}${failed.length ? ` · ${failed.length} 个渠道失败` : ''}`;
nb.title = failed.length
? `发送失败:${failed.map((r) => `${r.channel}: ${r.error}`).join('\n')}`
: (notify.lastResult ? `最近一次:${notify.lastResult.at},${(notify.lastResult.results ?? []).map((r) => `${r.channel} ${r.ok ? '成功' : '失败'}`).join('、')}` : '尚未发送过通知');
}
}
const ur = st?.sources?.uptimerobot;
const urChip = $('ur-chip');
if (urChip) {
@@ -612,6 +721,7 @@ function renderAll() {
renderTabs();
renderViewSwitch();
renderView();
renderEvents();
}
function renderError(err) {
@@ -666,7 +776,10 @@ function setLoading(on, text = '刷新中…') {
state.loading = on;
const btn = $('refresh-btn');
btn.disabled = on;
btn.textContent = on ? text : '刷新';
// 车轮图标(砂狼白子骑行意象,主题里会转起来)
btn.innerHTML = on
? `<svg class="wheel" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.35"/><g stroke="currentColor" stroke-width="1.3"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/><line x1="19.1" y1="4.9" x2="4.9" y2="19.1"/></g><circle cx="12" cy="12" r="2.2" fill="currentColor"/></svg><span>${escapeHtml(text)}</span>`
: '刷新';
}
async function refresh({ force = false } = {}) {
@@ -748,9 +861,67 @@ function startAutoRefresh() {
}, 1000);
}
/* ---------- 主题 ---------- */
const THEMES = { default: '默认', shiroko: '砂狼白子' };
function resolveTheme() {
const q = new URLSearchParams(location.search).get('theme');
let saved = null;
try {
saved = localStorage.getItem('shiroko.theme');
} catch { /* 隐私模式 */ }
return [q, saved, state.config?.settings?.theme].find((t) => t && THEMES[t]) ?? 'default';
}
// 角色立绘位:主题为白子且配置了 artwork 时才插入;文件不存在则静默移除(不留破图)
function applyArtwork() {
document.querySelector('.artwork')?.remove();
const src = state.config?.settings?.artwork;
if (state.theme !== 'shiroko' || !src) return;
const img = document.createElement('img');
img.className = 'artwork';
img.alt = '';
img.src = src;
img.addEventListener('error', () => img.remove());
document.body.appendChild(img);
}
function applyTheme(theme, { persist = true } = {}) {
state.theme = THEMES[theme] ? theme : 'default';
if (state.theme === 'default') delete document.body.dataset.theme;
else document.body.dataset.theme = state.theme;
// 装饰(人物/单车)的全局透明度:一个值同时作用于两者,避免其中一个更抢眼
const decoOpacity = Number(state.config?.settings?.decoOpacity);
document.body.style.setProperty('--sh-deco-opacity', String(Number.isFinite(decoOpacity) && decoOpacity > 0 && decoOpacity <= 1 ? decoOpacity : 0.78));
if (persist) {
try {
localStorage.setItem('shiroko.theme', state.theme);
} catch { /* 忽略 */ }
}
const icon = document.querySelector('link[rel="icon"]');
if (icon) icon.setAttribute('href', state.theme === 'shiroko' ? 'assets/favicon-shiroko.png' : 'assets/favicon.svg');
applyArtwork();
const btn = $('theme-btn');
if (btn) {
btn.classList.toggle('active', state.theme === 'shiroko');
btn.title = state.theme === 'shiroko' ? '当前主题:砂狼白子(点击切回默认)' : '当前主题:默认(点击切换到砂狼白子主题)';
}
}
function bindEvents() {
// 只有手动点「刷新」才会触发一次重新检测;自动刷新是只读的
$('refresh-btn').addEventListener('click', () => refresh({ force: true }));
$('theme-btn')?.addEventListener('click', () => applyTheme(state.theme === 'shiroko' ? 'default' : 'shiroko'));
$('events-more')?.addEventListener('click', () => {
state.eventsExpanded = !state.eventsExpanded;
renderEvents();
});
// 横竖屏切换 / 窗口缩放时重算视图(不覆盖用户显式选择,故 persist: false)
window.addEventListener('resize', () => {
const next = resolveView();
if (next !== state.view) setView(next, { persist: false });
});
}
/* ---------- 启动 ---------- */
@@ -767,6 +938,7 @@ async function init() {
$('site-desc').textContent = state.config.settings?.description || '';
const versionEl = document.querySelector('.version');
if (versionEl) versionEl.textContent = `shiroko-monitor v${VERSION}`;
applyTheme(resolveTheme());
await resolveSource();
state.view = resolveView();
bindEvents();
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 60" fill="none" preserveAspectRatio="none">
<title>天蓝色围巾飘带(自绘)</title>
<defs>
<linearGradient id="sg" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#a9e9ff"/>
<stop offset="0.45" stop-color="#2ea8dc"/>
<stop offset="1" stop-color="#1c2f4d"/>
</linearGradient>
</defs>
<!-- 波浪形的围巾:上边缘平直贴合顶栏,下边缘起波 -->
<path d="M0 6 H600 V22 C560 38 520 12 480 26 C440 40 400 14 360 28 C320 42 280 12 240 26 C200 40 160 14 120 28 C80 42 40 16 0 30 Z"
fill="url(#sg)"/>
<!-- 围巾的褶皱纹路 -->
<g stroke="#ffffff" stroke-opacity="0.35" stroke-width="2" stroke-linecap="round">
<path d="M60 16 C58 22 58 26 60 30"/>
<path d="M180 16 C178 22 178 26 180 30"/>
<path d="M300 16 C298 22 298 26 300 30"/>
<path d="M420 16 C418 22 418 26 420 30"/>
<path d="M540 16 C538 22 538 26 540 30"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 987 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+207 -29
View File
@@ -5,10 +5,13 @@
--text: #111827;
--text-2: #6b7280;
--up: #16a34a;
--up-text: #15803d;
--up-bg: #f0fdf4;
--down: #dc2626;
--down-text: #b91c1c;
--down-bg: #fef2f2;
--pending: #d97706;
--pending-text: #92400e;
--pending-bg: #fffbeb;
--accent: #2563eb;
--uptime-ok: #16a34a;
@@ -28,10 +31,13 @@
--text: #e5e7eb;
--text-2: #9ca3af;
--up: #4ade80;
--up-text: #4ade80;
--up-bg: #10281c;
--down: #f87171;
--down-text: #f87171;
--down-bg: #2a1517;
--pending: #fbbf24;
--pending-text: #fbbf24;
--pending-bg: #2a2113;
--accent: #60a5fa;
--uptime-none: #2a2f3a;
@@ -91,6 +97,8 @@ body {
.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; }
.stats { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.controls { display: flex; align-items: center; gap: 8px; }
.pill {
padding: 4px 12px;
@@ -99,17 +107,19 @@ body {
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); }
.pill.ok { background: var(--up-bg); color: var(--up-text); }
.pill.bad { background: var(--down-bg); color: var(--down-text); }
.pill.pending { background: var(--pending-bg); color: var(--pending-text); }
.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); }
.stat-up b { color: var(--up-text); }
.stat-down b { color: var(--down-text); }
#refresh-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 8px;
@@ -123,6 +133,29 @@ body {
#refresh-btn:hover { background: var(--bg); }
#refresh-btn:disabled { opacity: 0.6; cursor: default; }
/* 加载态用的车轮图标(砂狼白子骑行意象) */
.wheel { width: 14px; height: 14px; margin-right: 6px; animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 图标按钮(主题切换) */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text-2);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.icon-btn svg { width: 16px; height: 16px; }
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
.icon-btn.active { color: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); }
/* ---------- 视图切换 ---------- */
.view-switch { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.view-btn {
@@ -166,7 +199,7 @@ body {
/* ---------- 卡片网格 ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
gap: 14px;
max-width: 1200px;
margin: 0 auto;
@@ -185,6 +218,9 @@ body {
display: flex;
flex-direction: column;
gap: 10px;
/* 网格项默认 min-width:auto,长 URL/名称会把卡片撑破容器 —— 显式允许收缩并裁剪 */
min-width: 0;
overflow: hidden;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
@@ -256,7 +292,7 @@ body {
overflow: hidden;
text-overflow: ellipsis;
}
.card[data-status="down"] .card-detail { color: var(--down); }
.card[data-status="down"] .card-detail { color: var(--down-text); }
.empty {
grid-column: 1 / -1;
@@ -292,12 +328,23 @@ body {
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; }
.demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending-text); font-weight: 600; }
.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down-text); font-weight: 600; }
/* 窄屏(手机 / 竖屏平板):顶栏改为三行——标题 / 统计 / 控件,避免挤成一团 */
@media (max-width: 820px) {
.topbar { flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 14px 14px; }
.summary { flex-direction: column; align-items: stretch; gap: 8px; }
.stats { justify-content: space-between; gap: 4px; }
.stat { min-width: 0; flex: 1 1 0; }
.stat b { font-size: 15px; }
.stat label { font-size: 10px; }
.controls { justify-content: flex-end; gap: 10px; }
.footer { flex-direction: column; align-items: flex-start; gap: 4px; padding-bottom: 20px; }
.footer .version { margin-left: 0; }
}
@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; }
@@ -314,18 +361,36 @@ body[data-view="table"] .grid { display: block; }
box-shadow: var(--shadow);
}
.monitor-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.monitor-table {
width: 100%;
/* 列宽按百分比分配 + 固定表格算法:表格永远恰好铺满容器宽度,不会撑出横向滚动 */
table-layout: fixed;
border-collapse: collapse;
font-size: 13px;
}
/* 表格列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 90天可用率 = 100%
地址与结果列给足比例,避免长 URL、长名称被截断 */
.monitor-table th:nth-child(1) { width: 7%; }
.monitor-table th:nth-child(2) { width: 15%; }
.monitor-table th:nth-child(3) { width: 24%; }
.monitor-table th:nth-child(4) { width: 7%; }
.monitor-table th:nth-child(5) { width: 9%; }
.monitor-table th:nth-child(6) { width: 10%; }
.monitor-table th:nth-child(7) { width: 15%; }
.monitor-table th:nth-child(8) { width: 13%; }
.monitor-table th {
padding: 10px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
white-space: nowrap;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
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 td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; }
.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); }
@@ -333,24 +398,137 @@ body[data-view="table"] .grid { display: block; }
.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; }
tr[data-status="up"] .t-state { color: var(--up-text); }
tr[data-status="down"] .t-state { color: var(--down-text); }
tr[data-status="pending"] .t-state { color: var(--pending-text); }
.t-name { white-space: normal; overflow-wrap: anywhere; }
.t-name .type-badge { margin-left: 8px; }
.t-target {
max-width: 280px;
font-family: var(--mono);
font-size: 12px;
font-size: 11.5px;
line-height: 1.45;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
.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; }
.t-num { font-variant-numeric: tabular-nums; white-space: normal; overflow-wrap: anywhere; }
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
.tls-badge, .maint-badge {
margin-left: 8px;
padding: 0 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 650;
vertical-align: middle;
white-space: nowrap;
cursor: help;
}
.tls-badge.warn { background: var(--pending-bg); color: var(--pending-text); }
.tls-badge.danger { background: var(--down-bg); color: var(--down-text); }
.maint-badge { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.card-head .tls-badge, .card-head .maint-badge { flex: none; }
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
.t-uptime { white-space: normal; }
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
.uptime-bar-mini { display: inline-flex; width: clamp(32px, 6vw, 84px); height: 8px; margin-right: 6px; vertical-align: middle; }
.t-pct { font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
@media (max-width: 640px) {
.uptime-bar-mini { display: none; }
}
/* ---------- 最近事件:时间线风格(故障 / 恢复 / 证书提醒) ---------- */
.events {
max-width: 1200px;
margin: 18px auto 0;
padding: 0 20px;
}
.events-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.events-head h2 { margin: 0; font-size: 13px; font-weight: 650; color: var(--text); letter-spacing: 0.02em; }
.events-count { font-size: 11px; color: var(--text-2); }
.events-more {
margin-left: auto;
padding: 3px 10px;
font-size: 11px;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: 999px;
cursor: pointer;
}
.events-more:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
/* 左侧竖线 + 每行圆点,构成时间线;行本身仍是浅色卡片 */
.events-list {
list-style: none;
margin: 0;
padding: 2px 0 2px 16px;
position: relative;
}
.events-list::before {
content: "";
position: absolute;
left: 5px;
top: 8px;
bottom: 8px;
width: 1px;
background: var(--border);
}
.ev {
position: relative;
display: grid;
/* 名称列按内容自适应,详情列吃掉剩余宽度 —— 避免短名称后面拖出一大片空白 */
grid-template-columns: 62px 46px auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 6px 12px;
border-radius: 9px;
background: var(--surface);
border: 1px solid var(--border);
font-size: 12px;
min-width: 0;
}
.ev + .ev { margin-top: 4px; }
.ev::before {
content: "";
position: absolute;
left: -14px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--text-2);
box-shadow: 0 0 0 3px var(--bg);
}
.ev-down { border-left: 3px solid var(--down); }
.ev-up { border-left: 3px solid var(--up); }
.ev-tls { border-left: 3px solid var(--pending); }
.ev-down::before { background: var(--down); }
.ev-up::before { background: var(--up); }
.ev-tls::before { background: var(--pending); }
.ev-time { color: var(--text-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.ev-type { font-weight: 650; text-align: center; border-radius: 999px; padding: 2px 0; font-size: 11px; }
.ev-down .ev-type { background: var(--down-bg); color: var(--down-text); }
.ev-up .ev-type { background: var(--up-bg); color: var(--up-text); }
.ev-tls .ev-type { background: var(--pending-bg); color: var(--pending-text); }
.ev-maint { margin-left: 6px; border-radius: 999px; padding: 0 6px; font-size: 10px; font-weight: 650; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.ev-name { font-weight: 600; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ev-detail { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.events-empty { margin: 0; padding: 12px; font-size: 12px; color: var(--text-2); background: var(--surface); border: 1px dashed var(--border); border-radius: 10px; }
@media (max-width: 640px) {
.ev { grid-template-columns: 56px 44px minmax(0, 1fr); gap: 8px; }
.ev-detail { display: none; }
.events { padding: 0 14px; }
}
/* ---------- 卡片:90 天日均延迟趋势 ---------- */
.lat-trend { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.lat-label { font-size: 11px; color: var(--text-2); flex: none; }
.lat-bars { display: flex; align-items: flex-end; gap: 1px; height: 20px; flex: 1; min-width: 0; }
.lat-cell { flex: 1; min-width: 2px; background: color-mix(in srgb, var(--accent) 50%, transparent); border-radius: 1px; }
.lat-cell:hover { background: var(--accent); }
.lat-avg { font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; flex: none; }
+263
View File
@@ -0,0 +1,263 @@
/* ============================================================
砂狼白子主题(shiroko)
真实素材(头像 / 立绘)来自社区数据库 SchaleDB,版权归 NEXON Games · Yostar;
其余装饰为自绘几何图形,均按其公开设定还原:
· 光环:倾斜的椭圆环 + 四方位带圆头的短杆(全息瞄准镜准星意象)
· 发饰:青色倒十字(旋转 45°)
· 围巾:天蓝色飘带(星野的信物)
· 座驾:公路车
仅在 body[data-theme="shiroko"] 下生效,不影响默认主题。
============================================================ */
body[data-theme="shiroko"] {
--bg: #eef7fd;
--surface: #ffffff;
--border: #d3e6f5;
--text: #17253d;
--text-2: #55708c;
--up: #0da093;
--up-text: #0e7c72;
--up-bg: #e4faf6;
--down: #e0524e;
--down-text: #c0362f;
--down-bg: #fdecec;
--pending: #c07d0d;
--pending-text: #8a5a00;
--pending-bg: #fff7e6;
--accent: #0e7490;
--uptime-ok: #0da093;
--uptime-warn: #c07d0d;
--uptime-bad: #e0524e;
--uptime-none: #dbe8f3;
--shadow: 0 1px 2px rgb(23 60 100 / 0.05), 0 10px 26px -20px rgb(23 60 100 / 0.35);
--sh-sky: #8fe0ff;
--sh-sky-deep: #2ea8dc;
--sh-navy: #1c2f4d;
/* 面板/表头/分组的底色与标题色随明暗模式切换(此前是硬编码浅色,暗色模式会「白顶栏 + 深页面」) */
--sh-panel-1: #ffffff;
--sh-panel-2: #f6fcff;
--sh-card-2: #fbfdff;
--sh-th-1: #f7fcff;
--sh-th-2: #eef7fd;
--sh-group: #eaf6fd;
--sh-title: #1c2f4d;
}
@media (prefers-color-scheme: dark) {
body[data-theme="shiroko"] {
--bg: #0a1622;
--surface: #101d2c;
--border: #1d3247;
--text: #e4eef7;
--text-2: #8ba6bf;
--up: #2fd4c2;
--up-text: #4fe0cf;
--up-bg: #0d2a2a;
--down: #f4736c;
--down-text: #f9a29c;
--down-bg: #2c1518;
--pending: #e8b344;
--pending-text: #f0c76a;
--pending-bg: #2a2113;
--accent: #55c2ef;
--uptime-ok: #2fd4c2;
--uptime-none: #1a2b3d;
--shadow: none;
--sh-panel-1: #14243a;
--sh-panel-2: #101d2c;
--sh-card-2: #0e1a27;
--sh-th-1: #16273c;
--sh-th-2: #101d2c;
--sh-group: #132437;
--sh-title: #e4eef7;
}
}
/* ---------- 背景:天蓝渐变 + 淡斜纹 ---------- */
body[data-theme="shiroko"] {
background-color: var(--bg);
background-image:
repeating-linear-gradient(135deg, rgb(46 168 220 / 4%) 0 1px, transparent 1px 9px),
radial-gradient(120% 60% at 50% -20%, rgb(143 224 255 / 26%), transparent 70%),
linear-gradient(180deg, rgb(255 255 255 / 70%), transparent 320px);
background-attachment: fixed;
}
/* 页脚下方空带:按官方造型还原的光环水印(倾斜椭圆环 + 四方位圆头短杆),
与左下角公路车、右下角立绘形成一条装饰带,互不遮挡 */
body[data-theme="shiroko"]::after {
content: "";
position: fixed;
left: 44%;
bottom: 0;
width: 330px;
height: 180px;
pointer-events: none;
z-index: 0;
opacity: 0.5;
background-repeat: no-repeat;
background-size: contain;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 130'><g stroke='%232ea8dc' stroke-opacity='0.11' fill='none' stroke-linecap='round'><ellipse cx='120' cy='64' rx='86' ry='38' stroke-width='11'/><ellipse cx='120' cy='70' rx='48' ry='21' stroke-width='6'/><g stroke-width='7'><line x1='22' y1='64' x2='6' y2='64'/><line x1='218' y1='64' x2='234' y2='64'/><line x1='120' y1='18' x2='120' y2='2'/><line x1='120' y1='110' x2='120' y2='126'/></g></g><g fill='%232ea8dc' fill-opacity='0.11'><circle cx='6' cy='64' r='4.6'/><circle cx='234' cy='64' r='4.6'/><circle cx='120' cy='2' r='4.6'/><circle cx='120' cy='126' r='4.6'/></g></svg>");
}
/* 左下角:简约风公路车插画(与人物同一套扁平风格) */
body[data-theme="shiroko"]::before {
content: "";
position: fixed;
left: 8px;
bottom: 2px;
width: 158px;
height: 93px;
pointer-events: none;
z-index: 0;
/* 全局半透明 + 两端渐隐,弱化「一块贴图」的突兀感 */
opacity: var(--sh-deco-opacity, 0.78);
filter: saturate(0.92);
mask-image: linear-gradient(180deg, transparent 0, #000 16%, #000 84%, transparent 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 16%, #000 84%, transparent 100%);
background: url("shiroko-bike.webp") no-repeat center / contain;
}
/* ---------- 顶栏:天蓝到白 + 底部围巾飘带 ---------- */
body[data-theme="shiroko"] .topbar {
background: linear-gradient(180deg, var(--sh-panel-1) 0%, var(--sh-panel-2) 100%);
border-bottom: 0;
box-shadow: var(--shadow);
overflow: hidden;
}
body[data-theme="shiroko"] .topbar::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 12px;
background: url("deco-scarf.svg") repeat-x bottom left / 300px 12px;
pointer-events: none;
}
/* ---------- 品牌:真实头像 + 光环环 ---------- */
body[data-theme="shiroko"] .logo {
width: 34px;
height: 34px;
border-radius: 50%;
background: #eaf7ff url("shiroko-icon.webp") center / cover no-repeat;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--sh-sky-deep), 0 0 12px rgb(46 168 220 / 55%);
animation: avatar-halo 3.4s ease-out infinite;
}
@keyframes avatar-halo {
0% { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--sh-sky-deep), 0 0 12px rgb(46 168 220 / 55%); }
65% { box-shadow: 0 0 0 2px #fff, 0 0 0 8px rgb(46 168 220 / 0%), 0 0 16px rgb(46 168 220 / 30%); }
100% { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--sh-sky-deep), 0 0 12px rgb(46 168 220 / 55%); }
}
body[data-theme="shiroko"] .brand-text h1 {
color: var(--sh-title);
letter-spacing: 0.04em;
}
/* 分组标题前的青色倒十字发饰(旋转 45°) */
body[data-theme="shiroko"] .group-title::before,
body[data-theme="shiroko"] .group-row td::before {
content: "";
display: inline-block;
width: 11px;
height: 11px;
margin-right: 7px;
vertical-align: middle;
background-repeat: no-repeat;
background-size: contain;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><g transform='rotate(45 10 10)' fill='%232ea8dc'><rect x='8.5' y='0.5' width='3' height='19' rx='1.4'/><rect x='0.5' y='8.5' width='19' height='3' rx='1.4'/></g><circle cx='10' cy='10' r='2.6' fill='%23bfeaff'/></svg>");
}
/* ---------- 状态点 → 迷你光环 ---------- */
body[data-theme="shiroko"] [data-status="up"] { --sh-state: var(--up); }
body[data-theme="shiroko"] [data-status="down"] { --sh-state: var(--down); }
body[data-theme="shiroko"] [data-status="pending"] { --sh-state: var(--pending); }
body[data-theme="shiroko"] .dot {
width: 12px;
height: 12px;
background: transparent;
border: 2px solid var(--sh-state, var(--pending));
border-radius: 50%;
position: relative;
box-shadow: 0 0 7px -1px var(--sh-state, var(--pending));
}
body[data-theme="shiroko"] .dot::after {
content: "";
position: absolute;
inset: 2.5px;
border-radius: 50%;
background: var(--sh-state, var(--pending));
}
body[data-theme="shiroko"] .card[data-status="up"] .dot { animation: halo-pulse 2.6s ease-out infinite; }
@keyframes halo-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 45%, 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); }
}
/* ---------- 卡片 / 表格 / 交互 ---------- */
body[data-theme="shiroko"] .card {
border-left-width: 3px;
background: linear-gradient(180deg, var(--sh-panel-1), var(--sh-card-2));
}
body[data-theme="shiroko"] .tab.active,
body[data-theme="shiroko"] .view-btn.active {
background: linear-gradient(135deg, var(--sh-sky-deep), #1b7fb4);
box-shadow: 0 3px 10px -4px rgb(46 168 220 / 70%);
}
body[data-theme="shiroko"] #refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
body[data-theme="shiroko"] .monitor-table th {
background: linear-gradient(180deg, var(--sh-th-1), var(--sh-th-2));
color: var(--sh-title);
border-bottom: 1px solid var(--border);
}
body[data-theme="shiroko"] .monitor-table tbody tr:hover { background: rgb(46 168 220 / 6%); }
body[data-theme="shiroko"] .group-row td {
background: linear-gradient(90deg, var(--sh-group), var(--sh-th-1));
border-left: 3px solid var(--sh-sky-deep);
color: var(--sh-title);
}
body[data-theme="shiroko"] .table-wrap { box-shadow: var(--shadow); }
body[data-theme="shiroko"] .uptime-cell.lv-none {
background-image: repeating-linear-gradient(135deg, rgb(46 168 220 / 16%) 0 1px, transparent 1px 3px);
background-color: var(--uptime-none);
}
/* ---------- 刷新按钮的公路车轮加载态 ---------- */
body[data-theme="shiroko"] #refresh-btn .wheel { animation: wheel-spin 0.9s linear infinite; }
@keyframes wheel-spin { to { transform: rotate(360deg); } }
/* ---------- 简约风立绘(settings.artwork,默认指向内置形象) ---------- */
/* --sh-deco-opacity 由面板按 settings.decoOpacity 注入,人物与单车共用同一个值 */
body[data-theme="shiroko"] .artwork {
position: fixed;
right: 10px;
bottom: 0;
width: auto;
height: min(40vh, 340px);
z-index: 0;
pointer-events: none;
user-select: none;
/* 全局半透明(整张图统一淡化)+ 上下渐隐,读起来是背景插画而不是贴上去的贴纸 */
opacity: var(--sh-deco-opacity, 0.78);
filter: drop-shadow(0 6px 18px rgb(23 60 100 / 10%));
mask-image: linear-gradient(180deg, transparent 0, #000 4%, #000 92%, rgb(0 0 0 / 45%) 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 4%, #000 92%, rgb(0 0 0 / 45%) 100%);
}
/* 窄屏:立绘与固定装饰全部撤掉,避免压住内容或溢出(手机只保留配色与围巾飘带) */
@media (max-width: 820px) {
body[data-theme="shiroko"] .artwork,
body[data-theme="shiroko"]::before,
body[data-theme="shiroko"]::after { display: none; }
}
/* 横屏手机:可用高度很小,立绘同样撤掉 */
@media (max-height: 520px) {
body[data-theme="shiroko"] .artwork { display: none; }
}
/* 注:立绘尺寸在卡片/表格两种视图下必须保持一致,不再按视图改高度 */
/* 内容层置于装饰之上 */
body[data-theme="shiroko"] .grid,
body[data-theme="shiroko"] .footer,
body[data-theme="shiroko"] .group-tabs { position: relative; z-index: 1; }
+35 -8
View File
@@ -7,6 +7,7 @@
<title>Shiroko Monitor</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="assets/style.css">
<link rel="stylesheet" href="assets/theme-shiroko.css">
</head>
<body>
<header class="topbar">
@@ -19,13 +20,27 @@
</div>
<div class="summary">
<span id="overall-pill" class="pill pending">检测中…</span>
<div class="stat"><b id="stat-total">–</b><label>监控数</label></div>
<div class="stat stat-up"><b id="stat-up">–</b><label>在线</label></div>
<div class="stat stat-down"><b id="stat-down">–</b><label>离线</label></div>
<div class="stat"><b id="stat-latency">–</b><label>平均延迟</label></div>
<div class="stat"><b id="stat-uptime">–</b><label>90天可用率</label></div>
<div class="view-switch" id="view-switch" role="group" aria-label="视图切换"></div>
<button id="refresh-btn" type="button">刷新</button>
<div class="stats">
<div class="stat"><b id="stat-total">–</b><label>监控数</label></div>
<div class="stat stat-up"><b id="stat-up">–</b><label>在线</label></div>
<div class="stat stat-down"><b id="stat-down">–</b><label>离线</label></div>
<div class="stat"><b id="stat-latency">–</b><label>平均延迟</label></div>
<div class="stat"><b id="stat-uptime">–</b><label>90天可用率</label></div>
</div>
<div class="controls">
<div class="view-switch" id="view-switch" role="group" aria-label="视图切换"></div>
<button id="theme-btn" type="button" class="icon-btn" title="切换主题">
<svg viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="31" fill="none" stroke="currentColor" stroke-width="8"/>
<circle cx="50" cy="50" r="15" fill="none" stroke="currentColor" stroke-width="4" opacity="0.7"/>
<polygon points="46.4,4 53.6,4 51.2,15 48.8,15" fill="currentColor"/>
<polygon points="46.4,96 53.6,96 51.2,85 48.8,85" fill="currentColor"/>
<polygon points="4,46.4 4,53.6 15,51.2 15,48.8" fill="currentColor"/>
<polygon points="96,46.4 96,53.6 85,51.2 85,48.8" fill="currentColor"/>
</svg>
</button>
<button id="refresh-btn" type="button">刷新</button>
</div>
</div>
</header>
@@ -35,13 +50,25 @@
<div class="empty">正在加载监控配置…</div>
</main>
<!-- 最近事件:故障 / 恢复 / 证书提醒(含维护窗口内的事件) -->
<section class="events" id="events" hidden>
<div class="events-head">
<h2>最近事件</h2>
<span class="events-count" id="events-count"></span>
<button type="button" class="events-more" id="events-more" hidden>展开全部</button>
</div>
<ul class="events-list" id="events-list"></ul>
<p class="events-empty" id="events-empty" hidden>暂无事件记录(故障、恢复与证书提醒会出现在这里)</p>
</section>
<footer class="footer">
<span id="source-badge" class="footer-item">数据源:–</span>
<span id="demo-chip" class="footer-item demo-chip" hidden>演示数据</span>
<span id="ur-chip" class="footer-item warn-chip" hidden>UptimeRobot 同步异常</span>
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.6.0</span>
<span class="footer-item version">shiroko-monitor v0.11.1</span>
</footer>
<script type="module" src="assets/app.js"></script>
+3
View File
@@ -3,6 +3,9 @@
"title": "Shiroko Monitor",
"description": "轻量级服务监控面板",
"view": "table",
"theme": "shiroko",
"artwork": "assets/shiroko-figure.webp",
"decoOpacity": 0.78,
"dataFilter": "uptimerobot",
"refreshInterval": 300,
"dataSource": "auto",
+694 -596
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""从 assets-src/ 的原始插画生成 public/assets/ 下的透明素材。
为什么要这么处理:
图像生成返回的是**白底**图,需要抠成透明。抠白底分两步,缺一不可——
1) 边界洪泛:把与图片边界连通的白色变透明(背景主体)
2) 封闭区域补抠:被图形围起来的白色区域与边界不连通,第 1 步碰不到它们。
光环环内、单车车架三角、轮圈内扇形都会因此留成白块(v0.8.3 之前就踩过)
注意:不能简单「全局抠白」,那会把角色的脸/袜子/衣服白一起抠掉。
本脚本按连通域面积处理,并跳过角色躯干区域,保留服装白。
用法(PyPI 的 Pillow):
python scripts/prepare-artwork.py
"""
from collections import deque
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "assets-src"
DST = ROOT / "public" / "assets"
SENTINEL = (255, 0, 255)
WHITE_TOLERANCE = 18 # 与纯白的最大通道差
def is_white(px):
return px[3] > 128 and px[0] > 255 - WHITE_TOLERANCE and px[1] > 255 - WHITE_TOLERANCE and px[2] > 255 - WHITE_TOLERANCE
def connected_regions(im, w, h):
"""列出所有「近白且连通」的区域:[(面积, [(x, y), ...]), ...]"""
px = im.load()
seen = [[False] * w for _ in range(h)]
regions = []
for y in range(h):
for x in range(w):
if seen[y][x] or not is_white(px[x, y]):
continue
queue = deque([(x, y)])
seen[y][x] = True
cells = []
while queue:
cx, cy = queue.popleft()
cells.append((cx, cy))
for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
if 0 <= nx < w and 0 <= ny < h and not seen[ny][nx] and is_white(px[nx, ny]):
seen[ny][nx] = True
queue.append((nx, ny))
regions.append((len(cells), cells))
return regions
def cut_out(src: Path, dst: Path, target_height: int, *, min_area: int, square: bool = False,
skip_below: float | None = None, quality: int = 92):
"""抠白底 -> 裁边 -> 按高度缩放 -> 存 webp。
square: 裁边后补成正方形(头像/图标用,避免圆形裁切与 favicon 变形)
skip_below: 只清理位于该纵向比例(0~1)以上的封闭区域;None 表示全部清理
"""
im = Image.open(src).convert("RGBA")
width, height = im.size
# 第 1 步:边界洪泛(连通到边界的背景)
rgb = im.convert("RGB")
for x, y in ((0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1),
(width // 2, 0), (0, height // 2), (width - 1, height // 2), (width // 2, height - 1)):
if rgb.getpixel((x, y)) != SENTINEL:
ImageDraw.floodfill(rgb, (x, y), SENTINEL, thresh=WHITE_TOLERANCE + 24)
sentinel_px = rgb.load()
px = im.load()
for y in range(height):
for x in range(width):
if sentinel_px[x, y] == SENTINEL:
px[x, y] = (0, 0, 0, 0)
# 第 2 步:封闭区域补抠(按面积阈值 + 可选纵向范围)
removed = 0
for area, cells in connected_regions(im, width, height):
if area < min_area:
continue
if skip_below is not None and min(cy for _, cy in cells) > height * skip_below:
continue # 疑似服装白,保留
for x, y in cells:
px[x, y] = (0, 0, 0, 0)
removed += area
box = im.getbbox()
if box:
im = im.crop(box)
if square:
side = max(im.size)
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(im, ((side - im.width) // 2, (side - im.height) // 2))
im = canvas
scale = target_height / im.height
im = im.resize((round(im.width * scale), target_height), Image.LANCZOS)
im.save(dst, "WEBP", quality=quality, method=6)
print(f"{dst.name}: {im.size} 补抠封闭白区 {removed}px,体积 {dst.stat().st_size / 1024:.1f}KB")
return im
def main():
# 立绘:560 高(与 CSS 里 min(40vh, 340px) 的显示尺寸对应,留 1.6 倍余量给高 DPI)
figure = cut_out(SRC / "figure-original.png", DST / "shiroko-figure.webp", 560, min_area=150)
# 图标:补成正方形再缩到 180,供圆形头像与 favicon 使用
icon = cut_out(SRC / "icon-original.png", DST / "shiroko-icon.webp", 180, min_area=15, square=True)
icon.resize((64, 64), Image.LANCZOS).save(DST / "favicon-shiroko.png", "PNG", optimize=True)
print(f"favicon-shiroko.png: 64x64,体积 {(DST / 'favicon-shiroko.png').stat().st_size / 1024:.1f}KB")
# 单车:200 高,通体无服装白,全部清理
cut_out(SRC / "bike-original.png", DST / "shiroko-bike.webp", 200, min_area=20)
print("完成。视觉上请确认:光环环内、车架三角、轮圈内扇形均为透明,且角色的脸/袜子/衣服完好。")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
/**
* shiroko-monitor 布局回归:任何宽度、任何内容都不应出现横向滚动
*
* 硬要求(用户明确):不允许出现「需要滚动才能看全」的情况。
* 因此这里用**极端内容**(超长名称 / 超长 URL / 6 个分组 / 40 个目标)+ 13 种视口 × 2 种视图
* 做扫描,断言页面与表格容器都不能有横向溢出。
*
* 为什么要用极端内容:常规内容下很多撑破问题不会暴露(实测卡片视图在 320px 下
* 曾因 grid 的 min-width:auto 被长 URL 撑到 801px,溢出 495px)。
*
* 用法:node scripts/verify-layout.cjs
*/
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { chromium } = require('playwright');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const PORT = 8893;
const WIDTHS = [320, 360, 390, 480, 600, 768, 820, 900, 1024, 1280, 1440, 1920, 2560];
const results = [];
const check = (name, pass, detail) => {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
};
const MIME = { '.html': 'text/html;charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png' };
// 极端配置:长名称 + 长 URL + 多分组 + 多目标
function hostileConfig() {
const groups = ['超长分组名称测试一二三四五', '公共服务', '基础设施与内网设备', 'UptimeRobot', '外部依赖', '测试用极端分组名'];
const monitors = [];
for (let i = 0; i < 40; i++) {
monitors.push({
id: `hostile-${i}`,
name: i % 3 === 0 ? `这是一个非常非常长的监控目标名称用于测试极端情况下的布局表现与截断行为-${i + 1}` : `目标-${i + 1}`,
group: groups[i % groups.length],
type: 'http',
url: i % 2 === 0 ? `https://very-long-subdomain-name-for-testing-layout.example.com/very/long/path/segment?with=query&and=more&even=more&idx=${i}` : `https://s${i}.example.com`,
expectStatus: [200],
interval: 60,
timeout: 5,
});
}
return monitors;
}
function runOnce(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBER, ...args], { stdio: 'ignore' });
child.on('exit', (code) => resolve(code));
});
}
async function main() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-layout-'));
const cfg = path.join(tmp, 'monitors.json');
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const base = JSON.parse(fs.readFileSync(cfg, 'utf8'));
base.settings.dataFilter = 'config';
base.monitors = hostileConfig();
fs.writeFileSync(cfg, `${JSON.stringify(base, null, 2)}\n`);
await runOnce(['--once', '--demo', `--config=${cfg}`, `--local-config=${path.join(tmp, 'none.json')}`, `--out=${path.join(tmp, 'status.json')}`]);
console.log(`极端内容:${base.monitors.length} 个目标 / 6 个分组,视口 ${WIDTHS.length} 种 × 2 视图\n`);
const server = http.createServer((req, res) => {
const rel = decodeURIComponent(req.url.split('?')[0]);
const file = path.join(tmp, rel === '/' ? 'index.html' : rel);
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
res.end(data);
});
});
await new Promise((r) => server.listen(PORT, r));
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
let worst = null;
try {
for (const view of ['table', 'cards']) {
for (const width of WIDTHS) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
await page.goto(`http://localhost:${PORT}/?source=static&view=${view}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.card, table.monitor-table', { timeout: 20000 });
await page.waitForTimeout(400);
const r = await page.evaluate(() => ({
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
wrap: (() => { const w = document.querySelector('.table-wrap'); return w ? w.scrollWidth - w.clientWidth : 0; })(),
grid: (() => { const g = document.querySelector('.grid'); return g ? g.scrollWidth - g.clientWidth : 0; })(),
}));
const bad = Math.max(r.page, r.wrap, r.grid);
if (!worst || bad > worst.bad) worst = { bad, view, width, r };
check(`${String(width).padStart(4)}px ${view} 无横向溢出`, bad <= 0, `页面 ${r.page} / 表格 ${r.wrap} / 网格 ${r.grid}`);
await page.close();
}
}
} finally {
await browser.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
if (worst && worst.bad > 0) console.log(`\n最大溢出:${worst.width}px ${worst.view} → ${worst.bad}px`);
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);
process.exit(1);
});
+401
View File
@@ -0,0 +1,401 @@
/**
* shiroko-monitor 通知与证书验证
*
* 覆盖:
* A. 邮件通道(假 SMTP 服务器):完整握手、AUTH LOGIN、多收件人、UTF-8 主题与正文、
* 5xx 报错路径、不支持 STARTTLS 时默认拒绝明文
* B. MeoW 通道(假 HTTP 服务器):成功(status=200)、404 昵称未注册、429 限流、
* 「HTTP 200 但业务 status≠200」、非 JSON 响应
* C. 静默时段(含跨零点)与 MeoW 本地限流器
* D. 集成:真跑探针 --once,验证去抖(连续失败 N 次才通知)、恢复通知、
* 事件时间线落盘、维护窗口抑制告警但仍记录事件
* E. 证书:tlsTargetOf 解析 + 真实握手(无网络则跳过,不算失败)
*
* 用法:node scripts/verify-notify.cjs
*/
const fs = require('node:fs');
const net = require('node:net');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const LIB = path.join(ROOT, 'prober', 'node', 'lib', 'notify.js');
const TLS_LIB = path.join(ROOT, 'prober', 'node', 'lib', 'tls-probe.js');
const SMTP_PORT = 8895;
const MEOW_PORT = 8896;
const results = [];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
/* ---------- 假 SMTP 服务器 ---------- */
function startFakeSmtp() {
const received = [];
let failAt = null; // 'auth' | 'mail' 注入 5xx
const server = net.createServer((socket) => {
let buf = '';
let stage = 'greeting'; // greeting → ehlo → auth-user → auth-pass → ready → data
const send = (line) => socket.write(`${line}\r\n`);
send('220 fake-smtp ready');
socket.on('data', (chunk) => {
buf += chunk.toString('utf8');
if (!buf.endsWith('\r\n')) return;
const line = buf.trim();
buf = '';
const cmd = line.split(/\s+/)[0].toUpperCase();
// 不实现 TLS:EHLO 只通告 AUTH LOGIN(客户端会按 allowInsecure 决定是否明文继续)
if (stage === 'greeting' && cmd === 'EHLO') {
// SMTP 多行响应:最后一行必须是「250 + 空格」才算终止(客户端按此判定)
send('250-fake-smtp');
send('250-AUTH LOGIN PLAIN');
send('250 OK');
stage = 'ehlo';
} else if (cmd === 'AUTH' && line.toUpperCase().startsWith('AUTH LOGIN')) {
if (failAt === 'auth') return send('535 认证失败');
send('334 VXNlcm5hbWU6');
stage = 'auth-user';
} else if (stage === 'auth-user') {
send('334 UGFzc3dvcmQ6');
stage = 'auth-pass';
} else if (stage === 'auth-pass') {
send('235 认证成功');
stage = 'ready';
} else if (cmd === 'MAIL') {
if (failAt === 'mail') return send('550 发件人被拒');
received.push({ mailFrom: /<([^>]*)>/.exec(line)?.[1] ?? '' });
send('250 ok');
} else if (cmd === 'RCPT') {
const addr = /<([^>]*)>/.exec(line)?.[1] ?? '';
if (!received.length) received.push({});
received.at(-1).rcpt = [...(received.at(-1).rcpt ?? []), addr];
send('250 ok');
} else if (cmd === 'DATA') {
send('354 end with <CRLF>.<CRLF>');
stage = 'data';
} else if (stage === 'data') {
received.at(-1).raw = line;
stage = 'done';
send('250 queued');
} else if (cmd === 'QUIT') {
send('221 bye');
socket.end();
} else {
send('250 ok');
}
});
socket.on('error', () => {});
});
return new Promise((resolve) => server.listen(SMTP_PORT, '127.0.0.1', () => resolve({
received,
failAt: (v) => { failAt = v; },
close: () => new Promise((r) => server.close(r)),
})));
}
/* ---------- 假 MeoW 服务器 ---------- */
function startFakeMeoW() {
const received = [];
const server = http.createServer((req, res) => {
const nickname = decodeURIComponent(req.url.split('?')[0].replace(/^\//, ''));
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push({ nickname, url: req.url, body });
const json = (status, message) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status, message }));
};
// 注意:MeoW 的 HTTP 状态恒为 200,业务结果只在 body 里
if (nickname === 'ok') return json(200, '推送成功');
if (nickname === 'limited') return json(429, '请求过于频繁');
if (nickname === 'missing') return json(404, '昵称未注册');
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html>not json</html>');
});
});
return new Promise((resolve) => server.listen(MEOW_PORT, '127.0.0.1', () => resolve({
received,
close: () => new Promise((r) => server.close(r)),
})));
}
/* ---------- 探针进程 ---------- */
function runOnce(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('exit', (code) => resolve({ code, out }));
});
}
function writeConfig(dir, overrides = {}) {
const cfg = {
settings: {
title: 'notify-test',
dataFilter: 'config',
refreshInterval: 60,
notify: {
enabled: true,
failureThreshold: 2,
recoveryNotify: true,
channels: {
email: {
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
user: '[email protected]', pass: 'secret', from: '[email protected]',
to: ['[email protected]', '[email protected]'],
},
meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` },
},
},
},
monitors: [
// 拒绝连接 → 离线(127.0.0.1:1 一定连不上)
{ id: 'down-tcp', name: '必然离线的 TCP', group: '测试', type: 'tcp', host: '127.0.0.1', port: 1, interval: 60, timeout: 3 },
// data: URL 一定成功 → 在线
{ id: 'up-http', name: '必然在线的 HTTP', group: '测试', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
],
...overrides,
};
const file = path.join(dir, 'monitors.json');
fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
return { file, cfg };
}
async function main() {
const { sendEmail, sendMeoW, sendTestNotifications, notifyAll, parseQuietHours, inQuietHours, createMeoWLimiter } = await import(`file://${LIB.replace(/\\/g, '/')}`);
const { probeCertificate, tlsTargetOf } = await import(`file://${TLS_LIB.replace(/\\/g, '/')}`);
const smtp = await startFakeSmtp();
const meow = await startFakeMeoW();
try {
/* ---------- A. 邮件通道 ---------- */
console.log('\n[A] 邮件通道(假 SMTP)');
const emailCfg = {
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
user: '[email protected]', pass: 's3cret!', from: '[email protected]', to: ['[email protected]', '[email protected]'],
};
await sendEmail(emailCfg, { subject: '【故障】测试目标 连接失败', text: '第一行\n第二行:中文内容' });
const mail = smtp.received.at(-1);
check('邮件已发出', Boolean(mail?.raw), '假服务器收到 DATA');
check('发件人与收件人正确', mail?.mailFrom === '[email protected]' && mail?.rcpt?.join(',') === '[email protected],[email protected]', `${mail?.mailFrom} → ${mail?.rcpt?.join(',')}`);
const headers = (mail?.raw ?? '').split('\r\n\r\n')[0] ?? '';
const bodyB64 = (mail?.raw ?? '').split('\r\n\r\n')[1] ?? '';
check('中文主题按 RFC2047 编码', /^Subject: =\?UTF-8\?B\?/m.test(headers), headers.split('\r\n').find((l) => l.startsWith('Subject:'))?.slice(0, 46) + '…');
check('正文 base64 且可解码出中文', Buffer.from(bodyB64.replace(/\r\n/g, ''), 'base64').toString('utf8').includes('第二行:中文内容'), '');
check('声明 UTF-8 字符集', /Content-Type: text\/plain; charset=UTF-8/.test(headers), '');
smtp.failAt('mail');
let err = null;
try { await sendEmail(emailCfg, { subject: 'x', text: 'y' }); } catch (e) { err = e; }
check('SMTP 5xx 被识别为失败', /发件人被拒/.test(err?.message ?? ''), err?.message ?? '未抛错');
smtp.failAt(null); // 复位注入的故障,避免污染后续用例
let err2 = null;
try { await sendEmail({ ...emailCfg, host: '127.0.0.1', port: 8999 }, { subject: 'x', text: 'y' }); } catch (e) { err2 = e; }
check('连不上 SMTP 时报错可读', /ECONNREFUSED|超时|连接/.test(err2?.message ?? ''), err2?.message?.slice(0, 60) ?? '未抛错');
let err3 = null;
try { await sendEmail({ ...emailCfg, allowInsecure: false }, { subject: 'x', text: 'y' }); } catch (e) { err3 = e; }
check('服务器无 STARTTLS 时默认拒绝明文发信', /STARTTLS/.test(err3?.message ?? ''), err3?.message?.slice(0, 70) ?? '未抛错');
/* ---------- B. MeoW 通道 ---------- */
console.log('\n[B] MeoW 通道(假 HTTP)');
const meowCfg = { nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` };
const r1 = await sendMeoW(meowCfg, { title: '标题', text: '正文' });
check('status=200 判定为成功', r1.ok === true, r1.detail ?? '');
const req1 = meow.received.at(-1);
check('昵称走路径、msgType 不作为 body', req1.nickname === 'ok' && !req1.body.includes('msgType'), `POST /${req1.nickname}${req1.url.includes('?') ? req1.url.slice(req1.url.indexOf('?')) : ''}`);
check('JSON body 含 title 与 msg', (() => { const b = JSON.parse(req1.body); return b.title === '标题' && b.msg === '正文'; })(), '');
for (const [nick, label] of [['limited', '429 限流'], ['missing', '404 昵称未注册']]) {
let e = null;
try { await sendMeoW({ ...meowCfg, nickname: nick }, { title: 't', text: 'm' }); } catch (err3) { e = err3; }
check(`${label} 被识别为失败`, Boolean(e), e?.message?.slice(0, 70) ?? '未抛错');
}
let e4 = null;
try { await sendMeoW({ ...meowCfg, nickname: 'broken' }, { title: 't', text: 'm' }); } catch (err4) { e4 = err4; }
check('非 JSON 响应被识别为失败', /不是 JSON/.test(e4?.message ?? ''), e4?.message?.slice(0, 50) ?? '');
let e5 = null;
try { await sendMeoW({ ...meowCfg, nickname: 'a/b' }, { title: 't', text: 'm' }); } catch (err5) { e5 = err5; }
check('昵称含斜杠被拒绝', /斜杠/.test(e5?.message ?? ''), e5?.message ?? '');
/* ---------- C. 静默时段与限流器 ---------- */
console.log('\n[C] 静默时段与限流器');
check('静默时段解析', parseQuietHours('23:00-07:00').from === 23 * 60, '');
check('跨零点判定(23:30 在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T23:30:00')) === true, '');
check('跨零点判定(07:30 不在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T07:30:00')) === false, '');
check('同时段判定(09:00-17:00 内)', inQuietHours('09:00-17:00', new Date('2026-10-13T12:00:00')) === true, '');
const limiter = createMeoWLimiter(() => 0);
check('限流器首次放行', limiter.allow() === true, '');
limiter.mark();
check('限流器 3 秒内拦截', limiter.allow() === false, '');
const quiet = await notifyAll(
{ enabled: true, quietHours: '23:00-07:00', channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } },
{ subject: '静默测试', text: '不应发送' },
{ logger: console, fetchImpl: fetch },
);
const before = meow.received.length;
const now = new Date();
const quietCfg = { enabled: true, quietHours: `${String(now.getHours()).padStart(2, '0')}:00-${String(now.getHours()).padStart(2, '0')}:59`, channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } };
const q = await notifyAll(quietCfg, { subject: '静默测试', text: '不应发送' }, { logger: console });
check('静默时段内不发送且标记 suppressed', q.suppressed === true && meow.received.length === before, `received ${before} → ${meow.received.length}`);
/* ---------- D. 集成:真探针 ---------- */
console.log('\n[D] 集成(探针 --once × 多轮)');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-notify-'));
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const { file } = writeConfig(tmp);
const dataDir = path.join(tmp, 'data');
const outFile = path.join(tmp, 'status.json');
const common = [`--config=${file}`, `--local-config=${file}`, `--out=${outFile}`, `--data-dir=${dataDir}`];
const beforeMail = smtp.received.length;
const beforeMeow = meow.received.length;
const run1 = await runOnce(['--once', ...common]);
check('第一轮探测成功', run1.code === 0, run1.out.split('\n').find((l) => l.includes('[once]')) ?? '');
const mailAfter1 = smtp.received.length - beforeMail;
const meowAfter1 = meow.received.length - beforeMeow;
check('离线目标连续 2 次失败才告警:第一轮不通知', mailAfter1 === 0 && meowAfter1 === 0, `邮件 ${mailAfter1} / MeoW ${meowAfter1}`);
// 事件文件只在有事件时才落盘,因此要容忍不存在
const readEvents = (dir) => {
const f = path.join(dir, 'events.json');
return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : [];
};
const events1 = readEvents(dataDir);
check('第一轮未记录事件(去抖中)', events1.length === 0, `events=${events1.length}`);
await runOnce(['--once', ...common]);
const mailAfter2 = smtp.received.length - beforeMail;
const meowAfter2 = meow.received.length - beforeMeow;
check('第二轮达到阈值:邮件与 MeoW 各 1 条', mailAfter2 === 1 && meowAfter2 === 1, `邮件 ${mailAfter2} / MeoW ${meowAfter2}`);
const decodeMail = (raw) => {
const [head, body = ''] = raw.split('\r\n\r\n');
const subject = /^Subject: =\?UTF-8\?B\?(.*)\?=/m.exec(head);
return {
subject: subject ? Buffer.from(subject[1], 'base64').toString('utf8') : (/^Subject: (.*)$/m.exec(head)?.[1] ?? ''),
body: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString('utf8'),
};
};
const downMail = smtp.received.at(-1);
const downDecoded = decodeMail(downMail.raw);
check('告警邮件含目标名称与详情', /必然离线的 TCP/.test(downDecoded.body) && /【故障】/.test(downDecoded.subject), `主题「${downDecoded.subject}」正文 ${downDecoded.body.length} 字符`);
const events2 = readEvents(dataDir);
check('事件时间线记录 down', events2.some((e) => e.type === 'down' && e.id === 'down-tcp'), JSON.stringify(events2.map((e) => e.type)));
// 去抖计数必须与「探测轮次」绑定:评估每秒跑一次,不能每秒累加
const ns2 = JSON.parse(fs.readFileSync(path.join(dataDir, 'notify-state.json'), 'utf8'));
check('连续失败计数=探测轮数(不被每秒评估放大)', ns2.monitors['down-tcp'].failStreak === 2, `failStreak=${ns2.monitors['down-tcp'].failStreak},期望 2`);
await runOnce(['--once', ...common]);
const mailAfter3 = smtp.received.length - beforeMail;
check('第三轮不重复告警(去抖状态已持久化)', mailAfter3 === 1, `邮件累计 ${mailAfter3}`);
// 恢复:把离线目标换成 data: URL,下一轮应发「恢复」
const upCfg = JSON.parse(fs.readFileSync(file, 'utf8'));
upCfg.monitors = upCfg.monitors.map((m) => (m.id === 'down-tcp' ? { ...m, type: 'http', url: 'data:text/plain,ok', expectStatus: [200] } : m));
delete upCfg.monitors.find((m) => m.id === 'down-tcp').host;
delete upCfg.monitors.find((m) => m.id === 'down-tcp').port;
fs.writeFileSync(file, `${JSON.stringify(upCfg, null, 2)}\n`);
await runOnce(['--once', ...common]);
const recovery = smtp.received.at(-1);
const recoveryDecoded = decodeMail(recovery.raw);
check('恢复时发送「恢复」通知', /【恢复】/.test(recoveryDecoded.subject) && /恢复时间/.test(recoveryDecoded.body), `主题「${recoveryDecoded.subject}」`);
const events3 = readEvents(dataDir);
check('事件时间线记录 up(恢复)', events3.some((e) => e.type === 'up' && e.id === 'down-tcp'), JSON.stringify(events3.map((e) => e.type)));
/* ---------- D2. 维护窗口 ---------- */
console.log('\n[D2] 维护窗口抑制告警');
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-maint-'));
fs.cpSync(path.join(ROOT, 'public'), tmp2, { recursive: true });
const { file: file2 } = writeConfig(tmp2);
const cfg2 = JSON.parse(fs.readFileSync(file2, 'utf8'));
cfg2.settings.maintenance = [{ from: new Date(Date.now() - 3600_000).toISOString(), to: new Date(Date.now() + 3600_000).toISOString(), note: '计划维护' }];
fs.writeFileSync(file2, `${JSON.stringify(cfg2, null, 2)}\n`);
const dataDir2 = path.join(tmp2, 'data');
const outFile2 = path.join(tmp2, 'status.json');
const beforeMail2 = smtp.received.length;
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
check('维护窗口内不发告警', smtp.received.length === beforeMail2, `新增 ${smtp.received.length - beforeMail2} 封`);
const events4 = readEvents(dataDir2);
check('维护窗口内仍记录事件(标记 maintenance)', events4.some((e) => e.type === 'down' && e.maintenance === true), JSON.stringify(events4.map((e) => `${e.type}:${e.maintenance}`)));
const snap2 = JSON.parse(fs.readFileSync(outFile2, 'utf8'));
check('快照中目标带维护标记', snap2.monitors['down-tcp'].maintenance?.note === '计划维护', '');
/* ---------- D3. 探测范围可选(probeScope) ---------- */
console.log('\n[D3] 探测范围(probeScope)');
const tmp3 = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-scope-'));
fs.cpSync(path.join(ROOT, 'public'), tmp3, { recursive: true });
const { file: file3 } = writeConfig(tmp3);
const scopeCfg = JSON.parse(fs.readFileSync(file3, 'utf8'));
scopeCfg.settings.dataFilter = 'uptimerobot'; // 只展示 UR 口径
scopeCfg.settings.uptimeRobot = { enabled: false }; // 本测试不连 UR,只看本地探测是否发生
scopeCfg.monitors.push({ id: 'local-must-not-run', name: '不该被探测的本地目标', group: '测试', type: 'tcp', host: '127.0.0.1', port: 1, interval: 60, timeout: 3 });
fs.writeFileSync(file3, `${JSON.stringify(scopeCfg, null, 2)}\n`);
const scopeArgs = (cfgFile, out, data) => ['--once', `--config=${cfgFile}`, `--local-config=${cfgFile}`, `--out=${out}`, `--data-dir=${data}`];
const outA = path.join(tmp3, 'a.json');
const resA = await runOnce(scopeArgs(file3, outA, path.join(tmp3, 'da')));
const snapA = JSON.parse(fs.readFileSync(outA, 'utf8'));
check('dataFilter=uptimerobot 时探测范围自动跟随', snapA.probeScope === 'uptimerobot', `probeScope=${snapA.probeScope}`);
check('跟随口径时不再探测本地配置目标', !/local-must-not-run/.test(resA.out), resA.out.split('\n').filter((l) => /\[up\]|\[down\]/.test(l)).join(' | ').slice(0, 80) || '本地探测日志为空');
// 对照:显式 probeScope=all 时应恢复本地探测(证明是「可选」而非「砍掉」)
scopeCfg.settings.probeScope = 'all';
fs.writeFileSync(file3, `${JSON.stringify(scopeCfg, null, 2)}\n`);
const outB = path.join(tmp3, 'b.json');
const resB = await runOnce(scopeArgs(file3, outB, path.join(tmp3, 'db')));
const snapB = JSON.parse(fs.readFileSync(outB, 'utf8'));
check('显式 probeScope=all 时恢复本地探测', snapB.probeScope === 'all' && /local-must-not-run/.test(resB.out), `probeScope=${snapB.probeScope}`);
check('探测范围与展示口径解耦(all + uptimerobot 展示)', snapB.dataFilter === 'uptimerobot' && snapB.probeScope === 'all', `filter=${snapB.dataFilter} scope=${snapB.probeScope}`);
/* ---------- E. 证书 ---------- */
console.log('\n[E] 证书');
check('tlsTargetOf 识别 https 目标', tlsTargetOf({ type: 'http', url: 'https://www.example.com:8443/x' }).port === 8443, '');
check('tlsTargetOf 忽略 http 目标', tlsTargetOf({ type: 'http', url: 'http://www.example.com' }) === null, '');
check('tlsTargetOf 尊重 tls:false', tlsTargetOf({ type: 'http', url: 'https://www.example.com', tls: false }) === null, '');
try {
const cert = await probeCertificate({ host: 'www.example.com', timeoutMs: 8000 });
if (cert.error) {
console.log(` - 真实握手失败(可能无外网):${cert.error} → 跳过`);
} else {
check('真实证书可取到剩余天数', Number.isFinite(cert.daysLeft) && cert.daysLeft > -1, `剩余 ${cert.daysLeft} 天,签发者 ${cert.issuer}`);
}
} catch (err) {
console.log(` - 真实握手异常:${err.message} → 跳过`);
}
/* ---------- F. 通道自检 CLI ---------- */
console.log('\n[F] --test-notify 自检命令');
const test = await runOnce(['--test-notify', ...common]);
check('--test-notify 退出码为 0 且报告两渠道', test.code === 0 && /email/.test(test.out) && /meow/i.test(test.out), test.out.split('\n').slice(-4).join(' | ').slice(0, 120));
} finally {
await smtp.close();
await meow.close();
}
const failed = results.filter((r) => !r.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) process.exit(1);
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});
+251
View File
@@ -6,7 +6,10 @@
* 用法:NODE_PATH=<playwright node_modules> node scripts/verify-views.cjs [baseUrl]
*/
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { chromium } = require('playwright');
const BASE = process.argv[2] || 'http://localhost:8787';
@@ -48,8 +51,39 @@ async function main() {
bodyView: document.body.dataset.view,
count: document.querySelectorAll(sel).length,
activeBtn: document.querySelector('.view-btn.active')?.textContent ?? '',
// 页面与表格都不应出现横向溢出(历史上立绘避让的 padding 曾导致桌面端需要横向拖动)
overflowPage: document.documentElement.scrollWidth - document.documentElement.clientWidth,
overflowTable: (() => {
const w = document.querySelector('.table-wrap');
return w ? Math.round(w.scrollWidth - w.clientWidth) : 0;
})(),
}), SELECTOR[view]);
check(`${view} 视图渲染 ${shown} 个条目`, info.count === shown, `count=${info.count}, 切换器=${info.activeBtn}`);
check(`${view} 视图无横向溢出(无需拖动)`, info.overflowPage <= 0 && info.overflowTable <= 0, `页面溢出 ${info.overflowPage}px,表格溢出 ${info.overflowTable}px`);
// 立绘若存在:要么不与内容重叠,要么确实位于不透明面板之下(立绘 z-index 低于内容,不应影响阅读)
const artOverlap = await page.evaluate(() => {
const art = document.querySelector('.artwork');
if (!art || getComputedStyle(art).display === 'none') return null;
const a = art.getBoundingClientRect();
const opaque = (bg) => {
const m = bg.match(/rgba?\(([^)]+)\)/);
if (!m) return false;
const parts = m[1].split(',').map(Number);
return parts.length < 4 || parts[3] === 1;
};
const hits = [...document.querySelectorAll('table.monitor-table tbody tr, .card')]
.map((el) => ({ r: el.getBoundingClientRect(), el }))
.filter(({ r }) => r.right > a.left + 8 && r.bottom > a.top && r.top < a.bottom)
.map(({ el }) => {
// 不透明判定要同时看背景色与背景图(卡片用的是渐变背景,backgroundColor 仍是透明)
const cs = getComputedStyle(el);
if (cs.backgroundImage !== 'none') return true;
const chain = [cs, getComputedStyle(el.closest('.table-wrap, .grid') ?? el)];
return chain.some((s) => s.backgroundImage !== 'none' || opaque(s.backgroundColor));
});
return { count: hits.length, allOpaque: hits.every(Boolean) };
});
if (artOverlap) check(`${view} 视图立绘不影响阅读(不重叠或位于面板之下)`, artOverlap.count === 0 || artOverlap.allOpaque, `重叠 ${artOverlap.count} 个,均在面板之下=${artOverlap.allOpaque}`);
await page.screenshot({ path: path.join(__dirname, '..', 'docs', `screenshot-view-${view}.png`), fullPage: true });
}
@@ -113,6 +147,223 @@ async function main() {
fs.writeFileSync(CONFIG, raw); // 还原
}
// ---------- H. 移动端(窄屏) ----------
console.log('\n[H] 移动端窄屏');
const mCtx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, deviceScaleFactor: 2 });
const mp = await mCtx.newPage();
const mErrors = [];
mp.on('pageerror', (e) => mErrors.push(e.message));
// 不带 ?view=:窄屏下应自动落到卡片视图(表格在手机上必须横向拖动)
await mp.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' });
await mp.waitForSelector('.card', { timeout: 20000 });
await mp.waitForTimeout(1500);
const mob = await mp.evaluate(() => {
const top = (sel) => { const el = document.querySelector(sel); return el ? Math.round(el.getBoundingClientRect().top) : -1; };
const statsW = Math.round(document.querySelector('.stats')?.getBoundingClientRect().width ?? 0);
const lastFooter = [...document.querySelectorAll('.footer-item')].pop();
return {
view: document.body.dataset.view,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
brandTop: top('.brand'), statsTop: top('.stats'), controlsTop: top('.controls'),
statsW, vw: window.innerWidth,
artwork: getComputedStyle(document.querySelector('.artwork') ?? document.body).display,
beforeDisplay: getComputedStyle(document.body, '::before').display,
afterDisplay: getComputedStyle(document.body, '::after').display,
footerRight: lastFooter ? Math.round(lastFooter.getBoundingClientRect().right) : -1,
cards: document.querySelectorAll('.card').length,
tabsWrapped: getComputedStyle(document.querySelector('.topbar')).flexDirection,
};
});
check('窄屏默认使用卡片视图', mob.view === 'cards', `data-view=${mob.view}`);
check('窄屏无横向溢出', mob.overflow <= 0, `溢出 ${mob.overflow}px`);
check('顶栏在窄屏改为纵向三行(标题/统计/控件)', mob.brandTop < mob.statsTop && mob.statsTop < mob.controlsTop && mob.tabsWrapped === 'column', `top: ${mob.brandTop}/${mob.statsTop}/${mob.controlsTop}, flex=${mob.tabsWrapped}`);
check('统计行铺满视口宽度', Math.abs(mob.statsW - mob.vw) <= 32, `stats=${mob.statsW} 视口=${mob.vw}`);
check('窄屏撤掉立绘与固定装饰', mob.artwork === 'none' && mob.beforeDisplay === 'none' && mob.afterDisplay === 'none', `立绘=${mob.artwork} 左装饰=${mob.beforeDisplay} 光环水印=${mob.afterDisplay}`);
check('页脚内容不被裁切', mob.footerRight > 0 && mob.footerRight <= mob.vw, `最后一项右边界 ${mob.footerRight} / 视口 ${mob.vw}`);
check('窄屏渲染无 JS 错误', mErrors.length === 0, mErrors.join(' | ') || '无');
await mp.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-mobile.png'), fullPage: true });
await mCtx.close();
// ---------- I. 无障碍:文字与图形对比度(WCAG AA) ----------
console.log('\n[I] 对比度(WCAG AA:正文 ≥4.5,图形 ≥3.0)');
// 对比度需在表格视图下采样(在线/离线状态文字在表格行里)
await page.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await page.waitForTimeout(1000);
const contrast = await page.evaluate(() => {
// 颜色可能是 rgb()/rgba() 或 color(srgb r g b / a)(color-mix 的结果),统一解析后逐层合成,
// 否则半透明底色会被当成不透明、0~1 分量会被当成 0~255,算出的比值是错的
const parseColor = (c) => {
const nums = (c.match(/[\d.]+/g) ?? []).map(Number);
const srgb = c.startsWith('color(srgb');
const [r, g, b] = srgb ? nums.slice(0, 3).map((v) => v * 255) : nums.slice(0, 3);
const a = srgb ? (nums.length > 3 ? nums[3] : 1) : (nums.length > 3 ? nums[3] : 1);
return { r, g, b, a };
};
const effectiveBg = (el) => {
const chain = [];
for (let n = el; n; n = n.parentElement) chain.push(n);
let bg = { r: 255, g: 255, b: 255 };
for (const n of chain.reverse()) {
const c = parseColor(getComputedStyle(n).backgroundColor);
if (!c.a) continue;
bg = { r: bg.r * (1 - c.a) + c.r * c.a, g: bg.g * (1 - c.a) + c.g * c.a, b: bg.b * (1 - c.a) + c.b * c.a };
}
return `rgb(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)})`;
};
const luminance = ([r, g, b]) => {
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const ratio = (fg, bg) => {
const a = luminance([parseColor(fg).r, parseColor(fg).g, parseColor(fg).b]);
const b = luminance([parseColor(bg).r, parseColor(bg).g, parseColor(bg).b]);
const [hi, lo] = a > b ? [a, b] : [b, a];
return (hi + 0.05) / (lo + 0.05);
};
const sample = (sel, label) => {
const el = document.querySelector(sel);
if (!el) return null;
return { label, text: el.textContent.trim().slice(0, 8), r: +ratio(getComputedStyle(el).color, effectiveBg(el)).toFixed(2) };
};
const out = [
sample('tr[data-status="up"] .t-state', '在线状态文字'),
sample('tr[data-status="down"] .t-state', '离线状态文字'),
sample('tr[data-status="pending"] .t-state', '等待状态文字'),
sample('#stat-up', '在线统计数字'),
sample('#stat-down', '离线统计数字'),
sample('#overall-pill', '顶部状态胶囊'),
sample('.t-target', '目标地址'),
sample('.footer-item', '页脚文字'),
].filter(Boolean);
// 图形:状态点边框色 vs 其所在卡片底色(图形标准 3:1)
const dot = document.querySelector('.dot');
if (dot) out.push({ label: '状态点图形', r: +ratio(getComputedStyle(dot).borderTopColor, effectiveBg(dot)).toFixed(2), graphic: true });
return out;
});
for (const c of contrast) {
if (c.r == null) continue;
const need = c.graphic ? 3 : 4.5;
check(`${c.label}对比度 ≥${need}`, c.r >= need, `${c.r}:1`);
}
// ---------- J. 事件区块与延迟趋势(演示快照,进程内临时服务) ----------
console.log('\n[J] 事件区块与延迟趋势');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-events-'));
try {
fs.cpSync(path.join(__dirname, '..', 'public'), tmpDir, { recursive: true });
const cfgPath = path.join(tmpDir, 'monitors.json');
const demoCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
demoCfg.settings.dataFilter = 'config';
fs.writeFileSync(cfgPath, `${JSON.stringify(demoCfg, null, 2)}\n`);
await new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(__dirname, '..', 'prober', 'node', 'index.js'),
'--once', '--demo', `--config=${cfgPath}`, `--local-config=${path.join(tmpDir, 'none.json')}`,
`--out=${path.join(tmpDir, 'status.json')}`], { stdio: 'ignore' });
child.on('exit', (code) => resolve(code));
});
const demoServer = http.createServer((req, res) => {
const rel = decodeURIComponent(req.url.split('?')[0]);
const file = path.join(tmpDir, rel === '/' ? 'index.html' : rel);
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404);
res.end();
return;
}
const mime = { '.html': 'text/html;charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png' }[path.extname(file)] ?? 'application/octet-stream';
res.writeHead(200, { 'content-type': mime });
res.end(data);
});
});
await new Promise((r) => demoServer.listen(8894, r));
const dp = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await dp.goto('http://localhost:8894/?source=static&view=cards', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('.card', { timeout: 20000 });
await dp.waitForTimeout(1200);
const dj = await dp.evaluate(() => ({
cards: document.querySelectorAll('.card').length,
trends: document.querySelectorAll('.lat-trend').length,
cellsPerTrend: document.querySelector('.lat-trend')?.children[1]?.children.length ?? 0,
trendTip: document.querySelector('.lat-trend')?.title ?? '',
eventsHidden: document.getElementById('events').hidden,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
check('卡片视图渲染 90 天延迟趋势条', dj.trends > 0 && dj.cellsPerTrend === 30, `${dj.trends} 条 × ${dj.cellsPerTrend} 格`);
check('趋势条 tooltip 含日均与峰值', /90 天日均延迟 \d+ ms · 峰值 \d+ ms/.test(dj.trendTip), dj.trendTip.slice(0, 60));
check('无事件时区块隐藏', dj.eventsHidden === true, '');
check('演示视图无横向溢出', dj.overflow <= 0, `${dj.overflow}px`);
// 表格视图:可用率列 tooltip 应带日均延迟
await dp.evaluate(() => document.querySelector('.view-btn').click());
await dp.waitForSelector('table.monitor-table', { timeout: 10000 });
await dp.waitForTimeout(500);
const dt = await dp.evaluate(() => document.querySelector('tr[data-status] .t-uptime')?.title ?? '');
check('表格可用率 tooltip 含 90 天日均延迟', /90 天日均延迟 \d+ ms/.test(dt), dt.slice(0, 60));
// ---- 极端内容:表格不得把数据截断,同时不得出现横向滚动 ----
const statusPath = path.join(tmpDir, 'status.json');
const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
const ids = Object.keys(snap.monitors);
let injected = 0;
for (const id of ids.slice(0, 2)) {
snap.monitors[id].name = `这是一个相当长的监控目标名称用于验证表格完整显示-${id}`;
injected++;
}
// 注入事件,验证事件区块的时间线样式与折叠
snap.events = Array.from({ length: 12 }, (_, i) => ({
t: new Date(Date.now() - i * 60000).toISOString(),
id: ids[0],
name: snap.monitors[ids[0]].name,
type: i % 2 ? 'down' : 'up',
detail: i % 2 ? 'ETIMEDOUT' : '已恢复',
maintenance: false,
}));
fs.writeFileSync(statusPath, JSON.stringify(snap));
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await dp.waitForTimeout(900);
const clip = await dp.evaluate(() => {
let clipped = 0;
let worst = 0;
for (const tr of document.querySelectorAll('tr[data-status]')) {
for (const td of tr.querySelectorAll('td')) {
const d = td.scrollWidth - td.clientWidth;
if (d > 1) { clipped++; worst = Math.max(worst, d); }
}
}
const wrap = document.querySelector('.table-wrap');
return { clipped, worst, pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, tableOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0 };
});
check(`长名称/长地址不再被截断(注入 ${injected} 个)`, clip.clipped === 0, `截断单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
check('表格仍无横向滚动', clip.pageOverflow <= 0 && clip.tableOverflow <= 0, `页面 ${clip.pageOverflow} / 表格 ${clip.tableOverflow}`);
const evr = await dp.evaluate(() => {
const ev = document.querySelector('.ev');
const cs = ev ? getComputedStyle(ev) : null;
const dot = ev ? getComputedStyle(ev, '::before') : null;
const more = document.getElementById('events-more');
return {
shown: document.querySelectorAll('.ev').length,
grid: cs?.display ?? 'none',
dotVisible: dot ? dot.width !== '0px' && dot.backgroundColor !== 'rgba(0, 0, 0, 0)' : false,
leftBorder: cs?.borderLeftWidth ?? '',
moreHidden: more?.hidden ?? true,
moreText: more?.textContent ?? '',
count: document.getElementById('events-count')?.textContent ?? '',
};
});
check('事件区块使用时间线布局(grid + 圆点)', evr.grid === 'grid' && evr.dotVisible === true, `display=${evr.grid} 圆点=${evr.dotVisible}`);
check('事件行按类型着色(左描边)', evr.leftBorder === '3px', evr.leftBorder);
check('事件默认折叠为 8 条并可展开', evr.shown === 8 && evr.moreHidden === false && /展开全部 12 条/.test(evr.moreText), `显示 ${evr.shown} 条,按钮「${evr.moreText}」,计数「${evr.count}」`);
await dp.click('#events-more');
await dp.waitForTimeout(300);
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.ev').length, text: document.getElementById('events-more').textContent }));
check('展开后显示全部事件', evExpanded.shown === 12 && evExpanded.text === '收起', `显示 ${evExpanded.shown} 条`);
await dp.close();
await new Promise((r) => demoServer.close(r));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
// 429 是刷新限流保护在起作用(快速连续导航各触发一次强制探测),面板已优雅降级为只读数据;
// 这里只把 429 以外的错误视为失败
const hardErrors = errors.filter((e) => !/429/.test(e));
+6 -4
View File
@@ -107,12 +107,14 @@ async function main() {
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);
// 等首轮刷新结束(倒计时在刷新完成时才重置),否则读到的是加载中的旧值,偏差会被算进断言
await pageG.waitForFunction(() => document.getElementById('refresh-btn').textContent === '刷新' && !document.getElementById('refresh-btn').disabled, null, { timeout: 45000 });
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)`);
const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]);
check('倒计时对齐服务端下次更新时刻', shownSec >= expectSec - 2 && shownSec <= expectSec + 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`);
// 注:无头浏览器会冻结页面定时器(实测 performance.now() 会在采样间隔内跳跃十几秒),
// 因此不断言「N 秒内递减」,只断言与公式一致、且不退化为整周期重计——这两条不依赖定时器
check('倒计时不会退化为从整周期重新计时', shownSec <= Math.max(5, Math.min(cadence, Number(cfg.settings.refreshInterval) || cadence)), `显示 ${shownSec}s`);
await pageG.close();