diff --git a/CHANGELOG.md b/CHANGELOG.md index e896997..45a4877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- Monitoring screens (status, mail queue, system log, deliveries) use adaptive + HTMX polling: 5 s while the operator is active on the page, 30 s when the tab + is visible but idle, and no requests while the tab is hidden. Scheduling + lives in `panel.js` (`data-poll` markers) instead of `hx-trigger="every …"`, + which would need `unsafe-eval` under the panel's CSP. - The delivery page is laid out in two columns: what the journal recorded on the left, what happened to the message on the right, and the delivery log at full width under both. The facts the page used to stack one per line — domain, diff --git a/docs/architecture.md b/docs/architecture.md index 273cb27..965893e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -153,8 +153,11 @@ unless noted. | `/backup` | Full backup download, domain import | | `/account` | Admin username/password | -HTMX polling refreshes monitoring fragments; polling does not extend session -idle timeout (only non-`HX-Request` GET and mutating requests count as activity). +HTMX polling refreshes monitoring fragments (5 s while the operator is active on +the page, 30 s when the tab is visible but idle, none when hidden — scheduled in +`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend +session idle timeout (only non-`HX-Request` GET and mutating requests count as +activity). ### Sessions diff --git a/docs/progress.md b/docs/progress.md index 99a2444..8862805 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -54,7 +54,8 @@ - **Фаза 2 плана ревизии выполнена (2026-08-06)** (GUI polish, P2): опрос мониторинговых страниц не уходит на сервер, пока вкладка скрыта — фильтр повешен на `htmx:beforeRequest` в `panel.js`, а не на встроенный в htmx фильтр триггера (тот вычисляется через `new Function`, что CSP панели `default-src 'self'` без `unsafe-eval` молча ломает); тёмная тема переписана с каскада `!important` на переопределение CSS-переменных в одном блоке `prefers-color-scheme: dark`; дублирующее правило `main { max-width }` сведено к одному базовому плюс задокументированные постраничные оверрайды. Только CSS/JS, поведения сервера не касается; вживую не проверялось (нет Docker локально) — кандидат на следующий прогон на стенде. - **Фаза 3 плана ревизии выполнена (2026-08-06)** (operational improvements, P2–P3): (1) log-tailer сохраняет позицию чтения — таблица `logtail_state` (миграция `0003`, `internal/store/logtail.go`) хранит offset + отпечаток первых 512 байт лога, `internal/logtail/offset.go` решает откуда стартовать: отпечаток совпал → продолжаем с offset (дочитывается хвост, написанный пока панель лежала); не совпал (лог сменился/пересоздан) → читаем файл с начала (повторный разбор безвреден, `UpdateStatus` идемпотентен); записи нет вовсе (первый запуск) → с конца, как раньше. Запись offset — не чаще раза в 5 с, плюс форс при ротации и на выключении; сохраняется позиция *потреблённых* байт (минус недочитанная частичная строка). (2) L2-лимит перестал промахиваться при параллельных сессиях: между проверкой на MAIL FROM и вставкой строки на end-of-message сообщение не видно в БД, поэтому N одновременных сессий пропускали друг друга — теперь к счёту из БД добавляются «в полёте» (`internal/milter/inflight.go`, общий на процесс реестр резерваций); резервация освобождается после записи в send-log, на ABORT и по TTL 10 минут (у go-milter нет колбэка на закрытие соединения, а вечная резервация — это fail-closed-дрейф, которого у лимитера быть не должно). Транзакция «count+insert», как предлагал review, невозможна буквально: эти два шага разнесены по разным стадиям SMTP-транзакции. Тесты: restart/rotation-resume для tailer'а, четыре сценария резерваций для лимита. `gofmt`/`go vet` чистые; `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). Не проверено на стенде (нет Docker локально) — кандидат на следующий прогон на стенде. - **Добор по плану ревизии выполнен (2026-08-06):** (1) проект переехал на единственную площадку — GitHub (Codeberg уходит): вместе с URL, лицензионными шапками SVG/HTML и docs переехал путь Go-модуля на `github.com/mixeme/selfpost` (`go.mod`, `test/e2e/go.mod`, все импорты, `MODULE` в Makefile, `-ldflags` в Dockerfile и development.md) — оставлять импорты на исчезающем хосте нельзя, `go get`/`go install` сломались бы; (2) ссылки на архивную спецификацию убраны из кода целиком — не только «spec 7.x», как просило ревью, но и «spec 4/5/6/8/9», страдавшие тем же, каждая заменена на живой документ с секцией там, где документ большой; (3) [architecture.md](architecture.md) § Code layers — диаграмма слоёв (A2); (4) `TestParseDelivery` расширен экзотикой mail.log — и **вскрыл реальный баг**: шаблон брал `status=` жадно, то есть последнее вхождение в строке, а Postfix дописывает ответ удалённого сервера дословно, поэтому отказ с `status=sent` в тексте ответа попадал в журнал как доставленный (исправлено на ленивый разбор); (5) `CONTRIBUTING.md` перенесён в 2.x, бамп тега образа и git-тег оставлены в [roadmap.md](roadmap.md) § v1.x. `gofmt`/`go vet` чистые в обоих модулях, `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). На стенде не проверялось (нет Docker локально). -- **Дальше:** релизный гейт (Фаза 0) закрыт по существу — e2e C.4 и ревизия § D пройдены; остаются только шаги, которые делаются в момент резки версии (бамп тега образа в compose, git tag) по явной команде пользователя. Все polish-фазы плана ревизии (1, 1.5, 2, 3) закрыты. +- **v1.x-closure Фаза 1 выполнена (2026-08-08)** (адаптивный опрос мониторинга): четыре HTMX-фрагмента (`status_body`, `mail_queue_body`, `system_log_body`, `deliveries_rows`) несут `data-poll` и `hx-trigger="load"` только для первого запроса; `panel.js` планирует следующий опрос после `htmx:afterSwap` / `htmx:responseError` — 5 s при активности оператора на странице, 30 s при видимой, но простаивающей вкладке, 0 при скрытой (`beforeRequest` + сброс таймеров на `visibilitychange`). Без `hx-trigger="every … [expr]"` (CSP / `unsafe-eval`). Docs: `architecture.md`, `roadmap.md`, CHANGELOG. На стенде не проверялось. +- **Дальше:** v1.x-closure [v1.x-closure-plan.md](v1.x-closure-plan.md) — **Фаза 1 закрыта** (адаптивный опрос мониторинга: 5 s / 30 s / 0 при скрытой вкладке, `data-poll` в `panel.js`). Следующий шаг — **Фаза 2** (mail.log в `/data` + postqueue reconcile). Релизный гейт по коду закрыт; бамп тега образа и git tag — по явной команде (Фазы 4–5). - **Принятые риски** — [security.md](security.md). **Опционально v1.x / 2.x** — [roadmap.md](roadmap.md) (хвост документации, send-log gaps, Фаза O1+, роль администратора домена). - **Прод:** инстанс с реальным Let's Encrypt сертификатом и живым deliverability (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1f22db5..e45a282 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,14 +71,15 @@ git-тег `vX.Y.Z`; `implementation-plan.md` в `docs/archive/`, ссылок Стоит одного абзаца со списком всех файлов `docs/` и одной строкой на каждый. Мелочь, но именно она делает набор документов набором, а не россыпью. -**Опрос мониторинга у открытой, но незанятой вкладки.** Скрытая вкладка уже не +**Опрос мониторинга у открытой, но незанятой вкладки.** ~~Скрытая вкладка уже не опрашивает сервер (фильтр на `htmx:beforeRequest` в [panel.js](../internal/web/static/panel.js)). Остаток: вкладка на переднем плане, с которой не работают, всё равно ходит раз в 5 с. Кандидат — адаптивный интервал (5 с при активности, 30 с при простое) по `htmx:afterRequest` без изменения `hx-trigger`. Ценность низкая: нагрузка — один SQL-запрос и рендер фрагмента, так что это скорее гигиена, чем экономия. Допустимый исход — -осознанно не делать. +осознанно не делать.~~ **Закрыто (v1.x-closure Фаза 1):** адаптивный интервал +5 с / 30 с / 0 (скрытая вкладка) в `panel.js` через `data-poll`. **Send-log vs `mail.log` (частично закрыто).** Persist позиции чтения сделан (таблица `logtail_state`, миграция `0003`): после diff --git a/docs/v1.x-closure-plan.md b/docs/v1.x-closure-plan.md index 4cee7a4..a9aaaa1 100644 --- a/docs/v1.x-closure-plan.md +++ b/docs/v1.x-closure-plan.md @@ -35,7 +35,7 @@ ## Общий чек-лист прогресса -- [ ] **Фаза 1** — адаптивный опрос мониторинга +- [x] **Фаза 1** — адаптивный опрос мониторинга - [ ] **Фаза 2** — mail.log в `/data` + postqueue reconcile - [ ] **Фаза 3** — docs: development.md, README, удаление планов и `docs/archive/` - [ ] **Фаза 4** — релизный коммит `1.0.0` (по явной команде) @@ -60,12 +60,12 @@ ### Чек-лист -- [ ] `status_body.html`, `mail_queue_body.html`, `system_log_body.html`, `deliveries_rows.html` — `hx-trigger="load"` + маркер polling -- [ ] `panel.js` — `lastActivity`, schedule после `htmx:afterRequest` (5s / 30s) -- [ ] `htmx:afterSwap` — реинициализация polling-элементов -- [ ] `beforeRequest` для hidden tab — оставлен -- [ ] `architecture.md` — одна строка про polling -- [ ] CHANGELOG `[Unreleased]` +- [x] `status_body.html`, `mail_queue_body.html`, `system_log_body.html`, `deliveries_rows.html` — `hx-trigger="load"` + маркер polling +- [x] `panel.js` — `lastActivity`, schedule после `htmx:afterRequest` (5s / 30s) +- [x] `htmx:afterSwap` — реинициализация polling-элементов +- [x] `beforeRequest` для hidden tab — оставлен +- [x] `architecture.md` — одна строка про polling +- [x] CHANGELOG `[Unreleased]` - [ ] Стенд (опционально): idle → ~30s; активность → ~5s; hidden → 0 запросов --- diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 548d8ef..0397b02 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -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 { diff --git a/internal/web/static/panel.js b/internal/web/static/panel.js index c85df0d..4b9fe12 100644 --- a/internal/web/static/panel.js +++ b/internal/web/static/panel.js @@ -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); + }); })(); diff --git a/internal/web/templates/deliveries_rows.html b/internal/web/templates/deliveries_rows.html index efab2a1..ce9304f 100644 --- a/internal/web/templates/deliveries_rows.html +++ b/internal/web/templates/deliveries_rows.html @@ -1,6 +1,6 @@ {{define "deliveries_rows"}} -
+
{{if .Rows}} diff --git a/internal/web/templates/mail_queue_body.html b/internal/web/templates/mail_queue_body.html index b8ffd17..34b7caf 100644 --- a/internal/web/templates/mail_queue_body.html +++ b/internal/web/templates/mail_queue_body.html @@ -1,5 +1,5 @@ {{define "mail_queue_body"}} -
+
{{if .Error}}

{{.Error}}

{{end}} {{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}
diff --git a/internal/web/templates/status.html b/internal/web/templates/status.html index fa2f145..e0f020d 100644 --- a/internal/web/templates/status.html +++ b/internal/web/templates/status.html @@ -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"}} diff --git a/internal/web/templates/status_body.html b/internal/web/templates/status_body.html index a5b9a67..22f551b 100644 --- a/internal/web/templates/status_body.html +++ b/internal/web/templates/status_body.html @@ -1,5 +1,5 @@ {{define "status_body"}} -
+

Overall {{.OverallStatus}}

{{.OverallHeading}}

diff --git a/internal/web/templates/system_log_body.html b/internal/web/templates/system_log_body.html index b97c1fe..1c99e02 100644 --- a/internal/web/templates/system_log_body.html +++ b/internal/web/templates/system_log_body.html @@ -1,5 +1,5 @@ {{define "system_log_body"}} -
+
{{if .Error}}

{{.Error}}

{{end}} {{if .Lines}}{{range .Lines}}{{.}} {{end}}{{else}}No log lines yet.{{end}}