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
+20 -12
View File
@@ -114,11 +114,12 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload)
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
// Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
// вкладок, отрисованных прошлой версией (см. handleFragProgress).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
// имя и появившийся прогресс обновляются без перезагрузки).
// Карточка целиком — тик самообновления списка: пока задача наблюдаема,
// карточка приносит текущее состояние без перезагрузки страницы.
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
@@ -205,8 +206,9 @@ type downloadView struct {
State string
Error string
Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
IsDownloading bool // активная загрузка → живой прогресс-бар
SelfPoll bool // задача наблюдаема → карточка сама опрашивает себя
PollEvery string // интервал самообновления карточки (pollFast/pollSlow)
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
@@ -311,10 +313,11 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
// buildCardView собирает представление карточки списка из доменных данных и
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести
// прогресс-поллер — поэтому Progress заполняется здесь.
// живого снимка. Общий для полной страницы (handleIndex), тика самообновления
// (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы
// htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут
// вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress
// заполняется здесь на каждом пути.
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
@@ -494,7 +497,7 @@ func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
s.fragActionErr(w, err, id, "card-"+id)
return
}
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
@@ -514,7 +517,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
s.fragActionErr(w, err, id, "download-main")
return
}
v := s.buildDownloadView(id, rd)
@@ -661,7 +664,8 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading,
SelfPoll: d.State == store.StateCatched,
SelfPoll: d.State.IsObservable(),
PollEvery: pollSlow,
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
@@ -669,6 +673,10 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Note: desyncNote(d.State),
}
// Быстрый интервал — только там, где на поверхности бегут цифры качания.
if v.IsDownloading {
v.PollEvery = pollFast
}
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
// как в порядке списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok {