Files
goclaw/internal/gateway/update_check.go
T
Viet Tran 52c67d6d92 feat(build): embed web UI in backend binary + simplify Docker variants (#620)
- Add internal/webui/ package with //go:build embedui tag for optional
  SPA embedding (handler.go serves static files with SPA fallback)
- Add internal/version/ shared semver comparison (DRY: extracted from
  gateway/update_check.go and updater/updater.go)
- Enhance UpdateChecker: release notes, ETag caching, filter lite-v* tags
- Add web UI build stage to Dockerfile with ENABLE_EMBEDUI build arg
- Simplify CI: 7 Docker variants → 4 (base, latest, full, otel)
- Add SHA256 checksums job to release workflow
- Add Makefile build-full target (embeds web UI in Go binary)
- Default make up now embeds web UI (no separate nginx needed)
- Add WITH_WEB_NGINX=1 flag for optional nginx reverse proxy
- Update README + 30 translated READMEs: make up, port 18790
- Update docker-compose comments and prepare-env.sh
- About dialog: show release notes with markdown rendering
- Health card: amber badge for available updates

BREAKING: Default Docker setup no longer requires selfservice overlay.
Web dashboard served at :18790 (same port as API).
2026-04-01 15:25:59 +07:00

159 lines
3.9 KiB
Go

package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/version"
)
const (
githubRepo = "nextlevelbuilder/goclaw"
liteTagPrefix = "lite-v"
updateCheckInterval = 1 * time.Hour
maxResponseBody = 2 << 20 // 2 MB
)
// UpdateInfo holds the latest release information from GitHub.
type UpdateInfo struct {
LatestVersion string `json:"latestVersion"`
UpdateURL string `json:"updateUrl"`
UpdateAvailable bool `json:"updateAvailable"`
ReleaseNotes string `json:"releaseNotes,omitempty"`
}
// UpdateChecker periodically checks GitHub for new releases.
type UpdateChecker struct {
currentVersion string
mu sync.RWMutex
info *UpdateInfo
etag string // ETag for conditional requests
}
// NewUpdateChecker creates an UpdateChecker for the given current version.
func NewUpdateChecker(currentVersion string) *UpdateChecker {
return &UpdateChecker{currentVersion: currentVersion}
}
// Start begins periodic update checking. Call with a cancellable context.
func (uc *UpdateChecker) Start(ctx context.Context) {
go func() {
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
uc.check()
ticker := time.NewTicker(updateCheckInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
uc.check()
case <-ctx.Done():
return
}
}
}()
}
// Info returns the cached update info, or nil if not yet checked.
func (uc *UpdateChecker) Info() *UpdateInfo {
uc.mu.RLock()
defer uc.mu.RUnlock()
return uc.info
}
// githubRelease is a minimal GitHub Release API response.
type githubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
func (uc *UpdateChecker) check() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Use releases list API (not /latest) to filter out lite-v* tags.
url := fmt.Sprintf("https://api.github.com/repos/%s/releases", githubRepo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
slog.Warn("update check: failed to create request", "error", err)
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "goclaw/"+uc.currentVersion)
// ETag conditional request to reduce API rate limit usage.
if uc.etag != "" {
req.Header.Set("If-None-Match", uc.etag)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Warn("update check: request failed", "error", err)
return
}
defer resp.Body.Close()
// 304 Not Modified — cached info is still valid.
if resp.StatusCode == http.StatusNotModified {
return
}
if resp.StatusCode != http.StatusOK {
slog.Warn("update check: unexpected status", "status", resp.StatusCode)
return
}
// Cache ETag.
if etag := resp.Header.Get("ETag"); etag != "" {
uc.etag = etag
}
var releases []githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&releases); err != nil {
slog.Warn("update check: failed to decode response", "error", err)
return
}
// Find the latest non-draft, non-prerelease, non-lite server release.
for _, rel := range releases {
if rel.Draft || rel.Prerelease {
continue
}
if strings.HasPrefix(rel.TagName, liteTagPrefix) {
continue
}
if !strings.HasPrefix(rel.TagName, "v") {
continue
}
info := &UpdateInfo{
LatestVersion: rel.TagName,
UpdateURL: rel.HTMLURL,
UpdateAvailable: version.IsNewer(rel.TagName, uc.currentVersion),
ReleaseNotes: rel.Body,
}
uc.mu.Lock()
uc.info = info
uc.mu.Unlock()
if info.UpdateAvailable {
slog.Info("new version available", "current", uc.currentVersion, "latest", rel.TagName)
}
return
}
}