diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go index 1138c12db..397b712f7 100644 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -35,24 +35,26 @@ func getPidPath() string { } 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)) output, err := cmd.Output() if err != nil { return false } return strings.Contains(string(output), strconv.Itoa(pid)) - } else if runtime.GOOS == "darwin" { + case "darwin": cmd := exec.Command("ps", "aux") output, err := cmd.Output() if err != nil { return false } 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 { diff --git a/web/backend/api/router.go b/web/backend/api/router.go index ce652d4c4..af490d8b5 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -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) diff --git a/web/backend/api/version.go b/web/backend/api/version.go new file mode 100644 index 000000000..6232b989b --- /dev/null +++ b/web/backend/api/version.go @@ -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 +} diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go new file mode 100644 index 000000000..31c5366ab --- /dev/null +++ b/web/backend/api/version_test.go @@ -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") + } +} diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 2e2f36f15..dfc48b6b8 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -13,6 +13,13 @@ export interface LauncherConfig { allowed_cidrs: string[] } +export interface SystemVersionInfo { + version: string + git_commit?: string + build_time?: string + go_version: string +} + async function request(path: string, options?: RequestInit): Promise { const res = await launcherFetch(path, options) if (!res.ok) { @@ -62,3 +69,7 @@ export async function setLauncherConfig( body: JSON.stringify(payload), }) } + +export async function getSystemVersionInfo(): Promise { + return request("/api/system/version") +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 0e135c0c1..18bc9f092 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -10,10 +10,12 @@ import { IconSparkles, IconTools, } from "@tabler/icons-react" +import { useQuery } from "@tanstack/react-query" import { Link, useRouterState } from "@tanstack/react-router" import * as React from "react" import { useTranslation } from "react-i18next" +import { getSystemVersionInfo } from "@/api/system" import { Collapsible, CollapsibleContent, @@ -27,6 +29,7 @@ import { SidebarGroupLabel, SidebarMenu, SidebarMenuButton, + SidebarFooter, SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar" @@ -78,6 +81,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(), 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(() => { return [ @@ -235,6 +245,26 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ))} + +
+
+ {t("footer.version")}:{" "} + {versionText} +
+ {versionInfo?.git_commit && ( +
+ {t("footer.commit")}:{" "} + {versionInfo.git_commit} +
+ )} + {versionInfo?.build_time && ( +
+ {t("footer.build")}:{" "} + {versionInfo.build_time} +
+ )} +
+
) diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 38cdeb324..9d170a4c8 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -93,6 +93,12 @@ "labels": { "loading": "Loading..." }, + "footer": { + "version": "Version", + "commit": "Commit", + "build": "Build", + "version_unknown": "Unknown" + }, "credentials": { "description": "Manage OAuth and token-based credentials for supported providers.", "loading": "Loading credentials...", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 9ec4ec967..b214753ca 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -93,6 +93,12 @@ "labels": { "loading": "加载中..." }, + "footer": { + "version": "版本", + "commit": "提交", + "build": "构建", + "version_unknown": "未知" + }, "credentials": { "description": "管理已支持服务商的 OAuth 与 Token 凭据。", "loading": "正在加载凭据...",