feat(web): tell running from quiet from lost; poll every two seconds (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+100
-3
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
@@ -32,7 +33,15 @@ const (
|
|||||||
uploadOverhead = 1 << 20
|
uploadOverhead = 1 << 20
|
||||||
maxTitleRunes = 200
|
maxTitleRunes = 200
|
||||||
// uploadPollEvery is how often an unfinished upload re-asks for its status.
|
// 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.
|
// uploadsView is what the /uploads page renders.
|
||||||
@@ -137,7 +146,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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
|
// 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 {
|
if !ok {
|
||||||
return
|
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) {
|
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 {
|
func moreSpots(r *domain.Reconciliation) int {
|
||||||
return max(0, r.Changed+r.Unsure-len(shownSpots(r)))
|
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"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user