Files
code-utils/launchpad.go
2026-08-14 07:52:01 +08:00

429 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}