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