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
@@ -0,0 +1,56 @@
# Наблюдаемость поверхности не выводится из терминальности задачи
- **Дата:** 2026-08-10
- **Источник:** openspec/changes/archive/2026-08-10-card-live-refresh/design.md
## Решение
Веб-UI обновляет себя, пока задача **наблюдаема** — то есть её состояние ещё
может измениться без участия человека, — а не пока она нетерминальна.
Предикат `store.State.IsObservable()` живёт в домене рядом с `IsTerminal()` и
даёт: все нетерминальные плюс `failed`, `target_missing`, `orphaned`. Замолкают
`done`, `cancelled`, `reverted`, `deleted`.
## Почему
Очевидный предикат — «обновляемся, пока задача не терминальна» — оказался
неверным, и это выяснилось на ревью дизайна, до кода. Цитата из источника:
> Терминальность в проекте значит «не активна», а не «навсегда»: фоновая сверка
> двигает часть терминальных сама — `ListRecoverable` возвращает в поток
> `failed`/`stuck` с кодами `magnet_timeout` и `stalled`, а `desyncStates`
> переоценивает `done`, `target_missing` и `orphaned`. Карточка, застывшая по
> `IsTerminal`, показывала бы «Ошибка» у задачи, которая уже качается, — ровно
> тот дефект, ради которого затеян change.
`done` в перечень наблюдаемых не вошёл, и это отдельное решение с ценой:
> Переход `done → target_missing`/`orphaned` означает, что файлы удалили руками
> мимо сервиса, — событие редкое, а карточек `done` в списке больше всех.
> Платить за редкий случай постоянным фоновым запросом на каждую разложенную
> задачу дороже, чем показать её новое состояние при следующем заходе.
## Рассмотренные варианты
- **Наблюдать только нетерминальные** (как задумывалось изначально) — проще
всего и не заводит второго предиката. Отвергнут: задача, оживлённая сверкой из
`failed`, висела бы на экране с надписью «Ошибка» до перезагрузки, причём
соседние карточки при этом обновлялись бы — застывшая читалась бы как
достоверная.
- **Наблюдать всё, терминальные — редким тиком** — снимает вопрос целиком.
Отвергнут: список из сотни разложенных задач слал бы пустые запросы вечно, а
критерий приёмки «завершённая карточка себя не опрашивает» пришлось бы
отменить.
## Последствия
- `+` смена состояния становится видимой независимо от того, кто её сделал:
воркер, веб-UI, Telegram или фоновая сверка.
- `+` условие обновления выражено одним доменным предикатом; второго перечня
состояний в транспорте нет, и завести его нельзя не заметив.
- `` в домене стало два перечня состояний вместо одного, и второй выведен из
поведения воркера (`desyncStates`, `ListRecoverable`) вручную. Расширение
сверки новым состоянием молча вернёт застывшую карточку — связки, которая бы
это ловила, нет.
- `` карточка `failed`, `target_missing` или `orphaned` опрашивает сервер, пока
открыта вкладка: эти состояния живут долго и копятся (срока хранения нет).
+1
View File
@@ -42,6 +42,7 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-10 | [Наблюдаемость поверхности не выводится из терминальности задачи](ADR-2026-08-10-observability-is-not-terminality.md) | — |
| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — | | 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — |
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — | | 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
| 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — | | 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — |
+2 -1
View File
@@ -118,6 +118,7 @@
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | | Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью | | Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) | | Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) |
| Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) |
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) | | Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям | | Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
@@ -185,7 +186,7 @@ Jellyfin указывают на `movies`/`series`, а не на корень
| Масштаб | ориентир 100/1000 загрузок не зафиксирован, узкие места SQLite, воркера и поллинга не измерены | `scale-100-downloads` | | Масштаб | ориентир 100/1000 загрузок не зафиксирован, узкие места SQLite, воркера и поллинга не измерены | `scale-100-downloads` |
| Ретеншен | терминальные задачи и сырые ответы LLM копятся вечно, авточистки нет | `db-retention-cleanup` | | Ретеншен | терминальные задачи и сырые ответы LLM копятся вечно, авточистки нет | `db-retention-cleanup` |
| Бекап | бекапить `/data` требуется, а стратегия и ротация не описаны | `sqlite-backup` | | Бекап | бекапить `/data` требуется, а стратегия и ротация не описаны | `sqlite-backup` |
| Наблюдаемость | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | `deep-healthcheck-dependencies` | | Метрики и алертинг | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | `deep-healthcheck-dependencies` |
| Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` | | Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` |
| Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` | | Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` |
| История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` | | История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` |
+35 -14
View File
@@ -103,27 +103,48 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
## Живой поллинг ## Живой поллинг
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`талон — `hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. Эталон — карточка
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`): списка (`card`, `handleFragCard`):
```html ```html
{{define "progress"}}<div id="dl-live-{{.ID}}" {{define "card"}}<article class="card" id="card-{{.ID}}"
{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress" {{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}> hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
... ...
</div>{{end}} </article>{{end}}
``` ```
- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active` - **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности
ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx (карточка списка, главная область страницы), а вложенные живые регионы —
больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading` прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня
для прогресса), а не qBittorrent. уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга.
Живые цифры приезжают вместе с корнем.
- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
больше не опрашивает. Условие определяется store-состоянием
(`State.IsObservable()`), а не qBittorrent.
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
`200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`,
поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос —
бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он
собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел.
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай;
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его - **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
условии совпадения корневого `id`** (см. инвариант выше). условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без разметки своп не переживает: то, что должно пережить тик (раскрытый
БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа `<details>`), помечается `hx-preserve`.
доставки (поллинг сейчас, путь к SSE оставлен изолированным). - **Частота — по цене тика, и она названа числом в
[database.md](../database.md).** Поверхность с живыми цифрами качания
обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из in-memory
снимка воркера (`LiveStatus.Live(infohash)`), но состояние и размер раскладки
тик читает из хранилища, а тик страницы загрузки ещё и считает предпросмотр
раскладки с обходом ФС — отсюда и разные интервалы. Узкий контракт
`LiveStatus` при этом не зависит от способа доставки (поллинг сейчас, путь к
SSE оставлен изолированным).
- **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер, - **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер,
который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое, который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое,
см. [logging.md](logging.md)). см. [logging.md](logging.md)).
+2
View File
@@ -212,6 +212,8 @@ erDiagram
| Константа | Значение | Что означает | | Константа | Значение | Что означает |
| --- | --- | --- | | --- | --- | --- |
| `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) | | `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) |
| `httpapi.pollFast` | `5s` | интервал самообновления поверхности с живыми цифрами качания (карточка в `downloading`). Держится вровень с `[worker].poll_interval`: снимок телеметрии обновляется тиком воркера, и опрос чаще возвращает тот же снимок. Меняется `poll_interval` — меняется и эта константа |
| `httpapi.pollSlow` | `15s` | интервал самообновления прочих наблюдаемых поверхностей: карточек вне `downloading` и страницы `/download/{id}` в любом состоянии. Тик страницы считает предпросмотр раскладки и ходит в ФС, поэтому частота у него ниже |
| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой | | `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой |
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет, **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
+19
View File
@@ -325,6 +325,25 @@ Go-сервиса и что здесь уже проскакивало. Устр
случаи до этой даты не восстанавливались — восстановленная постфактум причина случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна. непоймания недостоверна, а именно она и нужна.
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
- **Где:** `internal/httpapi/live_test.go``TestFragProgressStopsWhenNotDownloading`
- **Симптом:** проход `autotests` на ревью кода change `card-live-refresh` заметил,
что тест «фрагмент отдаётся без атрибутов поллинга» больше не может упасть
- **Причина:** change снял `hx-get`/`hx-trigger` с партиала `progress`
**безусловно**, а тест утверждал их отсутствие только для завершённой задачи.
Утверждение стало истинным при любом входе — тест перестал проверять что-либо,
оставаясь в дереве как доказательство поведения
- **Чем воспроизведён:** `git diff` шаблона против тела теста; проверка инверсией
невозможна по построению — сломать реализацию так, чтобы тест покраснел, нечем
- **Что меняем:** ничего в гейте. `diff-coverage` меряет **исполнение**, а не
проверку, и такой класс не видит по устройству — 24/24 строк были покрыты при
зелёном тесте-пустышке. Ловится либо мутационным прогоном (в гейт не заводим:
цена выше пользы на нынешнем объёме), либо тем же вопросом темы `autotests`
(«есть ли тест, который упал бы без этой правки») — он и сработал. Тест
переформулирован на то, что теперь является предметом: вне `downloading` блок
живых цифр не рисуется вовсе
## 2026-08-10 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман] ## 2026-08-10 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман]
- **Где:** `internal/layout/layout.go``BuildLinks`; `internal/worker/review.go` - **Где:** `internal/layout/layout.go``BuildLinks`; `internal/worker/review.go`
+38 -4
View File
@@ -2,6 +2,7 @@ package httpapi
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -337,8 +338,9 @@ func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
}) })
} }
// TestRetryListShowsProgress: retry из списка → карточка downloading с // TestRetryListShowsProgress: retry из списка → карточка downloading с живым
// прогресс-поллером. // прогрессом и самообновлением карточки (опрашивает себя карточка, а не
// вложенный блок прогресса).
func TestRetryListShowsProgress(t *testing.T) { func TestRetryListShowsProgress(t *testing.T) {
dl := dlState(store.StateDownloading) dl := dlState(store.StateDownloading)
lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
@@ -348,8 +350,11 @@ func TestRetryListShowsProgress(t *testing.T) {
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("retry (htmx) = %d, want 200", rr.Code) t.Fatalf("retry (htmx) = %d, want 200", rr.Code)
} }
if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") { body := rr.Body.String()
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String()) for _, want := range []string{"width:42%", "/fragments/downloads/" + testULID + "/card"} {
if !strings.Contains(body, want) {
t.Errorf("карточка downloading без %q: %s", want, body)
}
} }
} }
@@ -407,3 +412,32 @@ func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
} }
}) })
} }
// TestActionSwapErrorKeepsSwapRoot: действие человека, упавшее на чтении задачи,
// отвечает 200 и фрагментом с корнем своей поверхности — иначе своп унёс бы
// якорь (#card-{id} у списка, #download-main у страницы) и следующие действия
// целились бы в несуществующий узел. Ретрая у действия нет, поэтому уровень лога
// здесь ERROR, а не WARN, как у повторяющегося тика.
func TestActionSwapErrorKeepsSwapRoot(t *testing.T) {
cases := []struct{ surface, root string }{
{"list", `id="card-` + testULID + `"`},
{"download", `id="download-main"`},
}
for _, c := range cases {
rd := stubReader{getErr: errors.New("db is gone")}
h := testRouterAction(t, rd, actionReviewer{}, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {c.surface}}, true)
if rr.Code != http.StatusOK {
t.Errorf("surface=%s: status = %d, want 200", c.surface, rr.Code)
continue
}
body := rr.Body.String()
if !strings.Contains(body, c.root) {
t.Errorf("surface=%s: фрагмент отказа без корня %s:\n%s", c.surface, c.root, body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("surface=%s: фрагмент отказа не самозавершается:\n%s", c.surface, body)
}
}
}
+15 -2
View File
@@ -21,7 +21,8 @@ type downloadDetailView struct {
Infohashes []string // все хеши загрузки (блок «Информация о торренте») Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string Context string
State string State string
SelfPoll bool // catched → страница сама опрашивает себя до перехода SelfPoll bool // задача наблюдаема → страница сама опрашивает себя
PollEvery string
Error string Error string
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
Note string Note string
@@ -89,6 +90,15 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
} }
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
// Тик самообновления страницы идёт этим же маршрутом (hx-get="/download/{id}"
// с hx-select="#download-main"). Отвечать ему статусом ошибки нельзя: htmx не
// свопит 4xx/5xx и не снимает hx-trigger — страница осталась бы навсегда
// устаревшей, а опрос продолжался бы до закрытия вкладки. Навигационный GET
// (адресная строка, закладка) по-прежнему получает честный статус.
if isHTMX(r) {
s.fragTickErr(w, err, id, "download-main")
return
}
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.Error(w, "задача не найдена", http.StatusNotFound) http.Error(w, "задача не найдена", http.StatusNotFound)
return return
@@ -113,7 +123,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Infohashes: d.HashList(), Infohashes: d.HashList(),
Context: d.Context, Context: d.Context,
State: string(d.State), State: string(d.State),
SelfPoll: d.State == store.StateCatched, SelfPoll: d.State.IsObservable(),
// Блока живых цифр качания на странице нет вовсе, а тик считает
// предпросмотр раскладки и ходит в ФС — интервал всегда медленный.
PollEvery: pollSlow,
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Note: desyncNote(d.State), Note: desyncNote(d.State),
CreatedAt: d.CreatedAt, CreatedAt: d.CreatedAt,
+20 -12
View File
@@ -114,11 +114,12 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/", s.handleIndex) r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload) r.Get("/download/{id}", s.handleDownload)
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). // Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
// вкладок, отрисованных прошлой версией (см. handleFragProgress).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж, // Карточка целиком — тик самообновления списка: пока задача наблюдаема,
// имя и появившийся прогресс обновляются без перезагрузки). // карточка приносит текущее состояние без перезагрузки страницы.
r.Get("/fragments/downloads/{id}/card", s.handleFragCard) r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана). // Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
@@ -205,8 +206,9 @@ type downloadView struct {
State string State string
Error string Error string
Terminal bool Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг IsDownloading bool // активная загрузка → живой прогресс-бар
SelfPoll bool // catched → карточка сама опрашивает себя до перехода SelfPoll bool // задача наблюдаема → карточка сама опрашивает себя
PollEvery string // интервал самообновления карточки (pollFast/pollSlow)
Progress progressView // живой прогресс (заполняется в handleIndex из снимка) Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
@@ -311,10 +313,11 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
} }
// buildCardView собирает представление карточки списка из доменных данных и // buildCardView собирает представление карточки списка из доменных данных и
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки // живого снимка. Общий для полной страницы (handleIndex), тика самообновления
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку // (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести // htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут
// прогресс-поллер — поэтому Progress заполняется здесь. // вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress
// заполняется здесь на каждом пути.
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView { func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
v := s.toView(d, now) v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг // Живой снимок читаем для всех карточек (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) { func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragActionErr(w, err, id, "card-"+id)
return return
} }
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) 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) { func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragActionErr(w, err, id, "download-main")
return return
} }
v := s.buildDownloadView(id, rd) v := s.buildDownloadView(id, rd)
@@ -661,7 +664,8 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading, 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, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || 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, Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Note: desyncNote(d.State), Note: desyncNote(d.State),
} }
// Быстрый интервал — только там, где на поверхности бегут цифры качания.
if v.IsDownloading {
v.PollEvery = pollFast
}
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at, // Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
// как в порядке списка); неразбираемое время просто опускаем. // как в порядке списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok { if t, ok := addedTime(d); ok {
+94 -26
View File
@@ -18,27 +18,43 @@ type LiveStatus interface {
Live(infohash string) (worker.Live, bool) 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 — заглушка на случай, когда источник телеметрии не подключён // noLive — заглушка на случай, когда источник телеметрии не подключён
// (Deps.Live == nil): живых данных нет, UI деградирует штатно. // (Deps.Live == nil): живых данных нет, UI деградирует штатно.
type noLive struct{} type noLive struct{}
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false } func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
// progressView — живой прогресс активной загрузки (для карточки и фрагмента // progressView — живой прогресс активной загрузки (вложенный блок карточки).
// /progress). Active управляется store-состоянием (downloading), а не qbt: // Active управляется store-состоянием (downloading), а не qbt: вне downloading
// когда задача покидает downloading, фрагмент возвращается без поллинга. // скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
// задачи»).
type progressView struct { type progressView struct {
ID string ID string
Active bool // store-состояние downloading → показываем бар и поллим Active bool // store-состояние downloading → показываем бар
Has bool // есть данные снимка Has bool // есть данные снимка
Percent int Percent int
DlSpeed string DlSpeed string
ETA string ETA string
} }
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding). // seedingView — живая статистика раздачи (секция страницы загрузки).
// Has истинно только если торрент сидирует и данные есть — иначе секция // Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер, поллинг прекращается). // деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
type seedingView struct { type seedingView struct {
ID string ID string
Has bool Has bool
@@ -79,7 +95,13 @@ func buildSeeding(id string, l worker.Live, ok bool) seedingView {
return v return v
} }
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг). // handleFragProgress отдаёт партиал живого прогресса карточки.
//
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
// её первым же тиком. Убирается отдельной уборкой после деплоя.
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { 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) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "dl-live-"+id)
return return
} }
active := d.State == store.StateDownloading 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)) s.render(w, "progress", buildProgress(id, active, l, ok))
} }
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched): // handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading // Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая // каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
// карточка уже не несёт самополлинга — цикл завершается сам. // действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
// несёт самополлинга, и цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { 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) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "card-"+id)
return 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) { func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { 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) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "seeding-"+id)
return return
} }
l, ok := s.liveFor(*d) l, ok := s.liveFor(*d)
s.render(w, "seeding", buildSeeding(id, l, ok)) s.render(w, "seeding", buildSeeding(id, l, ok))
} }
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound → // fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе). // фрагмент, который объясняет положение дел и НЕ несёт самообновления.
func (s *server) fragErr(w http.ResponseWriter, err error, id string) { //
if errors.Is(err, store.ErrNotFound) { // Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
http.Error(w, "не найдено", http.StatusNotFound) // поверхность осталась бы прежней навсегда (человек не отличит «ничего не
return // изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
// при затяжном отказе хранилища это поток записей в журнал с каждой открытой
// вкладки. Фрагмент без 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) s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) }
// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
// id узла, который фрагмент собой заменяет.
type fragNoteView struct {
RootID string
Text string
} }
// --- форматирование телеметрии --- // --- форматирование телеметрии ---
+246 -17
View File
@@ -1,6 +1,7 @@
package httpapi package httpapi
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -9,8 +10,8 @@ import (
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом, // TestFragProgressDownloading: маршрут прогресса остался гасителем старых
// значениями снимка и атрибутами htmx-поллинга. // вкладок — отдаёт цифры снимка и НЕ несёт собственного опроса.
func TestFragProgressDownloading(t *testing.T) { func TestFragProgressDownloading(t *testing.T) {
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading} dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
@@ -21,25 +22,80 @@ func TestFragProgressDownloading(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/" + testULID + "/progress", "width:42%", "42%"} { for _, want := range []string{"width:42%", "42%"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body) t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
} }
} }
if strings.Contains(body, "hx-trigger") {
t.Errorf("партиал прогресса всё ещё опрашивает сервер сам:\n%s", body)
}
} }
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading, // TestProgressBlockHiddenOutsideDownloading: вне downloading блок живых цифр не
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается). // рисуется вовсе — скорость и ETA там смысла не имеют. Проверка на отсутствие
func TestFragProgressStopsWhenNotDownloading(t *testing.T) { // hx-trigger сюда не годится: партиал не несёт его ни при каком входе, и такой
// тест был бы зелёным независимо от логики.
func TestProgressBlockHiddenOutsideDownloading(t *testing.T) {
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone} dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.9, DlSpeed: 6400000, ETA: 720}}}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress") rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
if body := rr.Body.String(); strings.Contains(body, "hx-trigger") { body := rr.Body.String()
t.Errorf("завершённая задача всё ещё поллит:\n%s", body) for _, unwanted := range []string{`class="progress"`, "dl-stats", "90%"} {
if strings.Contains(body, unwanted) {
t.Errorf("вне downloading блок цифр не должен рисоваться, есть %q:\n%s", unwanted, body)
}
}
}
// TestFragErrKeepsSwapRoot: фрагмент отказа несёт корневой id того узла, который
// он собой заменяет. Иначе своп уносит якорь поверхности: экран ревью или
// страница загрузки теряют цель для всех своих действий и мертвы до перезагрузки
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func TestFragErrKeepsSwapRoot(t *testing.T) {
cases := []struct{ path, root string }{
{"/fragments/downloads/" + testULID + "/card", `id="card-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/progress", `id="dl-live-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/seeding", `id="seeding-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/review", `id="review-main"`},
}
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
for _, c := range cases {
rr := get(t, h, c.path)
if rr.Code != http.StatusOK {
t.Errorf("%s: status = %d, want 200", c.path, rr.Code)
continue
}
if body := rr.Body.String(); !strings.Contains(body, c.root) {
t.Errorf("%s: фрагмент отказа без корня %s:\n%s", c.path, c.root, body)
}
}
}
// TestPageTickFailureSelfTerminates: тик страницы идёт тем же маршрутом, что и
// навигация, поэтому отказ на htmx-пути обязан отвечать 200 и фрагментом с
// корнем #download-main без hx-*; навигационный GET по-прежнему получает статус.
func TestPageTickFailureSelfTerminates(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
rr := getHTMX(t, h, "/download/"+testULID)
if rr.Code != http.StatusOK {
t.Fatalf("тик страницы: status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="download-main"`) {
t.Errorf("фрагмент отказа страницы без корня #download-main:\n%s", body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("фрагмент отказа страницы не самозавершается:\n%s", body)
}
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusNotFound {
t.Errorf("навигационный GET: status = %d, want 404", rr.Code)
} }
} }
@@ -57,11 +113,15 @@ func TestFragSeeding(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{"Раздача", "2.41", "38 / 14", `hx-trigger="every 3s"`} { for _, want := range []string{"Раздача", "2.41", "38 / 14"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body) t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body)
} }
} }
// Секция лежит внутри свопаемой области страницы — своего опроса не ведёт.
if strings.Contains(body, "hx-trigger") {
t.Errorf("секция раздачи всё ещё опрашивает сервер сама:\n%s", body)
}
} }
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет. // TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
@@ -80,7 +140,8 @@ func TestFragSeedingDegrades(t *testing.T) {
} }
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже // TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
// в первом кадре (значения снимка) и атрибуты поллинга. // в первом кадре (значения снимка), а опрашивает себя сама карточка — один
// поллер на поверхность, во вложенном блоке прогресса его нет.
func TestIndexCardShowsLiveProgress(t *testing.T) { func TestIndexCardShowsLiveProgress(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading} dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
@@ -91,21 +152,189 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/progress"} { for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/card"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("карточка без живого прогресса: нет %q", want) t.Errorf("карточка без живого прогресса: нет %q", want)
} }
} }
if strings.Contains(body, "/fragments/downloads/"+testULID+"/progress") {
t.Errorf("вложенный блок прогресса опрашивает себя сам:\n%s", body)
}
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
t.Errorf("объявлений самообновления на карточке = %d, want 1\n%s", n, body)
}
} }
// TestFragNotFound: фрагмент несуществующей задачи → 404. // TestFragTickOnMissingDownload: тик по исчезнувшей задаче отвечает 200 и
func TestFragNotFound(t *testing.T) { // фрагментом без hx-* — htmx не свопит 4xx/5xx, поэтому отказ статусом оставил
// бы карточку прежней навсегда, а опрос — бесконечным.
func TestFragTickOnMissingDownload(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{}) h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
if rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code) rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
} }
// Невалидный id → 404 без похода в БД. body := rr.Body.String()
if !strings.Contains(body, "не найдена") {
t.Errorf("фрагмент не объясняет отказ тика:\n%s", body)
}
if strings.Contains(body, "hx-trigger") || strings.Contains(body, "hx-get") {
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
}
}
// TestFragInvalidID: невалидный id → 404 без похода в БД (это не тик живой
// поверхности, а запрос по несуществующему адресу).
func TestFragInvalidID(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound { if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status(invalid id) = %d, want 404", rr.Code) t.Fatalf("status(invalid id) = %d, want 404", rr.Code)
} }
} }
// TestFragTickOnStoreFailure: отказ хранилища на тике — тоже 200 и
// самозавершающийся фрагмент, но с другим текстом: «не найдена» здесь соврало бы.
func TestFragTickOnStoreFailure(t *testing.T) {
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "не удалось обновить") {
t.Errorf("отказ хранилища выдан за пропажу задачи:\n%s", body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
}
}
// TestFragCardSurvivesSizeFailure: отказ чтения размеров не роняет тик —
// карточка деградирует на прочерк, а не на пустой ответ.
func TestFragCardSurvivesSizeFailure(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview}
rd := stubReader{one: &dl, sizesErr: errors.New("db is busy")}
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if body := rr.Body.String(); !strings.Contains(body, "Ревью →") {
t.Errorf("тик не пережил отказ чтения размеров:\n%s", body)
}
}
// TestCardSelfPollFollowsObservability: карточка опрашивает себя, пока задача
// наблюдаема, и замолкает, когда двигать её может только человек. failed,
// target_missing и orphaned наблюдаются: их возвращает в поток фоновая сверка.
func TestCardSelfPollFollowsObservability(t *testing.T) {
polling := []store.State{
store.StateCatched, store.StateDownloading, store.StateCompleted,
store.StateRecognizing, store.StateReview, store.StateLinking,
store.StateDeferred, store.StateStuck,
store.StateFailed, store.StateTargetMissing, store.StateOrphaned,
}
silent := []store.State{
store.StateDone, store.StateCancelled, store.StateReverted, store.StateDeleted,
}
for _, st := range polling {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, "/fragments/downloads/"+testULID+"/card") {
t.Errorf("%s: наблюдаемая карточка не опрашивает себя:\n%s", st, body)
}
}
for _, st := range silent {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if strings.Contains(body, "hx-trigger") {
t.Errorf("%s: ненаблюдаемая карточка продолжает опрос:\n%s", st, body)
}
}
}
// TestCardPollInterval: быстрый интервал — только там, где бегут цифры качания.
func TestCardPollInterval(t *testing.T) {
cases := []struct {
state store.State
want string
}{
{store.StateDownloading, `hx-trigger="every ` + pollFast + `"`},
{store.StateReview, `hx-trigger="every ` + pollSlow + `"`},
{store.StateCatched, `hx-trigger="every ` + pollSlow + `"`},
}
for _, c := range cases {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, c.want) {
t.Errorf("%s: нет %q\n%s", c.state, c.want, body)
}
}
}
// TestFragCardBringsNewStateAndActions: первый ответ фрагмента после смены
// состояния приносит новый бейдж и новый набор действий — ради этого change и
// затевался.
func TestFragCardBringsNewStateAndActions(t *testing.T) {
cases := []struct {
state store.State
want string
}{
{store.StateReview, "Ревью →"},
{store.StateDone, "Откатить"},
}
for _, c := range cases {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, c.want) {
t.Errorf("%s: фрагмент не принёс действие %q\n%s", c.state, c.want, body)
}
}
}
// TestDownloadPageSelfPoll: страница живёт по тому же правилу наблюдаемости,
// интервал у неё всегда медленный (блока живых цифр качания на ней нет), а
// секция «Раздача» своего опроса не ведёт — один поллер на поверхность.
func TestDownloadPageSelfPoll(t *testing.T) {
seedLive := stubLive{m: map[string]worker.Live{"ihp": {Seeding: true, Progress: 1, Ratio: 2.4, Seeds: 3, Peers: 1}}}
hashes := []store.Infohash{{DownloadID: testULID, Infohash: "ihp", Kind: store.HashV1}}
// Наблюдаемая задача с сидирующей раздачей: ровно одно объявление опроса.
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview, Infohashes: hashes}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}}, seedLive)
body := get(t, h, "/download/"+testULID).Body.String()
if !strings.Contains(body, `hx-trigger="every `+pollSlow+`"`) {
t.Errorf("страница наблюдаемой задачи без медленного самообновления:\n%s", body)
}
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
t.Errorf("объявлений самообновления на странице = %d, want 1", n)
}
// Ненаблюдаемая задача: страница замолкает.
done := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateDone, Infohashes: hashes}
h = testRouterLive(t, stubReader{one: &done}, stubReviewer{data: &worker.ReviewData{Download: done}}, seedLive)
if body := get(t, h, "/download/"+testULID).Body.String(); strings.Contains(body, `hx-trigger="every`) {
t.Errorf("страница ненаблюдаемой задачи продолжает опрос:\n%s", body)
}
}
// TestFragCardKeepsLayoutSize: самообновление не теряет полей полного рендера —
// размер разложенных файлов при отсутствии раздачи в снимке.
func TestFragCardKeepsLayoutSize(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateOrphaned}
rd := stubReader{one: &dl, sizes: map[string]int64{testULID: 3 << 30}}
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, "3.0 ГиБ") {
t.Errorf("фрагмент карточки потерял размер раскладки:\n%s", body)
}
}
+21 -3
View File
@@ -15,9 +15,11 @@ import (
// stubReader — минимальный Reader для проверки рендера списка. // stubReader — минимальный Reader для проверки рендера списка.
type stubReader struct { type stubReader struct {
list []store.Download list []store.Download
one *store.Download one *store.Download
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк) sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
getErr error // отказ чтения задачи (не ErrNotFound)
sizesErr error // отказ чтения размеров раскладки
} }
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil } func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
@@ -25,12 +27,18 @@ func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]stor
return s.list, len(s.list), nil return s.list, len(s.list), nil
} }
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) { func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) {
if s.getErr != nil {
return nil, s.getErr
}
if s.one == nil { if s.one == nil {
return nil, store.ErrNotFound return nil, store.ErrNotFound
} }
return s.one, nil return s.one, nil
} }
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) { func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
if s.sizesErr != nil {
return nil, s.sizesErr
}
return s.sizes, nil return s.sizes, nil
} }
@@ -94,6 +102,16 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
return rr return rr
} }
// getHTMX — тот же GET, но помеченный как htmx-запрос (тик самообновления).
func getHTMX(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("HX-Request", "true")
h.ServeHTTP(rr, req)
return rr
}
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат). // testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
const testULID = "01arz3ndektsv4rrffq69g5fav" const testULID = "01arz3ndektsv4rrffq69g5fav"
+1 -1
View File
@@ -455,7 +455,7 @@ func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
} }
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "review-main")
return return
} }
s.render(w, "review_main", buildReviewView(id, rd, "")) s.render(w, "review_main", buildReviewView(id, rd, ""))
+20
View File
@@ -64,6 +64,26 @@ func (s State) IsTerminal() bool {
return slices.Contains(terminalStates, s) return slices.Contains(terminalStates, s)
} }
// selfHealingStates — терминальные состояния, которые фон возвращает в поток
// САМ, без человека: failed (по восстановимым кодам — см. ListRecoverable) и
// состояния рассинхрона, которые сверка переоценивает по реальности (см.
// reconcileDesync). done в перечень не входит сознательно: его переоценка
// означает удаление файлов мимо сервиса — событие редкое, а разложенных задач в
// списке больше всех, и наблюдать за каждой дороже, чем показать новое
// состояние при следующем заходе.
var selfHealingStates = []State{
StateFailed, StateTargetMissing, StateOrphaned,
}
// IsObservable сообщает, может ли состояние задачи измениться без участия
// человека: любое нетерминальное плюс терминальные из selfHealingStates. На
// этом предикате стоит самообновление веб-UI: поверхность обновляет себя, пока
// задача наблюдаема, и замолкает, когда двигать её может только человек (см.
// openspec/specs/web-ui, «Самообновление живой задачи»).
func (s State) IsObservable() bool {
return !s.IsTerminal() || slices.Contains(selfHealingStates, s)
}
// allowedTransitions — декларативный граф легальных переходов машины состояний // allowedTransitions — декларативный граф легальных переходов машины состояний
// (from → множество допустимых to). Единственный источник истины о легальности // (from → множество допустимых to). Единственный источник истины о легальности
// рёбер: покрывает все переходы, которые worker выполняет по всем capability // рёбер: покрывает все переходы, которые worker выполняет по всем capability
+30
View File
@@ -653,3 +653,33 @@ func TestListAndByState(t *testing.T) {
t.Fatalf("ListDownloadsByState(downloading) = %v", dl) t.Fatalf("ListDownloadsByState(downloading) = %v", dl)
} }
} }
// TestIsObservable: наблюдаемость — «состояние ещё может измениться без
// человека». Нетерминальные наблюдаемы все; из терминальных — те, которые фон
// возвращает в поток сам (failed по восстановимым кодам, target_missing и
// orphaned переоценивает сверка). done в перечень не входит сознательно: его
// переоценка означает удаление файлов мимо сервиса.
func TestIsObservable(t *testing.T) {
observable := []State{
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
StateReview, StateLinking, StateDeferred, StateStuck,
StateFailed, StateTargetMissing, StateOrphaned,
}
silent := []State{StateDone, StateCancelled, StateReverted, StateDeleted}
for _, s := range observable {
if !s.IsObservable() {
t.Errorf("%s: IsObservable=false, want true", s)
}
}
for _, s := range silent {
if s.IsObservable() {
t.Errorf("%s: IsObservable=true, want false", s)
}
}
// Наблюдаемое множество не сводится к нетерминальному — иначе предикат был
// бы лишним, а карточка упавшей задачи замирала бы навсегда.
if !StateFailed.IsTerminal() || !StateFailed.IsObservable() {
t.Error("failed должно быть терминальным и при этом наблюдаемым")
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10
@@ -0,0 +1,228 @@
## Context
Живое обновление веб-UI собрано из трёх независимых поллеров, каждый привязан к
своей фазе или региону:
- карточка списка опрашивает себя, пока `SelfPoll`а это `d.State ==
StateCatched` (`internal/httpapi/httpapi.go:664`, `card.html:2`);
- вложенный блок прогресса опрашивает себя, пока `Active` — а это `d.State ==
StateDownloading` (`internal/httpapi/live.go:88`, `progress.html:1`);
- страница `/download/{id}` повторяет первое правило
(`internal/httpapi/download.go:116`, `download_main.html:2`), а внутри неё
секция «Раздача» опрашивает себя сама (`seeding.html:1`).
Ни одно из правил не покрывает выход из `downloading`, поэтому дальше задача
живёт на экране в прошлом. Домен предикат уже даёт: `State.IsTerminal()`
(`internal/store/download.go:63`) со своим единым перечнем терминальных
состояний — заводить второй перечень в транспорте нельзя.
Цена тика у двух поверхностей разная, и это главное ограничение дизайна:
- фрагмент карточки — `GetDownload` плюс чтение in-memory снимка воркера;
- страница загрузки — `Reviewer.ReviewData`, а он на каждом вызове строит
предпросмотр раскладки через `layout.BuildLinks`
(`internal/worker/review.go:1096`), то есть ходит в файловую систему.
Ещё одно свойство, из которого растут решения Р2 и Р5: `#seeding-{id}` лежит
**внутри** `#download-main` (`download_main.html:61`, корень закрыт на строке
129), а страница свопает этот корень целиком.
## Goals / Non-Goals
**Goals:**
- смена состояния становится видимой без перезагрузки страницы, кто бы её ни
сделал — воркер, веб-UI, Telegram или фоновая сверка;
- обновление само прекращается, когда состояние менять больше некому;
- число фоновых запросов на открытую страницу известно и обосновано.
**Non-Goals:**
- переход на SSE — отдельная задача `sse-live-updates`, и этот change её не
приближает и не отменяет;
- изменение состава живой телеметрии и `/api/**`;
- обновление списка **целиком** (появление новых задач, изменение порядка и
групп) — сегодня его нет, и эта задача его не заводит;
- экран `/review/{id}`: он сохраняет фазовое самообновление, заказанное спекой
`review` («пока загрузка в `recognizing`»), и приводится к общему правилу
отдельной задачей.
## Decisions
### Р1. Условие обновления — наблюдаемость, предикат общий с доменом
`SelfPoll` в обоих представлениях перестаёт зависеть от фазы. Наблюдаема
задача, состояние которой ещё может измениться без участия этого браузера.
Одного `!IsTerminal()` для этого мало, и это выяснилось на ревью дизайна.
Терминальность в проекте значит «не активна», а не «навсегда»: фоновая сверка
двигает часть терминальных сама — `ListRecoverable`
(`internal/store/download.go:606`) возвращает в поток `failed`/`stuck` с кодами
`magnet_timeout` и `stalled`, а `desyncStates` (`internal/worker/reconcile.go:22`)
переоценивает `done`, `target_missing` и `orphaned`. Карточка, застывшая по
`IsTerminal`, показывала бы «Ошибка» у задачи, которая уже качается, — ровно тот
дефект, ради которого затеян change.
**Решено на чекпоинте:** наблюдаемы все нетерминальные плюс `failed`,
`target_missing`, `orphaned` — те, кого сверка возвращает в поток сама.
Замолкают `done`, `cancelled`, `reverted`, `deleted`.
`done` в этот перечень не входит, хотя формально его тоже переоценивает сверка:
переход `done → target_missing`/`orphaned` означает, что файлы удалили руками
мимо сервиса, — событие редкое, а карточек `done` в списке больше всех. Платить
за редкий случай постоянным фоновым запросом на каждую разложенную задачу
дороже, чем показать её новое состояние при следующем заходе. Это принятое
ограничение, а не упущение.
Предикат живёт **в домене**, рядом с `IsTerminal`, а не в `httpapi`: второй
перечень состояний в транспорте разъедется на первом же новом состоянии.
Побочно это чинит и то, чего задача не заказывала: `stuck` и `deferred`
нетерминальны, и их карточки тоже перестают застревать.
**Рассмотрено и отвергнуто:** «поллить, пока задача в активной группе списка» —
группа считается из того же `IsTerminal`, то есть это то же условие, названное
через представление, а не через домен.
### Р2. Один источник обновления на поверхность
Правило общее для карточки и для страницы: вложенные живые регионы своего опроса
не ведут.
- в карточке блок прогресса (`progress.html`) остаётся вложенной разметкой без
своего `hx-get`;
- на странице секция «Раздача» (`seeding.html`) — тоже.
Основание фактическое, а не эстетическое. Своп корня меняет `outerHTML` целиком
и уносит вложенный узел вместе с его таймером: два опроса на одну поверхность
опрашивают одно и то же дважды, подменяют разметку друг друга, а тик корня,
попавший в незавершённый запрос региона, роняет его ответ в никуда. До этого
change конфликта не было только потому, что поверхности поллились в фазах, где
вложенных регионов не существует (`catched` — ни прогресса, ни раздачи).
Цена названа прямо: цифры сидирования обновлялись раз в 3 с, станут обновляться с
тиком страницы. Рейтинг и число пиров — не те величины, которым нужна
трёхсекундная свежесть; прогресс качания, которому она нужна, едет с быстрым
интервалом карточки.
**Рассмотрено и отвергнуто:** оставить регионам их опрос, а корень свопать
частями (`hx-select` по кускам) — это заводит вторую механику свопа ради
сохранения того, что и так не нужно с трёхсекундной частотой.
### Р3. Способ доставки — самополлинг фрагмента, а не сигнал из прогресса
Вариант «фрагмент прогресса, заметив уход из `downloading`, просит браузер
обновить карточку» (`HX-Trigger` или `hx-swap-oob`) дешевле по запросам, но
покрывает ровно один переход — тот, у которого был поллер. Переходы
`review → linking → done`, сделанные из Telegram, остались бы невидимыми, а
именно они дают самое долгое расхождение: задача стоит в ревью часами.
Вариант «поллить список одним запросом целиком» дал бы заодно появление новых
задач, но перерисовывал бы всю страницу, ломая фильтр, поиск и прокрутку, —
спека `live-status` это прямо запрещает. Это направление принадлежит SSE-задаче.
Следствие принятого варианта, названное сценарием спеки: карточка, дошедшая до
конца на глазах у смотрящего, остаётся на своём месте в прежней группе списка —
группы и фильтр считаются на рендере страницы и пересчитываются навигацией.
### Р4. Две частоты, потому что цена тика разная
Интервал зависит от того, несёт ли **поверхность** блок живых цифр качания:
- **быстрый** — карточка задачи в `downloading`: цифры меняются непрерывно, и
это единственное место, где реже значит хуже;
- **медленный** — все прочие наблюдаемые поверхности, включая **страницу
`/download/{id}` в любом состоянии**: блока прогресса на ней нет вовсе
(`grep progress web/templates/partials/download_main.html` пуст), а цифры
раздачи трёхсекундной свежести не требуют.
Критерий именно «есть блок живых цифр», а не «состояние `downloading`»: на
странице эти два признака расходятся, и по второму она перерисовывалась бы 20
раз в минуту, не показывая ни одной изменившейся величины.
Арифметика, ради которой это и сделано. Открытая страница `/download/{id}` в
`review` при быстром интервале звала бы `BuildLinks` 20 раз в минуту всё время,
что вкладка открыта; при медленном — 4 раза. Для списка из N наблюдаемых
карточек быстрый интервал везде дал бы `20 × N` запросов в минуту, разный —
`20` за качающиеся и `4 × N` за остальные.
Уточнение после ревью кода: один тик карточки — это **два** обращения к
хранилищу (`GetDownload` и `LayoutSizeByDownload`, оба по первичному ключу и
индексу `idx_file_link_download`), а не одно. То есть открытый список даёт
`8 × N` чтений в минуту вместо `4 × N`. Полный рендер страницы берёт размеры
одним батчем, самообновление — по карточке: это цена того, что список не
пересобирается целиком (см. Non-Goals). Замера под нагрузкой нет; порог, при
котором это перестанет быть бесплатным, ищет задача `scale-100-downloads`.
**Решено на чекпоинте:** быстрый интервал — 5 с, вровень с
`[worker].poll_interval` (`docs/database.md:201`), медленный — 15 с. Сегодняшние
3 с обгоняют источник: воркер снимает телеметрию раз в 5 с, поэтому примерно два
тика из пяти возвращают тот же кадр. Выравнивание убирает холостые запросы, а
свежесть цифр не портит — она и так ограничена тиком воркера, что спека
`live-status` прямо и требует («Свежесть не выше тика поллинга»).
Обе константы живут в одном месте кода рядом с представлениями и записываются в
`docs/database.md` в таблицу настроек с числовым значением, там же — связь
быстрого интервала с частотой опроса qBittorrent.
**Рассмотрено и отвергнуто:** единая частота — проще на один параметр, но делает
открытую вкладку с ревью источником постоянных обращений к файловой системе;
частота из конфига — настройка, которую никто не будет крутить, а
`config.example.toml` и документацию она утяжелит.
### Р5. Фрагменты прогресса и раздачи остаются — и гасят разметку прошлой версии
Оба маршрута (`/fragments/downloads/{id}/progress`, `.../seeding`) после Р2
остаются без потребителя в новой разметке. Удалить их сразу нельзя: htmx **не
свопит** ответы 4xx/5xx и не снимает с узла `hx-trigger`, поэтому вкладка,
открытая до деплоя, слала бы запросы на удалённый маршрут каждые 3 секунды до
самого закрытия — молча для смотрящего и десятками тысяч строк в журнале
доступа.
Оставленные маршруты отдают те же партиалы, которые после правки поллинга не
несут, — то есть первый же тик старой вкладки гасит её собственный опрос.
Уборка этих двух обработчиков — отдельная мелкая задача после деплоя; она
уезжает в урожай ревью, а не остаётся обещанием в комментарии.
`buildProgress` и `buildSeeding` остаются в любом случае: ими собираются виды,
вложенные в карточку и страницу.
### Р6. Самообновление не теряет полей полного рендера
`handleFragCard` сегодня зовёт `buildCardView` с `layoutSize = 0` — упрощение
времени, когда фрагмент обслуживал только `catched`, где раскладки не бывает.
После расширения тот же обработчик обслуживает `linking`, `deferred` и прочие
состояния с уже разложенными файлами, и при отсутствии раздачи в снимке
самообновление подменило бы показанный размер прочерком.
Фрагмент читает размер раскладки так же, как это делает своповый путь действия
(`renderCardFragment` → `LayoutSizeByDownload`).
### Р7. Отказ тика самозавершается
Тик, не сумевший прочитать задачу (записи нет — например, её убрала уборка; или
отказало хранилище), отвечает `200` и фрагментом без `hx-*`. Иначе htmx не
свопит ответ, поверхность остаётся прежней навсегда, а опрос продолжается: при
затяжном отказе хранилища одна открытая вкладка даёт `4 × N` записей в журнале в
минуту. Форма ответа согласована с уже записанной конвенцией для htmx-пути
действий (`docs/conventions/web-ui.md`).
## Risks / Trade-offs
- **Своп карточки раз в интервал попадает в момент, когда человек ведёт мышь к
кнопке** → поведение уже существует у карточек в `catched`; кнопки — обычные
формы, потеря фокуса восстанавливается повторным наведением. Отдельного
гашения свопа при наведении не делаем: заметная механика ради редкого случая.
- **Сообщение об ошибке действия (`ActionError`) живёт до следующего тика** →
сегодня оно живёт до любого следующего свопа, и в `catched` уже так. Отдельно
не удерживаем: место для устойчивого объяснения — страница загрузки.
- **Открытая на ночь вкладка держит опрос, пока есть хоть одна наблюдаемая
задача** → ограничено медленным интервалом и прекращается само. Полный отказ
от фонового опроса — предмет SSE-задачи.
- **Цифры раздачи стали обновляться реже** → принято осознанно в Р2; величины
медленные, а альтернатива — вложенный поллер внутри свопаемого корня.
## Open Questions
Нет. Обе развилки решены на чекпоинте и записаны в Р1 и Р4: наблюдаемы
нетерминальные плюс `failed`/`target_missing`/`orphaned`; интервалы — 5 с и 15 с.
@@ -0,0 +1,66 @@
## Why
Загрузка докачалась, воркер увёл её в распознавание и дальше в ревью — а в
списке она по-прежнему «Загружается», без кнопки «Ревью →». Верное состояние
появляется только после того, как человек сам перезагрузит страницу.
Живое обновление сегодня привязано к двум отдельным фазам: карточка опрашивает
себя, пока задача в `catched`, а прогресс — пока она в `downloading`. Выйдя из
`downloading`, задача не опрашивается ничем, хотя сменить состояние ей предстоит
ещё не раз (распознавание, ревью, раскладка) и часть этих смен идёт вообще без
участия того, кто смотрит на список: их делает воркер или человек из Telegram.
## What Changes
- Самообновление карточки списка привязывается к **нетерминальности** задачи, а
не к фазе `catched`: карточка обновляется, пока задача жива, и перестаёт —
когда та встала окончательно.
- У карточки остаётся **один** источник обновления. Сейчас в `downloading` их
было бы два (сама карточка и вложенный фрагмент прогресса), и они опрашивали
бы одно и то же дважды, подменяя разметку друг друга. Живые цифры прогресса
приходят вместе с карточкой.
- Страница `/download/{id}` живёт по тому же правилу: самообновляется, пока
задача нетерминальна.
- Частота обновления перестаёт быть одинаковой: карточка с живыми цифрами
(скорость, ETA) обновляется чаще, чем карточка, у которой меняется только
состояние. Цена тика у второй поверхности выше — сборка страницы загрузки
считает предпросмотр раскладки и ходит в файловую систему.
- Секция «Раздача» на странице загрузки перестаёт опрашивать сервер сама — она
лежит внутри области, которую страница обновляет целиком, и два опроса на одну
поверхность мешали бы друг другу.
- Тик самообновления, не сумевший прочитать задачу, перестаёт быть молчаливым:
он объясняет положение дел и прекращает опрос вместо бесконечного стука в
сервер.
- **BREAKING** для внутреннего контракта фрагментов: фрагменты прогресса и
раздачи перестают быть самостоятельными поллерами. Наружного API это не
касается — `/api/**` не меняется.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `web-ui`: требование «Отображение промежуточного состояния catched»
обобщается — самообновление интерфейса перестаёт быть свойством одной фазы и
становится свойством живой задачи; условие остановки — терминальное
состояние.
- `live-status`: требование «Живой прогресс активных загрузок» — сценарий
«Завершение останавливает поллинг» сегодня описывает наблюдаемый дефект как
норму. Прекращаться должен показ живых цифр, а не обновление карточки.
## Impact
- `internal/httpapi`: `toView` и `buildDownloadView` (условие самообновления),
обработчики фрагментов карточки и прогресса;
- `web/templates/partials/card.html`, `progress.html`, `download_main.html`;
- нагрузка: число фоновых запросов на открытую страницу меняется — считается в
`design.md`;
- вне scope: переход на SSE (задача `sse-live-updates`), любые изменения
`/api/**` и состава живой телеметрии;
- вне scope и названо сознательно: экран `/review/{id}` сохраняет фазовое
самообновление, заказанное спекой `review` («пока загрузка в `recognizing`»).
Третья поверхность приводится к общему правилу отдельной задачей — иначе
change тянет за собой ещё одну capability.
@@ -0,0 +1,155 @@
# Ревью изменения `card-live-refresh` — сводный отчёт триажа
## Сводка
- **Размер / сложность / метка:** среднее / знакомое / `medium`.
- **Режим прогона:** по графу. База диффа `969926f`.
- **Гейт:** зелёный. `BASE=969926f task gate` прогнан проходом `autotests` независимо: 14 шагов
`OK`, ни одного `SKIP`/`WARN`; `-race` реально исполнен, флаки-прогон побайтово совпал,
diff-coverage 24/24, `gitleaks` по 187 коммитам чисто, `govulncheck` — 0 достижимых.
- **Находок на входе:** 15 (autotests 2, specs 3, code 7, basics 4) + 3 наблюдения «вне спеки»
+ 3 «дешевле переделать до мерджа». **На выходе:** 2 блокирующие + 4 «сейчас» + 4 гипотезы
+ 5 promote.
### План разметки с исходом по темам
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| requirements | `openspec/specs/{web-ui,live-status}` + дельты | разбор | `specs` | закрыта, 3 находки |
| autotests | CLAUDE.md «Гейт» | прогон | `autotests` | закрыта, гейт прогнан, 2 находки |
| conventions | `docs/conventions/{README,web-ui,logging}.md` | разбор | `code` | закрыта, 3 находки |
| architecture | `docs/architecture.md` + `passport.md` | разбор | `basics` | закрыта, 1 находка |
| security | `docs/security.md` | разбор | `basics` | закрыта, 0 находок, все 5 вопросов отвечены |
| operations | `docs/architecture.md` «Эксплуатация» + `docs/database.md` | разбор | `basics` | закрыта, 2 находки (обе понижены) |
| техника (без темы) | — | разбор | `code` | 4 находки, 3 дедуплицированы |
**Тем без отчёта нет.** Своих тем проекта план не называл.
### Сигнал о заниженной метке
`review-code` — сигнала нет, возражений против `medium` не подаёт. `review-basics` строки о
метке не прислал; «возражений нет» и «не проверял» по молчанию не различаются — сигнал по
этому проходу считается неполученным, а не отрицательным.
## Блокирует мердж
### 1. Тик страницы `/download/{id}` при отказе чтения оставляет её навсегда устаревшей и стучит в сервер до закрытия вкладки
- Файл: `internal/httpapi/download.go:84-102`; `web/templates/partials/download_main.html:2`
- Severity: major. Confidence: high.
- Оракул: временный падающий тест триажа (прогнан, файл удалён): `page tick (not found)
status = 404, want 200`; `page tick (store failure) status = 500`; «ответ тика не несёт корня
`#download-main`». Плюс дельта `web-ui`: требование написано для обеих поверхностей.
- Последствие: htmx не свопит 4xx/5xx и не снимает `hx-trigger` — страница показывает состояние,
которого уже нет, человеку не сообщается ничего, вкладка стучит каждые 15 с бесконечно,
добавляя строку ERROR за тик. До change это было незаметно: `SelfPoll` стоял только на
короткоживущем `catched`, теперь наблюдаемых состояний 11.
- Найдено четырьмя проходами независимо (`specs`, `basics`, `code`, `autotests`).
- **Действие: развилка** (общая со следующей находкой).
### 2. Фрагмент отказа с чужим корневым `id` сносит `#review-main` и `#download-main` — экран мёртв до F5
- Файл: `internal/httpapi/live.go:175-182`; `web/templates/partials/frag_note.html:1`
- Severity: major. Confidence: high.
- Оракул: падающий тест триажа (тик `/review` при не-`ErrNotFound` отказе отдал
`<article class="card" id="card-…">`), плюс `docs/conventions/web-ui.md:36-41` дословно:
«корень `{{define}}` — это элемент с целевым `id` … если ответный фрагмент не несёт тот же
корневой `id`, следующее действие/поллер не найдёт таргет».
- Последствие: `fragErr` зовут из шести мест с четырьмя разными целями свопа, а отдаёт он всегда
карточку. Транзиентный `SQLITE_BUSY` на тике `recognizing` (самый частый тик проекта, 2 с)
заменяет весь `#review-main` карточкой списка — экран ревью теряет якорь и все действия.
**Регрессия против базы:** раньше 500 не свопился и экран оставался рабочим.
- **Действие: развилка** (тот же вопрос, отвечать один раз на обе).
## Стоит исправить сейчас
### 3. Тик страницы затирает то, что человек в этот момент читает
- Файл: `web/templates/partials/download_main.html:2,6,99-128`; `internal/httpapi/httpapi.go:515-526`
- Severity: minor. Confidence: high.
- Два проявления одной причины: (1) сообщение об отказе действия живёт ≤15 с и исчезает, а в
`error_msg` штатный конфликт не пишется и в логе он `DEBUG` — причина не остаётся нигде;
(2) раскрытая «Опасная зона» захлопывается каждый тик, пока человек читает текст про
необратимое удаление раздачи с файлами. Пересечение `Dismissable` с `IsObservable` —
`failed`, `orphaned`, `target_missing`.
- **Действие: развилка.**
### 4. Отказ на повторяющемся тике пишется ERROR — шторм в журнале ровно тогда, когда хранилищу плохо
- Файл: `internal/httpapi/live.go:139-143`, `internal/httpapi/live.go:175-180`
- Severity: minor. Confidence: high.
- Оракул: `docs/conventions/logging.md:164-171` дословно: «Повторяющийся сбой фонового цикла
(поллинг/сверка) — `WARN`, не `ERROR` … уровень задаёт не текст ошибки, а наличие штатного
ретрая».
- **Действие: инлайн.**
### 5. Конвенция и комментарии описывают поллер, снятый этим же диффом
- Файл: `docs/conventions/web-ui.md:104-129`; `internal/httpapi/live.go:121-124`;
`internal/httpapi/httpapi.go:314-318`
- Severity: minor. Confidence: high.
- Раздел «Живой поллинг» утверждает три неверных вещи: пример разметки с `hx-*` в `progress`;
«эталон — `progress`/`seeding`»; «данные тика — из in-memory снимка, без БД/сети на каждый
тик». Следующий автор возьмёт за образец снятое и заведёт второй поллер на поверхность.
- **Действие: инлайн.**
### 6. Тест `TestFragProgressStopsWhenNotDownloading` больше не может упасть
- Файл: `internal/httpapi/live_test.go:35-48`
- Severity: minor. Confidence: high.
- `hx-*` сняты с партиала безусловно, поэтому проверка ложна при любом состоянии: тест зелен
независимо от логики, которую называет. diff-coverage меряет исполнение, а не проверку.
- **Действие: инлайн.**
## Гипотезы без доказательства
- Тик списка шлёт N HTTP-запросов и 2N запросов в SQLite там, где полный рендер обходится одним
батчем (`live.go:133-146`). Понижено: замера нет; по-карточное обновление заказано дельтой,
запрос идёт по индексу `idx_file_link_download`. Остаётся верным одно: арифметика Р4 в
`design.md` занижена по числу обращений к БД.
- Отказ чтения размеров подменяет известный размер прочерком (`live.go:136-144`). Понижено:
дельта про отказ вспомогательного чтения молчит — вопрос к тексту дельты, не дефект кода.
- Перечень «кого фон возвращает сам» разошёлся на три ручные копии
(`store/download.go:67-84`, `worker/reconcile.go:22-26`). Понижено: расхождения и последствия
сегодня нет, риск чисто будущий.
- `pollFast = "5s"` — второй дом настройки `[worker].poll_interval`. Понижено: связь держится на
прозе, сегодняшнее значение верно.
**Отсеяно как вкусовщина:** «имя `IsObservable` и комментарий расходятся с поведением на
`review`/`deferred`» — предикат ровно такой, каким его определила дельта-спека поимённо.
**Проектных ложноположительных не сработало.**
## Promote candidates
- Правило «ответ-фрагмент несёт корневой `id` того узла, в который свопится» — записано в
конвенции, но не механизировано; кандидат в табличный тест или `internal/archrules`.
- Задача на уборку маршрутов-гасителей `/fragments/downloads/{id}/progress` и `/seeding`.
- Правило «константа, дублирующая значение настройки конфига, считается из конфига».
- Тест-связка `worker.desyncStates` ↔ `store.selfHealingStates`.
- Правило «тест, который не может упасть, — дефект теста»; класс ловится мутационной проверкой,
diff-coverage его не видит по устройству.
## Границы покрытия
- Запускались на метке `medium`, режим «по графу»: `autotests`, `specs`, `code`, `basics`. Все
четыре вернули отчёт. Триаж — сток.
- **Не запускались** (нет на `medium`): враждебный проход с построенным путём атаки,
эксплуатационный постмортем с замерами, независимая реализация. Их даёт только `large`.
- **Потолки:** `code/conventions` 3 из 4, срез не сработал; `basics` 4 из 4 — срез сработал, за
ним осталось наблюдение про ERROR на каждом тике (выведено отдельной находкой) и три пункта
«дешевле переделать до мерджа». `specs`, `autotests`, `code/техника` потолков не сообщили —
это находка о самом прогоне.
- **На `small` и `medium` ничего не проверяется запуском сверх гейта:** построенный путь атаки,
поведение библиотеки и драйвера в вырожденном случае, любые числа (время удержания блокировки,
пик кучи, темп роста журнала). `basics` задаёт часть тех же вопросов чтением — его ответы
слабее и выше гипотезы не поднимаются.
- **Решения проекта не сверялись:** `docs/adr/` — процессный документ, прогон его не открывает;
расхождение с записанным решением ловит `av-dev-docs:healthcheck`.
- **Записанные наблюдения не использовались:** `docs/research/` — тоже процессный; всякое число
в отчёте снято на этом прогоне.
- **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом** — проход
`idiom` упразднён (ADR-2026-08-04).
- **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
- Ни один тик в этом прогоне не исполнялся браузером: выводы о свопе и `hx-trigger` сделаны из
разметки и текста конвенции.
@@ -0,0 +1,68 @@
## MODIFIED Requirements
### Requirement: Живой прогресс активных загрузок
Веб-UI SHALL показывать прогресс, скорость и ETA активных (`downloading`)
загрузок на главной без перезагрузки страницы, обновляя их тем же
самообновлением, которым обновляется сама карточка (см. `web-ui`,
«Самообновление живой задачи»). Обновление MUST NOT сбрасывать клиентские
фильтр, поиск и прокрутку.
Когда задача покидает состояние `downloading`, показ скорости и ETA SHALL
прекращаться: вне качания эти величины смысла не имеют. Снимок при этом
продолжает питать прочие живые значения карточки и страницы — размер и рейтинг
раздачи, — и прекращение показа цифр качания MUST NOT означать прекращения
обновления поверхности: она продолжает отражать смену состояния, пока задача
наблюдаема.
Живые цифры MUST браться из снимка воркера; при отсутствии данных по задаче
поверхность деградирует без них, не ломая остального отображения.
#### Scenario: Прогресс растёт без перезагрузки
- **WHEN** загрузка качается и пользователь смотрит на главную
- **THEN** её прогресс-бар, скорость и ETA обновляются на месте без
перезагрузки страницы
#### Scenario: Клиентское состояние сохраняется
- **WHEN** применён фильтр или поиск и происходит фоновое обновление
- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются
#### Scenario: Завершение убирает цифры качания, но не обновление
- **WHEN** загрузка переходит из `downloading` в другое наблюдаемое состояние
- **THEN** блок прогресса, скорости и ETA с карточки исчезает
- **AND** карточка продолжает обновляться и приносит новое состояние
- **AND** размер и рейтинг раздачи по-прежнему берутся из снимка
### Requirement: Секция раздачи на странице загрузки
Страница `/download/{id}` SHALL показывать секцию «Раздача» с живой статистикой
(рейтинг, число сидов и пиров, объём отданного, скорость отдачи) для задач,
чей торрент сидирует. Если живых данных по задаче нет, секция SHALL
отсутствовать либо явно показывать «нет данных», не ломая остальную страницу.
Секция MUST NOT опрашивать сервер самостоятельно: она лежит внутри области,
которую страница обновляет целиком, и собственный опрос секции подменял бы
разметку страницы. Её цифры SHALL приходить с тиком самообновления страницы
(см. `web-ui`, «Самообновление живой задачи»), а частота их обновления
SHALL совпадать с частотой обновления страницы.
#### Scenario: Сидирующая задача показывает раздачу
- **WHEN** открыта страница задачи, торрент которой раздаётся
- **THEN** в секции «Раздача» видны рейтинг, сиды/пиры, отдано и скорость отдачи
#### Scenario: Нет живых данных — секция деградирует
- **WHEN** открыта страница задачи, торрента которой нет в qBittorrent
- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а
распознавание, файлы и история отображаются нормально
#### Scenario: Секция обновляется тиком страницы
- **GIVEN** открыта страница наблюдаемой задачи, чья раздача сидирует
- **WHEN** страница отрисована
- **THEN** секция «Раздача» не несёт собственного опроса
- **AND** её цифры обновляются вместе с остальной страницей
@@ -0,0 +1,200 @@
## ADDED Requirements
### Requirement: Самообновление живой задачи
Карточка списка и страница `/download/{id}` SHALL самообновляться, пока задача
**наблюдаема**, и SHALL прекращать самообновление, как только она наблюдаемой
быть перестала. Наблюдаемы все нетерминальные задачи, а из терминальных — те,
которые фоновая сверка возвращает в поток сама: `failed`, `target_missing`,
`orphaned`. Задача, которую с места двигает только человек (`done`, `cancelled`,
`reverted`, `deleted`), наблюдаемой не является. Признак SHALL жить в домене
рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT
заводить.
Самообновление SHALL приносить смену состояния целиком — бейдж статуса,
заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT
сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия
этого браузера (переход воркера, действие из Telegram, фоновая сверка), MUST
становиться видимой тем же способом, пока задача наблюдаема: интерфейс не знает,
кто изменил состояние.
У одной поверхности SHALL быть **ровно один** источник самообновления. Вложенные
живые регионы (прогресс качания в карточке, секция раздачи на странице) MUST NOT
опрашивать сервер самостоятельно: своп корня уносит вложенный узел вместе с его
поллером, поэтому два опроса на одну поверхность подменяют разметку друг друга и
опрашивают одно и то же дважды.
Интервал самообновления SHALL зависеть от того, несёт ли поверхность блок живых
цифр качания: у поверхности с таким блоком интервал SHALL быть **строго меньше**,
чем у поверхности без него. Числовые значения интервалов живут в документации
проекта, не в спеке.
Тик самообновления, не сумевший прочитать задачу (записи нет, хранилище
отказало), SHALL отвечать успехом и фрагментом, который объясняет положение дел
и **не несёт** самообновления: неуспешный ответ не заменяет разметку, поэтому
поверхность осталась бы прежней, а опрос продолжался бы бесконечно.
#### Scenario: Завершение качания видно без перезагрузки
- **GIVEN** открыт список загрузок и в нём есть задача в `downloading`
- **WHEN** qBittorrent довёл раздачу до конца и воркер увёл задачу в
`recognizing` и дальше в `review`
- **THEN** карточка без перезагрузки страницы показывает бейдж ревью и кнопку
«Ревью →»
- **AND** блок живого прогресса с неё исчезает
#### Scenario: Переход, сделанный не из этого браузера
- **GIVEN** открыт список загрузок и в нём есть задача в `review`
- **WHEN** человек подтвердил план из Telegram и задача прошла `linking` в `done`
- **THEN** карточка без перезагрузки страницы показывает бейдж `done` и действия
терминальной задачи
#### Scenario: Ненаблюдаемая задача не опрашивается
- **WHEN** задача находится в `done`, `cancelled`, `reverted` или `deleted`
- **THEN** её карточка и страница `/download/{id}` не несут самообновления, и
фоновых запросов по ним не уходит
#### Scenario: Задача, оживлённая сверкой, видна без перезагрузки
- **GIVEN** открыт список, и в нём есть задача в `failed` (магнет не добрал
метаданные за отведённое время)
- **WHEN** источник ожил и фоновая сверка вернула задачу в `downloading`
- **THEN** карточка без перезагрузки страницы показывает состояние качания
#### Scenario: Один источник обновления на поверхность
- **WHEN** отрисована карточка задачи в `downloading` или страница задачи, чья
раздача сидирует
- **THEN** самообновление объявлено ровно в одном месте поверхности, а вложенные
живые регионы своего опроса не ведут
#### Scenario: Быстрее обновляется то, где есть живые цифры
- **WHEN** рядом отрисованы карточка задачи в `downloading` и карточка задачи в
`review`
- **THEN** объявленный интервал самообновления первой строго меньше, чем у второй
#### Scenario: Тик, который не смог прочитать задачу
- **GIVEN** открыта карточка наблюдаемой задачи
- **WHEN** очередной тик самообновления не нашёл записи или получил отказ
хранилища
- **THEN** ответ успешен и несёт фрагмент с объяснением
- **AND** фрагмент не несёт самообновления, поэтому опрос прекращается
#### Scenario: Группа и фильтр списка пересчитываются навигацией
- **GIVEN** открыт список и в нём есть задача в `downloading`
- **WHEN** задача дошла до терминального состояния на глазах у смотрящего
- **THEN** карточка показывает новое состояние и остаётся на своём месте в
прежней группе списка
- **AND** группа и фильтр пересчитываются при следующей навигации или
перезагрузке — список целиком самообновлением не пересобирается
## MODIFIED Requirements
### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
загрузка в `catched` SHALL относиться к **активной** группе списка.
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
Самообновление карточки и страницы в `catched` — частный случай требования
«Самообновление живой задачи»: `catched` нетерминален, поэтому интерфейс
подхватывает переход в `downloading` (бейдж, выведенное имя, появившийся живой
прогресс) без перезагрузки страницы. Отдельного правила самообновления для этой
фазы веб-UI MUST NOT иметь: фаза перестала быть единственной, где интерфейс
обновляется сам.
#### Scenario: Бейдж и группа для catched
- **WHEN** загрузка находится в состоянии `catched`
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
- **AND** загрузка попадает в активную группу списка
#### Scenario: Заголовок catched без имени
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
- **WHEN** рендерится карточка/страница загрузки
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
источник), без ошибок отображения
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
#### Scenario: Самообновление при переходе в downloading
- **GIVEN** открытая карточка загрузки в `catched`
- **WHEN** worker перевёл загрузку в `downloading`
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
(бейдж, имя, живой прогресс)
- **AND** самообновление продолжается, потому что задача осталась наблюдаемой
### Requirement: Обзор жизненного цикла в карточке списка
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
списка.
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
если неизвестно ни то, ни другое — прочерк «—».
Карточка, пришедшая **самообновлением**, SHALL показывать те же значения, что и
карточка в полном рендере списка: фоновое обновление MUST NOT подменять
известное значение прочерком.
#### Scenario: Метка идентификатора
- **WHEN** рендерится карточка загрузки в списке
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
копирования копирует именно `download.id`
#### Scenario: Дата добавления показана всегда
- **WHEN** рендерится любая карточка списка
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
относительной давностью
- **AND** если `source_added_at` неизвестно, используется `created_at`
#### Scenario: Рейтинг из живого снимка
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** в карточке показан его рейтинг отдачи
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
#### Scenario: Размер с фолбэком на разложенные файлы
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** размер раздачи в карточке берётся из общего размера торрента
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
размер берётся из суммарного размера этих файлов
#### Scenario: Самообновление не теряет размер
- **GIVEN** торрента нет в живом снимке, а файлы задачи разложены
- **WHEN** карточка пришла самообновлением, а не полным рендером списка
- **THEN** размер показан по тому же фолбэку, а не прочерком «—»
#### Scenario: Контекст не в карточке
- **WHEN** у загрузки есть переданный контекст
- **THEN** он не показывается в карточке списка, но доступен на странице
`/download/{id}`
@@ -0,0 +1,101 @@
## 1. Условие и частота самообновления
- [x] 1.1 Завести в домене (`internal/store`, рядом с `IsTerminal`) предикат
наблюдаемости: нетерминальные плюс `failed`, `target_missing`, `orphaned`;
`done`, `cancelled`, `reverted`, `deleted` — не наблюдаемы. Перечень состояний
в `httpapi` не заводить
- [x] 1.2 Завести рядом с представлениями две константы интервала — быстрый 5 с
(вровень с `[worker].poll_interval`, для поверхности с блоком живых цифр
качания) и медленный 15 с — и поле вида, которое отдаёт шаблону выбранный
интервал
- [x] 1.3 `toView` (`internal/httpapi/httpapi.go`): `SelfPoll` — по предикату
наблюдаемости, интервал — быстрый только при `IsDownloading`
- [x] 1.4 `buildDownloadView` (`internal/httpapi/download.go`): тот же предикат,
интервал всегда медленный — блока прогресса на странице нет
## 2. Шаблоны
- [x] 2.1 `card.html`: интервал самополлинга берётся из вида, а не зашит в
разметку
- [x] 2.2 `progress.html`: снять собственный `hx-get`/`hx-trigger` — партиал
остаётся вложенной разметкой карточки без своего опроса
- [x] 2.3 `download_main.html`: интервал самополлинга берётся из вида
- [x] 2.4 `seeding.html`: снять собственный `hx-get`/`hx-trigger` — секция лежит
внутри свопаемого `#download-main` и едет с тиком страницы
## 3. Самообновление не теряет полей полного рендера
- [x] 3.1 `handleFragCard` читает размер раскладки через `LayoutSizeByDownload`,
как это делает `renderCardFragment`, вместо жёсткого `layoutSize = 0`
- [x] 3.2 Отказ тика (`fragErr` и путь «записи нет») отвечает `200` и фрагментом
без `hx-*`: объяснение вместо молчаливого застывания и вечного опроса
## 4. Маршруты фрагментов
- [x] 4.1 `GET /fragments/downloads/{id}/progress` и `.../seeding` оставить
живыми: их партиалы теперь без поллера, поэтому ответ гасит разметку вкладок,
открытых до деплоя. В комментарии назвать, что потребителей в новой разметке
нет и обработчики убираются отдельной уборкой
## 5. Тесты
- [x] 5.1 Карточка наблюдаемой задачи несёт самополлинг на
`/fragments/downloads/{id}/card` (включая `failed`, `target_missing`,
`orphaned`); карточка `done`, `cancelled`, `reverted`, `deleted` — не несёт ни
`hx-get`, ни `hx-trigger`
- [x] 5.2 Фрагмент карточки после смены состояния отдаёт новый бейдж и новый
набор действий: для `review` — кнопку «Ревью →», для `done` — «Откатить»
- [x] 5.3 Карточка в `downloading` содержит ровно одно объявление самополлинга, а
вложенный блок прогресса — ни одного; страница сидирующей задачи — ровно одно,
а секция «Раздача» — ни одного
- [x] 5.4 Интервал в разметке: быстрый у карточки в `downloading`, медленный у
прочих наблюдаемых карточек и у страницы в любом состоянии
- [x] 5.5 `buildDownloadView`: `SelfPoll` истинен для наблюдаемых состояний и
ложен для остальных
- [x] 5.6 Фрагмент карточки задачи без раздачи в снимке, но с разложенными
файлами показывает размер, а не «—»
- [x] 5.7 Тик по несуществующей задаче отвечает `200` фрагментом без `hx-*`
## 6. Документация и приёмка
- [x] 6.1 `docs/database.md`: обе константы интервала в таблицу настроек с
числовым значением
- [x] 6.2 Поведенческая проверка на живом стенде: открыть список, довести
раздачу до конца и убедиться, что карточка сама показала переход
- [x] 6.3 `openspec validate --strict card-live-refresh` и `task gate` зелёные
## Критерии приёмки (из записи задачи)
- [x] К1 Карточка нетерминальной загрузки самополлится, и первый ответ фрагмента
после смены состояния несёт новый бейдж (оракул: тест `internal/httpapi`
рендер карточки в `downloading` содержит `hx-get` на
`/fragments/downloads/{id}/card`, а подставной читатель, сменивший состояние на
`review`, отдаёт карточку с бейджем ревью)
- [x] К2 Терминальная карточка себя не опрашивает: фоновых запросов после `done`,
`cancelled`, `reverted` и `deleted` нет (оракул: тот же тест — в разметке такой
карточки нет ни `hx-get`, ни `hx-trigger`). **Уточнён на чекпоинте:** `failed`,
`target_missing` и `orphaned` из перечня выведены — их возвращает в поток
фоновая сверка, поэтому они наблюдаются медленным интервалом
- [x] К3 Появившееся действие видно без перезагрузки: карточка задачи,
перешедшей в `review`, несёт кнопку «Ревью →» (оракул: тест фрагмента карточки)
- [x] К4 Страница `/download/{id}` обновляет бейдж и блок действий по тому же
правилу, что и карточка (оракул: тест `internal/httpapi/download.go`
`SelfPoll` истинен для наблюдаемых состояний и ложен для остальных)
- [x] К5 Правило записано в дельта-спеках обеих затронутых capability (оракул:
`openspec validate --strict` и шаг канона в `task gate`)
## Приёмочные критерии из рубрики (ревью дизайна)
- [x] Р-1 Стоп-условие совпадает с «дальше само ничего не изменится»: для каждого
состояния, где опрос прекращается, названо, что его не двигает ни воркер, ни
сверка
- [x] Р-2 Финальный тик доставляет новое содержимое до остановки: остановка —
свойство уже отданного фрагмента, а не отдельное решение
- [x] Р-3 Ровно один поллер на обновляемый корень; корень фрагмента-ответа несёт
тот же `id`, что и цель свопа
- [x] Р-4 Все поля одного ответа посчитаны из одного чтения: бейдж, действия,
цифры и интервал не расходятся между собой
- [x] Р-5 Отказ тика определён: что видит человек, продолжается ли опрос, на
каком уровне пишется лог
- [x] Р-6 Стоимость тика посчитана: что делает один тик и сколько запросов даёт
открытая страница и список из N карточек
+34 -8
View File
@@ -86,10 +86,21 @@ SHALL быть доступен для любой раздачи, присутс
### Requirement: Живой прогресс активных загрузок ### Requirement: Живой прогресс активных загрузок
Веб-UI SHALL обновлять прогресс активных (downloading) загрузок на главной без Веб-UI SHALL показывать прогресс, скорость и ETA активных (`downloading`)
перезагрузки страницы — поллингом фрагмента через htmx. Обновление MUST NOT загрузок на главной без перезагрузки страницы, обновляя их тем же
сбрасывать клиентские фильтр, поиск и прокрутку. Когда задача покидает самообновлением, которым обновляется сама карточка (см. `web-ui`,
состояние downloading, поллинг её прогресса SHALL прекращаться. «Самообновление живой задачи»). Обновление MUST NOT сбрасывать клиентские
фильтр, поиск и прокрутку.
Когда задача покидает состояние `downloading`, показ скорости и ETA SHALL
прекращаться: вне качания эти величины смысла не имеют. Снимок при этом
продолжает питать прочие живые значения карточки и страницы — размер и рейтинг
раздачи, — и прекращение показа цифр качания MUST NOT означать прекращения
обновления поверхности: она продолжает отражать смену состояния, пока задача
наблюдаема.
Живые цифры MUST браться из снимка воркера; при отсутствии данных по задаче
поверхность деградирует без них, не ломая остального отображения.
#### Scenario: Прогресс растёт без перезагрузки #### Scenario: Прогресс растёт без перезагрузки
@@ -99,13 +110,15 @@ SHALL быть доступен для любой раздачи, присутс
#### Scenario: Клиентское состояние сохраняется #### Scenario: Клиентское состояние сохраняется
- **WHEN** применён фильтр или поиск и происходит фоновое обновление прогресса - **WHEN** применён фильтр или поиск и происходит фоновое обновление
- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются - **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются
#### Scenario: Завершение останавливает поллинг #### Scenario: Завершение убирает цифры качания, но не обновление
- **WHEN** загрузка переходит из downloading в другое состояние - **WHEN** загрузка переходит из `downloading` в другое наблюдаемое состояние
- **THEN** фоновый поллинг прогресса для этой карточки прекращается - **THEN** блок прогресса, скорости и ETA с карточки исчезает
- **AND** карточка продолжает обновляться и приносит новое состояние
- **AND** размер и рейтинг раздачи по-прежнему берутся из снимка
### Requirement: Секция раздачи на странице загрузки ### Requirement: Секция раздачи на странице загрузки
@@ -114,6 +127,12 @@ SHALL быть доступен для любой раздачи, присутс
чей торрент сидирует. Если живых данных по задаче нет, секция SHALL чей торрент сидирует. Если живых данных по задаче нет, секция SHALL
отсутствовать либо явно показывать «нет данных», не ломая остальную страницу. отсутствовать либо явно показывать «нет данных», не ломая остальную страницу.
Секция MUST NOT опрашивать сервер самостоятельно: она лежит внутри области,
которую страница обновляет целиком, и собственный опрос секции подменял бы
разметку страницы. Её цифры SHALL приходить с тиком самообновления страницы
(см. `web-ui`, «Самообновление живой задачи»), а частота их обновления
SHALL совпадать с частотой обновления страницы.
#### Scenario: Сидирующая задача показывает раздачу #### Scenario: Сидирующая задача показывает раздачу
- **WHEN** открыта страница задачи, торрент которой раздаётся - **WHEN** открыта страница задачи, торрент которой раздаётся
@@ -125,3 +144,10 @@ SHALL быть доступен для любой раздачи, присутс
- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а - **THEN** секция «Раздача» отсутствует или показывает «нет данных», а
распознавание, файлы и история отображаются нормально распознавание, файлы и история отображаются нормально
#### Scenario: Секция обновляется тиком страницы
- **GIVEN** открыта страница наблюдаемой задачи, чья раздача сидирует
- **WHEN** страница отрисована
- **THEN** секция «Раздача» не несёт собственного опроса
- **AND** её цифры обновляются вместе с остальной страницей
+110 -6
View File
@@ -339,6 +339,10 @@ MUST NOT показываться в карточке списка — он до
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки; отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
если неизвестно ни то, ни другое — прочерк «—». если неизвестно ни то, ни другое — прочерк «—».
Карточка, пришедшая **самообновлением**, SHALL показывать те же значения, что и
карточка в полном рендере списка: фоновое обновление MUST NOT подменять
известное значение прочерком.
#### Scenario: Метка идентификатора #### Scenario: Метка идентификатора
- **WHEN** рендерится карточка загрузки в списке - **WHEN** рендерится карточка загрузки в списке
@@ -365,6 +369,12 @@ MUST NOT показываться в карточке списка — он до
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы — - **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
размер берётся из суммарного размера этих файлов размер берётся из суммарного размера этих файлов
#### Scenario: Самообновление не теряет размер
- **GIVEN** торрента нет в живом снимке, а файлы задачи разложены
- **WHEN** карточка пришла самообновлением, а не полным рендером списка
- **THEN** размер показан по тому же фолбэку, а не прочерком «—»
#### Scenario: Контекст не в карточке #### Scenario: Контекст не в карточке
- **WHEN** у загрузки есть переданный контекст - **WHEN** у загрузки есть переданный контекст
@@ -470,6 +480,99 @@ PRG-редиректом, и действие исполняется тем же
- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до - **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до
следующей полной загрузки списка, без клиентского переупорядочивания следующей полной загрузки списка, без клиентского переупорядочивания
### Requirement: Самообновление живой задачи
Карточка списка и страница `/download/{id}` SHALL самообновляться, пока задача
**наблюдаема**, и SHALL прекращать самообновление, как только она наблюдаемой
быть перестала. Наблюдаемы все нетерминальные задачи, а из терминальных — те,
которые фоновая сверка возвращает в поток сама: `failed`, `target_missing`,
`orphaned`. Задача, которую с места двигает только человек (`done`, `cancelled`,
`reverted`, `deleted`), наблюдаемой не является. Признак SHALL жить в домене
рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT
заводить.
Самообновление SHALL приносить смену состояния целиком — бейдж статуса,
заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT
сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия
этого браузера (переход воркера, действие из Telegram, фоновая сверка), MUST
становиться видимой тем же способом, пока задача наблюдаема: интерфейс не знает,
кто изменил состояние.
У одной поверхности SHALL быть **ровно один** источник самообновления. Вложенные
живые регионы (прогресс качания в карточке, секция раздачи на странице) MUST NOT
опрашивать сервер самостоятельно: своп корня уносит вложенный узел вместе с его
поллером, поэтому два опроса на одну поверхность подменяют разметку друг друга и
опрашивают одно и то же дважды.
Интервал самообновления SHALL зависеть от того, несёт ли поверхность блок живых
цифр качания: у поверхности с таким блоком интервал SHALL быть **строго меньше**,
чем у поверхности без него. Числовые значения интервалов живут в документации
проекта, не в спеке.
Тик самообновления, не сумевший прочитать задачу (записи нет, хранилище
отказало), SHALL отвечать успехом и фрагментом, который объясняет положение дел
и **не несёт** самообновления: неуспешный ответ не заменяет разметку, поэтому
поверхность осталась бы прежней, а опрос продолжался бы бесконечно.
#### Scenario: Завершение качания видно без перезагрузки
- **GIVEN** открыт список загрузок и в нём есть задача в `downloading`
- **WHEN** qBittorrent довёл раздачу до конца и воркер увёл задачу в
`recognizing` и дальше в `review`
- **THEN** карточка без перезагрузки страницы показывает бейдж ревью и кнопку
«Ревью →»
- **AND** блок живого прогресса с неё исчезает
#### Scenario: Переход, сделанный не из этого браузера
- **GIVEN** открыт список загрузок и в нём есть задача в `review`
- **WHEN** человек подтвердил план из Telegram и задача прошла `linking` в `done`
- **THEN** карточка без перезагрузки страницы показывает бейдж `done` и действия
терминальной задачи
#### Scenario: Ненаблюдаемая задача не опрашивается
- **WHEN** задача находится в `done`, `cancelled`, `reverted` или `deleted`
- **THEN** её карточка и страница `/download/{id}` не несут самообновления, и
фоновых запросов по ним не уходит
#### Scenario: Задача, оживлённая сверкой, видна без перезагрузки
- **GIVEN** открыт список, и в нём есть задача в `failed` (магнет не добрал
метаданные за отведённое время)
- **WHEN** источник ожил и фоновая сверка вернула задачу в `downloading`
- **THEN** карточка без перезагрузки страницы показывает состояние качания
#### Scenario: Один источник обновления на поверхность
- **WHEN** отрисована карточка задачи в `downloading` или страница задачи, чья
раздача сидирует
- **THEN** самообновление объявлено ровно в одном месте поверхности, а вложенные
живые регионы своего опроса не ведут
#### Scenario: Быстрее обновляется то, где есть живые цифры
- **WHEN** рядом отрисованы карточка задачи в `downloading` и карточка задачи в
`review`
- **THEN** объявленный интервал самообновления первой строго меньше, чем у второй
#### Scenario: Тик, который не смог прочитать задачу
- **GIVEN** открыта карточка наблюдаемой задачи
- **WHEN** очередной тик самообновления не нашёл записи или получил отказ
хранилища
- **THEN** ответ успешен и несёт фрагмент с объяснением
- **AND** фрагмент не несёт самообновления, поэтому опрос прекращается
#### Scenario: Группа и фильтр списка пересчитываются навигацией
- **GIVEN** открыт список и в нём есть задача в `downloading`
- **WHEN** задача дошла до терминального состояния на глазах у смотрящего
- **THEN** карточка показывает новое состояние и остаётся на своём месте в
прежней группе списка
- **AND** группа и фильтр пересчитываются при следующей навигации или
перезагрузке — список целиком самообновлением не пересобирается
### Requirement: Отображение промежуточного состояния catched ### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
@@ -483,11 +586,12 @@ PRG-редиректом, и действие исполняется тем же
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения. отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся Самообновление карточки и страницы в `catched` — частный случай требования
htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading` «Самообновление живой задачи»: `catched` нетерминален, поэтому интерфейс
интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж, подхватывает переход в `downloading` (бейдж, выведенное имя, появившийся живой
выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL прогресс) без перезагрузки страницы. Отдельного правила самообновления для этой
завершаться, как только загрузка её покинула. фазы веб-UI MUST NOT иметь: фаза перестала быть единственной, где интерфейс
обновляется сам.
#### Scenario: Бейдж и группа для catched #### Scenario: Бейдж и группа для catched
@@ -509,7 +613,7 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо
- **WHEN** worker перевёл загрузку в `downloading` - **WHEN** worker перевёл загрузку в `downloading`
- **THEN** интерфейс без перезагрузки показывает состояние `downloading` - **THEN** интерфейс без перезагрузки показывает состояние `downloading`
(бейдж, имя, живой прогресс) (бейдж, имя, живой прогресс)
- **AND** поллинг фазы `catched` завершается - **AND** самообновление продолжается, потому что задача осталась наблюдаемой
### Requirement: Загрузка .torrent-файла на форме добавления ### Requirement: Загрузка .torrent-файла на форме добавления
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "card"}} {{define "card"}}
<article class="card" id="card-{{.ID}}"{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}> <article class="card" id="card-{{.ID}}"{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card" hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
<div class="card-top"> <div class="card-top">
<div> <div>
<div class="card-title">{{if eq .MediaType "series"}}<span class="type-ico" title="сериал">📺</span> {{else if eq .MediaType "movie"}}<span class="type-ico" title="фильм">🎬</span> {{end}}<a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div> <div class="card-title">{{if eq .MediaType "series"}}<span class="type-ico" title="сериал">📺</span> {{else if eq .MediaType "movie"}}<span class="type-ico" title="фильм">🎬</span> {{end}}<a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
+5 -2
View File
@@ -1,5 +1,5 @@
{{define "download_main"}} {{define "download_main"}}
<main class="main" id="download-main"{{if .SelfPoll}} hx-get="/download/{{.ID}}" hx-trigger="every 3s" hx-select="#download-main" hx-swap="outerHTML"{{end}}> <main class="main" id="download-main"{{if .SelfPoll}} hx-get="/download/{{.ID}}" hx-trigger="every {{.PollEvery}}" hx-select="#download-main" hx-swap="outerHTML"{{end}}>
<a class="back-link" href="/">← ко всем загрузкам</a> <a class="back-link" href="/">← ко всем загрузкам</a>
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}} {{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
@@ -97,7 +97,10 @@
С JS hx-confirm показывает диалог подтверждения; без JS гейт — только С JS hx-confirm показывает диалог подтверждения; без JS гейт — только
раскрытие details и явный submit (диалога нет). --> раскрытие details и явный submit (диалога нет). -->
{{if or .Dismissable .Deletable}} {{if or .Dismissable .Deletable}}
<details class="section danger-zone" style="margin-bottom:0;border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"> <!-- hx-preserve: страница самообновляется тиком и свопает #download-main целиком;
без этого раскрытая «Опасная зона» захлопывалась бы у человека под курсором
ровно там, где он читает текст про необратимое удаление раздачи с файлами. -->
<details id="danger-zone-{{.ID}}" hx-preserve class="section danger-zone" style="margin-bottom:0;border-color:color-mix(in srgb,var(--st-err) 35%,transparent)">
<summary style="color:var(--st-err);cursor:pointer;font-weight:600">Опасная зона</summary> <summary style="color:var(--st-err);cursor:pointer;font-weight:600">Опасная зона</summary>
{{if .Dismissable}} {{if .Dismissable}}
<p class="muted" style="margin:var(--sp-3) 0"> <p class="muted" style="margin:var(--sp-3) 0">
+3
View File
@@ -0,0 +1,3 @@
{{define "frag_note"}}<div class="card" id="{{.RootID}}">
<div class="card-foot"><span class="card-meta">{{.Text}}</span></div>
</div>{{end}}
+1 -1
View File
@@ -1,4 +1,4 @@
{{define "progress"}}<div class="dl-live" id="dl-live-{{.ID}}"{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>{{if .Active}} {{define "progress"}}<div class="dl-live" id="dl-live-{{.ID}}">{{if .Active}}
<div class="progress"><i style="width:{{.Percent}}%"></i></div> <div class="progress"><i style="width:{{.Percent}}%"></i></div>
{{if .Has}}<div class="dl-stats">{{.Percent}}% · ↓ {{.DlSpeed}}{{if .ETA}} · осталось {{.ETA}}{{end}}</div>{{end}} {{if .Has}}<div class="dl-stats">{{.Percent}}% · ↓ {{.DlSpeed}}{{if .ETA}} · осталось {{.ETA}}{{end}}</div>{{end}}
{{end}}</div>{{end}} {{end}}</div>{{end}}
+1 -1
View File
@@ -1,4 +1,4 @@
{{define "seeding"}}{{if .Has}}<div class="section" id="seeding-{{.ID}}" hx-get="/fragments/downloads/{{.ID}}/seeding" hx-trigger="every 3s" hx-swap="outerHTML"> {{define "seeding"}}{{if .Has}}<div class="section" id="seeding-{{.ID}}">
<div class="section-head"><h2>Раздача</h2><span class="hint">qBittorrent · источник продолжает раздаваться</span></div> <div class="section-head"><h2>Раздача</h2><span class="hint">qBittorrent · источник продолжает раздаваться</span></div>
<div class="stat-row"> <div class="stat-row">
<div class="stat"><div class="v good">{{.Percent}}%</div><div class="k">скачано</div></div> <div class="stat"><div class="v good">{{.Percent}}%</div><div class="k">скачано</div></div>