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:
parent
e88df4ff9c
commit
ff0266a40e
8 changed files with 725 additions and 5 deletions
|
|
@ -35,24 +35,26 @@ func getPidPath() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func isProcessRunning(pid int) bool {
|
func isProcessRunning(pid int) bool {
|
||||||
if runtime.GOOS == "windows" {
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.Contains(string(output), strconv.Itoa(pid))
|
return strings.Contains(string(output), strconv.Itoa(pid))
|
||||||
} else if runtime.GOOS == "darwin" {
|
case "darwin":
|
||||||
cmd := exec.Command("ps", "aux")
|
cmd := exec.Command("ps", "aux")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
||||||
|
default:
|
||||||
|
// Linux and other unix-like systems.
|
||||||
|
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
// Linux
|
|
||||||
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGatewayStatus() gatewayStatus {
|
func getGatewayStatus() gatewayStatus {
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Launcher service parameters (port/public)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
h.registerLauncherConfigRoutes(mux)
|
||||||
|
|
||||||
|
// Runtime build/version metadata
|
||||||
|
h.registerVersionRoutes(mux)
|
||||||
|
|
||||||
// WeChat QR login flow
|
// WeChat QR login flow
|
||||||
h.registerWeixinRoutes(mux)
|
h.registerWeixinRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
345
web/backend/api/version.go
Normal file
345
web/backend/api/version.go
Normal 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
web/backend/api/version_test.go
Normal file
317
web/backend/api/version_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,13 @@ export interface LauncherConfig {
|
||||||
allowed_cidrs: string[]
|
allowed_cidrs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemVersionInfo {
|
||||||
|
version: string
|
||||||
|
git_commit?: string
|
||||||
|
build_time?: string
|
||||||
|
go_version: string
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await launcherFetch(path, options)
|
const res = await launcherFetch(path, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -62,3 +69,7 @@ export async function setLauncherConfig(
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSystemVersionInfo(): Promise<SystemVersionInfo> {
|
||||||
|
return request<SystemVersionInfo>("/api/system/version")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import {
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconTools,
|
IconTools,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Link, useRouterState } from "@tanstack/react-router"
|
import { Link, useRouterState } from "@tanstack/react-router"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { getSystemVersionInfo } from "@/api/system"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
|
|
@ -27,6 +29,7 @@ import {
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
SidebarFooter,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
@ -78,6 +81,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
||||||
t,
|
t,
|
||||||
})
|
})
|
||||||
|
const { data: versionInfo } = useQuery({
|
||||||
|
queryKey: ["system", "version"],
|
||||||
|
queryFn: getSystemVersionInfo,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const versionText = versionInfo?.version ?? t("footer.version_unknown")
|
||||||
|
|
||||||
const navGroups: NavGroup[] = React.useMemo(() => {
|
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|
@ -235,6 +245,26 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarFooter className="border-t-border/30 group-data-[collapsible=icon]:hidden border-t px-3 py-2">
|
||||||
|
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4">
|
||||||
|
<div className="truncate" title={versionText}>
|
||||||
|
<span className="text-foreground/80">{t("footer.version")}:</span>{" "}
|
||||||
|
{versionText}
|
||||||
|
</div>
|
||||||
|
{versionInfo?.git_commit && (
|
||||||
|
<div className="truncate" title={versionInfo.git_commit}>
|
||||||
|
<span className="text-foreground/80">{t("footer.commit")}:</span>{" "}
|
||||||
|
{versionInfo.git_commit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{versionInfo?.build_time && (
|
||||||
|
<div className="truncate" title={versionInfo.build_time}>
|
||||||
|
<span className="text-foreground/80">{t("footer.build")}:</span>{" "}
|
||||||
|
{versionInfo.build_time}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "Loading..."
|
"loading": "Loading..."
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"version": "Version",
|
||||||
|
"commit": "Commit",
|
||||||
|
"build": "Build",
|
||||||
|
"version_unknown": "Unknown"
|
||||||
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "Manage OAuth and token-based credentials for supported providers.",
|
"description": "Manage OAuth and token-based credentials for supported providers.",
|
||||||
"loading": "Loading credentials...",
|
"loading": "Loading credentials...",
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "加载中..."
|
"loading": "加载中..."
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"version": "版本",
|
||||||
|
"commit": "提交",
|
||||||
|
"build": "构建",
|
||||||
|
"version_unknown": "未知"
|
||||||
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
||||||
"loading": "正在加载凭据...",
|
"loading": "正在加载凭据...",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue