feat(dashboard): add quota monitoring, process list, and command history
All checks were successful
Beta Release / beta (push) Successful in 44s

- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-23 19:24:23 +02:00
parent 3948a4c656
commit 7682717093
6 changed files with 592 additions and 537 deletions

View File

@@ -2,8 +2,14 @@ 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"
@@ -415,3 +421,191 @@ 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})
}

View File

@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"log"
"net/http"
"strings"
@@ -23,9 +24,26 @@ type Server struct {
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
mux: http.NewServeMux(),
}
// Auto-initialize config if nil or if no config file exists on disk
if cfg == nil || !config.Exists() {
defaultCfg := config.Default()
if cfg != nil {
// Preserve any user-provided settings from cfg
defaultCfg.Profile = cfg.Profile
defaultCfg.AI = cfg.AI
defaultCfg.Tools = cfg.Tools
defaultCfg.BMAD = cfg.BMAD
defaultCfg.Terminal = cfg.Terminal
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
log.Printf("config: initial save failed: %v", err)
}
cfg = defaultCfg
}
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.agentRegistry = agent.DefaultRegistry()
@@ -95,6 +113,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {