Files
jellybit/internal/httpapi/live.go
T
av a5d873b62d web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
2026-08-10 14:02:38 +03:00

349 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"errors"
"fmt"
"net/http"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// LiveStatus — источник живой телеметрии загрузок (снимок воркера). Контракт
// узкий и не зависит от способа доставки в браузер (поллинг сейчас, SSE позже).
type LiveStatus interface {
// Live возвращает телеметрию по infohash; ok=false — данных нет (нет
// торрента в последнем тике), читатель деградирует без живых значений.
Live(infohash string) (worker.Live, bool)
}
// Интервалы самообновления поверхностей (значение hx-trigger="every …").
//
// - pollFast — поверхность с живыми цифрами качания (карточка в downloading).
// Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и
// опрашивать чаще значит возвращать тот же кадр (docs/database.md).
// - pollSlow — все прочие наблюдаемые поверхности, включая страницу
// /download/{id} в любом состоянии: там меняется только состояние, а сборка
// страницы считает предпросмотр раскладки и ходит в ФС.
const (
pollFast = "5s"
pollSlow = "15s"
)
// noLive — заглушка на случай, когда источник телеметрии не подключён
// (Deps.Live == nil): живых данных нет, UI деградирует штатно.
type noLive struct{}
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
// progressView — живой прогресс активной загрузки (вложенный блок карточки).
// Active управляется store-состоянием (downloading), а не qbt: вне downloading
// скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
// задачи»).
type progressView struct {
ID string
Active bool // store-состояние downloading → показываем бар
Has bool // есть данные снимка
Percent int
DlSpeed string
ETA string
}
// seedingView — живая статистика раздачи (секция страницы загрузки).
// Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
type seedingView struct {
ID string
Has bool
Percent int
Ratio string
Uploaded string
Seeds int
Peers int
UpSpeed string
}
func buildProgress(id string, active bool, l worker.Live, ok bool) progressView {
v := progressView{ID: id, Active: active}
if ok {
v.Has = true
v.Percent = pct(l.Progress)
v.DlSpeed = fmtSpeed(l.DlSpeed)
// ETA опускаем при неизвестном/sentinel (stalled) — «осталось —» уродливо;
// шаблонный {{if .ETA}} тогда скрывает хвост строки.
if e := fmtETA(l.ETA); e != "—" {
v.ETA = e
}
}
return v
}
func buildSeeding(id string, l worker.Live, ok bool) seedingView {
v := seedingView{ID: id}
if ok && l.Seeding {
v.Has = true
v.Percent = pct(l.Progress)
v.Ratio = fmtRatio(l.Ratio)
v.Uploaded = fmtBytes(l.Uploaded)
v.Seeds = l.Seeds
v.Peers = l.Peers
v.UpSpeed = fmtSpeed(l.UpSpeed)
}
return v
}
// handleFragProgress отдаёт партиал живого прогресса карточки.
//
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
// её первым же тиком. Убирается отдельной уборкой после деплоя.
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragTickErr(w, err, id, "dl-live-"+id)
return
}
active := d.State == store.StateDownloading
l, ok := s.liveFor(*d)
s.render(w, "progress", buildProgress(id, active, l, ok))
}
// handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
// Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
// каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
// действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
// несёт самополлинга, и цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragTickErr(w, err, id, "card-"+id)
return
}
// Размер читаем так же, как своповый путь действия: самообновление
// обслуживает и состояния с разложенными файлами, и подмена известного
// размера прочерком была бы потерей поля полного рендера.
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 отдаёт партиал секции «Раздача».
//
// Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком
// страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress.
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragTickErr(w, err, id, "seeding-"+id)
return
}
l, ok := s.liveFor(*d)
s.render(w, "seeding", buildSeeding(id, l, ok))
}
// fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
// фрагмент, который объясняет положение дел и НЕ несёт самообновления.
//
// Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
// поверхность осталась бы прежней навсегда (человек не отличит «ничего не
// изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
// при затяжном отказе хранилища это поток записей в журнал с каждой открытой
// вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой
// задачи»).
//
// Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит
// (docs/conventions/logging.md, «Ошибки»).
func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, false)
}
// fragActionErr — отказ чтения на разовом действии человека: тот же
// самозавершающийся фрагмент, но ERROR: ретрая у действия нет.
func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, true)
}
// fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и
// приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id
// снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) {
text := "задача не найдена — обновите страницу"
if !errors.Is(err, store.ErrNotFound) {
if oneShot {
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
} else {
s.deps.Logger.Warn("live fragment", "download_id", id, "error", err)
}
text = "не удалось обновить — обновите страницу"
}
s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
}
// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
// id узла, который фрагмент собой заменяет.
type fragNoteView struct {
RootID string
Text string
}
// --- форматирование телеметрии ---
// etaInfinity — sentinel qBittorrent для неизвестного/бесконечного ETA.
const etaInfinity = 8640000
func pct(progress float64) int {
if progress < 0 {
return 0
}
if progress > 1 {
return 100
}
return int(progress*100 + 0.5)
}
// fmtBytes переводит байты в человекочитаемые единицы (двоичные, IEC).
func fmtBytes(n int64) string {
if n < 1024 {
return fmt.Sprintf("%d Б", n)
}
const unit = 1024
div, exp := int64(unit), 0
units := []string{"КиБ", "МиБ", "ГиБ", "ТиБ", "ПиБ", "ЭиБ"}
for v := n / unit; v >= unit && exp < len(units)-1; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), units[exp])
}
// fmtSpeed форматирует скорость (байт/с).
func fmtSpeed(n int64) string {
if n <= 0 {
return "0 Б/с"
}
return fmtBytes(n) + "/с"
}
// fmtETA форматирует оценку времени; sentinel/отрицательное → «—».
func fmtETA(sec int64) string {
if sec < 0 || sec >= etaInfinity {
return "—"
}
switch {
case sec < 60:
return fmt.Sprintf("%d с", sec)
case sec < 3600:
return fmt.Sprintf("%d мин", sec/60)
case sec < 86400:
return fmt.Sprintf("%d ч %d мин", sec/3600, (sec%3600)/60)
default:
return fmt.Sprintf("%d дн", sec/86400)
}
}
// fmtRatio форматирует рейтинг отдачи; отрицательный (sentinel) → «—».
func fmtRatio(r float64) string {
if r < 0 {
return "—"
}
return fmt.Sprintf("%.2f", r)
}
// fmtDate — абсолютная дата добавления для карточки в таймзоне отображения
// (general.timezone; хранение всегда UTC). loc не бывает nil — NewRouter
// подставляет UTC по умолчанию.
func fmtDate(t time.Time, loc *time.Location) string {
return t.In(loc).Format("2006-01-02")
}
// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
// (рассинхрон часов) схлопывается в «только что». Единицы огрубляются к
// минутам/часам/дням/месяцам/годам — для обзора возраста этого достаточно.
func humanizeAge(t, now time.Time) string {
d := now.Sub(t)
if d < time.Minute {
return "только что"
}
switch {
case d < time.Hour:
n := int(d / time.Minute)
return fmt.Sprintf("%d %s назад", n, plural(n, "минуту", "минуты", "минут"))
case d < 24*time.Hour:
n := int(d / time.Hour)
return fmt.Sprintf("%d %s назад", n, plural(n, "час", "часа", "часов"))
case d < 30*24*time.Hour:
n := int(d / (24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "день", "дня", "дней"))
case d < 365*24*time.Hour:
n := int(d / (30 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "месяц", "месяца", "месяцев"))
default:
n := int(d / (365 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "год", "года", "лет"))
}
}
// plural выбирает русскую форму по числу (1 файл / 2 файла / 5 файлов).
func plural(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if m := n % 100; m >= 11 && m <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// sizeText — размер раздачи для карточки: живой общий размер из снимка, иначе
// суммарный размер разложенных файлов (фолбэк для orphaned), иначе «—».
func sizeText(l worker.Live, ok bool, layoutSize int64) string {
if ok && l.TotalSize > 0 {
return fmtBytes(l.TotalSize)
}
if layoutSize > 0 {
return fmtBytes(layoutSize)
}
return "—"
}
// ratioText — рейтинг отдачи для карточки: из живого снимка, иначе «—» (торрента
// нет в qBittorrent).
func ratioText(l worker.Live, ok bool) string {
if !ok {
return "—"
}
return fmtRatio(l.Ratio)
}