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:
+94
-26
@@ -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
|
||||
}
|
||||
|
||||
// --- форматирование телеметрии ---
|
||||
|
||||
Reference in New Issue
Block a user