web-ui: карточка и страница обновляются, пока задачу может двигать фон

- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
This commit is contained in:
av
2026-08-10 14:02:38 +03:00
parent 969926fae3
commit a5d873b62d
29 changed files with 1575 additions and 99 deletions
+94 -26
View File
@@ -18,27 +18,43 @@ type LiveStatus interface {
Live(infohash string) (worker.Live, bool)
}
// Интервалы самообновления поверхностей (значение hx-trigger="every …").
//
// - pollFast — поверхность с живыми цифрами качания (карточка в downloading).
// Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и
// опрашивать чаще значит возвращать тот же кадр (docs/database.md).
// - pollSlow — все прочие наблюдаемые поверхности, включая страницу
// /download/{id} в любом состоянии: там меняется только состояние, а сборка
// страницы считает предпросмотр раскладки и ходит в ФС.
const (
pollFast = "5s"
pollSlow = "15s"
)
// noLive — заглушка на случай, когда источник телеметрии не подключён
// (Deps.Live == nil): живых данных нет, UI деградирует штатно.
type noLive struct{}
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
// progressView — живой прогресс активной загрузки (для карточки и фрагмента
// /progress). Active управляется store-состоянием (downloading), а не qbt:
// когда задача покидает downloading, фрагмент возвращается без поллинга.
// progressView — живой прогресс активной загрузки (вложенный блок карточки).
// Active управляется store-состоянием (downloading), а не qbt: вне downloading
// скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
// задачи»).
type progressView struct {
ID string
Active bool // store-состояние downloading → показываем бар и поллим
Active bool // store-состояние downloading → показываем бар
Has bool // есть данные снимка
Percent int
DlSpeed string
ETA string
}
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding).
// seedingView — живая статистика раздачи (секция страницы загрузки).
// Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер, поллинг прекращается).
// деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
type seedingView struct {
ID string
Has bool
@@ -79,7 +95,13 @@ func buildSeeding(id string, l worker.Live, ok bool) seedingView {
return v
}
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг).
// handleFragProgress отдаёт партиал живого прогресса карточки.
//
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
// её первым же тиком. Убирается отдельной уборкой после деплоя.
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
@@ -88,7 +110,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
s.fragTickErr(w, err, id, "dl-live-"+id)
return
}
active := d.State == store.StateDownloading
@@ -96,10 +118,11 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
s.render(w, "progress", buildProgress(id, active, l, ok))
}
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched):
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая
// карточка уже не несёт самополлинга — цикл завершается сам.
// handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
// Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
// каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
// действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
// несёт самополлинга, и цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
@@ -108,15 +131,25 @@ func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
s.fragTickErr(w, err, id, "card-"+id)
return
}
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из
// живого снимка внутри buildCardView.
s.render(w, "card", s.buildCardView(*d, store.Now(), 0))
// Размер читаем так же, как своповый путь действия: самообновление
// обслуживает и состояния с разложенными файлами, и подмена известного
// размера прочерком была бы потерей поля полного рендера.
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
if err != nil {
// WARN, а не ERROR: тик повторится сам (docs/conventions/logging.md).
s.deps.Logger.Warn("layout sizes", "download_id", id, "error", err)
sizes = nil // деградируем: размер уедет в фолбэк, тик не падает
}
s.render(w, "card", s.buildCardView(*d, store.Now(), sizes[id]))
}
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
// handleFragSeeding отдаёт партиал секции «Раздача».
//
// Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком
// страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress.
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
@@ -125,22 +158,57 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
s.fragTickErr(w, err, id, "seeding-"+id)
return
}
l, ok := s.liveFor(*d)
s.render(w, "seeding", buildSeeding(id, l, ok))
}
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
func (s *server) fragErr(w http.ResponseWriter, err error, id string) {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "не найдено", http.StatusNotFound)
return
// fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
// фрагмент, который объясняет положение дел и НЕ несёт самообновления.
//
// Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
// поверхность осталась бы прежней навсегда (человек не отличит «ничего не
// изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
// при затяжном отказе хранилища это поток записей в журнал с каждой открытой
// вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой
// задачи»).
//
// Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит
// (docs/conventions/logging.md, «Ошибки»).
func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, false)
}
// fragActionErr — отказ чтения на разовом действии человека: тот же
// самозавершающийся фрагмент, но ERROR: ретрая у действия нет.
func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, true)
}
// fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и
// приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id
// снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) {
text := "задача не найдена — обновите страницу"
if !errors.Is(err, store.ErrNotFound) {
if oneShot {
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
} else {
s.deps.Logger.Warn("live fragment", "download_id", id, "error", err)
}
text = "не удалось обновить — обновите страницу"
}
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
}
// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
// id узла, который фрагмент собой заменяет.
type fragNoteView struct {
RootID string
Text string
}
// --- форматирование телеметрии ---