package tui import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" "time" "bee/audit/internal/app" tea "github.com/charmbracelet/bubbletea" ) type satProgressMsg struct { lines []string } // pollSATProgress returns a Cmd that waits 300ms then reads the latest verbose.log // for the given SAT prefix and returns parsed step progress lines. func pollSATProgress(prefix string, since time.Time) tea.Cmd { return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg { return satProgressMsg{lines: readSATProgressLines(prefix, since)} }) } func readSATProgressLines(prefix string, since time.Time) []string { pattern := filepath.Join(app.DefaultSATBaseDir, prefix+"-*/verbose.log") matches, err := filepath.Glob(pattern) if err != nil || len(matches) == 0 { return nil } sort.Strings(matches) // Find the latest file created at or after (since - 5s) to account for clock skew. cutoff := since.Add(-5 * time.Second) candidate := "" for _, m := range matches { info, statErr := os.Stat(m) if statErr == nil && info.ModTime().After(cutoff) { candidate = m } } if candidate == "" { return nil } raw, err := os.ReadFile(candidate) if err != nil { return nil } return parseSATVerboseProgress(string(raw)) } // parseSATVerboseProgress parses verbose.log content and returns display lines like: // // "PASS lscpu (234ms)" // "FAIL stress-ng (60.0s)" // "... sensors-after" func parseSATVerboseProgress(content string) []string { type step struct { name string rc int durationMs int done bool } lines := strings.Split(content, "\n") var steps []step stepIdx := map[string]int{} for i, line := range lines { line = strings.TrimSpace(line) if idx := strings.Index(line, "] start "); idx >= 0 { name := strings.TrimSpace(line[idx+len("] start "):]) if _, exists := stepIdx[name]; !exists { stepIdx[name] = len(steps) steps = append(steps, step{name: name}) } } else if idx := strings.Index(line, "] finish "); idx >= 0 { name := strings.TrimSpace(line[idx+len("] finish "):]) si, exists := stepIdx[name] if !exists { continue } steps[si].done = true for j := i + 1; j < len(lines) && j <= i+3; j++ { l := strings.TrimSpace(lines[j]) if strings.HasPrefix(l, "rc: ") { steps[si].rc, _ = strconv.Atoi(strings.TrimPrefix(l, "rc: ")) } else if strings.HasPrefix(l, "duration_ms: ") { steps[si].durationMs, _ = strconv.Atoi(strings.TrimPrefix(l, "duration_ms: ")) } } } } var result []string for _, s := range steps { display := cleanSATStepName(s.name) if s.done { status := "PASS" if s.rc != 0 { status = "FAIL" } result = append(result, fmt.Sprintf("%-4s %s (%s)", status, display, fmtDurMs(s.durationMs))) } else { result = append(result, fmt.Sprintf("... %s", display)) } } return result } // cleanSATStepName strips leading digits and dash: "01-lscpu.log" → "lscpu". func cleanSATStepName(name string) string { name = strings.TrimSuffix(name, ".log") i := 0 for i < len(name) && name[i] >= '0' && name[i] <= '9' { i++ } if i < len(name) && name[i] == '-' { name = name[i+1:] } return name } // pollInstallProgress tails the install log file and returns recent lines as progress. func pollInstallProgress(logFile string) tea.Cmd { return tea.Tick(500*time.Millisecond, func(_ time.Time) tea.Msg { return satProgressMsg{lines: readInstallProgressLines(logFile)} }) } func readInstallProgressLines(logFile string) []string { raw, err := os.ReadFile(logFile) if err != nil { return nil } lines := strings.Split(strings.TrimSpace(string(raw)), "\n") // Show last 12 lines if len(lines) > 12 { lines = lines[len(lines)-12:] } return lines } func fmtDurMs(ms int) string { if ms < 1000 { return fmt.Sprintf("%dms", ms) } return fmt.Sprintf("%.1fs", float64(ms)/1000) }