diff --git a/internal/web/uploads.go b/internal/web/uploads.go index 6f57637..af1a166 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -9,6 +9,7 @@ import ( "net/http" "path/filepath" "strings" + "time" "unicode/utf8" "github.com/a-h/templ" @@ -32,7 +33,15 @@ const ( uploadOverhead = 1 << 20 maxTitleRunes = 200 // uploadPollEvery is how often an unfinished upload re-asks for its status. - uploadPollEvery = "5s" + uploadPollEvery = "2s" + // serviceSilentAfter is how long a job may go without any signal (heartbeat or + // progress) before the page calls the service lost. The heartbeat is every 10 s, so + // 45 s is four missed beats (ADR-035). + serviceSilentAfter = 45 * time.Second + // countedQuietAfter is how long a COUNTED phase (the deciding one) may go without + // advancing before the page says it is quiet. Each decision has a two-minute timeout, + // so five minutes is more than two in a row. A phase with no count is never quiet. + countedQuietAfter = 5 * time.Minute ) // uploadsView is what the /uploads page renders. @@ -137,7 +146,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) { if !ok { return } - a.render(w, r, UploadPage(*u, a.Chat != nil)) + a.render(w, r, UploadPage(*u, a.Chat != nil, a.now())) } // handleUploadStatus renders the status fragment HTMX polls. Unfinished states @@ -148,7 +157,7 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) { if !ok { return } - a.render(w, r, uploadStatus(*u, a.Chat != nil)) + a.render(w, r, uploadStatus(*u, a.Chat != nil, a.now())) } func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) { @@ -296,3 +305,91 @@ func shownSpots(r *domain.Reconciliation) []domain.ContestedSpot { func moreSpots(r *domain.Reconciliation) int { return max(0, r.Changed+r.Unsure-len(shownSpots(r))) } + +// jobHealth is what the page can honestly say about a job it is still polling. +type jobHealth string + +const ( + // healthRunning: the service is reporting and work is moving, or there is nothing yet + // to say otherwise. + healthRunning jobHealth = "running" + // healthQuiet: the service is alive but a counted phase has not advanced for a while. + healthQuiet jobHealth = "quiet" + // healthLost: nothing has been heard from the service for too long. It may have + // restarted or crashed; the start-up sweep fails the row once it is back. + healthLost jobHealth = "lost" +) + +// uploadHealth tells running from quiet from lost for an upload being transcribed (the +// page calls it for no other state: a queued or finished job has no heartbeat to judge). The +// two timestamps mean different things (ADR-035): alive_at is the heartbeat, which says +// the service is still reporting; progress_at moves only when work really moved. A +// single blocking engine call has no sub-progress, so only a counted phase can be quiet. +// silent is how long since the signal that decided it. +func uploadHealth(u domain.UploadedTranscript, now time.Time) (health jobHealth, silent time.Duration) { + last := u.AliveAt + if u.ProgressAt.After(last) { + last = u.ProgressAt + } + if last.IsZero() { + return healthRunning, 0 + } + if d := now.Sub(last); d > serviceSilentAfter { + return healthLost, d + } + for _, s := range u.Progress { + if s.State == domain.StepRunning && s.Total > 0 && !u.ProgressAt.IsZero() { + if d := now.Sub(u.ProgressAt); d > countedQuietAfter { + return healthQuiet, d + } + } + } + return healthRunning, 0 +} + +// stepDuration renders how long a phase took or has run: "<1s", "42s", "3m 12s", "1h 02m". +func stepDuration(d time.Duration) string { + switch { + case d < time.Second: + return "<1s" + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60) + default: + return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60) + } +} + +// stepTime is the time shown beside a phase: how long it has run, or how long it took. +func stepTime(s domain.ProgressStep, now time.Time) string { + switch s.State { + case domain.StepRunning: + return stepDuration(now.Sub(s.StartedAt)) + case domain.StepDone, domain.StepFailed: + return stepDuration(s.FinishedAt.Sub(s.StartedAt)) + } + return "" +} + +// stepMark is the glyph at the head of a phase line. +func stepMark(s domain.ProgressStep) string { + switch s.State { + case domain.StepDone: + return "โœ“" + case domain.StepFailed: + return "โœ—" + case domain.StepSkipped: + return "โ€“" + } + return "ยท" +} + +// minutesAgo is a whole number of minutes for the health notes: "6 min", or "under a +// minute" when it is less. +func minutesAgo(d time.Duration) string { + if m := int(d.Minutes()); m >= 1 { + return fmt.Sprintf("%d min", m) + } + return "under a minute" +}