mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-05 08:14:39 +00:00
- 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).
159 lines
3.9 KiB
Go
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
|
|
}
|
|
}
|