feat(panel): adaptive monitoring poll intervals (v1.x closure phase 1)
Replace fixed 5 s hx-trigger polling with data-poll markers and panel.js scheduling: 5 s while active, 30 s when idle, none when tab is hidden. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,7 @@ const usernameKey ctxKey = 0
|
||||
// everything except a GET request carrying HX-Request: the four monitoring
|
||||
// fragments (/status/fragment, /mail-queue/body, /system-log/body,
|
||||
// /deliveries/rows)
|
||||
// poll every 5s regardless of whether anyone is looking at the tab, so
|
||||
// poll on a timer regardless of whether anyone is looking at the tab, so
|
||||
// counting those as activity would make "N days idle" mean "N days since a
|
||||
// browser tab was last open" instead.
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
//
|
||||
// Each pass looks its targets up by id rather than holding on to elements
|
||||
// found once: the status page replaces its cards wholesale every five
|
||||
// seconds (hx-swap on #status-body), and anything remembered here would be
|
||||
// seconds (adaptive polling on #status-body), and anything remembered here would be
|
||||
// measuring boxes that had left the document.
|
||||
var sectionLinks = [];
|
||||
|
||||
@@ -204,19 +204,95 @@
|
||||
initSectionIndex();
|
||||
});
|
||||
|
||||
// --- Skip polling while the tab is hidden ------------------------------
|
||||
// The monitoring pages (status, mail queue, system log, deliveries) poll
|
||||
// every 5s via hx-trigger="every 5s". htmx has a built-in way to make that
|
||||
// conditional (an event filter, hx-trigger="every 5s [expr]"), but it
|
||||
// evaluates the filter with `new Function`, which the panel's CSP
|
||||
// (default-src 'self', no 'unsafe-eval') would silently break. Skipping the
|
||||
// request here instead needs nothing beyond what the CSP already allows: a
|
||||
// request due while the tab is hidden is simply not sent, and the next
|
||||
// request after it becomes visible again picks up on schedule as usual.
|
||||
// --- Adaptive monitoring polling ---------------------------------------
|
||||
// The four monitoring fragments carry data-poll and hx-trigger="load" for
|
||||
// the first fetch only. panel.js schedules the rest: 5 s while the operator
|
||||
// is active on the page, 30 s when the tab is visible but idle, and nothing
|
||||
// while the tab is hidden. hx-trigger="every Ns [expr]" could express some
|
||||
// of that, but the filter is evaluated with `new Function`, which the
|
||||
// panel's CSP (default-src 'self', no 'unsafe-eval') would silently break.
|
||||
var pollActiveMs = 5000;
|
||||
var pollIdleMs = 30000;
|
||||
// No pointer/keyboard/scroll input for this long → treat the tab as idle.
|
||||
var userIdleMs = 30000;
|
||||
var lastActivity = Date.now();
|
||||
var pollTimers = Object.create(null);
|
||||
|
||||
["mousedown", "mousemove", "keydown", "scroll", "touchstart"].forEach(function (evt) {
|
||||
document.addEventListener(evt, function () {
|
||||
lastActivity = Date.now();
|
||||
}, { passive: true });
|
||||
});
|
||||
|
||||
function pollDelayMs() {
|
||||
return Date.now() - lastActivity < userIdleMs ? pollActiveMs : pollIdleMs;
|
||||
}
|
||||
|
||||
function triggerPoll(el) {
|
||||
htmx.ajax("GET", el.getAttribute("hx-get"), {
|
||||
target: "#" + el.id,
|
||||
swap: el.getAttribute("hx-swap") || "outerHTML"
|
||||
});
|
||||
}
|
||||
|
||||
function schedulePoll(el) {
|
||||
if (!el || !el.id || !el.hasAttribute("data-poll")) {
|
||||
return;
|
||||
}
|
||||
if (pollTimers[el.id]) {
|
||||
clearTimeout(pollTimers[el.id]);
|
||||
delete pollTimers[el.id];
|
||||
}
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
var id = el.id;
|
||||
pollTimers[id] = setTimeout(function () {
|
||||
delete pollTimers[id];
|
||||
var current = document.getElementById(id);
|
||||
if (!current || !current.hasAttribute("data-poll")) {
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
schedulePoll(current);
|
||||
return;
|
||||
}
|
||||
triggerPoll(current);
|
||||
}, pollDelayMs());
|
||||
}
|
||||
|
||||
function onPollElementReady(el) {
|
||||
if (!el || !el.hasAttribute("data-poll")) {
|
||||
return;
|
||||
}
|
||||
// Swapped-in markup still carries hx-trigger="load"; strip it so htmx does
|
||||
// not issue a duplicate GET on top of the response we just received.
|
||||
el.removeAttribute("hx-trigger");
|
||||
schedulePoll(el);
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:afterSwap", function (ev) {
|
||||
onPollElementReady(ev.detail.elt);
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function (ev) {
|
||||
onPollElementReady(ev.detail.elt);
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", function (ev) {
|
||||
var trigger = ev.target.getAttribute && ev.target.getAttribute("hx-trigger");
|
||||
if (document.hidden && trigger && trigger.indexOf("every") !== -1) {
|
||||
if (document.hidden && ev.target.hasAttribute && ev.target.hasAttribute("data-poll")) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) {
|
||||
Object.keys(pollTimers).forEach(function (id) {
|
||||
clearTimeout(pollTimers[id]);
|
||||
delete pollTimers[id];
|
||||
});
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll("[data-poll]").forEach(schedulePoll);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{define "deliveries_rows"}}
|
||||
<div id="deliveries-rows" hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
|
||||
hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
<div id="deliveries-rows" data-poll hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
|
||||
hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Rows}}
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "mail_queue_body"}}
|
||||
<div id="mail-queue-body" hx-get="/mail-queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
<div id="mail-queue-body" data-poll hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
{{/* The status page's section index, shown in the navigation column (see the
|
||||
"sections" block in layout.html). The first six cards are the ones the
|
||||
polling fragment replaces every five seconds; their ids are part of
|
||||
polling fragment replaces on a timer; their ids are part of
|
||||
status_body.html and do not change with the reading, so the links here hold
|
||||
across a refresh. */}}
|
||||
{{define "sections"}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "status_body"}}
|
||||
<div id="status-body" hx-get="/status/fragment" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
<div id="status-body" data-poll hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="card" id="overall">
|
||||
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
||||
<p class="muted">{{.OverallHeading}}</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "system_log_body"}}
|
||||
<div id="system-log-body" hx-get="/system-log/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
<div id="system-log-body" data-poll hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
||||
{{end}}{{else}}No log lines yet.{{end}}</span>
|
||||
|
||||
Reference in New Issue
Block a user