Files
code-utils/launchpad.go

429 lines
12 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
package main
import (
"context"
"errors"
"os"
"os/exec"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
gnet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/process"
"view/platform"
)
// ---------------- 启动台:本机监听端口扫描 + 应用启停 ----------------
// launchSample 是进程资源的上一次采样,用于差值计算瞬时 CPU / IO 速率。
type launchSample struct {
at time.Time
cpuTotal float64
ioBytes uint64
}
var (
lpSampleMu sync.Mutex
lpSamples = map[int32]launchSample{}
)
// ---------- Store ----------
func (s *Store) ListLaunchApps() ([]LaunchApp, error) {
rows, e := s.db.Query(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at FROM launch_apps ORDER BY id`)
if e != nil {
return nil, e
}
defer rows.Close()
out := []LaunchApp{}
for rows.Next() {
var x LaunchApp
if e = rows.Scan(&x.ID, &x.Name, &x.Kind, &x.Port, &x.Dir, &x.StartCmd, &x.StopCmd, &x.LastPID, &x.CreatedAt, &x.UpdatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) GetLaunchApp(id int64) (LaunchApp, error) {
var x LaunchApp
e := s.db.QueryRow(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at FROM launch_apps WHERE id=?`, id).
Scan(&x.ID, &x.Name, &x.Kind, &x.Port, &x.Dir, &x.StartCmd, &x.StopCmd, &x.LastPID, &x.CreatedAt, &x.UpdatedAt)
return x, e
}
func (s *Store) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return in, errors.New("NAME_REQUIRED")
}
if in.Kind == "" {
in.Kind = "other"
}
now := nowRFC()
if in.ID == 0 {
res, e := s.db.Exec(`INSERT INTO launch_apps(name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at) VALUES(?,?,?,?,?,?,0,?,?)`,
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, now, now)
if e != nil {
return in, e
}
in.ID, _ = res.LastInsertId()
in.CreatedAt, in.UpdatedAt = now, now
return in, nil
}
_, e := s.db.Exec(`UPDATE launch_apps SET name=?,kind=?,port=?,dir=?,start_cmd=?,stop_cmd=?,updated_at=? WHERE id=?`,
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, now, in.ID)
if e != nil {
return in, e
}
return s.GetLaunchApp(in.ID)
}
func (s *Store) DeleteLaunchApp(id int64) error {
_, e := s.db.Exec(`DELETE FROM launch_apps WHERE id=?`, id)
return e
}
func (s *Store) setLaunchPID(id, pid int64) error {
_, e := s.db.Exec(`UPDATE launch_apps SET last_pid=?,updated_at=? WHERE id=?`, pid, nowRFC(), id)
return e
}
// ---------- 种类推断与命令推荐 ----------
// inferLaunchKind 根据进程名 / 可执行路径 / 命令行猜项目种类。
func inferLaunchKind(name, exe, cmdline string) string {
s := strings.ToLower(name + " " + exe + " " + cmdline)
switch {
case strings.Contains(s, "mysqld"):
return "mysql"
case strings.Contains(s, "redis"):
return "redis"
case strings.Contains(s, "nginx"):
return "nginx"
case strings.Contains(s, "node") || strings.Contains(s, "vite") || strings.Contains(s, "webpack") || strings.Contains(s, "npm") || strings.Contains(s, "pnpm") || strings.Contains(s, "yarn"):
return "node"
case strings.Contains(s, "javaw") || strings.Contains(s, "java ") || strings.HasSuffix(strings.TrimSpace(s), "java") || strings.Contains(s, "java.exe"):
return "java"
case strings.Contains(s, "python") || strings.Contains(s, "uvicorn") || strings.Contains(s, "gunicorn") || strings.Contains(s, "flask") || strings.Contains(s, "django"):
return "python"
case strings.Contains(s, "php"):
return "php"
case strings.Contains(s, "dotnet") || strings.Contains(s, "iisexpress") || strings.Contains(s, "w3wp"):
return "dotnet"
case strings.Contains(s, "go.exe") || strings.Contains(s, "go run") || strings.Contains(s, "__debug_bin"):
return "go"
case strings.Contains(s, "httpd") || strings.Contains(s, "apache") || strings.Contains(s, "caddy"):
return "web"
default:
return "other"
}
}
// launchSuggestions 按种类推荐启停命令;停止命令留空表示直接结束进程树。
var launchSuggestions = map[string]LaunchSuggest{
"node": {Start: []string{"npm run dev", "npm start", "pnpm dev", "yarn dev"}, Stop: nil},
"go": {Start: []string{"go run .", "go run main.go"}, Stop: nil},
"python": {Start: []string{"python main.py", "uvicorn main:app --reload", "python manage.py runserver"}, Stop: nil},
"java": {Start: []string{"mvn spring-boot:run", "java -jar app.jar", "gradle bootRun"}, Stop: nil},
"php": {Start: []string{"php artisan serve", "php -S 127.0.0.1:8000"}, Stop: nil},
"dotnet": {Start: []string{"dotnet run", "dotnet watch"}, Stop: nil},
"nginx": {Start: []string{"nginx"}, Stop: []string{"nginx -s stop", "nginx -s quit"}},
"mysql": {Start: []string{"net start mysql", "mysqld --console"}, Stop: []string{"net stop mysql", "mysqladmin -uroot shutdown"}},
"redis": {Start: []string{"redis-server"}, Stop: []string{"redis-cli shutdown"}},
"web": {Start: []string{"caddy run"}, Stop: []string{"caddy stop"}},
"other": {},
}
func (a *App) LaunchCmdSuggest(kind string) LaunchSuggest {
if s, ok := launchSuggestions[kind]; ok {
return s
}
return LaunchSuggest{}
}
// ---------- 扫描 ----------
// scanListenPorts 返回 pid -> 去重后的监听端口列表。
func scanListenPorts() (map[int32][]int, error) {
conns, e := gnet.Connections("tcp")
if e != nil {
return nil, e
}
seen := map[int32]map[int]bool{}
for _, c := range conns {
if c.Status != "LISTEN" || c.Pid <= 0 {
continue
}
if seen[c.Pid] == nil {
seen[c.Pid] = map[int]bool{}
}
seen[c.Pid][int(c.Laddr.Port)] = true
}
out := map[int32][]int{}
for pid, ports := range seen {
lst := make([]int, 0, len(ports))
for p := range ports {
lst = append(lst, p)
}
sort.Ints(lst)
out[pid] = lst
}
return out, nil
}
// probeProcess 读取进程静态信息与资源占用CPU/IO 用与上次采样的差值算速率)。
func probeProcess(pid int32) (name, exe, cmdline string, cpu, memMB, ioKBs float64) {
p, e := process.NewProcess(pid)
if e != nil {
return
}
name, _ = p.Name()
exe, _ = p.Exe()
cmdline, _ = p.Cmdline()
if mi, e := p.MemoryInfo(); e == nil && mi != nil {
memMB = float64(mi.RSS) / 1024 / 1024
}
now := time.Now()
var cpuTotal float64
if ts, e := p.Times(); e == nil && ts != nil {
cpuTotal = ts.User + ts.System
}
var ioBytes uint64
if io, e := p.IOCounters(); e == nil && io != nil {
ioBytes = io.ReadBytes + io.WriteBytes
}
lpSampleMu.Lock()
prev, ok := lpSamples[pid]
lpSamples[pid] = launchSample{at: now, cpuTotal: cpuTotal, ioBytes: ioBytes}
lpSampleMu.Unlock()
if ok {
wall := now.Sub(prev.at).Seconds()
if wall > 0.2 {
cpu = (cpuTotal - prev.cpuTotal) / wall * 100 / float64(runtime.NumCPU())
if cpu < 0 {
cpu = 0
}
if ioBytes >= prev.ioBytes {
ioKBs = float64(ioBytes-prev.ioBytes) / wall / 1024
}
}
}
return
}
// ListLaunchEntries 合并「保存的应用」与「实时扫描到的监听进程」。
func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
if e := a.ready(); e != nil {
return nil, e
}
apps, e := a.store.ListLaunchApps()
if e != nil {
return nil, e
}
byPid, e := scanListenPorts()
if e != nil {
a.store.Log("warning", "启动台", "端口扫描失败", e.Error())
byPid = map[int32][]int{}
}
self := int32(os.Getpid())
used := map[int32]bool{}
findByPort := func(port int) int32 {
if port <= 0 {
return 0
}
for pid, ports := range byPid {
if used[pid] || pid == self {
continue
}
for _, p := range ports {
if p == port {
return pid
}
}
}
return 0
}
out := []LaunchEntry{}
for _, app := range apps {
ent := LaunchEntry{ID: app.ID, Name: app.Name, Kind: app.Kind, Port: app.Port, Dir: app.Dir, StartCmd: app.StartCmd, StopCmd: app.StopCmd}
pid := int32(0)
if app.LastPID > 0 {
if _, ok := byPid[int32(app.LastPID)]; ok && !used[int32(app.LastPID)] {
pid = int32(app.LastPID)
}
}
if pid == 0 {
pid = findByPort(app.Port)
}
if pid > 0 {
used[pid] = true
ent.Running, ent.PID, ent.Ports = true, pid, byPid[pid]
var name string
name, ent.Exe, ent.Cmdline, ent.CPU, ent.MemMB, ent.IOKBs = probeProcess(pid)
if ent.Kind == "other" || ent.Kind == "" {
ent.Kind = inferLaunchKind(name, ent.Exe, ent.Cmdline)
}
}
out = append(out, ent)
}
scanned := []LaunchEntry{}
for pid, ports := range byPid {
if used[pid] || pid == self {
continue
}
name, exe, cmdline, cpu, memMB, ioKBs := probeProcess(pid)
if name == "" {
name = "PID " + strconv.Itoa(int(pid))
}
ent := LaunchEntry{
Name: name, Kind: inferLaunchKind(name, exe, cmdline),
Running: true, PID: pid, Exe: exe, Cmdline: cmdline, Ports: ports,
CPU: cpu, MemMB: memMB, IOKBs: ioKBs,
}
if len(ports) > 0 {
ent.Port = ports[0]
}
scanned = append(scanned, ent)
}
sort.Slice(scanned, func(i, j int) bool { return scanned[i].Port < scanned[j].Port })
return append(out, scanned...), nil
}
// ---------- 应用增删与启停 ----------
func (a *App) ListLaunchApps() ([]LaunchApp, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListLaunchApps()
}
func (a *App) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
if e := a.ready(); e != nil {
return in, e
}
out, e := a.store.SaveLaunchApp(in)
if e == nil {
a.emit("launchpad:changed", nil)
}
return out, e
}
func (a *App) DeleteLaunchApp(id int64) error {
if e := a.ready(); e != nil {
return e
}
e := a.store.DeleteLaunchApp(id)
if e == nil {
a.emit("launchpad:changed", nil)
}
return e
}
// shellCommand 把一行命令交给系统 shell 解释执行。
func shellCommand(line string) *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/C", line)
}
return exec.Command("sh", "-c", line)
}
// StartLaunchApp 在应用目录以独立进程组执行启动命令,并记录 PID 供停止时杀进程树。
func (a *App) StartLaunchApp(id int64) (LaunchApp, error) {
if e := a.ready(); e != nil {
return LaunchApp{}, e
}
app, e := a.store.GetLaunchApp(id)
if e != nil {
return app, errors.New("LAUNCH_APP_NOT_FOUND")
}
if strings.TrimSpace(app.StartCmd) == "" {
return app, errors.New("LAUNCH_CMD_REQUIRED")
}
cmd := shellCommand(app.StartCmd)
if st, err := os.Stat(app.Dir); err == nil && st.IsDir() {
cmd.Dir = app.Dir
}
platform.ConfigureDetached(cmd)
if e = cmd.Start(); e != nil {
a.store.Log("error", "启动台", "启动失败:"+app.Name, e.Error())
return app, e
}
pid := int64(cmd.Process.Pid)
_ = cmd.Process.Release()
_ = a.store.setLaunchPID(app.ID, pid)
app.LastPID = pid
a.store.Log("info", "启动台", "已启动 "+app.Name, "pid="+strconv.FormatInt(pid, 10)+" cmd="+app.StartCmd)
a.emit("launchpad:changed", nil)
return app, nil
}
// killProcessTree 结束进程及其全部子进程。
func killProcessTree(ctx context.Context, pid int32) error {
if runtime.GOOS == "windows" {
_, e := platform.RunHidden(ctx, "taskkill", "/T", "/F", "/PID", strconv.Itoa(int(pid)))
return e
}
_, e := platform.RunHidden(ctx, "kill", "-15", strconv.Itoa(int(pid)))
return e
}
// StopLaunchApp 停止应用:优先执行停止命令,否则结束记录/指定的进程树。
// pid 参数供未保存的扫描条目直接停止。
func (a *App) StopLaunchApp(id int64, pid int32) error {
if e := a.ready(); e != nil {
return e
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var app LaunchApp
if id > 0 {
var e error
if app, e = a.store.GetLaunchApp(id); e != nil {
return errors.New("LAUNCH_APP_NOT_FOUND")
}
if strings.TrimSpace(app.StopCmd) != "" {
cmd := shellCommand(app.StopCmd)
if st, err := os.Stat(app.Dir); err == nil && st.IsDir() {
cmd.Dir = app.Dir
}
platform.ConfigureDetached(cmd)
if e := cmd.Start(); e != nil {
a.store.Log("error", "启动台", "停止命令执行失败:"+app.Name, e.Error())
return e
}
_ = cmd.Process.Release()
_ = a.store.setLaunchPID(app.ID, 0)
a.store.Log("info", "启动台", "已执行停止命令:"+app.Name, app.StopCmd)
a.emit("launchpad:changed", nil)
return nil
}
if pid <= 0 && app.LastPID > 0 {
pid = int32(app.LastPID)
}
}
if pid <= 0 {
return errors.New("LAUNCH_PID_REQUIRED")
}
if int(pid) == os.Getpid() {
return errors.New("LAUNCH_SELF_FORBIDDEN")
}
if e := killProcessTree(ctx, pid); e != nil {
a.store.Log("error", "启动台", "结束进程失败 pid="+strconv.Itoa(int(pid)), e.Error())
return e
}
if id > 0 {
_ = a.store.setLaunchPID(id, 0)
}
a.store.Log("info", "启动台", "已结束进程", "pid="+strconv.Itoa(int(pid)))
a.emit("launchpad:changed", nil)
return nil
}