package webapp import ( "context" "encoding/json" "net/http" "sync" "time" ) // placeholderLatestVersion is the fallback shown on the /updates // page when Config.ReleaseURL is empty or the poll fails. Phase 1 // uses it permanently; once Config.ReleaseURL is set the handler // replaces it with the polled tag_name (GitHub release JSON shape). const placeholderLatestVersion = "v1 (dev)" // defaultReleasePollTimeout bounds the time a single release-server // HTTP fetch is allowed to take. 3 seconds keeps the page render // fast; a slow upstream just shows the placeholder. const defaultReleasePollTimeout = 3 * time.Second // releaseCacheTTL bounds how often the worker re-fetches the // release URL. One hour is short enough that a fresh release shows // up reasonably quickly, long enough that the page never hammers the // upstream. const releaseCacheTTL = 1 * time.Hour // releasePoller holds the cached release-server response. A single // instance lives on the Server (one per process) so concurrent // /updates hits share the same cache entry. type releasePoller struct { mu sync.RWMutex url string cached string cachedAt time.Time } // latest returns the cached value if it is fresh, otherwise it // fetches the URL, parses {"tag_name":"..."} from the response and // caches the result. Errors fall back to placeholderLatestVersion // without touching the cache, so a transient outage does not poison // the next successful poll. func (p *releasePoller) latest(ctx context.Context, httpClient *http.Client) string { if p == nil || p.url == "" { return placeholderLatestVersion } p.mu.RLock() if !p.cachedAt.IsZero() && time.Since(p.cachedAt) < releaseCacheTTL && p.cached != "" { out := p.cached p.mu.RUnlock() return out } p.mu.RUnlock() client := httpClient if client == nil { client = &http.Client{Timeout: defaultReleasePollTimeout} } fetchCtx, cancel := context.WithTimeout(ctx, defaultReleasePollTimeout) defer cancel() req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, p.url, http.NoBody) if err != nil { return placeholderLatestVersion } req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return placeholderLatestVersion } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return placeholderLatestVersion } var body struct { TagName string `json:"tag_name"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.TagName == "" { return placeholderLatestVersion } p.mu.Lock() p.cached = body.TagName p.cachedAt = time.Now() p.mu.Unlock() return body.TagName } // setURL configures the poller with a new release URL and resets // the cache. Called from New() when Config.ReleaseURL is set. func (p *releasePoller) setURL(u string) { if p == nil { return } p.mu.Lock() p.url = u p.cached = "" p.cachedAt = time.Time{} p.mu.Unlock() } // handleUpdates renders the updates page. The "pull and restart" // button stays disabled until sudo / docker socket access lands // (gated on the Phase 3 Docker management work). The version // comparison above it IS real: it polls Config.ReleaseURL (env // WORKER_RELEASE_URL) and falls back to placeholderLatestVersion on // any network or parse error. func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) { writeNoStore(w) sess, _ := sessionFromContext(r.Context()) data := updatesPageData{ basePageData: s.newBasePage(r, "Updates", sess), CurrentVersion: workerVersionOrDash(s.deps.Runner), LatestKnown: s.releasePoller.latest(r.Context(), s.deps.ReleaseHTTPClient), PullEnabled: false, PullTooltip: "Pull and restart lands with Docker management (sudo / docker socket required).", } if err := s.templates.Execute(w, "updates.html", data); err != nil { s.deps.Logger.Printf("render updates: %v", err) http.Error(w, "template error", http.StatusInternalServerError) } } type updatesPageData struct { basePageData CurrentVersion string LatestKnown string PullEnabled bool PullTooltip string }