diff --git a/backend/sysinfo.go b/backend/sysinfo.go index c65b812..2217aac 100644 --- a/backend/sysinfo.go +++ b/backend/sysinfo.go @@ -313,6 +313,26 @@ func KillProcess(pid int32, force bool) error { } return p.Terminate() } +// CollectFull gathers a complete metrics snapshot including GPU data. +// Unlike NewSysInfo, it does not start a background emitter and does not +// require a Wails runtime context — safe to call from server mode. +func CollectFull(ctx context.Context) (*Metrics, error) { + s := &SysInfo{ctx: ctx} + m, err := s.CollectOnce() + if err != nil { + return nil, err + } + name, tot, used, util := queryGPU(ctx) + if name != "" { + m.GPUName = name + m.GPUTotal = tot + m.GPUUsed = used + m.GPUUtil = util + m.GPUProcesses = queryGPUProcesses(ctx) + } + return m, nil +} + // GetManPage returns the plain-text man page for the given command name. // Returns an empty string if man is not installed or the page does not exist. func GetManPage(name string) string { diff --git a/main.go b/main.go index 8fecda1..0944a1d 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,10 @@ package main import ( "embed" + "fmt" + "os" + "strconv" + "strings" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -12,6 +16,18 @@ import ( var assets embed.FS func main() { + noGUI, port, help := parseSysmonArgs() + + if help { + printHelp() + os.Exit(0) + } + + if noGUI { + runServer(port) + return + } + // Create an instance of the app structure app := NewApp() @@ -34,3 +50,56 @@ func main() { println("Error:", err.Error()) } } + +// parseSysmonArgs scans os.Args manually so we don't call flag.Parse(), +// which would conflict with flags that Wails injects in dev mode (e.g. -assetdir). +func parseSysmonArgs() (noGUI bool, port int, help bool) { + port = 9731 + args := os.Args[1:] + for i := 0; i < len(args); i++ { + arg := args[i] + switch arg { + case "--no-gui": + noGUI = true + case "--help", "-h": + help = true + case "--port": + if i+1 < len(args) { + i++ + if p, err := strconv.Atoi(args[i]); err == nil && p > 0 && p <= 65535 { + port = p + } + } + default: + if strings.HasPrefix(arg, "--port=") { + if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil && p > 0 && p <= 65535 { + port = p + } + } + } + } + return +} + +func printHelp() { + fmt.Print(`sysmon — system monitor + +Usage: + sysmon Launch with graphical interface (default) + sysmon --no-gui Run as headless HTTP API server (port 9731) + sysmon --no-gui --port N Run server on port N + +Options: + --no-gui Start without GUI, expose HTTP API instead + --port HTTP server port (default: 9731, requires --no-gui) + --help, -h Show this help + +Server endpoints (--no-gui mode): + GET /api/metrics Latest metrics snapshot (JSON) + GET /api/metrics/stream Server-Sent Events stream (1 s interval) + GET /api/process/ Process detail (JSON) + POST /api/process//kill Kill process (?force=true → SIGKILL) + GET /api/man/ Manual page for a command +`) +} + diff --git a/server.go b/server.go new file mode 100644 index 0000000..05333cd --- /dev/null +++ b/server.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "sysmon/backend" +) + +func runServer(port int) { + _, cancel := context.WithCancel(context.Background()) + defer cancel() + + mux := http.NewServeMux() + + // GET /api/metrics — latest snapshot + mux.HandleFunc("/api/metrics", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/metrics" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + m, err := backend.CollectFull(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + json.NewEncoder(w).Encode(m) //nolint:errcheck + }) + + // GET /api/metrics/stream — SSE push every 1 s + mux.HandleFunc("/api/metrics/stream", func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + m, err := backend.CollectFull(r.Context()) + if err != nil { + continue + } + data, _ := json.Marshal(m) + fmt.Fprintf(w, "data: %s\n\n", data) //nolint:errcheck + flusher.Flush() + } + } + }) + + // GET /api/process/ + // POST /api/process//kill[?force=true] + mux.HandleFunc("/api/process/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + path := strings.TrimPrefix(r.URL.Path, "/api/process/") + parts := strings.SplitN(path, "/", 2) + if parts[0] == "" { + http.Error(w, "missing pid", http.StatusBadRequest) + return + } + pid64, err := strconv.ParseInt(parts[0], 10, 32) + if err != nil || pid64 <= 0 { + http.Error(w, "invalid pid", http.StatusBadRequest) + return + } + + // Kill action + if len(parts) == 2 && parts[1] == "kill" { + if r.Method != http.MethodPost { + http.Error(w, "use POST /api/process//kill", http.StatusMethodNotAllowed) + return + } + force := r.URL.Query().Get("force") == "true" + if err := backend.KillProcess(int32(pid64), force); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + + // Process detail + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + detail, err := backend.FetchProcessDetail(int32(pid64)) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(detail) //nolint:errcheck + }) + + // GET /api/man/ + mux.HandleFunc("/api/man/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + name := strings.TrimPrefix(r.URL.Path, "/api/man/") + // Strict validation: only allow characters common in command names. + // exec.Command passes the name as a separate arg (no shell), but we + // reject obviously bad input early to avoid surprising behaviour. + if name == "" || len(name) > 64 || strings.ContainsAny(name, "/ \t\n\r\\\"';|&`$<>(){}") { + http.Error(w, "invalid name", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") + fmt.Fprint(w, backend.GetManPage(name)) //nolint:errcheck + }) + + addr := fmt.Sprintf(":%d", port) + fmt.Printf("sysmon server → http://localhost%s\n\n", addr) + fmt.Println("Endpoints:") + fmt.Printf(" GET /api/metrics — latest metrics snapshot (JSON)\n") + fmt.Printf(" GET /api/metrics/stream — Server-Sent Events stream (1 s)\n") + fmt.Printf(" GET /api/process/ — process detail (JSON)\n") + fmt.Printf(" POST /api/process//kill — kill process (?force=true → SIGKILL)\n") + fmt.Printf(" GET /api/man/ — manual page for a command\n") + fmt.Println("\nPress Ctrl+C to stop.") + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 0, // disabled: SSE streams are long-lived + IdleTimeout: 60 * time.Second, + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-quit + fmt.Println("\nShutting down…") + cancel() + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutCancel() + srv.Shutdown(shutCtx) //nolint:errcheck + }() + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "server error: %v\n", err) + os.Exit(1) + } +}