fix(cli): improve logs command output and session resolution
- commit
- 5cbd7d8efa5c081054dde1994d03f58340fd641f
- author
- brice · · 2025-12-21 17:52 UTC
- committer
- brice · · 2025-12-21 20:20 UTC
- parent
- a33ce65
- signature
- unsigned
2 files · +221 -22
M internal/cli/cmd/logs.go +147 -22
| @@ -2,6 +2,7 @@ package cmd | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| @@ -14,6 +15,7 @@ import ( | |||
| 14 | 15 | "github.com/spf13/cobra" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/dumber/internal/cli/styles" | |
| 18 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | 19 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | 20 | "github.com/bnema/dumber/internal/logging" | |
| 19 | 21 | ) | |
| @@ -57,6 +59,16 @@ type SessionInfo struct { | |||
| 57 | 59 | Path string | |
| 58 | 60 | Size int64 | |
| 59 | 61 | ModTime time.Time | |
| 62 | + | ||
| 63 | + | // DB metadata (optional) | |
| 64 | + | StartedAt time.Time | |
| 65 | + | EndedAt *time.Time | |
| 66 | + | Type string | |
| 67 | + | FromDB bool | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | type sessionManager interface { | |
| 71 | + | GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error) | |
| 60 | 72 | } | |
| 61 | 73 | ||
| 62 | 74 | func runLogs(_ *cobra.Command, args []string) error { | |
| @@ -65,20 +77,28 @@ func runLogs(_ *cobra.Command, args []string) error { | |||
| 65 | 77 | return fmt.Errorf("app not initialized") | |
| 66 | 78 | } | |
| 67 | 79 | ||
| 80 | + | ctx := app.Ctx() | |
| 68 | 81 | logDir := getLogDir() | |
| 69 | 82 | ||
| 70 | 83 | // List sessions if no argument provided | |
| 71 | 84 | if len(args) == 0 { | |
| 72 | - | return listSessions(logDir, app.Theme) | |
| 85 | + | return listSessions(ctx, app.SessionUC, logDir, app.Theme) | |
| 73 | 86 | } | |
| 74 | 87 | ||
| 75 | 88 | // Find session by partial match | |
| 76 | 89 | sessionQuery := args[0] | |
| 77 | - | session, err := findSession(logDir, sessionQuery) | |
| 90 | + | session, err := findSession(ctx, app.SessionUC, logDir, sessionQuery) | |
| 78 | 91 | if err != nil { | |
| 79 | 92 | return err | |
| 80 | 93 | } | |
| 81 | 94 | ||
| 95 | + | if _, err := os.Stat(session.Path); err != nil { | |
| 96 | + | if os.IsNotExist(err) { | |
| 97 | + | return fmt.Errorf("log file for session '%s' not found at %s", session.ShortID, session.Path) | |
| 98 | + | } | |
| 99 | + | return fmt.Errorf("stat log file: %w", err) | |
| 100 | + | } | |
| 101 | + | ||
| 82 | 102 | if logsFollow { | |
| 83 | 103 | return tailSession(session.Path, app.Theme) | |
| 84 | 104 | } | |
| @@ -102,8 +122,8 @@ func getLogDir() string { | |||
| 102 | 122 | } | |
| 103 | 123 | ||
| 104 | 124 | // listSessions displays all available log sessions. | |
| 105 | - | func listSessions(logDir string, theme *styles.Theme) error { | |
| 106 | - | sessions, err := getSessions(logDir) | |
| 125 | + | func listSessions(ctx context.Context, sessionMgr sessionManager, logDir string, theme *styles.Theme) error { | |
| 126 | + | sessions, err := getSessionsMerged(ctx, sessionMgr, logDir) | |
| 107 | 127 | if err != nil { | |
| 108 | 128 | return err | |
| 109 | 129 | } | |
| @@ -117,14 +137,28 @@ func listSessions(logDir string, theme *styles.Theme) error { | |||
| 117 | 137 | fmt.Println(theme.Title.Render("Sessions (newest first):")) | |
| 118 | 138 | fmt.Println() | |
| 119 | 139 | ||
| 120 | - | // Table format: ShortID | DateTime | Size | |
| 140 | + | // Table format: ShortID | DateTime | Status | Size | |
| 121 | 141 | for _, s := range sessions { | |
| 122 | - | dateStr := s.ModTime.Format("2006-01-02 15:04:05") | |
| 123 | - | sizeStr := formatSize(s.Size) | |
| 142 | + | timeStr := sessionSortTime(s).Format("2006-01-02 15:04:05") | |
| 143 | + | ||
| 144 | + | status := "" | |
| 145 | + | if s.FromDB { | |
| 146 | + | if s.EndedAt == nil { | |
| 147 | + | status = theme.SuccessStyle.Render("active") | |
| 148 | + | } else { | |
| 149 | + | status = theme.Subtle.Render("ended") | |
| 150 | + | } | |
| 151 | + | } | |
| 124 | 152 | ||
| 125 | - | line := fmt.Sprintf(" %s %s %s", | |
| 153 | + | sizeStr := "?" | |
| 154 | + | if s.Size > 0 { | |
| 155 | + | sizeStr = formatSize(s.Size) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | line := fmt.Sprintf(" %s %s %s %s", | |
| 126 | 159 | theme.Highlight.Render(s.ShortID), | |
| 127 | - | theme.Subtle.Render(dateStr), | |
| 160 | + | theme.Subtle.Render(timeStr), | |
| 161 | + | status, | |
| 128 | 162 | theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)), | |
| 129 | 163 | ) | |
| 130 | 164 | fmt.Println(line) | |
| @@ -179,9 +213,80 @@ func getSessions(logDir string) ([]SessionInfo, error) { | |||
| 179 | 213 | return sessions, nil | |
| 180 | 214 | } | |
| 181 | 215 | ||
| 216 | + | func getSessionsMerged(ctx context.Context, sessionMgr sessionManager, logDir string) ([]SessionInfo, error) { | |
| 217 | + | fsSessions, err := getSessions(logDir) | |
| 218 | + | if err != nil { | |
| 219 | + | return nil, err | |
| 220 | + | } | |
| 221 | + | if sessionMgr == nil { | |
| 222 | + | return fsSessions, nil | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | dbSessions, err := sessionMgr.GetRecentSessions(ctx, 200) | |
| 226 | + | if err != nil { | |
| 227 | + | if isMissingSessionsTable(err) { | |
| 228 | + | return fsSessions, nil | |
| 229 | + | } | |
| 230 | + | return nil, err | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | byID := make(map[string]SessionInfo, len(dbSessions)) | |
| 234 | + | merged := make([]SessionInfo, 0, len(dbSessions)+len(fsSessions)) | |
| 235 | + | for _, s := range dbSessions { | |
| 236 | + | if s == nil { | |
| 237 | + | continue | |
| 238 | + | } | |
| 239 | + | filename := logging.SessionFilename(string(s.ID)) | |
| 240 | + | path := filepath.Join(logDir, filename) | |
| 241 | + | info := SessionInfo{ | |
| 242 | + | SessionID: string(s.ID), | |
| 243 | + | ShortID: s.ShortID(), | |
| 244 | + | Filename: filename, | |
| 245 | + | Path: path, | |
| 246 | + | StartedAt: s.StartedAt, | |
| 247 | + | EndedAt: s.EndedAt, | |
| 248 | + | Type: string(s.Type), | |
| 249 | + | FromDB: true, | |
| 250 | + | } | |
| 251 | + | if stat, statErr := os.Stat(path); statErr == nil { | |
| 252 | + | info.Size = stat.Size() | |
| 253 | + | info.ModTime = stat.ModTime() | |
| 254 | + | } | |
| 255 | + | byID[info.SessionID] = info | |
| 256 | + | merged = append(merged, info) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | for _, s := range fsSessions { | |
| 260 | + | if _, ok := byID[s.SessionID]; ok { | |
| 261 | + | continue | |
| 262 | + | } | |
| 263 | + | merged = append(merged, s) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | sort.Slice(merged, func(i, j int) bool { | |
| 267 | + | return sessionSortTime(merged[i]).After(sessionSortTime(merged[j])) | |
| 268 | + | }) | |
| 269 | + | return merged, nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func sessionSortTime(s SessionInfo) time.Time { | |
| 273 | + | if !s.StartedAt.IsZero() { | |
| 274 | + | return s.StartedAt | |
| 275 | + | } | |
| 276 | + | return s.ModTime | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func isMissingSessionsTable(err error) bool { | |
| 280 | + | if err == nil { | |
| 281 | + | return false | |
| 282 | + | } | |
| 283 | + | // sqlite returns e.g. "no such table: sessions" before migrations run. | |
| 284 | + | return strings.Contains(err.Error(), "no such table: sessions") | |
| 285 | + | } | |
| 286 | + | ||
| 182 | 287 | // findSession finds a session by partial ID match. | |
| 183 | - | func findSession(logDir, query string) (*SessionInfo, error) { | |
| 184 | - | sessions, err := getSessions(logDir) | |
| 288 | + | func findSession(ctx context.Context, sessionMgr sessionManager, logDir, query string) (*SessionInfo, error) { | |
| 289 | + | sessions, err := getSessionsMerged(ctx, sessionMgr, logDir) | |
| 185 | 290 | if err != nil { | |
| 186 | 291 | return nil, err | |
| 187 | 292 | } | |
| @@ -190,19 +295,24 @@ func findSession(logDir, query string) (*SessionInfo, error) { | |||
| 190 | 295 | return nil, fmt.Errorf("no sessions found") | |
| 191 | 296 | } | |
| 192 | 297 | ||
| 193 | - | queryLower := strings.ToLower(query) | |
| 298 | + | queryNormalized := strings.ToLower(strings.TrimSpace(query)) | |
| 194 | 299 | ||
| 195 | - | // Try exact short ID match first | |
| 196 | - | for _, s := range sessions { | |
| 197 | - | if strings.EqualFold(s.ShortID, queryLower) { | |
| 198 | - | return &s, nil | |
| 300 | + | // Try exact short ID match first. | |
| 301 | + | // Some entries may not have ShortID populated; derive it from SessionID. | |
| 302 | + | for i := range sessions { | |
| 303 | + | shortID := sessions[i].ShortID | |
| 304 | + | if shortID == "" { | |
| 305 | + | shortID = logging.ShortSessionID(sessions[i].SessionID) | |
| 306 | + | } | |
| 307 | + | if strings.ToLower(shortID) == queryNormalized { | |
| 308 | + | return &sessions[i], nil | |
| 199 | 309 | } | |
| 200 | 310 | } | |
| 201 | 311 | ||
| 202 | 312 | // Try partial match on full session ID | |
| 203 | 313 | var matches []SessionInfo | |
| 204 | 314 | for _, s := range sessions { | |
| 205 | - | if strings.Contains(strings.ToLower(s.SessionID), queryLower) { | |
| 315 | + | if strings.Contains(strings.ToLower(s.SessionID), queryNormalized) { | |
| 206 | 316 | matches = append(matches, s) | |
| 207 | 317 | } | |
| 208 | 318 | } | |
| @@ -273,14 +383,29 @@ func tailSession(logPath string, theme *styles.Theme) error { | |||
| 273 | 383 | fmt.Println() | |
| 274 | 384 | ||
| 275 | 385 | reader := bufio.NewReader(file) | |
| 386 | + | pending := "" | |
| 276 | 387 | for { | |
| 277 | - | line, err := reader.ReadString('\n') | |
| 388 | + | chunk, err := reader.ReadString('\n') | |
| 278 | 389 | if err != nil { | |
| 279 | - | // No new data, wait a bit | |
| 280 | - | time.Sleep(100 * time.Millisecond) | |
| 281 | - | continue | |
| 390 | + | if err == io.EOF { | |
| 391 | + | // No full line yet; keep partial data. | |
| 392 | + | pending += chunk | |
| 393 | + | time.Sleep(100 * time.Millisecond) | |
| 394 | + | continue | |
| 395 | + | } | |
| 396 | + | return fmt.Errorf("read log file: %w", err) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | pending += chunk | |
| 400 | + | for { | |
| 401 | + | idx := strings.IndexByte(pending, '\n') | |
| 402 | + | if idx == -1 { | |
| 403 | + | break | |
| 404 | + | } | |
| 405 | + | line := pending[:idx] | |
| 406 | + | pending = pending[idx+1:] | |
| 407 | + | fmt.Println(colorizeLogLine(line, theme)) | |
| 282 | 408 | } | |
| 283 | - | fmt.Print(colorizeLogLine(line, theme)) | |
| 284 | 409 | } | |
| 285 | 410 | } | |
| 286 | 411 | ||
| @@ -2,6 +2,7 @@ package cmd | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "io" | 8 | "io" | ||
| @@ -14,6 +15,7 @@ import ( | |||||
| 14 | "github.com/spf13/cobra" | 15 | "github.com/spf13/cobra" | ||
| 15 | 16 | ||||
| 16 | "github.com/bnema/dumber/internal/cli/styles" | 17 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 18 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 17 | "github.com/bnema/dumber/internal/infrastructure/config" | 19 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 18 | "github.com/bnema/dumber/internal/logging" | 20 | "github.com/bnema/dumber/internal/logging" | ||
| 19 | ) | 21 | ) | ||
| @@ -57,6 +59,16 @@ type SessionInfo struct { | |||||
| 57 | Path string | 59 | Path string | ||
| 58 | Size int64 | 60 | Size int64 | ||
| 59 | ModTime time.Time | 61 | ModTime time.Time | ||
| 62 | + | ||||
| 63 | + | // DB metadata (optional) | |||
| 64 | + | StartedAt time.Time | |||
| 65 | + | EndedAt *time.Time | |||
| 66 | + | Type string | |||
| 67 | + | FromDB bool | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | type sessionManager interface { | |||
| 71 | + | GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error) | |||
| 60 | } | 72 | } | ||
| 61 | 73 | ||||
| 62 | func runLogs(_ *cobra.Command, args []string) error { | 74 | func runLogs(_ *cobra.Command, args []string) error { | ||
| @@ -65,20 +77,28 @@ func runLogs(_ *cobra.Command, args []string) error { | |||||
| 65 | return fmt.Errorf("app not initialized") | 77 | return fmt.Errorf("app not initialized") | ||
| 66 | } | 78 | } | ||
| 67 | 79 | ||||
| 80 | + | ctx := app.Ctx() | |||
| 68 | logDir := getLogDir() | 81 | logDir := getLogDir() | ||
| 69 | 82 | ||||
| 70 | // List sessions if no argument provided | 83 | // List sessions if no argument provided | ||
| 71 | if len(args) == 0 { | 84 | if len(args) == 0 { | ||
| 72 | - | return listSessions(logDir, app.Theme) | 85 | + | return listSessions(ctx, app.SessionUC, logDir, app.Theme) |
| 73 | } | 86 | } | ||
| 74 | 87 | ||||
| 75 | // Find session by partial match | 88 | // Find session by partial match | ||
| 76 | sessionQuery := args[0] | 89 | sessionQuery := args[0] | ||
| 77 | - | session, err := findSession(logDir, sessionQuery) | 90 | + | session, err := findSession(ctx, app.SessionUC, logDir, sessionQuery) |
| 78 | if err != nil { | 91 | if err != nil { | ||
| 79 | return err | 92 | return err | ||
| 80 | } | 93 | } | ||
| 81 | 94 | ||||
| 95 | + | if _, err := os.Stat(session.Path); err != nil { | |||
| 96 | + | if os.IsNotExist(err) { | |||
| 97 | + | return fmt.Errorf("log file for session '%s' not found at %s", session.ShortID, session.Path) | |||
| 98 | + | } | |||
| 99 | + | return fmt.Errorf("stat log file: %w", err) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 82 | if logsFollow { | 102 | if logsFollow { | ||
| 83 | return tailSession(session.Path, app.Theme) | 103 | return tailSession(session.Path, app.Theme) | ||
| 84 | } | 104 | } | ||
| @@ -102,8 +122,8 @@ func getLogDir() string { | |||||
| 102 | } | 122 | } | ||
| 103 | 123 | ||||
| 104 | // listSessions displays all available log sessions. | 124 | // listSessions displays all available log sessions. | ||
| 105 | - | func listSessions(logDir string, theme *styles.Theme) error { | 125 | + | func listSessions(ctx context.Context, sessionMgr sessionManager, logDir string, theme *styles.Theme) error { |
| 106 | - | sessions, err := getSessions(logDir) | 126 | + | sessions, err := getSessionsMerged(ctx, sessionMgr, logDir) |
| 107 | if err != nil { | 127 | if err != nil { | ||
| 108 | return err | 128 | return err | ||
| 109 | } | 129 | } | ||
| @@ -117,14 +137,28 @@ func listSessions(logDir string, theme *styles.Theme) error { | |||||
| 117 | fmt.Println(theme.Title.Render("Sessions (newest first):")) | 137 | fmt.Println(theme.Title.Render("Sessions (newest first):")) | ||
| 118 | fmt.Println() | 138 | fmt.Println() | ||
| 119 | 139 | ||||
| 120 | - | // Table format: ShortID | DateTime | Size | 140 | + | // Table format: ShortID | DateTime | Status | Size |
| 121 | for _, s := range sessions { | 141 | for _, s := range sessions { | ||
| 122 | - | dateStr := s.ModTime.Format("2006-01-02 15:04:05") | 142 | + | timeStr := sessionSortTime(s).Format("2006-01-02 15:04:05") |
| 123 | - | sizeStr := formatSize(s.Size) | 143 | + | |
| 144 | + | status := "" | |||
| 145 | + | if s.FromDB { | |||
| 146 | + | if s.EndedAt == nil { | |||
| 147 | + | status = theme.SuccessStyle.Render("active") | |||
| 148 | + | } else { | |||
| 149 | + | status = theme.Subtle.Render("ended") | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 124 | 152 | ||||
| 125 | - | line := fmt.Sprintf(" %s %s %s", | 153 | + | sizeStr := "?" |
| 154 | + | if s.Size > 0 { | |||
| 155 | + | sizeStr = formatSize(s.Size) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | line := fmt.Sprintf(" %s %s %s %s", | |||
| 126 | theme.Highlight.Render(s.ShortID), | 159 | theme.Highlight.Render(s.ShortID), | ||
| 127 | - | theme.Subtle.Render(dateStr), | 160 | + | theme.Subtle.Render(timeStr), |
| 161 | + | status, | |||
| 128 | theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)), | 162 | theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)), | ||
| 129 | ) | 163 | ) | ||
| 130 | fmt.Println(line) | 164 | fmt.Println(line) | ||
| @@ -179,9 +213,80 @@ func getSessions(logDir string) ([]SessionInfo, error) { | |||||
| 179 | return sessions, nil | 213 | return sessions, nil | ||
| 180 | } | 214 | } | ||
| 181 | 215 | ||||
| 216 | + | func getSessionsMerged(ctx context.Context, sessionMgr sessionManager, logDir string) ([]SessionInfo, error) { | |||
| 217 | + | fsSessions, err := getSessions(logDir) | |||
| 218 | + | if err != nil { | |||
| 219 | + | return nil, err | |||
| 220 | + | } | |||
| 221 | + | if sessionMgr == nil { | |||
| 222 | + | return fsSessions, nil | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | dbSessions, err := sessionMgr.GetRecentSessions(ctx, 200) | |||
| 226 | + | if err != nil { | |||
| 227 | + | if isMissingSessionsTable(err) { | |||
| 228 | + | return fsSessions, nil | |||
| 229 | + | } | |||
| 230 | + | return nil, err | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | byID := make(map[string]SessionInfo, len(dbSessions)) | |||
| 234 | + | merged := make([]SessionInfo, 0, len(dbSessions)+len(fsSessions)) | |||
| 235 | + | for _, s := range dbSessions { | |||
| 236 | + | if s == nil { | |||
| 237 | + | continue | |||
| 238 | + | } | |||
| 239 | + | filename := logging.SessionFilename(string(s.ID)) | |||
| 240 | + | path := filepath.Join(logDir, filename) | |||
| 241 | + | info := SessionInfo{ | |||
| 242 | + | SessionID: string(s.ID), | |||
| 243 | + | ShortID: s.ShortID(), | |||
| 244 | + | Filename: filename, | |||
| 245 | + | Path: path, | |||
| 246 | + | StartedAt: s.StartedAt, | |||
| 247 | + | EndedAt: s.EndedAt, | |||
| 248 | + | Type: string(s.Type), | |||
| 249 | + | FromDB: true, | |||
| 250 | + | } | |||
| 251 | + | if stat, statErr := os.Stat(path); statErr == nil { | |||
| 252 | + | info.Size = stat.Size() | |||
| 253 | + | info.ModTime = stat.ModTime() | |||
| 254 | + | } | |||
| 255 | + | byID[info.SessionID] = info | |||
| 256 | + | merged = append(merged, info) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | for _, s := range fsSessions { | |||
| 260 | + | if _, ok := byID[s.SessionID]; ok { | |||
| 261 | + | continue | |||
| 262 | + | } | |||
| 263 | + | merged = append(merged, s) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | sort.Slice(merged, func(i, j int) bool { | |||
| 267 | + | return sessionSortTime(merged[i]).After(sessionSortTime(merged[j])) | |||
| 268 | + | }) | |||
| 269 | + | return merged, nil | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func sessionSortTime(s SessionInfo) time.Time { | |||
| 273 | + | if !s.StartedAt.IsZero() { | |||
| 274 | + | return s.StartedAt | |||
| 275 | + | } | |||
| 276 | + | return s.ModTime | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func isMissingSessionsTable(err error) bool { | |||
| 280 | + | if err == nil { | |||
| 281 | + | return false | |||
| 282 | + | } | |||
| 283 | + | // sqlite returns e.g. "no such table: sessions" before migrations run. | |||
| 284 | + | return strings.Contains(err.Error(), "no such table: sessions") | |||
| 285 | + | } | |||
| 286 | + | ||||
| 182 | // findSession finds a session by partial ID match. | 287 | // findSession finds a session by partial ID match. | ||
| 183 | - | func findSession(logDir, query string) (*SessionInfo, error) { | 288 | + | func findSession(ctx context.Context, sessionMgr sessionManager, logDir, query string) (*SessionInfo, error) { |
| 184 | - | sessions, err := getSessions(logDir) | 289 | + | sessions, err := getSessionsMerged(ctx, sessionMgr, logDir) |
| 185 | if err != nil { | 290 | if err != nil { | ||
| 186 | return nil, err | 291 | return nil, err | ||
| 187 | } | 292 | } | ||
| @@ -190,19 +295,24 @@ func findSession(logDir, query string) (*SessionInfo, error) { | |||||
| 190 | return nil, fmt.Errorf("no sessions found") | 295 | return nil, fmt.Errorf("no sessions found") | ||
| 191 | } | 296 | } | ||
| 192 | 297 | ||||
| 193 | - | queryLower := strings.ToLower(query) | 298 | + | queryNormalized := strings.ToLower(strings.TrimSpace(query)) |
| 194 | 299 | ||||
| 195 | - | // Try exact short ID match first | 300 | + | // Try exact short ID match first. |
| 196 | - | for _, s := range sessions { | 301 | + | // Some entries may not have ShortID populated; derive it from SessionID. |
| 197 | - | if strings.EqualFold(s.ShortID, queryLower) { | 302 | + | for i := range sessions { |
| 198 | - | return &s, nil | 303 | + | shortID := sessions[i].ShortID |
| 304 | + | if shortID == "" { | |||
| 305 | + | shortID = logging.ShortSessionID(sessions[i].SessionID) | |||
| 306 | + | } | |||
| 307 | + | if strings.ToLower(shortID) == queryNormalized { | |||
| 308 | + | return &sessions[i], nil | |||
| 199 | } | 309 | } | ||
| 200 | } | 310 | } | ||
| 201 | 311 | ||||
| 202 | // Try partial match on full session ID | 312 | // Try partial match on full session ID | ||
| 203 | var matches []SessionInfo | 313 | var matches []SessionInfo | ||
| 204 | for _, s := range sessions { | 314 | for _, s := range sessions { | ||
| 205 | - | if strings.Contains(strings.ToLower(s.SessionID), queryLower) { | 315 | + | if strings.Contains(strings.ToLower(s.SessionID), queryNormalized) { |
| 206 | matches = append(matches, s) | 316 | matches = append(matches, s) | ||
| 207 | } | 317 | } | ||
| 208 | } | 318 | } | ||
| @@ -273,14 +383,29 @@ func tailSession(logPath string, theme *styles.Theme) error { | |||||
| 273 | fmt.Println() | 383 | fmt.Println() | ||
| 274 | 384 | ||||
| 275 | reader := bufio.NewReader(file) | 385 | reader := bufio.NewReader(file) | ||
| 386 | + | pending := "" | |||
| 276 | for { | 387 | for { | ||
| 277 | - | line, err := reader.ReadString('\n') | 388 | + | chunk, err := reader.ReadString('\n') |
| 278 | if err != nil { | 389 | if err != nil { | ||
| 279 | - | // No new data, wait a bit | 390 | + | if err == io.EOF { |
| 280 | - | time.Sleep(100 * time.Millisecond) | 391 | + | // No full line yet; keep partial data. |
| 281 | - | continue | 392 | + | pending += chunk |
| 393 | + | time.Sleep(100 * time.Millisecond) | |||
| 394 | + | continue | |||
| 395 | + | } | |||
| 396 | + | return fmt.Errorf("read log file: %w", err) | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | pending += chunk | |||
| 400 | + | for { | |||
| 401 | + | idx := strings.IndexByte(pending, '\n') | |||
| 402 | + | if idx == -1 { | |||
| 403 | + | break | |||
| 404 | + | } | |||
| 405 | + | line := pending[:idx] | |||
| 406 | + | pending = pending[idx+1:] | |||
| 407 | + | fmt.Println(colorizeLogLine(line, theme)) | |||
| 282 | } | 408 | } | ||
| 283 | - | fmt.Print(colorizeLogLine(line, theme)) | |||
| 284 | } | 409 | } | ||
| 285 | } | 410 | } | ||
| 286 | 411 | ||||
A internal/cli/cmd/logs_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type fakeSessionMgr struct { | |
| 17 | + | sessions []*entity.Session | |
| 18 | + | err error | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (f *fakeSessionMgr) GetRecentSessions(_ context.Context, _ int) ([]*entity.Session, error) { | |
| 22 | + | if f.err != nil { | |
| 23 | + | return nil, f.err | |
| 24 | + | } | |
| 25 | + | return f.sessions, nil | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestGetSessionsMerged_DBAndFS(t *testing.T) { | |
| 29 | + | logDir := t.TempDir() | |
| 30 | + | ||
| 31 | + | s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)} | |
| 32 | + | s2 := &entity.Session{ID: entity.SessionID("20251218_205106_bbbb"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 18, 20, 51, 6, 0, time.UTC)} | |
| 33 | + | ||
| 34 | + | require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename(string(s1.ID))), []byte("hi\n"), 0o600)) | |
| 35 | + | require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600)) | |
| 36 | + | ||
| 37 | + | mgr := &fakeSessionMgr{sessions: []*entity.Session{s1, s2}} | |
| 38 | + | ||
| 39 | + | merged, err := getSessionsMerged(context.Background(), mgr, logDir) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.Len(t, merged, 3) | |
| 42 | + | ||
| 43 | + | byID := map[string]SessionInfo{} | |
| 44 | + | for _, s := range merged { | |
| 45 | + | byID[s.SessionID] = s | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | require.True(t, byID[string(s1.ID)].FromDB) | |
| 49 | + | require.True(t, byID[string(s2.ID)].FromDB) | |
| 50 | + | require.False(t, byID["legacy_only"].FromDB) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestGetSessionsMerged_MissingTableFallsBack(t *testing.T) { | |
| 54 | + | logDir := t.TempDir() | |
| 55 | + | require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600)) | |
| 56 | + | ||
| 57 | + | mgr := &fakeSessionMgr{err: fmt.Errorf("no such table: sessions")} | |
| 58 | + | merged, err := getSessionsMerged(context.Background(), mgr, logDir) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.Len(t, merged, 1) | |
| 61 | + | require.Equal(t, "legacy_only", merged[0].SessionID) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestFindSession_DBShortIDMatch(t *testing.T) { | |
| 65 | + | logDir := t.TempDir() | |
| 66 | + | ||
| 67 | + | s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)} | |
| 68 | + | mgr := &fakeSessionMgr{sessions: []*entity.Session{s1}} | |
| 69 | + | ||
| 70 | + | info, err := findSession(context.Background(), mgr, logDir, "a7b3") | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | require.Equal(t, string(s1.ID), info.SessionID) | |
| 73 | + | require.Equal(t, "a7b3", info.ShortID) | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type fakeSessionMgr struct { | |||
| 17 | + | sessions []*entity.Session | |||
| 18 | + | err error | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (f *fakeSessionMgr) GetRecentSessions(_ context.Context, _ int) ([]*entity.Session, error) { | |||
| 22 | + | if f.err != nil { | |||
| 23 | + | return nil, f.err | |||
| 24 | + | } | |||
| 25 | + | return f.sessions, nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestGetSessionsMerged_DBAndFS(t *testing.T) { | |||
| 29 | + | logDir := t.TempDir() | |||
| 30 | + | ||||
| 31 | + | s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)} | |||
| 32 | + | s2 := &entity.Session{ID: entity.SessionID("20251218_205106_bbbb"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 18, 20, 51, 6, 0, time.UTC)} | |||
| 33 | + | ||||
| 34 | + | require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename(string(s1.ID))), []byte("hi\n"), 0o600)) | |||
| 35 | + | require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600)) | |||
| 36 | + | ||||
| 37 | + | mgr := &fakeSessionMgr{sessions: []*entity.Session{s1, s2}} | |||
| 38 | + | ||||
| 39 | + | merged, err := getSessionsMerged(context.Background(), mgr, logDir) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | require.Len(t, merged, 3) | |||
| 42 | + | ||||
| 43 | + | byID := map[string]SessionInfo{} | |||
| 44 | + | for _, s := range merged { | |||
| 45 | + | byID[s.SessionID] = s | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | require.True(t, byID[string(s1.ID)].FromDB) | |||
| 49 | + | require.True(t, byID[string(s2.ID)].FromDB) | |||
| 50 | + | require.False(t, byID["legacy_only"].FromDB) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestGetSessionsMerged_MissingTableFallsBack(t *testing.T) { | |||
| 54 | + | logDir := t.TempDir() | |||
| 55 | + | require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600)) | |||
| 56 | + | ||||
| 57 | + | mgr := &fakeSessionMgr{err: fmt.Errorf("no such table: sessions")} | |||
| 58 | + | merged, err := getSessionsMerged(context.Background(), mgr, logDir) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | require.Len(t, merged, 1) | |||
| 61 | + | require.Equal(t, "legacy_only", merged[0].SessionID) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestFindSession_DBShortIDMatch(t *testing.T) { | |||
| 65 | + | logDir := t.TempDir() | |||
| 66 | + | ||||
| 67 | + | s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)} | |||
| 68 | + | mgr := &fakeSessionMgr{sessions: []*entity.Session{s1}} | |||
| 69 | + | ||||
| 70 | + | info, err := findSession(context.Background(), mgr, logDir, "a7b3") | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | require.Equal(t, string(s1.ID), info.SessionID) | |||
| 73 | + | require.Equal(t, "a7b3", info.ShortID) | |||
| 74 | + | } | |||