From a5d873b62dd2a782c3bd3b4d8e69aaf37d8a6978 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 10 Aug 2026 14:02:38 +0300 Subject: [PATCH] =?UTF-8?q?web-ui:=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D1=8F=D1=8E?= =?UTF-8?q?=D1=82=D1=81=D1=8F,=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D1=83=20=D0=BC=D0=BE=D0=B6=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D1=82=D1=8C=20=D1=84=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI --- ...-08-10-observability-is-not-terminality.md | 56 ++++ docs/adr/README.md | 1 + docs/architecture.md | 3 +- docs/conventions/web-ui.md | 49 +++- docs/database.md | 2 + docs/review.md | 19 ++ internal/httpapi/action_swap_test.go | 42 ++- internal/httpapi/download.go | 17 +- internal/httpapi/httpapi.go | 32 ++- internal/httpapi/live.go | 120 ++++++-- internal/httpapi/live_test.go | 263 ++++++++++++++++-- internal/httpapi/render_test.go | 24 +- internal/httpapi/review.go | 2 +- internal/store/download.go | 20 ++ internal/store/download_test.go | 30 ++ .../.openspec.yaml | 2 + .../2026-08-10-card-live-refresh/design.md | 228 +++++++++++++++ .../2026-08-10-card-live-refresh/proposal.md | 66 +++++ .../review/report.md | 155 +++++++++++ .../specs/live-status/spec.md | 68 +++++ .../specs/web-ui/spec.md | 200 +++++++++++++ .../2026-08-10-card-live-refresh/tasks.md | 101 +++++++ openspec/specs/live-status/spec.md | 42 ++- openspec/specs/web-ui/spec.md | 116 +++++++- web/templates/partials/card.html | 2 +- web/templates/partials/download_main.html | 7 +- web/templates/partials/frag_note.html | 3 + web/templates/partials/progress.html | 2 +- web/templates/partials/seeding.html | 2 +- 29 files changed, 1575 insertions(+), 99 deletions(-) create mode 100644 docs/adr/ADR-2026-08-10-observability-is-not-terminality.md create mode 100644 openspec/changes/archive/2026-08-10-card-live-refresh/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-10-card-live-refresh/design.md create mode 100644 openspec/changes/archive/2026-08-10-card-live-refresh/proposal.md create mode 100644 openspec/changes/archive/2026-08-10-card-live-refresh/review/report.md create mode 100644 openspec/changes/archive/2026-08-10-card-live-refresh/specs/live-status/spec.md create mode 100644 openspec/changes/archive/2026-08-10-card-live-refresh/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-08-10-card-live-refresh/tasks.md create mode 100644 web/templates/partials/frag_note.html diff --git a/docs/adr/ADR-2026-08-10-observability-is-not-terminality.md b/docs/adr/ADR-2026-08-10-observability-is-not-terminality.md new file mode 100644 index 0000000..5e1c882 --- /dev/null +++ b/docs/adr/ADR-2026-08-10-observability-is-not-terminality.md @@ -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` опрашивает сервер, пока + открыта вкладка: эти состояния живут долго и копятся (срока хранения нет). diff --git a/docs/adr/README.md b/docs/adr/README.md index 80d0e8a..71de7d7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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-sanitize-at-every-entry.md) | — | | 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — | diff --git a/docs/architecture.md b/docs/architecture.md index 01e76af..c1ac0d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,6 +118,7 @@ | Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | | Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью | | Причина, по которой человек не видит плана | считается **на показе** (`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) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | | Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям | @@ -185,7 +186,7 @@ Jellyfin указывают на `movies`/`series`, а не на корень | Масштаб | ориентир 100/1000 загрузок не зафиксирован, узкие места SQLite, воркера и поллинга не измерены | `scale-100-downloads` | | Ретеншен | терминальные задачи и сырые ответы LLM копятся вечно, авточистки нет | `db-retention-cleanup` | | Бекап | бекапить `/data` требуется, а стратегия и ротация не описаны | `sqlite-backup` | -| Наблюдаемость | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | `deep-healthcheck-dependencies` | +| Метрики и алертинг | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | `deep-healthcheck-dependencies` | | Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` | | Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` | | История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` | diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index 3e678d6..c41cded 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -103,27 +103,48 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx** ## Живой поллинг Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке -`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"` (эталон — -`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`): +`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. Эталон — карточка +списка (`card`, `handleFragCard`): ```html -{{define "progress"}}
+{{define "card"}}
... -
{{end}} +{{end}} ``` -- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active` - ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx - больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading` - для прогресса), а не qBittorrent. +- **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности + (карточка списка, главная область страницы), а вложенные живые регионы — + прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня + уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга. + Живые цифры приезжают вместе с корнем. +- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без + участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx + больше не опрашивает. Условие определяется store-состоянием + (`State.IsObservable()`), а не qBittorrent. +- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает + `200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`, + поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос — + бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он + собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел. +- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай; + `ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)). - **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его поллером и htmx `process`-инициализирует новый — двойного опроса нет **при - условии совпадения корневого `id`** (см. инвариант выше). -- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без - БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа - доставки (поллинг сейчас, путь к SSE оставлен изолированным). + условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние + разметки своп не переживает: то, что должно пережить тик (раскрытый + `
`), помечается `hx-preserve`. +- **Частота — по цене тика, и она названа числом в + [database.md](../database.md).** Поверхность с живыми цифрами качания + обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее + источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`). +- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из in-memory + снимка воркера (`LiveStatus.Live(infohash)`), но состояние и размер раскладки + тик читает из хранилища, а тик страницы загрузки ещё и считает предпросмотр + раскладки с обходом ФС — отсюда и разные интервалы. Узкий контракт + `LiveStatus` при этом не зависит от способа доставки (поллинг сейчас, путь к + SSE оставлен изолированным). - **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер, который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое, см. [logging.md](logging.md)). diff --git a/docs/database.md b/docs/database.md index d15dcee..c311170 100644 --- a/docs/database.md +++ b/docs/database.md @@ -212,6 +212,8 @@ erDiagram | Константа | Значение | Что означает | | --- | --- | --- | | `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`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой | **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет, diff --git a/docs/review.md b/docs/review.md index bda5836..b7c08ac 100644 --- a/docs/review.md +++ b/docs/review.md @@ -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 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман] - **Где:** `internal/layout/layout.go` — `BuildLinks`; `internal/worker/review.go` diff --git a/internal/httpapi/action_swap_test.go b/internal/httpapi/action_swap_test.go index 4f5a260..e6a87fb 100644 --- a/internal/httpapi/action_swap_test.go +++ b/internal/httpapi/action_swap_test.go @@ -2,6 +2,7 @@ package httpapi import ( "context" + "errors" "log/slog" "net/http" "net/http/httptest" @@ -337,8 +338,9 @@ func TestSourceSwapUpdatesActionBarOOB(t *testing.T) { }) } -// TestRetryListShowsProgress: retry из списка → карточка downloading с -// прогресс-поллером. +// TestRetryListShowsProgress: retry из списка → карточка downloading с живым +// прогрессом и самообновлением карточки (опрашивает себя карточка, а не +// вложенный блок прогресса). func TestRetryListShowsProgress(t *testing.T) { dl := dlState(store.StateDownloading) 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 { t.Fatalf("retry (htmx) = %d, want 200", rr.Code) } - if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") { - t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String()) + body := 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) + } + } +} diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 1d2e8dd..15d26a1 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -21,7 +21,8 @@ type downloadDetailView struct { Infohashes []string // все хеши загрузки (блок «Информация о торренте») Context string State string - SelfPoll bool // catched → страница сама опрашивает себя до перехода + SelfPoll bool // задача наблюдаема → страница сама опрашивает себя + PollEvery string Error string ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg 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) 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) { http.Error(w, "задача не найдена", http.StatusNotFound) return @@ -113,7 +123,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet Infohashes: d.HashList(), Context: d.Context, State: string(d.State), - SelfPoll: d.State == store.StateCatched, + SelfPoll: d.State.IsObservable(), + // Блока живых цифр качания на странице нет вовсе, а тик считает + // предпросмотр раскладки и ходит в ФС — интервал всегда медленный. + PollEvery: pollSlow, Error: d.ErrorMsg.String, Note: desyncNote(d.State), CreatedAt: d.CreatedAt, diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 8b6e930..13a6860 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -114,11 +114,12 @@ func NewRouter(d Deps) (http.Handler, error) { r.Get("/", s.handleIndex) r.Get("/download/{id}", s.handleDownload) - // Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). + // Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями + // вкладок, отрисованных прошлой версией (см. handleFragProgress). r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) - // Карточка целиком: самополлинг catched до перехода в downloading (бейдж, - // имя и появившийся прогресс обновляются без перезагрузки). + // Карточка целиком — тик самообновления списка: пока задача наблюдаема, + // карточка приносит текущее состояние без перезагрузки страницы. r.Get("/fragments/downloads/{id}/card", s.handleFragCard) // Тело ревью для поллинга recognizing (htmx-своп до готового плана). r.Get("/fragments/downloads/{id}/review", s.handleFragReview) @@ -205,8 +206,9 @@ type downloadView struct { State string Error string Terminal bool - IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг - SelfPoll bool // catched → карточка сама опрашивает себя до перехода + IsDownloading bool // активная загрузка → живой прогресс-бар + SelfPoll bool // задача наблюдаема → карточка сама опрашивает себя + PollEvery string // интервал самообновления карточки (pollFast/pollSlow) Progress progressView // живой прогресс (заполняется в handleIndex из снимка) Reviewable bool // review/deferred — есть экран ревью Undoable bool // done — можно откатить раскладку @@ -311,10 +313,11 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { } // buildCardView собирает представление карточки списка из доменных данных и -// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки -// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку -// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести -// прогресс-поллер — поэтому Progress заполняется здесь. +// живого снимка. Общий для полной страницы (handleIndex), тика самообновления +// (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы +// htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут +// вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress +// заполняется здесь на каждом пути. func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView { v := s.toView(d, now) // Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг @@ -494,7 +497,7 @@ func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { - s.fragErr(w, err, id) + s.fragActionErr(w, err, id, "card-"+id) return } sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) @@ -514,7 +517,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) if err != nil { - s.fragErr(w, err, id) + s.fragActionErr(w, err, id, "download-main") return } v := s.buildDownloadView(id, rd) @@ -661,7 +664,8 @@ func (s *server) toView(d store.Download, now time.Time) downloadView { Error: d.ErrorMsg.String, Terminal: d.State.IsTerminal(), IsDownloading: d.State == store.StateDownloading, - SelfPoll: d.State == store.StateCatched, + SelfPoll: d.State.IsObservable(), + PollEvery: pollSlow, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || @@ -669,6 +673,10 @@ func (s *server) toView(d store.Download, now time.Time) downloadView { Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Note: desyncNote(d.State), } + // Быстрый интервал — только там, где на поверхности бегут цифры качания. + if v.IsDownloading { + v.PollEvery = pollFast + } // Дата добавления в карточке — всегда (source_added_at → фолбэк created_at, // как в порядке списка); неразбираемое время просто опускаем. if t, ok := addedTime(d); ok { diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go index 7b9cab5..0a398c2 100644 --- a/internal/httpapi/live.go +++ b/internal/httpapi/live.go @@ -18,27 +18,43 @@ type LiveStatus interface { Live(infohash string) (worker.Live, bool) } +// Интервалы самообновления поверхностей (значение hx-trigger="every …"). +// +// - pollFast — поверхность с живыми цифрами качания (карточка в downloading). +// Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и +// опрашивать чаще значит возвращать тот же кадр (docs/database.md). +// - pollSlow — все прочие наблюдаемые поверхности, включая страницу +// /download/{id} в любом состоянии: там меняется только состояние, а сборка +// страницы считает предпросмотр раскладки и ходит в ФС. +const ( + pollFast = "5s" + pollSlow = "15s" +) + // noLive — заглушка на случай, когда источник телеметрии не подключён // (Deps.Live == nil): живых данных нет, UI деградирует штатно. type noLive struct{} func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false } -// progressView — живой прогресс активной загрузки (для карточки и фрагмента -// /progress). Active управляется store-состоянием (downloading), а не qbt: -// когда задача покидает downloading, фрагмент возвращается без поллинга. +// progressView — живой прогресс активной загрузки (вложенный блок карточки). +// Active управляется store-состоянием (downloading), а не qbt: вне downloading +// скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не +// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой +// задачи»). type progressView struct { ID string - Active bool // store-состояние downloading → показываем бар и поллим + Active bool // store-состояние downloading → показываем бар Has bool // есть данные снимка Percent int DlSpeed string ETA string } -// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding). +// seedingView — живая статистика раздачи (секция страницы загрузки). // Has истинно только если торрент сидирует и данные есть — иначе секция -// деградирует (пустой контейнер, поллинг прекращается). +// деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит +// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы. type seedingView struct { ID string Has bool @@ -79,7 +95,13 @@ func buildSeeding(id string, l worker.Live, ok bool) seedingView { return v } -// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг). +// handleFragProgress отдаёт партиал живого прогресса карточки. +// +// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком +// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией: +// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут +// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит +// её первым же тиком. Убирается отдельной уборкой после деплоя. func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { @@ -88,7 +110,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) { } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { - s.fragErr(w, err, id) + s.fragTickErr(w, err, id, "dl-live-"+id) return } active := d.State == store.StateDownloading @@ -96,10 +118,11 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) { s.render(w, "progress", buildProgress(id, active, l, ok)) } -// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched): -// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading -// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая -// карточка уже не несёт самополлинга — цикл завершается сам. +// handleFragCard отдаёт карточку списка целиком — это тик её самообновления. +// Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на +// каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор +// действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не +// несёт самополлинга, и цикл завершается сам. func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { @@ -108,15 +131,25 @@ func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) { } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { - s.fragErr(w, err, id) + s.fragTickErr(w, err, id, "card-"+id) return } - // layoutSize 0: у catched раскладки нет; в downloading размер берётся из - // живого снимка внутри buildCardView. - s.render(w, "card", s.buildCardView(*d, store.Now(), 0)) + // Размер читаем так же, как своповый путь действия: самообновление + // обслуживает и состояния с разложенными файлами, и подмена известного + // размера прочерком была бы потерей поля полного рендера. + sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) + if err != nil { + // WARN, а не ERROR: тик повторится сам (docs/conventions/logging.md). + s.deps.Logger.Warn("layout sizes", "download_id", id, "error", err) + sizes = nil // деградируем: размер уедет в фолбэк, тик не падает + } + s.render(w, "card", s.buildCardView(*d, store.Now(), sizes[id])) } -// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг). +// handleFragSeeding отдаёт партиал секции «Раздача». +// +// Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком +// страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress. func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { @@ -125,22 +158,57 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) { } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { - s.fragErr(w, err, id) + s.fragTickErr(w, err, id, "seeding-"+id) return } l, ok := s.liveFor(*d) s.render(w, "seeding", buildSeeding(id, l, ok)) } -// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound → -// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе). -func (s *server) fragErr(w http.ResponseWriter, err error, id string) { - if errors.Is(err, store.ErrNotFound) { - http.Error(w, "не найдено", http.StatusNotFound) - return +// fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и +// фрагмент, который объясняет положение дел и НЕ несёт самообновления. +// +// Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому +// поверхность осталась бы прежней навсегда (человек не отличит «ничего не +// изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно — +// при затяжном отказе хранилища это поток записей в журнал с каждой открытой +// вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой +// задачи»). +// +// Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит +// (docs/conventions/logging.md, «Ошибки»). +func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) { + s.fragNote(w, err, id, rootID, false) +} + +// fragActionErr — отказ чтения на разовом действии человека: тот же +// самозавершающийся фрагмент, но ERROR: ретрая у действия нет. +func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) { + s.fragNote(w, err, id, rootID, true) +} + +// fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и +// приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id +// снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы +// (docs/conventions/web-ui.md, «Единый источник разметки»). +func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) { + text := "задача не найдена — обновите страницу" + if !errors.Is(err, store.ErrNotFound) { + if oneShot { + s.deps.Logger.Error("live fragment", "download_id", id, "error", err) + } else { + s.deps.Logger.Warn("live fragment", "download_id", id, "error", err) + } + text = "не удалось обновить — обновите страницу" } - s.deps.Logger.Error("live fragment", "download_id", id, "error", err) - http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) + s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text}) +} + +// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID — +// id узла, который фрагмент собой заменяет. +type fragNoteView struct { + RootID string + Text string } // --- форматирование телеметрии --- diff --git a/internal/httpapi/live_test.go b/internal/httpapi/live_test.go index 08c79c0..992fa5a 100644 --- a/internal/httpapi/live_test.go +++ b/internal/httpapi/live_test.go @@ -1,6 +1,7 @@ package httpapi import ( + "errors" "net/http" "strings" "testing" @@ -9,8 +10,8 @@ import ( "git.vakhrushev.me/av/jellybit/internal/worker" ) -// TestFragProgressDownloading: активная задача → фрагмент с прогрессом, -// значениями снимка и атрибутами htmx-поллинга. +// TestFragProgressDownloading: маршрут прогресса остался гасителем старых +// вкладок — отдаёт цифры снимка и НЕ несёт собственного опроса. func TestFragProgressDownloading(t *testing.T) { 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}}} @@ -21,25 +22,80 @@ func TestFragProgressDownloading(t *testing.T) { t.Fatalf("status = %d, want 200", rr.Code) } 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) { t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body) } } + if strings.Contains(body, "hx-trigger") { + t.Errorf("партиал прогресса всё ещё опрашивает сервер сам:\n%s", body) + } } -// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading, -// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается). -func TestFragProgressStopsWhenNotDownloading(t *testing.T) { +// TestProgressBlockHiddenOutsideDownloading: вне downloading блок живых цифр не +// рисуется вовсе — скорость и ETA там смысла не имеют. Проверка на отсутствие +// 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} - 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") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } - if body := rr.Body.String(); strings.Contains(body, "hx-trigger") { - t.Errorf("завершённая задача всё ещё поллит:\n%s", body) + body := rr.Body.String() + 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) } 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) { t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body) } } + // Секция лежит внутри свопаемой области страницы — своего опроса не ведёт. + if strings.Contains(body, "hx-trigger") { + t.Errorf("секция раздачи всё ещё опрашивает сервер сама:\n%s", body) + } } // TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет. @@ -80,7 +140,8 @@ func TestFragSeedingDegrades(t *testing.T) { } // TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже -// в первом кадре (значения снимка) и атрибуты поллинга. +// в первом кадре (значения снимка), а опрашивает себя сама карточка — один +// поллер на поверхность, во вложенном блоке прогресса его нет. 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} 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) } 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) { 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. -func TestFragNotFound(t *testing.T) { +// TestFragTickOnMissingDownload: тик по исчезнувшей задаче отвечает 200 и +// фрагментом без hx-* — htmx не свопит 4xx/5xx, поэтому отказ статусом оставил +// бы карточку прежней навсегда, а опрос — бесконечным. +func TestFragTickOnMissingDownload(t *testing.T) { 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 { 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) + } +} diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index de05609..cd226f5 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -15,9 +15,11 @@ import ( // stubReader — минимальный Reader для проверки рендера списка. type stubReader struct { - list []store.Download - one *store.Download - sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк) + list []store.Download + one *store.Download + sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк) + getErr error // отказ чтения задачи (не ErrNotFound) + sizesErr error // отказ чтения размеров раскладки } 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 } func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) { + if s.getErr != nil { + return nil, s.getErr + } if s.one == nil { return nil, store.ErrNotFound } return s.one, nil } func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) { + if s.sizesErr != nil { + return nil, s.sizesErr + } return s.sizes, nil } @@ -94,6 +102,16 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { 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 валидирует формат). const testULID = "01arz3ndektsv4rrffq69g5fav" diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 19a3a29..d51e252 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -455,7 +455,7 @@ func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) { } rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) if err != nil { - s.fragErr(w, err, id) + s.fragTickErr(w, err, id, "review-main") return } s.render(w, "review_main", buildReviewView(id, rd, "")) diff --git a/internal/store/download.go b/internal/store/download.go index 50c9ae1..4918f51 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -64,6 +64,26 @@ func (s State) IsTerminal() bool { 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 — декларативный граф легальных переходов машины состояний // (from → множество допустимых to). Единственный источник истины о легальности // рёбер: покрывает все переходы, которые worker выполняет по всем capability diff --git a/internal/store/download_test.go b/internal/store/download_test.go index ed05d16..33b982c 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -653,3 +653,33 @@ func TestListAndByState(t *testing.T) { 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 должно быть терминальным и при этом наблюдаемым") + } +} diff --git a/openspec/changes/archive/2026-08-10-card-live-refresh/.openspec.yaml b/openspec/changes/archive/2026-08-10-card-live-refresh/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-card-live-refresh/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-10-card-live-refresh/design.md b/openspec/changes/archive/2026-08-10-card-live-refresh/design.md new file mode 100644 index 0000000..728c29a --- /dev/null +++ b/openspec/changes/archive/2026-08-10-card-live-refresh/design.md @@ -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 с. diff --git a/openspec/changes/archive/2026-08-10-card-live-refresh/proposal.md b/openspec/changes/archive/2026-08-10-card-live-refresh/proposal.md new file mode 100644 index 0000000..966c81c --- /dev/null +++ b/openspec/changes/archive/2026-08-10-card-live-refresh/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-08-10-card-live-refresh/review/report.md b/openspec/changes/archive/2026-08-10-card-live-refresh/review/report.md new file mode 100644 index 0000000..8648480 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-card-live-refresh/review/report.md @@ -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` отказе отдал + `
`), плюс `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` сделаны из + разметки и текста конвенции. diff --git a/openspec/changes/archive/2026-08-10-card-live-refresh/specs/live-status/spec.md b/openspec/changes/archive/2026-08-10-card-live-refresh/specs/live-status/spec.md new file mode 100644 index 0000000..23e7966 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-card-live-refresh/specs/live-status/spec.md @@ -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** её цифры обновляются вместе с остальной страницей diff --git a/openspec/changes/archive/2026-08-10-card-live-refresh/specs/web-ui/spec.md b/openspec/changes/archive/2026-08-10-card-live-refresh/specs/web-ui/spec.md new file mode 100644 index 0000000..acd3300 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-card-live-refresh/specs/web-ui/spec.md @@ -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}` diff --git a/openspec/changes/archive/2026-08-10-card-live-refresh/tasks.md b/openspec/changes/archive/2026-08-10-card-live-refresh/tasks.md new file mode 100644 index 0000000..4da404d --- /dev/null +++ b/openspec/changes/archive/2026-08-10-card-live-refresh/tasks.md @@ -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 карточек diff --git a/openspec/specs/live-status/spec.md b/openspec/specs/live-status/spec.md index 270a618..7520b05 100644 --- a/openspec/specs/live-status/spec.md +++ b/openspec/specs/live-status/spec.md @@ -86,10 +86,21 @@ SHALL быть доступен для любой раздачи, присутс ### Requirement: Живой прогресс активных загрузок -Веб-UI SHALL обновлять прогресс активных (downloading) загрузок на главной без -перезагрузки страницы — поллингом фрагмента через htmx. Обновление MUST NOT -сбрасывать клиентские фильтр, поиск и прокрутку. Когда задача покидает -состояние downloading, поллинг её прогресса SHALL прекращаться. +Веб-UI SHALL показывать прогресс, скорость и ETA активных (`downloading`) +загрузок на главной без перезагрузки страницы, обновляя их тем же +самообновлением, которым обновляется сама карточка (см. `web-ui`, +«Самообновление живой задачи»). Обновление MUST NOT сбрасывать клиентские +фильтр, поиск и прокрутку. + +Когда задача покидает состояние `downloading`, показ скорости и ETA SHALL +прекращаться: вне качания эти величины смысла не имеют. Снимок при этом +продолжает питать прочие живые значения карточки и страницы — размер и рейтинг +раздачи, — и прекращение показа цифр качания MUST NOT означать прекращения +обновления поверхности: она продолжает отражать смену состояния, пока задача +наблюдаема. + +Живые цифры MUST браться из снимка воркера; при отсутствии данных по задаче +поверхность деградирует без них, не ломая остального отображения. #### Scenario: Прогресс растёт без перезагрузки @@ -99,13 +110,15 @@ SHALL быть доступен для любой раздачи, присутс #### Scenario: Клиентское состояние сохраняется -- **WHEN** применён фильтр или поиск и происходит фоновое обновление прогресса +- **WHEN** применён фильтр или поиск и происходит фоновое обновление - **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются -#### Scenario: Завершение останавливает поллинг +#### Scenario: Завершение убирает цифры качания, но не обновление -- **WHEN** загрузка переходит из downloading в другое состояние -- **THEN** фоновый поллинг прогресса для этой карточки прекращается +- **WHEN** загрузка переходит из `downloading` в другое наблюдаемое состояние +- **THEN** блок прогресса, скорости и ETA с карточки исчезает +- **AND** карточка продолжает обновляться и приносит новое состояние +- **AND** размер и рейтинг раздачи по-прежнему берутся из снимка ### Requirement: Секция раздачи на странице загрузки @@ -114,6 +127,12 @@ SHALL быть доступен для любой раздачи, присутс чей торрент сидирует. Если живых данных по задаче нет, секция SHALL отсутствовать либо явно показывать «нет данных», не ломая остальную страницу. +Секция MUST NOT опрашивать сервер самостоятельно: она лежит внутри области, +которую страница обновляет целиком, и собственный опрос секции подменял бы +разметку страницы. Её цифры SHALL приходить с тиком самообновления страницы +(см. `web-ui`, «Самообновление живой задачи»), а частота их обновления +SHALL совпадать с частотой обновления страницы. + #### Scenario: Сидирующая задача показывает раздачу - **WHEN** открыта страница задачи, торрент которой раздаётся @@ -125,3 +144,10 @@ SHALL быть доступен для любой раздачи, присутс - **THEN** секция «Раздача» отсутствует или показывает «нет данных», а распознавание, файлы и история отображаются нормально +#### Scenario: Секция обновляется тиком страницы + +- **GIVEN** открыта страница наблюдаемой задачи, чья раздача сидирует +- **WHEN** страница отрисована +- **THEN** секция «Раздача» не несёт собственного опроса +- **AND** её цифры обновляются вместе с остальной страницей + diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index 18a3096..724794b 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -339,6 +339,10 @@ MUST NOT показываться в карточке списка — он до отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки; если неизвестно ни то, ни другое — прочерк «—». +Карточка, пришедшая **самообновлением**, SHALL показывать те же значения, что и +карточка в полном рендере списка: фоновое обновление MUST NOT подменять +известное значение прочерком. + #### Scenario: Метка идентификатора - **WHEN** рендерится карточка загрузки в списке @@ -365,6 +369,12 @@ MUST NOT показываться в карточке списка — он до - **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы — размер берётся из суммарного размера этих файлов +#### Scenario: Самообновление не теряет размер + +- **GIVEN** торрента нет в живом снимке, а файлы задачи разложены +- **WHEN** карточка пришла самообновлением, а не полным рендером списка +- **THEN** размер показан по тому же фолбэку, а не прочерком «—» + #### Scenario: Контекст не в карточке - **WHEN** у загрузки есть переданный контекст @@ -470,6 +480,99 @@ PRG-редиректом, и действие исполняется тем же - **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 Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу @@ -483,11 +586,12 @@ PRG-редиректом, и действие исполняется тем же имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения. -Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся -htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading` -интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж, -выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL -завершаться, как только загрузка её покинула. +Самообновление карточки и страницы в `catched` — частный случай требования +«Самообновление живой задачи»: `catched` нетерминален, поэтому интерфейс +подхватывает переход в `downloading` (бейдж, выведенное имя, появившийся живой +прогресс) без перезагрузки страницы. Отдельного правила самообновления для этой +фазы веб-UI MUST NOT иметь: фаза перестала быть единственной, где интерфейс +обновляется сам. #### Scenario: Бейдж и группа для catched @@ -509,7 +613,7 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо - **WHEN** worker перевёл загрузку в `downloading` - **THEN** интерфейс без перезагрузки показывает состояние `downloading` (бейдж, имя, живой прогресс) -- **AND** поллинг фазы `catched` завершается +- **AND** самообновление продолжается, потому что задача осталась наблюдаемой ### Requirement: Загрузка .torrent-файла на форме добавления diff --git a/web/templates/partials/card.html b/web/templates/partials/card.html index e3da5af..cfe4dd6 100644 --- a/web/templates/partials/card.html +++ b/web/templates/partials/card.html @@ -1,5 +1,5 @@ {{define "card"}} -
+
{{if eq .MediaType "series"}}📺 {{else if eq .MediaType "movie"}}🎬 {{end}}{{.Title}}
diff --git a/web/templates/partials/download_main.html b/web/templates/partials/download_main.html index 4f68f67..6e02fd6 100644 --- a/web/templates/partials/download_main.html +++ b/web/templates/partials/download_main.html @@ -1,5 +1,5 @@ {{define "download_main"}} -
+
← ко всем загрузкам {{if .Error}}

{{.Error}}

{{end}} @@ -97,7 +97,10 @@ С JS hx-confirm показывает диалог подтверждения; без JS гейт — только раскрытие details и явный submit (диалога нет). --> {{if or .Dismissable .Deletable}} -
+ +
Опасная зона {{if .Dismissable}}

diff --git a/web/templates/partials/frag_note.html b/web/templates/partials/frag_note.html new file mode 100644 index 0000000..43596ac --- /dev/null +++ b/web/templates/partials/frag_note.html @@ -0,0 +1,3 @@ +{{define "frag_note"}}

+
{{.Text}}
+
{{end}} diff --git a/web/templates/partials/progress.html b/web/templates/partials/progress.html index 7ffe26e..a67acfe 100644 --- a/web/templates/partials/progress.html +++ b/web/templates/partials/progress.html @@ -1,4 +1,4 @@ -{{define "progress"}}
{{if .Active}} +{{define "progress"}}
{{if .Active}}
{{if .Has}}
{{.Percent}}% · ↓ {{.DlSpeed}}{{if .ETA}} · осталось {{.ETA}}{{end}}
{{end}} {{end}}
{{end}} diff --git a/web/templates/partials/seeding.html b/web/templates/partials/seeding.html index 0655070..a3b9182 100644 --- a/web/templates/partials/seeding.html +++ b/web/templates/partials/seeding.html @@ -1,4 +1,4 @@ -{{define "seeding"}}{{if .Has}}
+{{define "seeding"}}{{if .Has}}

Раздача

qBittorrent · источник продолжает раздаваться
{{.Percent}}%
скачано