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 项全通过。
@@ -117,7 +117,8 @@ shiroko-monitor/
|
||||
"theme": "shiroko", // shiroko(砂狼白子)| default
|
||||
"artwork": "", // 立绘图片路径(相对 public/),留空则不显示
|
||||
"decoOpacity": 0.78, // 人物/单车装饰的全局透明度(0~1,一个值同时作用于两者)
|
||||
"dataFilter": "both", // both | config | uptimerobot(详见「面板形态与数据源」)
|
||||
"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)
|
||||
@@ -539,6 +540,21 @@ 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 条状态翻转(故障 / 恢复 / 证书 / 维护),无事件自动隐藏;窄屏隐藏「详情」列
|
||||
|
||||
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 595 KiB After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 219 KiB |
@@ -11,7 +11,7 @@
|
||||
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
|
||||
const VERSION = '0.11.0';
|
||||
const VERSION = '0.11.1';
|
||||
const HISTORY_LIMIT = 48;
|
||||
const UPTIME_DAYS = 90;
|
||||
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
|
||||
|
||||
@@ -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 未启用,面板将显示「等待检测」');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const VERSION = '0.11.0';
|
||||
const VERSION = '0.11.1';
|
||||
const HB_LIMIT = 48;
|
||||
const UPTIME_DAYS = 90;
|
||||
const BROWSER_CONCURRENCY = 6;
|
||||
@@ -16,6 +16,7 @@ const state = {
|
||||
filter: 'all',
|
||||
view: 'cards',
|
||||
theme: 'default',
|
||||
eventsExpanded: false,
|
||||
refreshTimer: null,
|
||||
nextRefreshAt: 0,
|
||||
loading: false,
|
||||
@@ -243,18 +244,26 @@ function latTrendBar(lat) {
|
||||
}
|
||||
|
||||
// 最近事件区块(故障 / 恢复 / 证书阈值 / 维护窗口内的事件)
|
||||
// 默认只列最近 EVENTS_COLLAPSED 条,其余折叠在「展开全部」后面,避免长列表把页面撑得过长
|
||||
const EVENTS_COLLAPSED = 8;
|
||||
|
||||
function renderEvents() {
|
||||
const box = $('events');
|
||||
if (!box) return;
|
||||
const list = $('events-list');
|
||||
const empty = $('events-empty');
|
||||
const events = (state.status?.events ?? []).slice(0, 20);
|
||||
box.hidden = events.length === 0;
|
||||
empty.hidden = events.length > 0;
|
||||
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 = events.length ? `最近 ${events.length} 条` : '';
|
||||
if (count) count.textContent = all.length > shown.length ? `最近 ${shown.length} / ${all.length} 条` : `最近 ${all.length} 条`;
|
||||
|
||||
const label = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
|
||||
list.innerHTML = events.map((e) => {
|
||||
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>
|
||||
@@ -263,6 +272,14 @@ function renderEvents() {
|
||||
<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) {
|
||||
@@ -896,6 +913,10 @@ 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();
|
||||
|
||||
@@ -368,15 +368,16 @@ body[data-view="table"] .grid { display: block; }
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* 列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 90天可用率 = 100% */
|
||||
.monitor-table th:nth-child(1) { width: 8%; }
|
||||
.monitor-table th:nth-child(2) { width: 16%; }
|
||||
.monitor-table th:nth-child(3) { width: 21%; }
|
||||
.monitor-table th:nth-child(4) { width: 8%; }
|
||||
.monitor-table th:nth-child(5) { width: 7%; }
|
||||
.monitor-table th:nth-child(6) { width: 11%; }
|
||||
.monitor-table th:nth-child(7) { width: 17%; }
|
||||
.monitor-table th:nth-child(8) { width: 12%; }
|
||||
/* 表格列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 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;
|
||||
@@ -400,18 +401,18 @@ body[data-view="table"] .grid { display: block; }
|
||||
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: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.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-num { font-variant-numeric: tabular-nums; white-space: normal; overflow-wrap: anywhere; }
|
||||
|
||||
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
|
||||
.tls-badge, .maint-badge {
|
||||
@@ -429,11 +430,105 @@ tr[data-status="pending"] .t-state { color: var(--pending-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: nowrap; }
|
||||
.t-uptime { white-space: normal; }
|
||||
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
|
||||
.uptime-bar-mini { display: inline-flex; width: clamp(40px, 8vw, 110px); height: 8px; margin-right: 8px; vertical-align: middle; }
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<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>
|
||||
@@ -67,7 +68,7 @@
|
||||
<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.11.0</span>
|
||||
<span class="footer-item version">shiroko-monitor v0.11.1</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="assets/app.js"></script>
|
||||
|
||||
@@ -298,6 +298,9 @@ async function main() {
|
||||
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;
|
||||
@@ -335,6 +338,33 @@ async function main() {
|
||||
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, '');
|
||||
|
||||
@@ -299,6 +299,65 @@ async function main() {
|
||||
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 {
|
||||
|
||||