feat(ui): 最近事件随分组页签联动过滤 + favicon 提供 WebP
① 最近事件随筛选过滤 此前事件只受后端 dataFilter 约束,与面板上方的分组页签无关 —— 切页签后 监控列表变了,事件列表却纹丝不动。 两个真实缺陷(都是实测发现,不是推断): 1. setFilter() 只调 renderTabs() + renderView(),**没调 renderEvents()** 2. 过滤依据用 allMonitors(),它只受 dataFilter 约束、不受分组影响 → 改为 visibleGroups().flatMap(...) 3. 无匹配事件时提前 return,没清空 list.innerHTML → 旧行仍留在 DOM 里, 换个分组再切回来会看到过期内容 实测:「全部」8 条 → 「UptimeRobot」1 条(正是「狼的评论系统 / UptimeRobot:403 Forbidden」)→「基础设施」0 条且区块自动隐藏。 过滤用「当前可见目标 id 集合」反查,而不是事件自身的 group 字段 —— 外部数据源(UptimeRobot)的事件在快照里没有 group 字段,按名字反查才准。 ② favicon 提供 WebP(保留原图与 PNG) 先核实了实际情况:assets-src 下三个原图(bike 1.8MB / figure 366KB / icon 277KB)此前已转成 WebP,public/assets 里唯一的 PNG 是 favicon-shiroko.png。 因此本次只处理 favicon:新增 64×64 WebP(2.7KB,比 PNG 的 5.6KB 省一半)。 **PNG 不删** —— favicon 是浏览器最早的请求,WebP 覆盖虽广但旧浏览器会直接 不显示图标,留 PNG 作回退代价只有几 KB,换来「任何浏览器都有图标」更稳妥。 原图 assets-src/ 完全未动,prepare-artwork.py 可随时重新生成。 页面用两个 <link rel="icon" type=...> 让浏览器按格式自动挑选 (认识 WebP 就取 WebP,否则回落 PNG / SVG)。 ③ 顺带修一个既存缺陷 探针的 MIME 表**缺 .webp**,导致三张 webp 素材一直被当作 application/octet-stream 伺服(能显示但类型不对)。已补。 测试:verify-views 49→52 项(事件随页签过滤、切回全部恢复、无事件时清空 DOM)。 verify.cjs 实为 24 项(此前 README 记的 17 是旧数据)。全项目 8 套 346 项通过。
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 452 KiB After Width: | Height: | Size: 924 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 260 KiB |
@@ -77,6 +77,7 @@ const MIME = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
|
||||
@@ -283,14 +283,25 @@ function renderEvents() {
|
||||
if (!box) return;
|
||||
const list = $('events-list');
|
||||
const empty = $('events-empty');
|
||||
const all = state.status?.events ?? [];
|
||||
const count = $('events-count');
|
||||
// 事件随分组页签联动:只展示当前页签下目标的事件。
|
||||
// 用「当前可见目标 id 集合」反查,而不是事件自身的 group 字段 ——
|
||||
// 外部数据源(UptimeRobot)的事件在快照里没有 group,按名字反查才准。
|
||||
const visibleIds = new Set(visibleGroups().flatMap(([, list]) => list.map((m) => m.id)));
|
||||
const all = (state.status?.events ?? []).filter((e) => visibleIds.has(e.id));
|
||||
box.hidden = all.length === 0;
|
||||
empty.hidden = all.length > 0;
|
||||
if (!all.length) return;
|
||||
if (!all.length) {
|
||||
// 必须清空列表:仅隐藏区块的话旧行仍留在 DOM 里(换个分组再切回来会看到过期内容)
|
||||
list.innerHTML = '';
|
||||
if (count) count.textContent = '';
|
||||
const more = $('events-more');
|
||||
if (more) more.hidden = true;
|
||||
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: '维护' };
|
||||
@@ -488,6 +499,8 @@ function setFilter(g) {
|
||||
state.filter = g;
|
||||
renderTabs();
|
||||
renderView();
|
||||
// 事件按当前页签过滤,切页签后必须一并重渲染
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
function visibleGroups() {
|
||||
@@ -943,6 +956,28 @@ function applyArtwork() {
|
||||
document.body.appendChild(img);
|
||||
}
|
||||
|
||||
// favicon 格式协商:同尺寸的 WebP 体积更小,浏览器按 type 自动挑第一个认识的;
|
||||
// 不认识 WebP 的旧浏览器会跳到 PNG,两者并存保证任何环境都有图标。
|
||||
function setFavicon(shiroko) {
|
||||
const head = document.head;
|
||||
if (!head) return;
|
||||
for (const el of head.querySelectorAll('link[data-favicon]')) el.remove();
|
||||
const links = shiroko
|
||||
? [
|
||||
{ href: 'assets/favicon-shiroko.webp', type: 'image/webp' },
|
||||
{ href: 'assets/favicon-shiroko.png', type: 'image/png' },
|
||||
]
|
||||
: [{ href: 'assets/favicon.svg', type: 'image/svg+xml' }];
|
||||
for (const l of links) {
|
||||
const el = document.createElement('link');
|
||||
el.rel = 'icon';
|
||||
el.href = l.href;
|
||||
el.type = l.type;
|
||||
el.dataset.favicon = '1';
|
||||
head.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme, { persist = true } = {}) {
|
||||
state.theme = THEMES[theme] ? theme : 'default';
|
||||
if (state.theme === 'default') delete document.body.dataset.theme;
|
||||
@@ -955,8 +990,7 @@ function applyTheme(theme, { persist = true } = {}) {
|
||||
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');
|
||||
setFavicon(state.theme === 'shiroko');
|
||||
applyArtwork();
|
||||
const btn = $('theme-btn');
|
||||
if (btn) {
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="轻量级服务监控面板:HTTP 可用性与 TCP 端口探测">
|
||||
<title>Shiroko Monitor</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" data-favicon="1">
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
<link rel="stylesheet" href="assets/theme-shiroko.css">
|
||||
</head>
|
||||
|
||||
@@ -104,8 +104,17 @@ def main():
|
||||
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")
|
||||
|
||||
# favicon 两种格式都产出:PNG 64 与 WebP 64。
|
||||
# PNG 不删——favicon 是浏览器最早的请求,WebP 覆盖虽广但旧浏览器会直接不显示图标,
|
||||
# 留 PNG 作回退代价只有几 KB,换来「任何浏览器都有图标」更稳妥。
|
||||
icon64 = icon.resize((64, 64), Image.LANCZOS)
|
||||
icon64.save(DST / "favicon-shiroko.png", "PNG", optimize=True)
|
||||
icon64.save(DST / "favicon-shiroko.webp", "WEBP", quality=90, method=6)
|
||||
png_kb = (DST / "favicon-shiroko.png").stat().st_size / 1024
|
||||
webp_kb = (DST / "favicon-shiroko.webp").stat().st_size / 1024
|
||||
print(f"favicon-shiroko: 64x64,PNG {png_kb:.1f}KB / WebP {webp_kb:.1f}KB")
|
||||
|
||||
# 单车:200 高,通体无服装白,全部清理
|
||||
cut_out(SRC / "bike-original.png", DST / "shiroko-bike.webp", 200, min_area=20)
|
||||
print("完成。视觉上请确认:光环环内、车架三角、轮圈内扇形均为透明,且角色的脸/袜子/衣服完好。")
|
||||
|
||||
@@ -437,6 +437,37 @@ async function main() {
|
||||
const refreshBtnShown = await dp.evaluate(() => !document.getElementById('refresh-btn').hidden);
|
||||
check('同源模式下保留刷新按钮', refreshBtnShown === true, `可见=${refreshBtnShown}`);
|
||||
check('事件默认折叠为 8 条并可展开', evr.shown === 8 && evr.moreHidden === false && /展开全部 12 条/.test(evr.moreText), `显示 ${evr.shown} 条,按钮「${evr.moreText}」,计数「${evr.count}」`);
|
||||
|
||||
// ---- 事件随分组页签联动 ----
|
||||
// 此前 setFilter 只重渲染视图不重渲染事件,且过滤依据用的是 allMonitors()
|
||||
// (只受 dataFilter 约束、不受分组影响),导致切页签后事件纹丝不动。
|
||||
const evTabs = await dp.evaluate(() => [...document.querySelectorAll('#group-tabs .tab')].map((t) => t.textContent.trim()));
|
||||
// 找一个「事件里没有目标」的分组:演示数据的 12 条事件全部挂在 ids[0] 上,
|
||||
// 因此要排除 ids[0] 所属的那个分组
|
||||
const ids0Group = cfgJson.monitors.find((m) => m.id === ids[0])?.group || '未分组';
|
||||
const quietTab = evTabs.find((t) => !t.startsWith('全部') && !t.startsWith(ids0Group));
|
||||
check('存在可用于验证的分组页签', Boolean(quietTab), `事件目标属于「${ids0Group}」,可用安静分组:${quietTab ?? '无'}`);
|
||||
if (quietTab) {
|
||||
await dp.click(`#group-tabs .tab:text-is("${quietTab}")`);
|
||||
await dp.waitForTimeout(500);
|
||||
const evAfter = await dp.evaluate(() => {
|
||||
const visIds = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => tr.dataset.id);
|
||||
const rows = [...document.querySelectorAll('.events-table tbody tr')];
|
||||
return {
|
||||
visibleTargets: visIds,
|
||||
events: rows.length,
|
||||
// 事件行里的目标名必须都属于当前页签可见的目标
|
||||
names: rows.map((tr) => tr.querySelector('.ev-name')?.textContent?.trim()),
|
||||
hidden: document.getElementById('events').hidden,
|
||||
};
|
||||
});
|
||||
check('切分组页签后事件同步过滤(不再显示其它分组的事件)', evAfter.events === 0 && evAfter.hidden === true,
|
||||
`「${quietTab}」页签下事件 ${evAfter.events} 条、区块隐藏=${evAfter.hidden}`);
|
||||
}
|
||||
await dp.click('#group-tabs .tab:text-matches("^全部")');
|
||||
await dp.waitForTimeout(400);
|
||||
const evBack = await dp.evaluate(() => document.querySelectorAll('.events-table tbody tr').length);
|
||||
check('切回「全部」恢复完整事件', evBack === 8, `${evBack} 条`);
|
||||
await dp.click('#events-more');
|
||||
await dp.waitForTimeout(300);
|
||||
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.events-table tbody tr').length, text: document.getElementById('events-more').textContent }));
|
||||
|
||||