coda/internal/web/render.go
Rodrigo Verdiani 2eead05c34 feat: expirar jobs stuck com status timeout
Adiciona mecanismo de expiração periódica para jobs que ficam presos
nos status received ou processing além de um threshold configurável.

- models: novo status StatusTimeout
- store: migration v2 com índice em jobs(status); método ExpireStuck
  que faz UPDATE atômico para os status alvo anteriores ao threshold
- expirer: novo pacote com goroutine periódica que chama ExpireStuck
  e loga WARN quando algum job é expirado
- config: campos JobTimeoutMinutes (default 120) e
  ExpirerIntervalMinutes (default 10), lidos via env vars
  JOB_TIMEOUT_MINUTES e EXPIRER_INTERVAL_MINUTES
- main: instancia e inicia o Expirer no startup
- dashboard: card Timeout com contagem; badge neutral na tabela de jobs
2026-07-07 08:43:33 -03:00

90 lines
2.1 KiB
Go

package web
import (
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"sync"
)
type Renderer struct {
fs fs.FS
tmpl *template.Template
mu sync.Mutex
}
func NewRenderer(templatesFS fs.FS) *Renderer {
r := &Renderer{fs: templatesFS}
if !isDev {
tmpl, err := r.parseAll()
if err != nil {
panic("failed to parse templates: " + err.Error())
}
r.tmpl = tmpl
}
return r
}
func (r *Renderer) Render(w io.Writer, name string, data any) error {
if isDev {
tmpl, err := r.parseAll()
if err != nil {
slog.Error("failed to parse templates (dev)", "error", err)
return err
}
return tmpl.ExecuteTemplate(w, name, data)
}
r.mu.Lock()
tmpl := r.tmpl
r.mu.Unlock()
return tmpl.ExecuteTemplate(w, name, data)
}
func (r *Renderer) parseAll() (*template.Template, error) {
funcs := template.FuncMap{
"formatDuration": formatDuration,
"statusBadge": statusBadge,
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"gt": func(a, b int) bool { return a > b },
}
tmpl := template.New("").Funcs(funcs)
return tmpl.ParseFS(r.fs, "*.html", "partials/*.html")
}
func formatDuration(ms int64) string {
if ms < 1000 {
return "0s"
}
s := ms / 1000
if s < 60 {
return fmt.Sprintf("%ds", s)
}
m := s / 60
s = s % 60
return fmt.Sprintf("%dm %ds", m, s)
}
func statusBadge(level string) template.HTML {
switch level {
case "DEBUG":
return template.HTML(`<span class="badge badge-neutral">` + level + `</span>`)
case "INFO", "done":
return template.HTML(`<span class="badge badge-success">` + level + `</span>`)
case "WARN", "processing":
return template.HTML(`<span class="badge badge-warning">` + level + `</span>`)
case "ERROR", "error":
return template.HTML(`<span class="badge badge-error">` + level + `</span>`)
case "received":
return template.HTML(`<span class="badge badge-info">` + level + `</span>`)
case "timeout":
return template.HTML(`<span class="badge badge-neutral">` + level + `</span>`)
default:
return template.HTML(level)
}
}