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 项全通过。
This commit is contained in:
tamakyi
2026-10-14 10:22:05 +08:00
parent 4bb5296525
commit 25b8b97970
14 changed files with 858 additions and 672 deletions
+73 -16
View File
@@ -37,7 +37,7 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.11.0';
const VERSION = '0.11.1';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
@@ -295,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 镜像目标由其自身节点探测,本探针只拉取结果
@@ -408,7 +408,7 @@ const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
function monitorNotifyState(runtime, id) {
const list = runtime.notifyState.monitors;
if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, notifiedTls: [], tlsValidTo: null };
if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, lastCheckedAt: null, notifiedTls: [], tlsValidTo: null };
return list[id];
}
@@ -423,12 +423,28 @@ async function evaluateRound(runtime) {
const notify = notifyConfig(runtime.config);
const events = [];
const pendingNotifications = [];
let changed = false;
for (const m of runtime.config.monitors) {
// 事件与告警覆盖两类目标:本地探测目标 + 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);
st.failStreak = r.status === 'down' ? (st.failStreak ?? 0) + 1 : 0;
// 去抖计数只应该跟着「新的探测结果」走:本函数每秒被调用,若每次评估都累加,
// 一个持续离线的目标会在 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();
@@ -436,27 +452,30 @@ async function evaluateRound(runtime) {
// 去抖:连续失败达到阈值才算确认故障(阈值 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.url ?? `${m.host}:${m.port}`}\n时间:${at}\n详情:${r.detail ?? '无'}\n连续失败:${st.failStreak} 次\n\n—— shiroko-monitor`,
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.url ?? `${m.host}:${m.port}`}\n恢复时间:${at}\n延迟:${r.latency ?? '—'} ms\n\n—— shiroko-monitor`,
text: `目标:${m.name}\n地址:${m.target}\n恢复时间:${at}\n延迟:${r.latency ?? '—'} ms\n\n—— shiroko-monitor`,
});
}
}
@@ -477,9 +496,10 @@ async function evaluateRound(runtime) {
};
const res = await notifyAll(notify, message, { logger: console });
runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
changed = true;
}
// 无论是否发通知都要落盘:否则「连续失败计数」会丢,重启或下一轮就达不到阈值
saveNotifyState(runtime);
// 只在状态真变化时落盘:tick 会每秒调用本函数,无变化时不写文件
if (changed) saveNotifyState(runtime);
}
// 证书检查:按 checkIntervalSec 逐个握手,跨阈值时告警(同一张证书每个阈值只告一次)
@@ -490,7 +510,7 @@ async function checkCertificates(runtime) {
const events = [];
const messages = [];
for (const m of runtime.config.monitors) {
for (const m of localProbeTargets(runtime.config)) {
const target = tlsTargetOf(m);
if (!target) continue;
const prev = runtime.tls.get(m.id);
@@ -554,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;
}
/* ---------- 服务端更新节奏 ---------- */
// 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端,
@@ -677,6 +717,8 @@ function snapshot(runtime, demo = false) {
updateIntervalSec: updateIntervalSec(config, filter),
// 让面板与服务端共用同一份过滤口径,避免私有配置覆盖后两边不一致
dataFilter: filter,
// 探测范围(本地目标 / 外部数据源),面板据此显示「数据源」与是否停止本地探测
probeScope: probeScopeSetting(config),
stats: {
total: entries.length,
up,
@@ -698,9 +740,14 @@ function snapshot(runtime, demo = false) {
lastResult: runtime.notifyState?.lastResult ?? null,
};
})(),
events: (runtime.events ?? []).slice(-EVENT_IN_SNAPSHOT).reverse().map((e) => (redact
? { t: e.t, id: e.id, type: e.type, name: e.name, detail: e.type === 'down' ? '异常' : e.type === 'up' ? '恢复' : e.detail, maintenance: e.maintenance }
: e)),
// 只下发当前展示口径内目标的事件:切到 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,
};
}
@@ -987,7 +1034,7 @@ async function main() {
}
runtime.state = hydrate(runtime.config);
// 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据
await syncUptimeRobot(runtime);
if (externalSyncEnabled(runtime.config)) await syncUptimeRobot(runtime);
if (ONCE) {
await probeDue(runtime, true);
@@ -1024,7 +1071,7 @@ async function main() {
})
.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 内部已记录错误 */ });
@@ -1041,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 未启用,面板将显示「等待检测」');
}
});
}