diff --git a/.gitignore b/.gitignore index d7d0537..2840f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.dll *.so *.dylib +nssh # Test binary, built with `go test -c` *.test diff --git a/cmd/nssh/main.go b/cmd/nssh/main.go index 429cde6..ff10e31 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -782,6 +782,7 @@ func newLogCmd() *cobra.Command { cmd.AddCommand(log.NewUploadCmd()) cmd.AddCommand(log.NewExportCmd()) cmd.AddCommand(log.NewAuthCmd()) + cmd.AddCommand(log.NewSearchCmd()) ui.ApplyStyledHelpRecursive(cmd) return cmd diff --git a/docs/examples/help/log.txt b/docs/examples/help/log.txt index 725d404..d7ce1d3 100644 --- a/docs/examples/help/log.txt +++ b/docs/examples/help/log.txt @@ -8,6 +8,7 @@ $ nssh log --help │ export Export recording │ │ list List recordings │ │ play Play recording │ +│ search Search recordings for text │ │ upload Upload to asciinema │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Global Options ─────────────────────────────────────────────────────────────╮ diff --git a/docs/examples/help/log/delete.txt b/docs/examples/help/log/delete.txt index 67424c6..0b7500b 100644 --- a/docs/examples/help/log/delete.txt +++ b/docs/examples/help/log/delete.txt @@ -5,7 +5,7 @@ $ nssh log delete --help ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --dry-run Preview actions without executing │ │ --older-than INT Delete recordings older than N days │ -│ -s, --select STRING Filter by regex pattern │ +│ -s, --select STRING Filter by pattern (today, yesterday... │ │ -y, --yes Skip confirmation │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Global Options ─────────────────────────────────────────────────────────────╮ diff --git a/docs/examples/help/log/list.txt b/docs/examples/help/log/list.txt index e0cf574..d59ef0d 100644 --- a/docs/examples/help/log/list.txt +++ b/docs/examples/help/log/list.txt @@ -4,7 +4,7 @@ $ nssh log list --help ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ -l, --last INT Filter on the last N entries │ -│ -s, --select STRING Filter by regex pattern │ +│ -s, --select STRING Filter by pattern (today, yesterday... │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Global Options ─────────────────────────────────────────────────────────────╮ │ -e, --explain Print command explanation │ diff --git a/docs/examples/help/log/search.txt b/docs/examples/help/log/search.txt new file mode 100644 index 0000000..feff4f7 --- /dev/null +++ b/docs/examples/help/log/search.txt @@ -0,0 +1,16 @@ +$ nssh log search --help +╭─ Usage ──────────────────────────────────────────────────────────────────────╮ +│ nssh log search [flags] Search recordings for text │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ -i, --case-sensitive Case-sensitive search │ +│ -C, --context INT Show N lines of context around matches │ +│ -l, --last INT Search only the last N sessions │ +│ -s, --select STRING Filter sessions by pattern (today, ... │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Global Options ─────────────────────────────────────────────────────────────╮ +│ -e, --explain Print command explanation │ +│ -h, --help Print command help │ +│ -v, --verbose Print debug messages │ +│ -V, --version Print command version │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/cli/log/common.go b/internal/cli/log/common.go index 6ea2c23..697ff16 100644 --- a/internal/cli/log/common.go +++ b/internal/cli/log/common.go @@ -146,17 +146,20 @@ func PrintSessions(records []recording.SessionRecord, filter string) { } // FilterSessionsByPattern filters sessions by regex pattern. +// Matches against host, start date, mtime date, and cast path. func FilterSessionsByPattern(sessions []recording.SessionRecord, pattern string) ([]recording.SessionRecord, error) { re, err := regexp.Compile("(?i)" + pattern) if err != nil { return nil, fmt.Errorf("invalid regex pattern: %w", err) } + localTZ := time.Now().Location() var filtered []recording.SessionRecord for _, session := range sessions { + startDate := session.StartedAt.In(localTZ).Format("2006-01-02") + mtimeDate := sessionUpdatedTimestamp(session).In(localTZ).Format("2006-01-02") castDisplay := homeReplace(session.CastPath) - display := fmt.Sprintf("%s %s %s", session.Host, session.StartedAt.Format("2006-01-02"), castDisplay) - if re.MatchString(display) { + if MatchesPattern(re, session.Host, session.SessionLabel, startDate, mtimeDate, castDisplay) { filtered = append(filtered, session) } } @@ -433,3 +436,49 @@ func MatchesPattern(pattern *regexp.Regexp, fields ...string) bool { } return false } + +// ExpandDateShortcut expands convenient date shortcuts to regex patterns. +// Supported shortcuts: today, yesterday, this-week, this-month, last-week, last-month +// Returns the original pattern if not a recognized shortcut. +func ExpandDateShortcut(pattern string) string { + now := time.Now() + lower := strings.ToLower(strings.TrimSpace(pattern)) + + switch lower { + case "today": + return now.Format("2006-01-02") + + case "yesterday": + return now.AddDate(0, 0, -1).Format("2006-01-02") + + case "this-week": + // Get dates for this week (Sunday to today) + weekday := int(now.Weekday()) + dates := make([]string, 0, weekday+1) + for i := weekday; i >= 0; i-- { + d := now.AddDate(0, 0, -i) + dates = append(dates, d.Format("2006-01-02")) + } + return "(" + strings.Join(dates, "|") + ")" + + case "last-week": + // Previous full week (Sunday to Saturday) + weekday := int(now.Weekday()) + lastSunday := now.AddDate(0, 0, -weekday-7) + dates := make([]string, 7) + for i := 0; i < 7; i++ { + d := lastSunday.AddDate(0, 0, i) + dates[i] = d.Format("2006-01-02") + } + return "(" + strings.Join(dates, "|") + ")" + + case "this-month": + return now.Format("2006-01") + + case "last-month": + return now.AddDate(0, -1, 0).Format("2006-01") + + default: + return pattern + } +} diff --git a/internal/cli/log/delete.go b/internal/cli/log/delete.go index e2df98f..07409fc 100644 --- a/internal/cli/log/delete.go +++ b/internal/cli/log/delete.go @@ -35,7 +35,7 @@ Cannot combine modes - use only one mode flag at a time.`, }, } - cmd.Flags().StringVarP(&selectPattern, "select", "s", "", "Filter by regex pattern") + cmd.Flags().StringVarP(&selectPattern, "select", "s", "", "Filter by pattern (today, yesterday, this-week, this-month, or regex)") cmd.Flags().IntVar(&olderThan, "older-than", 0, "Delete recordings older than N days") cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview actions without executing") @@ -118,6 +118,7 @@ func deleteOlderThan(settings recording.RecordingSettings, days int, dryRun bool func deleteByPattern(settings recording.RecordingSettings, pattern string, yes, dryRun bool) error { sessions := LoadSessions(settings) + pattern = ExpandDateShortcut(pattern) filtered, err := FilterSessionsByPattern(sessions, pattern) if err != nil { ui.Error("Invalid pattern: %s", err) diff --git a/internal/cli/log/list.go b/internal/cli/log/list.go index a9a9970..80b3a84 100644 --- a/internal/cli/log/list.go +++ b/internal/cli/log/list.go @@ -25,7 +25,7 @@ func NewListCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&selectPattern, "select", "s", "", "Filter by regex pattern") + cmd.Flags().StringVarP(&selectPattern, "select", "s", "", "Filter by pattern (today, yesterday, this-week, this-month, or regex)") cmd.Flags().IntVarP(&lastN, "last", "l", 0, "Filter on the last N entries") return cmd @@ -47,6 +47,7 @@ func runList(selectPattern string, lastN int) error { } if selectPattern != "" { + selectPattern = ExpandDateShortcut(selectPattern) pattern, err := regexp.Compile("(?i)" + selectPattern) if err != nil { ui.Error("Invalid regex pattern: %s", err) @@ -56,8 +57,9 @@ func runList(selectPattern string, lastN int) error { var filtered []recording.SessionRecord for _, s := range sessions { - localDate := s.StartedAt.In(localTZ).Format("2006-01-02") - if MatchesPattern(pattern, s.Host, s.SessionLabel, localDate) { + startDate := s.StartedAt.In(localTZ).Format("2006-01-02") + mtimeDate := sessionUpdatedTimestamp(s).In(localTZ).Format("2006-01-02") + if MatchesPattern(pattern, s.Host, s.SessionLabel, startDate, mtimeDate) { filtered = append(filtered, s) } } diff --git a/internal/cli/log/search.go b/internal/cli/log/search.go new file mode 100644 index 0000000..e8b7837 --- /dev/null +++ b/internal/cli/log/search.go @@ -0,0 +1,308 @@ +package log + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/ntwrknrd/nssh/internal/exit" + "github.com/ntwrknrd/nssh/internal/ssh/recording" + "github.com/ntwrknrd/nssh/internal/ui" + "github.com/spf13/cobra" +) + +// NewSearchCmd creates the 'log search' command. +func NewSearchCmd() *cobra.Command { + var ( + selectPattern string + lastN int + caseSensitive bool + context int + ) + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search recordings for text", + Long: `Search through session recordings for a keyword or pattern. + +Searches the terminal output captured in .cast files. If .txt exports exist +alongside .cast files, those are searched instead (faster). + +Examples: + nssh log search "show interfaces" + nssh log search -s router1 "bgp neighbor" + nssh log search --last 10 "error" + nssh log search -i "WARNING"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runSearch(args[0], selectPattern, lastN, caseSensitive, context) + }, + } + + cmd.Flags().StringVarP(&selectPattern, "select", "s", "", "Filter sessions by pattern (today, yesterday, this-week, this-month, or regex)") + cmd.Flags().IntVarP(&lastN, "last", "l", 0, "Search only the last N sessions") + cmd.Flags().BoolVarP(&caseSensitive, "case-sensitive", "i", false, "Case-sensitive search") + cmd.Flags().IntVarP(&context, "context", "C", 0, "Show N lines of context around matches") + + return cmd +} + +// searchMatch represents a single match in a recording. +type searchMatch struct { + Session recording.SessionRecord + LineNumber int + Line string +} + +func runSearch(pattern, selectPattern string, lastN int, caseSensitive bool, contextLines int) error { + settings := recording.LoadRecordingSettings() + localTZ := time.Now().Location() + + ui.CommandStart("SEARCH RECORDINGS") + + var sessions []recording.SessionRecord + if lastN > 0 && selectPattern == "" { + sessions = LoadSessionsLimit(settings, lastN) + } else { + sessions = LoadSessions(settings) + } + + if selectPattern != "" { + selectPattern = ExpandDateShortcut(selectPattern) + re, err := regexp.Compile("(?i)" + selectPattern) + if err != nil { + ui.Error("Invalid regex pattern: %s", err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + var filtered []recording.SessionRecord + for _, s := range sessions { + startDate := s.StartedAt.In(localTZ).Format("2006-01-02") + mtimeDate := sessionUpdatedTimestamp(s).In(localTZ).Format("2006-01-02") + if MatchesPattern(re, s.Host, s.SessionLabel, startDate, mtimeDate) { + filtered = append(filtered, s) + } + } + sessions = filtered + + if lastN > 0 && lastN < len(sessions) { + sessions = sessions[:lastN] + } + } + + if len(sessions) == 0 { + ui.Warning("No sessions to search") + ui.CommandEnd(ui.StatusWarning) + return nil + } + + ui.Info("Searching %d session(s) for: %s", len(sessions), pattern) + + var searchRe *regexp.Regexp + var err error + if caseSensitive { + searchRe, err = regexp.Compile(pattern) + } else { + searchRe, err = regexp.Compile("(?i)" + pattern) + } + if err != nil { + ui.Error("Invalid search pattern: %s", err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + var allMatches []searchMatch + sessionsWithMatches := 0 + + for _, session := range sessions { + matches, err := searchSession(session, searchRe, contextLines) + if err != nil { + continue + } + if len(matches) > 0 { + allMatches = append(allMatches, matches...) + sessionsWithMatches++ + } + } + + if len(allMatches) == 0 { + ui.Warning("No matches found for: %s", pattern) + ui.CommandEnd(ui.StatusWarning) + return nil + } + + printSearchResults(allMatches, searchRe, localTZ) + + ui.Info("Found %d match(es) in %d session(s)", len(allMatches), sessionsWithMatches) + ui.CommandEnd(ui.StatusSuccess) + return nil +} + +func searchSession(session recording.SessionRecord, pattern *regexp.Regexp, contextLines int) ([]searchMatch, error) { + txtPath := strings.TrimSuffix(session.CastPath, ".cast") + ".txt" + if _, err := os.Stat(txtPath); err == nil { + return searchTextFile(session, txtPath, pattern, contextLines) + } + return searchCastFile(session, pattern, contextLines) +} + +func searchTextFile(session recording.SessionRecord, txtPath string, pattern *regexp.Regexp, contextLines int) ([]searchMatch, error) { + f, err := os.Open(txtPath) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + var matches []searchMatch + var lines []string + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + for i, line := range lines { + if pattern.MatchString(line) { + if contextLines > 0 { + start := i - contextLines + if start < 0 { + start = 0 + } + end := i + contextLines + 1 + if end > len(lines) { + end = len(lines) + } + contextStr := strings.Join(lines[start:end], "\n") + matches = append(matches, searchMatch{ + Session: session, + LineNumber: i + 1, + Line: contextStr, + }) + } else { + matches = append(matches, searchMatch{ + Session: session, + LineNumber: i + 1, + Line: line, + }) + } + } + } + + return matches, nil +} + +func searchCastFile(session recording.SessionRecord, pattern *regexp.Regexp, contextLines int) ([]searchMatch, error) { + f, err := os.Open(session.CastPath) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + if !scanner.Scan() { + return nil, nil + } + + var outputChunks []string + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var event []any + if err := json.Unmarshal(line, &event); err != nil { + continue + } + + if len(event) >= 3 { + if eventType, ok := event[1].(string); ok && eventType == "o" { + if text, ok := event[2].(string); ok { + outputChunks = append(outputChunks, text) + } + } + } + } + + fullOutput := strings.Join(outputChunks, "") + fullOutput = stripANSI(fullOutput) + lines := strings.Split(fullOutput, "\n") + + var matches []searchMatch + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if pattern.MatchString(line) { + if contextLines > 0 { + start := i - contextLines + if start < 0 { + start = 0 + } + end := i + contextLines + 1 + if end > len(lines) { + end = len(lines) + } + var contextParts []string + for j := start; j < end; j++ { + if l := strings.TrimSpace(lines[j]); l != "" { + contextParts = append(contextParts, l) + } + } + matches = append(matches, searchMatch{ + Session: session, + LineNumber: i + 1, + Line: strings.Join(contextParts, "\n"), + }) + } else { + matches = append(matches, searchMatch{ + Session: session, + LineNumber: i + 1, + Line: line, + }) + } + } + } + + return matches, nil +} + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\\|\x1b\[[\?]?[0-9;]*[a-zA-Z]`) + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +func printSearchResults(matches []searchMatch, pattern *regexp.Regexp, tz *time.Location) { + currentSession := "" + + for i := range matches { + m := &matches[i] + sessionKey := m.Session.CastPath + if sessionKey != currentSession { + currentSession = sessionKey + dateStr := m.Session.StartedAt.In(tz).Format("2006-01-02 15:04") + fmt.Println() + ui.Info("%s [%s] %s", m.Session.Host, dateStr, homeReplace(m.Session.CastPath)) + } + + lines := strings.Split(m.Line, "\n") + for _, line := range lines { + highlighted := pattern.ReplaceAllStringFunc(line, func(match string) string { + return fmt.Sprintf("\033[1;33m%s\033[0m", match) + }) + fmt.Printf(" %4d: %s\n", m.LineNumber, highlighted) + } + } + fmt.Println() +} diff --git a/internal/ssh/connector/recording_wrapper.go b/internal/ssh/connector/recording_wrapper.go index 3c7d2d6..e49acac 100644 --- a/internal/ssh/connector/recording_wrapper.go +++ b/internal/ssh/connector/recording_wrapper.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "os/exec" + "time" "github.com/ntwrknrd/nssh/internal/ssh/recording" "github.com/ntwrknrd/nssh/internal/ui" @@ -62,7 +63,25 @@ func MaybeWrapWithRecording(hostname string, args []string) (bool, error) { cmd.Stderr = os.Stderr cmd.Env = append(os.Environ(), "NSSH_RECORDING_INNER=1") + startedAt := time.Now() err = cmd.Run() + finishedAt := time.Now() + + // Determine exit code + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + + // Write index file with session metadata for fast duration lookups + if plan.CastPath != "" { + sessionLabel := recording.ExtractSessionLabel(plan.CastPath) + if indexErr := recording.WriteIndex(plan.CastPath, hostname, startedAt, finishedAt, exitCode, "", args, sessionLabel); indexErr != nil { + slog.Debug("failed to write recording index", "err", indexErr) + } + } // Export to text if enabled (do this before handling exit errors) if settings.AutoExportTxt && plan.CastPath != "" { @@ -72,10 +91,10 @@ func MaybeWrapWithRecording(hostname string, args []string) (bool, error) { } if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { + if exitCode != 0 { lock.Release() //nolint:gocritic // os.Exit is intentional here; caller expects wrapper to handle exit code. - os.Exit(exitErr.ExitCode()) + os.Exit(exitCode) } return true, err } diff --git a/internal/ssh/recording/recording.go b/internal/ssh/recording/recording.go index db8d9bf..952cba6 100644 --- a/internal/ssh/recording/recording.go +++ b/internal/ssh/recording/recording.go @@ -770,8 +770,10 @@ func readFromIndex(castPath string) *SessionRecord { } } -// readFromCastFile reads metadata from cast file using tail optimization. -// Reads header for start time, then seeks to end to find final timestamp. +// readFromCastFile reads metadata from cast file header. +// Duration is not available from v3 cast files without reading the entire file +// (v3 uses relative timestamps that must be summed). For accurate duration, +// use the .index.json sidecar file which is written when sessions end. func readFromCastFile(castPath string) (*SessionRecord, error) { f, err := os.Open(castPath) if err != nil { @@ -779,7 +781,7 @@ func readFromCastFile(castPath string) (*SessionRecord, error) { } defer func() { _ = f.Close() }() - // Read header (first line) + // Read header (first line only - O(1)) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 64*1024), 1024*1024) @@ -813,9 +815,12 @@ func readFromCastFile(castPath string) (*SessionRecord, error) { argv := strings.Fields(header.Command) - // Get final timestamp using tail-read optimization - totalTime := readFinalTimestamp(f) - finishedAt := startedAt.Add(time.Duration(totalTime * float64(time.Second))) + // For files without index, use file mtime as approximate finish time. + // This is accurate for completed sessions since the file is written during recording. + finishedAt := startedAt + if info, err := f.Stat(); err == nil { + finishedAt = info.ModTime() + } return &SessionRecord{ Host: host, @@ -827,62 +832,6 @@ func readFromCastFile(castPath string) (*SessionRecord, error) { }, nil } -// readFinalTimestamp seeks to the end of the file and reads backwards to find -// the last event timestamp. This is O(1) instead of O(file_size). -func readFinalTimestamp(f *os.File) float64 { - const tailSize = 64 * 1024 // Read last 64KB - - info, err := f.Stat() - if err != nil { - return 0 - } - - fileSize := info.Size() - readSize := tailSize - if fileSize < int64(tailSize) { - readSize = int(fileSize) - } - - // Seek to near end of file - offset := fileSize - int64(readSize) - if offset < 0 { - offset = 0 - } - - buf := make([]byte, readSize) - n, err := f.ReadAt(buf, offset) - if err != nil && n == 0 { - return 0 - } - buf = buf[:n] - - // Find the last complete line with a valid event - var lastTimestamp float64 - lines := strings.Split(string(buf), "\n") - - // Process lines in reverse to find the last valid event - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if len(line) == 0 || line[0] != '[' { - continue - } - - var event []interface{} - if err := json.Unmarshal([]byte(line), &event); err != nil { - continue - } - - if len(event) >= 1 { - if ts, ok := event[0].(float64); ok { - lastTimestamp = ts - break - } - } - } - - return lastTimestamp -} - // CastFileInfo holds a cast file path and its modification time for lazy loading. type CastFileInfo struct { Path string diff --git a/nssh b/nssh deleted file mode 100755 index 8aac722..0000000 Binary files a/nssh and /dev/null differ diff --git a/scripts/migrate-indexes.sh b/scripts/migrate-indexes.sh new file mode 100755 index 0000000..6bf5142 --- /dev/null +++ b/scripts/migrate-indexes.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# One-time migration script to generate .index.json files for existing cast files. +# This fixes duration display for files recorded before index writing was implemented. + +set -uo pipefail + +CASTS_DIR="${1:-$HOME/.local/state/nssh/casts}" + +if [[ ! -d "$CASTS_DIR" ]]; then + echo "Usage: $0 [casts_directory]" + echo "Default: ~/.local/state/nssh/casts" + exit 1 +fi + +echo "Migrating cast files in: $CASTS_DIR" +echo "" + +migrated=0 +skipped=0 +failed=0 + +while IFS= read -r -d '' cast_file; do + index_file="${cast_file%.cast}.index.json" + + # Skip if index already exists + if [[ -f "$index_file" ]]; then + ((skipped++)) + continue + fi + + # Extract metadata and calculate duration + result=$(python3 << PYTHON +import json +import os +import sys +from datetime import datetime + +cast_path = "$cast_file" + +try: + with open(cast_path, 'r') as f: + # Read header + header = json.loads(f.readline()) + + # Get start time from header + started_at = datetime.fromtimestamp(header.get('timestamp', 0), tz=None) + + # Extract hostname from title (format: "nssh:hostname") + title = header.get('title', '') + if title.startswith('nssh:'): + host = title[5:] + else: + # Fallback: extract from path + parts = cast_path.split(os.sep) + host = parts[-3] if len(parts) >= 3 else 'unknown' + + # Sum all relative timestamps (v3 format) + total_seconds = 0.0 + for line in f: + line = line.strip() + if not line or not line.startswith('['): + continue + try: + event = json.loads(line) + if len(event) >= 1 and isinstance(event[0], (int, float)): + total_seconds += event[0] + except: + continue + + # Calculate finished time + from datetime import timedelta + finished_at = started_at + timedelta(seconds=total_seconds) + + # Build index payload + argv = header.get('command', '').split() + + # Extract session label from filename + basename = os.path.basename(cast_path) + import re + match = re.search(r'session-(\d+)\.cast$', basename) + session_label = f"session-{match.group(1)}" if match else "" + + index = { + "host": host, + "cast": cast_path, + "sessions": [{ + "host": host, + "started_at": started_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + "finished_at": finished_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + "exit_code": 0, + "auth": "", + "argv": argv, + "session": session_label + }] + } + + print(json.dumps(index)) + +except Exception as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) +PYTHON +) + + if [[ $? -ne 0 ]]; then + echo "FAILED: $cast_file" + ((failed++)) + continue + fi + + # Write index file + echo "$result" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin), indent=2))" > "$index_file" + + # Show what we did + duration=$(echo "$result" | python3 -c " +import json,sys +from datetime import datetime +d = json.load(sys.stdin) +s = d['sessions'][0] +start = datetime.fromisoformat(s['started_at'].rstrip('Z')) +end = datetime.fromisoformat(s['finished_at'].rstrip('Z')) +delta = end - start +hours, rem = divmod(int(delta.total_seconds()), 3600) +mins, secs = divmod(rem, 60) +if hours: + print(f'{hours:02d}:{mins:02d}:{secs:02d}') +else: + print(f'{mins:02d}:{secs:02d}') +") + + echo "MIGRATED: $(basename "$(dirname "$(dirname "$cast_file")")")/$(basename "$(dirname "$cast_file")")/$(basename "$cast_file") → $duration" + ((migrated++)) + +done < <(find "$CASTS_DIR" -name "*.cast" -print0) + +echo "" +echo "Done: $migrated migrated, $skipped skipped (already have index), $failed failed"