Files
VeillaxandClaude Sonnet 5.5 2b7cb5dd35
CI / test (push) Successful in 5m21s
Release / release (push) Successful in 6m9s
Show a progress bar and live log while Hammerhead, edges and sites update
The updater reports each step (checking, downloading with percent, verifying, installing, restarting).
Edges and sites send that progress and their log with their reports; Hammerhead serves it to a dialog
that opens when an update starts, and keeps its own last update log on disk across the restart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:05:42 -06:00

153 lines
4.7 KiB
Go

package hammerhead
import (
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const maxUpdateLines = 200
// updRun is the progress of one update an edge or site is carrying out, as that agent reported it.
type updRun struct {
ID string // the update request it belongs to
Stage string
Pct int
Lines []string
}
// recordUpdateProgress keeps what an agent says about the update it is running. When the agent
// reports a new version, the update is finished: its log gets a closing line and is then left alone,
// so the freshly restarted program (which starts a new, empty log) can't overwrite it.
// Caller holds s.mu.
func (s *Server) recordUpdateProgress(agentID, reqID, stage string, pct int, lines []string, oldVersion, newVersion string) {
run := s.updRuns[agentID]
if run != nil && run.Stage != "done" && oldVersion != "" && newVersion != "" && newVersion != oldVersion {
run.Lines = append(run.Lines, time.Now().Format("15:04:05")+" Now running "+newVersion)
run.Stage, run.Pct = "done", 100
return
}
if reqID == "" || (stage == "" && len(lines) == 0) {
return
}
if run != nil && run.ID == reqID && run.Stage == "done" {
return
}
s.updRuns[agentID] = &updRun{ID: reqID, Stage: stage, Pct: pct, Lines: lines}
}
func (s *Server) updateLogView(agentID string, pending bool, msg, version string) map[string]any {
out := map[string]any{"running": pending, "msg": msg, "version": version, "stage": "", "pct": 0, "lines": []string{}}
if run := s.updRuns[agentID]; run != nil {
out["stage"], out["pct"], out["lines"] = run.Stage, run.Pct, run.Lines
}
return out
}
func (s *Server) edgeUpdateLog(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.findEdge(r.PathValue("id"))
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
writeJSON(w, 200, s.updateLogView(e.ID, e.UpdateID != "", e.UpdateMsg, e.Version))
}
func (s *Server) siteUpdateLog(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(r.PathValue("id"))
if st == nil || !s.siteVisible(c, st) {
fail(w, 404, errors.New("no such site"))
return
}
writeJSON(w, 200, s.updateLogView(st.ID, st.UpdateID != "", st.UpdateMsg, st.Version))
}
// selfUpdate is the progress of this server's own update. It is kept in memory and in <data>/update.log,
// so the page can still show it after the restart that ends a successful update.
type selfUpdate struct {
mu sync.Mutex
running bool
stage string
pct int
lines []string
}
func (s *Server) updateLogPath() string { return filepath.Join(s.dir, "update.log") }
func (s *Server) selfNote(stage string, pct int, line string) {
u := &s.selfUpd
u.mu.Lock()
defer u.mu.Unlock()
if stage != "" {
u.stage = stage
}
if pct >= 0 {
u.pct = pct
}
if line == "" {
return
}
line = time.Now().Format("15:04:05") + " " + line
u.lines = append(u.lines, line)
if len(u.lines) > maxUpdateLines {
u.lines = u.lines[len(u.lines)-maxUpdateLines:]
}
if f, err := os.OpenFile(s.updateLogPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
f.WriteString(line + "\n")
f.Close()
}
}
func readLines(path string) []string {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) == 1 && lines[0] == "" {
return nil
}
if len(lines) > maxUpdateLines {
lines = lines[len(lines)-maxUpdateLines:]
}
return lines
}
// finishSelfUpdateLog runs at startup: if the last run ended by restarting into this process, say so.
func (s *Server) finishSelfUpdateLog() {
lines := readLines(s.updateLogPath())
if n := len(lines); n > 0 && strings.Contains(lines[n-1], "restarting") && s.opts.Version != "" {
if f, err := os.OpenFile(s.updateLogPath(), os.O_APPEND|os.O_WRONLY, 0o600); err == nil {
f.WriteString(time.Now().Format("15:04:05") + " Hammerhead restarted: now running " + s.opts.Version + "\n")
f.Close()
}
}
}
// updateStatus returns the progress and log of this server's own update, for the Settings page.
func (s *Server) updateStatus(w http.ResponseWriter, r *http.Request, p *principal) {
u := &s.selfUpd
u.mu.Lock()
out := map[string]any{"running": u.running, "stage": u.stage, "pct": u.pct, "lines": append([]string(nil), u.lines...), "version": s.opts.Version}
u.mu.Unlock()
if lines, _ := out["lines"].([]string); len(lines) == 0 {
// nothing in memory: this is a fresh process, so show the last update's log from disk
if saved := readLines(s.updateLogPath()); len(saved) > 0 {
stage := "done"
if strings.Contains(saved[len(saved)-1], "Failed:") {
stage = "failed"
}
out["lines"], out["stage"], out["pct"] = saved, stage, 100
}
}
writeJSON(w, 200, out)
}