package runtimelogs import ( "bufio" "bytes" "io" "os" "strings" "evobgp/internal/store" ) // TailOptions controls tail/preview reads. type TailOptions struct { Lines int Bytes int Grep string } // Tail reads the end of a runtime log file. func (s *Service) Tail(filename string, opts TailOptions) (*store.RuntimeLogTail, error) { if !s.Available() { return nil, ErrUnavailable } path, err := ResolveLogPath(s.cfg.RootDir, filename) if err != nil { return nil, err } st, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil, ErrNotFound } return nil, err } if st.IsDir() { return nil, ErrNotAFile } lines := opts.Lines if lines <= 0 { lines = DefaultTailLines } if lines > MaxTailLines { lines = MaxTailLines } maxRead := MaxTailBytes if opts.Bytes > 0 && opts.Bytes < maxRead { maxRead = opts.Bytes } raw, truncated, err := readTailBytes(path, int64(maxRead)) if err != nil { return nil, err } grep := strings.TrimSpace(opts.Grep) if len(grep) > MaxGrepLen { grep = grep[:MaxGrepLen] } contentLines := splitLines(raw) if grep != "" { filtered := contentLines[:0] for _, line := range contentLines { if strings.Contains(line, grep) { filtered = append(filtered, line) } } contentLines = filtered } if len(contentLines) > lines { contentLines = contentLines[len(contentLines)-lines:] truncated = true } return &store.RuntimeLogTail{ Filename: filename, Content: strings.Join(contentLines, "\n"), Truncated: truncated, LinesReturned: len(contentLines), }, nil } func readTailBytes(path string, maxRead int64) ([]byte, bool, error) { f, err := os.Open(path) if err != nil { return nil, false, err } defer func() { _ = f.Close() }() st, err := f.Stat() if err != nil { return nil, false, err } size := st.Size() truncated := size > maxRead start := int64(0) if size > maxRead { start = size - maxRead } if _, err := f.Seek(start, io.SeekStart); err != nil { return nil, false, err } buf := make([]byte, size-start) n, err := io.ReadFull(f, buf) if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { return nil, false, err } buf = buf[:n] if start > 0 { // Drop partial first line when reading from middle of file. if idx := bytes.IndexByte(buf, '\n'); idx >= 0 && idx+1 < len(buf) { buf = buf[idx+1:] truncated = true } else if start > 0 { truncated = true } } return buf, truncated, nil } func splitLines(b []byte) []string { if len(b) == 0 { return nil } sc := bufio.NewScanner(bytes.NewReader(b)) var lines []string for sc.Scan() { lines = append(lines, sc.Text()) } if len(lines) == 0 { return []string{string(b)} } return lines }