|
|
@@ -51,10 +51,25 @@
|
|
|
#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; }
|
|
|
+ /* Anchored to the bell by JS (#bar is a full-width flex row, so a plain
|
|
|
+ right:0 put the popup at the far edge of the toolbar, not under the
|
|
|
+ bell). position:fixed keeps it out of any parent overflow/transform. */
|
|
|
+ #notifbox { position: fixed; z-index: 30; width: min(460px, 92vw); max-height: 60vh; overflow: auto;
|
|
|
+ background: canvas; color: canvastext; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
|
|
+ border-radius: 6px; padding: 6px; box-shadow: 0 8px 26px rgba(0, 0, 0, .28); }
|
|
|
#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.warn .nic { color: #d2822a; }
|
|
|
+ .noti .sub { font-size: 11px; }
|
|
|
+ /* A recipe picked from the notification list: flashed so the eye finds it
|
|
|
+ in a long table (the row may be far down the page). */
|
|
|
+ @keyframes hitflash {
|
|
|
+ 0% { background: color-mix(in srgb, #f0a030 42%, transparent); }
|
|
|
+ 60% { background: color-mix(in srgb, #f0a030 26%, transparent); }
|
|
|
+ 100% { background: transparent; }
|
|
|
+ }
|
|
|
+ tr.row.hit > td { animation: hitflash 3.2s ease-out; }
|
|
|
+ tr.row.hit > td:first-child { box-shadow: inset 3px 0 0 #f0a030; }
|
|
|
.noti:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
|
|
|
.noti:last-child { border-bottom: none; }
|
|
|
.nic { font-weight: 700; }
|
|
|
@@ -122,7 +137,6 @@
|
|
|
</label>
|
|
|
<button id="prev">‹</button><span id="count"></span><button id="next">›</button>
|
|
|
<button id="syncbtn" title="sync the recipe repos now (dashboard job; new recipes appear via the live stream)" style="display:none">⟳ sync</button>
|
|
|
- <span id="repostat" class="dim" style="display:none"></span>
|
|
|
<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>
|
|
|
@@ -232,7 +246,10 @@ function cell(r, k) {
|
|
|
if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
|
|
|
if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
|
|
|
if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
|
|
|
- if (k === "state" || k === "uptime") return r.up != null ? nowSecs() - r.up : r.fleet_up ? 0 : -1; // live uptime secs (fleet-up: unknown start)
|
|
|
+ if (k === "state" || k === "uptime") {
|
|
|
+ const since = r.up != null ? r.up : r.fleet_since; // ledger, else the container's own start
|
|
|
+ return since != null ? nowSecs() - since : -1;
|
|
|
+ }
|
|
|
if (k === "runs") return r.runs ?? 0;
|
|
|
return r[k];
|
|
|
}
|
|
|
@@ -246,17 +263,30 @@ function cell(r, k) {
|
|
|
function notifDiff(prev, next) {
|
|
|
const out = [];
|
|
|
const byName = new Map(prev.map(r => [r.name, r]));
|
|
|
- const meta = r => JSON.stringify({ ...r, reports: [] });
|
|
|
+ // Run state (up / fleet_up / fleet_since / stale) has its own notification
|
|
|
+ // kinds — leaving it in the meta blob would double-report a serve as a
|
|
|
+ // "config changed" as well.
|
|
|
+ const meta = r => JSON.stringify({
|
|
|
+ ...r, reports: [], up: undefined, up_cluster: undefined,
|
|
|
+ fleet_up: undefined, fleet_since: undefined, stale: undefined, runs: undefined,
|
|
|
+ });
|
|
|
const item = (t, r, e) => ({
|
|
|
t, name: r.name,
|
|
|
- label: e ? e.label : (t === "recipe" ? (r.model || r.title) : "recipe config"),
|
|
|
+ label: e ? e.label
|
|
|
+ : t === "recipe" ? `new recipe · ${r.model || r.title}`
|
|
|
+ : t === "up" ? "started"
|
|
|
+ : t === "down" ? "stopped"
|
|
|
+ : "recipe config changed",
|
|
|
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]) : [],
|
|
|
});
|
|
|
+ const running = r => r.up != null || r.fleet_up;
|
|
|
for (const r of next) {
|
|
|
const old = byName.get(r.name);
|
|
|
if (!old) { out.push(item("recipe", r, null)); continue; }
|
|
|
+ if (!running(old) && running(r)) out.push(item("up", r, null));
|
|
|
+ else if (running(old) && !running(r)) out.push(item("down", r, null));
|
|
|
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) {
|
|
|
@@ -294,12 +324,21 @@ const bellEl = document.getElementById("bell");
|
|
|
const notifBox = document.getElementById("notifbox");
|
|
|
let UNREAD = 0;
|
|
|
let NOTIFS = [];
|
|
|
-const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−" };
|
|
|
+const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−",
|
|
|
+ up: "▶", down: "⏹", sync: "⟳" };
|
|
|
function renderBell() {
|
|
|
const c = document.getElementById("bellcount");
|
|
|
c.textContent = String(UNREAD);
|
|
|
c.style.display = UNREAD ? "" : "none";
|
|
|
}
|
|
|
+// Push a non-dataset notification (repo sync result, config warning …).
|
|
|
+function notify(t, name, label, warn) {
|
|
|
+ NOTIFS.unshift({ t, name: name || "", label, warn: !!warn, files: [] });
|
|
|
+ if (NOTIFS.length > 100) NOTIFS.length = 100;
|
|
|
+ UNREAD += 1;
|
|
|
+ renderBell(); renderNotifs();
|
|
|
+ bellEl.classList.remove("ring"); void bellEl.offsetWidth; bellEl.classList.add("ring");
|
|
|
+}
|
|
|
function renderNotifs() {
|
|
|
if (!NOTIFS.length) {
|
|
|
notifBox.innerHTML = '<div class="dim" style="padding:8px">no updates yet</div>';
|
|
|
@@ -310,26 +349,64 @@ function renderNotifs() {
|
|
|
const score = n.score != null ? `${n.score} /100` : n.points || "";
|
|
|
const links = n.files.map(f =>
|
|
|
`<a class="open" target="_blank" href="${auth("/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">
|
|
|
+ const cls = "noti" + (n.warn ? " warn" : "");
|
|
|
+ return `<div class="${cls}" data-name="${esc(n.name)}" title="${n.name ? "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("");
|
|
|
}
|
|
|
+// The popup hangs off the bell's own box, clamped to the viewport.
|
|
|
+function placeNotifs() {
|
|
|
+ const r = bellEl.getBoundingClientRect();
|
|
|
+ const w = Math.min(460, window.innerWidth * 0.92);
|
|
|
+ notifBox.style.width = w + "px";
|
|
|
+ const left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8));
|
|
|
+ const below = window.innerHeight - r.bottom - 16;
|
|
|
+ const top = below < 220 ? Math.max(8, r.top - Math.min(360, window.innerHeight * 0.6)) : r.bottom + 6;
|
|
|
+ notifBox.style.left = left + "px";
|
|
|
+ notifBox.style.top = top + "px";
|
|
|
+}
|
|
|
bellEl.addEventListener("click", e => {
|
|
|
e.stopPropagation();
|
|
|
const open = notifBox.style.display === "block";
|
|
|
- notifBox.style.display = open ? "none" : "block";
|
|
|
- if (!open) { UNREAD = 0; renderBell(); }
|
|
|
+ if (open) { notifBox.style.display = "none"; return; }
|
|
|
+ placeNotifs();
|
|
|
+ notifBox.style.display = "block";
|
|
|
+ UNREAD = 0; renderBell();
|
|
|
+});
|
|
|
+window.addEventListener("resize", () => {
|
|
|
+ if (notifBox.style.display === "block") placeNotifs();
|
|
|
});
|
|
|
+
|
|
|
+// Pick a recipe out of the notification list: make sure the row is on
|
|
|
+// screen (drop a filter/page that hides it), expand it, and flash it.
|
|
|
+function focusRecipe(name) {
|
|
|
+ if (!DATA.some(r => r.name === name)) return;
|
|
|
+ S.openRow = name;
|
|
|
+ render();
|
|
|
+ let row = document.querySelector(`tr.row[data-n="${cssEsc(name)}"]`);
|
|
|
+ if (!row) {
|
|
|
+ S.q = ""; S.uponly = ""; S.tp = ""; S.medal = ""; S.page = 0;
|
|
|
+ const q = document.getElementById("q"); if (q) q.value = "";
|
|
|
+ render();
|
|
|
+ row = document.querySelector(`tr.row[data-n="${cssEsc(name)}"]`);
|
|
|
+ }
|
|
|
+ if (!row) return; // still hidden by a collapsed group — the row is expanded
|
|
|
+ row.classList.remove("hit"); void row.offsetWidth; row.classList.add("hit");
|
|
|
+ row.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
|
+ setTimeout(() => row.classList.remove("hit"), 3400);
|
|
|
+}
|
|
|
+const cssEsc = s => (window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/["\\]/g, "\\$&"));
|
|
|
document.addEventListener("click", () => { notifBox.style.display = "none"; });
|
|
|
notifBox.addEventListener("click", e => {
|
|
|
+ if (e.target.closest("a")) return; // report links open on their own
|
|
|
const it = e.target.closest(".noti");
|
|
|
if (!it) return;
|
|
|
- S.openRow = it.dataset.name;
|
|
|
+ const name = it.dataset.name;
|
|
|
notifBox.style.display = "none";
|
|
|
- render();
|
|
|
+ if (name) focusRecipe(name);
|
|
|
});
|
|
|
|
|
|
let es = null;
|
|
|
@@ -521,10 +598,14 @@ function render() {
|
|
|
? `<span title="ledger says running but no live container found (node down? killed serve?) — verify and close: sm121 runlock close ${esc(r.name)}"><span style="color:#d2822a">●</span><span class="dim"> stale</span></span>`
|
|
|
: `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></span>`)
|
|
|
: (r.fleet_up
|
|
|
- ? `<span title="running in the fleet — a container was found on a cluster node, but this machine has no local run ledger (served by another launcher)"><span style="color:green">●</span><span class="dim"> up</span><span class="dim" style="font-size:.8em">(fleet)</span></span>`
|
|
|
+ ? `<span title="running in the fleet — a container was found on a cluster node, but this machine has no local run ledger (served by another launcher)${r.fleet_since ? `; started ${fmtSince(r.fleet_since)} UTC` : ""}"><span style="color:green">●</span><span class="dim"> up</span><span class="dim" style="font-size:.8em">(fleet)</span></span>`
|
|
|
: '<span class="dim" title="not running">○</span>');
|
|
|
} else if (c[0] === "uptime") {
|
|
|
- body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
|
|
|
+ // `up` (ledger) or the container's creation time (fleet): a serve
|
|
|
+ // with no local .lock still has a real uptime.
|
|
|
+ body = v >= 0
|
|
|
+ ? `<span title="up since ${fmtSince(r.up != null ? r.up : r.fleet_since)} UTC">${fmtUp(v)}</span>`
|
|
|
+ : '<span class="dim" title="not running">—</span>';
|
|
|
} else if (c[0] === "runs") {
|
|
|
body = v > 0 ? esc(v)
|
|
|
: '<span class="dim" title="no launch ledger on this machine (or never launched)">—</span>';
|
|
|
@@ -732,33 +813,27 @@ document.getElementById("mscopy").addEventListener("click", async (e) => {
|
|
|
setTimeout(() => { e.target.textContent = "copy"; }, 1200); } catch { /* clipboard blocked */ }
|
|
|
});
|
|
|
const syncBtn = document.getElementById("syncbtn");
|
|
|
-const repoStat = document.getElementById("repostat");
|
|
|
-// An empty recipe table must say WHY: no repo configured, an archived repo
|
|
|
-// and a repo whose clone keeps failing all render the same otherwise.
|
|
|
-function showRepoStat(text, bad) {
|
|
|
- if (!repoStat) return;
|
|
|
- repoStat.style.display = text ? "" : "none";
|
|
|
- repoStat.textContent = text;
|
|
|
- repoStat.style.color = bad ? "#e8833a" : "";
|
|
|
- repoStat.title = text;
|
|
|
-}
|
|
|
-async function loadRepoStatus() {
|
|
|
+// Repo/sync state arrives as notifications, not as a permanent toolbar text:
|
|
|
+// the panel stays quiet when everything is fine, and a failure stays in the
|
|
|
+// list (with the git error) instead of blinking away.
|
|
|
+async function reportRepoState(quietWhenFine) {
|
|
|
try {
|
|
|
const j = await (await fetch("/api/repos")).json();
|
|
|
if (!j.configured) {
|
|
|
- // Cached clones still serve recipes even with an empty [[recipe_repos]].
|
|
|
- if (j.cached_roots) showRepoStat(`repos: ${j.cached_roots} cached (none configured)`, false);
|
|
|
- else showRepoStat("⚠ no recipe repos configured", true);
|
|
|
+ notify("sync", "", "no recipe repos configured (set sm121_recipe_repos)", true);
|
|
|
return;
|
|
|
}
|
|
|
const arch = (j.repos || []).filter(r => r.archived).length;
|
|
|
const bad = (j.repos || []).filter(r => !r.cached && !r.archived).length;
|
|
|
- if (bad) { showRepoStat(`⚠ ${bad}/${j.configured} repo(s) not synced`, true); return; }
|
|
|
- if (arch) { showRepoStat(`repos: ${j.configured - arch}/${j.configured} synced (${arch} archived)`, false); return; }
|
|
|
- showRepoStat(`repos: ${j.configured} synced`, false);
|
|
|
- } catch { /* hosted mode has no /api/repos (standalone report) */ }
|
|
|
+ if (bad) notify("sync", "", `${bad} of ${j.configured} recipe repo(s) not synced yet`, true);
|
|
|
+ else if (!quietWhenFine)
|
|
|
+ notify("sync", "", `${j.configured - arch} repo(s) synced${arch ? ` (${arch} archived)` : ""}`);
|
|
|
+ } catch { /* standalone report: no /api/repos */ }
|
|
|
+}
|
|
|
+if (EMBED) {
|
|
|
+ syncBtn.style.display = "";
|
|
|
+ reportRepoState(false);
|
|
|
}
|
|
|
-if (EMBED) { syncBtn.style.display = ""; loadRepoStatus(); }
|
|
|
syncBtn.addEventListener("click", async () => {
|
|
|
syncBtn.disabled = true; syncBtn.textContent = "⟳ …";
|
|
|
try {
|
|
|
@@ -767,9 +842,11 @@ syncBtn.addEventListener("click", async () => {
|
|
|
const bad = !j.ok;
|
|
|
syncBtn.textContent = bad ? "⟳ ✗" : `⟳ ${j.repos} ok`;
|
|
|
syncBtn.title = j.detail || (bad ? String(res.status) : "recipe repos synced");
|
|
|
- showRepoStat(j.detail || "", bad);
|
|
|
- if (!bad) loadRepoStatus();
|
|
|
- } catch (err) { syncBtn.textContent = "⟳ ✗"; syncBtn.title = String(err); showRepoStat(String(err), true); }
|
|
|
+ notify("sync", "", j.detail || (bad ? String(res.status) : "recipe repos synced"), bad);
|
|
|
+ } catch (err) {
|
|
|
+ syncBtn.textContent = "⟳ ✗"; syncBtn.title = String(err);
|
|
|
+ notify("sync", "", String(err), true);
|
|
|
+ }
|
|
|
setTimeout(() => { syncBtn.disabled = false; syncBtn.textContent = "⟳ sync"; }, 6000);
|
|
|
});
|
|
|
// column checkboxes
|