feat(web): display backend version info in sidebar (#2087)

* feat(web): display backend version info in sidebar

* fix(web): improve version parsing and timeout behavior

* refactor(web): remove useless --version fallback

* feat(web): implement version info caching and improve retrieval logic

* fix(web): clarify version timeout rationale

* fix(web): harden gateway version probing and tests

* style(web): split regexp to two lines for lint
This commit is contained in:
LC
2026-03-30 16:44:50 +08:00
committed by GitHub
parent e88df4ff9c
commit ff0266a40e
8 changed files with 725 additions and 5 deletions
+3
View File
@@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
// Runtime build/version metadata
h.registerVersionRoutes(mux)
// WeChat QR login flow
h.registerWeixinRoutes(mux)
+345
View File
@@ -0,0 +1,345 @@
package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
)
type systemVersionResponse struct {
Version string `json:"version"`
GitCommit string `json:"git_commit,omitempty"`
BuildTime string `json:"build_time,omitempty"`
GoVersion string `json:"go_version"`
}
type cachedSystemVersion struct {
value systemVersionResponse
gatewayPID int
}
type systemVersionCache struct {
mu sync.Mutex
current cachedSystemVersion
hasCurrent bool
inflightCh chan struct{}
}
func newSystemVersionCache() *systemVersionCache {
return &systemVersionCache{}
}
var (
// 15 seconds matches the gateway startup window used elsewhere in launcher flow,
// giving slow/embedded hosts enough time for first command invocation while
// staying independent from cross-file init ordering.
versionCmdTimeout = 15 * time.Second
maxVersionResolveAttempts = 3
findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
runPicoclawVersionOutput = executePicoclawVersion
currentGatewayVersionState = gatewayVersionState
launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
versionInfoCache = newSystemVersionCache()
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
versionLinePattern = regexp.MustCompile(
`^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` +
`(?:\s+\(git:\s*([^)]+)\))?\s*$`,
)
)
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
}
// handleGetVersion returns runtime version information for web clients.
func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
versionInfo := h.resolveSystemVersionInfo(r.Context())
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(versionInfo); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
// and falls back to launcher build metadata when command execution fails.
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
for range maxVersionResolveAttempts {
gatewayPID, gatewayAlive := currentGatewayVersionState()
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
return cached
}
leader, ok := versionInfoCache.waitOrStart(ctx)
if !ok {
return fallbackSystemVersionInfo()
}
if !leader {
continue
}
resolved := h.resolveSystemVersionInfoUncached(ctx)
gatewayPID, gatewayAlive = currentGatewayVersionState()
versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
return resolved
}
return fallbackSystemVersionInfo()
}
func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
if ctx == nil {
ctx = context.Background()
}
fallback := fallbackSystemVersionInfo()
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
if execPath == "" {
return fallback
}
cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
defer cancel()
output, err := runPicoclawVersionOutput(cmdCtx, execPath)
if err != nil {
return fallback
}
parsed, ok := parsePicoclawVersionOutput(output)
if !ok {
return fallback
}
if parsed.GoVersion == "" {
parsed.GoVersion = fallback.GoVersion
if parsed.GoVersion == "" {
parsed.GoVersion = runtime.Version()
}
}
return parsed
}
func fallbackSystemVersionInfo() systemVersionResponse {
return launcherBuildInfoForVersion()
}
func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
buildTime, goVer := config.FormatBuildInfo()
return systemVersionResponse{
Version: config.GetVersion(),
GitCommit: config.GitCommit,
BuildTime: buildTime,
GoVersion: goVer,
}
}
// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher
// gateway start path when available, then falls back to launcher binary lookup.
// This keeps version probing aligned with the actual gateway startup behavior,
// so web and gateway do not drift onto different binaries.
func resolveGatewayBinaryForVersionInfo() string {
gateway.mu.Lock()
cmd := gateway.cmd
gateway.mu.Unlock()
if cmd != nil {
if execPath := strings.TrimSpace(cmd.Path); execPath != "" {
return execPath
}
}
return utils.FindPicoclawBinary()
}
func gatewayVersionState() (int, bool) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if gateway.cmd == nil || gateway.cmd.Process == nil {
return 0, false
}
pid := gateway.cmd.Process.Pid
if pid <= 0 {
return 0, false
}
return pid, isCmdProcessAliveLocked(gateway.cmd)
}
func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
c.clearCurrentLocked()
}
if c.hasCurrent {
return c.current.value, true
}
return systemVersionResponse{}, false
}
func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
return false, false
}
c.mu.Lock()
if c.inflightCh == nil {
c.inflightCh = make(chan struct{})
c.mu.Unlock()
return true, true
}
waitCh := c.inflightCh
c.mu.Unlock()
select {
case <-waitCh:
return false, true
case <-ctx.Done():
return false, false
}
}
func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
c.mu.Lock()
if gatewayAlive && gatewayPID > 0 {
c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
c.hasCurrent = true
} else {
c.clearCurrentLocked()
}
inflightCh := c.inflightCh
c.inflightCh = nil
c.mu.Unlock()
if inflightCh != nil {
close(inflightCh)
}
}
func (c *systemVersionCache) clearCurrentLocked() {
c.hasCurrent = false
c.current = cachedSystemVersion{}
}
func (c *systemVersionCache) resetForTest() {
c.mu.Lock()
defer c.mu.Unlock()
c.current = cachedSystemVersion{}
c.hasCurrent = false
if c.inflightCh != nil {
close(c.inflightCh)
c.inflightCh = nil
}
}
// executePicoclawVersion runs the version subcommand against the
// discovered picoclaw executable.
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
if err == nil {
return string(out), nil
}
return string(out), fmt.Errorf("failed to execute version command: %w", err)
}
// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
// It accepts banner/ANSI-decorated output and only requires the version line.
func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
var result systemVersionResponse
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
if line == "" {
continue
}
if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
candidateVersion := strings.TrimSpace(match[1])
if !isLikelyVersionValue(candidateVersion) {
continue
}
result.Version = candidateVersion
if len(match) > 2 {
result.GitCommit = strings.TrimSpace(match[2])
}
continue
}
if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
result.BuildTime = strings.TrimSpace(buildValue)
continue
}
if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
result.GoVersion = strings.TrimSpace(goValue)
}
}
if err := scanner.Err(); err != nil {
return systemVersionResponse{}, false
}
if result.Version == "" {
return systemVersionResponse{}, false
}
return result, true
}
func isLikelyVersionValue(value string) bool {
v := strings.TrimSpace(strings.ToLower(value))
if v == "" {
return false
}
if v == "dev" {
return true
}
// Accept git-like short/long hashes even when they contain only letters (a-f).
if len(v) >= 7 && len(v) <= 40 {
allHex := true
for _, ch := range v {
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
continue
}
allHex = false
break
}
if allHex {
return true
}
}
for _, ch := range v {
if ch >= '0' && ch <= '9' {
return true
}
}
return false
}
+317
View File
@@ -0,0 +1,317 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os/exec"
"runtime"
"testing"
)
func setupVersionTestIsolation(t *testing.T) {
t.Helper()
originalGatewayState := currentGatewayVersionState
originalFinder := findPicoclawBinaryForInfo
originalRunner := runPicoclawVersionOutput
originalFallback := launcherBuildInfoForVersion
t.Cleanup(func() {
currentGatewayVersionState = originalGatewayState
findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
launcherBuildInfoForVersion = originalFallback
versionInfoCache.resetForTest()
})
currentGatewayVersionState = func() (int, bool) { return 0, false }
versionInfoCache.resetForTest()
}
func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
}
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got systemVersionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Version != "v1.2.3" {
t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
}
if got.GitCommit != "deadbeef" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
}
if got.BuildTime != "2026-03-27T12:34:56Z" {
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
}
if got.GoVersion != "go1.25.8" {
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
}
}
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
setupVersionTestIsolation(t)
expected := systemVersionResponse{
Version: "v9.9.9",
GitCommit: "cafebabe",
BuildTime: "2026-03-27T10:43:34+0000",
GoVersion: "go1.25.8",
}
launcherBuildInfoForVersion = func() systemVersionResponse { return expected }
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "", errors.New("binary unavailable")
}
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got systemVersionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Version != expected.Version {
t.Fatalf("version = %q, want %q", got.Version, expected.Version)
}
if got.GitCommit != expected.GitCommit {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit)
}
if got.BuildTime != expected.BuildTime {
t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime)
}
if got.GoVersion != expected.GoVersion {
t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion)
}
}
func TestParsePicoclawVersionOutput(t *testing.T) {
setupVersionTestIsolation(t)
raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
got, ok := parsePicoclawVersionOutput(raw)
if !ok {
t.Fatal("parsePicoclawVersionOutput() should parse valid output")
}
if got.Version != "18ec263" {
t.Fatalf("version = %q, want %q", got.Version, "18ec263")
}
if got.GitCommit != "18ec2631" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
}
if got.BuildTime != "2026-03-27T10:43:34+0000" {
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
}
if got.GoVersion != "go1.25.8" {
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
}
}
func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) {
setupVersionTestIsolation(t)
raw := "Usage: picoclaw version [flags]\n"
got, ok := parsePicoclawVersionOutput(raw)
if ok {
t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got)
}
}
func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) {
setupVersionTestIsolation(t)
raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n"
got, ok := parsePicoclawVersionOutput(raw)
if !ok {
t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version")
}
if got.Version != "abcdefa" {
t.Fatalf("version = %q, want %q", got.Version, "abcdefa")
}
if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd")
}
}
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "dev", GoVersion: ""}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "picoclaw v1.0.0\n", nil
}
h := NewHandler("")
got := h.resolveSystemVersionInfo(context.Background())
if got.GoVersion != runtime.Version() {
t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
}
}
func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
pid := 4321
currentGatewayVersionState = func() (int, bool) { return pid, true }
runCount := 0
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
runCount++
return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil
}
h := NewHandler("")
first := h.resolveSystemVersionInfo(context.Background())
second := h.resolveSystemVersionInfo(context.Background())
if first.Version != "v1.2.1" {
t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1")
}
if second.Version != "v1.2.1" {
t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1")
}
if runCount != 1 {
t.Fatalf("run count = %d, want %d", runCount, 1)
}
}
func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
alive := true
pid := 9876
currentGatewayVersionState = func() (int, bool) {
if !alive {
return 0, false
}
return pid, true
}
runCount := 0
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
runCount++
return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil
}
h := NewHandler("")
first := h.resolveSystemVersionInfo(context.Background())
second := h.resolveSystemVersionInfo(context.Background())
if first.Version != "v2.0.1" || second.Version != "v2.0.1" {
t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version)
}
if runCount != 1 {
t.Fatalf("run count after cache hit = %d, want %d", runCount, 1)
}
alive = false
third := h.resolveSystemVersionInfo(context.Background())
if third.Version != "v2.0.2" {
t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2")
}
if runCount != 2 {
t.Fatalf("run count after invalidation = %d, want %d", runCount, 2)
}
}
func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runCount := 0
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
runCount++
return "picoclaw v9.9.9\n", nil
}
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
h := NewHandler("")
got := h.resolveSystemVersionInfo(canceledCtx)
if runCount != 0 {
t.Fatalf("run count = %d, want %d", runCount, 0)
}
if got.Version != "v3.0.0" {
t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0")
}
}
func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) {
setupVersionTestIsolation(t)
originalFinder := findPicoclawBinaryForInfo
t.Cleanup(func() {
findPicoclawBinaryForInfo = originalFinder
})
gateway.mu.Lock()
originalCmd := gateway.cmd
gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"}
gateway.mu.Unlock()
t.Cleanup(func() {
gateway.mu.Lock()
gateway.cmd = originalCmd
gateway.mu.Unlock()
})
got := resolveGatewayBinaryForVersionInfo()
if got != "/tmp/picoclaw-from-gateway" {
t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway")
}
}