diff --git a/pkg/nodes/registry_server.go b/pkg/nodes/registry_server.go index 5fc92a3..4fd2146 100644 --- a/pkg/nodes/registry_server.go +++ b/pkg/nodes/registry_server.go @@ -6,12 +6,14 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" + "runtime/debug" "strconv" "strings" "syscall" @@ -76,6 +78,7 @@ func (s *RegistryServer) Start(ctx context.Context) error { mux.HandleFunc("/webui/api/chat", s.handleWebUIChat) mux.HandleFunc("/webui/api/chat/history", s.handleWebUIChatHistory) mux.HandleFunc("/webui/api/chat/stream", s.handleWebUIChatStream) + mux.HandleFunc("/webui/api/version", s.handleWebUIVersion) mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload) mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes) mux.HandleFunc("/webui/api/cron", s.handleWebUICron) @@ -430,6 +433,22 @@ func (s *RegistryServer) handleWebUIChatStream(w http.ResponseWriter, r *http.Re } } +func (s *RegistryServer) handleWebUIVersion(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "gateway_version": gatewayBuildVersion(), + "webui_version": detectWebUIVersion(strings.TrimSpace(s.webUIDir)), + }) +} + func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -441,6 +460,15 @@ func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request if s.mgr != nil { list = s.mgr.List() } + host, _ := os.Hostname() + local := NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: time.Now(), Online: true} + if strings.TrimSpace(host) != "" { + local.Name = host + } + if ip := detectLocalIP(); ip != "" { + local.Endpoint = ip + } + list = append([]NodeInfo{local}, list...) _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list}) case http.MethodPost: var body struct { @@ -760,6 +788,82 @@ func readSkillMeta(path string) (desc string, tools []string, systemPrompt strin return } +func gatewayBuildVersion() string { + if bi, ok := debug.ReadBuildInfo(); ok && bi != nil { + ver := strings.TrimSpace(bi.Main.Version) + rev := "" + for _, s := range bi.Settings { + if s.Key == "vcs.revision" { + rev = s.Value + break + } + } + if len(rev) > 8 { + rev = rev[:8] + } + if ver == "" || ver == "(devel)" { + ver = "devel" + } + if rev != "" { + return ver + "+" + rev + } + return ver + } + return "unknown" +} + +func detectWebUIVersion(webUIDir string) string { + if strings.TrimSpace(webUIDir) == "" { + return "unknown" + } + assets := filepath.Join(webUIDir, "assets") + entries, err := os.ReadDir(assets) + if err != nil { + return "unknown" + } + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, "index-") && strings.HasSuffix(name, ".js") { + mid := strings.TrimSuffix(strings.TrimPrefix(name, "index-"), ".js") + if mid != "" { + return mid + } + } + } + return "unknown" +} + +func detectLocalIP() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, _ := iface.Addrs() + for _, a := range addrs { + var ip net.IP + switch v := a.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() { + continue + } + ip = ip.To4() + if ip == nil { + continue + } + return ip.String() + } + } + return "" +} + func normalizeCronJob(v interface{}) map[string]interface{} { if v == nil { return map[string]interface{}{} diff --git a/webui/src/context/AppContext.tsx b/webui/src/context/AppContext.tsx index 381121d..c841a5a 100644 --- a/webui/src/context/AppContext.tsx +++ b/webui/src/context/AppContext.tsx @@ -25,7 +25,10 @@ interface AppContextType { refreshNodes: () => Promise; refreshSkills: () => Promise; refreshSessions: () => Promise; + refreshVersion: () => Promise; loadConfig: () => Promise; + gatewayVersion: string; + webuiVersion: string; hotReloadFields: string[]; hotReloadFieldDetails: Array<{ path: string; name?: string; description?: string }>; q: string; @@ -50,7 +53,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children const [nodes, setNodes] = useState('[]'); const [cron, setCron] = useState([]); const [skills, setSkills] = useState([]); - const [sessions, setSessions] = useState([{ key: 'webui:default', title: 'Default' }]); + const [sessions, setSessions] = useState([{ key: 'main', title: 'main' }]); + const [gatewayVersion, setGatewayVersion] = useState('unknown'); + const [webuiVersion, setWebuiVersion] = useState('unknown'); const [hotReloadFields, setHotReloadFields] = useState([]); const [hotReloadFieldDetails, setHotReloadFieldDetails] = useState>([]); @@ -137,9 +142,21 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, [q]); + const refreshVersion = useCallback(async () => { + try { + const r = await fetch(`/webui/api/version${q}`); + if (!r.ok) throw new Error('Failed to load version'); + const j = await r.json(); + setGatewayVersion(j.gateway_version || 'unknown'); + setWebuiVersion(j.webui_version || 'unknown'); + } catch (e) { + console.error(e); + } + }, [q]); + const refreshAll = useCallback(async () => { - await Promise.all([loadConfig(), refreshCron(), refreshNodes(), refreshSkills(), refreshSessions()]); - }, [loadConfig, refreshCron, refreshNodes, refreshSkills, refreshSessions]); + await Promise.all([loadConfig(), refreshCron(), refreshNodes(), refreshSkills(), refreshSessions(), refreshVersion()]); + }, [loadConfig, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion]); useEffect(() => { refreshAll(); @@ -148,9 +165,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children refreshNodes(); refreshSkills(); refreshSessions(); + refreshVersion(); }, 10000); return () => clearInterval(interval); - }, [token, refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions]); + }, [token, refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion]); return ( = ({ children cfg, setCfg, cfgRaw, setCfgRaw, nodes, setNodes, cron, setCron, skills, setSkills, sessions, setSessions, - refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, loadConfig, - hotReloadFields, hotReloadFieldDetails, q + refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion, loadConfig, + gatewayVersion, webuiVersion, hotReloadFields, hotReloadFieldDetails, q }}> {children} diff --git a/webui/src/pages/Dashboard.tsx b/webui/src/pages/Dashboard.tsx index 69c8671..c55cae0 100644 --- a/webui/src/pages/Dashboard.tsx +++ b/webui/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import StatCard from '../components/StatCard'; const Dashboard: React.FC = () => { const { t } = useTranslation(); - const { isGatewayOnline, sessions, cron, nodes, refreshAll } = useAppContext(); + const { isGatewayOnline, sessions, cron, nodes, refreshAll, gatewayVersion, webuiVersion } = useAppContext(); const onlineNodes = useMemo(() => { try { @@ -19,8 +19,11 @@ const Dashboard: React.FC = () => { return (
-
-

{t('dashboard')}

+
+
+

{t('dashboard')}

+
Gateway: {gatewayVersion} ยท WebUI: {webuiVersion}
+