diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 33a0b78..35aea93 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -23,6 +23,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { "name": version.Name, "version": version.Version, "author": version.Author, + "sudo": os.Geteuid() == 0, }) } @@ -609,3 +610,111 @@ func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) writeJSON(w, map[string]interface{}{"processes": procs}) } + +type sysMetrics struct { + CPUPercent float64 `json:"cpu_percent"` + MemPercent float64 `json:"mem_percent"` + MemUsedMB float64 `json:"mem_used_mb"` + MemTotalMB float64 `json:"mem_total_mb"` + NetRxKBs float64 `json:"net_rx_kbs"` + NetTxKBs float64 `json:"net_tx_kbs"` +} + +var ( + lastCPU [2]float64 + lastNet [2]float64 + lastNetTs time.Time + lastCPUSet bool +) + +func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) { + m := sysMetrics{} + + // CPU from /proc/stat + if data, err := os.ReadFile("/proc/stat"); err == nil { + line := strings.Split(string(data), "\n")[0] + fields := strings.Fields(line) + if len(fields) >= 5 { + var idle, total float64 + for i := 1; i < len(fields) && i <= 4; i++ { + var v float64 + fmt.Sscanf(fields[i], "%f", &v) + total += v + if i == 4 { + idle = v + } + } + if lastCPUSet { + dIdle := idle - lastCPU[0] + dTotal := total - lastCPU[1] + if dTotal > 0 { + m.CPUPercent = (1 - dIdle/dTotal) * 100 + } + } + lastCPU = [2]float64{idle, total} + lastCPUSet = true + } + } + + // Memory from /proc/meminfo + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + var memTotal, memAvailable float64 + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + var v float64 + fmt.Sscanf(fields[1], "%f", &v) + switch fields[0] { + case "MemTotal:": + memTotal = v + case "MemAvailable:": + memAvailable = v + } + } + if memTotal > 0 { + m.MemTotalMB = memTotal / 1024 + m.MemUsedMB = (memTotal - memAvailable) / 1024 + m.MemPercent = (memTotal - memAvailable) / memTotal * 100 + } + } + + // Network from /proc/net/dev + if data, err := os.ReadFile("/proc/net/dev"); err == nil { + var rxBytes, txBytes float64 + for _, line := range strings.Split(string(data), "\n")[2:] { + fields := strings.Fields(line) + if len(fields) < 10 { + continue + } + iface := strings.TrimSuffix(fields[0], ":") + if iface == "lo" { + continue + } + var rx, tx float64 + fmt.Sscanf(fields[1], "%f", &rx) + fmt.Sscanf(fields[9], "%f", &tx) + rxBytes += rx + txBytes += tx + } + now := time.Now() + if !lastNetTs.IsZero() { + elapsed := now.Sub(lastNetTs).Seconds() + if elapsed > 0 { + m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed + m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed + if m.NetRxKBs < 0 { + m.NetRxKBs = 0 + } + if m.NetTxKBs < 0 { + m.NetTxKBs = 0 + } + } + } + lastNet = [2]float64{rxBytes, txBytes} + lastNetTs = now + } + + writeJSON(w, m) +} diff --git a/internal/api/server.go b/internal/api/server.go index 02a0a1c..57e4dfb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -116,6 +116,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands) s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses) + s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/version/version.go b/internal/version/version.go index 5c5e1cc..e5377aa 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.3.2" + Version = "0.3.3" Author = "La Légion de Muyue" ) diff --git a/web/src/api/client.js b/web/src/api/client.js index 68b3710..9affabf 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -40,6 +40,7 @@ const api = { getProvidersQuota: () => request('/providers/quota'), getRecentCommands: () => request('/recent-commands'), getRunningProcesses: () => request('/running-processes'), + getSystemMetrics: () => request('/system/metrics'), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index aca54b0..5d9f4db 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react' import api from '../api/client' import { getTheme, applyTheme } from '../themes' @@ -13,6 +13,9 @@ export default function App() { const [activeTab, setActiveTab] = useState('dash') const [info, setInfo] = useState({}) const [clock, setClock] = useState(new Date()) + const [isSudo, setIsSudo] = useState(false) + const [dashRefreshKey, setDashRefreshKey] = useState(0) + const dashRefreshRef = useRef(null) const [updates, setUpdates] = useState([]) const [tools, setTools] = useState([]) const [config, setConfig] = useState(null) @@ -27,7 +30,7 @@ export default function App() { ], [t]) useEffect(() => { - api.getInfo().then(setInfo).catch(() => {}) + api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {}) api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) api.getConfig().then(d => { @@ -60,6 +63,11 @@ export default function App() { if (map[e.code]) { e.preventDefault() setActiveTab(map[e.code]) + return + } + if (e.ctrlKey && e.code === 'KeyR') { + e.preventDefault() + if (dashRefreshRef.current) dashRefreshRef.current() } } window.addEventListener('keydown', onKey) @@ -72,27 +80,21 @@ export default function App() { const installed = tools.filter(tool => tool.installed).length const WINDOW_SHORTCUTS = useMemo(() => ({ - dash: [ - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, - ], + dash: [], studio: [ { keys: layout.keys.enter, desc: t('statusbar.sendMessage') }, { keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') }, - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, ], shell: [ { keys: layout.keys.enter, desc: t('statusbar.runCommand') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, - ], - config: [ - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, ], + config: [], }), [layout, t]) const renderContent = () => { switch (activeTab) { - case 'dash': return + case 'dash': return case 'studio': return case 'shell': return case 'config': return @@ -147,6 +149,12 @@ export default function App() {