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
+35 -14
View File
@@ -103,27 +103,48 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
## Живой поллинг
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`талон —
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`):
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. Эталон — карточка
списка (`card`, `handleFragCard`):
```html
{{define "progress"}}<div id="dl-live-{{.ID}}"
{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
{{define "card"}}<article class="card" id="card-{{.ID}}"
{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card"
hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
...
</div>{{end}}
</article>{{end}}
```
- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active`
ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx
больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading`
для прогресса), а не qBittorrent.
- **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности
(карточка списка, главная область страницы), а вложенные живые регионы —
прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня
уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга.
Живые цифры приезжают вместе с корнем.
- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
больше не опрашивает. Условие определяется store-состоянием
(`State.IsObservable()`), а не qBittorrent.
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
`200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`,
поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос —
бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он
собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел.
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай;
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
условии совпадения корневого `id`** (см. инвариант выше).
- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без
БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа
доставки (поллинг сейчас, путь к SSE оставлен изолированным).
условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
разметки своп не переживает: то, что должно пережить тик (раскрытый
`<details>`), помечается `hx-preserve`.
- **Частота — по цене тика, и она названа числом в
[database.md](../database.md).** Поверхность с живыми цифрами качания
обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из in-memory
снимка воркера (`LiveStatus.Live(infohash)`), но состояние и размер раскладки
тик читает из хранилища, а тик страницы загрузки ещё и считает предпросмотр
раскладки с обходом ФС — отсюда и разные интервалы. Узкий контракт
`LiveStatus` при этом не зависит от способа доставки (поллинг сейчас, путь к
SSE оставлен изолированным).
- **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер,
который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое,
см. [logging.md](logging.md)).