package main // launch_run.go:启动台启停运行态(starting/running/failed)与本地进程日志。 // launch_logs 仅存本机 SQLite,不参与云同步。 import ( "bufio" "io" "strings" "sync" "time" ) type launchRunState struct { mu sync.Mutex Status string // starting | running | failed | stopped Error string LastLog string StartedAt time.Time } var ( lpRunMu sync.Mutex lpRuns = map[int64]*launchRunState{} ) func lpState(id int64) *launchRunState { lpRunMu.Lock() defer lpRunMu.Unlock() st, ok := lpRuns[id] if !ok { st = &launchRunState{Status: "stopped"} lpRuns[id] = st } return st } func (st *launchRunState) snapshot() (status, errMsg, lastLog string) { st.mu.Lock() defer st.mu.Unlock() return st.Status, st.Error, st.LastLog } func (st *launchRunState) set(status, errMsg, lastLog string) { st.mu.Lock() defer st.mu.Unlock() if status != "" { st.Status = status } if errMsg != "" || status == "running" || status == "starting" { st.Error = errMsg } if lastLog != "" { st.LastLog = lastLog } if status == "starting" { st.StartedAt = time.Now() st.Error = "" } } func (s *Store) appendLaunchLog(appID int64, level, line string) { line = strings.TrimRight(line, "\r\n") if strings.TrimSpace(line) == "" { return } if len(line) > 4000 { line = line[:4000] + "…" } if level == "" { level = "info" } _, _ = s.db.Exec(`INSERT INTO launch_logs(app_id,level,line,created_at) VALUES(?,?,?,?)`, appID, level, line, nowRFC()) // 每个应用最多保留 800 行,避免无限膨胀。 _, _ = s.db.Exec(`DELETE FROM launch_logs WHERE app_id=? AND id NOT IN ( SELECT id FROM launch_logs WHERE app_id=? ORDER BY id DESC LIMIT 800)`, appID, appID) } func (s *Store) ListLaunchLogs(appID int64, limit int) ([]LaunchLogLine, error) { if limit <= 0 || limit > 500 { limit = 200 } rows, e := s.db.Query(`SELECT id,app_id,level,line,created_at FROM launch_logs WHERE app_id=? ORDER BY id DESC LIMIT ?`, appID, limit) if e != nil { return nil, e } defer rows.Close() out := []LaunchLogLine{} for rows.Next() { var x LaunchLogLine if e = rows.Scan(&x.ID, &x.AppID, &x.Level, &x.Line, &x.CreatedAt); e != nil { return nil, e } out = append(out, x) } // 按时间正序返回,便于控制台阅读。 for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out, rows.Err() } func (s *Store) ClearLaunchLogs(appID int64) error { _, e := s.db.Exec(`DELETE FROM launch_logs WHERE app_id=?`, appID) return e } func (a *App) ListLaunchLogs(appID int64, limit int) ([]LaunchLogLine, error) { if e := a.ready(); e != nil { return nil, e } return a.store.ListLaunchLogs(appID, limit) } func (a *App) ClearLaunchLogs(appID int64) error { if e := a.ready(); e != nil { return e } return a.store.ClearLaunchLogs(appID) } func (a *App) writeLaunchLog(appID int64, level, line string) { a.store.appendLaunchLog(appID, level, line) st := lpState(appID) st.set("", "", line) a.emit("launchpad:log", map[string]any{"appId": appID, "level": level, "line": line}) } // pipeLaunchOutput 把子进程 stdout/stderr 写入本地日志。 func (a *App) pipeLaunchOutput(appID int64, r io.Reader, fallbackLevel string) { sc := bufio.NewScanner(r) buf := make([]byte, 0, 64*1024) sc.Buffer(buf, 1024*1024) for sc.Scan() { line := sc.Text() level := launchLogLevelFromLine(line) if level == "info" && fallbackLevel == "error" { level = "error" } a.writeLaunchLog(appID, level, line) } } func launchLogLevelFromLine(line string) string { l := strings.ToLower(line) switch { case strings.Contains(l, "error"), strings.Contains(l, "fatal"), strings.Contains(l, "panic"), strings.Contains(l, "failed"): return "error" case strings.Contains(l, "warn"): return "warning" default: return "info" } }