From 18ec2631aaa8594b2bb3b79ea2d2b7fe91ea091c Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:39:11 +0800 Subject: [PATCH] feat(web): display backend version info in sidebar --- web/backend/api/router.go | 3 + web/backend/api/version.go | 31 +++++++ web/backend/api/version_test.go | 98 +++++++++++++++++++++ web/frontend/src/api/system.ts | 11 +++ web/frontend/src/components/app-sidebar.tsx | 30 +++++++ web/frontend/src/i18n/locales/en.json | 6 ++ web/frontend/src/i18n/locales/zh.json | 6 ++ 7 files changed, 185 insertions(+) create mode 100644 web/backend/api/version.go create mode 100644 web/backend/api/version_test.go 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..a3b7f2aba --- /dev/null +++ b/web/backend/api/version.go @@ -0,0 +1,31 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type systemVersionResponse struct { + Version string `json:"version"` + GitCommit string `json:"git_commit,omitempty"` + BuildTime string `json:"build_time,omitempty"` + GoVersion string `json:"go_version"` +} + +func (h *Handler) registerVersionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/system/version", h.handleGetVersion) +} + +func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) { + buildTime, goVer := config.FormatBuildInfo() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(systemVersionResponse{ + Version: config.GetVersion(), + GitCommit: config.GitCommit, + BuildTime: buildTime, + GoVersion: goVer, + }) +} diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go new file mode 100644 index 000000000..46cb844f3 --- /dev/null +++ b/web/backend/api/version_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestGetSystemVersion(t *testing.T) { + originalVersion := config.Version + originalGitCommit := config.GitCommit + originalBuildTime := config.BuildTime + originalGoVersion := config.GoVersion + t.Cleanup(func() { + config.Version = originalVersion + config.GitCommit = originalGitCommit + config.BuildTime = originalBuildTime + config.GoVersion = originalGoVersion + }) + + config.Version = "v1.2.3" + config.GitCommit = "deadbeef" + config.BuildTime = "2026-03-27T12:34:56Z" + config.GoVersion = "go1.24.1" + + 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 != config.Version { + t.Fatalf("version = %q, want %q", got.Version, config.Version) + } + if got.GitCommit != config.GitCommit { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, config.GitCommit) + } + if got.BuildTime != config.BuildTime { + t.Fatalf("build_time = %q, want %q", got.BuildTime, config.BuildTime) + } + if got.GoVersion != config.GoVersion { + t.Fatalf("go_version = %q, want %q", got.GoVersion, config.GoVersion) + } +} + +func TestGetSystemVersionUsesRuntimeGoVersionFallback(t *testing.T) { + originalVersion := config.Version + originalGitCommit := config.GitCommit + originalBuildTime := config.BuildTime + originalGoVersion := config.GoVersion + t.Cleanup(func() { + config.Version = originalVersion + config.GitCommit = originalGitCommit + config.BuildTime = originalBuildTime + config.GoVersion = originalGoVersion + }) + + config.Version = "dev" + config.GitCommit = "" + config.BuildTime = "" + config.GoVersion = "" + + 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.GoVersion != runtime.Version() { + t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version()) + } +} diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 543c8694d..6921d45c4 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -11,6 +11,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 fetch(path, options) if (!res.ok) { @@ -60,3 +67,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 a7c60f893..72543bc69 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -79,6 +79,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 9dde090f8..76ff8c09f 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -79,6 +79,12 @@ "labels": { "loading": "加载中..." }, + "footer": { + "version": "版本", + "commit": "提交", + "build": "构建", + "version_unknown": "未知" + }, "credentials": { "description": "管理已支持服务商的 OAuth 与 Token 凭据。", "loading": "正在加载凭据...",