|
|
@@ -27,6 +27,19 @@
|
|
|
#count { color: gray; }
|
|
|
#live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
|
|
|
#live.dim { color: gray; }
|
|
|
+ #bell { position: relative; padding: 3px 8px; cursor: pointer; background: none; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 4px; }
|
|
|
+ #bell svg { vertical-align: -3px; }
|
|
|
+ #bellcount { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; text-align: center;
|
|
|
+ font-size: 10px; font-weight: 700; color: #fff; background: #c0392b; border-radius: 9px; padding: 0 3px; }
|
|
|
+ #notifbox { position: absolute; z-index: 6; right: 0; top: 100%; width: min(460px, 90vw); max-height: 60vh; overflow: auto;
|
|
|
+ background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 5px; padding: 6px; }
|
|
|
+ #bar { position: relative; }
|
|
|
+ .noti { display: flex; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); cursor: pointer; }
|
|
|
+ .noti:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
|
|
|
+ .noti:last-child { border-bottom: none; }
|
|
|
+ .nic { font-weight: 700; }
|
|
|
+ @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
|
|
|
+ #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
|
|
|
.dim { color: gray; }
|
|
|
</style>
|
|
|
</head>
|
|
|
@@ -52,6 +65,11 @@
|
|
|
</label>
|
|
|
<button id="prev">‹</button><span id="count"></span><button id="next">›</button>
|
|
|
<span id="live" class="dim" title="live updates">● off</span>
|
|
|
+ <button id="bell" title="updates" aria-label="updates">
|
|
|
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
|
|
+ <span id="bellcount" style="display:none"></span>
|
|
|
+ </button>
|
|
|
+ <div id="notifbox" style="display:none"></div>
|
|
|
</div>
|
|
|
<table id="tbl"><thead></thead><tbody></tbody></table>
|
|
|
<script>
|
|
|
@@ -82,10 +100,44 @@ function cell(r, k) {
|
|
|
|
|
|
// deep-link state: the full view (filter, grouping, sort, columns, paging,
|
|
|
// collapsed groups) lives in the URL — survives any reload and is shareable
|
|
|
-// LIVE: the server re-scans the reports/ dirs on a tick and pushes the rows
|
|
|
-// JSON over SSE whenever something changed (new bench, finishing run, new
|
|
|
-// report file). The view state (filter, sort, paging, columns, collapsed
|
|
|
-// groups) is untouched — render() re-derives everything from DATA.
|
|
|
+// Update notifications: pure diff between two rows snapshots → the list of
|
|
|
+// what changed (new bench, new/removed recipe, changed entry or row config).
|
|
|
+// Newest first. Probed by the node test, extracted between the markers.
|
|
|
+/*NOTIFDIFF:START*/
|
|
|
+function notifDiff(prev, next) {
|
|
|
+ const out = [];
|
|
|
+ const byName = new Map(prev.map(r => [r.name, r]));
|
|
|
+ const meta = r => JSON.stringify({ ...r, reports: [] });
|
|
|
+ const item = (t, r, e) => ({
|
|
|
+ t, name: r.name,
|
|
|
+ label: e ? e.label : (t === "recipe" ? (r.model || r.title) : "recipe config"),
|
|
|
+ date: e ? e.date : "", time: e ? e.time : null,
|
|
|
+ score: e ? e.pct : null, points: e ? e.points : null,
|
|
|
+ files: e ? (e.files || [e.file]) : [],
|
|
|
+ });
|
|
|
+ for (const r of next) {
|
|
|
+ const old = byName.get(r.name);
|
|
|
+ if (!old) { out.push(item("recipe", r, null)); continue; }
|
|
|
+ if (meta(old) !== meta(r)) out.push(item("config", r, null));
|
|
|
+ const oldR = new Map(old.reports.map(e => [e.date + "\u0000" + e.label, e]));
|
|
|
+ for (const e of r.reports) {
|
|
|
+ const oe = oldR.get(e.date + "\u0000" + e.label);
|
|
|
+ if (!oe) out.push(item("bench", r, e));
|
|
|
+ else if (JSON.stringify(oe) !== JSON.stringify(e)) out.push(item("change", r, e));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const nextNames = new Set(next.map(r => r.name));
|
|
|
+ for (const r of prev)
|
|
|
+ if (!nextNames.has(r.name)) out.push(item("removed", r, null));
|
|
|
+ return out.reverse();
|
|
|
+}
|
|
|
+/*NOTIFDIFF:END*/
|
|
|
+
|
|
|
+// LIVE: the server re-scans the reports/ dirs on file-system events and
|
|
|
+// pushes the rows JSON over SSE whenever something changed (new bench,
|
|
|
+// finishing run, new report file). The view state (filter, sort, paging,
|
|
|
+// columns, collapsed groups) is untouched — render() re-derives everything
|
|
|
+// from DATA.
|
|
|
const liveEl = document.getElementById("live");
|
|
|
function syncTp() {
|
|
|
const vals = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
|
|
|
@@ -95,15 +147,70 @@ function syncTp() {
|
|
|
if (S.tp && ![...sel.options].some(o => o.value === S.tp)) S.tp = "";
|
|
|
sel.value = S.tp;
|
|
|
}
|
|
|
+
|
|
|
+// Bell + notifications: UNREAD counts updates since the panel was last
|
|
|
+// opened; NOTIFS (capped) is the list the panel shows. Clicking an item
|
|
|
+// expands the matching row in the table.
|
|
|
+const bellEl = document.getElementById("bell");
|
|
|
+const notifBox = document.getElementById("notifbox");
|
|
|
+let UNREAD = 0;
|
|
|
+let NOTIFS = [];
|
|
|
+const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−" };
|
|
|
+function renderBell() {
|
|
|
+ const c = document.getElementById("bellcount");
|
|
|
+ c.textContent = String(UNREAD);
|
|
|
+ c.style.display = UNREAD ? "" : "none";
|
|
|
+}
|
|
|
+function renderNotifs() {
|
|
|
+ if (!NOTIFS.length) {
|
|
|
+ notifBox.innerHTML = '<div class="dim" style="padding:8px">no updates yet</div>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ notifBox.innerHTML = NOTIFS.map(n => {
|
|
|
+ const when = n.date ? `${n.date}${n.time ? "·" + n.time : ""}` : "";
|
|
|
+ const score = n.score != null ? `${n.score} /100` : n.points || "";
|
|
|
+ const links = n.files.map(f =>
|
|
|
+ `<a class="open" target="_blank" href="/r/${encodeURIComponent(n.name)}/${encodeURIComponent(f)}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
|
|
|
+ return `<div class="noti" data-name="${esc(n.name)}" title="show in table">
|
|
|
+ <span class="nic">${NICON[n.t] || "•"}</span>
|
|
|
+ <div><b>${esc(n.name)}</b> <span class="dim">${esc(n.label)}</span><br>
|
|
|
+ <span class="dim">${esc(when)}</span> ${score ? esc(score) : ""} ${links}</div>
|
|
|
+ </div>`;
|
|
|
+ }).join("");
|
|
|
+}
|
|
|
+bellEl.addEventListener("click", e => {
|
|
|
+ e.stopPropagation();
|
|
|
+ const open = notifBox.style.display === "block";
|
|
|
+ notifBox.style.display = open ? "none" : "block";
|
|
|
+ if (!open) { UNREAD = 0; renderBell(); }
|
|
|
+});
|
|
|
+document.addEventListener("click", () => { notifBox.style.display = "none"; });
|
|
|
+notifBox.addEventListener("click", e => {
|
|
|
+ const it = e.target.closest(".noti");
|
|
|
+ if (!it) return;
|
|
|
+ S.openRow = it.dataset.name;
|
|
|
+ notifBox.style.display = "none";
|
|
|
+ render();
|
|
|
+});
|
|
|
+
|
|
|
const es = new EventSource("/events");
|
|
|
es.addEventListener("open", () => { liveEl.textContent = "● live"; liveEl.classList.remove("dim"); });
|
|
|
es.addEventListener("error", () => { liveEl.textContent = "● off"; liveEl.classList.add("dim"); });
|
|
|
es.onmessage = ev => {
|
|
|
let rows; try { rows = JSON.parse(ev.data); } catch { return; }
|
|
|
if (JSON.stringify(rows) === JSON.stringify(DATA)) return;
|
|
|
+ const upd = notifDiff(DATA, rows);
|
|
|
DATA = rows; syncTp(); render();
|
|
|
liveEl.title = "updated " + new Date().toLocaleTimeString();
|
|
|
+ if (upd.length) {
|
|
|
+ NOTIFS.unshift(...upd);
|
|
|
+ if (NOTIFS.length > 100) NOTIFS.length = 100;
|
|
|
+ UNREAD += upd.length;
|
|
|
+ renderBell(); renderNotifs();
|
|
|
+ bellEl.classList.remove("ring"); void bellEl.offsetWidth; bellEl.classList.add("ring");
|
|
|
+ }
|
|
|
};
|
|
|
+renderBell(); renderNotifs();
|
|
|
const P = new URLSearchParams(location.search);
|
|
|
const size0 = +(P.get("size") ?? 50);
|
|
|
// distinct TP values from the data, for the exact-TP filter
|