Files
MuyueWorkspace/internal/api/handlers_info.go
Augustin 79d082180c
All checks were successful
Beta Release / beta (push) Successful in 46s
feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
- Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh
- Add live CPU/RAM/Network SVG graphs with rolling 30-point history
- Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev
- Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring
- Add backend /api/recent-commands reading bash/zsh history
- Add backend /api/running-processes filtering editors/IDEs/languages
- Add sudo/root indicator ( ROOT) in footer when running as root
- Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side)
- Add Ctrl+R shortcut on dashboard for metrics-only refresh
- Make API key mandatory in onboarding, auto-scan editors via AI chat
- Remove manual editor input, only show AI-detected editors
- Bump version to 0.3.3

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:46:16 +02:00

721 lines
18 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/version"
)
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"name": version.Name,
"version": version.Version,
"author": version.Author,
"sudo": os.Geteuid() == 0,
})
}
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
writeJSON(w, map[string]interface{}{
"system": s.scanResult.System,
})
}
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
type toolInfo struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Version string `json:"version"`
Path string `json:"path"`
}
tools := make([]toolInfo, len(s.scanResult.Tools))
for i, t := range s.scanResult.Tools {
tools[i] = toolInfo{
Name: t.Name,
Installed: t.Installed,
Version: t.Version,
Path: t.Path,
}
}
writeJSON(w, map[string]interface{}{
"tools": tools,
"total": len(tools),
})
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"profile": s.config.Profile,
"terminal": s.config.Terminal,
"bmad": s.config.BMAD,
})
}
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"providers": s.config.AI.Providers,
})
}
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
list, err := skills.List()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),
})
}
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
writeJSON(w, map[string]interface{}{
"servers": servers,
})
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
servers := mcp.ScanServers()
home, _ := os.UserHomeDir()
editors := mcp.DetectInstalledEditors(home)
statuses := mcp.GetAllStatuses()
writeJSON(w, map[string]interface{}{
"servers": servers,
"configured": true,
"detected_editors": editors,
"statuses": statuses,
})
}
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Editor string `json:"editor,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
if body.Editor != "" {
if err := mcp.ConfigureForEditor(s.config, body.Editor); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
if err := mcp.ConfigureAll(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleMCPStatus(w http.ResponseWriter, r *http.Request) {
statuses := mcp.GetAllStatuses()
writeJSON(w, map[string]interface{}{
"statuses": statuses,
})
}
func (s *Server) handleMCPRegistry(w http.ResponseWriter, r *http.Request) {
reg, err := mcp.LoadRegistry()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"registry": reg,
})
}
func (s *Server) handleLSPHealth(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
type healthInfo struct {
Name string `json:"name"`
Language string `json:"language"`
Installed bool `json:"installed"`
Healthy bool `json:"healthy"`
Detail string `json:"detail,omitempty"`
}
var results []healthInfo
for _, srv := range servers {
healthy, detail := lsp.HealthCheck(srv.Name)
results = append(results, healthInfo{
Name: srv.Name,
Language: srv.Language,
Installed: srv.Installed,
Healthy: healthy,
Detail: detail,
})
}
writeJSON(w, map[string]interface{}{
"servers": results,
})
}
func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
ProjectDir string `json:"project_dir,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
if body.ProjectDir == "" {
home, _ := os.UserHomeDir()
body.ProjectDir = home
}
results, err := lsp.AutoInstallForProject(body.ProjectDir)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"results": results,
})
}
func (s *Server) handleLSPEditorConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Editor string `json:"editor"`
Names []string `json:"names,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
allServers := lsp.ScanServers()
var selected []lsp.LSPServer
if len(body.Names) > 0 {
nameSet := map[string]bool{}
for _, n := range body.Names {
nameSet[n] = true
}
for _, srv := range allServers {
if nameSet[srv.Name] {
selected = append(selected, srv)
}
}
} else {
for _, srv := range allServers {
if srv.Installed {
selected = append(selected, srv)
}
}
}
config, err := lsp.GenerateEditorConfigs(selected, body.Editor, "")
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"editor": body.Editor,
"config": config,
})
}
func (s *Server) handleSkillValidate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
skill, err := skills.Get(body.Name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
errs := skills.Validate(skill)
writeJSON(w, map[string]interface{}{
"name": body.Name,
"valid": len(errs) == 0,
"errors": errs,
"dependencies": skills.CheckDependencies(skill),
})
}
func (s *Server) handleSkillTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
SampleTask string `json:"sample_task,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
result := skills.DryRun(body.Name, body.SampleTask)
writeJSON(w, result)
}
func (s *Server) handleSkillExport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
ExportPath string `json:"export_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
if body.ExportPath == "" {
body.ExportPath = home + "/.muyue/exports/" + body.Name + ".md"
}
if err := skills.Export(body.Name, body.ExportPath); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok", "path": body.ExportPath})
}
func (s *Server) handleSkillImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
ImportPath string `json:"import_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
skill, err := skills.Import(body.ImportPath)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if err := skills.Create(skill); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"status": "ok", "skill": skill.Name})
}
func (s *Server) handleDashboardStatus(w http.ResponseWriter, r *http.Request) {
mcpStatuses := mcp.GetAllStatuses()
lspServers := lsp.ScanServers()
skillList, _ := skills.List()
mcpHealthy := 0
mcpTotal := len(mcpStatuses)
for _, st := range mcpStatuses {
if st.Healthy {
mcpHealthy++
}
}
lspInstalled := 0
lspTotal := len(lspServers)
for _, srv := range lspServers {
if srv.Installed {
lspInstalled++
}
}
skillsDeployed := len(skillList)
var skillIssues []string
for _, sk := range skillList {
missing := skills.CheckDependencies(&sk)
if len(missing) > 0 {
for _, dep := range missing {
skillIssues = append(skillIssues, sk.Name+": missing "+dep.Type+" "+dep.Name)
}
}
}
writeJSON(w, map[string]interface{}{
"mcp": map[string]interface{}{
"total": mcpTotal,
"healthy": mcpHealthy,
"servers": mcpStatuses,
},
"lsp": map[string]interface{}{
"total": lspTotal,
"installed": lspInstalled,
"servers": lspServers,
},
"skills": map[string]interface{}{
"total": skillsDeployed,
"issues": skillIssues,
"deployed": skillList,
},
})
}
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
editors := scanner.ScanEditors()
writeJSON(w, map[string]interface{}{"editors": editors})
}
func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
type providerQuota struct {
Name string `json:"name"`
Active bool `json:"active"`
Healthy bool `json:"healthy"`
Data map[string]interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
var results []providerQuota
client := &http.Client{Timeout: 8 * time.Second}
for _, p := range s.config.AI.Providers {
q := providerQuota{Name: p.Name, Active: p.Active}
switch p.Name {
case "minimax":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if models, ok := data["model_remains"].([]interface{}); ok {
filtered := make([]map[string]interface{}, 0)
for _, m := range models {
if mm, ok := m.(map[string]interface{}); ok {
usage, _ := mm["current_interval_usage_count"].(float64)
total, _ := mm["current_interval_total_count"].(float64)
if total > 0 {
filtered = append(filtered, map[string]interface{}{
"model": mm["model_name"],
"used": usage,
"total": total,
"remaining": total - usage,
"weekly_used": mm["current_weekly_usage_count"],
"weekly_total": mm["current_weekly_total_count"],
})
}
}
}
q.Data = map[string]interface{}{"models": filtered}
q.Healthy = true
}
}
}
case "zai":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
q.Data = data
q.Healthy = true
}
}
default:
q.Error = "quota not supported"
}
results = append(results, q)
}
writeJSON(w, map[string]interface{}{"providers": results})
}
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir()
type cmdEntry struct {
Cmd string `json:"cmd"`
Shell string `json:"shell"`
}
var entries []cmdEntry
for _, histFile := range []string{".bash_history", ".zsh_history"} {
path := filepath.Join(home, histFile)
data, err := os.ReadFile(path)
if err != nil {
continue
}
shell := "bash"
if strings.Contains(histFile, "zsh") {
shell = "zsh"
}
lines := strings.Split(string(data), "\n")
start := len(lines) - 25
if start < 0 {
start = 0
}
for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, ": ") {
parts := strings.SplitN(line, ";", 2)
if len(parts) == 2 {
line = strings.TrimSpace(parts[1])
} else {
continue
}
}
if line == "" {
continue
}
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
}
}
max := 20
if len(entries) > max {
entries = entries[:max]
}
writeJSON(w, map[string]interface{}{"commands": entries})
}
func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) {
type proc struct {
PID int `json:"pid"`
Name string `json:"name"`
Command string `json:"command"`
CPU string `json:"cpu"`
Mem string `json:"mem"`
}
var procs []proc
editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"}
langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"}
interesting := append(editors, langs...)
interesting = append(interesting, "muyue")
cmd := exec.Command("ps", "aux")
out, err := cmd.Output()
if err != nil {
writeJSON(w, map[string]interface{}{"processes": procs})
return
}
lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
fullCmd := strings.Join(fields[10:], " ")
name := filepath.Base(fields[10])
matched := false
for _, pattern := range interesting {
if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) {
matched = true
break
}
}
if !matched {
continue
}
var pid int
fmt.Sscanf(fields[1], "%d", &pid)
procs = append(procs, proc{
PID: pid,
Name: name,
Command: fullCmd,
CPU: fields[2],
Mem: fields[3],
})
}
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)
}