v0.14.0 76d1892

logs.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"fmt"
6	"os"
7	"path/filepath"
8	"sort"
9	"strconv"
10	"strings"
11	"time"
12
13	"github.com/bnema/dumber/internal/config"
14	"github.com/spf13/cobra"
15)
16
17// NewLogsCmd creates the logs command and its subcommands
18func NewLogsCmd() *cobra.Command {
19	cmd := &cobra.Command{
20		Use:   "logs",
21		Short: "View and manage application logs",
22		Long:  "Access logs of the aplication, including viewing recent entries, listing log files, cleaning old logs, and showing the log directory path.",
23	}
24
25	cmd.AddCommand(newLogsTailCmd())
26	cmd.AddCommand(newLogsListCmd())
27	cmd.AddCommand(newLogsCleanCmd())
28	cmd.AddCommand(newLogsPathCmd())
29	cmd.AddCommand(newLogsConsoleCmd())
30
31	return cmd
32}
33
34// newLogsTailCmd creates the tail subcommand
35func newLogsTailCmd() *cobra.Command {
36	var lines int
37
38	cmd := &cobra.Command{
39		Use:   "tail [lines]",
40		Short: "Show last N lines of current log",
41		Long:  "Display the last N lines from the current log file. Defaults to 50 lines.",
42		Args:  cobra.MaximumNArgs(1),
43		RunE: func(cmd *cobra.Command, args []string) error {
44			// Parse lines argument if provided
45			if len(args) > 0 {
46				var err error
47				lines, err = strconv.Atoi(args[0])
48				if err != nil {
49					return fmt.Errorf("invalid number of lines: %s", args[0])
50				}
51			}
52
53			logDir, err := config.GetLogDir()
54			if err != nil {
55				return fmt.Errorf("failed to get log directory: %w", err)
56			}
57
58			currentLog := filepath.Join(logDir, "dumber.log")
59			return tailFile(currentLog, lines)
60		},
61	}
62
63	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
64	return cmd
65}
66
67// newLogsListCmd creates the list subcommand
68func newLogsListCmd() *cobra.Command {
69	cmd := &cobra.Command{
70		Use:   "list",
71		Short: "List all available log files",
72		Long:  "Display all log files in the log directory with their sizes and modification times.",
73		RunE: func(cmd *cobra.Command, args []string) error {
74			logDir, err := config.GetLogDir()
75			if err != nil {
76				return fmt.Errorf("failed to get log directory: %w", err)
77			}
78
79			return listLogFiles(logDir)
80		},
81	}
82
83	return cmd
84}
85
86// newLogsCleanCmd creates the clean subcommand
87func newLogsCleanCmd() *cobra.Command {
88	var dryRun bool
89	var maxAge int
90
91	cmd := &cobra.Command{
92		Use:   "clean",
93		Short: "Remove old log files",
94		Long:  "Remove log files older than the specified age in days. Use --dry-run to see what would be deleted.",
95		RunE: func(cmd *cobra.Command, args []string) error {
96			logDir, err := config.GetLogDir()
97			if err != nil {
98				return fmt.Errorf("failed to get log directory: %w", err)
99			}
100
101			return cleanOldLogs(logDir, maxAge, dryRun)
102		},
103	}
104
105	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting")
106	cmd.Flags().IntVar(&maxAge, "max-age", 30, "Maximum age of log files in days")
107	return cmd
108}
109
110// newLogsPathCmd creates the path subcommand
111func newLogsPathCmd() *cobra.Command {
112	cmd := &cobra.Command{
113		Use:   "path",
114		Short: "Show the path to the log directory",
115		Long:  "Display the XDG-compliant path where logs are stored.",
116		RunE: func(cmd *cobra.Command, args []string) error {
117			logDir, err := config.GetLogDir()
118			if err != nil {
119				return fmt.Errorf("failed to get log directory: %w", err)
120			}
121
122			fmt.Println(logDir)
123			return nil
124		},
125	}
126
127	return cmd
128}
129
130// newLogsConsoleCmd creates the console subcommand for filtering console logs
131func newLogsConsoleCmd() *cobra.Command {
132	var lines int
133	var level string
134
135	cmd := &cobra.Command{
136		Use:   "console",
137		Short: "View console logs from WebKit",
138		Long:  "Display console.log, console.error, and other console messages from web pages.",
139		RunE: func(cmd *cobra.Command, args []string) error {
140			logDir, err := config.GetLogDir()
141			if err != nil {
142				return fmt.Errorf("failed to get log directory: %w", err)
143			}
144
145			currentLog := filepath.Join(logDir, "dumber.log")
146			return tailConsoleMessages(currentLog, lines, level)
147		},
148	}
149
150	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
151	cmd.Flags().StringVarP(&level, "level", "l", "", "Filter by console level (error, warn, info, log, debug)")
152
153	return cmd
154}
155
156// tailFile displays the last N lines of a file
157func tailFile(filePath string, lines int) error {
158	file, err := os.Open(filePath)
159	if err != nil {
160		if os.IsNotExist(err) {
161			fmt.Printf("Log file does not exist: %s\n", filePath)
162			return nil
163		}
164		return fmt.Errorf("failed to open log file: %w", err)
165	}
166	defer func() {
167		if err := file.Close(); err != nil {
168			fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
169		}
170	}()
171
172	// Read all lines
173	var allLines []string
174	scanner := bufio.NewScanner(file)
175	for scanner.Scan() {
176		allLines = append(allLines, scanner.Text())
177	}
178
179	if err := scanner.Err(); err != nil {
180		return fmt.Errorf("error reading log file: %w", err)
181	}
182
183	// Display last N lines
184	start := len(allLines) - lines
185	if start < 0 {
186		start = 0
187	}
188
189	for i := start; i < len(allLines); i++ {
190		fmt.Println(allLines[i])
191	}
192
193	return nil
194}
195
196// listLogFiles displays all log files with their information
197func listLogFiles(logDir string) error {
198	if _, err := os.Stat(logDir); os.IsNotExist(err) {
199		fmt.Printf("Log directory does not exist: %s\n", logDir)
200		return nil
201	}
202
203	files, err := os.ReadDir(logDir)
204	if err != nil {
205		return fmt.Errorf("failed to read log directory: %w", err)
206	}
207
208	// Filter and collect log files
209	var logFiles []os.FileInfo
210	for _, file := range files {
211		if file.IsDir() {
212			continue
213		}
214
215		name := file.Name()
216		if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
217			info, err := file.Info()
218			if err != nil {
219				continue
220			}
221			logFiles = append(logFiles, info)
222		}
223	}
224
225	if len(logFiles) == 0 {
226		fmt.Println("No log files found")
227		return nil
228	}
229
230	// Sort by modification time (newest first)
231	sort.Slice(logFiles, func(i, j int) bool {
232		return logFiles[i].ModTime().After(logFiles[j].ModTime())
233	})
234
235	// Display files
236	fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
237	fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
238
239	for _, file := range logFiles {
240		size := formatSize(file.Size())
241		modTime := file.ModTime().Format("2006-01-02 15:04:05")
242		fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size)
243	}
244
245	return nil
246}
247
248// cleanOldLogs removes log files older than maxAge days
249func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
250	if _, err := os.Stat(logDir); os.IsNotExist(err) {
251		fmt.Printf("Log directory does not exist: %s\n", logDir)
252		return nil
253	}
254
255	files, err := os.ReadDir(logDir)
256	if err != nil {
257		return fmt.Errorf("failed to read log directory: %w", err)
258	}
259
260	cutoffTime := time.Now().AddDate(0, 0, -maxAge)
261	var deletedCount int
262	var deletedSize int64
263
264	for _, file := range files {
265		if file.IsDir() {
266			continue
267		}
268
269		name := file.Name()
270		// Skip the current log file
271		if name == "dumber.log" {
272			continue
273		}
274
275		// Only process log files
276		if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") {
277			continue
278		}
279
280		info, err := file.Info()
281		if err != nil {
282			continue
283		}
284
285		if info.ModTime().Before(cutoffTime) {
286			filePath := filepath.Join(logDir, name)
287
288			if dryRun {
289				fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
290					name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
291			} else {
292				err := os.Remove(filePath)
293				if err != nil {
294					fmt.Printf("Warning: failed to delete %s: %v\n", name, err)
295				} else {
296					fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
297				}
298			}
299
300			deletedCount++
301			deletedSize += info.Size()
302		}
303	}
304
305	if dryRun {
306		fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
307			deletedCount, float64(deletedSize)/1024)
308	} else {
309		fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
310			deletedCount, float64(deletedSize)/1024)
311	}
312
313	return nil
314}
315
316// formatSize formats file size in human-readable format
317func formatSize(size int64) string {
318	const unit = 1024
319	if size < unit {
320		return fmt.Sprintf("%d B", size)
321	}
322
323	div, exp := int64(unit), 0
324	for n := size / unit; n >= unit; n /= unit {
325		div *= unit
326		exp++
327	}
328
329	return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
330}
331
332// tailConsoleMessages displays console messages from the log file
333func tailConsoleMessages(filePath string, lines int, level string) error {
334	file, err := os.Open(filePath)
335	if err != nil {
336		if os.IsNotExist(err) {
337			fmt.Printf("Log file does not exist: %s\n", filePath)
338			return nil
339		}
340		return fmt.Errorf("failed to open log file: %w", err)
341	}
342	defer func() {
343		if err := file.Close(); err != nil {
344			fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
345		}
346	}()
347
348	// Read all lines and filter for console messages
349	var consoleLines []string
350	scanner := bufio.NewScanner(file)
351	for scanner.Scan() {
352		line := scanner.Text()
353
354		// Filter for [CONSOLE] tagged messages
355		if !strings.Contains(line, "[CONSOLE]") {
356			continue
357		}
358
359		// Filter by level if specified
360		if level != "" {
361			levelFilter := fmt.Sprintf("[%s]", strings.ToUpper(level))
362			if !strings.Contains(line, levelFilter) {
363				continue
364			}
365		}
366
367		consoleLines = append(consoleLines, line)
368	}
369
370	if err := scanner.Err(); err != nil {
371		return fmt.Errorf("error reading log file: %w", err)
372	}
373
374	if len(consoleLines) == 0 {
375		if level != "" {
376			fmt.Printf("No console messages found with level '%s'\n", level)
377		} else {
378			fmt.Println("No console messages found")
379		}
380		return nil
381	}
382
383	// Display last N lines
384	start := len(consoleLines) - lines
385	if start < 0 {
386		start = 0
387	}
388
389	for i := start; i < len(consoleLines); i++ {
390		fmt.Println(consoleLines[i])
391	}
392
393	return nil
394}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close